Replicant.Decoder.Wal2json (Replicant v1.4.2)

Copy Markdown View Source

The wal2json decoder — JSON format version 2 (ADR-0009 §4).

One JSON document per action (B, C, I, U, D, T, M; anchors in .kimosabe/intents/pglogical-wal2json-decoders.md). The decoder requests include-transaction, include-lsn, include-xids, include-timestamp, include-types, include-type-oids, include-pk and numeric-data-types-as-string (wal2json ≥ 2.6): numerics arrive as JSON strings so no trailing digit is lost, and type names come from the type OIDs through the same OidDatabase call pgoutput's decoder uses. add-tables carries the configured tables.

wal2json sends no relation message, so the decoder synthesizes a Replicant.Decoder.Messages.Relation from the first change it sees for a table and re-emits it ahead of any change whose column list differs (ADR-0009 §5) — the existing schema-change classification then runs unchanged. A column ABSENT from a change's columns list is wal2json's unchanged-TOAST sentinel (the plugin simply skips it), which this decoder surfaces as the first-class :unchanged_toast marker. The relation's replica_identity and stable id come from the connect-time catalog read in the cache.

Documented divergences from the pgoutput contract

wal2json's format 2 has no relation message, so a dropped column is detected from the change stream by two wire rules (dropped_columns/3): a cached column ABSENT from an INSERT is dropped (inserts carry every live column — NULLs included, as in-array null values), and a cached FIXED-WIDTH column ABSENT from an UPDATE is dropped (no column STORAGE setting can store a fixed-width type out-of-line). Both re-emit the subset relation ahead of the change and the assembler's destructive classification (Critical Rule 4) halts fail-closed, exactly like a pgoutput Relation re-emit after the same DDL. The residual: a dropped TOASTable column on an update-only table is wire-identical to the unchanged-TOAST sentinel and stays unchanged: until the periodic catalog guard (schema_check_interval, default 30s) or the next reconnect's catalog read re-emits the shrunken relation. A DROP and an ADD committed in one DDL batch take the append-only drift branch first (the ADD is visible in the change, the DROP is not), so the destructive halt for that DROP is deferred to the guard or reconnect. A column ADD alone is detected and classified as usual. A replica-identity change is still only visible at the next reconnect's catalog read.

Value text: byte-identical to pgoutput, with one server-vintage boundary

With numeric-data-types-as-string (pre-flighted at connect — a build that rejects it halts :decoder_option_unsupported before streaming), wal2json emits every column value as the SERVER'S OWN output-function text: int2/4/8, oid, float4/8 and numeric as JSON strings, bool as a JSON boolean, bytea as bare hex, every other type as an escaped JSON string, NULL as an in-array null. A JSON number in a column value is therefore structurally impossible on the wire, and normalize_value/2 fails closed (:decode_failure) on one rather than re-rendering it — an Elixir re-render (for example Float.to_string/1's "1.0e20") is NOT the server's float8out text ("1e+20") and would silently diverge from what pgoutput delivers. The one boundary is a SERVER fact, not a decoder one: a float whose shortest round-trip needs more than 15 significant digits prints differently on pre-PG12 servers (%.{DBL_DIG}g) than on PG12+ (shortest round-trip), so cross-vintage comparisons of that magnitude class compare different server text; within one server both decoders deliver identical bytes.