PG19: reject FOR PORTION OF modifications planned by Citus
Problem
PostgreSQL 19 adds temporal modifications:
UPDATE table_name FOR PORTION OF valid_at FROM ... TO ...;
DELETE FROM table_name FOR PORTION OF valid_at FROM ... TO ...;When Citus plans/deparses these statements, it silently drops the FOR PORTION OF clause. PostgreSQL's rewritten overlap predicate and range intersection remain, but the executor-only leftover-row insertion phase is lost.
Verified impact
Starting from one row valid over [2020-01-01,2030-01-01) and modifying/deleting [2023-01-01,2025-01-01):
- Correct PostgreSQL UPDATE produces left, changed-middle, and right fragments.
- Citus remote UPDATE keeps only the middle fragment.
- Correct PostgreSQL DELETE preserves left and right fragments.
- Citus remote DELETE removes the entire overlapping row.
- RETURNING and command tags remain plausible, making the loss difficult to detect.
- Remote execution also omits the INSERT trigger cycles PostgreSQL fires for leftover rows.
Reproduced across router, multi-shard, single-shard, reference, Citus-local, partitioned, modifying-CTE, and ordinary-local-target/distributed-source paths. Local execution can appear correct accidentally; forcing remote execution exposes deterministic loss.
If the temporal range is the distribution column, leftover fragments can belong to different shards, so simply restoring clause deparsing would still be unsafe.
Root cause
PG19 Query.forPortionOf is carried to the PostgreSQL executor, which locks the original tuple and inserts untouched left/right ranges. Citus's PG19 ruleutils copy does not emit the temporal clause and sends an ordinary rewritten UPDATE/DELETE to workers. The worker executor therefore never runs temporal leftover handling.
Severity
High PG19 support release blocker: valid SQL causes deterministic silent data loss/corruption without an error.
Proposed minimum fix
Explicitly reject every PG19 temporal modification that enters Citus modification planning:
- Add a PG19-gated check for
queryTree->forPortionOf != NULLinModifyPartialQuerySupported()before routing/target-list validation. - Return
ERRCODE_FEATURE_NOT_SUPPORTEDwith a stable message. - Do not create router-only, reference, Citus-local, local-execution, or partitioned exceptions.
- Pure PostgreSQL local-table statements that never enter Citus planning remain supported.
Acceptance criteria
- Ordinary PostgreSQL local temporal UPDATE/DELETE remains correct.
- Distributed temporal UPDATE and DELETE reject consistently with local execution on/off.
- Modifying CTEs reject consistently.
- Ordinary local targets that read distributed sources reject consistently.
- Temporal DELETE with the period as distribution column rejects before any row loss.
- PG17/18 builds and behavior remain unchanged through version-gated field access.
- PG19 regression coverage verifies the rejection boundaries and unchanged local behavior.
Non-goals
Real distributed support requires preserving the original temporal clause, shard-correct leftover routing, reference replication, partition tuple routing, EvalPlanQual/locking, trigger semantics, and RETURNING behavior. It is out of scope for this rejection fix.
Scope clarifications
- PostgreSQL 19 grammar permits
FOR PORTION OFon UPDATE and DELETE only. MERGE and its reduced action syntax do not accept the clause, so noCreateMergePlan()guard is needed. - The one PG19-gated check in
ModifyPartialQuerySupported()covers both supported temporal target spellings:FROM start_time TO end_time(portion)
- PostgreSQL inserts zero to two range leftovers, or at most one multirange leftover. Leftovers preserve original values in non-period columns, including the distribution column; the verified Citus defect is silent loss of leftover rows after deparsing, not demonstrated cross-shard misrouting.
- PostgreSQL does not require INSERT privilege for these executor-generated leftovers. Any future real support must preserve that privilege behavior; the rejection fix does not emulate leftovers.
Accepted local implementation
Local commit 9453db38c implements the approved minimum rejection in ModifyPartialQuerySupported() with mandatory PG19 gating and focused PG19 regression coverage for both temporal target forms and all validated Citus planning boundaries.
Validation:
- PG17/18/19
-Werrorbuilds succeeded. - PG19 and PG18 focused base/planner runs passed 17/17.
- PG17 relevant
pg19,multi_modifications, andcitus_local_tables_queriestests passed. make check-style, final diff checks, and independent release review passed.
No MERGE guard is needed because PostgreSQL 19 grammar does not admit FOR PORTION OF in MERGE.
Implementation PR: #8763.
Source: citusdata/citus