Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,11 @@ VALUES
`text[]`, and `lsn`, `xid`, `commit_ts`, `is_deleted` for metadata column names.
See [Query destination data](destination-tables.md)

`config_namespace` carries namespace defaults: `target_database`, `auto_create`,
`auto_create_name` (the derived-name template, see
[Name auto-created tables](destination-tables.md#name-auto-created-tables)) and
`drop_table_strategy`

Use `config_column` to configure individual columns. Set `namespace` to schema
name, `relname` to table name, and `attname` to column name. Use `match` for
name patterns and `target_type` to choose a ClickHouse type. Set `nan`,
Expand Down
44 changes: 41 additions & 3 deletions docs/destination-tables.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,48 @@

walshadow creates ClickHouse tables in configured `[ch] database` unless a
namespace or table rule chooses another database. Destination table name
defaults to source relation name
defaults to `<source namespace>_<source relation>`, so `public.orders` lands as
`public_orders` and `audit.orders` as `audit_orders` — same-named tables from
different PostgreSQL schemas never collide

Map same-named tables from different PostgreSQL schemas to distinct ClickHouse
tables or databases to avoid collisions
## Name auto-created tables

`auto_create_name` sets the derived name per namespace as a template over
`$database$` (source database), `$schema$` (source namespace) and `$table$`
(source relation). Default is `$schema$_$table$`

```toml
[namespace.audit]
auto_create = true
auto_create_name = "$database$_$schema$_$table$" # audit.orders -> app_audit_orders

[namespace.shop]
auto_create = true
auto_create_name = "$table$_v2" # shop.orders -> orders_v2

[namespace.public]
auto_create = true
auto_create_name = "$table$" # public.orders -> orders
```

A template naming no `$table$` is read as a prefix, so `wh_` means
`wh_$table$` and `""` means `$table$`. Write `$$` for a literal `$`. An unknown
placeholder or an unterminated `$` is a config error, and in the
`config_namespace` overlay it is rejected with a warning, leaving the previous
value in place

| `auto_create_name` | `app`, `audit.orders` becomes |
|---|---|
| unset | `audit_orders` |
| `$schema$_` | `audit_orders` |
| `$database$_$schema$_$table$` | `app_audit_orders` |
| `$table$_v2` | `orders_v2` |
| `wh_` | `wh_orders` |
| `""` or `$table$` | `orders` |

`target_table` names a destination outright and ignores the template. The
template applies when the table is created, so changing it leaves existing
destination tables where they are and starts writing to the new name

## Generated shape

Expand Down
2 changes: 1 addition & 1 deletion docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ Insert or update one selected source row, then query matching ClickHouse table
```bash
clickhouse-client --host ch.internal --database cdc \
--user default --password secret --query \
"SELECT * FROM users FINAL WHERE _is_deleted = 0 ORDER BY id"
"SELECT * FROM public_users FINAL WHERE _is_deleted = 0 ORDER BY id"
```

`FINAL` resolves versions written during updates or replay. `_is_deleted = 0`
Expand Down
11 changes: 6 additions & 5 deletions docs/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,12 @@ and workload limits before attaching
- date and timestamp values are written without checking ClickHouse calendar
limits. Values outside supported ranges can return incorrect dates or times
without stopping replication
- same-named tables from different PostgreSQL schemas or databases need
explicit destination mapping. Config parsing rejects conflicts between
explicit entries; `replicate_all` names destinations after source tables, so
the second table to claim one destination is refused with an error and left
unreplicated until it names its own `target_database` or `target_table`
- same-named tables from different PostgreSQL databases share one destination
and merge their rows into it, silently. Derived names carry `$schema$`, so
schemas do not collide, but databases do. Separate them with
`target_database`, `target_table`, or an `auto_create_name` carrying
`$database$`, see
[Choose shared or separate destinations](multi-database.md)
- `base_backup` and `object_store` table loads publish with staging-table swap, database must support `EXCHANGE TABLES`
- backup rows inserted into staging do not fire destination materialized views, live rows copied back after swap can fire twice

Expand Down
30 changes: 18 additions & 12 deletions docs/multi-database.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,27 +34,33 @@ the key level decides which one a name means
Startup checks reject database names missing from `pg_database`, so a typo
causes an error instead of replicating a database nobody asked for

## Use separate destinations
## Choose shared or separate destinations

Tables with matching names in different source databases use the same
ClickHouse table by default. Parsing rejects this conflict:
Tables with matching names in different source databases land in the same
ClickHouse table, merging their rows there. Derived names carry `$schema$`, so
same-named tables in different *schemas* already differ

```text
`database.billing.table.public.orders` and `table.public.orders` both write to
cdc.orders, set a different target_database or target_table
```
Merging is allowed, not an error. It suits shards of one logical table, where
every source has the same shape. Rows from different sources interleave by
`_lsn`, and because `ReplacingMergeTree` keys on the sort key, two sources
writing the same key values collapse onto each other — give them disjoint keys
or separate destinations

Set separate destinations for each database, schema, or table:
Separate per database, schema, or table:

```toml
[database.billing.namespace.public]
target_database = "billing_cdc"
```

Tables with matching names in different schemas need the same treatment, see
[limitations](limitations.md). Config checks cover explicit entries;
destinations `replicate_all` derives are claimed as each table is first seen,
and a second claim on one destination is refused
Or keep one ClickHouse database and separate the names, since `auto_create_name`
reads `$database$`:

```toml
[namespace.public]
auto_create = true
auto_create_name = "$database$_$schema$_$table$"
```

## Run `ctl` against one database

Expand Down
2 changes: 1 addition & 1 deletion docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ docker compose -f docker/docker-compose.yml \
docker compose -f docker/docker-compose.yml \
-f docker/docker-compose.quickstart.yml exec clickhouse \
clickhouse-client --query \
"SELECT id, name, email FROM walshadow.users FINAL ORDER BY id"
"SELECT id, name, email FROM walshadow.public_users FINAL ORDER BY id"
```

Expected result includes updated address:
Expand Down
4 changes: 4 additions & 0 deletions sql/runtime_config_install.sql
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ CREATE TABLE IF NOT EXISTS :"walshadow_schema".config_namespace (
namespace text PRIMARY KEY,
target_database text,
auto_create boolean,
auto_create_name text, -- derived table name template over
-- $database$/$schema$/$table$; NULL
-- means '$schema$_$table$'
drop_table_strategy text -- overrides config_global for this namespace
);

Expand Down Expand Up @@ -79,6 +82,7 @@ CREATE TABLE IF NOT EXISTS :"walshadow_schema".config_column (
-- Additive upgrade: columns introduced after the initial config_table shape.
-- CREATE TABLE IF NOT EXISTS above no-ops on an existing install, so an
-- upgrading deployment re-runs this to gain the columns the newer daemon reads.
ALTER TABLE :"walshadow_schema".config_namespace ADD COLUMN IF NOT EXISTS auto_create_name text;
ALTER TABLE :"walshadow_schema".config_table ADD COLUMN IF NOT EXISTS replicate boolean;
ALTER TABLE :"walshadow_schema".config_table ADD COLUMN IF NOT EXISTS initial_load text;
ALTER TABLE :"walshadow_schema".config_table ADD COLUMN IF NOT EXISTS target_database text;
Expand Down
1 change: 1 addition & 0 deletions src/bin/stream/bootstrap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1233,6 +1233,7 @@ pub(crate) async fn bootstrap_build_mapping(
walshadow::ch_ddl::DdlConfig::from_resolved(
&snap,
emitter_cfg.database.clone(),
emitter_cfg.source.dbname.clone(),
emitter_cfg.soft_delete,
emitter_cfg.system_columns.clone(),
emitter_cfg.replicate_all,
Expand Down
5 changes: 3 additions & 2 deletions src/bin/stream/runtime_cfg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,8 @@ pub(crate) async fn load_runtime_config(
for row in client
.query(
&format!(
"SELECT namespace, target_database, auto_create, drop_table_strategy \
FROM {s}.config_namespace"
"SELECT namespace, target_database, auto_create, auto_create_name, \
drop_table_strategy FROM {s}.config_namespace"
),
&[],
)
Expand All @@ -136,6 +136,7 @@ pub(crate) async fn load_runtime_config(
NamespaceRow {
target_database: row.get("target_database"),
auto_create: row.get("auto_create"),
auto_create_name: row.get("auto_create_name"),
drop_table_strategy: row.get("drop_table_strategy"),
},
);
Expand Down
4 changes: 0 additions & 4 deletions src/bin/stream/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -854,15 +854,11 @@ pub(crate) async fn run_session(
let mut applicators: HashMap<Oid, walshadow::ch_ddl::DdlApplicator> = HashMap::new();
let mut backfillers: HashMap<Oid, Arc<dyn walshadow::opt_in::Backfiller>> = HashMap::new();
let mut resolvers: Vec<Arc<ConfigResolver>> = Vec::with_capacity(db_conns.len());
// One claim per destination across every database, so `replicate_all`
// cannot name one ClickHouse table from two source tables
let targets = Arc::new(walshadow::mapping::TargetOwners::default());
for (i, conn) in db_conns.iter().enumerate() {
let built = build_source_db(SourceDbInputs {
args,
conn,
primary: i == primary_index,
targets: &targets,
desc_log: &desc_logs[i],
spill_dir: if i == primary_index {
args.spill_dir.clone()
Expand Down
5 changes: 2 additions & 3 deletions src/bin/stream/source_db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ pub(crate) struct SourceDbInputs<'a> {
/// `[source] dbname`: the one database a cluster backup can load
pub(crate) primary: bool,
/// Destinations every followed database has claimed
pub(crate) targets: &'a Arc<walshadow::mapping::TargetOwners>,
pub(crate) desc_log: &'a Arc<walshadow::desc_log::DescriptorLog>,
/// Backfill ledger lives here, beside this database's descriptor log
pub(crate) spill_dir: PathBuf,
Expand Down Expand Up @@ -138,6 +137,7 @@ pub(crate) async fn build_source_db(input: SourceDbInputs<'_>) -> anyhow::Result
let ddl_cfg = walshadow::ch_ddl::DdlConfig::from_resolved(
&config_rx.borrow(),
cfg.database.clone(),
cfg.source.dbname.clone(),
cfg.soft_delete,
cfg.system_columns.clone(),
cfg.replicate_all,
Expand All @@ -153,8 +153,7 @@ pub(crate) async fn build_source_db(input: SourceDbInputs<'_>) -> anyhow::Result
.await
.context("init DDL applicator")?
.with_resolver(resolver.clone())
.with_oracle(input.oracle.clone())
.with_target_owners(conn.oid, input.targets.clone());
.with_oracle(input.oracle.clone());
// Backfiller for `initial_load` opt-ins (COPY / backup-sourced):
// own source session + CH tail per backfill or pass, spill-dir
// ledger dedups restarts. Wired whenever the emitter runs, since an
Expand Down
Loading
Loading