Skip to content

Transparent DML on compressed partitions (INSERT / UPDATE / DELETE) - #49

Merged
tsg merged 26 commits into
mainfrom
feat/compressed-dml
Jul 23, 2026
Merged

Transparent DML on compressed partitions (INSERT / UPDATE / DELETE)#49
tsg merged 26 commits into
mainfrom
feat/compressed-dml

Conversation

@tsg

@tsg tsg commented Jul 6, 2026

Copy link
Copy Markdown
Member

Combines and finishes @SferaDev's two stacked PRs — #34 (transparent INSERT, DML P1) and #36 (UPDATE/DELETE + tombstone DELETE, DML P2/P2.5) — rebased onto current main, so compressed partitions accept plain INSERT / UPDATE / DELETE / MERGE with full transactional semantics.

Full design, cost profile, limitations, and remaining work are in dev/docs/COMPRESSED_DML.md.

On top of the two base PRs, this branch adds:

  • Catalog-flag DML gates (has_loose_rows / has_tombstones), replacing per-partition physical probes — keeps the analytics happy path at parity (fixes a fresh-backend point-query regression; verified on ClickBench + RTABench EC2).
  • Two correctness/perf fixes the new benchmark surfaced: time-range retention DELETE now whole-segment-drops instead of decomposing (~80× faster); json_extract tables no longer trip a false layout error on loose rows.
  • make bench-dml — a DML write-latency benchmark (vs a plain-PG twin) plus a wide type-coverage suite that guards DML round-trips against codec/format changes.

Tests: 607 unit (PG17 & PG18), 438 integration + 3 xfail (PG17), clippy clean.

SferaDev and others added 26 commits June 12, 2026 22:03
Two Unix-epoch vs PostgreSQL-epoch (offset 946,684,800 s) confusions in
src/scan/exec/segments.rs:

1. Bloom probe encoding (live bug): the per-segment bloom BUILD side
   (compress.rs) hashes TypedColumn values, which store timestamps/dates
   as Unix-epoch microseconds — but the probe side hashed the raw
   PG-epoch datum of the qual constant. The hashes never matched, every
   segment was falsely bloom-rejected, and `ts = const` / `ts IN (...)`
   on timestamp/date columns returned ZERO rows on compressed
   partitions. New bloom_probe_encode() converts the constant into the
   build-side domain (also normalizing the f32 bit-pattern case the Eq
   path previously special-cased and the InList path missed).

2. Meta-table min/max encoding (latent): load_segments_heap stored the
   time column's meta `_min_`/`_max_` identity-encoded (PG-epoch datum)
   in ColMinMax, while every consumer (decode_encoded_to_pg_i64,
   segment_all_rows_pass, constant_extract_key_for_segment) decodes the
   colstats encoding (Unix-epoch us) — timestamps 30 years early. On
   current main all reachable consumers either pass load_minmax=false
   (correct block below) or get the value overwritten by the colstats
   load, so no wrong results are reachable today — but the contract
   violation bites any new consumer of the map. Convert at load,
   matching the colstats encoding contract.

Regression tests: bloom_probe_encode_matches_build_domain unit test +
TestEpochEncoding integration class (timestamp sort column with
year-2000-adjacent constants: equality, IN-list, absent-value, DATE
payload equality, MIN/MAX aggregate shapes, boundary range predicates).
The equality/IN tests return zero rows without fix #1.

Extracted from the perf/clickhouse-gap-session branch (commit 7ea1b97
and the bloom-probe portion of fcfb1dc).
…epoch bug in in-list minmax filter

The colstats min/max in-list filter identity-encoded raw PG-epoch datums
for timestamp/date IN-lists on non-time columns while comparing against
Unix-epoch-microsecond colstats bounds - falsely pruning every segment
(zero rows). Route all constant encoding (eq, range, in-list, bloom
probe) through encode_datum_to_i64, and skip minmax pruning entirely
when an element is unencodable rather than risk a wrong prune.
Remove the INSERT-side read-only limitation on compressed partitions.
INSERTs (direct or parent-routed) land in the partition heap at native
heap speed — no decompression, no segment rewrite. Every read path
unions the compressed segments with the uncompressed heap tail:

- DeltaXDecompress/DeltaXAppend gain a Phase 3 heap-tail scan (leader-
  only in parallel plans), with the full plan qual re-enabled when a
  tail exists (heap rows are never batch-filtered).
- DeltaXCount/DeltaXMinMax fold loose heap rows into their metadata-
  derived results at exec time, so those pushdowns stay enabled.
- DeltaXAgg bails to plain Agg over heap-tail-aware scans when any
  involved compressed partition has loose rows (its columnar
  accumulators cannot ingest row-form tuples); a begin-time guard
  errors on the stale-cached-plan race instead of dropping rows.
- Top-N pushdown and pathkey claims are disabled at plan time when a
  tail exists; a [-4, flag] custom-private marker + relcache
  invalidation on the heap's empty->non-empty transition (sent by the
  leaf trigger's INSERT branch) catch stale cached plans.
- INSERT ... ON CONFLICT stays rejected (conflict inference cannot see
  rows inside segments). UPDATE/DELETE stay rejected unchanged.

deltax_compact_partition() folds loose rows into new segments appended
to the existing companion tables (colstats/blooms/text_lengths/
valbitmap kept exact or dropped, never under-covering; catalog
counters, column_minmax, HLL/ndistinct refreshed) and truncates the
loose region; the background worker compacts automatically each cycle.

Extracted from a longer perf session (DML P1, validated against a
plain-PostgreSQL twin table); UPDATE/DELETE (P2) and tombstones (P2.5)
follow in a separate PR. dev/docs/COMPRESSED_DML.md carries the full
program design.
Independent correctness/cleanup pass over the P1 transparent-INSERT work:

- Close the data-modifying-CTE bypass of the INSERT ... ON CONFLICT
  rejection: a wCTE hides the ModifyTable in PlannedStmt.subplans under a
  top-level CMD_SELECT, so the ExecutorStart hook never saw it. Conflict
  inference against empty leaf indexes would silently miss conflicts with
  segment rows (duplicate inserts under ON CONFLICT DO NOTHING). New
  integration test covers it.

- Guard the leader-only Phase 3 heap tail against
  parallel_leader_participation=off: Gather never drives the leader's plan
  copy when workers launch, so the tail would be silently dropped. The
  begin-time guard errors with a remedy instead.

- Re-validate the partition catalog row under the AccessExclusive lock in
  deltax_compact_partition(): a concurrent decompress committing between
  the unlocked is_compressed check and the lock grant would have made
  compaction treat the fully-restored heap as loose rows and truncate
  real data.

- Self-heal dead loose-row pages in the loose_rows == 0 compaction branch:
  a REPEATABLE READ compaction deletes (not truncates) and autovacuum is
  off on compressed partitions, so the heap would otherwise stay at
  nonzero blocks forever — pinning scans on the heap-tail path and
  re-taking the AEL every worker cycle.

- Reject json_extract tables in compaction with a clear remedy (synthetic
  columns have no heap presence — the cursor would fail with a raw SQL
  error) and skip them in worker auto-compaction to avoid an error loop.

- Disambiguate partition_oid_for_companion() by is_compressed: two
  deltatables with the same table name in different schemas both register
  same-named partitions in the catalog; the reverse lookup could return
  the wrong schema's heap and union foreign rows into the scan.

- Use the executor snapshot (es_snapshot) instead of GetActiveSnapshot()
  for the DeltaXCount/DeltaXMinMax heap-tail fold, matching the
  DeltaXDecompress/Append Phase 3 scan.

- Tighten the Phase 3 deform guard to also check the scan-slot tupdesc:
  for DeltaXAppend the slot is the parent's layout, and a dropped column
  on either side would silently misalign the positional deform.
The pre-existing correctness test asserted the P0 contract (parent-routed
INSERT into a compressed partition raises). DML P1 intentionally accepts
plain INSERTs — rows land in the partition heap and are unioned with
segment data at scan time. Update the test to insert into both the plain
and deltax tables, verify the loose row is visible through the deltax
read path (count + total parity), and keep the join-equality check.
UPDATE/DELETE rejection coverage is unchanged.
P2 decompose-on-write: the ExecutorStart interceptor walks every
ModifyTable node (UPDATE/DELETE/MERGE, incl. data-modifying CTEs),
locates candidate segments via the read path's pruning pipeline
(dml_candidate_segments), and decomposes them back into ordinary heap
rows (decompose_segments_for_dml) inside the user's transaction before
the planned DML runs — plain WAL-logged heap DML, so MVCC/rollback/
crash recovery need no extension-side machinery. Whole-segment DELETE
drops segments without restoring rows (guards: no RETURNING, no user
row DELETE triggers, all quals batch-provable AllPass), with an
ExecutorRun hook folding dropped rows into es_processed.

P2.5 tombstones-as-rows (DELETE-only fast layer): qualifying DELETEs
append (segment_id, row_offset) tombstone rows to a per-partition
_tombstones companion instead of decomposing — near-native DELETE
latency. Scans AND tombstones into the selection vector on every read
path (incl. parallel workers, which load the map under the shared
snapshot); DeltaXCount counts live rows; DeltaXMinMax/DeltaXAgg carry
stale-plan guards that error when tombstones appeared after planning.
Compaction folds tombstoned segments in; the catalog max_segment_id
high-water mark is raised before any meta delete so segment ids are
never reused (shared caches are keyed by (companion_oid, segment_id)).

UPDATE stays on decompose-on-write (correct, transactional, slower).

Extracted from a longer perf session; stacks on DML P1
(feat/compressed-insert).
…ETEs

Review fixes for the P2/P2.5 fast paths (whole-segment drop + tombstone),
which must be disabled whenever something observes the deleted rows:

- RETURNING is now checked per ModifyTable (returningLists), not via
  planned_stmt.hasReturning, which only reflects the top-level query: a
  data-modifying CTE's DELETE ... RETURNING previously took the fast path
  and returned no rows to the outer query.
- AFTER DELETE triggers with OLD TABLE transition relations (REFERENCING
  OLD TABLE) now disable the fast paths too — checked on the leaf and on
  the statement's named target, since transition capture on a partitioned
  parent collects rows from every leaf.
- es_processed credit is only applied when the ModifyTable is the
  statement's top plan node: CTE-deleted rows never count toward the outer
  command tag in PostgreSQL.
- PENDING_DML_EXTRA_ROWS is a Vec now so nested statements (trigger or
  function-body DML on another compressed partition) can't drop the outer
  statement's pending credit.

Also: reuse ddl_if_not_exists in copy.rs, doc update, and three new
integration tests covering CTE RETURNING, CTE tag integrity, and
transition-table observation.
…stack

Partition bloom sentinels (#35) and dual-mode segment files (#32) are
separate PRs; the new decompose comments referenced them as if present.
Rephrase as forward-looking interaction notes (matching the existing
'PERF #47, not yet on this branch' convention) or drop the reference.
…s succeeds

The correctness suite still asserted the P0/P1 contract (UPDATE/DELETE
on compressed partitions raises), which this PR obsoletes. Rewrite both
tests to assert the new transparent-DML contract, mirroring the
accepts_parent_routed_insert pattern: run the same statement against the
plain twin and the deltax table, commit, and require parity through the
deltax read path.

- test_partition_edge_compressed_partition_rejects_dml ->
  test_partition_edge_compressed_partition_applies_dml: direct
  partition-targeted UPDATE/DELETE on a compressed partition; asserts
  rowcount parity, post-UPDATE value visibility, post-DELETE row absence
  with the partition still compressed (tombstone-aware read path), total
  count parity, and full ordered result-set parity.
- test_rtabench_compressed_partition_rejects_parent_routed_update_delete ->
  test_rtabench_compressed_partition_accepts_parent_routed_update_delete:
  parent-routed UPDATE/DELETE across all four COPY layouts; asserts
  rowcount parity, per-order row parity (update) / absence on both sides
  with the touched partition still compressed (delete), total count
  parity, and join-case equality.

INSERT ... ON CONFLICT rejection coverage is unchanged.
The new correctness DML tests exposed two jsonb bugs:

- restore_segment_rows (shared by decompress_partition, decompose-on-write
  UPDATE/DELETE, and compaction) decoded jsonb blobs through the UTF-8
  text codec path. jsonb blobs hold binary jsonb varlena payloads, so any
  restore of a jsonb-bearing segment panicked with 'invalid UTF-8 in LZ4
  data'. Decode them byte-safe (decompress_column_byte_values) and convert
  back to JSON text via jsonb_out (jsonb_binary_to_text, the inverse of
  jsonb_text_to_binary) for the INSERT literal.

- The SPI SELECT builders in compress_partition_impl and the compaction
  path did not cast jsonb columns to ::text, but the accumulate path reads
  jsonb as String (canonical JSON text) — pgrx rejects the oid mismatch.
  Include ColumnKind::Jsonb in the ::text cast.

Adds a pg_test covering compress -> UPDATE (decompose) -> compact ->
decompress with jsonb payloads incl. NULLs.
Combines PR #34 (feat/compressed-insert) and PR #36
(feat/compressed-update-delete) — already stacked, so this branch is #36's
head — and merges origin/main (epoch fixes were on the stack's base; main
gained flatten-partitions, the worker launcher refactor, and the Q19/Q25
perf work from #47).

Conflict resolutions beyond the textual ones:

- flatten_eligible_companions: refuse to flatten a parent when any
  compressed child holds loose heap rows (P1) or tombstones (P2.5).
  Flattening removes children from simple_rte_array, which is where every
  DML-aware plan-time gate looks; a flattened plan would sail past the
  gates and trip the exec-time stale-plan errors instead of falling back.
- deltax_planner: never flatten under DML_BYPASS — set_rel_pathlist
  early-returns under the bypass, so a flattened (dummy) rel would be left
  path-less and scan empty during internal maintenance.
- DeltaXCount fallback: merged main's partial-window segment folding with
  P2.5 tombstone subtraction (live_row_count for fully-contained segments,
  time-mask ∧ not-tombstoned for straddling ones).
- Q19 point-scan bloom pruning (new on main) probed with the raw PG-epoch
  datum for timestamp/date constants — the exact epoch bug class #33
  fixed. Routed it through bloom_probe_encode.
- maintain_one_table (main's worker refactor): re-added the P1/P2.5
  auto_compact_partitions step between auto-compress and retention.
run.sh filtered psql output through grep 'Time|psql: error', which drops
server-side ERROR lines while \timing still prints a duration — a failing
query gets recorded as a fast success. This produced a bogus result set
when a json_extract metadata mismatch errored every order_events query
(all three runs recorded the time-to-error, some 100x 'faster').

Port the ON_ERROR_STOP=on + QUERY_ERROR marker guard from
clickbench/run.sh, and make parse-results.sh map QUERY_ERROR to null
explicitly (it previously only degraded to null via flush padding).
The heap-loose-rows and tombstone gates (relation_heap_is_empty +
companion_may_have_tombstones) run at several plan-time sites × every
compressed partition on every planning: heap-tail Top-N gating,
collect_compressed_children, flatten eligibility, the DeltaXAgg/MinMax
disables. Each probe is a table_open + nblocks lseek (+ a syscache name
lookup for the tombstone sibling); measured +3-4ms/query on ClickBench
(18 partitions) and +5-35ms on RTABench point queries (127 partitions).

Cache both answers per backend, keyed by partition/companion OID, in the
existing hook-cache family: cleared wholesale by
invalidate_compressed_cache on any relcache invalidation. Correctness
rides the invalidation contract the writers already provide for cached
plans — deltax_note_compressed_insert fires on the empty→non-empty heap
transition, the tombstone writer / decompose / compaction invalidate the
partition explicitly, and compaction's TRUNCATE swaps relfilenodes.

Scope guards:
- Only COMPRESSED partitions are cached; plain INSERTs into uncompressed
  children fire no invalidation, so those emptiness checks (DeltaXAppend
  would silently drop their rows) keep the physical probe.
- Exec-time consumers (begin_deltax_append heap-tail collection,
  attach_tombstones, count/minmax folds, companion_has_live_tombstones
  stale-plan guards) stay uncached as the last line of defense.
The per-backend probe cache (6de2acd) only amortized within a single
planning; a fresh backend still paid ~40ms on the first wide point query
(RTABench Q7 46ms cold vs 3.2ms warm) because both the plan-time gates AND
begin_deltax_append / the count-minmax heap fold / attach_tombstones / the
DeltaXAgg+MinMax stale guards physically opened every one of ~127 partition
heaps + _tombstones tables — relations a segment-only scan never otherwise
touches — forcing cold relcache + smgr builds.

Replace the physical probes with two catalog flags on deltax_partition:
  has_loose_rows, has_tombstones
maintained transactionally by the writers:
  - INSERT trigger (deltax_note_compressed_insert): idempotent UPDATE +
    per-txn memo (xact/subxact callbacks clear it so a rolled-back first
    insert re-runs); skipped under DML_BYPASS.
  - decompose_segments_for_dml: sets has_loose_rows when rows restored.
  - insert_dml_tombstones: sets has_tombstones.
  - compaction / mark_partition_compressed: clear both.
Every plan/exec gate now reads a single bulk-loaded per-backend flag map
(hook::DML_FLAGS, one SPI joining deltax_partition->pg_class), invalidated
by the same relcache-callback path as COMPRESSED_NAMESET. Opens nothing
beyond the deltax_partition catalog.

Correctness: flags are MVCC-atomic with the rows (a snapshot that sees the
rows sees the flag) and refreshed at command start via the writer's
relcache invalidation — the same contract COMPRESSED_NAMESET already
relies on. The outer statement snapshot precedes the flag-map SPI snapshot,
so a stale-false flag cannot coexist with a post-state read; a stale-true
flag is merely conservative (slow path, still correct).

Behavior change: analytic readers no longer block behind a decompose
UPDATE's AccessExclusive partition lock (the gates no longer AccessShare
the partition heap) — they read a consistent MVCC snapshot instead
(standard PG readers-don't-block-writers). Verified non-torn;
test_concurrent_reader_sees_consistent_data updated to assert the
pre-or-post invariant rather than the old lock-blocking mechanism. New
TestDmlGateFlags covers the flag lifecycle + rollback.
The standalone DML_FLAGS SPI (470eadc) still left the tombstone gate and
the exec-time heap-tail collectors calling partition_oid_for_companion — a
branch-only SPI cached *per companion*, so a fresh backend running a wide
point query (RTABench Q7/Q9-Q13, 127 partitions, no time filter) paid ~127
of them ON TOP of the flag SPI. Net: point queries stayed at 17-44ms vs
main's ~10ms (+37% geomean).

Build the flag map from ONE double-join SPI that resolves both the
partition heap OID (user schema) and the _meta companion OID
(_deltax_compressed) alongside the flags, and key the map by BOTH oids
(same flag tuple). Every gate is then a single hash lookup:
  - plan-time loose/tombstone gates key by partition oid,
  - exec-time heap-tail collectors + DeltaXAgg/MinMax guards +
    attach_tombstones key by companion oid,
  - partition_oid_for_companion is resolved ONLY when a partition actually
    has loose rows (rare) — off the steady-state path entirely.

RTABench (clean main baseline, same data, fresh-backend-per-try):
  point queries Q7/Q9-Q13: 17-44ms -> 11-12ms (main ~10ms)
  hot geomean: main 0.1095, probes 0.1501 (+37%), this 0.1134 (+3.6%)
  hot sum: main 11.52s, this 11.56s (+0.3%)
Residual +1ms on the smallest queries is the second per-backend SPI
(COMPRESSED_NAMESET + DML_FLAGS); folding them into one load is a possible
follow-up but is <=1ms on 11ms fresh-backend timings.

607 unit (PG17+18), 426 integration green.
RTABench and ClickBench are read-only; nothing tracked the write side that
DML-on-compressed adds. bench_dml.py measures it, self-contained (synthetic
300k-row time-series, no external data), against a plain-PostgreSQL twin so
every op is an absolute warm latency AND a ratio to native — the ratio is
the product requirement.

Ops (each measured warm, rollback-isolated so runs don't accumulate):
single/batch/COPY INSERT (loose region), point DELETE (tombstone), retention
DELETE (whole-segment drop), DELETE...RETURNING + point/range UPDATE
(decompose-on-write). Plus the read-after-write cliff: a GROUP BY aggregate
pristine (DeltaXAgg pushdown) vs after one INSERT (pushdown disabled) vs
after compaction — the 'does DML slow the happy path' number.

Results + timestamped history archive via save_bench_results; a pinned
dml_baseline.json drives a soft regression gate (DML_BLESS=1 to record).

Targets: make bench-dml / bench-dml-keep / bench-dml-bless.

Initial run flagged two issues to fix next (json_extract+heap-tail layout
guard; retention DELETE not taking the whole-segment-drop fast path), so the
baseline is intentionally left unblessed until those land.
Bug 1 — json_extract table + loose rows tripped a false layout-mismatch.
The heap-tail layout guards compared the partition heap's physical column
count against the companion col_names count, which includes json_extract
synthetic columns (attnum 0, no pg_attribute row). So any INSERT into a
json_extract-configured compressed table followed by a read errored with a
cryptic 'layout does not match the compressed layout'. The synthetic column
is absent from the physical heap and from a scan that doesn't select it, so
the correct test is heap-vs-scan-OUTPUT layout, not heap-vs-total-columns:
- decompress.rs heap-tail (begin + emit): compare rel_natts to the scan
  slot's natts; a query that DOES select a json_extract column widens the
  slot and is caught with a clear, actionable message.
- count_minmax.rs heap-tail: expected_natts = MetadataInfo::physical_col_count()
  (attnums != 0), not col_names.len().
Queries not referencing the extracted path now work on json_extract tables
with loose rows; ones that do get a clear 'run compact/decompress' error.

Bug 2 — time-range retention DELETE decomposed/tombstoned every row instead
of whole-segment-dropping (~80x slower: 156ms vs 1.9ms on the 300k-row
bench). classify_segment_quals proved the min/max coverage (AllPass on the
Lt) but its NULL-safety check read the time column's colstats
_nonnull_count — which compression writes as a ZERO placeholder (the time
column's authoritative stats live in _meta) — so it saw 0 non-null rows and
returned Ambiguous, defeating the whole-drop. The time column is non-null by
construction in a compressed (non-default) partition (NULL partition keys
route to the default), so classify_segment_quals now takes the time_column
name and skips its NULL check. Fixes already-compressed data (read-side, no
recompress). Retention DELETE now whole-drops: bench 156ms -> 1.9ms (0.1x of
plain PG).

Tests: TestCompressedInsertJsonExtract (insert+read works, synthetic-col
read errors clearly, compaction refused clearly); time-range whole-drop
regression asserting has_loose_rows stays false (whole-drop, not decompose).
607 unit (PG17), 430 integration green; clippy clean.
- Describe what's implemented (INSERT loose region + heap tail, catalog-flag
  gates, tombstone/whole-drop/decompose DELETE+UPDATE, compaction) rather
  than the original phased proposal with status prefixes and deviations.
- Remove all references to other products; state the decompose-vs-bitmap
  rationale from first principles (metadata-exactness).
- Document the catalog-flag gate mechanism, the readers-don't-block change,
  the cost profile (from bench-dml), limitations (ON CONFLICT, uniqueness,
  json_extract), and a concrete 'things to improve' list.
The existing DML tests use a narrow (ts, text, int, float8) schema, so they
exercise only a few codecs. This adds a wide-schema table — one column per
codec-relevant type (int2/4/8, float4/8, hi/lo-card text, varchar, char(n),
bool, date, timestamptz, jsonb, a constant column, and nullable columns with
NULLs) — loaded across 4 segments with a byte-identical plain-PG twin, and
asserts full-row equality after INSERT (loose), UPDATE (decompose, single +
multi segment), DELETE (tombstone), compaction, and full decompress. This is
the durable guard that a codec or on-disk-format change can't silently break
a DML round-trip.

Also surfaces (and tracks via xfail) a pre-existing, non-DML compression bug
the matrix found: numeric/uuid/bytea columns — types with no specialized
codec — corrupt on compressed read (stored/returned as their text-output
form). Latent because ClickBench/RTABench never use these types. The main
matrix excludes them; test_unspecialized_type_roundtrips_through_compression
documents the corruption and flips to XPASS when the codec/fallback is fixed.

438 passed + 3 xfailed (PG17).
Closes the logical-replication gap for DML-on-compressed. Decompose-on-write
re-inserts a whole segment's rows into the partition heap before the user's
UPDATE/DELETE runs; under Scenario-1 logical replication (subscriber holds the
user table, (de)compresses independently) those internal inserts would land on
the subscriber as duplicate rows.

Fix: tag the WAL of internal maintenance writes (decompose restored-row
inserts + companion deletes; compaction segment folds + loose-row removal)
with a dedicated 'deltax_internal' replication origin, set per-record via the
backend-local replorigin_session_origin (InternalOriginGuard in compress.rs —
set directly, not replorigin_session_setup, so concurrent internal writers
don't contend on the origin's progress slot). PostgreSQL filters by origin
per-WAL-record at decode time, so a subscription created WITH (origin = none)
drops exactly this internal churn while the user's own origin-less DML (run
after decompose returns, origin reset) replicates normally. Scenario 2
(replicate raw companion+heap storage) uses the default origin = any and
receives the tagged changes unchanged — tagging is safe for both topologies.

The origin is created in the extension SQL (superuser, any wal_level; name has
no reserved 'pg_' prefix); the Rust paths look it up by name via
replorigin_by_name and no-op if absent.

Test: test_logical_replication.py::
test_decompose_dml_on_compressed_replicates_without_duplication — Scenario 1 +
origin=none: 200 rows, compress, UPDATE (decomposes a 40-row segment), assert
subscriber stays 200 (no duplication) with the 40 rows updated; compaction is
likewise invisible. Scenario 2 still passes.

Remaining (documented in COMPRESSED_DML.md §7/§8): tombstone / whole-segment
-drop fast-path DELETEs touch only companion tables, so they don't emit a
replicable event for a Scenario-1 subscriber — needs a force-decompose switch.

607 unit (PG17), 439 integration + 3 xfail; clippy clean.
… under Scenario 1

Closes the remaining logical-replication gap. The tombstone and
whole-segment-drop DELETE fast paths touch only companion tables, so under
Scenario-1 replication (publish the user table, companions excluded,
origin=none) a fast-path delete produces no replicated event and the
subscriber keeps the row. New GUC pg_deltax.replicable_deletes (bool, USERSET,
default off): when on, DELETEs on compressed partitions skip both fast paths
and decompose-on-write, so the delete runs as an ordinary heap DELETE that
replicates like any row delete (the decompose re-inserts are origin-tagged and
filtered by origin=none). Off by default — the fast paths are far faster;
Scenario 1 publishers turn it on (typically ALTER DATABASE ... SET). Scenario 2
leaves it off and lets tombstone rows replicate through the companion tables.

Implemented by gating allow_whole_drop on !replicable_deletes in the
ExecutorStart interceptor (tombstone_eligible derives from it, so both fast
paths turn off together and all candidates fall to decompose).

Test: test_logical_replication.py::
test_replicable_deletes_guc_propagates_delete_on_compressed — Scenario 1 +
origin=none + GUC on: DELETE 40 rows on a compressed partition, subscriber
goes 200 -> 160 with device gone.

Also harden test_worker.py::_cleanup to retry its teardown DROP on
DeadlockDetected: these tests run against the postgres DB where the real
background worker is active, so the cleanup DROP races the worker's concurrent
maintenance of the same table (a normal, retryable DDL-vs-maintenance
deadlock). Timing-sensitive; surfaced when the new GUC shifted the worker
cycle. Product behavior is correct (PG aborts one side); the test now retries
rather than fail on teardown.

COMPRESSED_DML.md §7/§8 updated. 607 unit (PG17), 440 integration + 3 xfail;
clippy clean.
The correctness harness compared the curated query battery only against a
freshly-compressed (pristine) table. But DML changes the physical state a read
path sees — loose rows in the heap tail, tombstones subtracted on the fly,
decomposed segments, post-compaction segments — which is exactly where a
scan/agg/Top-N path's DML-awareness could silently diverge from PostgreSQL.

New suite mutates BOTH twins identically (so plain PG stays the oracle), then
re-runs curated_smoke_cases against each state: pristine, loose_inserts,
tombstone_deletes, decompose_updates, mixed (loose + tombstones + decomposed
at once), and compacted. Each state asserts the physical shape it claims to
exercise actually exists (has_loose_rows / has_tombstones), so the coverage
can't silently collapse into a plain heap. Reuses the existing dataset builder
and query generators — no new query definitions.

Marked . Full correctness suite: 1041 passed, 3 skipped, 6 xfailed.
The 3 xfail cases I'd added (numeric/uuid/bytea compressed round-trip) only
did compress->read, with no DML — so they duplicated the pre-existing,
strict=True tracking in tests/correctness/test_codecs_extended.py (numeric /
time / uuid / bytea fallback corruption, from commit e1aa83f on main), and
were weaker (strict=False). That corruption is a base-codec bug independent of
DML. Replace the duplicate xfails with a comment: the type matrix excludes
those four types, points at the canonical tracking, and says to add them back
once the codec fallback is fixed.
# Conflicts:
#	src/compress.rs
#	tests/test_worker.py
@tsg
tsg merged commit 3e4d603 into main Jul 23, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants