FOR PORTION OF and updatedCols: A Bug Exposing a Conceptual Overload
The Core Problem
PostgreSQL 18's FOR PORTION OF feature (temporal UPDATE/DELETE, committed by Paul Jungwirth) narrows a range-typed column to a sub-interval, inserting "leftover" rows for the portions outside the targeted sub-range. Satya Narlapuram reported that when a GENERATED ALWAYS AS ... STORED column references the range column, the stored value becomes stale on the updated row:
UPDATE t FOR PORTION OF valid_at FROM 30 TO 70 SET val = 99;
-- row [30,70) still has range_len = 99 (inherited from original [1,100)),
-- not the correct 40.
The leftover rows [1,30) and [70,100) come out correct because they are synthesized via CMD_INSERT, and insert paths unconditionally recompute all generated columns. The directly updated row goes through the UPDATE path, which only recomputes generated columns whose dependencies intersect perminfo->updatedCols. Virtual generated columns are unaffected because they are re-evaluated at read time from the stored range value.
Why the range column is absent from updatedCols
transformForPortionOfClause() deliberately omits the range column from RTEPermissionInfo.updatedCols. The rationale is a user-facing permissions decision: a user with UPDATE privilege on val but not on valid_at is still allowed to execute UPDATE ... FOR PORTION OF valid_at, because the user is not semantically updating valid_at — the system is narrowing it as a side effect of the temporal operation.
The bug is that updatedCols is used for more than permissions checking. Over time it has accumulated roles:
- Permissions enforcement (
ExecCheckPermissions).
- Choosing which
GENERATED STORED columns to recompute (ExecInitStoredGenerated / ExecComputeStoredGenerated).
- Firing
UPDATE OF column triggers.
- HOT / index-update optimizations via
ExecGetExtraUpdatedCols → index_unchanged_by_update.
Because the range column is physically being rewritten on disk, every consumer in (2)–(4) needs to know about it; only (1) wants it hidden. The fix therefore sits at the boundary of a conceptual overload that predates FOR PORTION OF.
Evolution of the Fix
v1 (Satya): add in ExecInitGenerated
Satya's initial patch added the range attno to the local updatedCols only inside ExecInitGenerated. This fixes generated columns but leaves triggers and index_unchanged_by_update wrong. It is also scoped too narrowly to address the broader inheritance / trigger issues Jian was tracking in parallel threads.
v2 (Jian): centralize in ExecGetUpdatedCols
Jian Universality argued for putting the adjustment in ExecGetUpdatedCols itself, caching the result on estate->es_query_cxt (mirroring how ExecGetExtraUpdatedCols caches ri_extraUpdatedCols). Reasons:
- Several callers use
ExecGetUpdatedCols without ExecGetExtraUpdatedCols, so the fix needs to live in the former.
- Permission checking (
ExecCheckPermissions in InitPlan) runs before ExecGetUpdatedCols is first invoked, so the permission bypass for the range column is preserved.
Jian also raised an architectural concern: ri_forPortionOf is lazily initialized deep in ExecForPortionOfLeftovers (inside ExecUpdateEpilogue). That is "too late" for other code paths that want to consult fp_rangeAttno earlier. He proposed initializing ForPortionOfState right after the ExecLookupResultRelByOid call, and noted redundancy between ForPortionOfExpr (plan node) and ForPortionOfState (exec state).
Additionally, Jian flagged fp_leftoverstypcache as apparently unused (dead code that could move to ExecInitModifyTable), and pointed out a stale comment claiming "other types may have more" leftovers when only anyrange/anymultirange are supported.
v3–v5 (Paul): partitioning correctness
Paul Jungwirth — the FOR PORTION OF author and thus the domain authority here — accepted Jian's centralization approach but caught a partitioning bug: ri_forPortionOf->fp_rangeAttno on a leaf partition has already been mapped from parent to child attnos. Adding the unmapped parent attno to updatedCols and then running the tuple-conversion mapping again would double-map.
The v5 fix adds the range attno to updatedCols after the parent→child mapping is applied, and therefore explicitly avoids mutating perminfo->updatedCols (which holds parent attnos and must not carry child-specific entries). Paul added a partitioned-table regression test with a GENERATED STORED column that reproduces the mis-mapping.
Paul expressed discomfort with mutating perminfo at all — it optimizes away repeated bitmapset allocations for multi-row updates but is surprising as a side effect on a cached catalog-derived structure.
v5→v7: segfault scare and polish
Jian reported a segfault in v5; after a clean rebuild it disappeared (stale build artifacts). The remaining review was cosmetic: remove an accidental #include "nodes/print.h", refactor a regression test to compute range_len by hand for easier diffing, and rebase past d3bba04154.
Secondary Issues Raised and Their Resolutions
Satya also reported two related scenarios:
-
BEFORE INSERT trigger returning NULL kills leftovers. A DELETE FOR PORTION OF against a table with a BEFORE INSERT trigger that returns NULL silently loses both leftover rows — the deletion succeeds but no leftover is inserted, violating the user's evident intent.
-
Same issue on UPDATE: the leftovers vanish, and only the narrowed row survives.
Both Jian and Paul classified this as expected behavior, citing the documented warning that FOR PORTION OF fires INSERT triggers on leftovers and a trigger returning NULL means "do nothing". The ExecInsert path returns NULL on ExecBRInsertTriggers failure, which is the standard trigger contract. Changing this would require special-casing leftover inserts, which the thread declined to do.
Deeper Design Question Raised by Paul
Paul raised the key architectural question explicitly: should updatedCols be split into two bitmapsets — one for permissions, one for "physically modified columns"? Today the patch is essentially a workaround: updatedCols keeps its permission semantics, and each consumer that cares about physical modification is patched to add the range attno. A cleaner refactor would invert the defaults: make updatedCols mean "physically modified", and move the permission carve-out into ExecCheckPermissions.
This refactor was not undertaken — the bug needs a backpatchable fix first — but it is the right long-term direction and was left as an open question.
Implications
- Correctness on a committed feature.
FOR PORTION OF shipped with this latent bug; any user combining temporal UPDATE with stored generated columns, UPDATE OF col triggers, or HOT-sensitive indexes on the range column would see wrong results or missed optimizations.
index_unchanged_by_update impact. Without the fix, B-tree bottom-up deletion heuristics would incorrectly treat the range column as unchanged on a FOR PORTION OF update, potentially triggering suboptimal deduplication behavior on indexes that include the range column.
- Partition-aware bitmapset handling. The partitioning half of the fix is a reminder that any code touching tuple-routed
updatedCols must be mindful of whether attnos are in parent or child coordinate space.