Skip to content

Document what a decoding slot actually does with a columnar table - #190

Merged
jdatcmd merged 1 commit into
mainfrom
docs/logical-decoding-cdc
Jul 28, 2026
Merged

Document what a decoding slot actually does with a columnar table#190
jdatcmd merged 1 commit into
mainfrom
docs/logical-decoding-cdc

Conversation

@jdatcmd

@jdatcmd jdatcmd commented Jul 28, 2026

Copy link
Copy Markdown
Owner

@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:

table pgcolumnar.storage:       INSERT: storage_id[bigint]:10000000000 relation_oid[oid]:16513 ...
table pgcolumnar.row_group:     INSERT: storage_id[bigint]:10000000000 group_number[bigint]:1 row_count[bigint]:2 ...
table pgcolumnar.column_chunk:  INSERT: ... encoding_descriptor[bytea]:'\x0200010000000202000000100000000b00000000000000' ...
table pgcolumnar.zone_map:      INSERT: ... minimum[bytea]:'\x0100000000000000' maximum[bytea]:'\x0200000000000000' ...
table pgcolumnar.delete_vector: UPDATE: ... bitmap[bytea]:'\x03' deleted_count[integer]:2

and zero changes naming the columnar table. Measured, not inferred: changes naming the columnar table (expect 0): 0, against a real slot on PG18 with test_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 pgcolumnar schema 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:

  • An UPDATE arrives as one UPDATE. A columnar update is internally a delete plus an insert, and the slot shows that in the metadata (a new row_group and a delete_vector write 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.
  • The capture is transactional, so a rolled back statement leaves nothing captured.
  • It costs a heap row per changed row. A ten million row load writes ten million heap rows too, which is the case to think about before switching it on.
  • Nothing prunes the capture table, and the pruning deletes are themselves decoded unless the consumer filters them.

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. TRUNCATE is 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.

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 ChronicallyJD left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

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