Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
f903238
postgres_cdc: add multi-schema support
ness-david-dedu Jul 6, 2026
c38fd7a
postgres_cdc: reject empty quoted schema identifier and fix misleadin…
ness-david-dedu Jul 6, 2026
bda218d
postgres_cdc: add tests/current/ Docker Compose + Taskfile manual tes…
ness-david-dedu Jul 7, 2026
df2dca6
postgres_cdc: add commit_ts_ms and before metadata fields
ness-david-dedu Jul 7, 2026
e6bbd4c
Merge remote-tracking branch 'upstream/main' into feature/postgres_cd…
ness-david-dedu Jul 9, 2026
fcb0427
postgres_cdc: fix lint and docs
ness-david-dedu Jul 10, 2026
7bcc3ac
postgres_cdc: review fixes and test coverage
ness-david-dedu Jul 10, 2026
d39de9a
postgres_cdc: fix tests
ness-david-dedu Jul 13, 2026
5f9a2a8
Merge remote-tracking branch 'upstream/main' into feature/postgres_cd…
ness-david-dedu Jul 13, 2026
0aa387a
test(cdctest): waive tigerbeetle_cdc conformance fields
ness-david-dedu Jul 15, 2026
ba7c384
postgres_cdc: fix lint
ness-david-dedu Jul 15, 2026
bd3e383
Merge remote-tracking branch 'upstream/main' into feature/postgres_cd…
ness-david-dedu Jul 22, 2026
caa0b1a
postgres_cdc: skip missing tables per-schema instead of failing whole…
ness-david-dedu Jul 22, 2026
c161947
postgres_cdc: fix lint
ness-david-dedu Jul 22, 2026
0ed96fd
Update internal/impl/postgresql/pglogicalstream/schema_resolver.go
ness-david-dedu Jul 23, 2026
d893e1a
postgres_cdc: warn when schema pattern matches privilege-hidden schemas
ness-david-dedu Jul 27, 2026
28b2710
Merge branch 'main' into feature/postgres_cdc_multi_schema
josephwoodward Aug 4, 2026
0e662de
postgres_cdc: Address minor issues
josephwoodward Aug 4, 2026
b0c39d2
postgres_cdc: fix broken test
josephwoodward Aug 4, 2026
3d253f8
postgres_cdc: replace pg_schema with database_schema
josephwoodward Aug 4, 2026
1f1b68f
postgres_cdc: move schema validation to unit test closer to use
josephwoodward Aug 4, 2026
836f1cd
postgres_cdc: clean up redundant comment
josephwoodward Aug 4, 2026
620c9da
postgres_cdc: normalie test structure
josephwoodward Aug 4, 2026
d276c1e
postgres_cdc: t.Context()
josephwoodward Aug 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ All notable changes to this project will be documented in this file.

### Added

- postgres_cdc: Postgres CDC now accepts a glob pattern for the `schema` field (e.g. `tenant_*`), replicating all matching schemas through a single replication slot. Useful for multi-tenant databases where each tenant has its own schema. ([@ness-david-dedu](https://github.com/ness-david-dedu), [#4589](https://github.com/redpanda-data/connect/pull/4589))
- aws_dynamodb_cdc: DynamoDB CDC now supports an optional checkpoint_namespace field, allowing multiple independent pipelines to share a single checkpoint table without overwriting each other's checkpoints. ([@squiidz](https://github.com/squiidz), [#4602](https://github.com/redpanda-data/connect/pull/4602))

### Fixed
Expand Down
14 changes: 13 additions & 1 deletion docs/modules/components/pages/inputs/postgres_cdc.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,13 @@ snapshot_batch_size: 10000

=== `schema`

The PostgreSQL schema from which to replicate data.
The PostgreSQL schema to replicate data from. Accepts an exact schema name or a glob pattern using `*` as a wildcard to match multiple schemas.

When a pattern is used, all schemas whose names match the pattern are replicated using a single replication slot and publication. This is useful for multi-tenant databases where each tenant has its own schema (e.g. `tenant_*` matches `tenant_foo`, `tenant_bar`, etc.).

Double-quoted identifiers are treated as exact names and do not support wildcards.

Schema pattern matching runs once at pipeline startup. Schemas created after the pipeline starts will not be picked up until the pipeline is restarted.


*Type*: `string`
Expand All @@ -182,12 +188,18 @@ The PostgreSQL schema from which to replicate data.
schema: public

schema: '"MyCaseSensitiveSchemaNeedingQuotes"'

schema: tenant_*

schema: '*'
```

=== `tables`

A list of table names to include in the logical replication. Each table should be specified as a separate item.

When `schema` is a glob pattern, this list is resolved against each matched schema independently: a table missing from a given schema is skipped (with a warning logged) rather than failing replication for every matched schema.

If left empty, the underlying PostgreSQL publication is created `FOR ALL TABLES`, which replicates every table in every schema of the database, ignoring `schema`. This also disables `stream_snapshot`, since the initial snapshot is only planned for tables listed here.


Expand Down
58 changes: 55 additions & 3 deletions internal/impl/postgresql/input_pg_stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"errors"
"fmt"
"strconv"
"strings"
"sync"
"time"

Expand All @@ -26,6 +27,7 @@ import (

"github.com/redpanda-data/connect/v4/internal/asyncroutine"
"github.com/redpanda-data/connect/v4/internal/impl/postgresql/pglogicalstream"
"github.com/redpanda-data/connect/v4/internal/impl/postgresql/pglogicalstream/sanitize"
"github.com/redpanda-data/connect/v4/internal/license"
)

Expand Down Expand Up @@ -83,6 +85,7 @@ Additionally, if ` + "`" + fieldStreamSnapshot + "`" + ` is set to true, then th

This input adds the following metadata fields to each message:
- table: Name of the table that the message originated from
- database_schema: The database schema for the table where the message originates from (e.g. "public", "tenant_foo"). Useful for per-schema routing when using schema patterns.
- operation: Type of operation that generated the message: "read", "insert", "update", or "delete". "read" is from messages that are read in the initial snapshot phase. This will also be "begin" and "commit" if ` + "`" + fieldIncludeTxnMarkers + "`" + ` is enabled
- lsn: the log sequence number in postgres
- schema: The table schema in benthos common schema format, compatible with processors like parquet_encode
Expand Down Expand Up @@ -113,12 +116,20 @@ This input adds the following metadata fields to each message:
Example(10000).
Default(1000)).
Field(service.NewStringField(fieldSchema).
Description("The PostgreSQL schema from which to replicate data.").
Examples("public", `"MyCaseSensitiveSchemaNeedingQuotes"`),
Description(`The PostgreSQL schema to replicate data from. Accepts an exact schema name or a glob pattern using `+"`*`"+` as a wildcard to match multiple schemas.

When a pattern is used, all schemas whose names match the pattern are replicated using a single replication slot and publication. This is useful for multi-tenant databases where each tenant has its own schema (e.g. `+"`tenant_*`"+` matches `+"`tenant_foo`"+`, `+"`tenant_bar`"+`, etc.).

Double-quoted identifiers are treated as exact names and do not support wildcards.

Schema pattern matching runs once at pipeline startup. Schemas created after the pipeline starts will not be picked up until the pipeline is restarted.`).
Examples("public", `"MyCaseSensitiveSchemaNeedingQuotes"`, "tenant_*", "*"),
).
Field(service.NewStringListField(fieldTables).
Description(`A list of table names to include in the logical replication. Each table should be specified as a separate item.

When ` + "`schema`" + ` is a glob pattern, this list is resolved against each matched schema independently: a table missing from a given schema is skipped (with a warning logged) rather than failing replication for every matched schema.

If left empty, the underlying PostgreSQL publication is created ` + "`FOR ALL TABLES`" + `, which replicates every table in every schema of the database, ignoring ` + "`" + fieldSchema + "`" + `. This also disables ` + "`" + fieldStreamSnapshot + "`" + `, since the initial snapshot is only planned for tables listed here.`).
Example([]string{"my_table_1", `"MyCaseSensitiveTableNeedingQuotes"`})).
Field(service.NewIntField(fieldCheckpointLimit).
Expand Down Expand Up @@ -262,6 +273,15 @@ func newPgStreamInput(conf *service.ParsedConfig, mgr *service.Resources) (s ser
if schema, err = conf.FieldString(fieldSchema); err != nil {
return nil, err
}
if err = validateSchemaPattern(schema); err != nil {
return nil, fmt.Errorf("invalid schema: %w", err)
}
// Normalize unquoted patterns to lower-case: PostgreSQL folds unquoted
// identifiers at creation time, so TENANT_* and tenant_* resolve identically.
// Normalizing early avoids silent case-folding surprises in resolveSchemas.
if !strings.HasPrefix(schema, `"`) {
schema = strings.ToLower(schema)
}

if tables, err = conf.FieldStringList(fieldTables); err != nil {
return nil, err
Expand Down Expand Up @@ -341,7 +361,7 @@ func newPgStreamInput(conf *service.ParsedConfig, mgr *service.Resources) (s ser
DBConfig: pgConnConfig,
TLSConfig: pgConnConfig.TLSConfig,
DBRawDSN: dsn,
DBSchema: schema,
DBSchemaPattern: schema,
DBTables: tables,
RefreshAuthToken: iamAuthTokenBuilder,

Expand Down Expand Up @@ -381,6 +401,37 @@ func newPgStreamInput(conf *service.ParsedConfig, mgr *service.Resources) (s ser
return conf.WrapBatchInputExtractTracingSpanMapping("postgres_cdc", r)
}

// validateSchemaPattern validates a schema name or glob pattern.
// Accepts exact postgres identifiers (letters/digits/underscores) and glob
// patterns that additionally allow '*' as a wildcard character.
// Double-quoted identifiers (e.g. "MySchema") are accepted as exact names;
// wildcards are not allowed inside quotes.
func validateSchemaPattern(s string) error {
if s == "" {
return errors.New("schema cannot be empty")
}
if strings.HasPrefix(s, `"`) {
if _, err := sanitize.UnquotePostgresIdentifier(s); err != nil {
return fmt.Errorf("invalid quoted schema identifier: %w", err)
}
if strings.ContainsRune(s, '*') {
return errors.New("wildcard '*' is not allowed inside a quoted schema identifier")
}
return nil
}
for i, ch := range s {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

validateSchemaPattern is stricter than the validation it replaces, so some previously-valid schema values are now rejected at config parse time.

The old validation path was sanitize.NormalizePostgresIdentifier, which accepts any unicode.IsLetter/unicode.IsDigit character plus _ and . (sanitize.go#L443-L453). This loop only accepts ASCII a-z, A-Z, 0-9, _ and *, so a legal unquoted PostgreSQL schema containing a non-ASCII letter (e.g. schema: crème) now fails startup with invalid schema: invalid character …, where it previously worked. The same applies to ..

Suggest mirroring NormalizePostgresIdentifier's character classes (unicode.IsLetter/unicode.IsDigit) and only adding * on top, so the glob validator is a strict superset of what was accepted before. Otherwise this is a silent breaking change for existing configs (CONTRIBUTING.md §3.1.4 — implementation is complete and correct with no known bugs).

if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '*' {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Regression: non-ASCII schema names that worked before are now rejected at config time.

validateSchemaPattern replaces what NormalizePostgresIdentifier used to do for the schema field, but narrows the accepted character class from Unicode to ASCII. The old path used unicode.IsLetter/unicode.IsDigit:

// First character must be a letter or underscore
if !unicode.IsLetter(rune(name[0])) && name[0] != '_' {
return "", errors.New("identifier must start with a letter or underscore")
}
// Subsequent characters must be letters, numbers, underscores, or dots
for i, char := range name {
if !unicode.IsLetter(char) && !unicode.IsDigit(char) && char != '_' && char != '.' {
return "", fmt.Errorf("invalid character '%c' at position %d in identifier '%s'", char, i, name)
}
}

PostgreSQL accepts non-ASCII letters in unquoted identifiers (folding them to lower case), so a config such as schema: münchen previously normalised fine and streamed. With this change the for i, ch := range s loop hits ü, and newPgStreamInput now fails with invalid schema: invalid character 'ü' at position 1 … — an existing pipeline stops starting after upgrade.

Suggested fix: accept unicode.IsLetter(ch) || unicode.IsDigit(ch) || ch == '_' || ch == '*' (and use unicode.IsLetter in the first-character check below) so the pattern validator is a superset of NormalizePostgresIdentifier plus *, rather than a subset of it. Worth a table case for a non-ASCII schema name too.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Schema validation is stricter than the identifier rules it replaces.

validateSchemaPattern takes over from the sanitize.NormalizePostgresIdentifier(config.DBSchema) call that NewPgStream used to make for this field, but its character class is ASCII-only (a-z, A-Z, 0-9, _, *), whereas NormalizePostgresIdentifier accepts any unicode.IsLetter/unicode.IsDigit rune:

// First character must be a letter or underscore
if !unicode.IsLetter(rune(name[0])) && name[0] != '_' {
return "", errors.New("identifier must start with a letter or underscore")
}
// Subsequent characters must be letters, numbers, underscores, or dots
for i, char := range name {
if !unicode.IsLetter(char) && !unicode.IsDigit(char) && char != '_' && char != '.' {
return "", fmt.Errorf("invalid character '%c' at position %d in identifier '%s'", char, i, name)
}
}

So a config that works today with an unquoted non-ASCII schema name — e.g. schema: münchen, a legal unquoted Postgres identifier that NormalizePostgresIdentifier folds and quotes fine — now fails at config-parse time with invalid schema: invalid character 'ü' at position 1. The same substitution also drops the MaxIdentifierLength (63 char) check that NormalizePostgresIdentifier enforced for this field.

Suggested fix: use unicode.IsLetter/unicode.IsDigit (plus _ and *) here and keep a length check — or strip the * wildcards and delegate the remainder to NormalizePostgresIdentifier so the two paths can't drift again.

Ref: CONTRIBUTING.md §3.1.4 (implementation complete and correct, no known bugs).

continue
}
return fmt.Errorf("invalid character %q at position %d in schema pattern %q", ch, i, s)
}
Comment on lines +422 to +427

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Narrows the accepted schema-name character set, breaking existing configs

This whitelist is ASCII-only, but the validation it replaces used unicode.IsLetter — see sanitize.NormalizePostgresIdentifier L443-L453, which was the only schema validation before this PR (NewPgStream normalised config.DBSchema with it).

So a pipeline that today runs with an unquoted non-ASCII schema name (PostgreSQL permits these — e.g. schema: café, schema: ünternehmen) now fails at startup with invalid character 'é' at position 3 in schema pattern "café". Existing quoted-identifier configs are unaffected, only unquoted ones.

Suggested fix: keep the wildcard/* handling but use unicode.IsLetter/unicode.IsDigit for the character class so the accepted set is a superset of what the previous validation allowed.

first := rune(s[0])
if first != '_' && first != '*' && (first < 'a' || first > 'z') && (first < 'A' || first > 'Z') {
return fmt.Errorf("schema pattern %q must start with a letter, underscore, or '*'", s)
}
Comment on lines +422 to +431

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Regression: unquoted schema names containing non-ASCII letters are now rejected at startup.

This validator only accepts [a-zA-Z0-9_*], but the code path it replaces — sanitize.NormalizePostgresIdentifier, previously called on config.DBSchema in NewPgStream — accepts any unicode.IsLetter/unicode.IsDigit rune (plus .): see sanitize.go#L443-L457.

Failure scenario: an existing pipeline with schema: münchen (a legal unquoted PostgreSQL identifier that previously normalised to "münchen" and worked) now fails config construction with invalid schema: invalid character 'ü' at position 1 in schema pattern "münchen". The user has no way to know quoting is the workaround.

Suggested fix: mirror NormalizePostgresIdentifier's character classes here (unicode.IsLetter/unicode.IsDigit, plus _ and *) so the pattern validator is a superset of what was previously accepted, rather than a stricter ASCII-only rule.

return nil
}

// validateSimpleString ensures we aren't vuln to SQL injection.
func validateSimpleString(s string) error {
for _, b := range []byte(s) {
Expand Down Expand Up @@ -535,6 +586,7 @@ func (p *pgStreamInput) processStream(pgStream *pglogicalstream.Stream, batcher
}
batchMsg := service.NewMessage(mb)
batchMsg.MetaSet("table", msg.Table)
batchMsg.MetaSet("database_schema", msg.Schema)
batchMsg.MetaSet("operation", string(msg.Operation))
if msg.LSN != nil {
batchMsg.MetaSet("lsn", *msg.LSN)
Expand Down
72 changes: 72 additions & 0 deletions internal/impl/postgresql/input_pg_stream_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Copyright 2024 Redpanda Data, Inc.
//
// Licensed as a Redpanda Enterprise file under the Redpanda Community
// License (the "License"); you may not use this file except in compliance with
// the License. You may obtain a copy of the License at
//
// https://github.com/redpanda-data/connect/v4/blob/main/licenses/rcl.md

package pgstream

import (
"fmt"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/redpanda-data/benthos/v4/public/service"

"github.com/redpanda-data/connect/v4/internal/license"
)

// TestSchemaPatternValidation verifies that the schema field is validated
// during config parsing, before any network I/O is attempted. Success is
// asserted via newPgStreamInput returning no error - the constructor doesn't
// dial the database, so a valid pattern implies validation passed.
func TestSchemaPatternValidation(t *testing.T) {
tests := []struct {
pattern string
errContains string
}{
{"public", ""},
{"tenant_*", ""},
{"*", ""},
{`"MySchema"`, ""},
// Regression test: len("") == 2 used to pass the old `len(s) < 2` guard.
// Fixed to `len(s) < 3`.
{`""`, "invalid quoted schema identifier"},
{"1abc", "must start with a letter"},
{`"unclosed`, "invalid quoted schema identifier"},
{"schema-name", "invalid character"},
{"", "schema cannot be empty"},
{`"quoted*"`, "wildcard"},
}
for _, tt := range tests {
t.Run(tt.pattern, func(t *testing.T) {
// Single-quoted so the pattern (which may itself contain double
// quotes, e.g. `"MySchema"`) reaches validateSchemaPattern verbatim.
yaml := fmt.Sprintf(`
dsn: postgres://testuser:testpass@localhost:5432/testdb?sslmode=disable
schema: '%s'
slot_name: test_slot
tables:
- events
`, tt.pattern)

conf, err := newPostgresCDCConfig().ParseYAML(yaml, nil)
require.NoError(t, err)

mgr := service.MockResources()
license.InjectTestService(mgr)

_, err = newPgStreamInput(conf, mgr)
if tt.errContains != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.errContains)
return
}
require.NoError(t, err)
})
}
}
Loading