Files

1943 lines
77 KiB
Python

import io
import json
import logging
import random
import re
import time
import uuid
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
import pyarrow as pa
import pytest
import requests
import pytz
from pyiceberg.catalog import load_catalog
from pyiceberg.partitioning import PartitionField, PartitionSpec
from pyiceberg.schema import Schema
from pyiceberg.table.sorting import SortField, SortOrder
from pyiceberg.transforms import DayTransform, IdentityTransform
from pyiceberg.types import (
DoubleType,
NestedField,
StringType,
StructType,
TimestampType,
TimestamptzType
)
from helpers.cluster import ClickHouseCluster
from helpers.config_cluster import minio_secret_key, minio_access_key
from helpers.client import QueryRuntimeException
from helpers.s3_tools import get_file_contents, list_s3_objects
BASE_URL = "http://rest:8181/v1"
CATALOG_NAME = "demo"
DEFAULT_SCHEMA = Schema(
NestedField(
field_id=1, name="datetime", field_type=TimestampType(), required=False
),
NestedField(field_id=2, name="symbol", field_type=StringType(), required=False),
NestedField(field_id=3, name="bid", field_type=DoubleType(), required=False),
NestedField(field_id=4, name="ask", field_type=DoubleType(), required=False),
NestedField(
field_id=5,
name="details",
field_type=StructType(
NestedField(
field_id=4,
name="created_by",
field_type=StringType(),
required=False,
),
),
required=False,
),
)
DEFAULT_CREATE_TABLE = "CREATE TABLE {}.`{}.{}`\\n(\\n `datetime` Nullable(DateTime64(6)),\\n `symbol` Nullable(String),\\n `bid` Nullable(Float64),\\n `ask` Nullable(Float64),\\n `details` Tuple(created_by Nullable(String))\\n)\\nENGINE = Iceberg(\\'http://minio1:9001/warehouse-rest/data/\\', \\'minio\\', \\'[HIDDEN]\\')\n"
DEFAULT_PARTITION_SPEC = PartitionSpec(
PartitionField(
source_id=1, field_id=1000, transform=DayTransform(), name="datetime_day"
)
)
DEFAULT_SORT_ORDER = SortOrder(SortField(source_id=2, transform=IdentityTransform()))
def list_namespaces(started_cluster):
base_url_local = f"http://localhost:{started_cluster.iceberg_rest_catalog_port}/v1"
response = requests.get(f"{base_url_local}/namespaces")
if response.status_code == 200:
return response.json()
else:
raise Exception(f"Failed to list namespaces: {response.status_code}")
def load_catalog_impl(started_cluster):
base_url_local_raw = f"http://localhost:{started_cluster.iceberg_rest_catalog_port}"
return load_catalog(
CATALOG_NAME,
**{
"uri": base_url_local_raw,
"type": "rest",
"s3.endpoint": f"http://{started_cluster.minio_ip}:{started_cluster.minio_port}",
"s3.access-key-id": minio_access_key,
"s3.secret-access-key": minio_secret_key,
},
)
def create_table(
catalog,
namespace,
table,
schema=DEFAULT_SCHEMA,
partition_spec=DEFAULT_PARTITION_SPEC,
sort_order=DEFAULT_SORT_ORDER,
):
return catalog.create_table(
identifier=f"{namespace}.{table}",
schema=schema,
location="s3://warehouse-rest/data",
partition_spec=partition_spec,
sort_order=sort_order,
)
def generate_record():
return {
"datetime": datetime.now(),
"symbol": str("kek"),
"bid": round(random.uniform(100, 200), 2),
"ask": round(random.uniform(200, 300), 2),
"details": {"created_by": "Alice Smith"},
}
def create_clickhouse_iceberg_database(
started_cluster, node, name, additional_settings={}
):
settings = {
"catalog_type": "rest",
"warehouse": "demo",
"storage_endpoint": "http://minio1:9001/warehouse-rest",
}
settings.update(additional_settings)
node.query(
f"""
DROP DATABASE IF EXISTS {name};
CREATE DATABASE {name} ENGINE = DataLakeCatalog('{BASE_URL}', 'minio', '{minio_secret_key}')
SETTINGS {",".join((k+"="+repr(v) for k, v in settings.items()))}
""",
settings={
"allow_database_iceberg": 1,
"write_full_path_in_iceberg_metadata": 1,
},
)
show_result = node.query(f"SHOW DATABASE {name}")
assert minio_secret_key not in show_result
assert "HIDDEN" in show_result
def create_clickhouse_iceberg_table(
started_cluster, node, database_name, table_name, schema, additional_settings={}
):
settings_suffix = "" if len(additional_settings) == 0 else f"SETTINGS {",".join((k+"="+repr(v) for k, v in additional_settings.items()))}"
node.query(
f"""
CREATE TABLE {CATALOG_NAME}.`{database_name}.{table_name}` {schema} ENGINE = IcebergS3('http://minio1:9001/warehouse-rest/{table_name}/', '{minio_access_key}', '{minio_secret_key}')
{settings_suffix}
""",
settings={
"allow_experimental_database_iceberg": 1,
"write_full_path_in_iceberg_metadata": 1,
},
)
def drop_clickhouse_iceberg_table(
node, database_name, table_name, if_exists=False
):
if if_exists:
node.query(
f"""
DROP TABLE IF EXISTS {CATALOG_NAME}.`{database_name}.{table_name}`
"""
)
else:
node.query(
f"""
DROP TABLE {CATALOG_NAME}.`{database_name}.{table_name}`
"""
)
@pytest.fixture(scope="module")
def started_cluster():
try:
cluster = ClickHouseCluster(__file__)
cluster.add_instance(
"node1",
main_configs=[
"configs/backups.xml",
"configs/cluster.xml",
"configs/text_log.xml",
"configs/display_secrets.xml",
],
user_configs=[],
stay_alive=True,
with_iceberg_catalog=True,
)
cluster.add_instance(
"node2",
main_configs=[
"configs/backups.xml",
"configs/cluster.xml",
"configs/text_log.xml",
],
user_configs=[],
stay_alive=True,
with_iceberg_catalog=True,
)
logging.info("Starting cluster...")
cluster.start()
# TODO: properly wait for container
time.sleep(10)
yield cluster
finally:
cluster.shutdown()
def test_list_tables(started_cluster):
node = started_cluster.instances["node1"]
root_namespace = f"clickhouse_{uuid.uuid4()}"
namespace_1 = f"{root_namespace}.testA.A"
namespace_2 = f"{root_namespace}.testB.B"
namespace_1_tables = ["tableA", "tableB"]
namespace_2_tables = ["tableC", "tableD"]
catalog = load_catalog_impl(started_cluster)
for namespace in [namespace_1, namespace_2]:
catalog.create_namespace(namespace)
found = False
for namespace_list in list_namespaces(started_cluster)["namespaces"]:
if root_namespace == namespace_list[0]:
found = True
break
assert found
found = False
for namespace_list in catalog.list_namespaces():
if root_namespace == namespace_list[0]:
found = True
break
assert found
for namespace in [namespace_1, namespace_2]:
assert len(catalog.list_tables(namespace)) == 0
create_clickhouse_iceberg_database(started_cluster, node, CATALOG_NAME)
tables_list = ""
for table in namespace_1_tables:
create_table(catalog, namespace_1, table)
if len(tables_list) > 0:
tables_list += "\n"
tables_list += f"{namespace_1}.{table}"
for table in namespace_2_tables:
create_table(catalog, namespace_2, table)
if len(tables_list) > 0:
tables_list += "\n"
tables_list += f"{namespace_2}.{table}"
assert (
tables_list
== node.query(
f"SELECT name FROM system.tables WHERE database = '{CATALOG_NAME}' and name ILIKE '{root_namespace}%' ORDER BY name SETTINGS show_data_lake_catalogs_in_system_tables = true"
).strip()
)
node.restart_clickhouse()
assert (
tables_list
== node.query(
f"SELECT name FROM system.tables WHERE database = '{CATALOG_NAME}' and name ILIKE '{root_namespace}%' ORDER BY name SETTINGS show_data_lake_catalogs_in_system_tables = true"
).strip()
)
expected = DEFAULT_CREATE_TABLE.format(CATALOG_NAME, namespace_2, "tableC")
assert expected == node.query(
f"SHOW CREATE TABLE {CATALOG_NAME}.`{namespace_2}.tableC`"
)
def escape_like_literal(s):
# Escape SQL LIKE wildcards (`%`, `_`) and `\` so the value matches literally
# (ClickHouse keeps the backslash, so one backslash in the query text suffices).
return re.sub(r"([\\%_])", r"\\\1", s)
def test_namespace_filter_pushdown(started_cluster):
"""
Verify that `system.tables` predicates that fully bind the namespace
(`name = '<ns>.<table>'`, `name LIKE '<ns>.%'`) only fetch the table list
from the targeted namespace instead of enumerating the whole catalog.
See issue #105022.
Checking the result rows alone is not enough: an implementation that lists
the whole catalog and filters in memory would return the same rows. To prove
the scoped catalog API is actually used we also count the per-namespace
`Received tables response for namespace: <ns>` log line that `RestCatalog`
emits for every namespace whose `.../tables` endpoint it hits. A scoped query
must bump the count for the targeted namespace while leaving the sibling
namespace untouched; a regression to a full-catalog scan would also fetch the
sibling and fail the assertion.
"""
node = started_cluster.instances["node1"]
root_namespace = f"clickhouse_{uuid.uuid4()}"
namespace_1 = f"{root_namespace}.target.scope"
namespace_2 = f"{root_namespace}.other.scope"
namespace_1_tables = ["scoped_a", "scoped_b"]
namespace_2_tables = ["other_a", "other_b"]
catalog = load_catalog_impl(started_cluster)
for namespace in [namespace_1, namespace_2]:
catalog.create_namespace(namespace)
create_clickhouse_iceberg_database(started_cluster, node, CATALOG_NAME)
for table in namespace_1_tables:
create_table(catalog, namespace_1, table)
for table in namespace_2_tables:
create_table(catalog, namespace_2, table)
def namespace_listings(namespace):
# Number of times RestCatalog has fetched the table list of `namespace`
# so far. `count_in_log` only scans the current (non-rotated) log file,
# which is what we want for before/after deltas within a single test.
return int(
node.count_in_log(f"Received tables response for namespace: {namespace}")
)
def assert_scoped(query, expected):
# Run a query that should be scoped to `namespace_1` and assert both the
# result rows and that only the target namespace's table list was fetched.
before_target = namespace_listings(namespace_1)
before_sibling = namespace_listings(namespace_2)
assert expected == node.query(query).strip()
# The catalog requests run on a background thread pool, so the log line
# may land slightly after the query returns. Wait for the target listing
# to confirm the query really reached the catalog before checking that the
# sibling was left alone.
for _ in range(30):
if namespace_listings(namespace_1) > before_target:
break
time.sleep(0.5)
else:
raise AssertionError(
f"Scoped query did not fetch the table list of '{namespace_1}': {query}"
)
assert namespace_listings(namespace_2) == before_sibling, (
f"Scoped query for '{namespace_1}' also fetched the sibling namespace "
f"'{namespace_2}' — namespace push-down regressed to a full-catalog "
f"scan: {query}"
)
expected_ns1 = "\n".join(sorted(f"{namespace_1}.{t}" for t in namespace_1_tables))
# Case-sensitive LIKE pushdown. The namespace's literal `_` is a LIKE wildcard,
# so escape it (`\_`) to bind the namespace exactly.
assert_scoped(
f"SELECT name FROM system.tables WHERE database = '{CATALOG_NAME}' AND name LIKE '{escape_like_literal(namespace_1)}.%' ORDER BY name "
"SETTINGS show_data_lake_catalogs_in_system_tables = true",
expected_ns1,
)
# `startsWith` pushdown, pinned directly: the analyzer rewrites perfect-prefix
# `name LIKE 'prefix%'` to `startsWith(name, 'prefix')`, which must also scope.
assert_scoped(
f"SELECT name FROM system.tables WHERE database = '{CATALOG_NAME}' AND startsWith(name, '{namespace_1}.') ORDER BY name "
"SETTINGS show_data_lake_catalogs_in_system_tables = true",
expected_ns1,
)
# The same query written as `LIKE`, with the rewrite forced on, to guard the
# analyzer-rewrite path end-to-end even if the default flips in the future.
assert_scoped(
f"SELECT name FROM system.tables WHERE database = '{CATALOG_NAME}' AND name LIKE '{escape_like_literal(namespace_1)}.%' ORDER BY name "
"SETTINGS show_data_lake_catalogs_in_system_tables = true, optimize_rewrite_like_perfect_affix = 1",
expected_ns1,
)
# Equality pushdown for a fully-qualified table name.
one_table = f"{namespace_1}.{namespace_1_tables[0]}"
assert_scoped(
f"SELECT name FROM system.tables WHERE database = '{CATALOG_NAME}' AND name = '{one_table}' ORDER BY name "
"SETTINGS show_data_lake_catalogs_in_system_tables = true",
one_table,
)
def test_check_database(started_cluster):
node = started_cluster.instances["node1"]
root_namespace = f"clickhouse_{uuid.uuid4()}"
namespace_1 = f"{root_namespace}.testA.A"
namespace_2 = f"{root_namespace}.testB.B"
namespace_1_tables = ["tableA", "tableB"]
namespace_2_tables = ["tableC", "tableD"]
catalog = load_catalog_impl(started_cluster)
for namespace in [namespace_1, namespace_2]:
catalog.create_namespace(namespace)
for namespace in [namespace_1, namespace_2]:
assert len(catalog.list_tables(namespace)) == 0
create_clickhouse_iceberg_database(started_cluster, node, CATALOG_NAME)
tables_list = ""
for table in namespace_1_tables:
create_table(catalog, namespace_1, table)
if len(tables_list) > 0:
tables_list += "\n"
tables_list += f"{namespace_1}.{table}"
for table in namespace_2_tables:
create_table(catalog, namespace_2, table)
if len(tables_list) > 0:
tables_list += "\n"
tables_list += f"{namespace_2}.{table}"
assert (
tables_list
== node.query(
f"SELECT name FROM system.tables WHERE database = '{CATALOG_NAME}' and name ILIKE '{root_namespace}%' ORDER BY name SETTINGS show_data_lake_catalogs_in_system_tables = true"
).strip()
)
node.restart_clickhouse()
assert (
tables_list
== node.query(
f"SELECT name FROM system.tables WHERE database = '{CATALOG_NAME}' and name ILIKE '{root_namespace}%' ORDER BY name SETTINGS show_data_lake_catalogs_in_system_tables = true"
).strip()
)
node.query(
f"CHECK DATABASE {CATALOG_NAME}"
)
try:
node.query(
"SYSTEM ENABLE FAILPOINT check_database_datalake_negative"
)
assert "fault when checking database" in node.query_and_get_error(
f"CHECK DATABASE {CATALOG_NAME}"
)
finally:
node.query(
"SYSTEM DISABLE FAILPOINT check_database_datalake_negative"
)
def test_many_namespaces(started_cluster):
node = started_cluster.instances["node1"]
root_namespace_1 = f"A_{uuid.uuid4()}"
root_namespace_2 = f"B_{uuid.uuid4()}"
namespaces = [
f"{root_namespace_1}",
f"{root_namespace_1}.B.C",
f"{root_namespace_1}.B.C.D",
f"{root_namespace_1}.B.C.D.E",
f"{root_namespace_2}",
f"{root_namespace_2}.C",
f"{root_namespace_2}.CC",
]
tables = ["A", "B", "C"]
catalog = load_catalog_impl(started_cluster)
for namespace in namespaces:
catalog.create_namespace(namespace)
for table in tables:
create_table(catalog, namespace, table)
create_clickhouse_iceberg_database(started_cluster, node, CATALOG_NAME)
for namespace in namespaces:
for table in tables:
table_name = f"{namespace}.{table}"
assert int(
node.query(
f"SELECT count() FROM system.tables WHERE database = '{CATALOG_NAME}' and name = '{table_name}' SETTINGS show_data_lake_catalogs_in_system_tables = true"
)
)
def test_select(started_cluster):
node = started_cluster.instances["node1"]
test_ref = f"test_list_tables_{uuid.uuid4()}"
table_name = f"{test_ref}_table"
root_namespace = f"{test_ref}_namespace"
namespace = f"{root_namespace}.A.B.C"
namespaces_to_create = [
root_namespace,
f"{root_namespace}.A",
f"{root_namespace}.A.B",
f"{root_namespace}.A.B.C",
]
catalog = load_catalog_impl(started_cluster)
for namespace in namespaces_to_create:
catalog.create_namespace(namespace)
assert len(catalog.list_tables(namespace)) == 0
table = create_table(catalog, namespace, table_name)
num_rows = 10
data = [generate_record() for _ in range(num_rows)]
df = pa.Table.from_pylist(data)
table.append(df)
create_clickhouse_iceberg_database(started_cluster, node, CATALOG_NAME)
expected = DEFAULT_CREATE_TABLE.format(CATALOG_NAME, namespace, table_name)
assert expected == node.query(
f"SHOW CREATE TABLE {CATALOG_NAME}.`{namespace}.{table_name}`"
)
assert num_rows == int(
node.query(
# Regression test: a session temp table used to be pinned by the query context
# captured in the S3 client refresher and cached with the manifest file in the
# global IcebergMetadataFilesCache, crashing the graceful restart below with a
# use-after-free. The SELECT * is required: it reads a manifest file (count()
# alone is served from the snapshot summary). All statements must stay in one
# node.query call = one session.
f"CREATE TEMPORARY TABLE pin_me (x UInt8) ENGINE = Memory;"
f"SELECT * FROM {CATALOG_NAME}.`{namespace}.{table_name}` FORMAT Null;"
f"SELECT count() FROM {CATALOG_NAME}.`{namespace}.{table_name}`"
)
)
assert int(node.query(f"SELECT count() FROM system.iceberg_history WHERE table = '{namespace}.{table_name}' and database = '{CATALOG_NAME}'").strip()) == 1
# Replays the graceful shutdown; the teardown sanitizer check catches the UAF if it regresses.
node.restart_clickhouse()
def test_hide_sensitive_info(started_cluster):
node = started_cluster.instances["node1"]
test_ref = f"test_hide_sensitive_info_{uuid.uuid4()}"
table_name = f"{test_ref}_table"
root_namespace = f"{test_ref}_namespace"
namespace = f"{root_namespace}.A"
catalog = load_catalog_impl(started_cluster)
catalog.create_namespace(namespace)
create_table(catalog, namespace, table_name)
def check_secret_hidden(secret, additional_settings):
settings = {
"catalog_type": "rest",
"warehouse": "demo",
"storage_endpoint": "http://minio1:9001/warehouse-rest",
}
settings.update(additional_settings)
node.query(f"DROP DATABASE IF EXISTS {CATALOG_NAME}")
try:
node.query(
f"""CREATE DATABASE {CATALOG_NAME} ENGINE = DataLakeCatalog('{BASE_URL}', 'minio', '{minio_secret_key}')
SETTINGS {",".join((k + "=" + repr(v) for k, v in settings.items()))}""",
settings={
"allow_database_iceberg": 1,
"write_full_path_in_iceberg_metadata": 1,
},
)
except QueryRuntimeException as e:
assert secret not in str(e), (
f"Secret {secret!r} leaked into CREATE DATABASE error message"
)
return
assert secret not in node.query(f"SHOW CREATE DATABASE {CATALOG_NAME}")
check_secret_hidden("SECRET_1", {"catalog_credential": "id:SECRET_1"})
check_secret_hidden("SECRET_2", {"auth_header": "Authorization: SECRET_2"})
def test_no_secrets_in_logs(started_cluster):
node = started_cluster.instances["node1"]
db_name = f"iceberg_query_log_{uuid.uuid4().hex}"
root_namespace = f"log_check_ns_{uuid.uuid4().hex}"
table_name = f"log_check_tbl_{uuid.uuid4().hex}"
catalog = load_catalog_impl(started_cluster)
catalog.create_namespace(root_namespace)
db_settings = {
"catalog_type": "rest",
"warehouse": "demo",
"storage_endpoint": "http://minio1:9001/warehouse-rest",
}
qid_db = uuid.uuid4().hex
node.query(f"DROP DATABASE IF EXISTS {db_name}")
node.query(
f"""CREATE DATABASE {db_name} ENGINE = DataLakeCatalog('{BASE_URL}', 'minio', '{minio_secret_key}')
SETTINGS {",".join((k + "=" + repr(v) for k, v in db_settings.items()))}""",
query_id=qid_db,
settings={
"allow_database_iceberg": 1,
"write_full_path_in_iceberg_metadata": 1,
},
)
qid_table = uuid.uuid4().hex
node.query(
f"""CREATE TABLE {db_name}.`{root_namespace}.{table_name}` (x String) ENGINE = IcebergS3('http://minio1:9001/warehouse-rest/{table_name}/', '{minio_access_key}', '{minio_secret_key}')""",
query_id=qid_table,
settings={
"allow_experimental_database_iceberg": 1,
"write_full_path_in_iceberg_metadata": 1,
},
)
qid_show_db = uuid.uuid4().hex
show_db_result = node.query(
f"SHOW CREATE DATABASE {db_name}", query_id=qid_show_db
)
assert minio_secret_key not in show_db_result
assert "[HIDDEN]" in show_db_result
qid_show_table = uuid.uuid4().hex
show_table_result = node.query(
f"SHOW CREATE TABLE {db_name}.`{root_namespace}.{table_name}`",
query_id=qid_show_table,
)
assert minio_secret_key not in show_table_result
assert "[HIDDEN]" in show_table_result
node.query("SYSTEM FLUSH LOGS system.query_log")
node.query("SYSTEM FLUSH LOGS system.text_log")
for qid in (qid_db, qid_table, qid_show_db, qid_show_table):
assert (
int(
node.query(
f"SELECT count() FROM system.query_log WHERE query_id = '{qid}' AND type = 'QueryFinish'"
).strip()
)
>= 1
)
query_text = node.query(
f"SELECT arrayStringConcat(groupArray(query), '\\n') FROM system.query_log WHERE query_id = '{qid}' AND type = 'QueryFinish'"
).strip()
assert minio_secret_key not in query_text
text_log_rows = node.query(
f"""
SELECT message, value1, value2, value3, value4, value5, value6, value7, value8, value9, value10
FROM system.text_log
WHERE query_id IN ('{qid_db}', '{qid_table}', '{qid_show_db}', '{qid_show_table}')
FORMAT JSONEachRow
"""
).strip()
assert text_log_rows
for line in text_log_rows.split("\n"):
row = json.loads(line)
for val in row.values():
if isinstance(val, str):
assert minio_secret_key not in val
def test_tables_with_same_location(started_cluster):
node = started_cluster.instances["node1"]
test_ref = f"test_tables_with_same_location_{uuid.uuid4()}"
namespace = f"{test_ref}_namespace"
catalog = load_catalog_impl(started_cluster)
table_name = f"{test_ref}_table"
table_name_2 = f"{test_ref}_table_2"
catalog.create_namespace(namespace)
table = create_table(catalog, namespace, table_name)
table_2 = create_table(catalog, namespace, table_name_2)
def record(key):
return {
"datetime": datetime.now(),
"symbol": str(key),
"bid": round(random.uniform(100, 200), 2),
"ask": round(random.uniform(200, 300), 2),
"details": {"created_by": "Alice Smith"},
}
data = [record('aaa') for _ in range(3)]
df = pa.Table.from_pylist(data)
table.append(df)
data = [record('bbb') for _ in range(3)]
df = pa.Table.from_pylist(data)
table_2.append(df)
create_clickhouse_iceberg_database(started_cluster, node, CATALOG_NAME)
assert 'aaa\naaa\naaa' == node.query(f"SELECT symbol FROM {CATALOG_NAME}.`{namespace}.{table_name}`").strip()
assert 'bbb\nbbb\nbbb' == node.query(f"SELECT symbol FROM {CATALOG_NAME}.`{namespace}.{table_name_2}`").strip()
def test_backup_database(started_cluster):
node = started_cluster.instances["node1"]
create_clickhouse_iceberg_database(started_cluster, node, "backup_database")
backup_id = uuid.uuid4().hex
backup_name = f"File('/backups/test_backup_{backup_id}/')"
node.query(f"BACKUP DATABASE backup_database TO {backup_name}")
node.query("DROP DATABASE backup_database SYNC")
assert "backup_database" not in node.query("SHOW DATABASES")
node.query(f"RESTORE DATABASE backup_database FROM {backup_name}", settings={"allow_database_iceberg": 1})
assert (
node.query("SHOW CREATE DATABASE backup_database")
== "CREATE DATABASE backup_database\\nENGINE = DataLakeCatalog(\\'http://rest:8181/v1\\', \\'minio\\', \\'[HIDDEN]\\')\\nSETTINGS catalog_type = \\'rest\\', warehouse = \\'demo\\', storage_endpoint = \\'http://minio1:9001/warehouse-rest\\'\n"
)
def test_restore_database_replace_external_to_null(started_cluster):
node = started_cluster.instances["node1"]
db_name = "backup_database_null"
create_clickhouse_iceberg_database(started_cluster, node, db_name)
backup_id = uuid.uuid4().hex
backup_name = f"File('/backups/test_backup_{backup_id}/')"
node.query(f"BACKUP DATABASE {db_name} TO {backup_name}")
node.query(f"DROP DATABASE {db_name} SYNC")
assert db_name not in node.query("SHOW DATABASES")
node.query(
f"RESTORE DATABASE {db_name} FROM {backup_name}",
settings={
"restore_replace_external_engines_to_null": 1,
"restore_replace_external_table_functions_to_null": 1,
"restore_replace_external_dictionary_source_to_null": 1,
},
)
assert db_name not in node.query("SHOW DATABASES")
def test_non_existing_tables(started_cluster):
node = started_cluster.instances["node1"]
test_ref = f"test_list_tables_{uuid.uuid4()}"
table_name = f"{test_ref}_table"
root_namespace = f"{test_ref}_namespace"
namespace = f"{root_namespace}.A.B.C"
namespaces_to_create = [
root_namespace,
f"{root_namespace}.A",
f"{root_namespace}.A.B",
f"{root_namespace}.A.B.C",
]
catalog = load_catalog_impl(started_cluster)
for namespace in namespaces_to_create:
catalog.create_namespace(namespace)
assert len(catalog.list_tables(namespace)) == 0
table = create_table(catalog, namespace, table_name)
num_rows = 10
data = [generate_record() for _ in range(num_rows)]
df = pa.Table.from_pylist(data)
table.append(df)
create_clickhouse_iceberg_database(started_cluster, node, CATALOG_NAME)
expected = DEFAULT_CREATE_TABLE.format(CATALOG_NAME, namespace, table_name)
assert expected == node.query(
f"SHOW CREATE TABLE {CATALOG_NAME}.`{namespace}.{table_name}`"
)
try:
node.query(
f"SHOW CREATE TABLE {CATALOG_NAME}.`{namespace}.qweqwe`"
)
except Exception as e:
assert "DB::Exception: Table" in str(e)
assert "doesn't exist" in str(e)
try:
node.query(
f"SHOW CREATE TABLE {CATALOG_NAME}.`qweqwe.qweqwe`"
)
except Exception as e:
assert "DB::Exception: Table" in str(e)
assert "doesn't exist" in str(e)
def test_timestamps(started_cluster):
node = started_cluster.instances["node1"]
test_ref = f"test_list_tables_{uuid.uuid4()}"
table_name = f"{test_ref}_table"
root_namespace = f"{test_ref}_namespace"
catalog = load_catalog_impl(started_cluster)
catalog.create_namespace(root_namespace)
schema = Schema(
NestedField(
field_id=1, name="timestamp", field_type=TimestampType(), required=False
),
NestedField(
field_id=2,
name="timestamptz",
field_type=TimestamptzType(),
required=False,
),
)
table = create_table(catalog, root_namespace, table_name, schema)
create_clickhouse_iceberg_database(started_cluster, node, CATALOG_NAME)
data = [
{
"timestamp": datetime(2024, 1, 1, hour=12, minute=0, second=0, microsecond=0),
"timestamptz": datetime(
2024,
1,
1,
hour=12,
minute=0,
second=0,
microsecond=0,
tzinfo=pytz.timezone("UTC"),
)
}
]
df = pa.Table.from_pylist(data)
table.append(df)
assert node.query(f"SHOW CREATE TABLE {CATALOG_NAME}.`{root_namespace}.{table_name}`") == f"CREATE TABLE {CATALOG_NAME}.`{root_namespace}.{table_name}`\\n(\\n `timestamp` Nullable(DateTime64(6)),\\n `timestamptz` Nullable(DateTime64(6, \\'UTC\\'))\\n)\\nENGINE = Iceberg(\\'http://minio1:9001/warehouse-rest/data/\\', \\'minio\\', \\'[HIDDEN]\\')\n"
assert node.query(f"SELECT * FROM {CATALOG_NAME}.`{root_namespace}.{table_name}`") == "2024-01-01 12:00:00.000000\t2024-01-01 12:00:00.000000\n"
def test_insert(started_cluster):
node = started_cluster.instances["node1"]
test_ref = f"test_list_tables_{uuid.uuid4()}"
table_name = f"{test_ref}_table"
root_namespace = f"{test_ref}_namespace"
catalog = load_catalog_impl(started_cluster)
catalog.create_namespace(root_namespace)
create_table(catalog, root_namespace, table_name, DEFAULT_SCHEMA, PartitionSpec(), DEFAULT_SORT_ORDER)
create_clickhouse_iceberg_database(started_cluster, node, CATALOG_NAME)
node.query(f"INSERT INTO {CATALOG_NAME}.`{root_namespace}.{table_name}` VALUES (NULL, 'AAPL', 193.24, 193.31, tuple('bot'));", settings={"allow_insert_into_iceberg": 1, 'write_full_path_in_iceberg_metadata': 1})
catalog.load_table(f"{root_namespace}.{table_name}")
assert node.query(f"SELECT * FROM {CATALOG_NAME}.`{root_namespace}.{table_name}`") == "\\N\tAAPL\t193.24\t193.31\t('bot')\n"
node.query(f"INSERT INTO {CATALOG_NAME}.`{root_namespace}.{table_name}` VALUES (NULL, 'Pavel Ivanov (pudge1000-7) pereezhai v amsterdam', 193.24, 193.31, tuple('bot'));", settings={"allow_insert_into_iceberg": 1, 'write_full_path_in_iceberg_metadata': 1})
assert node.query(f"SELECT * FROM {CATALOG_NAME}.`{root_namespace}.{table_name}` ORDER BY ALL") == "\\N\tAAPL\t193.24\t193.31\t('bot')\n\\N\tPavel Ivanov (pudge1000-7) pereezhai v amsterdam\t193.24\t193.31\t('bot')\n"
def test_optimize_manifest_with_catalog(started_cluster):
# OPTIMIZE TABLE ... MANIFEST on a catalog-managed table must consolidate the per-insert manifests
# and commit the new snapshot back through the catalog, without changing the data.
node = started_cluster.instances["node1"]
test_ref = f"test_optimize_manifest_{uuid.uuid4()}"
table_name = f"{test_ref}_table"
root_namespace = f"{test_ref}_namespace"
catalog = load_catalog_impl(started_cluster)
catalog.create_namespace(root_namespace)
# Unpartitioned table, so every per-insert data manifest can consolidate into a single one.
create_table(catalog, root_namespace, table_name, DEFAULT_SCHEMA, PartitionSpec(), DEFAULT_SORT_ORDER)
create_clickhouse_iceberg_database(started_cluster, node, CATALOG_NAME)
table_ref = f"{CATALOG_NAME}.`{root_namespace}.{table_name}`"
write_settings = {"allow_insert_into_iceberg": 1, "write_full_path_in_iceberg_metadata": 1}
# Several separate inserts -> several snapshots, each adding its own data manifest.
num_inserts = 5
for i in range(num_inserts):
node.query(
f"INSERT INTO {table_ref} VALUES (NULL, 'sym{i}', {100 + i}, {200 + i}, tuple('bot'));",
settings=write_settings,
)
def current_snapshot_id():
# Read the current snapshot from the catalog's metadata.json (avoids parsing the manifest-list
# Avro, which pyiceberg rejects because ClickHouse omits field-ids there).
table = catalog.load_table(f"{root_namespace}.{table_name}")
assert table.current_snapshot() is not None, "expected a current snapshot after inserts"
return table.metadata.current_snapshot_id
snapshot_id_before = current_snapshot_id()
rows_before = node.query(f"SELECT symbol, bid, ask FROM {table_ref} ORDER BY ALL")
node.query(
f"OPTIMIZE TABLE {table_ref} MANIFEST",
settings={
"allow_experimental_iceberg_compaction": 1,
"iceberg_manifest_min_count_to_compact": 2,
"allow_insert_into_iceberg": 1,
"write_full_path_in_iceberg_metadata": 1,
},
)
# The compaction must commit a new (replace) snapshot back through the catalog.
assert current_snapshot_id() != snapshot_id_before, (
"OPTIMIZE TABLE ... MANIFEST did not commit a new snapshot through the catalog"
)
# The metadata-only rewrite must not change the data.
rows_after = node.query(f"SELECT symbol, bid, ask FROM {table_ref} ORDER BY ALL")
assert rows_after == rows_before
@pytest.mark.parametrize(
"fields_to_remove",
[
["snapshots"],
["metadata-log"],
["snapshot-log"],
["snapshots", "metadata-log", "snapshot-log"],
],
)
def test_insert_into_table_without_optional_metadata_arrays(started_cluster, fields_to_remove):
# The Iceberg spec marks snapshots / metadata-log / snapshot-log as optional, so external
# engines may create empty-table metadata that omits any of them. Inserting into such a table
# must still succeed instead of aborting in the metadata write path.
node = started_cluster.instances["node1"]
test_ref = f"test_insert_no_optional_arrays_{uuid.uuid4()}"
table_name = f"{test_ref}_table"
root_namespace = f"{test_ref}_namespace"
catalog = load_catalog_impl(started_cluster)
catalog.create_namespace(root_namespace)
create_table(catalog, root_namespace, table_name, DEFAULT_SCHEMA, PartitionSpec(), DEFAULT_SORT_ORDER)
iceberg_table = catalog.load_table(f"{root_namespace}.{table_name}")
assert iceberg_table.metadata_location.startswith("s3://")
metadata_bucket, metadata_key = iceberg_table.metadata_location[len("s3://"):].split("/", 1)
metadata = json.loads(get_file_contents(started_cluster.minio_client, metadata_bucket, metadata_key))
for field in fields_to_remove:
metadata.pop(field, None)
metadata_bytes = json.dumps(metadata).encode()
started_cluster.minio_client.put_object(
metadata_bucket,
metadata_key,
io.BytesIO(metadata_bytes),
len(metadata_bytes),
content_type="application/json",
)
create_clickhouse_iceberg_database(started_cluster, node, CATALOG_NAME)
node.query(
f"INSERT INTO {CATALOG_NAME}.`{root_namespace}.{table_name}` VALUES (NULL, 'AAPL', 193.24, 193.31, tuple('bot'));",
settings={"allow_insert_into_iceberg": 1, "write_full_path_in_iceberg_metadata": 1},
)
assert node.query(f"SELECT * FROM {CATALOG_NAME}.`{root_namespace}.{table_name}`") == "\\N\tAAPL\t193.24\t193.31\t('bot')\n"
def test_create(started_cluster):
node = started_cluster.instances["node1"]
test_ref = f"test_list_tables_{uuid.uuid4()}"
table_name = f"{test_ref}_table"
root_namespace = f"{test_ref}_namespace"
create_clickhouse_iceberg_database(started_cluster, node, CATALOG_NAME)
create_clickhouse_iceberg_table(started_cluster, node, root_namespace, table_name, "(x String)")
node.query(f"INSERT INTO {CATALOG_NAME}.`{root_namespace}.{table_name}` VALUES ('AAPL');", settings={"allow_insert_into_iceberg": 1, 'write_full_path_in_iceberg_metadata': 1})
assert node.query(f"SELECT * FROM {CATALOG_NAME}.`{root_namespace}.{table_name}`") == "AAPL\n"
def test_create_gzip_metadata(started_cluster):
# Catalog-backed CREATE TABLE from ClickHouse with gzip metadata
# compression exercises IcebergMetadata::createInitial and the
# catalog->createTable registration. The first revision of the issue
# #109801 fix registered a hardcoded `v1.metadata.json` location while
# writing `v1.gz.metadata.json`, so a reopen through the catalog failed.
# This test creates the table from ClickHouse, reopens it through the
# catalog, and verifies insert/read succeeds.
node = started_cluster.instances["node1"]
test_ref = f"test_create_gzip_metadata_{uuid.uuid4()}"
table_name = f"{test_ref}_table"
root_namespace = f"{test_ref}_namespace"
create_clickhouse_iceberg_database(started_cluster, node, CATALOG_NAME)
create_clickhouse_iceberg_table(
started_cluster,
node,
root_namespace,
table_name,
"(x String)",
additional_settings={"iceberg_metadata_compression_method": "gzip"},
)
node.query(
f"INSERT INTO {CATALOG_NAME}.`{root_namespace}.{table_name}` VALUES ('AAPL');",
settings={
"allow_insert_into_iceberg": 1,
"write_full_path_in_iceberg_metadata": 1,
"iceberg_metadata_compression_method": "gzip",
},
)
assert node.query(f"SELECT * FROM {CATALOG_NAME}.`{root_namespace}.{table_name}`") == "AAPL\n"
# The initial metadata ClickHouse registered with the catalog must use the
# spec `gz` extension, and the catalog must point at the file that exists.
metadata_objects = list_s3_objects(
started_cluster.minio_client, "warehouse-rest", f"{table_name}/metadata/"
)
assert any(obj.endswith(".gz.metadata.json") for obj in metadata_objects), metadata_objects
assert not any(obj.endswith(".gzip.metadata.json") for obj in metadata_objects), metadata_objects
# Reopen through the catalog (fresh database) and confirm read still works.
create_clickhouse_iceberg_database(started_cluster, node, CATALOG_NAME)
assert node.query(f"SELECT * FROM {CATALOG_NAME}.`{root_namespace}.{table_name}`") == "AAPL\n"
def test_drop_table(started_cluster):
node = started_cluster.instances["node1"]
test_ref = f"test_list_tables_{uuid.uuid4()}"
table_name = f"{test_ref}_table"
root_namespace = f"{test_ref}_namespace"
catalog = load_catalog_impl(started_cluster)
create_clickhouse_iceberg_database(started_cluster, node, CATALOG_NAME)
create_clickhouse_iceberg_table(started_cluster, node, root_namespace, table_name, "(x String)")
assert len(catalog.list_tables(root_namespace)) == 1
drop_clickhouse_iceberg_table(node, root_namespace, table_name + "some_strange_non_exists_suffix", True)
assert len(catalog.list_tables(root_namespace)) == 1
drop_clickhouse_iceberg_table(node, root_namespace, table_name)
assert len(catalog.list_tables(root_namespace)) == 0
def test_table_with_slash(started_cluster):
node = started_cluster.instances["node1"]
# pyiceberg at current moment (version 0.9.1) has a bug with table names with slashes
# see https://github.com/apache/iceberg-python/issues/2462
# so we need to encode it manually
table_raw_suffix = "table/foo"
table_encoded_suffix = "table%2Ffoo"
test_ref = f"test_list_tables_{uuid.uuid4()}"
table_name = f"{test_ref}_{table_raw_suffix}"
table_encoded_name = f"{test_ref}_{table_encoded_suffix}"
root_namespace = f"{test_ref}_namespace"
catalog = load_catalog_impl(started_cluster)
catalog.create_namespace(root_namespace)
create_table(catalog, root_namespace, table_name, DEFAULT_SCHEMA, PartitionSpec(), DEFAULT_SORT_ORDER)
create_clickhouse_iceberg_database(started_cluster, node, CATALOG_NAME)
node.query(f"INSERT INTO {CATALOG_NAME}.`{root_namespace}.{table_encoded_name}` VALUES (NULL, 'AAPL', 193.24, 193.31, tuple('bot'));", settings={"allow_insert_into_iceberg": 1, 'write_full_path_in_iceberg_metadata': 1})
assert node.query(f"SELECT * FROM {CATALOG_NAME}.`{root_namespace}.{table_encoded_name}`") == "\\N\tAAPL\t193.24\t193.31\t('bot')\n"
def test_cluster_select(started_cluster):
node1 = started_cluster.instances["node1"]
node2 = started_cluster.instances["node2"]
test_ref = f"test_list_tables_{uuid.uuid4()}"
table_name = f"{test_ref}_table"
root_namespace = f"{test_ref}_namespace"
load_catalog_impl(started_cluster)
create_clickhouse_iceberg_database(started_cluster, node1, CATALOG_NAME)
create_clickhouse_iceberg_database(started_cluster, node2, CATALOG_NAME)
create_clickhouse_iceberg_table(started_cluster, node1, root_namespace, table_name, "(x String)")
node1.query(f"INSERT INTO {CATALOG_NAME}.`{root_namespace}.{table_name}` VALUES ('pablo');", settings={"allow_insert_into_iceberg": 1, 'write_full_path_in_iceberg_metadata': 1})
query_id = uuid.uuid4().hex
assert node1.query(f"SELECT * FROM {CATALOG_NAME}.`{root_namespace}.{table_name}` SETTINGS parallel_replicas_for_cluster_engines=1, enable_parallel_replicas=2, cluster_for_parallel_replicas='cluster_simple'", query_id=query_id) == 'pablo\n'
node1.query("SYSTEM FLUSH LOGS system.query_log")
node2.query("SYSTEM FLUSH LOGS system.query_log")
assert node1.query(f"SELECT Settings['parallel_replicas_for_cluster_engines'] AS parallel_replicas_for_cluster_engines FROM system.query_log WHERE query_id = '{query_id}' LIMIT 1;") == '1\n'
for replica in [node1, node2]:
cluster_secondary_queries = (
replica.query(
"""
SELECT query, type, is_initial_query, read_rows, read_bytes FROM system.query_log
WHERE
type = 'QueryStart' AND
positionCaseInsensitive(query, 's3Cluster') != 0 AND
position(query, 'system.query_log') = 0 AND
NOT is_initial_query
"""
)
.strip()
.split("\n")
)
assert len(cluster_secondary_queries) == 1
assert node2.query(f"SELECT * FROM {CATALOG_NAME}.`{root_namespace}.{table_name}`", settings={"parallel_replicas_for_cluster_engines": 1, "enable_parallel_replicas": 2, "cluster_for_parallel_replicas": "cluster_simple"}) == 'pablo\n'
def test_used_storages_in_query_log(started_cluster):
node1 = started_cluster.instances["node1"]
node2 = started_cluster.instances["node2"]
test_ref = f"test_query_log_{uuid.uuid4()}"
table_name = f"{test_ref}_table"
root_namespace = f"{test_ref}_namespace"
load_catalog_impl(started_cluster)
create_clickhouse_iceberg_database(started_cluster, node1, CATALOG_NAME)
create_clickhouse_iceberg_database(started_cluster, node2, CATALOG_NAME)
create_clickhouse_iceberg_table(
started_cluster, node1, root_namespace, table_name, "(x String)"
)
node1.query(
f"INSERT INTO {CATALOG_NAME}.`{root_namespace}.{table_name}` VALUES ('test_log');",
settings={
"allow_insert_into_iceberg": 1,
"write_full_path_in_iceberg_metadata": 1,
},
)
query_id_non_cluster = uuid.uuid4().hex
node1.query(
f"SELECT * FROM {CATALOG_NAME}.`{root_namespace}.{table_name}`",
query_id=query_id_non_cluster,
)
query_id_cluster = uuid.uuid4().hex
node1.query(
f"SELECT * FROM {CATALOG_NAME}.`{root_namespace}.{table_name}`"
f" SETTINGS parallel_replicas_for_cluster_engines=1,"
f" enable_parallel_replicas=2,"
f" cluster_for_parallel_replicas='cluster_simple'",
query_id=query_id_cluster,
)
node1.query("SYSTEM FLUSH LOGS")
result_non_cluster = node1.query(
f"SELECT used_storages FROM system.query_log"
f" WHERE query_id = '{query_id_non_cluster}' AND type = 'QueryFinish'"
).strip()
assert (
"'IcebergS3'" in result_non_cluster
), f"Non-cluster: expected IcebergS3 in used_storages, got {result_non_cluster}"
result_cluster = node1.query(
f"SELECT used_storages FROM system.query_log"
f" WHERE query_id = '{query_id_cluster}' AND type = 'QueryFinish'"
).strip()
assert (
"'IcebergS3'" in result_cluster
), f"Cluster: expected IcebergS3 in used_storages, got {result_cluster}"
def test_not_specified_catalog_type(started_cluster):
node = started_cluster.instances["node1"]
settings = {
"warehouse": "demo",
"storage_endpoint": "http://minio1:9001/warehouse-rest",
}
node.query(f"DROP DATABASE IF EXISTS {CATALOG_NAME}")
with pytest.raises(QueryRuntimeException) as exc_info:
node.query(
f"""CREATE DATABASE {CATALOG_NAME} ENGINE = DataLakeCatalog('{BASE_URL}', 'minio', '{minio_secret_key}')
SETTINGS {",".join((k + "=" + repr(v) for k, v in settings.items()))}""",
settings={
"allow_database_iceberg": 1,
"write_full_path_in_iceberg_metadata": 1,
},
)
message = str(exc_info.value)
assert "Unspecified catalog type" in message, message
assert "Code: 36" in message, message
def test_system_tables_with_nullptr_table(started_cluster):
"""
Test that querying system.tables does not crash when DataLake database
returns nullptr for some tables (e.g. when table metadata fetch fails).
Reproduces: https://github.com/ClickHouse/clickhouse-core-incidents/issues/1434
"""
node = started_cluster.instances["node1"]
root_namespace = f"clickhouse_{uuid.uuid4()}"
namespace = f"{root_namespace}_test_nullptr"
catalog = load_catalog_impl(started_cluster)
catalog.create_namespace(namespace)
table_name = "test_table"
create_table(catalog, namespace, table_name)
num_rows = 5
arrow_data = pa.table(
{
"datetime": [datetime.now() for _ in range(num_rows)],
"symbol": [f"sym_{i}" for i in range(num_rows)],
"bid": [float(i) for i in range(num_rows)],
"ask": [float(i + 1) for i in range(num_rows)],
"details": [{"created_by": f"user_{i}"} for i in range(num_rows)],
},
schema=pa.schema(
[
pa.field("datetime", pa.timestamp("us")),
pa.field("symbol", pa.string()),
pa.field("bid", pa.float64()),
pa.field("ask", pa.float64()),
pa.field(
"details", pa.struct([pa.field("created_by", pa.string())])
),
]
),
)
iceberg_table = catalog.load_table(f"{namespace}.{table_name}")
iceberg_table.append(arrow_data)
create_clickhouse_iceberg_database(started_cluster, node, CATALOG_NAME)
## Enable the failpoint so that tryGetTableImpl returns nullptr for all tables.
node.query("SYSTEM ENABLE FAILPOINT datalake_try_get_table_return_nullptr")
try:
## getFilteredTables with engine_column populated (a former crash site). The table
## whose storage object could not be resolved is now KEPT in the listing with an empty
## engine, rather than being silently dropped.
result = node.query(
f"SELECT name, engine FROM system.tables WHERE database = '{CATALOG_NAME}' "
f"SETTINGS show_data_lake_catalogs_in_system_tables = 1"
)
assert table_name in result
## engine is empty for the unresolved table (nothing after the name + tab).
assert f"{table_name}\t" in result or f"{table_name}." in result
## fillData main loop path: the row is present (not dropped) even though every
## storage-dependent column is defaulted.
result = node.query(
f"SELECT count() FROM system.tables WHERE database = '{CATALOG_NAME}' "
f"SETTINGS show_data_lake_catalogs_in_system_tables = 1"
)
assert int(result.strip()) >= 1
## A predicate on engine still filters correctly: the unresolved table has an empty
## engine, so it does not match a concrete engine pattern.
result = node.query(
f"SELECT count(engine) FROM system.tables WHERE database = '{CATALOG_NAME}' "
f"AND engine LIKE '%ReplicatedMergeTree' "
f"SETTINGS show_data_lake_catalogs_in_system_tables = 1"
)
assert result.strip() == "0"
finally:
node.query(
"SYSTEM DISABLE FAILPOINT datalake_try_get_table_return_nullptr"
)
## After disabling the failpoint, verify normal operation still works.
result = node.query(
f"SELECT count() FROM system.tables WHERE database = '{CATALOG_NAME}' "
f"AND name ILIKE '%{table_name}%' "
f"SETTINGS show_data_lake_catalogs_in_system_tables = 1"
)
assert int(result.strip()) > 0
node.query(f"DROP DATABASE IF EXISTS {CATALOG_NAME}")
def test_system_tables_metadata_unresolvable_does_not_abort_scan(started_cluster):
"""
Regression test for https://github.com/ClickHouse/ClickHouse/issues/110032.
When a table's metadata is unresolvable, a system.tables scan of the whole
DataLakeCatalog database must not abort (with database_datalake_require_metadata_access=1)
nor silently drop the table (with =0). Either way the table stays listed by name, with
default/empty values for the storage-dependent columns.
"""
node = started_cluster.instances["node1"]
root_namespace = f"clickhouse_{uuid.uuid4()}"
namespace = f"{root_namespace}_test_unresolvable"
catalog = load_catalog_impl(started_cluster)
catalog.create_namespace(namespace)
table_name = "broken_table"
create_table(catalog, namespace, table_name)
create_clickhouse_iceberg_database(started_cluster, node, CATALOG_NAME)
## Simulate a per-table metadata resolution failure (throws).
node.query("SYSTEM ENABLE FAILPOINT datalake_try_get_table_throw")
try:
for require in (1, 0):
settings = (
f"SETTINGS show_data_lake_catalogs_in_system_tables = 1, "
f"database_datalake_require_metadata_access = {require}"
)
## Name-only fast path always worked; still lists the table.
result = node.query(
f"SELECT name FROM system.tables WHERE database = '{CATALOG_NAME}' {settings}"
)
assert table_name in result, f"name-only path, require={require}"
## The whole-database scan requesting a storage-dependent column must NOT abort
## and must NOT drop the table -- it is kept with an empty engine.
result = node.query(
f"SELECT name, engine FROM system.tables WHERE database = '{CATALOG_NAME}' {settings}"
)
assert table_name in result, f"full scan, require={require}"
result = node.query(
f"SELECT count() FROM system.tables WHERE database = '{CATALOG_NAME}' {settings}"
)
assert int(result.strip()) >= 1, f"count, require={require}"
## total_rows (a per-column stat that needs the opened storage) is defaulted, not fatal.
result = node.query(
f"SELECT count() FROM system.tables WHERE database = '{CATALOG_NAME}' "
f"AND total_rows IS NULL {settings}"
)
assert int(result.strip()) >= 1, f"total_rows default, require={require}"
## parameterized_view_parameters needs the opened storage too. Selecting it alongside
## other columns must keep the column aligned (defaulted to an empty array), not abort.
result = node.query(
f"SELECT name, parameterized_view_parameters FROM system.tables "
f"WHERE database = '{CATALOG_NAME}' {settings}"
)
assert table_name in result, f"parameterized_view_parameters scan, require={require}"
result = node.query(
f"SELECT count() FROM system.tables WHERE database = '{CATALOG_NAME}' "
f"AND empty(parameterized_view_parameters) {settings}"
)
assert int(result.strip()) >= 1, f"parameterized_view_parameters default, require={require}"
## create_table_query / engine_full / as_select re-enter the catalog metadata query
## for a null-storage row. Selecting them must not re-throw and abort the scan; the
## columns are defaulted to empty strings for the unresolvable table.
result = node.query(
f"SELECT name, create_table_query, engine_full, as_select FROM system.tables "
f"WHERE database = '{CATALOG_NAME}' {settings}"
)
assert table_name in result, f"create_table_query scan, require={require}"
result = node.query(
f"SELECT count() FROM system.tables WHERE database = '{CATALOG_NAME}' "
f"AND create_table_query = '' AND engine_full = '' AND as_select = '' {settings}"
)
assert int(result.strip()) >= 1, f"create_table_query default, require={require}"
finally:
node.query("SYSTEM DISABLE FAILPOINT datalake_try_get_table_throw")
## Direct access to the broken table still surfaces the error (query_and_get_error already
## asserts the query failed; here we check it is the injected metadata failure).
node.query("SYSTEM ENABLE FAILPOINT datalake_try_get_table_throw")
try:
assert "Injected metadata resolution failure" in node.query_and_get_error(
f"SELECT * FROM {CATALOG_NAME}.`{namespace}.{table_name}` "
f"SETTINGS database_datalake_require_metadata_access = 1"
)
finally:
node.query("SYSTEM DISABLE FAILPOINT datalake_try_get_table_throw")
node.query(f"DROP DATABASE IF EXISTS {CATALOG_NAME}")
def test_merge_over_datalake_with_unresolvable_table_does_not_hang(started_cluster):
"""
Regression test for the StorageMerge consumer of DatabaseDataLake::getTablesIterator.
Only system.tables (getTablesIteratorWithHint) keeps a row with a null storage object
for a table whose metadata is unresolvable. Every other consumer -- StorageMerge in
particular -- dereferences the storage object of every iterated row unconditionally
(ReadFromMerge::getSelectedTables skips null storage with `continue` without advancing
the iterator, so it would loop forever; traverseTablesUntil callers such as
supportsPrewhere / totalRows deref the table directly). getTablesIterator therefore must
NOT yield null-storage rows: it propagates the error when
database_datalake_require_metadata_access=1 and drops the unresolved table otherwise.
A SELECT through a Merge table over the catalog with one broken table must fail cleanly
or return only the resolvable tables' rows, never hang or crash during planning.
"""
node = started_cluster.instances["node1"]
root_namespace = f"clickhouse_{uuid.uuid4()}"
namespace = f"{root_namespace}_test_merge_unresolvable"
catalog = load_catalog_impl(started_cluster)
catalog.create_namespace(namespace)
table_name = "broken_table"
create_table(catalog, namespace, table_name)
create_clickhouse_iceberg_database(started_cluster, node, CATALOG_NAME)
## An explicitly-created Merge table with a declared structure skips schema inference, so a
## SELECT through it reaches ReadFromMerge::getSelectedTables directly -- the read path the
## bot flagged, where a null-storage iterator row would spin forever (`if (!storage)
## continue;` never advances the iterator). (The merge() table function cannot exercise this
## because it forces schema inference first, which resolves/errors before the read path.)
node.query("DROP TABLE IF EXISTS default.merge_over_datalake")
node.query(
f"CREATE TABLE default.merge_over_datalake (symbol Nullable(String)) "
f"ENGINE = Merge('{CATALOG_NAME}', '.*broken_table.*')"
)
node.query("SYSTEM ENABLE FAILPOINT datalake_try_get_table_throw")
## Pre-fix, getTablesIterator handed StorageMerge a null-storage iterator row for the
## broken table; ReadFromMerge::getSelectedTables then spun forever (`if (!storage)
## continue;` never advances the iterator), so the SELECT hung. With the fix that row is
## never yielded to StorageMerge -- the error is propagated (require_metadata_access
## defaults to 1 in the storage context) instead. Either way the query must COMPLETE
## within the timeout (a hang would trip the timeout and fail the test) and the server
## must survive. A generous-but-bounded timeout converts the pre-fix hang into a clean
## test failure rather than hanging the whole job.
def run_merge_select(require):
## Completes (error or result) within the timeout == no infinite loop; return the
## outcome text for a sanity assertion. A hang raises and fails the test.
try:
return node.query(
"SELECT count() FROM default.merge_over_datalake "
f"SETTINGS database_datalake_require_metadata_access = {require}",
timeout=60,
).strip()
except QueryRuntimeException as e:
return str(e)
try:
for require in (1, 0):
outcome = run_merge_select(require)
## Either a clean numeric result (unresolved table dropped -> 0 rows) or the
## injected metadata error -- never a hang, never a crash/LOGICAL_ERROR.
assert (
outcome.isdigit()
or "Injected metadata resolution failure" in outcome
or "metadata" in outcome
), f"require={require}: {outcome}"
assert "LOGICAL_ERROR" not in outcome, f"require={require}: {outcome}"
## Server is still alive (i.e. no crash from a null-storage deref).
assert node.query("SELECT 1").strip() == "1", f"require={require}"
finally:
node.query("SYSTEM DISABLE FAILPOINT datalake_try_get_table_throw")
node.query("DROP TABLE IF EXISTS default.merge_over_datalake")
node.query(f"DROP DATABASE IF EXISTS {CATALOG_NAME}")
def test_delete_on_lazy_initialized_table(started_cluster):
"""
Regression test for https://github.com/ClickHouse/ClickHouse/issues/96806.
Tables in a DataLakeCatalog database use lazy metadata initialization
(lazy_init=true), meaning the DataLake metadata is not loaded at table
construction time. Prior to the fix, running ALTER TABLE ... DELETE (or
DELETE FROM ...) as the very first operation on such a table -- before any
SELECT had a chance to trigger metadata initialization -- resulted in a
LOGICAL_ERROR: 'Metadata is not initialized'.
"""
node = started_cluster.instances["node1"]
test_ref = f"test_delete_lazy_{uuid.uuid4()}"
table_name = f"{test_ref}_table"
root_namespace = f"{test_ref}_namespace"
create_clickhouse_iceberg_database(started_cluster, node, CATALOG_NAME)
create_clickhouse_iceberg_table(
started_cluster, node, root_namespace, table_name, "(x String)"
)
# Insert rows without any prior SELECT so that metadata starts uninitialized.
node.query(
f"INSERT INTO {CATALOG_NAME}.`{root_namespace}.{table_name}` VALUES ('keep');",
settings={"allow_insert_into_iceberg": 1, "write_full_path_in_iceberg_metadata": 1},
)
node.query(
f"INSERT INTO {CATALOG_NAME}.`{root_namespace}.{table_name}` VALUES ('delete_me');",
settings={"allow_insert_into_iceberg": 1, "write_full_path_in_iceberg_metadata": 1},
)
# Run ALTER TABLE DELETE without a prior SELECT. This is exactly the query
# that triggered LOGICAL_ERROR: 'Metadata is not initialized' before the fix.
node.query(
f"ALTER TABLE {CATALOG_NAME}.`{root_namespace}.{table_name}` DELETE WHERE x = 'delete_me';",
settings={"allow_insert_into_iceberg": 1, "write_full_path_in_iceberg_metadata": 1},
)
# Also exercise the DELETE FROM syntax (InterpreterDeleteQuery path).
node.query(
f"DELETE FROM {CATALOG_NAME}.`{root_namespace}.{table_name}` WHERE x = 'keep';",
settings={"allow_insert_into_iceberg": 1, "write_full_path_in_iceberg_metadata": 1},
)
assert node.query(f"SELECT count() FROM {CATALOG_NAME}.`{root_namespace}.{table_name}`") == "0\n"
def test_writes_schema_evolution(started_cluster):
node = started_cluster.instances["node1"]
test_ref = f"test_writes_schema_evolution_{uuid.uuid4()}"
table_name = f"{test_ref}_table"
root_namespace = f"{test_ref}_namespace"
table_ref = f"{CATALOG_NAME}.`{root_namespace}.{table_name}`"
write_settings = {"allow_insert_into_iceberg": 1, "write_full_path_in_iceberg_metadata": 1}
create_clickhouse_iceberg_database(started_cluster, node, CATALOG_NAME)
create_clickhouse_iceberg_table(started_cluster, node, root_namespace, table_name, "(x String, y Int32)")
node.query(f"INSERT INTO {table_ref} VALUES ('123', 1);", settings=write_settings)
node.query(f"ALTER TABLE {table_ref} ADD COLUMN z Nullable(String);", settings=write_settings)
assert "z" in node.query(f"DESCRIBE TABLE {table_ref}", settings=write_settings)
assert node.query(f"SELECT x, y, z FROM {table_ref} ORDER BY ALL", settings=write_settings) == "123\t1\t\\N\n"
node.query(f"INSERT INTO {table_ref} VALUES ('456', 2, 'hello');", settings=write_settings)
assert (
node.query(f"SELECT x, y, z FROM {table_ref} ORDER BY ALL", settings=write_settings)
== "123\t1\t\\N\n456\t2\thello\n"
)
node.query(f"ALTER TABLE {table_ref} RENAME COLUMN z TO w;", settings=write_settings)
assert "w" in node.query(f"DESCRIBE TABLE {table_ref}", settings=write_settings)
assert (
node.query(f"SELECT x, y, w FROM {table_ref} ORDER BY ALL", settings=write_settings)
== "123\t1\t\\N\n456\t2\thello\n"
)
def test_writes_schema_evolution_concurrent_add_columns(started_cluster):
node = started_cluster.instances["node1"]
test_ref = f"test_writes_schema_evolution_concurrent_{uuid.uuid4()}"
table_name = f"{test_ref}_table"
root_namespace = f"{test_ref}_namespace"
table_ref = f"{CATALOG_NAME}.`{root_namespace}.{table_name}`"
write_settings = {"allow_insert_into_iceberg": 1, "write_full_path_in_iceberg_metadata": 1}
create_clickhouse_iceberg_database(started_cluster, node, CATALOG_NAME)
create_clickhouse_iceberg_table(started_cluster, node, root_namespace, table_name, "(x String, y Int32)")
node.query(f"INSERT INTO {table_ref} VALUES ('123', 1);", settings=write_settings)
# Concurrent ADD COLUMN commits must contend on the REST catalog to surface
# the commit-conflict/retry race. A handful of concurrent writers is enough
# to interleave; the original count of 10 just multiplied catalog round-trips.
num_columns = 4
def add_column(idx):
node.query(
f"ALTER TABLE {table_ref} ADD COLUMN col_{idx} Nullable(String);",
settings=write_settings,
)
with ThreadPoolExecutor(max_workers=num_columns) as executor:
list(executor.map(add_column, range(num_columns)))
description = node.query(f"DESCRIBE TABLE {table_ref}", settings=write_settings)
for idx in range(num_columns):
assert f"col_{idx}" in description, f"col_{idx} missing from:\n{description}"
columns = [line.split("\t")[0] for line in description.strip().split("\n")]
assert sorted(columns) == sorted(["x", "y"] + [f"col_{idx}" for idx in range(num_columns)])
select_cols = ", ".join(["x", "y"] + [f"col_{idx}" for idx in range(num_columns)])
expected = "123\t1" + "\t\\N" * num_columns + "\n"
assert node.query(f"SELECT {select_cols} FROM {table_ref} ORDER BY ALL", settings=write_settings) == expected
def test_gcs(started_cluster):
node = started_cluster.instances["node1"]
node.query("SYSTEM ENABLE FAILPOINT database_iceberg_gcs")
node.query(f"DROP DATABASE IF EXISTS {CATALOG_NAME};")
with pytest.raises(Exception) as err:
node.query(
f"""
CREATE DATABASE {CATALOG_NAME}
ENGINE = DataLakeCatalog('{BASE_URL}', 'gcs', 'dummy')
SETTINGS
catalog_type = 'rest',
warehouse = 'demo',
""",
settings={"allow_database_iceberg": 1},
)
assert "Google cloud storage converts to S3" in str(err.value)
def test_invalid_auth_header_format(started_cluster):
node = started_cluster.instances["node1"]
node.query(f"DROP DATABASE IF EXISTS {CATALOG_NAME};")
with pytest.raises(Exception) as err:
node.query(
f"""
SET allow_database_iceberg = 1;
CREATE DATABASE {CATALOG_NAME}
ENGINE = DataLakeCatalog('{BASE_URL}', 'minio', 'dummy')
SETTINGS
catalog_type = 'rest',
warehouse = 'demo',
auth_header = 'wrong.header'
"""
)
assert "Invalid auth header format" in str(err.value)
def test_writes_mutate_update(started_cluster):
node = started_cluster.instances["node1"]
test_ref = f"test_writes_mutate_update_{uuid.uuid4()}"
table_name = f"{test_ref}_table"
root_namespace = f"{test_ref}_namespace"
table_ref = f"{CATALOG_NAME}.`{root_namespace}.{table_name}`"
write_settings = {"allow_insert_into_iceberg": 1, "write_full_path_in_iceberg_metadata": 1}
create_clickhouse_iceberg_database(started_cluster, node, CATALOG_NAME)
create_clickhouse_iceberg_table(started_cluster, node, root_namespace, table_name, "(x String, y Int32)")
node.query(f"INSERT INTO {table_ref} VALUES ('123', 1);", settings=write_settings)
node.query(f"INSERT INTO {table_ref} VALUES ('456', 2);", settings=write_settings)
node.query(f"INSERT INTO {table_ref} VALUES ('999', 3);", settings=write_settings)
assert node.query(f"SELECT * FROM {table_ref} ORDER BY ALL") == "123\t1\n456\t2\n999\t3\n"
node.query(f"ALTER TABLE {table_ref} UPDATE x = '777' WHERE x = '123';", settings=write_settings)
assert node.query(f"SELECT * FROM {table_ref} ORDER BY ALL") == "456\t2\n777\t1\n999\t3\n"
node.query(f"ALTER TABLE {table_ref} UPDATE x = 'goshan dr' WHERE x = '777';", settings=write_settings)
assert node.query(f"SELECT * FROM {table_ref} ORDER BY ALL") == "456\t2\n999\t3\ngoshan dr\t1\n"
node.query(f"ALTER TABLE {table_ref} UPDATE x = 'pudge1000-7' WHERE y = 2;", settings=write_settings)
assert node.query(f"SELECT * FROM {table_ref} ORDER BY ALL") == "999\t3\ngoshan dr\t1\npudge1000-7\t2\n"
def test_writes_mutate_delete(started_cluster):
node = started_cluster.instances["node1"]
test_ref = f"test_writes_mutate_delete_{uuid.uuid4()}"
table_name = f"{test_ref}_table"
root_namespace = f"{test_ref}_namespace"
table_ref = f"{CATALOG_NAME}.`{root_namespace}.{table_name}`"
write_settings = {"allow_insert_into_iceberg": 1, "write_full_path_in_iceberg_metadata": 1}
create_clickhouse_iceberg_database(started_cluster, node, CATALOG_NAME)
create_clickhouse_iceberg_table(started_cluster, node, root_namespace, table_name, "(x String)")
# DELETE on empty table is a no-op.
node.query(f"ALTER TABLE {table_ref} DELETE WHERE x = 'pudge1000-7';", settings=write_settings)
assert node.query(f"SELECT * FROM {table_ref} ORDER BY ALL") == ""
node.query(f"INSERT INTO {table_ref} VALUES ('123');", settings=write_settings)
node.query(f"INSERT INTO {table_ref} VALUES ('456');", settings=write_settings)
node.query(f"INSERT INTO {table_ref} VALUES ('789'), ('890'), ('999');", settings=write_settings)
assert node.query(f"SELECT * FROM {table_ref} ORDER BY ALL") == "123\n456\n789\n890\n999\n"
# No-match DELETE keeps the table intact.
node.query(f"ALTER TABLE {table_ref} DELETE WHERE x = 'pudge1000-7';", settings=write_settings)
assert node.query(f"SELECT * FROM {table_ref} ORDER BY ALL") == "123\n456\n789\n890\n999\n"
node.query(f"ALTER TABLE {table_ref} DELETE WHERE x = '789';", settings=write_settings)
assert node.query(f"SELECT * FROM {table_ref} ORDER BY ALL") == "123\n456\n890\n999\n"
# Lightweight DELETE syntax should work identically against catalog tables.
node.query(f"DELETE FROM {table_ref} WHERE x = '123';", settings=write_settings)
assert node.query(f"SELECT * FROM {table_ref} ORDER BY ALL") == "456\n890\n999\n"
node.query(f"ALTER TABLE {table_ref} DELETE WHERE x = '999';", settings=write_settings)
assert node.query(f"SELECT * FROM {table_ref} ORDER BY ALL") == "456\n890\n"
def test_iceberg_file_progress_callback(started_cluster):
"""
Regression test for the `IcebergIterator::next` file-progress callback wiring (PR #105413).
`IcebergIterator` stored a `FileProgressCallback` but never invoked it, so the
per-query `Progress.total_bytes_to_read` stayed at zero for Iceberg scans and
the progress bar showed no estimate. The fix invokes the callback with the data
file size for every object info returned. The assertion below uses the
`FileProgressCallbackInvocations` ProfileEvent, which is incremented inside the
callback lambda installed by `TCPHandler::setFileProgressCallback`, so removing
the `callback(...)` call in `IcebergIterator::next` makes this event stay at
zero for the test query.
"""
node = started_cluster.instances["node1"]
test_ref = f"test_progress_callback_{uuid.uuid4().hex[:8]}"
table_name = f"{test_ref}_table"
root_namespace = f"{test_ref}_namespace"
catalog = load_catalog_impl(started_cluster)
catalog.create_namespace(root_namespace)
table = create_table(
catalog,
root_namespace,
table_name,
DEFAULT_SCHEMA,
PartitionSpec(),
DEFAULT_SORT_ORDER,
)
# Append a small but non-empty batch so the iterator returns a data-file entry.
num_rows = 50
data = [generate_record() for _ in range(num_rows)]
df = pa.Table.from_pylist(data)
table.append(df)
create_clickhouse_iceberg_database(started_cluster, node, CATALOG_NAME)
# `node.query` uses native TCP, the only protocol path where
# `setFileProgressCallback` is currently wired. `SELECT *` with `FORMAT Null`
# forces a full scan: the metadata-only `SELECT count()` path resolves the row
# count from manifest statistics and bypasses the data-file iterator.
query_id = f"iceberg_progress_callback_{uuid.uuid4().hex}"
node.query(
f"SELECT * FROM {CATALOG_NAME}.`{root_namespace}.{table_name}` FORMAT Null",
query_id=query_id,
)
node.query("SYSTEM FLUSH LOGS")
# `FileProgressCallbackInvocations` is incremented inside the lambda installed
# by `TCPHandler::setFileProgressCallback`. For an Iceberg-table scan it can
# only fire from `IcebergIterator::next` (the generic
# `StorageObjectStorageSource::KeysIterator` path is replaced by
# `IcebergIterator` for Iceberg storage), so a non-zero value proves the
# iterator's `callback(FileProgress(...))` invocation was executed.
profile_event_value = node.query(
f"""
SELECT ProfileEvents['FileProgressCallbackInvocations']
FROM system.query_log
WHERE query_id = '{query_id}' AND type = 'QueryFinish'
ORDER BY event_time_microseconds DESC
LIMIT 1
"""
).strip()
assert profile_event_value, (
f"`system.query_log` has no `QueryFinish` row for query_id={query_id}."
)
file_progress_callback_invocations = int(profile_event_value)
assert file_progress_callback_invocations > 0, (
f"Expected `FileProgressCallbackInvocations` > 0 from the Iceberg scan, "
f"got {file_progress_callback_invocations}. "
f"`IcebergIterator::next` did not invoke the file-progress callback "
f"(regression of PR #105413 wiring)."
)
def test_alter_database_settings_not_supported(started_cluster):
node = started_cluster.instances["node1"]
db_name = f"iceberg_alter_settings_{uuid.uuid4().hex}"
create_clickhouse_iceberg_database(started_cluster, node, db_name)
fake_token = f"fake_secret_token_{uuid.uuid4().hex}"
qid_alter = uuid.uuid4().hex
error = node.query_and_get_error(
f"ALTER DATABASE {db_name} MODIFY SETTING warehouse = 'other_warehouse'"
)
assert "BAD_ARGUMENTS" in error
error = node.query_and_get_error(
f"ALTER DATABASE {db_name} MODIFY SETTING onelake_bearer_token = '{fake_token}'",
query_id=qid_alter,
)
assert "BAD_ARGUMENTS" in error
error = node.query_and_get_error(
f"ALTER DATABASE {db_name} MODIFY SETTING no_such_setting = 1"
)
assert "BAD_ARGUMENTS" in error or "UNKNOWN_SETTING" in error
show_result = node.query(f"SHOW CREATE DATABASE {db_name}")
assert "other_warehouse" not in show_result
assert "onelake_bearer_token" not in show_result
node.query(
f"SELECT name FROM system.tables WHERE database = '{db_name}' SETTINGS show_data_lake_catalogs_in_system_tables = true"
)
node.query("SYSTEM FLUSH LOGS system.query_log")
logged_query = node.query(
f"SELECT arrayStringConcat(groupArray(query), '\\n') FROM system.query_log WHERE query_id = '{qid_alter}'"
)
assert fake_token not in logged_query
assert "[HIDDEN]" in logged_query
node.query(f"DROP DATABASE {db_name}")
glue_db_name = f"glue_alter_settings_{uuid.uuid4().hex}"
node.query(
f"""
ATTACH DATABASE {glue_db_name} ENGINE = DataLakeCatalog('http://fake-glue:1')
SETTINGS catalog_type = 'glue', region = 'us-east-1', storage_endpoint = 'http://fake-glue:1/x'
"""
)
error = node.query_and_get_error(
f"ALTER DATABASE {glue_db_name} MODIFY SETTING region = 'eu-west-1'"
)
assert "NOT_IMPLEMENTED" in error
node.query(f"DROP DATABASE {glue_db_name}")
def test_alter_database_settings_rest_auth_header(started_cluster):
node = started_cluster.instances["node1"]
db_name = f"rest_alter_auth_header_{uuid.uuid4().hex}"
old_header = f"Authorization: Bearer old_{uuid.uuid4().hex}"
new_header = f"Authorization: Bearer new_{uuid.uuid4().hex}"
node.query(
f"""
ATTACH DATABASE {db_name} ENGINE = DataLakeCatalog('http://fake-rest:1/api')
SETTINGS catalog_type = 'rest', warehouse = 'wh', auth_header = '{old_header}'
"""
)
node.query(
f"ALTER DATABASE {db_name} MODIFY SETTING auth_header = '{new_header}'"
)
error = node.query_and_get_error(
f"ALTER DATABASE {db_name} MODIFY SETTING catalog_credential = 'id:secret'"
)
assert "BAD_ARGUMENTS" in error
show_result = node.query(f"SHOW CREATE DATABASE {db_name}")
assert new_header not in show_result
assert "[HIDDEN]" in show_result
node.restart_clickhouse()
engine_full_with_secrets = node.query(
f"SELECT engine_full FROM system.databases WHERE name = '{db_name}'",
settings={"format_display_secrets_in_show_and_select": 1},
)
assert new_header in engine_full_with_secrets
assert old_header not in engine_full_with_secrets
node.query(f"DROP DATABASE {db_name}")
def test_alter_database_settings_onelake_persistence(started_cluster):
node = started_cluster.instances["node1"]
db_name = f"onelake_alter_persist_{uuid.uuid4().hex}"
old_token = f"secret_token_{uuid.uuid4().hex}"
new_token = f"secret_token_{uuid.uuid4().hex}"
node.query(
f"""
ATTACH DATABASE {db_name} ENGINE = DataLakeCatalog('http://fake-onelake:1/api')
SETTINGS catalog_type = 'onelake', warehouse = 'wh', onelake_tenant_id = 'tenant-0', onelake_tenant_id = 'tenant-1', onelake_bearer_token = '{old_token}'
"""
)
node.query(
f"ALTER DATABASE {db_name} MODIFY SETTING onelake_tenant_id = 'tenant-2', onelake_bearer_token = '{new_token}'"
)
error = node.query_and_get_error(
f"ALTER DATABASE {db_name} MODIFY SETTING onelake_client_id = 'client-1'"
)
assert "BAD_ARGUMENTS" in error
error = node.query_and_get_error(
f"ALTER DATABASE {db_name} MODIFY SETTING warehouse = 'other_warehouse'"
)
assert "BAD_ARGUMENTS" in error
error = node.query_and_get_error(
f"ALTER DATABASE {db_name} MODIFY SETTING onelake_bearer_token = ''"
)
assert "BAD_ARGUMENTS" in error
show_result = node.query(f"SHOW CREATE DATABASE {db_name}")
assert "tenant-2" in show_result
assert new_token not in show_result
assert old_token not in show_result
assert "[HIDDEN]" in show_result
engine_full_with_secrets = node.query(
f"SELECT engine_full FROM system.databases WHERE name = '{db_name}'",
settings={"format_display_secrets_in_show_and_select": 1},
)
assert "tenant-2" in engine_full_with_secrets
assert new_token in engine_full_with_secrets
assert old_token not in engine_full_with_secrets
node.restart_clickhouse()
show_result = node.query(f"SHOW CREATE DATABASE {db_name}")
assert "tenant-2" in show_result
assert "tenant-0" not in show_result
assert "tenant-1" not in show_result
assert new_token not in show_result
assert "[HIDDEN]" in show_result
engine_full = node.query(
f"SELECT engine_full FROM system.databases WHERE name = '{db_name}'"
)
assert "tenant-2" in engine_full
assert "tenant-0" not in engine_full
assert "tenant-1" not in engine_full
assert new_token not in engine_full
engine_full_with_secrets = node.query(
f"SELECT engine_full FROM system.databases WHERE name = '{db_name}'",
settings={"format_display_secrets_in_show_and_select": 1},
)
assert new_token in engine_full_with_secrets
assert old_token not in engine_full_with_secrets
node.query(f"DROP DATABASE {db_name}")