Skip to content

Native CREATE TABLE / DROP TABLE for DataLakeCatalog; optionally prune on DROP - #2305

Merged
zvonand merged 3 commits into
antalya-26.6from
releasy/port/pr-98670-2f540f
Oct 7, 2026
Merged

zvonand merged 3 commits into
antalya-26.6from
releasy/port/pr-98670-2f540f

Conversation

@zvonand

@zvonand zvonand commented Sep 2, 2026 •

Copy link
Copy Markdown
Member

Cherry-picked from ClickHouse#98670.

Changelog category (leave one):

  • Improvement

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

Supports CREATE TABLE, CREATE TABLE … AS source, and DROP TABLE for DataLakeCatalog; DROP TABLE can request catalog-side data purge via the new data_lake_delete_data_on_drop setting

CI/CD Options

Exclude tests:

  • Fast test
  • Integration Tests
  • Stateless tests
  • Stateful tests
  • Unit tests
  • Performance tests
  • Aarch64 tests
  • All with ASAN
  • All with TSAN
  • All with MSAN
  • All with UBSAN
  • All with Coverage
  • All Regression
  • Disable CI Cache

Regression jobs to run:

  • Fast suites (mostly <1h)
  • Aggregate Functions (2h)
  • Alter (1.5h)
  • Benchmark (30m)
  • CAS (content-addressed storage; Antalya only)
  • ClickHouse Keeper (1h)
  • Iceberg (2h)
  • LDAP (1h)
  • OAuth (5m)
  • Parquet (1.5h)
  • RBAC (1.5h)
  • SSL Server (1h)
  • S3 (2h)
  • S3 Export (2h)
  • Swarms (30m)
  • Tiered Storage (2h)

@zvonand

zvonand commented Sep 2, 2026

Copy link
Copy Markdown
Member Author

RelEasy — resolution kept with warnings

The AI resolution was pushed, but a post-resolution check is still failing on it. Nothing was rolled back — please review the point(s) below before merging and either fix them or dismiss them as false positives.

  • src/Core/SettingsChangesHistory.cpp: 1 unauthorized setting row(s) added during resolution: data_lake_delete_data_on_drop — these names are not in the source PR's diff for this file. This registry is append-only: only rows the source PR itself adds may land in the port. Re-resolve and drop the extras (likely context lines from "ours" that got swept in alongside a real edit). (still failing after 2 correction pass(es))

@github-actions

github-actions Bot commented Sep 2, 2026 •

Copy link
Copy Markdown

Workflow [PR], commit [f9b636f]

@zvonand zvonand added antalya antalya-26.6 port-antalya PRs to be ported to all new Antalya releases labels Sep 2, 2026
@zvonand zvonand changed the title Cherry-pick: Native CREATE TABLE / DROP TABLE for DataLakeCatalog; optionally prune on DROP Native CREATE TABLE / DROP TABLE for DataLakeCatalog; optionally prune on DROP Sep 2, 2026
@zvonand

zvonand commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

@blau-ai

@blau-ai

This comment was marked as outdated.

@blau-ai

This comment was marked as outdated.

zvonand added a commit that referenced this pull request Sep 4, 2026
`-Wdocumentation-html` treats `<table>` in a `///` comment as an unclosed
HTML start tag (backticks are Markdown and the Doxygen-style parser does
not honor them), which is fatal under `-Weverything -Werror`:

    src/Storages/ObjectStorage/DataLakes/Iceberg/IcebergMetadata.cpp:942:32:
    error: HTML tag 'table' requires an end tag [-Werror,-Wdocumentation-html]

Build report:
https://altinity-build-artifacts.s3.amazonaws.com/json.html?PR=2305&sha=7809d87ff19fbda330d3238b289f338c146bb533&name_0=PR&name_1=Build%20%28amd_binary%29
#2305
@zvonand

zvonand commented Sep 7, 2026

Copy link
Copy Markdown
Member Author

@blau-ai

@blau-ai

This comment was marked as outdated.

@Selfeer

Selfeer commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

For the initial check, here is what audit review was able to hit.

PR #2305 Audit Findings

1) DROP TABLE ... SETTINGS data_lake_delete_data_on_drop = 1 may silently keep data for lazy, never-loaded tables

  • Severity: Medium
  • Affected code: StorageTableProxy::prepareForDrop, StorageTableProxy::drop, StorageObjectStorage::drop
  • Why this is an issue: The query explicitly requests purge, but the setting is not propagated when the table is still a lazy proxy with unresolved nested storage. The later background drop then defaults to delete_data = false, so files are kept despite explicit user intent.
  • Human-readable impact: Users can run a purge drop and still leave table data/metadata behind, causing silent storage leaks and violating expected semantics of data_lake_delete_data_on_drop.
  • Reproduction steps:
    1. Enable lazy table loading (so table objects are proxied after restart).
    2. Create an Iceberg table and insert data.
    3. Restart the server, then run DROP TABLE <table> SYNC SETTINGS data_lake_delete_data_on_drop = 1 before any read/write on that table.
    4. Check object storage/filesystem path: table files remain instead of being deleted.

2) S3TablesCatalog::dropTable swallows “table not found” even when IF EXISTS is not used

  • Severity: Low
  • Affected code: S3TablesCatalog::dropTable
  • Why this is an issue: The HTTP_NOT_FOUND case is treated as success unconditionally. For non-IF EXISTS drops, this hides state races and violates normal SQL expectation that dropping a missing table should error.
  • Human-readable impact: Concurrent or racey automation can report a successful DROP TABLE even though the table was already removed by another actor, masking real state transitions and making troubleshooting harder.
  • Reproduction steps:
    1. Create a table in an S3 Tables-backed DataLake catalog.
    2. Start two concurrent sessions and run DROP TABLE <same_table> SETTINGS data_lake_delete_data_on_drop = 1 (without IF EXISTS) nearly simultaneously.
    3. First drop succeeds; second hits catalog 404.
    4. Observe second drop is reported as success instead of throwing a “table does not exist” exception.

@Selfeer

Selfeer commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

PR #2305 fix verification

Run from clickhouse-regression/iceberg. Set BUILD to the package URL or docker image of the build to check. A fix is in when the listed scenarios report OK instead of XFail / XError.

BUILD="https://altinity-build-artifacts.s3.amazonaws.com/PRs/2305/<sha>/build_amd_release/clickhouse-common-static_26.6.2.20001.altinityantalya_amd64.deb"

Backport 114864 (transform names day / hour)

python3 regression.py --clickhouse-binary-path "$BUILD" -o classic --only \
  "/iceberg/native create/rest catalog/sanity/create as source copies keys/*" \
  "/iceberg/native create/rest catalog/schema/accepted transforms/*" \
  "/iceberg/native create/rest catalog/metadata/initial file/*"

Backport 111786 (Avro field-ids in manifest lists)

python3 regression.py --clickhouse-binary-path "$BUILD" -o classic --only \
  "/iceberg/native create/rest catalog/schema/partition values in manifest/*" \
  "/iceberg/native create/rest catalog/metadata/first commit has no parent/*" \
  "/iceberg/native create/rest catalog/metadata/pyiceberg reads clickhouse rows/*"

Backport 109812 (.gz extension for gzip metadata)

sed -i 's/\.gzip\.metadata\.json/.gz.metadata.json/' tests/iceberg_engine/native_create/metadata.py
python3 regression.py --clickhouse-binary-path "$BUILD" -o classic --only \
  "/iceberg/native create/rest catalog/metadata/gzip metadata/*"

Bug: CREATE TABLE stalls ~33 s when the namespace exists

python3 regression.py --clickhouse-binary-path "$BUILD" -o classic --only \
  "/iceberg/native create/rest catalog/namespaces/second table lands beside the first/*"
grep -c "Failed to make request to 'http://ice-rest-catalog:5000/v1/namespaces'" _instances/clickhouse1/logs/clickhouse-server.log

Fixed when the scenario takes a few seconds, not ~35 s, and the grep prints 0.

Bug: orphan metadata files on REST (purge leaves files, re-create refused)

python3 regression.py --clickhouse-binary-path "$BUILD" -o classic --only \
  "/iceberg/native create/rest catalog/sanity/drop with purge/*" \
  "/iceberg/native create/rest catalog/sanity/explicit engine create/*" \
  "/iceberg/native create/rest catalog/drop/drop routes/*" \
  "/iceberg/native create/rest catalog/lifecycle/recreate after purge drop/*" \
  "/iceberg/native create/rest catalog/explicit engine/initial file naming/*"

Everything

python3 regression.py --clickhouse-binary-path "$BUILD" -o classic --only "/iceberg/native create/*"

@arthurpassos arthurpassos left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

It is a big PR, I'll do it partially. Below are my human generated comments

Comment thread tests/integration/test_database_iceberg/test.py Outdated
Comment thread tests/integration/test_database_iceberg/test.py Outdated
Comment thread src/Databases/DataLake/Common.h Outdated
Comment thread src/Databases/DataLake/Common.cpp
Comment thread src/Databases/DataLake/DatabaseDataLake.cpp Outdated
Comment thread src/Databases/DataLake/DatabaseDataLake.cpp Outdated
Comment thread src/Databases/DataLake/DatabaseDataLake.cpp Outdated
Comment thread src/Databases/DataLake/DatabaseDataLake.cpp
Comment thread src/Databases/DataLake/DatabaseDataLake.cpp
Comment thread src/Databases/DataLake/GlueCatalog.cpp Outdated
@arthurpassos

Copy link
Copy Markdown
Collaborator

Below you can find AI comments:

6. Confirmed defects
High
None under the crash/UB/corruption/deadlock rubric. D2 is the closest (data left behind + CREATE blocked).

Medium
D1: Iceberg REST 409 is retried ~33s before catalog code treats it as success

Impact: Second CREATE TABLE in an existing REST namespace stalls ~33s (http_max_tries=10, backoff 100ms…10s ≈ 32.7s). Same stall for CREATE IF NOT EXISTS that loses the catalog race. Matches [#2386](https://github.com/Altinity/ClickHouse/issues/2386).
Anchor: src/IO/HTTPCommon.cpp isRetriableHTTPError; RestCatalog::sendRequest; RestCatalog::createNamespaceIfNotExists / createTable
Trigger: CREATE TABLE catalog.\ns.t2`afternsalready exists (GET miss or catalog without GET), or concurrentIF NOT EXISTS`.
Why defect: The catalog handlers already catch HTTP_CONFLICT, but ReadWriteBufferFromHTTP::doWithRetries retries 409 because it is not in the non-retriable list. The catch runs only after retries.
Fix: Treat 409 as non-retriable for these calls (max_tries=1 or add HTTP_CONFLICT to the local non-retriable set). Do not make 409 globally non-retriable without checking other HTTP users.
Regression test: Second native CREATE in the same REST namespace must finish in seconds; log must not show 10 failed POSTs to /v1/namespaces.

HTTPCommon.cpp
Ln 65–76
bool isRetriableHTTPError(const Poco::Net::HTTPResponse::HTTPStatus http_status) noexcept
{
    static constexpr std::array non_retriable_errors{
        Poco::Net::HTTPResponse::HTTPStatus::HTTP_BAD_REQUEST,
        // ... HTTP_NOT_FOUND, HTTP_FORBIDDEN, HTTP_NOT_IMPLEMENTED, HTTP_METHOD_NOT_ALLOWED
        // HTTP_CONFLICT (409) is NOT listed → retried
    };
D2: REST CREATE with ENGINE writes metadata the catalog never references; purge DROP cannot remove it

Impact: Orphan v1-<uuid>.metadata.json (and version-hint.text) under the table prefix. DROP … SETTINGS data_lake_delete_data_on_drop = 1 removes catalog-tracked files only. Recreate with an explicit engine then hits TableAlreadyExistsInCatalogException (“metadata files are already present”). Matches [#2387](https://github.com/Altinity/ClickHouse/issues/2387).
Anchor: IcebergMetadata::createInitial; RestCatalog::createTable (new_metadata_path unused); S3TablesCatalog::managesTableLocation only
Trigger: CREATE TABLE catalog.\ns.t` ENGINE = IcebergS3('s3://…')in a RESTDataLakeCatalog`, then purge DROP, then CREATE again.
Why defect: managesTableLocation means “catalog assigns the location” (S3 Tables). REST still lets the client propose a location and write metadata, but REST CreateTable generates its own metadata and ignores new_metadata_path. Engine-less CREATE is fine (empty path, catalog writes).
Fix: Do not write client metadata when the catalog will write it (separate flag from managesTableLocation), or pass the written path into the REST create body (metadata-location / equivalent). Align DROP with the files that were actually written.
Regression test: Explicit-engine REST create → purge drop → recreate; object listing of …/metadata/ must be empty after purge.
D3: Engine-argument location check is a string prefix of storage_endpoint, not a storage prefix

Impact: CREATE TABLE … ENGINE = IcebergS3('s3://warehouse-rest/ns/tbl', …) is rejected when the database uses storage_endpoint = 'http://minio:9000/warehouse-rest', even though constructTableLocation itself emits that s3:// form.
Anchor: DatabaseDataLake::validateCreateTableEngineArguments
Trigger: Explicit engine + HTTP(S) storage_endpoint + s3:// / abfss:// location.
Why defect: Reopen uses the catalog location plus database engine args, not a string prefix of storage_endpoint. The check fires on scheme mismatch (http:// vs s3://).
Fix: Compare parsed bucket/prefix (same rules as constructTableLocation / TableMetadata::setLocation), or skip the prefix check when schemes differ and the constructed warehouse URI matches.
Regression test: Database with MinIO HTTP endpoint; CREATE with s3://<bucket>/… engine location that constructTableLocation would produce.
D4: prepareForDrop is not applied to an unresolved lazy nested storage

Impact: For Iceberg ENGINE tables in a lazy_load_tables database, DROP TABLE … SETTINGS data_lake_delete_data_on_drop = 1 after restart (never read) keeps files. StorageObjectStorage::drop uses value_or(false) and only logs if the server-wide setting is on.
Anchor: StorageTableProxy::prepareForDrop; StorageTableFunctionProxy::prepareForDrop; StorageObjectStorage::drop
Trigger: Atomic/Ordinary DB with lazy_load_tables, Iceberg engine table, drop without prior access.
Why defect: DROP is supposed to honor the query setting. Capture is skipped; later drop() never sees it. Not the DataLakeCatalog path (that one purges via DatabaseDataLake::dropTable).
Fix: Resolve nested storage in prepareForDrop (without startup), or pass delete_data into IStorage::drop.
Regression test: Create Iceberg engine table, restart with lazy load, drop with purge, assert objects gone.
D5: Glue initial metadata uses HTTP encoding gzip, not Iceberg’s gz

Impact: With iceberg_metadata_compression_method = 'gzip' (or 'gz'), Glue CREATE writes v1.gzip.metadata.json. Spark/pyiceberg and the PR’s own test expect v1.gz.metadata.json.
Anchor: GlueCatalog::createTable (toContentEncodingName); contrast IcebergMetadata::createInitial (raw setting string)
Trigger: Glue native CREATE with metadata compression enabled.
Why defect: Iceberg’s compressed metadata extension is gz. toContentEncodingName(Gzip) is "gzip" (HTTP Content-Encoding). Upstream uses toIcebergMetadataCompressionExtension, which this port does not have.
Fix: Map gzip → gz for Iceberg filenames (shared helper used by Glue and createInitial).
Regression test: The assertion already in the PR (endswith(".gz.metadata.json") and not .gzip.).
Low
None additional after dedup (gzip/gz is Medium because it breaks external readers).

@Selfeer

Selfeer commented Sep 23, 2026

Copy link
Copy Markdown
Collaborator

@zvonand does this need to be addressed in this PR? #2387 ?

@zvonand

zvonand commented Sep 23, 2026

Copy link
Copy Markdown
Member Author

@zvonand does this need to be addressed in this PR? #2387 ?

I think so

…prune on `DROP`

Adds native `CREATE TABLE`, `CREATE TABLE ... AS source` and `DROP TABLE`
support for `DataLakeCatalog` databases, so tables can be created and removed
in the catalog itself instead of only being read through it.

`CREATE TABLE` registers the table in the catalog (REST, Glue, Unity,
S3 Tables), honouring `IF NOT EXISTS` and rolling the staged metadata back
when the creation fails or loses a race. `iceberg_metadata_compression_method`
is respected by `GlueCatalog::createTable`.

`DROP TABLE` unregisters the table and, when the new
`data_lake_delete_data_on_drop` setting is enabled, asks the catalog to purge
the table data as well. `IF EXISTS` and `IF NOT EXISTS` are passed down to the
catalog so the backends can honour them before rejecting an unsupported purge.

The engine named in `CREATE TABLE` is validated against the catalog backend,
including its arguments: `tryGetTableImpl` reopens a table from its catalog
location plus the database engine arguments, so any other argument given in
`CREATE` - storage credentials above all - would apply only to the creation and
then be replaced on the first read. Engine `SETTINGS` are accepted with an
explicit engine. Clauses unsupported for a catalog table are rejected when
inherited from a `CREATE TABLE ... AS` source, also when the destination gives
its own engine; `datalake_create_table_as_ignore_unsupported_source_properties`
drops them instead.

The accepted engine family is the catalog's own, so
`CREATE TABLE ... ENGINE = DeltaLakeLocal(...)` is accepted in a Unity-backed
database. Only Iceberg metadata is generated for a `CREATE TABLE` without a
table engine, so that path throws `NOT_IMPLEMENTED` for a catalog of another
family before resolving the table location.

`OneLakeCatalog`, `BigLakeCatalog` and `S3TablesCatalog` report their fixed
backend from `getStorageType`, so every backend check reads
`ICatalog::getStorageType`. A catalog that assigns table locations itself
rejects an explicit `ENGINE` and `storage_endpoint` as the placement for a new
table and asks for `default_base_location`; `SHOW CREATE TABLE` omits the
`ENGINE` clause for such a catalog. `SHOW CREATE TABLE` now includes the
Iceberg `PARTITION BY` / `ORDER BY` keys when they can be represented.

A lost `IF NOT EXISTS` race throws `TABLE_ALREADY_EXISTS`, which
`InterpreterCreateQuery` matches on to turn into a no-op, and the Glue rollback
removes the staged metadata file only after `GetTable` confirms that nothing
still points at it. A transactional (REST) catalog writes the initial metadata
file itself, so ClickHouse no longer leaves an orphaned one next to it.
`ON CLUSTER` DDL, including `DROP DATABASE ... ON CLUSTER`, is rejected for
`DataLakeCatalog`.

ClickHouse#98670

(cherry picked from the state of ClickHouse#98670 at b3b0b22, squashed)

Adaptations for antalya-26.6:

* Dropped: `UnityV2Catalog`, `DatabaseRemote` and
  `DeltaLakeCatalogRegistration.cpp` do not exist here, nor do the Delta Lake
  `createTable` path of `DeltaLakeMetadataDeltaKernel` and `UnityCatalog`, so
  their changes are not applied. Upstream-only tests that the PR merely
  touched (`test_create_gzip_metadata`, `test_cluster_insert`,
  `test_database_unity_v2`) are not added.
* Added: `ICatalog::managesTableLocation` and the `S3TablesCatalog` override,
  which upstream already had in its base.
* Adapted: `ICatalog::getTableEngineName` does not exist here, so the engine
  family comes from `table_engine_definition`, and `getCreateTableQueryImpl`
  keeps this branch's engine name for unreadable tables.
* Adapted: `toIcebergMetadataCompressionExtension` and
  `Iceberg::makeIcebergLocationURI` do not exist here, so `GlueCatalog` and
  `IcebergMetadata::createInitial` keep this branch's file naming and
  location construction.
* Adapted: `parseTransformAndArgument` takes a time zone here; the new
  `getPartitionAndSortingKeyASTsFromMetadata` passes an empty one.
* Adapted: the Iceberg REST namespace-identifier fix (`namespaceToJSONArray`)
  was applied inside `buildUpdateMetadataRequestBody` and
  `buildUpdateSchemaRequestBody`, where this branch builds those request bodies.
* Adapted: `IcebergMetadata::drop` keeps this branch's reachable-file
  enumeration and takes the resolved `delete_data` flag.
* Adapted: `StorageObjectStorageCluster` delegates to `pure_storage` here, so
  `prepareForDrop` forwards there instead of capturing the setting and calling
  `StorageObjectStorage::dropImpl` itself.
* Adapted: `writeMetadataFiles` in `Iceberg/Mutations.cpp` gained
  `previous_metadata_file_path` beside this branch's `content_type` and
  `write_metadata_json_file` parameters.
* Adapted: the namespace filter (`allowed_namespaces` / `isNamespaceAllowed`)
  of `RestCatalog` and `GlueCatalog`, `GlueCatalog::getOrFetchMetadataObject`,
  and the `NoSuchBucket` wrapping in `createInitial` are kept.
  `TableMetadata::hasDataLakeSpecificProperties` does not exist here, so
  `GlueCatalog` checks `getDataLakeSpecificProperties().has_value()`.
* Adapted: the new settings are registered in `SettingsChangesHistory.cpp`,
  since this branch does not declare the history inline.
@zvonand
zvonand force-pushed the releasy/port/pr-98670-2f540f branch from 49215ff to 9c43382 Compare September 29, 2026 13:22
@arthurpassos

Copy link
Copy Markdown
Collaborator

@zvonand have you had time to look into #2305 (comment)?

"(got {})", datalake_unsupported_storage_clause);
}

if (!ignore_unsupported_source_properties || columns_user_specified)

@arthurpassos arthurpassos Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nit: the amount of times ignore_unsupported_source_properties is checked surprised me. Asked AI for a simplification - not sure it looks better, I'll leave it up to you to decide:

auto reject_unsupported_columns = [&]
{
    for (const auto & column : properties.columns)
    {
        if (column.default_desc.expression || column.default_desc.kind != ColumnDefaultKind::Default)
            throw Exception(ErrorCodes::BAD_ARGUMENTS,
                "Column '{}': {} is not yet supported by DataLakeCatalog table creation",
                column.name, toString(column.default_desc.kind));

        if (!column.comment.empty() || column.codec || column.ttl
            || !column.settings.empty() || column.statistics.hasExplicitStatistics())
            throw Exception(ErrorCodes::BAD_ARGUMENTS,
                "Column '{}': COMMENT, CODEC, TTL, STATISTICS, SETTINGS, and PRIMARY KEY "
                "are not supported by DataLakeCatalog table creation",
                column.name);
    }

    if (!properties.indices.empty() || !properties.constraints.empty() || !properties.projections.empty()
        || (create.columns_list && (create.columns_list->primary_key || create.columns_list->primary_key_from_columns)))
        throw Exception(ErrorCodes::BAD_ARGUMENTS,
            "DataLakeCatalog CREATE TABLE does not support PRIMARY KEY, indices, constraints, or projections");
};

if (ignore_unsupported_source_properties)
{
    if (columns_user_specified)
        reject_unsupported_columns();
    else
    {
        ColumnsDescription plain_columns;
        for (const auto & column : properties.columns)
            plain_columns.add(ColumnDescription(column.name, column.type));

        properties.columns = std::move(plain_columns);
        properties.indices = {};
        properties.constraints = {};
        properties.projections = {};

        auto columns_list = make_intrusive<ASTColumns>();
        columns_list->set(columns_list->columns, formatColumns(properties.columns));
        create.set(create.columns_list, columns_list);
    }

    if (comment_user_specified)
    {
        if (create.comment)
            throw Exception(ErrorCodes::BAD_ARGUMENTS,
                "Table COMMENT is not supported by DataLakeCatalog table creation "
                "(note: CREATE TABLE ... AS inherits the comment from the source table)");
    }
    else
        create.reset(create.comment);
}
else
{
    reject_unsupported_columns();

    if (create.comment)
        throw Exception(ErrorCodes::BAD_ARGUMENTS,
            "Table COMMENT is not supported by DataLakeCatalog table creation "
            "(note: CREATE TABLE ... AS inherits the comment from the source table)");

    if (!as_table_saved.empty())
    {
        // existing source-storage lookup and findUnsupportedDatalakeStorageClause call
    }
}

@zvonand

zvonand commented Sep 30, 2026

Copy link
Copy Markdown
Member Author

have you had time to look into

only partially. now I'll make AI look into AI findings first :)

Comment thread tests/integration/test_database_iceberg/test.py
Comment thread tests/integration/test_database_iceberg/test.py
Comment thread tests/integration/test_database_iceberg/test.py Outdated
arthurpassos
arthurpassos previously approved these changes Sep 30, 2026

@arthurpassos arthurpassos left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I read the drop code, and parts of the create as. Left a couple of comments. I also went through the tests, left a couple of comments regarding test duplication. Regardless, it seems like it works and that's how far I can go with a human review in a reasonable time.

I'll leave it to the author to fix the AI comments.

@zvonand

zvonand commented Oct 5, 2026

Copy link
Copy Markdown
Member Author

@blau-ai

blau-ai commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator

Regression triage — iceberg2 / export partition

Verdict: yes, this is caused by this PR. Two real test failures, both losing rows when EXPORT PARTITION writes multiple partitions to a DataLakeCatalog Iceberg table. Not flaky, and the base branch passes these exact tests.

What failed

Out of 1923 scenarios only 2 failed (everything else green — 1847 OK). Both are the "export several partitions, each commits to the same catalog table" case:

Scenario Expected Got
…/ice catalog/replicated merge tree/sanity/export multiple partitions in one ALTER 4 rows 1
…/ice catalog/replicated merge tree/concurrent writes/multi_statement_alter_commits_each_partition 9 rows 6

In both cases some commits are silently dropped (lost writes). Single-partition exports and every other Iceberg scenario pass — so the bug is specifically in accumulating multiple sequential/concurrent commits into one catalog table.

Why it's this PR (not pre-existing, not flaky)

  • Base branch passes the identical tests. The antalya-26.6 nightly (run 37083467466, commit d8345a0) ran the same iceberg suite and had 0 hard fails in iceberg2. The regression commit it used (5f7decba) is a 7-commit descendant of the one your PR run used (25030e18), and none of those 7 commits touch iceberg/ or export_partition/ — so the test code is identical between the two runs. The only variable is the ClickHouse binary.
  • Neither test is xfail'd in the suite (checked iceberg/regression.py xfails — the only export partition xfails are for manifest integrity, truncate, datatypes, KILL, etc.; these two are not listed).
  • Deterministic, not flaky: your PR run (36753531651) auto-retried twice (job.retry=2) and produced the identical assertion each time.
  • The PR rewrites exactly the code these tests exercise. The EXPORT PARTITION driver itself (StorageMergeTree / PartitionCommands) is not touched — only the Iceberg write/commit target is: IcebergMetadata::createInitial, writeMetadataFileAndVersionHint, MetadataGenerator::generateNextMetadata, IcebergWrites.cpp (generateManifestList / initializeMetadata), and the catalog updateMetadata path.

Likely root cause (lost updates on multi-commit append)

Each exported partition is a separate append/commit to the destination catalog table, so N partitions = N snapshots that must chain off each other. The symptom (only a subset of partitions survive, always the "latest wins") is a classic lost-update: a commit that should carry forward the previously-committed snapshot instead overwrites it. The changed hunks that most plausibly cause this:

  • MetadataGenerator.cpp: parent-snapshot-id is now set only when parent_snapshot_id != -1, and

  • IcebergWrites.cpp generateManifestList: previous-snapshot carry-forward is now gated on new_snapshot->has(f_parent_snapshot_id).

    → If a second/retried commit computes parent_snapshot == -1 (e.g. because it reads a stale metadata/catalog pointer rather than the just-committed snapshot), the new snapshot gets no parent and skips manifest carry-forward, so it contains only its own partition's data and silently replaces the prior commit.

  • writeMetadataFileAndVersionHint (Utils.cpp) changed from break (keep file, succeed) to removing the metadata file and returning false on old_version >= metadata_file_info.version. That is the right call only if the caller then re-reads the newly-committed state and retries to completion; combined with the catalog CAS in IcebergWrites::write / catalog->updateMetadata and the new invalidateMetadataCache(), a conflict that doesn't re-read the winning snapshot as the new parent will drop the losing writer's rows.

So the fix should ensure that on a commit conflict/retry, the append re-reads the latest committed snapshot and carries it forward (parent-snapshot-id + manifest list) before writing — rather than overwriting — for the catalog-backed path.

Next step

I have not pushed anything. I can:

  1. Pull clickhouse-server.log from the failing run and pinpoint the exact commit that drops the data (snapshot IDs / parent chain), then
  2. Prepare the fix — by default as a new blau/* PR, or committed directly to releasy/port/pr-98670-2f540f if you'd rather (just confirm, since direct commits need your explicit go-ahead).

Want me to go ahead and dig into the server log + draft the patch?

`MetadataGenerator::generateNextMetadata` no longer writes `parent-snapshot-id`
for the first snapshot of a table, but `buildUpdateMetadataRequestBody` only
added the `assert-ref-snapshot-id` requirement when a parent was present. The
first commit to an empty table therefore had no conflict check: two concurrent
commits (e.g. several partitions of one `EXPORT PARTITION` statement) were both
accepted and the later one replaced `main`, losing the other's data.

Always send the requirement for `main`, with `snapshot-id` only when there is a
parent; without it the catalog asserts that `main` does not exist yet. This
matches upstream ClickHouse#115652.

#2305 (comment)
https://github.com/Altinity/ClickHouse/actions/runs/36753531651
@Selfeer

Selfeer commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator

Last round of audit review, this time done with GLM. If none of this are something we want to fix in this PR - I can mark it as "Verified" from my side.

Audit Review — Defects — Altinity/ClickHouse PR #2305

PR: Altinity/ClickHouse#2305 — Native CREATE TABLE / DROP TABLE for DataLakeCatalog
Audited head: f9b636f20d9 (merge base b73e7588a4f7). None of the findings duplicate the issues already reported in the PR comments.


Defect 1 [Medium] — CREATE TABLE ... AS from an unkeyed S3 Tables table fails with LOGICAL_ERROR: Cannot set engine, it's a bug

Description

SHOW CREATE TABLE for an S3 Tables (location-managed) catalog table omits the ENGINE clause — that's intentional. But when the table has no PARTITION BY and no ORDER BY (the default for tables created via pyiceberg or the AWS console), the create query is produced without any ASTStorage at all. InterpreterCreateQuery::setEngine then falls through every AS-branch condition and hits the generic "this is a bug" else-branch instead of the DataLakeCatalog-aware branch right above it.

Result: a legitimate, documented feature path (CTAS into a DataLakeCatalog, introduced by this PR) throws a bug-class exception for the most common kind of source table.

How to reproduce

-- 1. Have an S3 Tables catalog database (ENGINE = S3TablesCatalog ...) and inside it
--    a table WITHOUT partition/sort keys, e.g. created by pyiceberg or AWS console:
SHOW CREATE TABLE s3tables_db.`ns.src`;
-- returns:  CREATE TABLE s3tables_db.`ns.src` (`id` Int32, ...)   -- no ENGINE, no PARTITION BY, no ORDER BY

-- 2. Try to create a table from it:
CREATE TABLE s3tables_db.`ns.dst` AS s3tables_db.`ns.src`;

Expected: table created (keys copied — there are none), or a clean user-facing error.
Actual: Code: 49. DB::Exception: Cannot set engine, it's a bug.

Note: if the user gives an explicit ENGINE in the CTAS, setEngine returns early (the create.storage branch) and the query succeeds — the failure is specific to the engine-less AS form, i.e. exactly the form the PR's CTAS feature promotes.

Evidence

// src/Databases/DataLake/DatabaseDataLake.cpp:1395 (DatabaseDataLake::getCreateTableQueryImpl, PR head f9b636f20d9)
if (catalog->managesTableLocation())
{
    if (partition_by || order_by)          // unkeyed table: storage clause never set
    {
        auto storage = make_intrusive<ASTStorage>();
        ...
        create_table_query->set(create_table_query->storage, storage);
    }
    return create_table_query;             // <-- create query with NO storage clause at all
}
// src/Interpreters/InterpreterCreateQuery.cpp:1428-1441 (InterpreterCreateQuery::setEngine, PR head)
else if (as_create.storage)                 // nullptr for an unkeyed source -> this branch is SKIPPED
{
    /// A `DataLakeCatalog` that assigns table locations itself shows its tables without `ENGINE`.
    /// Only a `DataLakeCatalog` target can be created from such a table ...
    ...
}
else
{
    throw Exception(ErrorCodes::LOGICAL_ERROR, "Cannot set engine, it's a bug.");   // <-- reached
}

Defect 2 [Medium] — Per-table SETTINGS on the engine clause are accepted at CREATE but silently disappear after restart

Description

The new validation (findUnsupportedDatalakeStorageClause) accepts engine-clause SETTINGS when the engine is explicitly specified (allow_settings=true), and those settings are applied to the live storage created at CREATE time. But nothing persists them:

  • the catalog entry stores no engine settings;
  • SHOW CREATE TABLE wipes them (storage->settings = {});
  • on reopen, tryGetTableImpl rebuilds settings only from the database engine settings.

After a server restart (or any cache eviction + reload) the table silently reverts to database defaults. Concrete impact with Glue: iceberg_use_version_hint = 1 writes metadata/version-hint.text at CREATE; after restart the flag is gone, so every later INSERT / OPTIMIZE / mutation stops updating version-hint.text (all commit paths read data_lake_settings[iceberg_use_version_hint]), and Spark's file-based Iceberg reader — which prefers version-hint.text — keeps reading stale metadata.

How to reproduce

-- 1. Create a table with a per-table engine setting (explicit-engine path, Glue or REST catalog):
CREATE TABLE glue_db.`ns.t` (id Int32)
ENGINE = IcebergS3('s3://bucket/path/', ...)
SETTINGS iceberg_use_version_hint = 1;
-- -> accepted, no error. version-hint.text is maintained on commits.

-- 2. Restart the server (or otherwise force the table to be re-fetched from the catalog).

-- 3. INSERT INTO glue_db.`ns.t` VALUES (1);
-- -> commit no longer updates version-hint.text (setting reverted to default 'false').

-- 4. Confirm: check the file — version-hint.text stays at its old version
--    while new *.metadata.json files appear.

Expected: the setting survives restart (or CREATE rejects it up front).
Actual: accepted at CREATE, silently reverted to DB default on reopen; Spark reads stale data.

Evidence

// src/Interpreters/InterpreterCreateQuery.cpp:1570-1582 (PR head)
const char * findUnsupportedDatalakeStorageClause(const ASTStorage & storage, bool allow_settings)
{
    ...
    if (!allow_settings && storage.settings)   // engine-less path REJECTS SETTINGS,
        return "engine SETTINGS";              // explicit-ENGINE path (allow_settings=true) ACCEPTS them
    return nullptr;
}
// src/Databases/DataLake/DatabaseDataLake.cpp:724-725 (DatabaseDataLake::tryGetTableImpl — the reopen path)
auto storage_settings = std::make_shared<DataLakeStorageSettings>();
storage_settings->loadFromSettingsChanges(settings.allChanged());   // database-level settings ONLY —
                                                                    // nothing per-table is restored
// src/Databases/DataLake/DatabaseDataLake.cpp:1416 (DatabaseDataLake::getCreateTableQueryImpl)
storage->settings = {};   // per-table engine settings are wiped from SHOW CREATE — nothing persists them

Commit paths that consult the (now reverted) setting: IcebergMetadata.cpp:1040, IcebergWrites.cpp:1755, Mutations.cpp:609/964, Compaction.cpp:909.


Defect 3 [Medium] — OPTIMIZE TABLE stops maintaining the Iceberg metadata-log (and truncate/export write the wrong entry)

Description

The PR renamed the second parameter of MetadataGenerator::generateNextMetadata from "the path to append to metadata-log" to "the previous metadata file path" and gated the append on it being non-empty. This is the correct Iceberg semantics (Java Iceberg logs the superseded file). But not all call sites were updated:

Call site (PR head) Passes Result
Mutations.cpp:463/478 reverseResolve(metadata_path) — the previous file correct (fixed by this PR)
IcebergWrites.cpp:1585 previous file or {} when none correct
Compaction.cpp:1024 Iceberg::IcebergPathFromMetadata{} — empty no metadata-log entry at all
IcebergMetadata.cpp:775 (truncate) metadata_info.path — the new file wrong file logged; true previous file orphaned
IcebergMetadata.cpp:1944 (export-partition) metadata_info.path — the new file same

Before this PR, the compaction path appended an entry on every commit (merge base b73e7588 appended unconditionally). After it, OPTIMIZE produces a commit whose metadata-log never grows. External cleanup tools (Spark expireSnapshots metadata cleanup, pyiceberg expire_metadata) derive the list of deletable old metadata files from metadata-log — the pre-OPTIMIZE *.metadata.json files become permanently invisible to them and accumulate on every OPTIMIZE. No test asserts the new behavior, and the PR's own call sites are mutually inconsistent — this looks like an oversight, not a design decision.

How to reproduce

-- 1. Create an Iceberg table in a REST/Glue DataLakeCatalog and insert some rows:
CREATE TABLE dl.`ns.t` (id Int32, dt Date) ENGINE = IcebergS3('s3://...') PARTITION BY dt;
INSERT INTO dl.`ns.t` VALUES (1, '2026-01-01'), ...;

-- 2. Fetch the current metadata JSON, note its file name and the length of "metadata-log".

-- 3. OPTIMIZE TABLE dl.`ns.t`;   -- needs allow_experimental_iceberg_compaction

-- 4. Fetch the new current metadata JSON again.

Expected: metadata-log gained one entry naming the pre-OPTIMIZE metadata file (what Mutations/INSERT commits do).
Actual: metadata-log is unchanged; the pre-OPTIMIZE metadata file is no longer tracked anywhere. (Same check after TRUNCATE shows an entry naming the file that just became current instead.)

Evidence

--- a/src/Storages/ObjectStorage/DataLakes/Iceberg/Compaction.cpp   (PR diff hunk)
         auto new_snapshot = metadata_generator.generateNextMetadata(
             plan.generator,
-            generated_metadata_info.path,
+            Iceberg::IcebergPathFromMetadata{},     // <-- empty: the append is skipped entirely
--- a/src/Storages/ObjectStorage/DataLakes/Iceberg/MetadataGenerator.cpp   (PR diff hunk)
-    const Iceberg::IcebergPathFromMetadata & metadata_file_path,
+    const Iceberg::IcebergPathFromMetadata & previous_metadata_file_path,
...
+    if (!previous_metadata_file_path.empty())       // <-- gate added: empty path = no entry
     {
         Poco::JSON::Object::Ptr new_metadata_item = new Poco::JSON::Object;
-        new_metadata_item->set(Iceberg::f_metadata_file, metadata_file_path.serialize());
+        new_metadata_item->set(Iceberg::f_metadata_file, previous_metadata_file_path.serialize());
// src/Storages/ObjectStorage/DataLakes/Iceberg/IcebergMetadata.cpp:775-777 (truncate path, NOT updated by the PR)
auto [new_snapshot, manifest_list_path] = MetadataGenerator(metadata_object).generateNextMetadata(
    filename_generator, metadata_info.path,   // <-- under the new semantics this is the NEW file,
    parent_snapshot_id, ...                   //     so the current file is logged as "previous"

For contrast, the same PR fixes the mutation path correctly (Mutations.cpp:676):
previous_metadata_path = persistent_table_components.path_resolver.reverseResolve(metadata_path);


Defect 4 [Low] — RENAME DATABASE ... ON CLUSTER skips the new DataLakeCatalog ON CLUSTER guard and fails confusingly on every worker

Description

The PR added an initiator-side check (checkQueryDatabasesSupportOnClusterDDL) so that ON CLUSTER DDL targeting a DataLakeCatalog fails immediately with a clear "Database engine DataLakeCatalog doesn't support ON CLUSTER" error. The ASTRenameQuery branch of that check has a typo-like condition: !rename->database. But ASTRenameQuery::database is a bool flag meaning "this is a RENAME DATABASE query" — so for RENAME DATABASE the condition is false, no target databases are collected, and the guard is bypassed entirely.

The query is then broadcast to every worker in the cluster; each one fails with an unrelated NOT_IMPLEMENTED from IDatabase::renameDatabase (DataLakeCatalog doesn't implement rename). Diagnostics/UX gap only — no data risk, the rename still fails everywhere — but the error the user sees points at the wrong layer and the guard the PR added is dead for exactly the query shape it was written to handle.

How to reproduce

-- Given a DataLakeCatalog database and a cluster with ≥2 nodes:
RENAME DATABASE my_datalake_catalog TO other ON CLUSTER '{cluster}';

Expected (as for CREATE/DROP): immediate initiator-side error — "Database engine DataLakeCatalog doesn't support ON CLUSTER".
Actual: the DDL is enqueued to all cluster nodes; each node reports NOT_IMPLEMENTED: Database engine DataLakeCatalog doesn't support rename (from IDatabase::renameDatabase), the initiator shows distributed-DDL task failures.

Evidence

// src/Interpreters/executeDDLQueryOnCluster.cpp:69 (PR head)
else if (const auto * rename = dynamic_cast<const ASTRenameQuery *>(query_ptr.get()); rename && !rename->database)
//                                                                            ^^^^^^^^^^^^^^^^^ false for RENAME DATABASE:
// ASTRenameQuery::database is a bool flag ("For RENAME DATABASE"), so this branch never runs
// and the query's databases are never checked by checkDatabaseSupportsOnClusterDDL
// src/Parsers/ASTRenameQuery.h (PR head)
bool database{false};   /// For RENAME DATABASE

@zvonand

zvonand commented Oct 6, 2026

Copy link
Copy Markdown
Member Author

@blau-ai

@zvonand

zvonand commented Oct 6, 2026 •

Copy link
Copy Markdown
Member Author

@Selfeer I think you can put a verified-with-issues here. I will merge it and it will go into 26.6 refresh. None of those findings are really blocking us, and I will fix them in subsequent PRs, most likely when porting onto 26.8

@blau-ai

blau-ai commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator

CI triage — no failures caused by this PR

Verdict: 4 distinct red checks, 0 PR-caused. All are pre-existing/infra/flaky and reproduce (or are known-flaky) independent of this change. Head SHA analyzed: f9b636f.

The PR's own additions are green: all 8 Integration-test shards passed (0 failures), the new tests (test_database_glue, test_database_iceberg, test_storage_iceberg_no_spark/test_drop_delete_data_on_drop.py) passed, and Stateless/Fast/Unit/Build/AST-fuzzer/BuzzHouse/SQLStorm plus the Iceberg/Parquet/S3-export regression suites are all OK.

Check Classification PR-caused?
Grype Scan (keeper, server-alpine) Base-image / dependency CVEs ❌ No
SQLLogic test Job hit the 3h wall-clock timeout (SIGKILL) ❌ No
Regression release / Swarms Flaky racy node-restart assertion (1 scenario) ❌ No
PR Aggregate gate — red only because of the above ❌ No

1. Grype Scan — keeper (4 high/crit), server-alpine (1 high/crit)

Container-image vulnerability scans over the built Docker images. These flag CVEs in the base image / bundled OS packages, not in anything this C++ PR touches. The same scans also fail on unrelated PRs (e.g. #2477, an error-codes change) — it's branch-wide, handled at the release/base-image level, not in a feature PR.
→ Next step: ignore for this PR.

2. SQLLogic test — job timeout (exit 137)

Not an assertion failure. From the job log, the job ran out of wall-clock time and was SIGKILLed:

[18:18:09] Run complete-test
[20:30:53] WARNING: Timeout exceeded [10800] for [5260]
[20:31:00] WARNING: Job timed out: [SQLLogic test], timeout [10800], exit code [137]
[20:31:00] ERROR: Run failed with exit code [137]

statements-test alone took ~47 min and complete-test ran 2h12m before the 3h cap. SQLLogic runs the generic gregrahn/sqllogictest corpus (plain SELECT/statements) — it has no code path through DataLake catalogs or the DDL this PR changes, and SQLLogic test is also red on unrelated PR #2477. Pre-existing/infra.
→ Next step: safe to re-run; the persistent timeout is a suite/job-sizing issue on antalya-26.6, not something this PR should fix.

3. Regression release / Swarms — 1 flaky scenario

1535 scenarios (1520 ok, 1 failed, 14 skipped). The single failure is /swarms/feature/node failure/check restart swarm node (swarms/tests/node_failure.py:108, part of the external testflows regression suite — not in this PR's diff):

Description
  Expected exit code in [138], but got: 0

The test runs a long SELECT ... sleepEachRow() from an iceberg table over a swarm cluster while a node is restarted, and asserts the query is interrupted (exit 138). Here the query instead completed successfully (exit 0) — i.e. the restart didn't land during the query window. The test's own docstring states this is "inherently racy: stopping a swarm node mid-query can surface either a graceful cancellation or an abrupt connection drop, and both are valid." The allow-list is just [138], so a clean completion trips it. A query succeeding is not a regression this PR introduced, and Swarms passed on #2477.
→ Next step: re-run the Swarms regression; it typically passes. (Separately, the regression suite could widen that allow-list to include 0, but that lives in the QA/testflows repo, not here.)

4. PR

Aggregate status rollup — red solely because checks 1–3 are red. No independent failure.


Bottom line: nothing here blocks the change on technical grounds. I'd re-run Swarms (flaky) and SQLLogic (timeout); Grype is a branch-wide base-image concern independent of this PR. No code fix is warranted on releasy/port/pr-98670-2f540f for these failures.

🤖 Automated CI triage by @blau-ai. Evidence: praktika result_pr.json, sqllogic_test/job.log, and the Swarms job log for f9b636f. I can't build/run ClickHouse in-container, so this is evidence-based from CI artifacts.

@Selfeer Selfeer added verified Approved for release verified-with-issues Verified by QA and issues found. labels Oct 7, 2026
@Selfeer

Selfeer commented Oct 7, 2026

Copy link
Copy Markdown
Collaborator

I'm okay with the merge. Marked both as "Verified" and "Verified with issues" so we have correct track. The regression fails are unrelated to changes here.

@zvonand
zvonand merged commit beba45e into antalya-26.6 Oct 7, 2026
307 of 316 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-needs-verify antalya antalya-26.6 antalya-26.6.8.20001 port-antalya PRs to be ported to all new Antalya releases verified Approved for release verified-with-issues Verified by QA and issues found.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants