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 204+ 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; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 204+ messages in thread
* [PATCH v2 4/4] Extra tests @ 2025-09-04 16:28 Nikolay Shaplov <[email protected]> 0 siblings, 0 replies; 204+ messages in thread From: Nikolay Shaplov @ 2025-09-04 16:28 UTC (permalink / raw) Add more tests for ternary reloptions in dummy_index_am module --- src/test/modules/dummy_index_am/README | 1 + .../modules/dummy_index_am/dummy_index_am.c | 36 +++++++++++++----- .../dummy_index_am/expected/reloptions.out | 38 +++++++++++++++++-- .../modules/dummy_index_am/sql/reloptions.sql | 16 ++++++++ src/test/regress/expected/reloptions.out | 4 +- src/test/regress/sql/reloptions.sql | 4 +- 6 files changed, 81 insertions(+), 18 deletions(-) diff --git a/src/test/modules/dummy_index_am/README b/src/test/modules/dummy_index_am/README index 61510f02fa..d80aff0db1 100644 --- a/src/test/modules/dummy_index_am/README +++ b/src/test/modules/dummy_index_am/README @@ -6,6 +6,7 @@ access method, whose code is kept a maximum simple. This includes tests for all relation option types: - boolean +- ternary - enum - integer - real diff --git a/src/test/modules/dummy_index_am/dummy_index_am.c b/src/test/modules/dummy_index_am/dummy_index_am.c index 94ef639b6f..bf8446ddc6 100644 --- a/src/test/modules/dummy_index_am/dummy_index_am.c +++ b/src/test/modules/dummy_index_am/dummy_index_am.c @@ -22,7 +22,7 @@ PG_MODULE_MAGIC; /* parse table for fillRelOptions */ -static relopt_parse_elt di_relopt_tab[6]; +static relopt_parse_elt di_relopt_tab[8]; /* Kind of relation options for dummy index */ static relopt_kind di_relopt_kind; @@ -40,6 +40,8 @@ typedef struct DummyIndexOptions int option_int; double option_real; bool option_bool; + ternary option_ternary1; + ternary option_ternary2; DummyAmEnum option_enum; int option_string_val_offset; int option_string_null_offset; @@ -96,23 +98,37 @@ create_reloptions_table(void) di_relopt_tab[2].opttype = RELOPT_TYPE_BOOL; di_relopt_tab[2].offset = offsetof(DummyIndexOptions, option_bool); + add_ternary_reloption(di_relopt_kind, "option_ternary1", + "First ternary option for dummy_index_am", + TERNARY_UNSET, NULL, AccessExclusiveLock); + di_relopt_tab[3].optname = "option_ternary1"; + di_relopt_tab[3].opttype = RELOPT_TYPE_TERNARY; + di_relopt_tab[3].offset = offsetof(DummyIndexOptions, option_ternary1); + + add_ternary_reloption(di_relopt_kind, "option_ternary2", + "Second ternary option for dummy_index_am", + TERNARY_TRUE, "do_not_know_yet", AccessExclusiveLock); + di_relopt_tab[4].optname = "option_ternary2"; + di_relopt_tab[4].opttype = RELOPT_TYPE_TERNARY; + di_relopt_tab[4].offset = offsetof(DummyIndexOptions, option_ternary2); + add_enum_reloption(di_relopt_kind, "option_enum", "Enum option for dummy_index_am", dummyAmEnumValues, DUMMY_AM_ENUM_ONE, "Valid values are \"one\" and \"two\".", AccessExclusiveLock); - di_relopt_tab[3].optname = "option_enum"; - di_relopt_tab[3].opttype = RELOPT_TYPE_ENUM; - di_relopt_tab[3].offset = offsetof(DummyIndexOptions, option_enum); + di_relopt_tab[5].optname = "option_enum"; + di_relopt_tab[5].opttype = RELOPT_TYPE_ENUM; + di_relopt_tab[5].offset = offsetof(DummyIndexOptions, option_enum); add_string_reloption(di_relopt_kind, "option_string_val", "String option for dummy_index_am with non-NULL default", "DefaultValue", &validate_string_option, AccessExclusiveLock); - di_relopt_tab[4].optname = "option_string_val"; - di_relopt_tab[4].opttype = RELOPT_TYPE_STRING; - di_relopt_tab[4].offset = offsetof(DummyIndexOptions, + di_relopt_tab[6].optname = "option_string_val"; + di_relopt_tab[6].opttype = RELOPT_TYPE_STRING; + di_relopt_tab[6].offset = offsetof(DummyIndexOptions, option_string_val_offset); /* @@ -123,9 +139,9 @@ create_reloptions_table(void) NULL, /* description */ NULL, &validate_string_option, AccessExclusiveLock); - di_relopt_tab[5].optname = "option_string_null"; - di_relopt_tab[5].opttype = RELOPT_TYPE_STRING; - di_relopt_tab[5].offset = offsetof(DummyIndexOptions, + di_relopt_tab[7].optname = "option_string_null"; + di_relopt_tab[7].opttype = RELOPT_TYPE_STRING; + di_relopt_tab[7].offset = offsetof(DummyIndexOptions, option_string_null_offset); } diff --git a/src/test/modules/dummy_index_am/expected/reloptions.out b/src/test/modules/dummy_index_am/expected/reloptions.out index c873a80bb7..ad1b2ea639 100644 --- a/src/test/modules/dummy_index_am/expected/reloptions.out +++ b/src/test/modules/dummy_index_am/expected/reloptions.out @@ -18,6 +18,8 @@ SET client_min_messages TO 'notice'; CREATE INDEX dummy_test_idx ON dummy_test_tab USING dummy_index_am (i) WITH ( option_bool = false, + option_ternary1, + option_ternary2 = off, option_int = 5, option_real = 3.1, option_enum = 'two', @@ -31,16 +33,20 @@ SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_idx'; unnest ------------------------ option_bool=false + option_ternary1=true + option_ternary2=off option_int=5 option_real=3.1 option_enum=two option_string_val=null option_string_null=val -(6 rows) +(8 rows) -- ALTER INDEX .. SET ALTER INDEX dummy_test_idx SET (option_int = 10); ALTER INDEX dummy_test_idx SET (option_bool = true); +ALTER INDEX dummy_test_idx SET (option_ternary1 = false); +ALTER INDEX dummy_test_idx SET (option_ternary2 = Do_Not_Know_YET); ALTER INDEX dummy_test_idx SET (option_real = 3.2); ALTER INDEX dummy_test_idx SET (option_string_val = 'val2'); ALTER INDEX dummy_test_idx SET (option_string_null = NULL); @@ -49,19 +55,23 @@ ALTER INDEX dummy_test_idx SET (option_enum = 'three'); ERROR: invalid value for enum option "option_enum": three DETAIL: Valid values are "one" and "two". SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_idx'; - unnest -------------------------- + unnest +--------------------------------- option_int=10 option_bool=true + option_ternary1=false + option_ternary2=do_not_know_yet option_real=3.2 option_string_val=val2 option_string_null=null option_enum=one -(6 rows) +(8 rows) -- ALTER INDEX .. RESET ALTER INDEX dummy_test_idx RESET (option_int); ALTER INDEX dummy_test_idx RESET (option_bool); +ALTER INDEX dummy_test_idx RESET (option_ternary1); +ALTER INDEX dummy_test_idx RESET (option_ternary2); ALTER INDEX dummy_test_idx RESET (option_real); ALTER INDEX dummy_test_idx RESET (option_enum); ALTER INDEX dummy_test_idx RESET (option_string_val); @@ -100,6 +110,26 @@ SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_idx'; (1 row) ALTER INDEX dummy_test_idx RESET (option_bool); +-- Ternary +ALTER INDEX dummy_test_idx SET (option_ternary1 = 4); -- error +ERROR: invalid value for ternary option "option_ternary1": 4 +ALTER INDEX dummy_test_idx SET (option_ternary1 = 1); -- ok, as true +ALTER INDEX dummy_test_idx SET (option_ternary1 = 3.4); -- error +ERROR: invalid value for ternary option "option_ternary1": 3.4 +ALTER INDEX dummy_test_idx SET (option_ternary1 = 'val4'); -- error +ERROR: invalid value for ternary option "option_ternary1": val4 +ALTER INDEX dummy_test_idx SET (option_ternary1 = 'do_not_know_yet'); -- error. Valid for ternary2 not for ternary1 +ERROR: invalid value for ternary option "option_ternary1": do_not_know_yet +ALTER INDEX dummy_test_idx SET (option_ternary2 = 'do_not_know_yet'); -- ok +SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_idx'; + unnest +--------------------------------- + option_ternary1=1 + option_ternary2=do_not_know_yet +(2 rows) + +ALTER INDEX dummy_test_idx RESET (option_ternary1); +ALTER INDEX dummy_test_idx RESET (option_ternary2); -- Float ALTER INDEX dummy_test_idx SET (option_real = 4); -- ok ALTER INDEX dummy_test_idx SET (option_real = true); -- error diff --git a/src/test/modules/dummy_index_am/sql/reloptions.sql b/src/test/modules/dummy_index_am/sql/reloptions.sql index 6749d763e6..123540905a 100644 --- a/src/test/modules/dummy_index_am/sql/reloptions.sql +++ b/src/test/modules/dummy_index_am/sql/reloptions.sql @@ -18,6 +18,8 @@ SET client_min_messages TO 'notice'; CREATE INDEX dummy_test_idx ON dummy_test_tab USING dummy_index_am (i) WITH ( option_bool = false, + option_ternary1, + option_ternary2 = off, option_int = 5, option_real = 3.1, option_enum = 'two', @@ -30,6 +32,8 @@ SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_idx'; -- ALTER INDEX .. SET ALTER INDEX dummy_test_idx SET (option_int = 10); ALTER INDEX dummy_test_idx SET (option_bool = true); +ALTER INDEX dummy_test_idx SET (option_ternary1 = false); +ALTER INDEX dummy_test_idx SET (option_ternary2 = Do_Not_Know_YET); ALTER INDEX dummy_test_idx SET (option_real = 3.2); ALTER INDEX dummy_test_idx SET (option_string_val = 'val2'); ALTER INDEX dummy_test_idx SET (option_string_null = NULL); @@ -40,6 +44,8 @@ SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_idx'; -- ALTER INDEX .. RESET ALTER INDEX dummy_test_idx RESET (option_int); ALTER INDEX dummy_test_idx RESET (option_bool); +ALTER INDEX dummy_test_idx RESET (option_ternary1); +ALTER INDEX dummy_test_idx RESET (option_ternary2); ALTER INDEX dummy_test_idx RESET (option_real); ALTER INDEX dummy_test_idx RESET (option_enum); ALTER INDEX dummy_test_idx RESET (option_string_val); @@ -60,6 +66,16 @@ ALTER INDEX dummy_test_idx SET (option_bool = 3.4); -- error ALTER INDEX dummy_test_idx SET (option_bool = 'val4'); -- error SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_idx'; ALTER INDEX dummy_test_idx RESET (option_bool); +-- Ternary +ALTER INDEX dummy_test_idx SET (option_ternary1 = 4); -- error +ALTER INDEX dummy_test_idx SET (option_ternary1 = 1); -- ok, as true +ALTER INDEX dummy_test_idx SET (option_ternary1 = 3.4); -- error +ALTER INDEX dummy_test_idx SET (option_ternary1 = 'val4'); -- error +ALTER INDEX dummy_test_idx SET (option_ternary1 = 'do_not_know_yet'); -- error. Valid for ternary2 not for ternary1 +ALTER INDEX dummy_test_idx SET (option_ternary2 = 'do_not_know_yet'); -- ok +SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_idx'; +ALTER INDEX dummy_test_idx RESET (option_ternary1); +ALTER INDEX dummy_test_idx RESET (option_ternary2); -- Float ALTER INDEX dummy_test_idx SET (option_real = 4); -- ok ALTER INDEX dummy_test_idx SET (option_real = true); -- error diff --git a/src/test/regress/expected/reloptions.out b/src/test/regress/expected/reloptions.out index 1c99f79ab0..6e65cd5c3d 100644 --- a/src/test/regress/expected/reloptions.out +++ b/src/test/regress/expected/reloptions.out @@ -98,7 +98,7 @@ SELECT reloptions FROM pg_class WHERE oid = 'reloptions_test'::regclass; {fillfactor=13,autovacuum_enabled=false} (1 row) --- Tests for future (FIXME) ternary options +-- Tests for ternary options -- behave as boolean option: accept unassigned name and truncated value DROP TABLE reloptions_test; CREATE TABLE reloptions_test(i INT) WITH (vacuum_truncate); @@ -116,7 +116,7 @@ SELECT reloptions FROM pg_class WHERE oid = 'reloptions_test'::regclass; {vacuum_truncate=fals} (1 row) --- preferred "true" alias is used when storing +-- preferred "true" alias is stored in pg_class DROP TABLE reloptions_test; CREATE TABLE reloptions_test(i INT) WITH (vacuum_index_cleanup=on); SELECT reloptions FROM pg_class WHERE oid = 'reloptions_test'::regclass; diff --git a/src/test/regress/sql/reloptions.sql b/src/test/regress/sql/reloptions.sql index f5980dafcb..c99673db9e 100644 --- a/src/test/regress/sql/reloptions.sql +++ b/src/test/regress/sql/reloptions.sql @@ -59,7 +59,7 @@ UPDATE pg_class ALTER TABLE reloptions_test RESET (illegal_option); SELECT reloptions FROM pg_class WHERE oid = 'reloptions_test'::regclass; --- Tests for future (FIXME) ternary options +-- Tests for ternary options -- behave as boolean option: accept unassigned name and truncated value DROP TABLE reloptions_test; @@ -70,7 +70,7 @@ DROP TABLE reloptions_test; CREATE TABLE reloptions_test(i INT) WITH (vacuum_truncate=FaLS); SELECT reloptions FROM pg_class WHERE oid = 'reloptions_test'::regclass; --- preferred "true" alias is used when storing +-- preferred "true" alias is stored in pg_class DROP TABLE reloptions_test; CREATE TABLE reloptions_test(i INT) WITH (vacuum_index_cleanup=on); SELECT reloptions FROM pg_class WHERE oid = 'reloptions_test'::regclass; -- 2.39.2 --nextPart5129932.5fSG56mABF-- --nextPart7913091.MhkbZ0Pkbq Content-Type: application/pgp-signature; name="signature.asc" Content-Description: This is a digitally signed message part. Content-Transfer-Encoding: 7Bit -----BEGIN PGP SIGNATURE----- iQEzBAABCgAdFiEE+sk3ebqQKlezKOi8PMbfuIHAGpgFAmi5xzQACgkQPMbfuIHA GpgVIAf/XPjp6PMbvLKQNz7lPTc3R8PRBXQG4MIj5C5xa3fM5s5uIq/aMYD6CL2g PycNUWsA/HumkH6h64TYT2JL3oeo2cXO9BU66Dm/wDJqz95eR6KANzfS2UC0qpyf Qab+JdontqQITiXrvHX4IB9RZRndkJa+OKWHxvzY2oToizXkbPwEDFcQR3+kUPAu BEl6f6BmJzd9tJAJz5wXINV3TTRe++dmtHkNesmq8c8P4R62PhBkjjGynczCjnke z319jJS71lEjNsKVvCkzkjpVNTGO7w33rIJQGscW+PUNGhUVB4qDqxfHPGSZS1Sb fU0Sv+UciXMopbvtezVtK30GBezX8Q== =RxGQ -----END PGP SIGNATURE----- --nextPart7913091.MhkbZ0Pkbq-- ^ permalink raw reply [nested|flat] 204+ messages in thread
end of thread, other threads:[~2025-09-04 16:28 UTC | newest] Thread overview: 204+ 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-09-04 16:28 [PATCH v2 4/4] Extra tests Nikolay Shaplov <[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