Skip to content

Add --dump-schema option to clickhouse-client and clickhouse-local - #114098

Open
valerypetrov wants to merge 65 commits into
ClickHouse:masterfrom
valerypetrov:dump-database-schema
Open

Add --dump-schema option to clickhouse-client and clickhouse-local#114098
valerypetrov wants to merge 65 commits into
ClickHouse:masterfrom
valerypetrov:dump-database-schema

Conversation

@valerypetrov

@valerypetrov valerypetrov commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Closes: #60681

Adds a --dump-schema[=<db1,db2,...>] option that dumps CREATE DATABASE/CREATE TABLE/CREATE DICTIONARY/CREATE MATERIALIZED VIEW/CREATE VIEW statements for one or more databases, or all non-system user databases if no value is given, to stdout, then exits — similar to pg_dump --schema-only.

Tables are ordered (via TablesDependencyGraph, the same class DatabaseCatalog uses to load tables at startup and backups use to order their restore; ties within a level broken by (database, name) for determinism) using every dependency signal system.tables exposes, combined: loading_dependencies_* (dictionaries), inverted dependencies_* (a materialized view's source — recorded only on the source row, not the view), target_database/target_table (a materialized view's TO target, explicit or implicit), and — since the server tracks none of this for plain views at all (DROP TABLE on a view's source succeeds even with the view still referencing it) — a best-effort scan of each view's stored, server-formatted SELECT text for other dumped tables' names. A genuine dependency cycle is a clear error rather than being silently reordered.

This is entirely a client-side feature: system.tables.create_table_query and SHOW CREATE DATABASE already give the server-computed DDL text, so no server-side or wire-protocol changes are needed — the new code (src/Client/SchemaDumper.{h,cpp}) just issues a couple of queries over the existing connection (same pattern as Suggest.cpp) and prints the results in dependency order.

--dump-schema also accepts a comma-separated database list (--dump-schema=db1,db2), and two modifiers:

  • --dump-schema-exclude=db1,db2 dumps all user databases except the listed ones (rejected if combined with an explicit database list — the two are mutually exclusive ways of selecting the same thing).
  • --dump-schema-dir=<path> writes one <database>.sql file per dumped database into that directory (created if it doesn't exist, e.g. mkdir clickhouse-lab && clickhouse-client --dump-schema --dump-schema-exclude=... --dump-schema-dir=clickhouse-lab) instead of writing everything to stdout. Database names are escaped with escapeForFileName before being used as filenames; two target databases whose escaped names collide case-insensitively are rejected unconditionally (regardless of the destination filesystem's actual case sensitivity — reliably probing that portably isn't worth it when the point of a dump is to be replayed somewhere else later anyway), rather than one silently overwriting the other's file. Per-database write failures are detected instead of reported as success, and when a table in one dumped database depends on a table in another, the files (and their confirmation lines) are ordered — and a note printed — so replaying them in the printed order works; a database-level circular dependency (distinct from, and not implied by, a table-level cycle) is rejected with a clear error since no file order could satisfy it.

A few things worth calling out for review:

  • Implicit materialized-view storage tables are excluded from the dump (recreated automatically by their owning CREATE MATERIALIZED VIEW statement) — but only when confirmed to be a live materialized view's own target with the generated-name shape (.inner_id.*/.inner.*), not merely name-shaped like one, so a real user table literally named `.inner.foo` stays in the dump. Orphaned mid-refresh leftovers (.tmp.inner_id.*/.tmp.inner.*) are filtered by name, since they're never any live target.
  • --dump-schema forces non-interactive mode (like --query/--queries-file) and is rejected if combined with either of them, so its stdout is never mixed with an interactive banner, warnings, or another query's output. For clickhouse-local, it's also rejected combined with --file/--structure. --dump-schema-exclude/--dump-schema-dir are rejected if --dump-schema itself isn't given — all validated in processConfig(), before connect(), so a plain argument mistake fails fast instead of after a connection attempt (which could otherwise block on an interactive password prompt).
  • Verified end-to-end via real replays into a fresh clickhouse-local instance: a source table with a materialized view (implicit storage), a dictionary, a plain view, a view-on-view chain, and an explicit TO target, named so that alphabetical order alone would produce an unreplayable dump without the fix; single/list/exclude/all-databases selection; --dump-schema-dir output with a genuine cross-database dependency; the case-insensitive filename collision guard (using a case-sensitive APFS volume to stand in for a Linux source, confirming both that Foo/foo coexist there and that the guard fires when dumping them); and all the rejection cases (unknown database, combined with --query/--file/--structure, list + exclude together, dir without --dump-schema).

Changelog category (leave one):

  • New Feature

Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):

Added a --dump-schema[=<db1,db2,...>] option to clickhouse-client and clickhouse-local that dumps CREATE DATABASE/CREATE TABLE/CREATE DICTIONARY/CREATE MATERIALIZED VIEW/CREATE VIEW statements for the given database(s), or all user databases if no value is given, to stdout in dependency order, then exits — similar to pg_dump --schema-only. --dump-schema-exclude=<db1,db2,...> skips the listed databases when dumping all of them, and --dump-schema-dir=<path> writes one <database>.sql file per dumped database into that directory instead of stdout.


Workflow [PR]
Sync PR [sync-upstream/pr/114098]

valerypetrov and others added 2 commits August 9, 2026 20:19
Adds a `--dump-schema[=<database>]` flag that dumps `CREATE DATABASE`/
`CREATE TABLE`/`CREATE DICTIONARY`/`CREATE MATERIALIZED VIEW` statements for
one database, or all non-system user databases if no value is given, to
stdout, then exits. Tables are ordered so a table is only printed after the
tables/views/dictionaries it depends on, using the existing
`system.tables.loading_dependencies_*` columns.

This is a client-only feature: it reuses `create_table_query` and
`SHOW CREATE DATABASE`, which the server already computes, so no server-side
or wire-protocol changes are needed. Implicit materialized-view inner storage
tables (`.inner_id.*`/`.inner.*`, plus `.tmp.` variants left over from an
interrupted refresh) are excluded, since they are recreated automatically by
their owning `CREATE MATERIALIZED VIEW` statement. A genuine loading-dependency
cycle is reported as an error rather than silently reordered.

Closes: ClickHouse#60681

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…hema

Extends --dump-schema with:
- A comma-separated database list (`--dump-schema=db1,db2`), instead of just
  a single database or "all".
- `--dump-schema-exclude=db1,db2` to dump all user databases except the
  listed ones (mutually exclusive with an explicit database list).
- `--dump-schema-dir=<path>` to write one `<database>.sql` file per dumped
  database into that directory (created if missing) instead of stdout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@valerypetrov

Copy link
Copy Markdown
Contributor Author

@groeneai, please check this one. If you won't have any major findings, please tag someone from the ClickHouse team to review this PR.

…idation

Fixes found during review of the --dump-schema-dir addition:
- Escape database names with escapeForFileName before using them as
  filenames under --dump-schema-dir, and reject the dump outright if two
  target databases would collide on a case-insensitive filesystem, instead
  of one silently overwriting the other's file.
- Detect and surface write failures (e.g. disk full) on the per-database
  files instead of reporting success unconditionally.
- Order the --dump-schema-dir files (and their confirmation lines) by
  cross-database table dependency, not alphabetically, and print a note
  when that ordering matters; a database-level dependency cycle is now a
  clear error instead of producing files that can't be replayed correctly
  in any order.
- Move the "--dump-schema-exclude/--dump-schema-dir require --dump-schema"
  and (clickhouse-local only) "--dump-schema conflicts with --file/
  --structure" checks into processConfig(), before connect() is attempted,
  matching how the existing --query/--queries-file conflict check already
  works -- these are pure argument errors and shouldn't require a
  connection (or an interactive password prompt) to fail on.
- Replace the hand-rolled topological sort in orderTablesByDependencies
  with TablesDependencyGraph, the same dependency-ordering class
  DatabaseCatalog uses to load tables at startup and backups use to order
  their restore, keeping the deterministic (database, name) tie-break
  within each dependency level.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@groeneai

Copy link
Copy Markdown
Collaborator

Reviewed. One blocker, then some smaller notes.

Blocker: views and materialized views are not dependency-ordered, so the dump does not replay

orderTablesByDependencies orders by system.tables.loading_dependencies_*, but that column is empty for View and MaterializedView. The view-to-source edge is only recorded in the reverse direction, on the source table's dependencies_table. So every view lands in dependency level 0 alongside its own source table, and the (database, name) tie-break decides the output order. Whenever a view sorts before its source table by name, the dump is emitted in an unreplayable order.

Reproduced on master (26.8.1.1), realistic names, no cycle involved:

CREATE DATABASE analytics;
CREATE TABLE analytics.raw_events (ts DateTime, uid UInt64) ENGINE = MergeTree ORDER BY ts;
CREATE MATERIALIZED VIEW analytics.daily_agg (day Date, cnt UInt64) ENGINE = SummingMergeTree ORDER BY day
    AS SELECT toDate(ts) AS day, count() AS cnt FROM analytics.raw_events GROUP BY day;

loading_dependencies_table is [] for both daily_agg and raw_events; raw_events.dependencies_table is ['daily_agg']. d sorts before r, so the dump emits CREATE MATERIALIZED VIEW analytics.daily_agg first, and replaying it into a fresh instance gives:

Code: 60. DB::Exception: Unknown table expression identifier 'analytics.raw_events'
in scope SELECT toDate(ts) AS day, count() AS cnt FROM analytics.raw_events GROUP BY day. (UNKNOWN_TABLE)

Same failure for a plain CREATE VIEW whose source sorts after it. Ordering base tables before views fixes it: I replayed a fixture with an inner-table MV, a TO MV and a plain view in that order and it applied cleanly, with SELECT returning correct rows through all three.

Two things fall out of this:

  • The TO target is not covered by either dependency column. For CREATE MATERIALIZED VIEW ... TO tgt, both loading_dependencies_table and dependencies_table are empty on the view and on tgt, so nothing orders the view after its target, and replaying the view first fails with Table d.z_target does not exist. system.tables.target_database/target_table do carry it, so that edge is available.
  • The test passes by name luck, not by dependency tracking. In 04836, the fixture is base, mv, dict. mv has empty loading_dependencies_table, so it sits in level 0 with base, and base happens to sort first alphabetically, which satisfies the base_line -lt mv_line assertion. Rename base to z_base (or the MV to a_mv) and the assertion fails. dict is the only table in the fixture whose ordering is actually exercised, since a dictionary is the one object type here that does populate loading_dependencies_*. Worth adding a case where the view sorts before its source.

Smaller notes

  • A user table literally named .inner.foo is silently dropped from the dump. CREATE TABLE d.\.inner.mytable` (...)succeeds on master, and theNOT startsWith(name, '.inner.')filter then excludes it, so the dump is quietly incomplete rather than wrong. Matching the implicit-storage naming more precisely (the real inner tables are.inner_id., and .inner.` only for the pre-UUID form) would narrow it. Low priority, but it is a silent omission.
  • The test only ever invokes $CLICKHOUSE_LOCAL, never $CLICKHOUSE_CLIENT, so the programs/client/Client.cpp path and its processConfig() validation are untested. The two entry points have separately written validation blocks, so a divergence between them would not be caught.
  • On the case-insensitive-filesystem guard you flagged: escapeForFileName percent-escapes every non-word-character byte but leaves ASCII letters as-is, so db and DB do collide after escaping and the guard is doing real work. It reads correct to me.

Given the blocker I have not tagged a reviewer yet. Ping me once the ordering is fixed and I will route it.

…ncies

loading_dependencies_* (the only signal the dump previously used) is empty
for View and MaterializedView, so every view landed in dependency level 0
alongside its own source table and the (database, name) tie-break decided
the order -- whenever a view sorted before its source by name, the dump
replayed in the wrong order and failed.

Fixes it by combining every dependency signal system.tables exposes:
loading_dependencies_* (dictionaries), dependencies_* inverted (a
materialized view's source, which the server only records on the source
row), target_database/target_table (a materialized view's TO target,
explicit or implicit), and -- since the server tracks none of this for
plain views -- a best-effort scan of each view's stored SELECT text for
other dumped tables' names.

Also narrows the implicit-inner-table filter: a table is now only excluded
when it's confirmed to be a live materialized view's own generated-name
target, not merely name-shaped like one, so a real user table literally
named `.inner.foo` is no longer silently dropped from the dump.

Strengthens 04836_client_dump_schema.sh accordingly: adversarial naming so
the ordering assertion can't pass by alphabetical luck, coverage for plain
views, view-on-view chains, and explicit TO targets (each round-tripped
through a real replay), the `.inner.foo` case, and a clickhouse-client
invocation alongside the existing clickhouse-local ones.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@valerypetrov

Copy link
Copy Markdown
Contributor Author

@groeneai Fixed, thanks for the very thorough repro — I reproduced your exact case locally (analytics.raw_events/analytics.daily_agg) and confirmed both the failure before the fix and a clean replay after it.

Root cause was exactly what you found: loading_dependencies_* is empty for View/MaterializedView, so both landed in dependency level 0. The fix combines every dependency signal system.tables exposes — loading_dependencies_* (dictionaries), inverted dependencies_* (a materialized view's source, recorded only on the source row), target_database/target_table (the TO target you flagged as uncovered, explicit or implicit), and — since the server doesn't track this for plain views at all (confirmed: DROP TABLE on a view's source succeeds even with the view still referencing it) — a best-effort scan of each view's stored SELECT text for other dumped tables' names.

Also fixed the .inner.foo false-positive you flagged: a table is now only excluded when it's confirmed to be a live materialized view's own target with the generated-name shape, not merely name-shaped like one.

Test (04836_client_dump_schema.sh) is substantially strengthened: renamed the fixture so alphabetical order can't accidentally pass the assertion, added plain views, a view-on-view chain, an explicit TO target, the .inner.foo case, and a clickhouse-client invocation alongside the existing clickhouse-local ones (your test-coverage note). Each new case is round-tripped through an actual replay, not just checked for line order.

One thing I want to flag rather than claim silently: for plain-view-on-plain-view (and view-on-table) ordering, there's no server-tracked signal at all, so I'm relying on a text scan of the resolved, server-formatted SELECT (which is reliably fully-qualified even when the CREATE VIEW wasn't). It's reliable for straightforward FROM/JOIN references but can in principle miss one hidden behind a subquery, CTE, or table function — happy to discuss if you'd rather that residual case fail loudly instead of best-effort.

@valerypetrov

valerypetrov commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

Correction to my earlier caveat: I did reproduce the case-collision guard — mounted a case-sensitive APFS volume to stand in for a Linux source, created Foo/foo there, dumped with --dump-schema-dir:

Code: 36. DB::Exception: Cannot dump to `--dump-schema-dir`: databases Foo, foo would all be written to the same file on a case-insensitive filesystem. (BAD_ARGUMENTS)

Note: the guard isn't destination-aware — it fires unconditionally regardless of the actual target filesystem, by design (portably detecting case-sensitivity isn't worth it for a dump meant to be replayed elsewhere anyway).

@groeneai

Copy link
Copy Markdown
Collaborator

Answering your question directly: I would not pick either contract, because the text scan does not only under-collect. It also over-collects, and that direction is worse: a spurious edge fabricates a dependency cycle, and checkNoCyclicDependencies() then aborts the entire dump with INFINITE_LOOP for a schema that has no cycle at all. So the current behaviour is not "graceful in the miss direction"; it can refuse to dump a perfectly ordinary database.

Two distinct false-positive classes, both reproduced on master (26.8.1.1) by running the predicate at SchemaDumper.cpp:262 against real server-produced as_select text.

1. Substring, not identifier, matching. find(qualified_name) matches inside a longer identifier, so any table whose qualified name is a prefix of another's yields a bogus edge. Ordinary names are enough:

CREATE TABLE analytics.metrics_source    (a UInt64) ENGINE=MergeTree ORDER BY a;
CREATE TABLE analytics.metrics_daily_raw (a UInt64) ENGINE=MergeTree ORDER BY a;
CREATE VIEW  analytics.metrics       AS SELECT * FROM analytics.metrics_daily_raw;
CREATE VIEW  analytics.metrics_daily AS SELECT * FROM analytics.metrics_source;

metrics matches analytics.metrics_daily inside its own SELECT, and metrics_daily matches analytics.metrics inside its own SELECT, so the two views become mutually dependent and the dump fails. Neither view references the other. Confirmed against the server's own text rather than a reimplementation:

SELECT v.name, o.name FROM system.tables v CROSS JOIN system.tables o
WHERE v.engine='View' AND NOT (v.name=o.name)
  AND position(v.as_select, concat(o.database,'.',o.name)) > 0

metrics         metrics_daily        <-- bogus
metrics         metrics_daily_raw
metrics_daily   metrics              <-- bogus
metrics_daily   metrics_source

2. String literals. A qualified name inside a literal matches too, and this class survives any word-boundary tightening, so tightening the match is not a fix:

CREATE VIEW d.audit_orders AS SELECT a, 'd.audit_users'  AS src FROM d.orders;
CREATE VIEW d.audit_users  AS SELECT a, 'd.audit_orders' AS src FROM d.users;

Same outcome: fake mutual dependency, whole dump refused.

Suggested direction, which removes your question rather than answering it: collect referenced tables from the parsed AST instead of the text. ClientBase.cpp already includes the parsers, and an ASTTableIdentifier walk resolves exactly the cases the scan cannot see. Verified with EXPLAIN AST:

SELECT * FROM (SELECT * FROM d.base2)             -> TableIdentifier d.base2
WITH c AS (SELECT * FROM d.base2) SELECT * FROM c -> TableIdentifier d.base2
SELECT a, 'd.audit_users' AS src FROM d.orders    -> Literal, not an identifier

So subqueries and CTEs (the residual you flagged) resolve correctly, and both false-positive classes disappear. Better still, getDependenciesFromCreateQuery in src/Databases/DDLDependencyVisitor.h is the extractor the server itself uses for this purpose, and it also covers table functions and dictGet; it takes a ContextPtr, so if that is awkward for a client talking to a remote server, a local identifier walk is the fallback. One caveat if you hand-roll it: a CTE alias also parses as a TableIdentifier (WITH orders AS (SELECT 1) SELECT * FROM orders gives TableIdentifier orders), so skip names bound by an enclosing WITH, or you reintroduce a spurious edge.

Smaller notes:

  • The test cannot catch either class today: every fixture name is aaa_*/zzz_*, so no qualified name is a substring of another, and there is no literal case. A prefix-collision pair like the one above is worth adding, since the suite currently passes with this present.
  • Same shape as the .inner. case you fixed: .tmp.inner. is still filtered by name at SchemaDumper.cpp:189, and a user table named .tmp.inner.x is creatable on master (verified), so it would be silently dropped from the dump.

Thanks for the ordering and .inner.foo fixes, and for confirming the case-collision guard. Since this one blocks a plain dump, I am holding off on tagging a ClickHouse team reviewer again; ping me once it is resolved and I will route it.

The substring scan added for plain View dependency detection could
fabricate dependency edges two ways: a qualified name that's a textual
prefix of another table's name (e.g. `analytics.metrics_daily` inside
`analytics.metrics_daily_raw`), and a qualified name appearing inside a
string literal rather than an actual table reference. Either can create a
false mutual dependency between two views that don't reference each other,
which made the whole dump abort with a fabricated INFINITE_LOOP cycle error
for an entirely valid schema.

Replaces the substring scan with a proper parse: `as_select` is parsed with
`ParserSelectWithUnionQuery` (the same grammar the server used to produce
it), and every `ASTTableIdentifier` in the resulting tree is collected and
matched against the known table set by (database, name) equality rather
than text search. A CTE alias reference stays unqualified in server-
formatted SQL, so filtering to identifiers with a non-empty database part
is enough to exclude them without separately tracking WITH-bound names.

Also fixes the same false-exclusion class the `.inner.foo` fix addressed
for `.tmp.inner_id.*`/`.tmp.inner.*`: those are now only treated as
implicit mid-refresh storage when they can be traced to a live
materialized view's own uuid/name, so a literal user table sharing that
name prefix is no longer silently dropped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@valerypetrov

Copy link
Copy Markdown
Contributor Author

@groeneai Fixed — you're right that the substring scan was worse than the original bug, and your two repro classes were exactly the right proof. Replaced it with a proper parse: as_select is reparsed with ParserSelectWithUnionQuery (same grammar the server used to produce it), and every ASTTableIdentifier in the resulting tree is matched against the known table set by (database, name) equality instead of text search.

Your CTE-alias caveat turned out to be free: I confirmed empirically that a CTE alias reference stays unqualified in server-formatted SQL (WITH c AS (SELECT * FROM d.base2) SELECT * FROM c stores as_select with FROM c untouched, while d.base2 is fully qualified), so filtering to identifiers with a non-empty database part excludes CTE aliases without needing to track WITH-bound names separately — that alone also kills the string-literal false positive, since a literal parses as ASTLiteral, never ASTTableIdentifier.

Went with the local AST-walk fallback you flagged, not getDependenciesFromCreateQuery — confirmed it takes a ContextPtr, which a remote clickhouse-client connection doesn't have.

Verified against both your exact repros (metrics/metrics_daily_raw prefix collision, audit_orders/audit_users literal collision) directly against the built binary — both now dump cleanly with no INFINITE_LOOP, correct ordering preserved for the genuine dependencies. Also fixed .tmp.inner_id.*/.tmp.inner.* the same way as the .inner.foo case: only excluded when traceable to a live materialized view's own uuid/name via system.tables.uuid, so a literal user table with that prefix stays in the dump.

Test now covers both false-positive classes, a genuine view-on-view/view-on-table chain (round-tripped through actual replay), and the .tmp.inner. literal case.

@groeneai

Copy link
Copy Markdown
Collaborator

The AST walk is the right call, and I verified all three things you claimed. The empty-database-part filter (SchemaDumper.cpp:321) does make the CTE-alias case free. getDependenciesFromCreateQuery does take a ContextPtr (DDLDependencyVisitor.h:26), so the local walk is correct for a remote client. Both of my round-2 false-positive classes are now dead by construction.

One new blocker, same class as round 1.

A materialized view with two or more sources is ordered by only one of them.

The AST scan is gated if (row.engine != "View") continue; (SchemaDumper.cpp:302), so MV rows never reach it, and an MV's ordering rests entirely on the inverted dependents edge. That edge carries at most one source, because the server records mv_from_dependency as a single std::optional<StorageID> (DDLDependencyVisitor.h:18), not a set.

Measured on master 26.8.1.1:

CREATE VIEW d.zzz_view AS SELECT id, b FROM d.base;
CREATE MATERIALIZED VIEW d.aaa_join_mv ENGINE = MergeTree ORDER BY id AS
  SELECT s.id AS id, s.a AS a, v.b AS b FROM d.src AS s INNER JOIN d.zzz_view AS v ON s.id = v.id;

dependencies_table is ['aaa_join_mv'] on d.src only; d.zzz_view gets no edge. MV and view both land in level 1, the (database, name) tie-break emits aaa_join_mv first, and replay fails:

Code: 60. DB::Exception: Unknown table expression identifier 'd.zzz_view' ... (UNKNOWN_TABLE)

Emitting the view first replays cleanly and data flows through the MV, so it is the ordering and not the fixture. Reproduces the same way with UNION ALL instead of the join, and with a TO-target MV.

Two precisions so you do not over-fix, both measured:

  • It is specifically the second and later sources. A single-source MV reading a view does get its edge, so the tracked-source path is fine.
  • It only bites when the untracked source outranks level 0. An MV over two plain tables replays fine, since both sources sort into level 0 anyway. The shape that breaks is: two or more sources, at least one of them a view (or anything else carrying its own dependencies) that sorts after the MV by name.

Fix direction: the scan you already wrote is the right tool, just gated too narrowly. Dropping the engine != "View" gate so MV rows are scanned too gives the MV its missing edge from its own as_select, which is populated for MVs including TO-target ones (I checked that on every shape above). The known_tables and self-reference guards at SchemaDumper.cpp:324 already cover the spurious-edge classes, and the TO-target edge is separate and already correct.

The test cannot catch this: 04836_client_dump_schema.sh has no JOIN and no UNION, and every MV in it reads zzz_source, a plain MergeTree table. Worth adding an MV that joins a table and a view, and one over UNION ALL with a view, each with the second source named so it sorts after the MV, round-tripped through an actual replay. Both should fail against the current head.

Holding the reviewer routing one more round, since your condition was no major findings. Ping me once this is in and I will route it.

The AST-based dependency scan only ran for row.engine == "View", so a
materialized view with two or more sources (e.g. a JOIN or UNION ALL
combining a table and a view) only got an edge to one source: the server
records mv_from_dependency as a single std::optional<StorageID>, not a
set, so the second source went untracked. Whenever that untracked source
sorted after the MV by name, the dump emitted the MV first and replay
failed with UNKNOWN_TABLE.

Widening the scan to also run for MaterializedView rows gives it the
missing edge from the MV's own as_select, which is populated for MVs
including TO-target ones and never mentions the MV's own name or storage,
so it can't conflict with the already-correct target-based edge. A
duplicate edge to the already-tracked source is harmless (collapses in
TablesDependencyGraph's own dependency set).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@valerypetrov

Copy link
Copy Markdown
Contributor Author

@groeneai Fixed — good catch, and the precision on when it does/doesn't bite (second-and-later sources, only when the untracked one outranks level 0) made this trivial to scope correctly.

Went with your exact fix direction: widened the if (row.engine != "View") gate to also let MaterializedView rows through the AST scan. Confirmed empirically before touching anything: as_select for an MV never mentions its own name, implicit storage, or TO target (verified on a TO-target MV too — identical as_select to the non-TO case), so there's no conflict with the already-correct target_database/target_table edge. A duplicate edge to the already-tracked single source is harmless — TablesDependencyGraph::addDependencies collects into an unordered_set before storing.

Verified against your exact JOIN repro (d.src/d.zzz_view/d.aaa_join_mv) directly against the built binary: zzz_view now dumps before aaa_join_mv, replay succeeds. Test now covers both the JOIN and UNION ALL shapes, each with the untracked source named to sort after the MV, round-tripped through an actual insert+select (confirmed the UNION ALL view-sourced branch not firing on insert is a separate, pre-existing ClickHouse MV limitation — only the table-sourced branch is expected to populate — not something this fix touches).

@groeneai

Copy link
Copy Markdown
Collaborator

Round-4 blocker is discharged. I verified the widened gate at SchemaDumper.cpp:302 against my exact JOIN repro and the UNION-with-a-view shape: zzz_view now lands at level 1 and both MVs at level 2, the dump replays with RC=0, and data flows through both MVs. Negative control: restoring the old engine != "View" gate puts both MVs back at level 1 next to zzz_view and replay fails Code: 60 ... Unknown table expression identifier 'd.zzz_view'. Your dedup reasoning is right (TablesDependencyGraph.cpp:114-116 collects into an unordered_set before comparing). I also re-ran both round-2 false-positive classes in their MV form (mutual 'd.mv_users' literals, and a mtr/mtr_daily prefix collision) and neither fabricates a cycle now that MVs go through the same parse.

One more blocker in the same place, though.

collectTableIdentifiers only picks up ASTTableIdentifier, so a reference the grammar puts in a function ARGUMENT is invisible. Two forms, both reproduced end to end for MVs:

  1. dictGet. SELECT id, dictGet('d.zzz_dict','val',id) AS v FROM d.base parses the dictionary as Literal 'd.zzz_dict', never a TableIdentifier. loading_dependencies_table is [] on the MV and the dictionary gets no dependent edge, so MV and dictionary both land at level 1 and the name tie-break emits the MV first. Replay: Code: 36. DB::Exception: Dictionary (d.zzz_dict) not found. (BAD_ARGUMENTS).

  2. IN. SELECT id FROM d.base WHERE id IN d.zzz_inview is stored as ... IN (d.zzz_inview) and parses as a plain Identifier, not a TableIdentifier. Replay: Code: 47 ... Unknown expression or table expression identifier 'd.zzz_inview'. (UNKNOWN_IDENTIFIER).

Negative control for both: emit the missed object first and replay is RC=0 with data flowing (MV_ROWS 7 seven, IN_MV_ROWS 3). So it is the ordering, not the fixture.

Two precisions, since they scope it:

  • It bites materialized views, not plain views. A dumped CREATE VIEW carries an explicit column list, and with one the server accepts a view over a missing dictionary or table (verified, RC=0), so a plain view still replays. An MV validates its SELECT regardless of the column list, which is what makes this one hard-fail.
  • Like the round-4 case, it needs the missed object to sit at level 1 or above and to sort after the MV. For dictGet that is the ordinary case rather than an exotic one: a dictionary whose source table is in the same dump is always at level 1, so only the name decides. Renaming zzz_dict to aaa_dict makes the identical schema replay cleanly.

The test cannot catch either. dictGet appears once in 04836, in a post-replay probe at line 115, never inside a dumped view or MV SELECT; joinGet and IN <table> do not appear at all.

Fix direction, verified only to this depth: the server's own extractor already handles exactly these argument positions in visitFunction (src/Databases/DDLDependencyVisitor.cpp:312-329) -- argument 0 for functionIsDictGet / functionIsJoinGet and for dictionary(...), argument 1 for functionIsInOrGlobalInOperator. Those predicates are header-only inline functions over std::string in src/Interpreters/misc.h and take no ContextPtr, so the local walk can reuse them: when a node is an ASTFunction, read the relevant argument as a string literal (dictGet / joinGet / dictionary) or as an ASTIdentifier (IN), and match it against known_tables exactly as now. joinGet over a Join-engine table is the same shape and worth covering in the same pass.

Holding the reviewer routing one more round, for the same reason as before. Ping me once this is in and I will route it.

collectTableIdentifiers only caught ASTTableIdentifier nodes (FROM/JOIN
position), so a reference the grammar puts in a function argument was
invisible to the dependency scan: dictGet('db.dict', ...), joinGet(...),
the dictionary(db.dict) table function, and WHERE x IN db.table. Each
could leave the referenced object dumped after the view/materialized view
that needs it, breaking replay.

Adds collectFunctionArgumentReferences, which walks the same parsed AST
for ASTFunction nodes matching functionIsDictGet/functionIsJoinGet/
"dictionary" (argument 0) or functionIsInOrGlobalInOperator (argument 1),
and reads that argument as either an identifier or a plain (unescaped)
db.name string literal via QualifiedTableName::tryParseFromString --
mirroring the argument positions DDLDependencyVisitor::visitFunction reads
for the same function families. remote()/cluster() and non-literal
argument evaluation are deliberately excluded: both need a live
Context (to resolve cluster locality, or evaluate a constant expression)
that a client talking to a remote server doesn't have, and extracting
text blindly for remote()/cluster() risks a spurious dependency between
unrelated servers, which is worse than the bug being fixed given a false
cycle aborts the whole dump.

Verified this doesn't need to cover DEFAULT/MATERIALIZED column
expressions (already reachable via loading_dependencies_*, which walks
the full CREATE query, not just the SELECT body) or merge() (resolves its
source lazily at query time, not at CREATE time, so dump order doesn't
affect whether it replays).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@valerypetrov

Copy link
Copy Markdown
Contributor Author

@groeneai Fixed. Went further than the two cases you'd verified — audited DDLDependencyVisitor.cpp's full visitFunction to catch the whole class rather than patch two instances: dictGet/dictHas/dictIsIn/naiveBayesClassifier*/joinGet/joinGetOrNull (arg 0), the dictionary() table function (arg 0, confirmed empirically it's an ASTIdentifier not a literal — different shape from dictGet), and the full IN/GLOBAL IN family including notIn/globalNotIn/etc. (arg 1).

Deliberately excluded remote()/cluster() and non-literal argument evaluation (e.g. dictGet(concat(...), ...)) — both need a live Context this client-side tool doesn't have, and for remote()/cluster() specifically, blindly extracting the argument text risks a spurious dependency between genuinely unrelated servers, which is worse than the original bug since a false cycle aborts the whole dump.

Given the pattern of this review (a new gap in the same mechanism each round), I ran this one through a fuller pipeline before pushing: architect audited the full extractor and verified every case empirically, a dev implementation, then a senior-style review (confirmed no correctness bugs, and — critically — empirically confirmed each new test case actually forces the target and its referencing MV to the same dependency level, so the fix is what's deciding the order, not an unrelated level gap that would pass either way), and an adversarial pass specifically hunting for what a round 5 would find: tested NOT IN/GLOBAL NOT IN, a UNION ALL mixing a dictGet branch with a plain FROM branch, DEFAULT-column expressions referencing dictGet (confirmed already covered — loading_dependencies_* walks the full CREATE query, not just as_select), and merge()/view() table functions (merge() isn't tracked, but confirmed it resolves lazily at query time rather than CREATE time, so dump order doesn't affect whether it replays — consistent with the server's own tracker also not tracking it).

Your exact dictGet/IN repros verified directly against the built binary, plus the dictionary() case.

@groeneai

Copy link
Copy Markdown
Collaborator

Verified at af8d5b0. collectFunctionArgumentReferences reads the same argument positions as visitFunction and reuses the misc.h predicates, so the dictGet/dictHas/dictIsIn/naiveBayesClassifier*/joinGet family (arg 0), dictionary() (arg 0) and the IN/GLOBAL IN family (arg 1) are all covered. Round-5 blocker discharged.

The arm discriminates, which is the part the previous rounds' suites failed. Restoring the old ASTTableIdentifier-only collector reddens: Code: 36 ... Dictionary (d.zzz_dict) not found for the dictGet MV, Code: 47 UNKNOWN_IDENTIFIER for IN (d.zzz_inview). Ran your new fixture through both collectors: at head the four new MVs sit one level above their targets and replay RC=0; with the narrow collector aaa_mv_inref drops to zzz_inview_tbl's level and replay fails. So ordering is what the fix decides.

I also checked the widening for a round-2 style false cycle, since arg 0 of dictGet is a string literal and a literal is again being read as an object name. Could not construct one: the server refuses a mutual IN 'db.tbl' pair at CREATE (Code: 269 INFINITE_LOOP) and refuses dictGet('d.some_view', ...) outright (Code: 36), so the shapes that would fabricate an edge cannot exist in a dumpable catalog. Unqualified literals are stored already qualified in as_select, so there is no current-database gap. Your dictionary()-vs-dictGet shape claim and the DEFAULT dictGet coverage claim both check out.

One correction, not a blocker. merge() is not resolved lazily, it resolves at CREATE. An MV cannot be created directly from a table function (Code: 397), but it can hold one in a JOIN, and there dump order does matter:

CREATE MATERIALIZED VIEW d.aaa_mv_mergejoin TO d.sink AS
  SELECT s.id AS id, s.val AS val, m.w AS w FROM d.src AS s
  LEFT JOIN merge('d','^zzz_merge_view$') AS m ON s.id = m.id;

With zzz_merge_view a view over a base table, it and the MV are both level 1, aaa_* sorts first, and replaying that order fails Code: 636 CANNOT_EXTRACT_TABLE_STRUCTURE; emitting the view first replays RC=0 with data flowing. loop(d.zzz_loop_view) is the same shape (Code: 60 UNKNOWN_TABLE) and holds arg 0 as a plain Identifier, so it is the extraction path dictionary() already uses. merge() would need its regexp matched against the table list, which is a larger call, and the server tracks neither. Documenting both as limitations is defensible; I would just state the reason as resolved-at-CREATE-but-untracked rather than lazy.

cc @azat @yakov-olkhovskiy, could you review this? It adds a client-side --dump-schema to clickhouse-client and clickhouse-local that prints CREATE statements in dependency order, ordering views and materialized views by parsing their stored SELECT text since the server tracks no dependencies for plain views.

`merge` and `loop` resolve at `CREATE` rather than lazily at query time, so dump
order does matter for a view holding one in a `JOIN`. Neither is tracked by the
server and neither is extracted here, so record both as known limitations next
to the `remote`/`cluster` exclusion instead of leaving the reason unstated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XvXEBdaJTCCnUdunCf8icr
@nikitamikhaylov nikitamikhaylov added the can be tested Allows running workflows for external contributors label Aug 16, 2026
@clickhouse-gh

clickhouse-gh Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [fd18a1a]

@clickhouse-gh clickhouse-gh Bot added the pr-feature Pull request with new product feature label Aug 16, 2026
Comment thread src/Client/SchemaDumper.cpp Outdated
@clickhouse-gh

clickhouse-gh Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Build profile diff (arm_release)

Comparing d96b66676 with master 003d95fa0 (stripped binary size, per-symbol sizes and ThinLTO time; object sizes against the warmup build of 2e619bf86; compile times per translation unit against the most recent warmup build that recompiled it).

⚠️ Significant changes: object file sizes.

Binary sizes
Binary Master PR Δ
programs/clickhouse-stripped 710.12 MiB 707.21 MiB -2.91 MiB (-0.41%)

Only the stripped binary is compared: the official master build keeps debug symbols while PR builds strip them, so the other binaries differ by construction.

Object file sizes ⚠️

8 object files changed (+476.64 KiB total), 1 added.

Object file Master PR Δ
src/CMakeFiles/dbms.dir/Client/SchemaDumper.cpp.o new 462.25 KiB +462.25 KiB

716 more object files are built by the master warmup baseline only (it builds every object-file target, a pull request build only clickhouse-bundle) and not compared.

Compile time of recompiled translation units

22 translation units recompiled, 137 s compile time in total, 21 of them have a recent master baseline.

Translation units without a recent master baseline:

  • src/Client/SchemaDumper.cpp: 6.8 s
Symbol sizes
Binary Symbol Master PR Δ
programs/clickhouse DB::attachSystemTablesServer(std::__1::shared_ptr<DB::Context const>, DB::IDatabase&, bool, bool) 274.74 KiB removed -274.74 KiB (-100.00%)
programs/clickhouse DB::attachSystemTablesServerExceptOne(std::__1::shared_ptr<DB::Context const>, DB::IDatabase&, bool… new 272.20 KiB +272.20 KiB

Job report

The implicit-inner filter classified a materialized view's target purely by name,
so a real user table called `.inner.foo`/`.inner_id.foo` that an MV points at with
an explicit `TO` was dropped from the dump entirely. Replay then failed: the view
was created before a target that was never emitted at all.

`ASTCreateQuery` already records the two cases distinctly -- an explicit
`TO db.table` sets the `To` target's table id, while an auto-generated inner table
renders as `TO INNER UUID '...'`, which the parser stores as an inner UUID and no
table id. So `hasExplicitTargetTable` parses `create_table_query` with
`ParserCreateQuery` and reads `is_materialized_view_with_external_target()`; a
substring test for `TO` cannot work here, since the implicit form literally
contains `TO INNER UUID`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XvXEBdaJTCCnUdunCf8icr
Comment thread src/Client/SchemaDumper.cpp Outdated
Comment thread src/Client/SchemaDumper.cpp Outdated
valerypetrov and others added 2 commits August 16, 2026 12:43
- merge(['db'|REGEXP('db_regexp'),] 'tables_regexp') and loop(db.table |
  'db','table') resolve their source(s) against the local catalog at
  CREATE time, same as remote()/cluster() would if they had a live
  Context available -- but unlike those, merge/loop are local and can be
  matched here. A materialized view reading through either previously
  got no dependency edge to its source, so a name tie-break could dump
  it before that source and produce a replay failure. Only explicitly-
  qualified forms are handled (matching the file's existing convention
  for plain unqualified FROM references, which are already skipped for
  the same reason: no "current database" to resolve against statically).

- A `.tmp.inner.<name>` row was classified as a materialized view's own
  mid-refresh leftover storage whenever a live MV happened to share that
  name, with no server-side proof of ownership (unlike `.tmp.inner_id.
  <uuid>`, confirmed via a live MV's actual UUID). A real user table
  named this way was silently dropped from the dump. Since there's no
  reliable signal to distinguish the two, treat `.tmp.inner.<name>` as
  an ordinary table instead of guessing by name alone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PUMFxWmof1zGB7KtqUyPk6
Comment thread src/Client/SchemaDumper.cpp Outdated
Comment thread src/Client/SchemaDumper.cpp Outdated
Comment thread src/Client/SchemaDumper.cpp
Comment thread src/Client/SchemaDumper.cpp
In a database without UUIDs every materialized view reports the nil UUID,
so the classifier inserted the all-zero key and silently dropped a user
table literally named .tmp.inner_id.00000000-... from the dump.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
Comment thread src/Client/SchemaDumper.cpp Outdated
Comment thread src/Client/SchemaDumper.cpp
test.py's cluster leaves enable_client_options_passing at its default
(false), so the env request was silently dropped and the session exited 0
before any refusal could fire; test_options_propagation_enabled.py's
cluster is the one where the option actually reaches the embedded client.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
Comment thread src/Client/SchemaDumper.cpp
valerypetrov and others added 2 commits August 28, 2026 17:08
…s it

A target kind named explicitly in the CREATE has no generated storage, so
a table carrying that kind's generated name is an ordinary user table
(verified: a TimeSeries with explicit METRICS generates only the samples
and tags helpers, a window view with explicit TO generates no
.inner.target one). The classifier now consults the stored targets.
merge() also resolves its regexp against helper tables, so its walker
matches the unfiltered name set and the resulting edge is remapped onto
the owning object instead of vanishing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
buildTargets skips a kind the CREATE does not declare, so a TimeSeries
without a RECENT SAMPLES clause has no recentsamples helper and a table
carrying that name is an ordinary one. The runtime also prefers the
modern samples helper and only falls back to the legacy data alias when
it is absent, so that alias is only implicit while no samples row exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
Comment thread src/Client/SchemaDumper.cpp
TimeSeries tables, window views and Ordinary databases are gated behind
settings that are off by default, so replaying a dump containing them
stopped at the first such statement. The needed SETs are now written
ahead of the statements in both the single-stream and per-file paths, and
only when the dump actually contains such an object.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
Comment thread src/Client/SchemaDumper.cpp
valerypetrov and others added 2 commits August 28, 2026 21:05
The dump's metadata queries were sent with no settings at all, so
system.tables hid every data lake catalog table and the dump emitted
their CREATE DATABASE with nothing in it. Those queries now pass
show_data_lake_catalogs_in_system_tables explicitly.

StorageWindowView also refuses to be created while the analyzer is on, so
a dump containing one needs that turned off alongside the feature flag or
the replay just fails one statement later.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
Comment thread src/Client/SchemaDumper.cpp Outdated
Comment thread src/Client/SchemaDumper.cpp Outdated
valerypetrov and others added 5 commits August 28, 2026 22:07
Turning the analyzer off for the whole replay broke every other statement
that needs it - a view using QUALIFY, for instance - so the downgrade is
now wrapped around the one CREATE that requires it and restored straight
after.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
Extends the prelude beyond the three engines it started with: the
Replicated / MaterializedPostgreSQL / MaterializedMySQL database engines,
all five DataLakeCatalog kinds (the kind lives inside the engine
arguments, so all are enabled rather than guessed), UNIQUE KEY, the
Paimon table engines and YTsaurus. Verified every emitted setting name is
accepted by a server. This is still a whitelist that will lag a new gate;
a server-driven required-settings mechanism remains the follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
Three more create-time gates the dump can already emit statements for:
the MaterializedPostgreSQL table engine (distinct from the database one),
the Kafka engine storing offsets in Keeper, which is selected by
kafka_keeper_path or kafka_replica_name being set, and use_hive_partitioning
on the object-storage queue engines. Matching on the setting names is
deliberately fail-close: an unneeded SET replays harmlessly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
SHOW CREATE keeps the deprecated positional *MergeTree(...) definition
verbatim, and replaying it needs allow_deprecated_syntax_for_merge_tree;
a YTsaurus dictionary source needs its own gate, which nothing on the
table or database markers reached. New-syntax Replicated tables also
carry engine arguments and match the MergeTree marker, which only emits
a setting their replay ignores.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
The legacy-alias and gated-object cases sit above the block that set DB,
so ${DB} expanded to empty: the setup ran CREATE DATABASE  ENGINE =
Ordinary, which is the position-25 syntax error those runs reported, and
--dump-schema= then dumped every database, so the replay also tried to
recreate default. Every assertion in those two sections was reading an
empty dump. Move the three database-name assignments above the first use.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
Comment thread src/Client/SchemaDumper.cpp Outdated
A view's columns are inferred by analysing its SELECT, so a table
function in it is rejected at CREATE time: eval() and ytsaurus() each
have their own gate and neither is reachable from the engine or
dictionary-source markers. Match on a leading space so a name ending in
the same letters, such as retrieval(, does not trip eval(.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
Comment thread src/Client/SchemaDumper.cpp Outdated
{
std::set<String> settings;

/// Every create-time gate identifiable in-tree today, keyed by what the stored `CREATE` spells.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

settingsPreludeFor still misses create-time settings on stored table definitions, not just engine names. A concrete repro is any MergeTree created under allow_suspicious_primary_key = 1: SHOW CREATE preserves the ORDER BY with SimpleAggregateFunction, but the dump emits no matching SET, so replay into a default session re-enters registerStorageMergeTree.cpp:731-732 and fails with DATA_TYPE_CANNOT_BE_USED_IN_KEY (see tests/queries/0_stateless/03020_order_by_SimpleAggregateFunction.sql).

The same gap exists for other already-supported stored DDL such as allow_suspicious_ttl_expressions (registerStorageMergeTree.cpp:876-882) and gated codecs in MergeTree settings (registerStorageMergeTree.cpp:906-913). That means --dump-schema can still succeed on a valid catalog but emit a script that a fresh default session cannot replay. I think this needs either broader prelude detection for these create-time settings, or a fail-close check when the stored DDL contains one of these gated forms.

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.

Added in 64f7a66. I verified each gate and each setting name, and confirmed the mechanism you point at: is_fresh_definition is true for a CREATE, so a replay is re-validated in full even though the source already accepted the DDL — TTLValidationMode::Validate rather than ::Attach, and the codec check likewise.

This round is a different kind from the previous four, and I want to name that rather than let it look like more of the same. The earlier markers stood for features — an engine, a source, a table function — and the statement text names them exactly. These stand for validations, and the conditions are not decidable from the text: whether an ORDER BY key is "suspicious" depends on resolved column types, and whether a TTL expression is depends on what it references. So the markers are deliberately coarse — any SimpleAggregateFunction, any TTL , any of the three stored compression-codec settings — and a definition that would have passed anyway just carries a spare SET.

I think that is the right trade here specifically because these are validations. The definitions in a dump were already accepted once by the source; relaxing re-validation on replay is closer to the intent of a restore than failing it. That is not an argument I would make for a feature gate, where the setting actually changes what the server will do.

The honest summary is that this is the fifth round of gates on this table, each found by inspection rather than by anything systematic, and the classes keep widening: engines, then database engines, then dictionary sources, then table functions, now validation settings. That is the concrete case for the server-driven "settings this statement requires" mechanism you scoped as the follow-up — I do not think any amount of auditing converges here, and I would rather that be visible from the thread than discovered again in a sixth round.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This thread is not fully fixed yet. replaySettingsPrelude() now sets only allow_experimental_codecs at src/Client/SchemaDumper.cpp:1260, but fresh replay still has another codec validator behind allow_suspicious_codecs.

registerStorageMergeTree.cpp:920-925 re-runs validateCodecString() for fresh CREATEs, and CompressionFactoryAdditions.cpp:263-315 still rejects several codec combinations unless allow_suspicious_codecs is enabled. So a table that was originally created under SET allow_suspicious_codecs = 1 can still dump successfully and then fail to replay in a default session. I think this prelude still needs allow_suspicious_codecs, plus a focused 04836 regression for a stored codec string that depends on it.

A replay is a fresh definition, so the suspicious-key, suspicious-TTL and
experimental-codec checks all run again even though the source already
accepted the DDL. None of these are engine names, so nothing on the
existing markers reached them. The markers are coarser than the checks
they stand for -- any SimpleAggregateFunction, any TTL, any stored
compression codec -- because the conditions are not decidable from the
statement text; a definition that would pass anyway just gets a spare SET.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
Comment thread src/Client/SchemaDumper.cpp Outdated
validateDataType() and MergeTreeData::checkProperties() re-run on every
non-ATTACH CREATE, so a table accepted under a relaxed session setting
replays into a default session and is rejected. Unlike the engine and
validation gates, these settings leave no trace in the statement at all
-- only the shape they object to is visible -- so the markers match the
shape and take the false positives.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
Comment thread src/Client/SchemaDumper.cpp Outdated
valerypetrov and others added 3 commits August 29, 2026 03:32
Seven rounds of review found gates the marker table missed, and the last
two classes cannot be detected at all: the suspicious-type validators and
the analyzer-side settings a view's AS SELECT is re-analysed under are
session state that the stored CREATE does not record. The markers that
remained (Tuple(, INDEX , TTL ) fired on nearly every statement anyway,
so matching bought precision only on paper. Emit the whole set, which is
complete by construction rather than by audit; each is inert when unused.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
GROUP BY on a Dynamic column is rejected when the view is recreated,
since CREATE re-analyses the stored SELECT, and the setting that allows
it is session state that appears nowhere in the statement. Verified both
directions locally: the CREATE fails with ILLEGAL_COLUMN naming
allow_suspicious_types_in_group_by, and succeeds with it set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
Those four sections were added at the top of the script but their lines
were appended to the end of the reference, so the recorded order never
matched the emitted one. The empty-database bug fixed in 3387aef was
masking this: the test failed on stderr before the diff was reached.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
/// Validators that re-run because a replay is a fresh definition.
"allow_suspicious_primary_key",
"allow_suspicious_ttl_expressions",
"allow_experimental_codecs",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

replaySettingsPrelude() is still incomplete here. The emitted script only enables allow_experimental_codecs, but replaying a dumped CREATE TABLE can still fail on the other codec gates that are revalidated during CREATE:

  • the dedicated per-codec settings (enable_alp_codec, enable_zxc_codec, enable_quantized_codec, enable_sz3_codec, etc.)
  • the sanity-bypass setting allow_suspicious_codecs

Those are all real create-time settings in this tree (enableAllExperimentalSettings() already treats them that way), so a table accepted under one of them will dump successfully and then fail to replay in a default session. A simple example is a table created under SET enable_alp_codec = 1 with CODEC(ALP): the stored CREATE still needs that setting when the dump re-runs it.

I think this prelude needs to add allow_suspicious_codecs plus the codec gate list from CompressionCodecFactory::instance().getGateSettingNames(), otherwise --dump-schema still emits non-self-contained dumps for codec-gated schemas.

"enable_nullable_tuple_type",
"allow_suspicious_indices",
"allow_minmax_index_for_json",
"allow_suspicious_types_in_group_by",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This unconditional prelude still misses allow_dynamic_type_in_join_keys. CREATE VIEW / CREATE MATERIALIZED VIEW re-analyze their stored AS SELECT on replay, and join analysis still throws on Dynamic join keys unless that session setting is enabled.

So a schema like SET allow_dynamic_type_in_join_keys = 1; CREATE VIEW v AS SELECT * FROM lhs JOIN rhs USING d; (with d of type Dynamic) will dump successfully, but the emitted script will fail when it recreates the view in a default session because this prelude never restores the setting. That is the same replay-compatibility class as the allow_suspicious_types_in_group_by gap fixed above: the setting lives in session state, not in the stored CREATE.

Please add allow_dynamic_type_in_join_keys to replaySettingsPrelude() and cover it with a round-trip regression.

"allow_suspicious_indices",
"allow_minmax_index_for_json",
"allow_suspicious_types_in_group_by",
"allow_suspicious_types_in_order_by",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

replaySettingsPrelude() still misses one of the analyzer-time replay gates for VIEW / MATERIALIZED VIEW: allow_dynamic_type_in_join_keys.

The same stored-AS SELECT re-analysis that justified allow_suspicious_types_in_group_by / allow_suspicious_types_in_order_by still re-enters TableJoin.cpp:950-960, where a JOIN on Dynamic keys throws unless this setting is enabled. Because SHOW CREATE does not serialize that session setting, a view created under SET allow_dynamic_type_in_join_keys = 1 will dump successfully but the replayed CREATE VIEW / CREATE MATERIALIZED VIEW still fails in a default session.

Please add allow_dynamic_type_in_join_keys to this unconditional prelude and cover it with a focused 04836 arm.

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

Labels

can be tested Allows running workflows for external contributors pr-feature Pull request with new product feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Command to dump database schema

3 participants