Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
69f071a
1st vibe coded version
arthurpassos Jul 9, 2026
3190603
some vibe coded fixes
arthurpassos Jul 10, 2026
d94ea53
persist-then-apply and uuidv4 instead of snowflakeid
arthurpassos Jul 14, 2026
b2af06c
idk
arthurpassos Jul 22, 2026
f2ee03f
possibly fix build
arthurpassos Jul 22, 2026
240da81
chmod
arthurpassos Aug 29, 2026
9ff4a4a
fix kill operation
arthurpassos Aug 31, 2026
6c86d2f
fix backoff on plain
arthurpassos Aug 31, 2026
a622f44
make code a bit more readable, ai
arthurpassos Aug 31, 2026
23d90fa
release parts
arthurpassos Aug 31, 2026
fe7e61a
fix endless commit due to part ref lost
arthurpassos Aug 31, 2026
7c959fb
well, I don't like it, but ok
arthurpassos Sep 1, 2026
f06a7dd
fix dispatch err handling
arthurpassos Sep 2, 2026
7482d6b
fix
arthurpassos Sep 2, 2026
c8d038d
Merge remote-tracking branch 'origin/antalya-26.6' into feature/antal…
arthurpassos Sep 3, 2026
875cc47
throw exceptions to the user
arthurpassos Sep 4, 2026
9496d85
throw on unknown values
arthurpassos Sep 4, 2026
3af519a
simpĺify docs
arthurpassos Sep 4, 2026
31b04b1
throw on exporting unreadable entries
arthurpassos Sep 4, 2026
01b5d38
Merge branch 'antalya-26.6' into feature/antalya-26.6/export-partitio…
arthurpassos Sep 4, 2026
a0badb4
unify tables
arthurpassos Sep 7, 2026
2eab452
fix old bug
arthurpassos Sep 7, 2026
cd502a6
add no-cas
arthurpassos Sep 8, 2026
3fc5e5f
rmv unnecessary tests
arthurpassos Sep 8, 2026
cae460a
fix long standing issue with key
arthurpassos Sep 8, 2026
d4a3859
unify replicated and plain export tests
arthurpassos Sep 9, 2026
b50cee3
Merge branch 'antalya-26.6' into feature/antalya-26.6/export-partitio…
arthurpassos Sep 9, 2026
b89bfa1
fix few bugs
arthurpassos Sep 10, 2026
00b1540
improve testing suite
arthurpassos Sep 10, 2026
f93a925
Merge branch 'antalya-26.6' into feature/antalya-26.6/export-partitio…
arthurpassos Sep 10, 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
25 changes: 20 additions & 5 deletions docs/en/antalya/partition_export.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@

## Overview

The `ALTER TABLE EXPORT PARTITION` command exports entire partitions from Replicated*MergeTree tables to object storage (S3, Azure Blob Storage, etc.) or data lakes like Apache Iceberg tables (with and without catalogs), typically in Parquet format. This feature coordinates export part operations across all replicas using ZooKeeper.
The `ALTER TABLE EXPORT PARTITION` command exports entire partitions from `MergeTree`-family tables to object storage (S3, Azure Blob Storage, etc.) or data lakes like Apache Iceberg tables (with and without catalogs), typically in Parquet format.

The set of parts that are exported is based on the list of parts the replica that received the export command sees. The other replicas will assist in the export process if they have those parts locally. Otherwise they will ignore it.
- On `Replicated*MergeTree` tables the export is coordinated across all replicas using ZooKeeper.
- On plain (non-replicated) `MergeTree` tables the export runs entirely on the single node that received the command. No ZooKeeper / `clickhouse-keeper` ensemble is required. See [Plain (non-replicated) MergeTree](#plain-non-replicated-mergetree) below.

The partition export tasks can be observed through `system.replicated_partition_exports`. The table is served from each replica's in-memory mirror, so queries do not contact ZooKeeper and are cheap to run. The mirror is refreshed on the manifest-updater poll cycle and on every status change, so a freshly written exception or terminal state may take up to one poll interval to appear. Individual part export progress can be observed as usual through `system.exports`.
The set of parts that are exported is based on the list of parts the replica that received the export command sees. On `Replicated*MergeTree`, the other replicas will assist in the export process if they have those parts locally. Otherwise they will ignore it.

The partition export tasks can be observed through `system.replicated_partition_exports` (for `Replicated*MergeTree`) or `system.partition_exports` (for plain `MergeTree`). Both tables are served from an in-memory mirror, so queries do not contact ZooKeeper or disk and are cheap to run. The `system.replicated_partition_exports` mirror is refreshed on the manifest-updater poll cycle and on every status change, so a freshly written exception or terminal state may take up to one poll interval to appear. Individual part export progress can be observed as usual through `system.exports`.

The same partition can not be exported to the same destination more than once. There are two ways to override this behavior: either by setting the `export_merge_tree_partition_force_export` setting or waiting for the task to expire.

Expand Down Expand Up @@ -35,6 +38,18 @@ The source partition must not be split in the destination. This is validated at

Each MergeTree part will become a separate file with the following name convention: `<table_directory>/<partitioning>/<data_part_name>_<merge_tree_part_checksum>.<format>`. To ensure atomicity, a commit file containing the relative paths of all exported parts is also shipped. A data file should only be considered part of the dataset if a commit file references it. The commit file will be named using the following convention: `<table_directory>/commit_<partition_id>_<transaction_id>`.

## Plain (non-replicated) MergeTree
Comment thread
arthurpassos marked this conversation as resolved.
Outdated

`EXPORT PARTITION` is supported on plain `MergeTree` tables in addition to `Replicated*MergeTree`. Because a single node owns the whole export there is no cross-replica coordination and no ZooKeeper is involved:

- The task descriptor (the list of parts, their per-part progress, the export settings and the task status) is persisted as a small JSON file on the table's disk under `<table_data_path>/partition_exports/<transaction_id>.json`, instead of in ZooKeeper.
Comment thread
arthurpassos marked this conversation as resolved.
Outdated
- A local background task drives the export: it schedules the part exports, records their completion, and performs the final commit (a commit file for plain object storage, or the Iceberg snapshot commit for Iceberg destinations).
- The task is persistent — an in-flight export is reloaded from disk and resumed after a server restart.
- Progress is observed through `system.partition_exports` (not `system.replicated_partition_exports`). It exposes the same core columns: `source_database`, `source_table`, `destination_database`, `destination_table`, `create_time`, `partition_id`, `transaction_id`, `query_id`, `parts`, `parts_count`, `parts_to_do`, `status`, `last_exception`, `last_exception_part`, `last_exception_time`, and `exception_count`. Replica-specific columns are omitted.
- Duplicate protection, `export_merge_tree_partition_force_export`, `EXPORT PARTITION ALL`, and `KILL EXPORT PARTITION` all behave the same way as for `Replicated*MergeTree`. A single `KILL EXPORT PARTITION ... WHERE ...` matches tasks from both engines.

All the object-storage and Iceberg output layouts, atomicity guarantees, and settings described in this document apply equally to plain `MergeTree`.

## Syntax

```sql
Expand Down Expand Up @@ -66,7 +81,7 @@ TO TABLE [destination_database.]destination_table

- **Type**: `Bool`
- **Default**: `false`
- **Description**: Enable export replicated merge tree partition feature. It is experimental and not yet ready for production use.
- **Description**: Enable the `EXPORT PARTITION` feature for both `Replicated*MergeTree` and plain `MergeTree` tables. It is experimental and not yet ready for production use.

### Query Settings

Expand Down Expand Up @@ -173,7 +188,7 @@ WHERE partition_id = '2020'
AND destination_table = 's3_table'
```

The `WHERE` clause filters exports from the `system.replicated_partition_exports` table. You can use any columns from that table in the filter.
The `WHERE` clause filters exports from the `system.replicated_partition_exports` table (for `Replicated*MergeTree`) and the `system.partition_exports` table (for plain `MergeTree`); a single `KILL EXPORT PARTITION` consults both. You can use any columns common to those tables in the filter (for example `partition_id`, `source_table`, `destination_table`).

## Monitoring

Expand Down
134 changes: 94 additions & 40 deletions src/Interpreters/InterpreterKillQueryQuery.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -265,65 +265,119 @@ BlockIO InterpreterKillQueryQuery::execute()
"Exporting merge tree partition is experimental. Set the server setting `allow_experimental_export_merge_tree_partition` to enable it");
}

Block exports_block = getSelectResult(
"source_database, source_table, transaction_id, destination_database, destination_table, partition_id",
"system.replicated_partition_exports");
if (exports_block.empty())
return res_io;
const String export_columns = "source_database, source_table, transaction_id, destination_database, destination_table, partition_id";

/// Partition exports live in two system tables: `replicated_partition_exports` for
/// ReplicatedMergeTree and `partition_exports` for plain MergeTree. Query both so a single
/// KILL EXPORT PARTITION targets either engine. Each query applies the user WHERE to its own
/// table (which exposes all of its columns); a WHERE that references columns specific to the
/// other engine will fail against the table that lacks them. We tolerate that per-table so a
/// predicate on engine-specific columns still reaches the matching engine — but if BOTH reads
/// fail (e.g. a genuinely bad predicate / unknown column), we rethrow so the error surfaces.
Block replicated_exports_block;
Block plain_exports_block;
std::exception_ptr replicated_error;
std::exception_ptr plain_error;

try
{
replicated_exports_block = getSelectResult(export_columns, "system.replicated_partition_exports");
}
catch (...)
{
replicated_error = std::current_exception();
tryLogCurrentException(getLogger("InterpreterKillQueryQuery"),
"KILL EXPORT PARTITION: could not read system.replicated_partition_exports (the WHERE may "
"reference columns that only exist for plain MergeTree); ignoring ReplicatedMergeTree tables");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Propagate system-table read failures during KILL

If either system-table query fails for an operational reason such as a memory limit, timeout, or internal exception, this broad catch treats it like an engine-specific unknown column and continues with the other table. The command can therefore report successful results while silently leaving matching exports from the failed table running. Only suppress the specific predicate-resolution error that proves the filter belongs exclusively to the other table; propagate all other failures.

AGENTS.md reference: AGENTS.md:L153-L153

Useful? React with 👍 / 👎.

}

try
{
plain_exports_block = getSelectResult(export_columns, "system.partition_exports");
}
catch (...)
{
plain_error = std::current_exception();
tryLogCurrentException(getLogger("InterpreterKillQueryQuery"),
"KILL EXPORT PARTITION: could not read system.partition_exports (the WHERE may reference "
"columns that only exist for ReplicatedMergeTree); ignoring plain MergeTree tables");
}

const ColumnString & src_db_col = typeid_cast<const ColumnString &>(*exports_block.getByName("source_database").column);
const ColumnString & src_table_col = typeid_cast<const ColumnString &>(*exports_block.getByName("source_table").column);
const ColumnString & dst_db_col = typeid_cast<const ColumnString &>(*exports_block.getByName("destination_database").column);
const ColumnString & dst_table_col = typeid_cast<const ColumnString &>(*exports_block.getByName("destination_table").column);
const ColumnString & tx_col = typeid_cast<const ColumnString &>(*exports_block.getByName("transaction_id").column);
/// Neither table could be read: the predicate is invalid for both, so surface the error
/// instead of silently matching nothing.
if (replicated_error && plain_error)
std::rethrow_exception(plain_error);

if (replicated_exports_block.empty() && plain_exports_block.empty())
return res_io;

auto header = exports_block.cloneEmpty();
/// Build the result header explicitly from the fixed projection so it does not depend on
/// whether either source block ended up with rows.
Block header;
for (const auto & column_name : {"source_database", "source_table", "transaction_id",
"destination_database", "destination_table", "partition_id"})
header.insert({ColumnString::create(), std::make_shared<DataTypeString>(), column_name});
header.insert(0, {ColumnString::create(), std::make_shared<DataTypeString>(), "kill_status"});

MutableColumns res_columns = header.cloneEmptyColumns();
AccessRightsElements required_access_rights;
auto access = getContext()->getAccess();
bool access_denied = false;

for (size_t i = 0; i < exports_block.rows(); ++i)
auto process_block = [&](const Block & exports_block)
{
const auto src_database = src_db_col.getDataAt(i);
const auto src_table = src_table_col.getDataAt(i);
const auto dst_database = dst_db_col.getDataAt(i);
const auto dst_table = dst_table_col.getDataAt(i);
if (exports_block.empty())
return;

const auto table_id = StorageID{std::string{src_database}, std::string{src_table}};
const auto transaction_id = tx_col.getDataAt(i);
const ColumnString & src_db_col = typeid_cast<const ColumnString &>(*exports_block.getByName("source_database").column);
const ColumnString & src_table_col = typeid_cast<const ColumnString &>(*exports_block.getByName("source_table").column);
const ColumnString & dst_db_col = typeid_cast<const ColumnString &>(*exports_block.getByName("destination_database").column);
const ColumnString & dst_table_col = typeid_cast<const ColumnString &>(*exports_block.getByName("destination_table").column);
const ColumnString & tx_col = typeid_cast<const ColumnString &>(*exports_block.getByName("transaction_id").column);

CancellationCode code = CancellationCode::Unknown;
if (!query.test)
for (size_t i = 0; i < exports_block.rows(); ++i)
{
auto storage = DatabaseCatalog::instance().tryGetTable(table_id, getContext());
if (!storage)
code = CancellationCode::NotFound;
else
{
ASTAlterCommand alter_command{};
alter_command.type = ASTAlterCommand::EXPORT_PARTITION;
alter_command.move_destination_type = DataDestinationType::TABLE;
alter_command.from_database = src_database;
alter_command.from_table = src_table;
alter_command.to_database = dst_database;
alter_command.to_table = dst_table;
const auto src_database = src_db_col.getDataAt(i);
const auto src_table = src_table_col.getDataAt(i);
const auto dst_database = dst_db_col.getDataAt(i);
const auto dst_table = dst_table_col.getDataAt(i);

required_access_rights = InterpreterAlterQuery::getRequiredAccessForCommand(
alter_command, table_id.database_name, table_id.table_name);
if (!access->isGranted(required_access_rights))
const auto table_id = StorageID{std::string{src_database}, std::string{src_table}};
const auto transaction_id = tx_col.getDataAt(i);

CancellationCode code = CancellationCode::Unknown;
if (!query.test)
{
auto storage = DatabaseCatalog::instance().tryGetTable(table_id, getContext());
if (!storage)
code = CancellationCode::NotFound;
else
{
access_denied = true;
continue;
ASTAlterCommand alter_command{};
alter_command.type = ASTAlterCommand::EXPORT_PARTITION;
alter_command.move_destination_type = DataDestinationType::TABLE;
alter_command.from_database = src_database;
alter_command.from_table = src_table;
alter_command.to_database = dst_database;
alter_command.to_table = dst_table;

required_access_rights = InterpreterAlterQuery::getRequiredAccessForCommand(
alter_command, table_id.database_name, table_id.table_name);
if (!access->isGranted(required_access_rights))
{
access_denied = true;
continue;
}
code = storage->killExportPartition(std::string{transaction_id});
}
code = storage->killExportPartition(std::string{transaction_id});
}

insertResultRow(i, code, exports_block, header, res_columns);
}
};

insertResultRow(i, code, exports_block, header, res_columns);
}
process_block(replicated_exports_block);
process_block(plain_exports_block);

if (res_columns[0]->empty() && access_denied)
throw Exception(ErrorCodes::ACCESS_DENIED, "Not allowed to kill export partition. "
Expand Down
Loading
Loading