Skip to content

postgres_cdc: multi-schema support - #4720

Open
josephwoodward wants to merge 75 commits into
mainfrom
jw/postgres_multischema_support
Open

postgres_cdc: multi-schema support#4720
josephwoodward wants to merge 75 commits into
mainfrom
jw/postgres_multischema_support

Conversation

@josephwoodward

@josephwoodward josephwoodward commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

This change adds multi-schema support to the PostgreSQL CDC connector via two new config fields (schema_include and schema_exclude). The two new fields take glob patterns, allowing users to select a range of tables across schemas.

Examples:

## replicate all "events" tables across schemas matching tenant_*
postgres_cdc:
    schema_include: tenant_*
    tables:
      - events

## replicate all "events" tables across schemas matching tenant_* excluding tenant_b
postgres_cdc:
    schema_include: tenant_*
    schema_exclude:
      - tenant_b
    tables:
      - events

## replicate all tables across schemas matching tenant_*
postgres_cdc:
    schema_include: tenant_*
    tables: []

Proof of Work

You can see in the below demonstration I create a config to include all schemas with the exception of tenant_b and replicate the users table. I run it once then create a new schema (tenant_c) which automatically gets picked up on the second run.

image

ness-david-dedu and others added 30 commits July 6, 2026 22:00
…t harness

Two-schema (tenant_a, tenant_b) Postgres 16 setup that exercises the
multi-schema CDC pipeline end-to-end. Also adds schema_validation unit
tests that verify invalid patterns are rejected at startup without a DB.
Co-authored-by: Joseph Woodward <joseph.woodward@xeuse.com>
  information_schema.schemata only lists schemas the connecting role can
  see, so a schema hidden by missing USAGE was silently dropped from a
  matched pattern with no signal to the user. resolveSchemas now cross-checks
  pg_catalog.pg_namespace, which isn't privilege-filtered, and reports those
  as inaccessible so logical_stream.go can warn instead of skipping silently.
If no schema is found and tables is empty then we want to ensure we
create publication for all tables.
Introduces schema_pattern as a new optional field for glob-based
multi-schema CDC, instead of overloading schema with pattern semantics.
This keeps schema backwards compatible: it reverts to exact-name-only
behavior (now defaulting to "public"), while schema_pattern opts into
the glob resolution path. Setting both is a config error.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
validateSchemaPattern only accepted [a-zA-Z0-9_*], stricter than
sanitize.NormalizePostgresIdentifier (unicode.IsLetter/IsDigit) which
governs the exact-name schema path. Since schema_pattern is now the
sole caller of validateSchemaPattern, an unquoted pattern with
non-ASCII letters (e.g. münchen) was rejected at startup even though
the equivalent schema value has always been accepted. Widened the
character classes to match, using utf8.DecodeRuneInString for the
first-character check instead of a raw byte cast.

Also corrected the Unreleased changelog entry, which still described
the old design where schema itself accepted glob patterns.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
internal/impl/postgresql/tests/current/ is a docker-compose-backed
Taskfile harness for manually exercising multi-schema CDC against a
real Postgres (tenant_a/tenant_b). It predates the schema/schema_pattern
split and still used schema: tenant_* directly, which the split turns
into a literal (and invalid) exact schema name instead of a glob.

- test_config.yaml: schema -> schema_pattern for the tenant_* glob.
- Taskfile.yaml test:invalid-schema: schema="" is now a no-op (unset
  schema_pattern falls back to schema's "public" default), so it no
  longer exercises a failure path. Repointed at schema_pattern=1abc,
  which still fails fast in newPgStreamInput before any DB connection,
  matching the task's original intent.

Verified by bringing the compose stack up and running the smoke test;
config loads and reaches runtime init (blocked only by the license
check in this sandboxed environment, which is unrelated).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Comment thread internal/impl/postgresql/pglogicalstream/pglogrepl.go
Comment thread internal/impl/postgresql/input_pg_stream.go
Comment thread internal/impl/postgresql/pglogicalstream/multischema/resolver_integration_test.go Outdated
Comment thread internal/impl/postgresql/bench/users.sql Outdated
Comment thread internal/impl/postgresql/bench/benchmark_config.yaml Outdated
Comment thread internal/impl/postgresql/bench/users.sql Outdated
Comment thread internal/impl/postgresql/bench/create.sql
Comment thread internal/impl/postgresql/input_pg_stream.go
Comment thread internal/impl/postgresql/input_pg_stream.go
Comment thread internal/impl/postgresql/input_pg_stream.go
Comment thread internal/impl/postgresql/bench/users.sql Outdated
Comment thread internal/impl/postgresql/bench/benchmark_config.yaml Outdated
Comment thread internal/impl/postgresql/pglogicalstream/pglogrepl.go
require.NoError(t, err)

for _, m := range logs.Messages() {
assert.NotContains(t, m, fieldSchema+" is set", "schema was left at its default, so no warning about it should be logged, got: %v", m)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Assertion can never fail — this test does not verify what its name claims.

The substring being searched for is fieldSchema + " is set", i.e. "schema is set". The warning the code actually emits is:

Field 'schema_include' configured, ignoring field 'schema' configuration

That string never contains "schema is set", so assert.NotContains passes unconditionally — the test would still be green if the warning were emitted for a default-valued schema. The same defect is present in TestSchemaExplicitlySetToDefaultAlongsideSchemaIncludeDoesNotWarn at line 188.

Suggested fix: assert against a substring that actually appears in the emitted warning (e.g. "ignoring field '"+fieldSchema+"'"), or better, have the production code expose the warning text as a constant/format string that both the code and the tests reference, so the two can't drift.

Ref: warning emitted at

}
if err = validateSchemaPattern(schemaInclude); err != nil {
return nil, fmt.Errorf("invalid schema_include: %w", err)
}
// Normalize unquoted patterns to lower-case: PostgreSQL folds unquoted
— see the test patterns guidance on assertions.

Default([]string{})).
Field(service.NewIntField(fieldCheckpointLimit).
Description("The maximum number of messages that can be processed at a given time. Increasing this limit enables parallel processing and batching at the output level. Any given LSN will not be acknowledged unless all messages under that offset are delivered in order to preserve at least once delivery guarantees.").
ShortDescription("The maximum number of messages that can be processed at a given time.").

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Making tables optional removes the only guard against accidentally replicating the whole database.

Before this PR tables was a required field (the regenerated docs confirm it: tables: [] # No default (required)tables: []). With .Optional().Default([]string{}), a config that simply omits tables now lints clean and, when schema_include is not set, falls through to CreatePublication's FOR ALL TABLES branch — replicating every table in every schema of the database and silently making stream_snapshot a no-op:

Previously that outcome required the user to explicitly write tables: []; now an omitted key produces it silently. That conflicts with CONTRIBUTING.md §1.2.4 ("Strongly lints and validates user-provided configuration, clearly telling users of any problems"), and it is a behaviour change not mentioned in the CHANGELOG.

tables only needs to be optional when schema_include is set (for auto-discovery). Suggested fix: keep the field optional at the spec level but reject an unset tables in newPgStreamInput when schema_include is empty — using conf.Contains(fieldTables) to distinguish "omitted" from an explicit tables: [] — so the FOR-ALL-TABLES path still requires a deliberate opt-in.

Comment thread CHANGELOG.md Outdated
Comment thread internal/impl/postgresql/bench/benchmark_config.yaml
require.NoError(t, err)

for _, m := range logs.Messages() {
assert.NotContains(t, m, fieldSchema+" is set", "schema was left at its default, so no warning about it should be logged, got: %v", m)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

These two negative assertions are vacuous — they can never fail.

The production warning is emitted as:

mgr.Logger().Warnf("Field '%s' configured, ignoring field '%s' configuration", fieldSchemaInclude, fieldSchema)

(input_pg_stream.go#L488-L491), which renders as Field 'schema_include' configured, ignoring field 'schema' configuration. It never contains the substring schema is set, so assert.NotContains(t, m, fieldSchema+" is set") holds for every message the connector could ever log — including the warning these tests are meant to prove is absent.

The same vacuous assertion is repeated in TestSchemaExplicitlySetToDefaultAlongsideSchemaIncludeDoesNotWarn (line 188).

Both tests should assert against the substring the code actually emits — e.g. mirror the positive check in TestSchemaIgnoredWhenSchemaIncludeSet (line 127) and assert that no message contains both fieldSchema and fieldSchemaInclude. As written, a regression that started warning on the default schema: public would ship undetected, which is exactly the behaviour the doc comment on line 160-167 claims is being pinned.

Per the test patterns, tests must actually exercise the asserted behaviour.

// pubTables instead of tables would always be true here (a FOR ALL
// TABLES publication has no pg_publication_rel rows), silently ignoring
// a caller that has since narrowed to an explicit table list.
if forAllTables && len(tables) == 0 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Narrowing an existing FOR ALL TABLES publication now fails with a raw, unactionable PostgreSQL error, and this is a path existing users will hit on upgrade.

Before this change, forAllTables && len(pubTables) == 0 was always true for a FOR ALL TABLES publication, so the reconcile was a silent no-op. With the new condition, any caller that supplies a non-empty table list against an existing FOR ALL TABLES publication falls through to the ALTER PUBLICATION ... ADD TABLE batch below (L383-L400), which PostgreSQL rejects. The new test TestIntegrationCreatePublicationNarrowingFromForAllTablesFailsLoudly confirms this is the intended outcome.

The concrete upgrade scenario: a pipeline currently running with tables: [] (so pglog_stream_<slot> exists as FOR ALL TABLES) that adds schema_include on the same slot_name. Auto-discovery in logical_stream.go#L138-L149 makes tables non-empty, so Connect fails with adding tables to publication: ERROR: publication "pglog_stream_x" is defined as FOR ALL TABLES and the framework retries forever with no hint about the cause or the fix.

Suggest detecting forAllTables && len(tables) > 0 here and returning an error that names the publication and states the remedy (recreate the publication, or use a different slot_name), rather than letting the bare driver error surface. CONTRIBUTING §1.2.4 requires clearly telling users about configuration problems, and §3.2.2 lists difficult-to-diagnose errors as an anti-pattern. This transition is also not covered in the new schema_include / tables docs (§1.2.3).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is a pre-existing bug, I need to think about this part some more.

@josephwoodward
josephwoodward marked this pull request as ready for review August 25, 2026 18:23
require.NoError(t, err)

for _, m := range logs.Messages() {
assert.NotContains(t, m, fieldSchema+" is set", "schema was left at its default, so no warning about it should be logged, got: %v", m)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Vacuous assertion — this test can never fail.

The substring being asserted against is fieldSchema + " is set", i.e. "schema is set". The production warning this test is guarding is emitted as:

mgr.Logger().Warnf("Field '%s' configured, ignoring field '%s' configuration", fieldSchemaInclude, fieldSchema)

(input_pg_stream.go#L371-L373) — which renders as Field 'schema_include' configured, ignoring field 'schema' configuration. That string never contains schema is set, so the NotContains check passes regardless of whether the warning is logged. The same dead assertion appears again at line 188 in TestSchemaExplicitlySetToDefaultAlongsideSchemaIncludeDoesNotWarn.

Both tests would still pass if the schema != defaultSchema guard on the warning were removed entirely, so neither actually covers the behaviour its doc comment claims to document.

Suggested fix: assert against the substring the code really emits — mirror the positive test at lines 125-131 by scanning for a message containing fieldSchemaInclude and "ignoring field" and asserting none was found. Note that the positive test's own predicate (strings.Contains(m, fieldSchema) && strings.Contains(m, fieldSchemaInclude)) is also weaker than intended, since "schema" is a substring of "schema_include".

Per CONTRIBUTING.md §1.3.2, tests must prove the behaviour they claim to cover.

Comment on lines +138 to +148
if autoDiscoverTables {
// Only ordinary tables: a partitioned parent must not be
// auto-discovered alongside its leaf partitions (which are
// themselves ordinary tables) - see ResolveExistingTables.
for table, relkind := range existingTables {
if relkind != multischema.RelKindOrdinaryTable {
continue
}
tables = append(tables, TableFQN{Schema: schema, Table: table})
}
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Undocumented data-loss window: auto-discovery publishes leaf partitions only, so partitions created after connect are never replicated.

Auto-discovery filters to RelKindOrdinaryTable, so a partitioned table enters the publication as its current leaf partitions rather than the parent. CreatePublication then issues CREATE PUBLICATION ... FOR TABLE <leaf1>, <leaf2> (pglogrepl.go#L296-L322).

Publishing the parent makes future partitions members automatically; publishing individual leaves does not. Table resolution only re-runs inside NewPgStream, i.e. on connect/reconnect, so for a healthy long-lived stream the set is frozen at connect time. Failure scenario: schema_include: tenant_* with tables left empty over a monthly-partitioned orders table — every row written to the orders_2026_09 partition created after the pipeline started is silently dropped, with no warning, until the pipeline happens to reconnect.

The tables field docs cover the metadata consequence of leaf-level discovery ("the table metadata ... will be the partition's name") but say nothing about newly-created partitions being missed, so an operator has no way to know they need to list the parent explicitly or restart after each partition rollover.

Per CONTRIBUTING.md §1.2.3 known limitations and edge cases must be documented. Suggested fix: either state this limitation explicitly in the tables field description alongside the existing partition note, or auto-discover the partitioned parent for publication purposes while continuing to snapshot only its leaves (the TABLESAMPLE/double-count concerns cited in ResolveExistingTables apply to snapshot planning, not to publication membership).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants