Retention internals¶
The column¶
When retention is set, the utility hook appends a ColumnDef for _$purge_ts (timestamptz) to the CREATE TABLE statement before PostgreSQL processes it.
Injection is conditional on the column not already being present. That is the normal case on restore: pg_dump emits _$purge_ts as an ordinary column alongside WITH (retention = ...), so injecting blindly would produce a duplicate.
The column is deliberately nullable. A NOT NULL constraint is checked by ExecConstraints before table_tuple_insert runs, so it would reject rows before the access method could populate the value. Enforcement treats a null deadline as not yet eligible, so a null cannot cause early deletion.
Populating it¶
On every insert path:
Two details are load-bearing:
clock_timestamp(), notnow(). It gives each row its true arrival even inside a long transaction, and since it is always at or afternow()within a transaction, it also yields the later — more conservative — deadline.- Interval addition, not seconds.
timestamptz + interval '90 days'is calendar-correct across a daylight-saving boundary;90 * 86400is not, and a retention period means calendar days.
The slot's cached values cannot be edited in place, because for some slot types they are only a view onto an underlying tuple. A fresh tuple is built and stored back, which is the supported way to change a row in transit.
Preserving it across an update¶
tuple_update fetches the old row and carries its deadline into the new version.
Without that, an UPDATE could set _$purge_ts into the past and then delete the row, defeating retention entirely on any table granting both update and delete. The old value always wins: an update can change the row, never how long it must be kept.
Checking it on delete¶
tuple_delete receives the row's address but not its contents, so the row is fetched to read its deadline. The comparison uses now() — transaction start — so every row in one purge run is judged against a single consistent instant rather than drifting as the scan proceeds.
A row whose deadline has not passed raises. At every isolation level.
Why it raises rather than skipping¶
Silently passing over an ineligible row is not available. tuple_delete returns TM_Result, and the executor, not the access method, decides what each value means:
| Return | Result |
|---|---|
TM_Deleted |
Clean skip at READ COMMITTED; serialization failure under a transaction-level snapshot |
TM_Ok without deleting |
Silent everywhere, but the command tag counts rows it did not remove, AFTER DELETE triggers fire for surviving rows, and RETURNING hands back rows that still exist |
| Anything else | Rejected outright by the executor |
There is no "skip this row" member. So the guarantee is enforced loudly instead, and the purge procedure carries an explicit WHERE _$purge_ts < now() so nothing in normal operation reaches the error.
Restore mode¶
pg_vault_tables.restore_mode is a superuser-only boolean, off by default.
While off, a supplied deadline is discarded and recomputed. While on, it is preserved.
The switch has to be explicit because the two cases cannot be told apart by inspecting the value: a legitimately restored old row and a deliberately backdated one look identical.