Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
3f47047
vibe coded monotonicity check for mt -> iceberg exports
arthurpassos Jul 17, 2026
343c2d9
add some more tests
arthurpassos Jul 17, 2026
3f3af7c
make tests better
arthurpassos Jul 18, 2026
d385268
more vibe coded changes
arthurpassos Jul 20, 2026
454e9fc
recompute dst key with correct set of parts, include tz
arthurpassos Jul 20, 2026
abcba15
Merge branch 'antalya-26.3' into feature/antalya-26.3/export-partitio…
arthurpassos Jul 20, 2026
4e10ade
Merge branch 'antalya-26.3' into feature/antalya-26.3/export-partitio…
arthurpassos Jul 22, 2026
e4a69a0
some more vibe coded style changes
arthurpassos Jul 22, 2026
c60c97d
add missing tests
arthurpassos Jul 22, 2026
a9f51e1
code simplification
arthurpassos Jul 22, 2026
12c5e5b
some docs
arthurpassos Jul 22, 2026
ca43cf1
handle iceberg_timezone
arthurpassos Jul 24, 2026
56fde58
missing arg restore
arthurpassos Jul 24, 2026
ee1993a
docs
arthurpassos Jul 24, 2026
41fbe8f
opsy
arthurpassos Jul 24, 2026
450eddc
possible simplification
arthurpassos Jul 24, 2026
6df47c7
some more simplifications
arthurpassos Jul 27, 2026
1501cc7
add some comments
arthurpassos Jul 27, 2026
a89d2f0
simplifications and docs
arthurpassos Jul 27, 2026
776be0b
fix more vibe coded issues
arthurpassos Jul 27, 2026
7495ab6
further simplifications
arthurpassos Jul 27, 2026
e75220d
one more simplification
arthurpassos Jul 27, 2026
4285c90
fix tests
arthurpassos Jul 27, 2026
c439e33
rename variable
arthurpassos Jul 27, 2026
43096d4
opsy
arthurpassos Jul 27, 2026
78024e2
docs and style changes
arthurpassos Jul 27, 2026
2015f2a
more docs
arthurpassos Jul 27, 2026
ad1301f
shitty changes
arthurpassos Jul 27, 2026
a690e33
rmv too verbose documentation
arthurpassos Jul 27, 2026
e055b1f
unify
arthurpassos Jul 27, 2026
3848194
vibe coded short term fix
arthurpassos Jul 28, 2026
12f73f0
rmv not necessary comment
arthurpassos Jul 28, 2026
584186f
Revert "vibe coded short term fix"
arthurpassos Jul 28, 2026
b4cca45
check type casting
arthurpassos Jul 28, 2026
d256041
vibe coded fix
arthurpassos Jul 29, 2026
c529fbe
function extraction
arthurpassos Aug 5, 2026
55304f7
simplification
arthurpassos Aug 5, 2026
3e60b54
piggy back some bug fixes
arthurpassos Aug 5, 2026
1822382
re-use existing code and make the structural match a bit more flexible
arthurpassos Aug 19, 2026
378d9ca
Merge branch 'antalya-26.3' into feature/antalya-26.3/export-partitio…
arthurpassos Aug 19, 2026
9a6a4db
one fix for sure
arthurpassos Aug 19, 2026
834a50b
fix lc(nullable)
arthurpassos Aug 19, 2026
0743a3d
fix tests
arthurpassos Aug 19, 2026
7caa8d4
fix a few tests
arthurpassos Aug 20, 2026
35605c1
rmv corner case
arthurpassos Aug 20, 2026
9a1cb91
rmv duplicated call
arthurpassos Aug 21, 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
4 changes: 2 additions & 2 deletions docs/en/antalya/part_export.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,10 @@ Source and destination tables must support positional schema conversion. The fol
- **Column types** may differ, as long as the source type is safely castable to the destination type. Set `export_merge_tree_part_allow_lossy_cast = 1` to also permit lossy casts.
- **`Tuple` element names** may differ if either the source or destination declares the tuple without named elements: an unnamed `Tuple` (e.g. `Tuple(Int32, Int32)`) is matched against the destination by element position and type only, not by name. For example, exporting from `t Tuple(Int32, Int32)` to `t Tuple(x Int32, y Int32)` is allowed as long as element types match positionally.

The following must match between source and destination:
The following requirements apply to the source and destination:

1. **Column count** - source and destination must have the same number of columns by default. A mismatch in either direction throws `NUMBER_OF_COLUMNS_DOESNT_MATCH`. Set `export_merge_tree_part_schema_mismatch_mode = 'ignore_extra_source_columns_by_position'` to allow a source table with extra trailing columns; the destination having more columns than the source is still rejected in this mode.
2. **`PARTITION BY` expressions** - for destinations other than data lakes, the source and destination `PARTITION BY` expressions must be identical. For Apache Iceberg destinations, the source partition key must be representable as an Iceberg partition spec and must match the destination partition fields and transforms.
2. **`PARTITION BY` expressions** - the whole part must land in a single destination partition. Identical expressions always satisfy this; otherwise the destination expression has to be computable from the values the source partition key pins, or be proven single-valued over the part's min/max range. The same requirement applies to the partition fields and transforms of an Apache Iceberg destination. See [Source partition key compatibility](/docs/en/antalya/partition_export.md#source-partition-key-compatibility).
3. **The position of every column backing the partition key** - it is not enough for the `PARTITION BY` expressions to be textually identical: every top-level column that provides a column or subcolumn used by the source table's partition key must have the same name at the same position in the destination table's schema. If such a column contains a named `Tuple`, its element names must also be declared in the same order (an unnamed `Tuple` on either side is exempt from this, per the allowance above). This comparison is recursive through nested tuples and through container types such as `Array` and `Map`.

For example, `CREATE TABLE src (a Int32, b Int32) ... PARTITION BY a` and `CREATE TABLE dst (b Int32, a Int32) ... PARTITION BY a` both have the expression `PARTITION BY a`, but `a` is at position 0 in `src` and position 1 in `dst`. The export is rejected with a `BAD_ARGUMENTS` exception whose message includes `Cannot export to <destination>: partition key column 'a' is at position 0 in the source table, but the destination's column at that position is named 'b'`.
Expand Down
11 changes: 9 additions & 2 deletions docs/en/antalya/partition_export.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ The manifest file produced by the commit contains a summary field `clickhouse.ex

The Iceberg manifest files contain statistics about the data. Exporting a merge tree partition is a non ephemeral long running task, in which nodes can be turned off and turned on. This means the stats of individual files need to be persisted somewhere in order to produce the final manifest. This is implemented through sidecars. Each data file exported will contain a "sibling" sidecar file named `<data_file_name>_clickhouse_export_part_sidecar.avro`. ClickHouse does not clean up these files, and they can be safely deleted once the data is comitted.

#### Source partition key compatibility

The source partition must not be split in the destination. This is validated at schedule time through two mechanisms:

1. Structural match: in case the source and destination are identical, the destination expression is a subset of the source expression or the destination expression can be entirely computed using only constants and the exact values guaranteed (pinned) by the source.
2. Dynamic proof: the destination expression is monotonic over the source partition min/max range.

### On plain object storage exports:

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>`.
Expand All @@ -45,10 +52,10 @@ TO TABLE [destination_database.]destination_table

## Requirements

`EXPORT PARTITION` exports each part via the same mechanism as [`EXPORT PART`](/docs/en/antalya/part_export.md#requirements), so the source and destination tables must satisfy the same compatibility requirements. Column names may differ (columns are matched by position, not by name), and column types may differ as long as they are safely castable (or `export_merge_tree_part_allow_lossy_cast = 1` is set). Beyond that, the following must match:
`EXPORT PARTITION` exports each part via the same mechanism as [`EXPORT PART`](/docs/en/antalya/part_export.md#requirements), so the source and destination tables must satisfy the same compatibility requirements. Column names may differ (columns are matched by position, not by name), and column types may differ as long as they are safely castable (or `export_merge_tree_part_allow_lossy_cast = 1` is set). Beyond that, the following requirements apply:

1. **Column count** - source and destination must have the same number of columns by default. Set `export_merge_tree_part_schema_mismatch_mode = 'ignore_extra_source_columns_by_position'` to allow a source table with extra trailing columns; the destination having more columns than the source is still rejected in this mode.
2. **`PARTITION BY` expressions** - for destinations other than data lakes, the source and destination `PARTITION BY` expressions must be identical. For Apache Iceberg destinations, the source partition key must match the destination partition fields and transforms.
2. **`PARTITION BY` expressions** - the whole source partition must land in a single destination partition. Identical expressions always satisfy this; otherwise the destination expression has to be computable from the values the source partition key pins, or be proven single-valued over the partition's min/max range. The same requirement applies to the partition fields and transforms of an Apache Iceberg destination. See [Source partition key compatibility](#source-partition-key-compatibility).
3. **Partition key column positions and layouts** - every top-level column that provides a column or subcolumn used by the source table's partition key must have the same name at the same position in the destination table's schema. Named `Tuple` elements within such a column must also be declared in the same order, including tuples nested inside `Array` or `Map`. This applies even if both tables' `PARTITION BY` expressions are textually identical. See [`EXPORT PART` requirements](/docs/en/antalya/part_export.md#requirements) for a worked example and the corresponding exception message.

## Settings
Expand Down
14 changes: 2 additions & 12 deletions src/Processors/QueryPlan/Optimizations/actionsDAGUtils.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -245,16 +245,7 @@ MatchedTrees::Matches matchTrees(const ActionsDAG::NodeRawConstPtrs & inner_dag,
}


struct PossiblyMonotonicChain
{
const ActionsDAG::Node * input_node = nullptr;
std::vector<size_t> non_const_arg_pos;
bool changes_order = false;
bool is_strict = true;
};

/// Build a chain of functions which may be monotonic.
static PossiblyMonotonicChain buildPossiblyMonitinicChain(const ActionsDAG::Node * node)
PossiblyMonotonicChain buildPossiblyMonotonicChain(const ActionsDAG::Node * node)
{
std::vector<size_t> chain;

Expand Down Expand Up @@ -307,7 +298,6 @@ static PossiblyMonotonicChain buildPossiblyMonitinicChain(const ActionsDAG::Node
return {node, std::move(chain)};
}

/// Check whether all the function in chain are monotonic
bool isMonotonicChain(const ActionsDAG::Node * node, PossiblyMonotonicChain & chain)
{
auto it = chain.non_const_arg_pos.begin();
Expand Down Expand Up @@ -388,7 +378,7 @@ void applyActionsToSortDescription(
if (output == output_to_skip)
continue;

auto chain = buildPossiblyMonitinicChain(output);
auto chain = buildPossiblyMonotonicChain(output);
if (!chain.input_node)
break;

Expand Down
16 changes: 16 additions & 0 deletions src/Processors/QueryPlan/Optimizations/actionsDAGUtils.h
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,22 @@ struct MatchedTrees

MatchedTrees::Matches matchTrees(const ActionsDAG::NodeRawConstPtrs & inner_dag, const ActionsDAG & outer_dag, bool check_monotonicity = true);

/// A path from a node down to an input, where every function on the path has a single non-constant argument.
/// `non_const_arg_pos` holds the position of that argument for every function on the path, top-down.
struct PossiblyMonotonicChain
{
const ActionsDAG::Node * input_node = nullptr;
std::vector<size_t> non_const_arg_pos;
bool changes_order = false;
bool is_strict = true;
};

/// Build a chain of functions which may be monotonic. `input_node` is nullptr if the node is not such a chain.
PossiblyMonotonicChain buildPossiblyMonotonicChain(const ActionsDAG::Node * node);

/// Check whether all the function in chain are monotonic
bool isMonotonicChain(const ActionsDAG::Node * node, PossiblyMonotonicChain & chain);

/// Update SortDescription (inplace) by applying ActionsDAG.
///
/// Assuming that sorting properties are fulfilled for inputs, calculate sorting properties for the outputs.
Expand Down
12 changes: 12 additions & 0 deletions src/Storages/ExportReplicatedMergeTreePartitionManifest.h
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,11 @@ struct ExportReplicatedMergeTreePartitionManifest
std::optional<UInt64> parquet_row_group_size_bytes;
std::optional<MergeTreePartExportSchemaMismatchMode> schema_mismatch_mode;

/// this is a controversial setting. As far as I can infer from the iceberg docs, the transforms are always UTC.
/// this setting allows to specify different timezones. Since it is already implemented, we must respect it.
/// At the same time, we don't allow transforms with timezones, so this is very weird.
std::optional<String> iceberg_partition_timezone;

std::string toJsonString() const
{
Poco::JSON::Object json;
Expand Down Expand Up @@ -291,6 +296,8 @@ struct ExportReplicatedMergeTreePartitionManifest
json.set("parquet_row_group_size", *parquet_row_group_size);
if (parquet_row_group_size_bytes)
json.set("parquet_row_group_size_bytes", *parquet_row_group_size_bytes);
if (iceberg_partition_timezone)
json.set("iceberg_partition_timezone", *iceberg_partition_timezone);

@k-morozov k-morozov Aug 21, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

We try to set this setting for export. But if we have several columns with different timezones, we would set the value from the setting for all of them, right?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I think that's how this setting was designed, @ianton-ru can confirm. AFAIK, this setting was created due to a misconception on the Apache Iceberg specs (the spec itself is poorly written) when it was believed we could tweak the timezone of an apache iceberg column. When in reality, according to 3rd party docs, the types are always UTC.

if (schema_mismatch_mode)
json.set("schema_mismatch_mode", String(magic_enum::enum_name(*schema_mismatch_mode)));
std::ostringstream oss; // STYLE_CHECK_ALLOW_STD_STRING_STREAM
Expand Down Expand Up @@ -391,6 +398,11 @@ struct ExportReplicatedMergeTreePartitionManifest
manifest.parquet_row_group_size_bytes = json->getValue<UInt64>("parquet_row_group_size_bytes");
}

if (json->has("iceberg_partition_timezone"))
{
manifest.iceberg_partition_timezone = json->getValue<String>("iceberg_partition_timezone");
}

return manifest;
}
};
Expand Down
Loading
Loading