Files
zeekay 9a90943b8f merge: ClickHouse 26.7.1.1315-stable into datastore — preserve debrand
13,838 upstream commits, forward from the 26.6.1.1 fork point. 129 conflicts:
37 resolved mechanically (take upstream's file, re-apply the brand sweep) and
92 by hand where our fork and upstream had both changed the same code.

The rule throughout: upstream owns the engine, we own the naming and a small set
of deliberate deviations. Never resolve by discarding an upstream change — a
dropped rename or a missed new parameter becomes a compile error a thousand
files away.

Deviations verified intact after the merge:
  ENABLE_XRAY still defaults OFF        (CMakeLists.txt:105)
  X-Datastore-User read by the server   (authenticateUserByHTTP.cpp)
  X-ClickHouse-* in src/                0
  hanzo/research                        present
  system.*_log retention bound          present
  ArrowFlight rip                       held against upstream re-adding it

Two things worth naming, because neither shows up as a conflict:

  src/CMakeLists.txt gained an UNGUARDED
  `add_object_library(datastore_storages_arrowflight Storages/ArrowFlight)` from
  upstream, for a directory our gRPC rip deleted. It sat outside every conflict
  hunk, so a resolver reading only the markers would sail straight past it into
  a CMake configure error. Removed; the Server/ArrowFlight sibling at line 474
  stays because it is inside `if (TARGET ch_contrib::arrow_flight)`.

  The brand sweep had rewritten github.com/ClickHouse/ClickHouse issue links to
  github.com/Datastore/Datastore — a dead host. Those point at upstream's own
  tracker and are restored, alongside the contributors list, sql.clickhouse.com
  and the ClickHouse Inc copyright: attribution is theirs, not ours to rename.

base/glibc-compatibility/memcpy/memcpy.h is deleted, not kept: upstream dropped
it for LLVM-libc's, and our only change to it was a comment rebrand.
2026-07-26 14:47:43 -07:00

1638 lines
63 KiB
Python

import json
import logging
import os
import random
import uuid
from datetime import datetime
from concurrent.futures import ThreadPoolExecutor
import pyarrow as pa
import pytest
import pytz
from datetime import timedelta
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 helpers.config_cluster import minio_access_key, minio_secret_key
import decimal
from pyiceberg.types import (
DoubleType,
NestedField,
StringType,
StructType,
TimestampType,
TimestamptzType,
MapType,
DecimalType,
)
from helpers.cluster import DatastoreCluster
from helpers.mock_servers import start_mock_servers
import boto3
def run_s3_mocks(started_cluster, args=[]):
script_dir = os.path.join(os.path.dirname(__file__), "s3_mocks")
start_mock_servers(
started_cluster,
script_dir,
[("mock_sts.py", "sts.us-east-1.amazonaws.com", "80", args)],
)
CATALOG_NAME = "test"
BASE_URL = "http://glue:3000"
def get_glue_local_url(cluster):
return f"http://localhost:{cluster.glue_catalog_port}"
def generate_decimal(precision=9, scale=2):
max_value = 10**(precision - scale) - 1
value = random.uniform(0, max_value)
return round(decimal.Decimal(value), scale)
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,
),
NestedField(
field_id=6,
name="map_string_decimal",
field_type=MapType(
key_type=StringType(),
value_type=DecimalType(9, 2),
key_id=7,
value_id=8,
value_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 `map_string_decimal` Map(String, Nullable(Decimal(9, 2)))\\n)\\nENGINE = Iceberg(\\'http://minio1:9001/warehouse-glue/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_databases(started_cluster):
client = boto3.client(
"glue", region_name="us-east-1", endpoint_url=get_glue_local_url(started_cluster)
)
databases = client.get_databases()
return databases
def load_catalog_impl(started_cluster):
return load_catalog(
CATALOG_NAME, # name is not important
**{
"type": "glue",
"glue.endpoint": get_glue_local_url(started_cluster),
"glue.region": "us-east-1",
"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,
dir="data"
):
return catalog.create_table(
identifier=f"{namespace}.{table}",
schema=schema,
location=f"s3://warehouse-glue/{dir}",
partition_spec=partition_spec,
sort_order=sort_order,
)
def generate_arrow_data(num_rows=5):
datetimes = []
symbols = []
bids = []
asks = []
details_created_by = []
map_keys = []
map_values = []
offsets = [0]
for _ in range(num_rows):
datetimes.append(datetime.utcnow() - timedelta(minutes=random.randint(0, 60)))
symbols.append(random.choice(["AAPL", "GOOG", "MSFT"]))
bids.append(random.uniform(100, 150))
asks.append(random.uniform(150, 200))
details_created_by.append(random.choice(["alice", "bob", "carol"]))
# map<string, decimal(9,2)>
keys = []
values = []
for i in range(random.randint(1, 3)):
keys.append(f"key{i}")
values.append(generate_decimal())
map_keys.extend(keys)
map_values.extend(values)
offsets.append(offsets[-1] + len(keys))
# Struct for 'details'
struct_array = pa.StructArray.from_arrays(
[pa.array(details_created_by, type=pa.string())],
names=["created_by"]
)
# Map array
map_array = pa.MapArray.from_arrays(
offsets=pa.array(offsets, type=pa.int32()),
keys=pa.array(map_keys, type=pa.string()),
items=pa.array(map_values, type=pa.decimal128(9, 2))
)
# Final table
table = pa.table({
"datetime": pa.array(datetimes, type=pa.timestamp("us")),
"symbol": pa.array(symbols, type=pa.string()),
"bid": pa.array(bids, type=pa.float64()),
"ask": pa.array(asks, type=pa.float64()),
"details": struct_array,
"map_string_decimal": map_array,
})
return table
def create_datastore_glue_database(
started_cluster, node, name, additional_settings={}, query_settings={}, with_credentials=True
):
settings = {
"catalog_type": "glue",
"warehouse": "test",
"storage_endpoint": "http://minio1:9001/warehouse-glue",
"region": "us-east-1",
}
# The Glue catalog API client is subject to the server-managed credential restriction. Unless the session
# opts in via `s3_allow_server_credentials_in_user_queries`, the creator must pass explicit catalog
# credentials instead of falling back to the server's AWS identity (moto accepts any non-empty values).
if not query_settings.get("s3_allow_server_credentials_in_user_queries"):
settings["aws_access_key_id"] = minio_access_key
settings["aws_secret_access_key"] = minio_secret_key
settings.update(additional_settings)
credential_args = f",'{minio_access_key}', '{minio_secret_key}'" if with_credentials else ""
node.query(
f"""
DROP DATABASE IF EXISTS {name};
CREATE DATABASE {name} ENGINE = DataLakeCatalog('{BASE_URL}'{credential_args})
SETTINGS {",".join((k+"="+repr(v) for k, v in settings.items()))}
""",
settings={
"allow_database_glue_catalog": 1,
"write_full_path_in_iceberg_metadata": 1,
**query_settings,
},
)
def create_datastore_glue_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-glue/{table_name}/', '{minio_access_key}', '{minio_secret_key}')
{settings_suffix}
""",
settings={
"allow_experimental_database_glue_catalog": 1,
"write_full_path_in_iceberg_metadata": 1,
},
)
show_result = node.query(f"SHOW DATABASE {CATALOG_NAME}")
assert minio_secret_key not in show_result
def create_glue_table_directly(
started_cluster, database_name, table_name, table_type=None, columns=None
):
"""Create a raw (non-Iceberg) table directly in Glue via boto3.
Such a table - e.g. a Delta Lake table (table_type != 'ICEBERG') or a raw parquet
file registered by a crawler (no table_type at all) - is visible in the Glue catalog
but cannot be read by Datastore, whose Glue engine only supports Iceberg tables.
"""
client = boto3.client(
"glue", region_name="us-east-1", endpoint_url=get_glue_local_url(started_cluster)
)
if columns is None:
columns = [
{"Name": "id", "Type": "bigint"},
{"Name": "value", "Type": "string"},
]
parameters = {} if table_type is None else {"table_type": table_type}
client.create_table(
DatabaseName=database_name,
TableInput={
"Name": table_name,
"StorageDescriptor": {
"Columns": columns,
"Location": f"s3://warehouse-glue/{table_name}/",
},
"TableType": "EXTERNAL_TABLE",
"Parameters": parameters,
},
)
def test_show_tables_hides_unreadable_tables(started_cluster):
"""
Regression test: SHOW TABLES and system.tables must agree for a Glue catalog that
mixes Iceberg tables with non-Iceberg tables (Delta Lake tables, raw parquet files,
...). Datastore can only read Iceberg tables via the Glue catalog, so the
non-Iceberg tables must appear in NEITHER listing.
Before the fix, the lightweight name-only listing path (used by SHOW TABLES and by
SELECT name FROM system.tables) did not filter unreadable tables, while the full
system.tables path dropped them - so the two disagreed and SHOW TABLES listed tables
that could not be queried. This test fails on master and passes with the fix.
"""
node = started_cluster.instances["node1"]
test_ref = f"test_hide_unreadable_{uuid.uuid4().hex[:8]}"
namespace = f"{test_ref}_ns"
iceberg_table = f"{test_ref}_iceberg"
delta_table = f"{test_ref}_delta"
parquet_table = f"{test_ref}_parquet"
catalog = load_catalog_impl(started_cluster)
catalog.create_namespace(namespace)
# Readable Iceberg table (pyiceberg sets table_type=ICEBERG in Glue).
table = create_table(catalog, namespace, iceberg_table)
table.append(generate_arrow_data(10))
# Unreadable tables registered directly in Glue.
create_glue_table_directly(started_cluster, namespace, delta_table, table_type="delta")
create_glue_table_directly(started_cluster, namespace, parquet_table, table_type=None)
create_datastore_glue_database(started_cluster, node, CATALOG_NAME)
iceberg_full = f"{namespace}.{iceberg_table}"
delta_full = f"{namespace}.{delta_table}"
parquet_full = f"{namespace}.{parquet_table}"
settings = "SETTINGS show_data_lake_catalogs_in_system_tables = true"
# SHOW TABLES (lightweight name-only path): only the Iceberg table is listed.
show_tables = node.query(
f"SHOW TABLES FROM {CATALOG_NAME} LIKE '%{test_ref}%'"
).split()
assert iceberg_full in show_tables, show_tables
assert delta_full not in show_tables, show_tables
assert parquet_full not in show_tables, show_tables
# system.tables name-only path (same lightweight iterator as SHOW TABLES).
names_only = node.query(
f"SELECT name FROM system.tables WHERE database = '{CATALOG_NAME}' "
f"AND name LIKE '%{test_ref}%' ORDER BY name {settings}"
).split()
assert names_only == [iceberg_full], names_only
# system.tables full path (references engine -> fetches per-table metadata).
full = node.query(
f"SELECT name FROM system.tables WHERE database = '{CATALOG_NAME}' "
f"AND name LIKE '%{test_ref}%' AND engine != '' ORDER BY name {settings}"
).split()
assert full == [iceberg_full], full
# The two listings agree - this is exactly the property that was broken.
assert sorted(show_tables) == sorted(names_only)
def drop_datastore_glue_table(
node, database_name, table_name
):
node.query(
f"""
DROP TABLE {CATALOG_NAME}.`{database_name}.{table_name}`;
"""
)
@pytest.fixture(scope="module")
def started_cluster():
try:
# We must add some credentials, otherwise moto (AWS Mock)
# will reject boto connection
os.environ["AWS_ACCESS_KEY_ID"] = "testing"
os.environ["AWS_SECRET_ACCESS_KEY"] = "testing"
cluster = DatastoreCluster(__file__)
cluster.add_instance(
"node1",
main_configs=[
"configs/query_log.xml",
"configs/text_log.xml",
],
user_configs=[],
stay_alive=True,
with_glue_catalog=True,
with_zookeeper=True
)
sts = cluster.add_instance(
name="sts.us-east-1.amazonaws.com",
hostname="sts.us-east-1.amazonaws.com",
image="datastore/python-bottle",
tag="latest",
stay_alive=True,
)
sts.stop_datastore(kill=True)
logging.info("Starting cluster...")
cluster.start()
logging.info("Cluster started")
run_s3_mocks(cluster)
yield cluster
finally:
cluster.shutdown()
@pytest.fixture(autouse=True)
def clean_catalog(started_cluster):
# All tests share one module-scoped Glue catalog. Listing operations such as
# SHOW TABLES enumerate every namespace and make one sequential Glue call per
# namespace, so leftover namespaces from earlier tests make these calls grow
# unbounded and eventually exceed query timeouts. Drop everything each test
# created so the catalog stays bounded to the running test.
yield
catalog = load_catalog_impl(started_cluster)
glue_client = boto3.client(
"glue", region_name="us-east-1", endpoint_url=get_glue_local_url(started_cluster)
)
for namespace in catalog.list_namespaces():
# Drop Iceberg tables via pyiceberg so their data and metadata are removed too.
for table in catalog.list_tables(namespace):
catalog.drop_table(table)
# Drop any remaining tables registered directly in Glue (e.g. non-Iceberg tables
# such as Delta Lake tables or raw data files). pyiceberg's list_tables ignores
# them, and drop_namespace refuses to drop a namespace that still contains them.
database_name = ".".join(namespace)
for table in glue_client.get_tables(DatabaseName=database_name).get("TableList", []):
glue_client.delete_table(DatabaseName=database_name, Name=table["Name"])
catalog.drop_namespace(namespace)
def test_no_secrets_in_logs(started_cluster):
node = started_cluster.instances["node1"]
db_name = f"glue_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": "glue",
"warehouse": "test",
"storage_endpoint": "http://minio1:9001/warehouse-glue",
"region": "us-east-1",
# The Glue catalog API client is restricted; pass explicit catalog credentials instead of relying on
# the server's AWS identity (moto accepts any non-empty values).
"aws_access_key_id": minio_access_key,
"aws_secret_access_key": minio_secret_key,
}
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_access_key}', '{minio_secret_key}')
SETTINGS {",".join((k + "=" + repr(v) for k, v in db_settings.items()))}""",
query_id=qid_db,
settings={
"allow_database_glue_catalog": 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-glue/{table_name}/', '{minio_access_key}', '{minio_secret_key}')""",
query_id=qid_table,
settings={
"allow_experimental_database_glue_catalog": 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_list_tables(started_cluster):
node = started_cluster.instances["node1"]
root_namespace = f"datastore_{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_datastore_glue_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_datastore()
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")
print("Expected", expected)
print("Got", node.query(f"SHOW CREATE TABLE {CATALOG_NAME}.`{namespace_2}.tableC`"))
assert expected == node.query(
f"SHOW CREATE TABLE {CATALOG_NAME}.`{namespace_2}.tableC`"
)
assert int(node.query(f"SELECT count() FROM system.iceberg_history WHERE database = '{CATALOG_NAME}' and table ilike '%{root_namespace}%'").strip()) == 0
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"
namespaces_to_create = [
root_namespace,
f"{root_namespace}_A",
f"{root_namespace}_B",
f"{root_namespace}_C",
]
catalog = load_catalog_impl(started_cluster)
for namespace in namespaces_to_create:
catalog.create_namespace(namespace)
assert len(catalog.list_tables(namespace)) == 0
for namespace in namespaces_to_create:
table = create_table(catalog, namespace, table_name)
num_rows = 10
df = generate_arrow_data(num_rows)
table.append(df)
create_datastore_glue_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(f"SELECT count() FROM {CATALOG_NAME}.`{namespace}.{table_name}`")
)
assert int(node.query(f"SELECT count() FROM system.iceberg_history WHERE database = '{CATALOG_NAME}' and table ilike '%{root_namespace}%'").strip()) == 4
assert int(node.query(f"SELECT count() FROM system.tables WHERE database = '{CATALOG_NAME}' and table ilike '%{root_namespace}%' SETTINGS show_data_lake_catalogs_in_system_tables = true").strip()) == 4
assert int(node.query(f"SELECT count() FROM system.tables WHERE database = '{CATALOG_NAME}' and table ilike '%{root_namespace}%' SETTINGS show_data_lake_catalogs_in_system_tables = false").strip()) == 0
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)
create_datastore_glue_database(
started_cluster,
node,
CATALOG_NAME,
additional_settings={
"aws_access_key_id": "SECRET_1",
"aws_secret_access_key": "SECRET_2",
},
)
assert "SECRET_1" not in node.query(f"SHOW CREATE DATABASE {CATALOG_NAME}")
assert "SECRET_2" not in node.query(f"SHOW CREATE DATABASE {CATALOG_NAME}")
def test_select_after_rename(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"
namespaces_to_create = [
root_namespace,
f"{root_namespace}_A",
f"{root_namespace}_B",
f"{root_namespace}_C",
]
catalog = load_catalog_impl(started_cluster)
for namespace in namespaces_to_create:
catalog.create_namespace(namespace)
assert len(catalog.list_tables(namespace)) == 0
for namespace in namespaces_to_create:
table = create_table(catalog, namespace, table_name)
num_rows = 10
df = generate_arrow_data(num_rows)
table.append(df)
create_datastore_glue_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}`"
)
with table.update_schema() as update:
update.rename_column("bid", "new_bid")
print(node.query(f"SELECT * FROM {CATALOG_NAME}.`{namespace}.{table_name}`"))
def test_non_existing_tables(started_cluster):
node = started_cluster.instances["node1"]
test_ref = f"test_non_existing_tables_{uuid.uuid4()}"
table_name = f"{test_ref}_table"
root_namespace = f"{test_ref}_namespace"
namespaces_to_create = [
root_namespace,
f"{root_namespace}_A",
]
catalog = load_catalog_impl(started_cluster)
for namespace in namespaces_to_create:
catalog.create_namespace(namespace)
for namespace in namespaces_to_create:
table = create_table(catalog, namespace, table_name)
num_rows = 10
df = generate_arrow_data(num_rows)
table.append(df)
create_datastore_glue_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}.wrong_table_name`")
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}.`fake_namespace.wrong_table_name`")
except Exception as e:
assert "DB::Exception: Table" in str(e)
assert "doesn't exist" in str(e)
def test_empty_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)
catalog.create_namespace(root_namespace)
create_table(catalog, root_namespace, table_name)
create_datastore_glue_database(started_cluster, node, CATALOG_NAME)
assert len(node.query(f"SELECT * FROM {CATALOG_NAME}.`{root_namespace}.{table_name}`")) == 0
def test_cloud_mode(started_cluster):
node = started_cluster.instances["node1"]
test_ref = f"test_cloud_mode_{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)
table = create_table(catalog, namespace, table_name)
num_rows = 10
df = generate_arrow_data(num_rows)
table.append(df)
create_datastore_glue_database(
started_cluster,
node,
CATALOG_NAME, query_settings={"cloud_mode": 1}
)
assert int(
node.query(f"SELECT count() FROM {CATALOG_NAME}.`{namespace}.{table_name}`")
) == num_rows
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_datastore_glue_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-glue/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, table_name)
create_datastore_glue_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'), NULL);", 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')\t{}\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_datastore_glue_database(started_cluster, node, CATALOG_NAME)
create_datastore_glue_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):
# Regression for issue #109801: a catalog-backed CREATE TABLE from ClickHouse
# with gzip metadata compression exercises IcebergMetadata::createInitial and
# the catalog->createTable registration. The first revision of the fix wrote
# v1.gz.metadata.json on disk but registered a hardcoded v1.metadata.json as
# the catalog metadata_location, so a reopen through the catalog failed.
#
# Glue is the concrete broken path: GlueCatalog::createTable persists the
# supplied new_metadata_path verbatim as parameters["metadata_location"]
# (unlike RestCatalog, which ignores new_metadata_path). We therefore read
# the registered metadata_location straight from Glue immediately after
# CREATE TABLE, before any INSERT, so the assertion validates createInitial's
# registration itself rather than a later updateMetadata pointer advance.
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_datastore_glue_database(started_cluster, node, CATALOG_NAME)
create_datastore_glue_table(
started_cluster,
node,
root_namespace,
table_name,
"(x String)",
additional_settings={"iceberg_metadata_compression_method": "gzip"},
)
# Validate the catalog registration produced by createInitial, before INSERT.
glue_client = boto3.client(
"glue", region_name="us-east-1", endpoint_url=get_glue_local_url(started_cluster)
)
table_info = glue_client.get_table(DatabaseName=root_namespace, Name=table_name)["Table"]
metadata_location = table_info["Parameters"]["metadata_location"]
assert metadata_location.endswith(".gz.metadata.json"), metadata_location
assert not metadata_location.endswith(".gzip.metadata.json"), metadata_location
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"
# Reopen through the catalog (fresh database) and confirm read still works.
create_datastore_glue_database(started_cluster, node, CATALOG_NAME)
assert node.query(f"SELECT * FROM {CATALOG_NAME}.`{root_namespace}.{table_name}`") == "AAPL\n"
def test_schema_evolution(started_cluster):
node = started_cluster.instances["node1"]
test_ref = f"test_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_datastore_glue_database(started_cluster, node, CATALOG_NAME)
create_datastore_glue_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_datastore_glue_database(started_cluster, node, CATALOG_NAME)
create_datastore_glue_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)
num_columns = 10
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_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_datastore_glue_database(started_cluster, node, CATALOG_NAME)
create_datastore_glue_table(started_cluster, node, root_namespace, table_name, "(x String)")
assert len(catalog.list_tables(root_namespace)) == 1
assert node.query(f"SELECT * FROM {CATALOG_NAME}.`{root_namespace}.{table_name}`") == ""
drop_datastore_glue_table(node, root_namespace, table_name)
assert len(catalog.list_tables(root_namespace)) == 0
def test_system_tables(started_cluster):
node = started_cluster.instances["node1"]
test_ref = f"test_system_tables_{uuid.uuid4()}"
table_name = f"{test_ref}_table"
root_namespace = f"{test_ref}_namespace"
namespaces_to_create = [
root_namespace,
f"{root_namespace}_A",
f"{root_namespace}_B",
f"{root_namespace}_C",
]
catalog = load_catalog_impl(started_cluster)
for namespace in namespaces_to_create:
catalog.create_namespace(namespace)
assert len(catalog.list_tables(namespace)) == 0
for namespace in namespaces_to_create:
table = create_table(catalog, namespace, table_name)
num_rows = 10
df = generate_arrow_data(num_rows)
table.append(df)
create_datastore_glue_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(f"SELECT count() FROM {CATALOG_NAME}.`{namespace}.{table_name}`")
)
assert CATALOG_NAME in node.query("SHOW DATABASES")
assert table_name in node.query(f"SHOW TABLES FROM {CATALOG_NAME}")
# system.tables
assert int(node.query(f"SELECT count() FROM system.tables WHERE database = '{CATALOG_NAME}' and table ilike '%{root_namespace}%' SETTINGS show_data_lake_catalogs_in_system_tables = true").strip()) == 4
assert int(node.query(f"SELECT count() FROM system.tables WHERE database = '{CATALOG_NAME}' and table ilike '%{root_namespace}%'").strip()) == 0
# system.iceberg_history - always displays irrespective of show_data_lake_catalogs_in_system_tables
assert int(node.query(f"SELECT count() FROM system.iceberg_history WHERE database = '{CATALOG_NAME}' and table ilike '%{root_namespace}%' SETTINGS show_data_lake_catalogs_in_system_tables = true").strip()) == 4
assert int(node.query(f"SELECT count() FROM system.iceberg_history WHERE database = '{CATALOG_NAME}' and table ilike '%{root_namespace}%' SETTINGS show_data_lake_catalogs_in_system_tables = false").strip()) == 4
# system.databases always shows data lake catalog databases regardless of show_data_lake_catalogs_in_system_tables,
# because listing a database name is purely local metadata and never requires calls to an external catalog service.
assert int(node.query(f"SELECT count() FROM system.databases WHERE name = '{CATALOG_NAME}' SETTINGS show_data_lake_catalogs_in_system_tables = true").strip()) == 1
assert int(node.query(f"SELECT count() FROM system.databases WHERE name = '{CATALOG_NAME}' SETTINGS show_data_lake_catalogs_in_system_tables = false").strip()) == 1
# system.columns
assert int(node.query(f"SELECT count() FROM system.columns WHERE database = '{CATALOG_NAME}' and table ilike '%{root_namespace}%' SETTINGS show_data_lake_catalogs_in_system_tables = true").strip()) == 24
assert int(node.query(f"SELECT count() FROM system.columns WHERE database = '{CATALOG_NAME}' and table ilike '%{root_namespace}%'").strip()) == 0
# system.completions
assert int(node.query(f"SELECT count() FROM system.completions WHERE startsWith(word, '{test_ref}') SETTINGS show_data_lake_catalogs_in_system_tables = true").strip()) != 0
assert int(node.query(f"SELECT count() FROM system.completions WHERE startsWith(word, '{test_ref}')").strip()) == 0
def test_show_tables_optimization(started_cluster):
node = started_cluster.instances["node1"]
test_ref = f"test_show_tables_{uuid.uuid4()}"
table_name = f"{test_ref}_table"
root_namespace = f"{test_ref}_namespace"
namespaces_to_create = [
root_namespace,
f"{root_namespace}_A",
f"{root_namespace}_B",
f"{root_namespace}_C",
]
catalog = load_catalog_impl(started_cluster)
for namespace in namespaces_to_create:
catalog.create_namespace(namespace)
assert len(catalog.list_tables(namespace)) == 0
for namespace in namespaces_to_create:
table = create_table(catalog, namespace, table_name)
num_rows = 10
df = generate_arrow_data(num_rows)
table.append(df)
create_datastore_glue_database(started_cluster, node, CATALOG_NAME)
assert table_name in node.query(f"SHOW TABLES FROM {CATALOG_NAME}")
assert not node.contains_in_log(
f"Get table information for table {root_namespace}.{table_name}"
)
node.query(f"SELECT * from system.tables where table ilike '%{root_namespace}%' SETTINGS show_data_lake_catalogs_in_system_tables = true")
assert node.contains_in_log(
f"Get table information for table {root_namespace}.{table_name}"
)
node.query("SYSTEM ENABLE FAILPOINT lightweight_show_tables")
node.query(f"SHOW TABLES FROM {CATALOG_NAME}", timeout=5)
def test_typo_hint_does_not_trigger_heavyweight_fetch(started_cluster):
"""
Regression test for the high-memory typo-hint path on a DataLake (Glue) database.
When a query references a table that does not exist, the UNKNOWN_TABLE error
path builds a "maybe you meant ...?" suggestion through
TableNameHints -> getAllRegisteredNames -> IDatabase::getAllTableNames. Before
this fix DatabaseDataLake did not override getAllTableNames, so the base
implementation iterated getTablesIterator and fetched per-table metadata from
the catalog (one heavyweight S3 read per table). With many tables this hint
lookup alone could exhaust the server's memory. The fix overrides
getAllTableNames to return getCatalog()->getTables() (table names only).
INSERT resolves its target table through the throwing DatabaseCatalog::getTable
overload independently of the analyzer, so it deterministically drives the
hint path. show_data_lake_catalogs_in_system_tables=1 is required: otherwise
getAllRegisteredNames short-circuits for remote databases and getAllTableNames
is never reached, so the override would not be exercised at all.
Signal: the log line emitted once per table inside
DatabaseDataLake::getTablesIterator. Counting it scoped to this namespace
makes the measurement exact and immune to other tables in the catalog. A
global S3GetObject counter is deliberately NOT used: catalog metadata is
cached across queries, so a repeated heavyweight iteration issues no new S3
GET requests even though it still walks every table.
Pre-fix the marker fires once per table (N). With the fix it stays at 0.
"""
node = started_cluster.instances["node1"]
test_ref = f"test_hint_heavy_{uuid.uuid4().hex[:8]}"
namespace = f"{test_ref}_ns"
N = 20
catalog = load_catalog_impl(started_cluster)
catalog.create_namespace(namespace)
try:
for i in range(N):
tbl = create_table(catalog, namespace, f"{test_ref}_t{i:02d}")
tbl.append(generate_arrow_data(1))
create_datastore_glue_database(started_cluster, node, CATALOG_NAME)
# Emitted once per table inside DatabaseDataLake::getTablesIterator. If the
# message is ever rephrased, update this marker.
marker = f"Get table information for table {namespace}."
baseline = int(node.count_in_log(marker).strip())
# Reference a non-existent table; this must raise UNKNOWN_TABLE and on the
# way build the typo hint that walks getAllTableNames.
error = node.query_and_get_error(
f"INSERT INTO {CATALOG_NAME}.`{namespace}.does_not_exist` VALUES (1)",
settings={"show_data_lake_catalogs_in_system_tables": 1},
)
assert "UNKNOWN_TABLE" in error or "does not exist" in error, (
f"Expected an UNKNOWN_TABLE error for a non-existent table, got: {error}"
)
fetches = int(node.count_in_log(marker).strip()) - baseline
assert fetches == 0, (
f"The typo-hint lookup for a non-existent table triggered {fetches} "
f"per-table metadata fetches (expected 0; the namespace has {N} tables). "
f"DatabaseDataLake.getAllTableNames is falling through to "
f"IDatabase::getAllTableNames -> getTablesIterator instead of using "
f"getCatalog()->getTables() directly."
)
finally:
try:
for i in range(N):
catalog.drop_table(f"{namespace}.{test_ref}_t{i:02d}")
catalog.drop_namespace(namespace)
except Exception:
pass
def test_table_without_metadata_location(started_cluster):
"""
Test that Datastore can read Iceberg tables from Glue when 'metadata_location' is not present in Glue parameters.
Glue's Location field is used to deduce the metadata location.
"""
node = started_cluster.instances["node1"]
test_ref = f"test_no_metadata_location_{uuid.uuid4()}"
table_name = f"{test_ref}_table"
root_namespace = f"{test_ref}_namespace"
# Use unique database name to avoid interference with other tests (as we are playing with Glue metadata here)
db_name = f"db_{test_ref.replace('-', '_')}"
catalog = load_catalog_impl(started_cluster)
catalog.create_namespace(root_namespace)
schema = Schema(
NestedField(field_id=1, name="id", field_type=StringType(), required=False),
NestedField(field_id=2, name="value", field_type=DoubleType(), required=False),
)
table = create_table(catalog, root_namespace, table_name, schema, PartitionSpec(), DEFAULT_SORT_ORDER, dir=table_name)
data = [
{"id": "row1", "value": 1.5},
{"id": "row2", "value": 2.5},
{"id": "row3", "value": 3.5},
]
df = pa.Table.from_pylist(data)
table.append(df)
glue_client = boto3.client(
"glue", region_name="us-east-1", endpoint_url=get_glue_local_url(started_cluster)
)
table_response = glue_client.get_table(
DatabaseName=root_namespace,
Name=table_name
)
table_info = table_response["Table"]
# Verify metadata_location exists before we remove it
assert "metadata_location" in table_info.get("Parameters", {}), "Test setup failed: metadata_location should exist initially"
# Remove metadata_location from parameters but keep other parameters
new_parameters = {k: v for k, v in table_info.get("Parameters", {}).items() if k != "metadata_location"}
glue_client.update_table(
DatabaseName=root_namespace,
TableInput={
"Name": table_name,
"StorageDescriptor": table_info["StorageDescriptor"],
"Parameters": new_parameters,
"TableType": table_info.get("TableType", "EXTERNAL_TABLE"),
}
)
# Verify metadata_location was removed
updated_table = glue_client.get_table(DatabaseName=root_namespace, Name=table_name)
assert "metadata_location" not in updated_table["Table"].get("Parameters", {}), "Failed to remove metadata_location"
assert updated_table["Table"]["StorageDescriptor"]["Location"], "Location should still be present"
create_datastore_glue_database(started_cluster, node, db_name)
# Verify SHOW TABLES FROM works
tables_result = node.query(f"SHOW TABLES FROM {db_name} LIKE '%{table_name}%'")
assert table_name in tables_result, f"Table {table_name} not found in SHOW TABLES: {tables_result}"
# Query should work even without metadata_location in Glue
result = node.query(f"SELECT id, value FROM {db_name}.`{root_namespace}.{table_name}` ORDER BY id")
assert result == "row1\t1.5\nrow2\t2.5\nrow3\t3.5\n", f"Unexpected result: {result}"
# Also verify SHOW CREATE TABLE works
create_table_result = node.query(f"SHOW CREATE TABLE {db_name}.`{root_namespace}.{table_name}`")
assert "Iceberg" in create_table_result, f"Expected Iceberg engine in: {create_table_result}"
node.query(f"DROP DATABASE IF EXISTS {db_name} SYNC")
def test_check_database(started_cluster):
"""Test that CHECK DATABASE works with Glue catalog database."""
node = started_cluster.instances["node1"]
test_ref = f"test_check_database_{uuid.uuid4()}"
table_name = f"{test_ref}_table"
root_namespace = f"{test_ref}_namespace"
namespaces_to_create = [
root_namespace,
f"{root_namespace}_A",
f"{root_namespace}_B",
f"{root_namespace}_C",
]
catalog = load_catalog_impl(started_cluster)
# Create namespaces
for namespace in namespaces_to_create:
catalog.create_namespace(namespace)
assert len(catalog.list_tables(namespace)) == 0
# Create Datastore Glue database once
create_datastore_glue_database(started_cluster, node, CATALOG_NAME)
# Create tables in each namespace
for namespace in namespaces_to_create:
table = create_table(catalog, namespace, table_name)
num_rows = 10
df = generate_arrow_data(num_rows)
table.append(df)
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(f"SELECT count() FROM {CATALOG_NAME}.`{namespace}.{table_name}`")
)
# Verify database exists
assert CATALOG_NAME in node.query("SHOW DATABASES")
# Run CHECK DATABASE and verify it completes without error
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_sts_smoke(started_cluster):
"""Test that STS authentication works with Glue catalog using role_arn and role_session_name"""
node = started_cluster.instances["node1"]
test_ref = f"test_sts_smoke_{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="id", field_type=StringType(), required=False),
NestedField(field_id=2, name="value", field_type=DoubleType(), required=False),
)
table = create_table(catalog, root_namespace, table_name, schema, PartitionSpec(), DEFAULT_SORT_ORDER, dir=table_name)
data = [
{"id": "row1", "value": 10.0},
{"id": "row2", "value": 20.0},
{"id": "row3", "value": 30.0},
]
df = pa.Table.from_pylist(data)
table.append(df)
# Test with wrong role_session_name - should fail
db_name_fail = f"db_fail_{test_ref.replace('-', '_')}"
create_datastore_glue_database(
started_cluster,
node,
db_name_fail,
additional_settings={
"aws_role_arn": "arn::role",
"aws_role_session_name": "wrongsession",
},
query_settings={"s3_allow_server_credentials_in_user_queries": 1},
with_credentials=False,
)
# Query should fail with wrong session name
try:
result = node.query(
f"SELECT sum(value) FROM {db_name_fail}.`{root_namespace}.{table_name}` "
f"SETTINGS s3_max_single_read_retries = 1, s3_retry_attempts = 1, s3_request_timeout_ms = 1000"
)
assert False, f"Expected query to fail with wrong session name but got result: {result}"
except Exception as e:
error_str = str(e)
assert "403" in error_str or "Failed to get object info" in error_str or "HTTP response code: 403" in error_str, \
f"Expected 403 error but got: {error_str}"
# Test with correct role_session_name - should succeed
db_name_success = f"db_success_{test_ref.replace('-', '_')}"
create_datastore_glue_database(
started_cluster,
node,
db_name_success,
additional_settings={
"aws_role_arn": "arn::role",
"aws_role_session_name": "miniorole",
},
query_settings={"s3_allow_server_credentials_in_user_queries": 1},
with_credentials=False,
)
# Query should succeed with correct session name
result = node.query(f"SELECT sum(value) FROM {db_name_success}.`{root_namespace}.{table_name}`")
assert result.strip() == "60", f"Expected sum to be 60 but got: {result}"
# Cleanup
node.query(f"DROP DATABASE IF EXISTS {db_name_fail} SYNC")
node.query(f"DROP DATABASE IF EXISTS {db_name_success} SYNC")
def test_sts_external_id(started_cluster):
"""Test that `aws_external_id` reaches the STS AssumeRole request from the
Glue catalog database engine. The mock STS only vends working credentials
when the supplied ExternalId matches, so the negative case fails purely on
the external id (the role session name is correct in both cases)."""
node = started_cluster.instances["node1"]
test_ref = f"test_sts_external_id_{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="id", field_type=StringType(), required=False),
NestedField(field_id=2, name="value", field_type=DoubleType(), required=False),
)
table = create_table(catalog, root_namespace, table_name, schema, PartitionSpec(), DEFAULT_SORT_ORDER, dir=table_name)
data = [
{"id": "row1", "value": 10.0},
{"id": "row2", "value": 20.0},
{"id": "row3", "value": 30.0},
]
df = pa.Table.from_pylist(data)
table.append(df)
# Negative: correct role session name but wrong external id - should fail.
db_name_fail = f"db_fail_{test_ref.replace('-', '_')}"
create_datastore_glue_database(
started_cluster,
node,
db_name_fail,
additional_settings={
"aws_role_arn": "arn::role",
"aws_role_session_name": "miniorole",
"aws_external_id": "wrong_external_id",
},
query_settings={"s3_allow_server_credentials_in_user_queries": 1},
with_credentials=False,
)
try:
result = node.query(
f"SELECT sum(value) FROM {db_name_fail}.`{root_namespace}.{table_name}` "
f"SETTINGS s3_max_single_read_retries = 1, s3_retry_attempts = 1, s3_request_timeout_ms = 1000"
)
assert False, f"Expected query to fail with wrong external id but got result: {result}"
except Exception as e:
error_str = str(e)
assert "403" in error_str or "Failed to get object info" in error_str or "HTTP response code: 403" in error_str, \
f"Expected 403 error but got: {error_str}"
# Positive: correct role session name and external id - should succeed.
db_name_success = f"db_success_{test_ref.replace('-', '_')}"
create_datastore_glue_database(
started_cluster,
node,
db_name_success,
additional_settings={
"aws_role_arn": "arn::role",
"aws_role_session_name": "miniorole",
"aws_external_id": "miniexternalid",
},
query_settings={"s3_allow_server_credentials_in_user_queries": 1},
with_credentials=False,
)
result = node.query(f"SELECT sum(value) FROM {db_name_success}.`{root_namespace}.{table_name}`")
assert result.strip() == "60", f"Expected sum to be 60 but got: {result}"
# Cleanup
node.query(f"DROP DATABASE IF EXISTS {db_name_fail} SYNC")
node.query(f"DROP DATABASE IF EXISTS {db_name_success} SYNC")
def test_sts_credential_refresh_on_expired_token(started_cluster):
"""When an S3 read fails mid-query with an `ExpiredToken`-style error,
`GlueCatalog::getCredentialsConfigurationCallback` should fire and the read
should recover with freshly-vended STS credentials.
Without the override (the previous behavior), the catalog inherits the default
`std::nullopt` from `ICatalog::getCredentialsConfigurationCallback`,
`ReadBufferFromS3::processException` skips the refresh branch, and the read
surfaces the `ExpiredToken` to the user.
The `s3_send_request_throw_expired_token` failpoint fires inside
`ReadBufferFromS3::sendRequest`, so it covers both `nextImpl`-driven
streaming reads and the `readBigAt` range-read path that Parquet column-chunk
reads go through. It is `ONCE`, so only the first S3 GET fails — the
subsequent retry triggered by the credentials refresh callback succeeds.
"""
node = started_cluster.instances["node1"]
test_ref = f"refresh_token_{uuid.uuid4().hex}"
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="id", field_type=StringType(), required=False),
NestedField(field_id=2, name="value", field_type=DoubleType(), required=False),
)
rows = [
{"id": "row1", "value": 10.0},
{"id": "row2", "value": 20.0},
{"id": "row3", "value": 30.0},
]
expected_sum = sum(r["value"] for r in rows)
create_table(
catalog, root_namespace, table_name, schema, PartitionSpec(), DEFAULT_SORT_ORDER, dir=table_name
).append(pa.Table.from_pylist(rows))
db_name = f"db_{test_ref.replace('-', '_')}"
create_datastore_glue_database(
started_cluster,
node,
db_name,
additional_settings={
"aws_role_arn": "arn::role",
"aws_role_session_name": "miniorole",
},
query_settings={"s3_allow_server_credentials_in_user_queries": 1},
with_credentials=False,
)
# Sanity check: query works without the failpoint.
pre_failpoint = node.query(
f"SELECT sum(value) FROM {db_name}.`{root_namespace}.{table_name}`"
).strip()
assert pre_failpoint == str(int(expected_sum)), (
f"Baseline query returned {pre_failpoint}, expected {int(expected_sum)}"
)
qid_select = uuid.uuid4().hex
node.query("SYSTEM ENABLE FAILPOINT s3_send_request_throw_expired_token")
try:
result = node.query(
f"SELECT sum(value) FROM {db_name}.`{root_namespace}.{table_name}` "
"SETTINGS max_threads = 1",
query_id=qid_select,
)
finally:
node.query("SYSTEM DISABLE FAILPOINT s3_send_request_throw_expired_token")
assert result.strip() == str(int(expected_sum)), (
f"Expected sum {int(expected_sum)} after refresh-driven retry, got: {result}"
)
node.query("SYSTEM FLUSH LOGS system.text_log")
# Diagnostic: confirm the failpoint actually tripped. If this assertion fails,
# the test environment did not reach the credential-refresh code path at all
# (e.g. column chunks completed in a single `nextImpl` call); the refresh-log
# assertion below would be vacuous.
failpoint_fired = int(
node.query(
"SELECT count() FROM system.text_log "
"WHERE message LIKE '%Caught exception while reading S3 object%' "
"AND message LIKE '%ExpiredToken%' "
"AND event_time >= now() - INTERVAL 5 MINUTE"
).strip()
)
assert failpoint_fired >= 1, (
"Failpoint `s3_send_request_throw_expired_token` did not fire — "
"`processException` was never invoked, so the credentials-refresh "
"code path was not exercised."
)
# The `LOG_DEBUG` in `GlueCatalog::getCredentialsConfigurationCallback` must
# have fired — its absence would mean the override is not in place and the
# retry was attributable to some other path.
refresh_log_count = int(
node.query(
"SELECT count() FROM system.text_log "
"WHERE message LIKE '%Refreshing AWS credentials%after expired token%' "
"AND event_time >= now() - INTERVAL 5 MINUTE"
).strip()
)
assert refresh_log_count >= 1, (
"The failpoint fired but `GlueCatalog::getCredentialsConfigurationCallback` "
"was not invoked — the override is missing or not wired into "
"`StorageS3Configuration::createObjectStorage`"
)
node.query(f"DROP DATABASE IF EXISTS {db_name} SYNC")
def test_glue_catalog_unavailable_after_restart_under_restriction(started_cluster):
# A Glue `DataLakeCatalog` whose catalog credentials are server-managed (no explicit keys -> resolved from
# the server environment) is created under the per-session opt-in. After a restart it is reloaded under the
# default restriction (`s3_allow_server_credentials_in_user_queries = 0`); the server must still start, but
# the catalog is left unavailable (governed by `s3_load_table_anonymously_if_credentials_restricted`) rather
# than silently reusing the server identity or aborting startup.
node = started_cluster.instances["node1"]
db_name = f"glue_server_cred_restart_{uuid.uuid4().hex}"
create_datastore_glue_database(
started_cluster,
node,
db_name,
with_credentials=False,
query_settings={"s3_allow_server_credentials_in_user_queries": 1},
)
# Works while the creating session allows server-managed credentials for the catalog.
node.query(
f"SHOW TABLES FROM {db_name}",
settings={"s3_allow_server_credentials_in_user_queries": 1},
)
node.restart_clickhouse()
# The server starts even though the catalog can no longer resolve its (server-managed) credentials.
assert node.query("SELECT 1").strip() == "1"
# Reloaded under the default restriction, the catalog is unavailable. Querying a table goes through the
# catalog client (`getCatalog`) and reports the restriction, rather than silently reusing the server
# identity. (`SHOW TABLES` intentionally swallows catalog errors so `system.tables` does not fail, so it
# is not a reliable probe here.)
error = node.query_and_get_error(f"SELECT * FROM {db_name}.`unavailable.table`")
assert (
"ACCESS_DENIED" in error
or "server-managed" in error
or "s3_allow_server_credentials_in_user_queries" in error
), error
node.query(f"DROP DATABASE IF EXISTS {db_name} SYNC")
def test_glue_catalog_user_attach_under_restriction_is_rejected(started_cluster):
# A user ATTACH DATABASE of a catalog that resolves server-managed credentials must stay fail-closed for a
# restricted user. The catalog is built lazily (on first access, not at ATTACH), so the ATTACH itself
# succeeds but the database is inaccessible: the first query against it is refused because the catalog
# resolves server-managed credentials that are restricted for user queries.
node = started_cluster.instances["node1"]
db_name = f"glue_user_attach_{uuid.uuid4().hex}"
allow = {"s3_allow_server_credentials_in_user_queries": 1}
create_datastore_glue_database(
started_cluster, node, db_name, with_credentials=False, query_settings=allow
)
node.query(f"DETACH DATABASE {db_name}")
# Re-attaching as a user query under the default restriction succeeds (lazy), but the database is
# fail-closed: accessing a table goes through the catalog client (`getCatalog`), which resolves the
# restricted server credentials and is refused. (`SHOW TABLES` intentionally swallows catalog errors, so it
# is not a reliable probe.)
node.query(f"ATTACH DATABASE {db_name}")
error = node.query_and_get_error(f"SELECT * FROM {db_name}.`unavailable.table`")
assert (
"ACCESS_DENIED" in error
or "server-managed" in error
or "s3_allow_server_credentials_in_user_queries" in error
), error
# With the opt-in the database is usable again, so it can be cleaned up.
node.query(f"DROP DATABASE IF EXISTS {db_name} SYNC", settings=allow)