NAME
DBIO::ClickHouse::Deploy - Deploy and upgrade ClickHouse schemas via test-deploy-and-compare
VERSION
version 0.900004
DESCRIPTION
DBIO::ClickHouse::Deploy orchestrates schema deployment and upgrades for ClickHouse with the family's test-deploy-and-compare strategy:
- 1. introspect the live database via the
system.*catalog - 2. deploy the desired schema into a throwaway scratch database
- 3. introspect that the same way
- 4. diff the two models with DBIO::ClickHouse::Diff
my $deploy = DBIO::ClickHouse::Deploy->new(
schema => MyApp::DB->connect($dsn, $user, $pass),
);
$deploy->install; # fresh install
my $diff = $deploy->diff; # or step by step
$deploy->apply($diff) if $diff->has_changes;
$deploy->upgrade; # convenience: diff + apply
$deploy->upgrade(allow_destructive => 1); # a diff that drops or converts values
Read "WHAT THIS DIFF WILL NOT DO" in DBIO::ClickHouse::Diff before wiring upgrade into anything automatic. A diff with a destructive operation runs only with allow_destructive ("_check_before_apply"). A change to a table's engine or MergeTree keys throws instead of rebuilding the table.
METHODS
temp_db_prefix
Prefix for the scratch database name; defaults to dbio_deploy_tmp_. The full name appends the PID and the current epoch second. Override per instance:
DBIO::ClickHouse::Deploy->new(schema => $schema, temp_db_prefix => 'scratch_');
_create_temp_db
my $name = $self->_create_temp_db($dbh);
Creates the scratch database and returns its name. The statement comes from "create_database" in DBIO::ClickHouse::DDL::Emit, which specifies no ENGINE, so it gets the server default (Atomic on any supported version).
_drop_temp_db
$self->_drop_temp_db($dbh, $name);
Drops the scratch database. IF EXISTS because this runs unconditionally, including on paths where the CREATE is the statement that failed.
install
$deploy->install;
Generates the install DDL via "install_ddl" in DBIO::ClickHouse::DDL and executes each statement against the connected database. For a fresh install on an empty database. Otherwise "install" in DBIO::Deploy::Base.
A source whose table or view is named database.table / database.view is made in that database ("qualified_tables" in DBIO::ClickHouse::DDL). The driver creates no database: one the server does not have ends the install before its first statement, with a DBIO::Exception naming the table and the database. The diff reads the current database alone, so the scratch database of a diff gets the tables and views of the current database only; a table or view of another database is installed, never compared, replaced or dropped.
diff
my $diff = $deploy->diff;
Introspects the live database, builds the desired model in a scratch database (see "THE SCRATCH DATABASE"), and returns a DBIO::ClickHouse::Diff. Throws if the schema asks for a change ClickHouse cannot make in place.
Overrides "diff" in DBIO::Deploy::Base for one reason: the base builds the diff from the two models alone, and the diff reads the IF [NOT] EXISTS capability off the storage it is handed. Here that is $self->schema->storage, so the statements apply and upgrade run carry the guards, and applying the same diff a second time after a partial failure is a no-op. It also hands the diff the SETTINGS the result classes declare ("declared_settings" in DBIO::ClickHouse::DDL), so a table the upgrade creates carries them, and the views the schema declares in the current database ("declared_views" in DBIO::ClickHouse::DDL, local_only), so a view the upgrade lacks is created. Everything else is the base's recipe (_introspect_current, _build_target_model).
_check_before_apply
$self->_check_before_apply($diff);
The driver's checks of "apply" in DBIO::Deploy::Base, run for a released diff before its first statement, against the live connection: "_refuse_nulls_in_narrowed_columns" and "_refuse_not_null_added_over_rows", then the base's. They read the data; diff, as_sql and summary stay data-free, and a dry_run checks nothing and asks the server nothing.
apply and upgrade are DBIO::Deploy::Base's as they are, with their arguments: a diff holding a destructive operation -- a drop, or a MODIFY COLUMN that can alter stored values ("lossy" in DBIO::ClickHouse::Diff::Column) -- runs only with allow_destructive => 1, given as argument or as attribute, and dry_run => 1 returns the diff without executing it.
$deploy->apply($diff, allow_destructive => 1);
$deploy->upgrade(dry_run => 1);
The ALTER TABLE ... MODIFY COLUMN and ADD INDEX statements it runs are accepted synchronously but applied as background mutations; watch system.mutations to see them finish.
_refuse_nulls_in_narrowed_columns
Every MODIFY COLUMN that turns a Nullable(...) column into a non-nullable one ("needs_null_check" in DBIO::ClickHouse::Diff::Column) is asked SELECT count() FROM t WHERE c IS NULL. A column that holds NULL rows ends the run with a DBIO::Exception naming table, column, row count and the way out, before any statement has been executed: the server would accept the MODIFY, switch the metadata at once and fail in the background mutation, leaving the column unreadable and the table's mutation queue stuck. The remedy is by hand:
ALTER TABLE t UPDATE c = <value> WHERE c IS NULL SETTINGS mutations_sync = 1
then run the upgrade again.
_refuse_not_null_added_over_rows
Likewise a non-nullable ADD COLUMN without a default to a table that holds rows ("needs_row_check" in DBIO::ClickHouse::Diff::Column) ends the run before the first statement: the server would fill the existing rows with the type's zero value.
upgrade
my $diff = $deploy->upgrade;
Convenience: "diff" then apply. Returns the diff object if changes were applied, or undef if the database was already up to date. Inherited from DBIO::Deploy::Base, with its allow_destructive and dry_run arguments.
A materialized view (or live or window view) that was created by hand and is not declared by the schema blocks upgrade: the diff refuses it loudly (see "View" in DBIO::ClickHouse::Diff, "Materialized views are refused") rather than dropping data. Declare it or drop it yourself before the upgrade.
THE SCRATCH DATABASE
SQLite and DuckDB build the desired-state model in :memory:. ClickHouse has no in-memory and no embedded mode at all -- not a :memory: DSN, not a file database, nothing that runs in-process. The only place a ClickHouse schema can be deployed is a ClickHouse server.
So the throwaway target is a scratch database on the very same server the live schema lives on:
CREATE DATABASE `dbio_deploy_tmp_<pid>_<epoch>`
-> connect to it (DSN rewritten from the live connect_info)
-> run install_ddl
-> introspect
-> disconnect
DROP DATABASE IF EXISTS `dbio_deploy_tmp_<pid>_<epoch>`
which is the same shape DBIO::MySQL::Deploy and DBIO::PostgreSQL::Deploy use, and is why this class subclasses DBIO::Deploy::Base::TempDatabase rather than overriding _build_target_model itself. Only the two-statement dialect seam ("_create_temp_db" / "_drop_temp_db") is ClickHouse's own.
The name carries the PID and the current epoch second, so two processes -- and the same process twice -- never collide.
What this costs you
The scratch database is real, on the real server. It is created empty, gets the schema (no rows), and is dropped again, so it costs a metadata write and whatever the DDL itself costs. But it does mean:
the connecting user needs
CREATE DATABASEandDROP DATABASE. On a locked-down cluster, rundifffrom an account that has them, or generate the DDL offline with "install_ddl" in DBIO::ClickHouse::DDL and review it by hand.on a replicated cluster,
CREATE DATABASEwithoutON CLUSTERcreates the scratch database on the coordinating node only. That is what we want -- it is throwaway -- but aReplicated*engine in the schema will want a ZooKeeper path, and those paths are not scoped to the scratch database. Deploy such schemas against a single node, or review the diff before applying.
Cleanup is guaranteed, and never eats the real error
The lifecycle is inherited verbatim from "_build_target_model" in DBIO::Deploy::Base::TempDatabase, which is already the try/finally shape this needs:
my $temp_db = $self->_create_temp_db($dbh);
my $model = eval { $self->_deploy_and_introspect_temp($temp_db) };
my $err = $@;
eval { $self->_drop_temp_db($dbh, $temp_db) }; # always, in its own eval
die $err if $err; # original error, unchanged
Three properties matter, and all three are already true there:
The drop always runs. It is not conditional on the deploy having succeeded, so a schema with one bad
CREATE TABLEdoes not leak a scratch database.The drop cannot swallow the original error. It is wrapped in its own
eval, and$errwas captured before it ran, so a cleanup that itself fails (server gone, permission revoked mid-run) cannot overwrite the exception that actually explains what went wrong.The drop uses the live connection, not the scratch one. The scratch connection is opened and closed inside
_deploy_and_introspect_temp; if it dies mid-flight the drop still has a working handle to issueDROP DATABASEon.
Why not a guard object. A DESTROY-based guard buys one thing this shape lacks -- cleanup on exit or an uncaught signal -- and costs several. It fires during global destruction, where the $dbh it needs may already be torn down, so the drop it exists to guarantee is exactly the one that can fail silently. And error handling inside DESTROY is where original exceptions get mangled -- this distribution has already had to replace core's scope-guard DESTROY for exactly that reason, see DBIO::ClickHouse::Storage::TxnScopeGuard. Adding a second guard with the same hazard, to cover a case (kill -9) that no in-process mechanism covers anyway, would trade a guarantee we have for one we cannot keep. A scratch database that survives a kill -9 is named dbio_deploy_tmp_* and is safe to drop by hand.
No transactions are involved
Nothing in this lifecycle depends on a transaction, and nothing may: this storage's txn_rollback always throws (docs/adr/0003-no-transactions-policy.md). The CREATE DATABASE / DROP DATABASE pair is the unit of recovery, which is why the drop is a plain statement in its own eval rather than a rollback.
"_execute_ddl" in DBIO::Deploy::Base would wrap its statement loop in $storage->txn_do if the storage reported the transactional_ddl capability. DBIO::ClickHouse::Storage declares _use_transactional_ddl(0), so the loop runs statement at a time and core emits its one-time "non-transactional DDL" notice. That notice is accurate: a multi-statement apply that fails half way through leaves the earlier statements applied. Re-running upgrade is safe -- the diff is recomputed against live state, so the second run picks up only what is left.
SEE ALSO
DBIO::ClickHouse - the schema component
DBIO::ClickHouse::DDL - generates the desired-state DDL
DBIO::ClickHouse::DDL::Emit - the
CREATE/DROP DATABASEstatementsDBIO::ClickHouse::Introspect - reads live database state
DBIO::ClickHouse::Diff - compares the two models
DBIO::Deploy::Base::TempDatabase - the scratch-database lifecycle
DBIO::MySQL::Deploy - the sibling that shares that lifecycle
AUTHOR
DBIO Authors
COPYRIGHT AND LICENSE
Copyright (C) 2026 DBIO Authors
This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.