Document what a decoding slot actually does with a columnar table - #190
Conversation
The published limitation said changes are not emitted through logical decoding and to use physical replication. True, and it leaves out the part a user finds out the hard way. A slot is not silent about a columnar table. The metadata catalog is ordinary heap, so a slot subscribed to everything delivers inserts and updates to pgcolumnar.storage, row_group, column_chunk, zone_map and delete_vector as the table is written, including encoded chunk descriptors as bytea, and not one of the table's own rows. Verified against a real slot on PG18 with test_decoding: zero changes named the columnar table, and the stream carried the internal bookkeeping instead. That is worse than silence, because it looks like the consumer is working. So the entry now says to filter the pgcolumnar schema out of a publication, and points at a recipe for capturing the changes themselves. The recipe is a row trigger writing to a heap table, which works because #182 made after-row triggers fire on columnar tables. Every statement in it was run before it was written down, and the sample output is copied from that run rather than composed. Four properties are documented because each is a thing a reader would otherwise have to discover: an UPDATE arrives as one UPDATE even though a columnar update is internally a delete and an insert; the capture is transactional, so a rolled back statement leaves nothing; it costs a heap row per changed row, which is worth thinking about before a bulk load; and nothing prunes the capture table. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012uKWWwBDt5TWWS5DR2tzDb
ChronicallyJD
left a comment
There was a problem hiding this comment.
Approving. I reproduced it rather than reading it, and the measurement is if anything stronger than the text.
A columnar table taking two inserts, one update and one delete, against a test_decoding slot subscribed to everything:
| changes delivered | |
|---|---|
| naming the columnar table | 0 |
naming pgcolumnar internal catalogs |
33 |
| naming an equivalent heap table doing the same work | 2 |
Broken down: zone_map 16, column_chunk 8, row_group 4, delete_vector 3, storage 2. I also checked that no user value reaches the stream — the only text payloads present belong to the heap control table.
"A slot is not silent" undersells it. The ratio is the thing: a consumer gets sixteen times more rows about our internals than a heap table generates about real data, and none of it is theirs. Worth putting the count in the doc, because "you will see some catalog traffic" and "you will see 33 rows of zone maps for four user rows" lead to different decisions about whether to subscribe to everything.
One detail worth a line, since a reader will hit it and wonder: the delete_vector and zone_map deletes arrive as DELETE: (no-tuple-data), so a consumer cannot even tell what was removed without a replica identity on our catalogs. That is correct behaviour and not something to fix — but it means "filter these out" is the only sane advice, which is what the doc says.
The trigger recipe is the right thing to document and I am glad it is here rather than only in the plan. It is also the honest answer for anyone who wants rows on a subscriber rather than messages.
@ChronicallyJD — documentation only, no code. The recipe here works because of your #182, and I ran every statement in it before writing it down.
What the limitation left out
It said changes are not emitted through logical decoding and to use physical replication. That is true, and it omits the part a user finds out the hard way: a slot is not silent about a columnar table.
The metadata catalog is ordinary heap, so a slot subscribed to everything delivers this while the table is written:
and zero changes naming the columnar table. Measured, not inferred:
changes naming the columnar table (expect 0): 0, against a real slot on PG18 withtest_decoding.That is worse than silence. A consumer subscribed to all tables sees a busy, plausible-looking stream and receives none of the data. So the entry now says to filter the
pgcolumnarschema out of any publication, which nothing told anyone before.The recipe
A row trigger writing to a heap table, decoded normally. Verified end to end: an insert, an update and a delete on a columnar table produced exactly three captured changes in the slot, with the right values.
Four properties are written down because each is something a reader would otherwise have to find out:
UPDATEarrives as oneUPDATE. A columnar update is internally a delete plus an insert, and the slot shows that in the metadata (a newrow_groupand adelete_vectorwrite in the same transaction), but the trigger fires once per row event so the capture table records what the statement did rather than how the storage did it. That is the thing I most expected to read badly, and it does not.What I would like checked
The claim I am least able to falsify on my own is the completeness of the four properties: whether there is a fifth that bites someone in production.
TRUNCATEis the one I can think of and did not test, since a statement-level truncate fires no row triggers, so a truncated columnar table would leave the capture table describing rows that no longer exist. If you agree that is real, it belongs in the list and I would rather add it than have a reader discover it.Related: #187 is the design proposal for doing this properly in the engine, and it points at this recipe as the answer available today.