ADR-0002: Multi-publication per pipeline (discovery union + fail-closed existence check)
Copy Markdown View SourceStatus: Accepted
Date: 2026-07-14
Deciders: replicant maintainer (feature A3; spec 2026-07-11-replicant-messages-multipub-design.md §5.3)
Context
A replicant pipeline consumed exactly one publication (:publication was a single string).
Real deployments need to consume multiple publications through one replication slot (pgoutput
accepts a comma-list of publication_names). Adding this touches three security- and
correctness-sensitive surfaces:
- The identifier interpolation surface (Critical Rule 2 — the replication simple-query protocol
cannot bind
$1, so publication names are interpolated and must be allowlist-validated). - Snapshot/PK/column discovery queries, which enumerate the tables to snapshot and their PK
and column sets from
pg_publication_tables. - The failure mode when a requested publication does not exist on the server.
Two facts were probe-established on live PG16 during design:
START_REPLICATIONwith a missing publication does NOT error — it silently streams the existing subset. So a typo'd publication would silently under-replicate.- A table in two publications produces two
pg_publication_tablesrows, and thepg_class/pg_index/pg_attributejoins fan out per pubname-row, so a barepubname = ANY($1)duplicatesarray_aggPK/column entries (["id","id"]).
Decision
- Column-list correction (October 10, 2026). Table membership is a union;
column lists are not. PostgreSQL 15+ column discovery retains DISTINCT effective
attnamesper table and projects only those attributes inattnumorder. Identical lists collapse; conflicting lists halt:snapshot_column_list_conflictbefore snapshot delivery in either mode. The pre-15 query remains version-selected because that catalog column does not exist. OBSERVED on PostgreSQL 18.6 viapg_logical_slot_peek_binary_changes: differing lists, including a partial list plus an unrestricted publication, producecannot use different column lists for table "public.rep_column_probe" in different publications. The live regression istest/integration/snapshot_columns_test.exs. - Row-filter correction (October 10, 2026). Initial snapshots in both
checkpoint modes and incremental chunks use PG15+
pg_publication_tables.rowfilter. For a shared table, an unfiltered publication means no predicate; otherwise the server-rendered predicates combine with OR. Every expression is parenthesized before it is composed with a keyset bound. No client predicate is interpolated. The connected regression istest/integration/snapshot_rows_test.exs. - PG15 dropped-column collapse — documentation only. OBSERVED in the
PG15
pgoutput_column_list_initsource: an explicit list is collapsed to NULL only when its bitmap member count equalsRelationGetNumberOfAttributes, which includes dropped columns. An implicit list is already NULL. Consequently, a table with dropped attributes can have identical effectiveattnamesfor an implicit list and an explicit list of every live column, yet PG15 pgoutput rejects that combination. Snapshot discovery accepts those effective lists; the stream refuses them fail-closed, never exposing an unpublished column. No matching early refusal was added:attnamespluspg_attributecannot distinguish explicit and implicit lists with the same names. Refusing all identical lists on a table with dropped attributes would also reject combinations pgoutput accepts. Operators can use consistently explicit or consistently implicit lists on PG15. publication: String | [String]. A single string is the byte-unchanged default path (the published 0.1.0 START_REPLICATION is reproduced exactly); a list enables multi-publication. Every name is validated viaIdentifier.validate/1and normalized to a list. (config.ex.)- Table and PK discovery queries drive from a DISTINCT table set:
(SELECT DISTINCT schemaname, tablename FROM pg_publication_tables WHERE pubname = ANY($1)) pTHEN join — collapsing the pubname dimension before the joins soarray_aggnever duplicates PK/column entries (decision #19; probe-provenpk_raw = ["id"]for a shared table). (query_builder.expublication_tables/1,pk_columns/0; column discovery follows the correction above.) - A new fail-closed connect-chain
:publication_checkstep. After the recovery check and before slot invalidation/classification, runpublication_exists(pubname = ANY(...)) and halt if the found set ≠ the requested set — because START_REPLICATION would otherwise silently stream the subset.publication_existsinterpolates a validatedIN (...)list (the connect-chain simple-query protocol can't bind$1), each name allowlist-guarded. (connection.ex:publication_check;query_builder.expublication_exists/1.)
Options Considered
Option A: Comma-string only (publication: "p1,p2")
Cons: No validation boundary per name; pushes the parsing/validation burden onto the user and makes the identifier-allowlist guarantee (Rule 2) harder to enforce. Rejected in favor of an explicit list normalized + validated in Config.
Option B: Bare pubname = ANY($1) on pg_publication_tables (no DISTINCT)
Cons: A table in two publications duplicates array_agg PK/column entries via the per-pubname
join fan-out — probe-disproven (["id","id"]). Would corrupt PK-based dedup. Rejected.
Option C: Rely on START_REPLICATION to reject a missing publication
Cons: Probe-disproven — it silently streams the existing subset. A typo would silently under-replicate with no error. Rejected in favor of an explicit connect-time existence check.
Option D: Validated list + DISTINCT discovery + fail-closed existence check (adopted)
| Dimension | Assessment |
|---|---|
| Complexity | Medium (one new connect step, four discovery queries reworked) |
| Security | High — every interpolated name allowlist-validated (Rule 2) |
| Correctness | High — DISTINCT prevents discovery duplication; fail-closed prevents silent subset streaming |
Trade-off Analysis
The adopted design pays for one new connect-chain step and a discovery-query rework in exchange for (a) no silent under-replication on a bad publication name and (b) no PK/column duplication for tables shared across publications. The rejected simpler options each fail silently — exactly the class of failure this library's fail-closed posture exists to prevent.
Consequences
- Easier: One slot can consume many publications; overlapping tables deliver once (pgoutput
dedups streaming; the DISTINCT driving set dedups discovery); a typo'd publication fails fast at
START with a
:publication_missingsignal instead of silently streaming a subset. - Harder: The connect chain has one more fail-closed gate to reason about; discovery queries
must keep the DISTINCT driving set (a future edit that reverts to a bare
ANY($1)reintroduces the duplication bug). - Revisit if: per-publication routing/filtering (delivering a change tagged by which publication carried it) is ever required — the current design intentionally deduplicates and does not preserve the pubname dimension past discovery.
Verification (closeout 2026-07-14)
Proven at runtime against live PG16 by test/integration/multipub_test.exs: both publications'
changes deliver overlap-deduped in commit order; snapshot discovery unions across the list with
the shared table snapshotted exactly once (DISTINCT); a missing publication halts fail-closed at
START (:publication_missing).