ADR-0009: Decoders for the pglogical and wal2json output plugins (PostgreSQL 9.6 to 14)
Copy Markdown View SourceStatus: Accepted 2026-09-29 (ships in 1.4.0) Date: 2026-09-29 Deciders: replicant maintainer (roadmap row A7)
Context
Replicant reads exactly one logical-decoding output plugin. The slot grammar emits
CREATE_REPLICATION_SLOT ... LOGICAL pgoutput (lib/replicant/query_builder.ex, the
@pgoutput constant), Replicant.Decoder.decode/2 parses pgoutput frames only, and the
connect chain discovers tables through pg_publication and pg_publication_tables.
pgoutput and publications exist from PostgreSQL 10; the library is tested on 15, 16, 17
and 18 (README.md, "PostgreSQL version support").
A large installed base runs older majors. PostgreSQL 9.6 has wal_level = logical but no
pgoutput and no publications; 10 through 14 have both but are outside the tested range,
and many of those servers, including managed services, already carry the pglogical
extension (whose output plugin is pglogical_output) or wal2json, installed for an
earlier consumer. Today such a server cannot be read by Replicant at all: a 9.6 server
fails at slot creation, and a 10 to 14 server is unproven. The pieces that make Replicant
worth using (sink-owned effect-once by commit_lsn, the value-free boundary, fail-closed
halts, snapshot and checkpoint modes) do not depend on the plugin's wire format; only the
first parsing step does.
Decision
Introduce a decoder behavior behind the existing pgoutput decoder and add two implementations, selected by configuration. The sink contract, checkpoint semantics and halt semantics do not change.
- Behavior. A
Replicant.Decoder.Pluginbehavior is introduced and pgoutput becomes its first implementation. The behavior covers the four places the plugin's format leaks into the pipeline: the plugin name and options forCREATE_REPLICATION_SLOTandSTART_REPLICATION;decode/2from one XLogData payload (plus the frame'swal_startandwal_endLSNs, passed inopts) to the existingReplicant.Decoder.Messagesstructs; the table-set discovery queries the connect chain runs; and a capability set the config validator checks.Replicant.Decoder.decode/2keeps its signature and delegates to the configured plugin, so the value-free boundary (ADR-0003) stays in one place: every raise, throw or exit inside any plugin decoder still scrubs to%Replicant.Error{}. - Configuration. A new top-level option
decoder:with:pgoutput(the default, byte-identical behavior),:pglogicalor:wal2json. The table set is named per decoder:publication:for pgoutput (unchanged);replication_sets:for pglogical (the names pglogical uses in place of publications);tables:(a list of{schema, table}) for wal2json, passed as the plugin'sadd-tablesoption. Naming the wrong table-set key for the chosen decoder is refused at start with:config_invalid, and every name still passesReplicant.Identifier.validate/1before it reaches SQL or a plugin option (Critical Rule 2). - pglogical. One implementation for the
pglogical_outputbinary protocol that pglogical 2.x speaks: the startup message that reports the negotiated protocol version and parameters, the relation-metadata message, begin, commit, origin, insert, update, delete, and the tuple format with its null, unchanged-TOAST, text and binary column markers. The decoder requests text-format values only (nobinary.want_binary_basetypes), so the existing casting layer (ADR-0008) receives the same server text output pgoutput delivers. The decoder refuses at connect if the plugin's startup message reports a protocol version outside the range the decoder was built for (:decoder_protocol_unsupported). - wal2json. One implementation for the JSON format, wal2json's
format-version '2'(one JSON document per action:B,C,I,U,D,T,M), withinclude-lsn,include-xids,include-timestamp,include-types,include-typmodandinclude-pkon, andadd-tablescarrying the configured tables. Format version 1 (one document per transaction) is not decoded: it has no per-change LSN and forces the whole transaction into one payload, which defeats the in-flight window. A wal2json build that rejectsformat-version '2'fails the slot's firstSTART_REPLICATIONand the pipeline halts:decoder_option_unsupportedrather than falling back to version 1. - Synthesized relations. wal2json sends no relation message, so the decoder supplies
a
%Relation{}itself. The decoder builds one from the first change it sees for a table (column names and types come with every change) and re-checks every following change against it; a change whose column list differs is a schema change the plugin did not announce, so the decoder emits the new%Relation{}before the change, and the existingReplicant.SchemaChangeclassification runs unchanged. Because the stream cannot express a replica-identity change, the connect chain readspg_class.relreplidentfor every configured table at every connect and reconnect and stores it on the synthesized relation; a change to it is detected at the next reconnect, not at the moment of theALTER TABLE. That window is documented, not hidden. The same catalog read serves the pglogical decoder (verification section): pglogical's relation metadata carries no column types and no replica-identity field, so its TYPES andrelreplidentcome from this read. - Explicit markers. Fields a plugin cannot express are set to an explicit marker, never
a fabricated value.
type_modifieris-1(PostgreSQL's own "no modifier" value) when the plugin does not carry it;replica_identityon a synthesized relation is the catalog value read at connect; a column'sflags(key membership) come from wal2json'sidentitylist orinclude-pkoutput and from pglogical's attribute flags. A change whose old-key data is required and absent halts:decode_failure(a defensive branch). The live substrate CORRECTED the premise: a keyless write never reaches ANY stream — PostgreSQL refuses the write outright on an update-publishing table (object_not_in_prerequisite_state, 55000-class), wal2json drops it plugin-side, and pglogical refuses such tables in update-carrying replication sets — so the halt is unreachable by construction and the per-decoder divergences are documented instead (verification section). - Commit LSN. It is never fabricated:
commit_lsncomes from the plugin's commit message (pglogical carries it; wal2json version 2'sCaction carrieslsnwithinclude-lsnon). A commit payload without it halts:decoder_lsn_missing. The frame'swal_endis used only for the keepalive and idle-advance paths, exactly as today. - Capabilities. They are refused at start, never degraded at run time.
streaming:(pgoutput protocol 2 in-progress streaming),failover: true, andmessages: trueon pglogical are refused:decoder_capability_unsupportedwhen the chosen decoder cannot express them.messages: trueon wal2json maps theMaction to%Message{}with the same transactional split as ADR-0001.snapshot: trueandsnapshot: [mode: :incremental]work with every decoder:EXPORT_SNAPSHOTis a slot option, not a plugin one, and the table set for the reader comes from the decoder's discovery query. The exported snapshot's NAME diverges on 9.6 (%08X-%d, two parts; 10+ export%08X-%08X-%d), and the back-fill adopts either form — through 1.4.1 the adoption allowlist admitted only the three-part name, so a 9.6 snapshot halted:snapshot_failed(see the plugin-fact entry below). - Server version gates. On
server_version_num < 100000the pgoutput decoder is refused{:config, :decoder_unsupported_on_server}before slot creation. The slot status query gains a tier below 130000 that selects no invalidation column (wal_statusandmax_slot_wal_keep_sizearrive in 13; on 9.6 to 12 a slot cannot be invalidated by size, and the only loss signal is a removed WAL segment, which surfaces as aSTART_REPLICATIONfailure and halts through the command-error watchdog). The 9.6 function names (pg_current_xlog_locationforpg_current_wal_lsn) are selected by the same version gate wherever the library calls them.
Version matrix and where each cell is tested
| PostgreSQL | pgoutput | pglogical_output | wal2json | Tested by |
|---|---|---|---|---|
| 9.6 | absent | pglogical 2.x | yes | a real 9.6 with both plugins in the matrix (new row); the connected snapshot leg (plugin_snapshot_pg96_test.exs) back-fills under 9.6's two-part exported-snapshot name |
| 10, 11 | present | pglogical 2.x | yes | not in the matrix; documented as untested |
| 12 | present | pglogical 2.x | yes | a real 12 in the matrix (new row): pgoutput and both plugins on one server |
| 13, 14 | present | pglogical 2.x | yes | not in the matrix; documented as untested |
| 15 | present, tested today | a real 15 plugin lane in the matrix (wal2json master 75a4b494; pglogical 2.4.8) | yes | the 15 plugin row runs the full trio incl. the truncate+message variant; a stock-15 row keeps pgoutput-only coverage |
| 16 to 18 | present, tested today | not tested here (a plugin-bearing primary of any major runs the trio via the plugin probe — no lane wires one) | not tested here | existing rows; pgoutput only |
Which pglogical release supports which major, and the exact wal2json release that introduced each option named above, are read from the two projects' own sources before the build and recorded in the intent; they are not asserted here. Two rows is the whole added matrix: 9.6 is the only major where the plugins are the sole route, and 12 is the newest major on which pgoutput and both plugins can be compared on one server against the same fixture.
Plugin-fact verification (2026-09-29, sources read before the build)
Each fact this ADR rests on, marked OBSERVED (verified in the project's own source) or
CORRECTED (the ADR's claim was adjusted; anchor record in
.kimosabe/intents/pglogical-wal2json-decoders.md):
- pglogical startup message (protocol version + parameter pairs), begin/commit/origin,
and the tuple markers
n/u/t— OBSERVED. The row messages carryK/Ntuple markers exactly like pgoutput;protocol.txt's "tupleformatT" row-header text is stale (BDR-era) — CORRECTED. Text values't-kind length is NUL-inclusive (unlike pgoutput). - pglogical relation metadata carries NO column types and NO replica-identity field —
OBSERVED (
coltypesis hardcoded false). CORRECTED consequence: the connect-time catalog read (§5) serves pglogical too — column TYPES for the ADR-0008 casting layer andrelreplidentto disambiguate aREPLICA IDENTITY FULLold tuple from a key-only one. - pglogical emits no TRUNCATE and no logical-decoding message (no
truncate_cb, nomessage_cb) — OBSERVED. The fixture's truncate and message legs therefore do not ride the pglogical comparison. - wal2json format-version 2,
C-action commit LSN, per-action documents,add-tables— OBSERVED. In format 2 the typmod is always embedded in thetypestring (include-typmodgates format 1 only) — CORRECTED; the decoder takes type names frominclude-type-oids(present since 1.0) through the sameOidDatabasecall pgoutput uses. - wal2json emits numerics as raw JSON numbers (NaN/Infinity become
null) — OBSERVED. CORRECTED:numeric-data-types-as-string 'true'(wal2json ≥ 2.6) is REQUIRED for byte-identical numerics and rides everySTART_REPLICATION; the substrate builds wal2json ≥ 2.6. - The SQL peek cannot carry a binary-output plugin's changes — on a slot with pending
WAL,
pg_logical_slot_peek_changesunderpglogical_outputis refused with exactlyfeature_not_supported(0A000 — OBSERVED live; NOTXX000as first recorded) — the SAME SQLSTATE class as wal2json's format-bound rejection. The §4 option pre-flight therefore rejects ONLY oninvalid_parameter_value(22023, the startup callback's option refusal) and probes pglogical withproto_format 'json'(textual output; the same startup option processing) so a pglogical resume with pending WAL is never falsely halted. Before this correction the probe halted every such resume with:decoder_option_unsupported(found by the crash-resume acceptance leg, 2026-09-30). - wal2json silently DROPS an update or delete on a table with no replica-identity index and
identity ≠ FULL (including
REPLICA IDENTITY NOTHING) — the change never reaches the wire — OBSERVED. CORRECTED: the §6:decode_failureold-key halt is reachable through pglogical's keyless UPDATE (delivered keyless, OBSERVED), not through wal2json, where the drop is documented to operators instead. messages: trueimpossible on PG 9.6 andTimpossible on PG 9.6/10 (server-side:pg_logical_emit_messageand logical-decoding TRUNCATE arrived in PG 10/11) — CORRECTED matrix cells; those fixture legs run on 12 and 15 only.- wal2json silently DROPS an update/delete on a keyless table (no replica-identity
index and identity ≠ FULL) with only a server-side WARNING — wal2json.c's UPDATE/DELETE
guard, OBSERVED in source; no option exists to make it error and no wire signal exists.
RESOLVED 2026-09-30 (beyond documentation): a configured keyless table halts
{:decoder, :table_keyless}at connect (both catalog paths), withallow_keyless_tables: trueas the explicit insert-only opt-in. pgoutput refuses the write at the server (fail-closed at the source) and pglogical DELIVERS the keyless update, which halts at decode — the divergence was wal2json-only. - pglogical re-emits relation metadata after DDL (a relcache-invalidation callback,
pglogical_relcache.c — OBSERVED in source), so dropped columns classify
:destructiveimmediately there, exactly like pgoutput. wal2json format 2 has NO relation messages, so a dropped column was detectable only at reconnect. RESOLVED 2026-09-30: two wire rules (a cached column absent from an INSERT — inserts carry every live column — or absent from an UPDATE when the cached type is fixed-width and can never be stored out-of-line) re-emit the subset relation through the shippedcolumn_droppedclassification, and the residual TOASTable-on-update-only case (wire-identical to the unchanged-TOAST sentinel) is bounded by the periodic catalog guard (schema_check_interval, default 30s). The false-positive direction is structurally excluded: the UPDATE rule fires only on types no column STORAGE setting can externalize. - A pre-PG15 pgoutput walsender still streams EMPTY
BEGIN/COMMITpairs for transactions with zero published changes (every catalog-touching txn — DDL included; PostgreSQL 15 added the server-side skip) — OBSERVED live on the 12.22 substrate while running the full matrix rows (2026-09-30): empty transactions reached sinks, shifted kill-window checkpoints and acked filtered WAL past append frontiers. The assembler now suppresses them on the v1 path exactly as the streamed (proto-v2) path always had — no sink call, a state mirror acks the proven-empty WAL, an:append_logsink does not (Rule 3's append clause). On PG15+ nothing changes (the server skips them). - PostgreSQL 9.6 exports a two-part exported-snapshot name —
CREATE_REPLICATION_SLOT ... LOGICALon 9.6 exports%08X-%d(OBSERVED live:00004E57-1, wal2json on 9.6.24), where 10 and later export%08X-%08X-%d. The §8 snapshot claim therefore does not hold on 9.6 by slot options alone: the back-fill must ADOPT the exported name inSET TRANSACTION SNAPSHOT, and through 1.4.1 the name allowlist admitted only the three-part form — a 9.6snapshot: trueback-fill failed:snapshot_failedand held:snapshot_incomplete, fail-closed, delivering nothing. RESOLVED 2026-10-03: the allowlist admits both forms with the same character class (no quote, backslash or whitespace reaches the string literal;Identifier.validate/1stays the wrong guard there — it rejects uppercase hex and hyphens), and a connected leg (test/integration/plugin_snapshot_pg96_test.exs) back-fills on a real 9.6 while a writer commits across the handoff, asserting every row arrives exactly once across snapshot and stream — a back-fill that ignores the exported snapshot fails it.
Acceptance
A real PostgreSQL 9.6 and a real 12, each with
pglogicalandwal2jsoninstalled, join the CI matrix and the local substrate with port mappings recorded inAGENTS.mdthe way the 15 to 18 mappings are. Images are built from a committed Dockerfile that compiles the two extensions against the officialpostgres:9.6andpostgres:12images, pinned by digest. No mock, stub or canned payload stands in for a server.One fixture (a transaction touching every casted type — NULL scalars and extreme-magnitude floats included — a TOASTed column updated without touching it, an update and a delete under each replica identity, a truncate, and a transactional message where the plugin can carry one) is committed. The delivered
%Replicant.Transaction{}list is byte-identical, aftercommit_lsn,xidand timestamps are normalized, across pgoutput, pglogical and wal2json. The comparison is a test that reads the deliveries and diffs them; a difference is a failure, not a documented deviation, and a missing expected leg FAILS LOUD instead of narrowing the comparison. CI's lane is the 12 row: pgoutput (core variant) on the 12 primary against pglogical and wal2json on that same primary AND on a real 9.6 secondary started beside it (REPLICANT_PG96_URL; the marquee flunks if the wiring is missing on a pre-15 plugin row). The truncate + transactional-message variant (messages are PG14+, and the pgoutput leg needsmessages: trueplus a sink implementinghandle_message/2) runs in CI on the 15 plugin lane:pg_old.dockerfilepins wal2json per major (thewal2json_2_6tag on pre-15 bases — the committed bytes the 9.6/12 receipts are built on — and master75a4b494on 15+, where 2.6 predates PG15'sReorderBufferTXNchange and does not compile), and the lane's server starts withoutput_plugin_libraries=wal2json,pglogical_output,pgoutput(PG15+ gates EVERY output plugin, pgoutput included). An earlier one-off run against a 15.19 with wal2json75a4b494preceded the lane and is retained in the development record.Receipts, in this repo (per-row
mix testresults on real docker-built servers): CI run 36973967528 on commitf2537f6— the binding receipt, all six rows green (PG9, PG12 with the 9.6 secondary this amendment wires — 876 passed / 16 excluded with the marquee executing, PG15, PG16, PG17, PG18; plus the docs/package and reference-example jobs), run URL derivable ashttps://github.com/baselabs/replicant/actions/runs/36973967528; the fresh-review repair round's receipt is run37013373244(commitcf93df9, six rows), and the 15 plugin lane's binding receipt is run37019950420(commitfee13e4— all seven test rows green, the plugin row at 893/2 with the full variant executing; its immediate predecessor37018576121red on a postgres readiness race the lane's second dice-roll surfaced, fixed by requiring three consecutive readiness probes). The 9.6 snapshot leg's binding receipt is run37122961851(commitb481dc8, 2026-10-03 — all rows green): the leg executes on the PG9 row against its own server (811 passed / 86 excluded) and on the PG12 row against the wired 9.6 secondary (881 passed / 16 excluded); its skip tag is unreachable on both rows because the workflow setsREPLICANT_PG96_URLunconditionally there. Local full-geometry corroboration on the BaseLabs ephemeral cluster, at the fresh-review repair revision (counts include that round's new guard tests): 12 row with the 9.6 secondary wired andEXPECTED_PG_MAJORset — 879/16 (re-verified on images rebuilt from the per-major-pin Dockerfile — the pre-15 substrate bytes are unchanged); 15 plugin row (committed-image bytes, plugin-probe admission, no include flags) — 893/2 with the full variant executing; at the first repair revision the same 12 row measured 875/16 withoutEXPECTED_PG_MAJORand 876/16 with it (the version-behavior test is conditionally defined from that var; CI always sets it); 9.6 row — 806/86; 15 — 878/14; 16 — 878/14; 17 — 880/12 (one transient spill-marquee wait timeout on a first run under shared-substrate load, green alone and on immediate full rerun); 18 — 880/12; unit-only — 799/93. The development tree's outcome record (.kimosabe/work/, untracked by design) carries the raw logs behind these numbers.Real captured bytes from the 9.6 and 12 servers (one frame per message kind per plugin) join the conformance suite with the same byte-flip tamper test the pgoutput fixtures carry, so each new fixture is proven to go red on mutation.
Every halt in this ADR has a red-capable test:
:decoder_protocol_unsupported,:decoder_option_unsupported,:decoder_lsn_missing,:decoder_capability_unsupported,{:config, :decoder_unsupported_on_server}, the shape-mismatch relation re-emit, and theREPLICA IDENTITY NOTHINGupdate halt, each on the live server where it can occur.The crash-injection marquee (loss = 0, effect-dup = 0) runs once per new decoder on 12.
The default path is untouched: with
decoder:absent, every existing test passes and the emitted replication commands are byte-identical to 1.3.0.
Options considered
- Extend the pgoutput parser with plugin-specific clauses rather than a behavior: rejected. The three formats share no bytes, and one module would carry three parsers behind one set of pattern matches; the value-free boundary is easier to prove per module.
- wal2json format version 1: rejected (whole-transaction payloads, no per-change LSN,
the
write-in-chunksoption produces partial JSON the decoder would have to reassemble). - Derive replica identity from the stream alone for wal2json: rejected. The stream
reveals it only when an update or delete arrives, so a change to
NOTHINGon a quiet table would be invisible until it caused a halt; the catalog read at connect names the window instead of hiding it. - Support 10 to 14 through pgoutput only and skip the plugins: rejected as incomplete. It leaves 9.6 unreadable and does not help a server whose operator can install no new extension but already runs pglogical.
- Binary base types from pglogical (
binary.want_binary_basetypes): deferred. It would bypass the text casting layer and need a second casting contract; text output is what every other path delivers.
Consequences
- Snapshot column correction (October 10, 2026). Both readers use
pglogical.replication_set_table.set_att_listfor the requested local sets. OBSERVED in the pglogical 2.4.8 SQL schema: the stored field isset_att_list text[]; the effective-list function calls its outputatt_list. Identical effective projections deduplicate; Replicant refuses conflicting set lists with:snapshot_column_list_conflict, as an explicit snapshot policy instead of combining columns. OBSERVED inget_table_replication_infoin pglogical 2.4.8 source: pglogical unionsset_att_listacross selected sets withbms_add_member. Replicant's refusal of differing lists is conservative: it does not reproduce that union, and requires the operator to align the lists before snapshotting. wal2json's documented parameters provide table filters, not publication column lists, so its snapshot behavior remains all configured-table columns. Unpublished PK columns are never added for pagination; either reader preserves the published projection, and incremental backfills use whole-table windows when necessary. The connected regression istest/integration/pglogical_snapshot_columns_test.exs; it requires a server carrying pglogical and is skipped when unavailable. - Snapshot row-filter correction (October 10, 2026). Both readers use
pg_get_expr(set_row_filter::pg_node_tree, set_reloid)for the requested local sets. OBSERVED in pglogical 2.4.8 source:get_table_replication_infoin pglogical_repset.c accumulates every non-NULL filter, andpglogical_change_filterin pglogical_output_plugin.c rejects the change if any expression is false or NULL. Therefore the combination is AND, including beside an unfiltered set; an unfiltered set does not override another set's filter. Inserts flow when any selected set hasreplicate_insert, and the filters of every selected set then apply, whatever each set's insert flag (pglogical_repset.c:458-498); a snapshot delivers nothing when no selected set replicates inserts. Snapshots use that same rule with parenthesized server expressions. wal2json has no server row-predicate option; its configured table membership remains the filter. The live pglogical regression covers both readers, two filters, a filtered/unfiltered pair, and wholly unfiltered sets. Local PG18.6 does not carry pglogical; execution belongs to the hosted 9.6, 12, and 15-plugin CI legs, not to a simulated plugin. - Sinks see no new struct, callback or field; a sink written for pgoutput runs unchanged
against a 9.6 server. The guarantees table in
docs/INVARIANTS.mdapplies per checkpoint mode, not per decoder. - Operators of 9.6 to 12 accept documented differences, each bounded: a replica-identity
change is classified at the next reconnect (wal2json); a column DROP is caught IN
STREAM by the two wire rules (a cached column absent from an INSERT, or absent from an
UPDATE when its cached type is fixed-width — verification section), with a bounded
residual: a dropped TOASTable column on an update-only table stays
unchanged:until the periodic catalog guard (schema_check_interval, default 30s) or the next reconnect's catalog read, and a DROP committed in the same DDL batch as an ADD takes the append-only drift branch first (the ADD is visible in the change, the DROP is not), deferring that DROP's destructive halt to the same guard-or-reconnect window; andtype_modifieris-1where the plugin's stream does not carry it (wal2json parses real typmods from its type strings and pglogical takes them from the connect-time catalog read, so-1survives only on a catalog miss). - The test matrix grows by two rows and one image build; the images are the maintainer's to keep building as the official base images age.
- The library gains its first plugin-specific error atoms;
Replicant.Error.reason/0and the halt-reason table inusage-rules.mdgrow by the six reasons above.
Non-goals
- No statement-based or test_decoding output; only the two plugins named here.
- No schema migration, extension installation or configuration change on the source; the
plugin must already be installed and
wal_levelalreadylogical. - No pglogical subscription, node or replication-set management; Replicant reads a slot the operator created the sets for.
- No support for PostgreSQL 9.4 or 9.5 (no
confirmed_flush_lsnon 9.4; both are outside what a 9.6 test can prove). - No change to how 15 to 18 are read; pgoutput remains the default and the only decoder tested there.