Files
Alexey MilovidovandClaude Opus 4.8 3310b5669b Fix test_url_wildcard_page_cache_uses_web_source_identity
The test queried two comma-separated full URLs with *different paths*
(`.../page_cache_shard0/part*.tsv,.../page_cache_shard1/part*.tsv`). The URL
index-page wildcard feature represents shards as a single shared path (`path.path`)
that differs only by authority/query (`StorageWebConfiguration::setNamespaceFromURL`
only expands shards in the authority part). A top-level comma between two
different-path URLs is therefore absorbed into the first shard's path, producing a
single shard whose glob is `part*.tsv,http://.../part*.tsv` that matches nothing, so
the query silently read 0 rows (`files_read=0`) instead of 303.

Besides being unsupported, the different-path form would not have isolated source
identity anyway: distinct object paths already produce distinct page-cache keys, so
the test would pass with or without the fix.

Convert the test to the supported same-path/different-source form
(`.../page_cache_identity/part*.tsv?shard={0,1}`, mirroring
`test_url_wildcard_archive_metadata_uses_shard_identity`): both shards list the same
`part.tsv` object with the same ETag but different contents, so the page cache must
use the web source identity to avoid a cross-shard cache collision. Verified locally
against a fresh build and the mock server: the query returns 303 with
`use_page_cache_for_object_storage=1`.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-30 06:46:46 +00:00

852 lines
31 KiB
Python

import pytest
import time
import uuid
import os
import json
from helpers.cluster import ClickHouseCluster
from helpers.test_tools import TSV
from helpers.mock_servers import start_mock_servers
cluster = ClickHouseCluster(__file__)
URL_WILDCARD_EXPERIMENTAL_SETTING = "allow_experimental_url_wildcard_from_index_pages=1"
node1 = cluster.add_instance(
"node1",
main_configs=["configs/conf.xml", "configs/named_collections.xml", "configs/query_log.xml"],
user_configs=["configs/users.xml"],
with_nginx=True,
with_minio=True,
)
@pytest.fixture(scope="module", autouse=True)
def setup_node():
try:
cluster.start()
start_mock_servers(
cluster,
os.path.dirname(__file__),
[
("index_pages_server.py", "resolver", 8087),
("index_pages_server_redirect_target.py", "resolver", 8088),
],
)
node1.query(
"insert into table function url(url1) partition by column3 values (1, 2, 3), (3, 2, 1), (1, 3, 2)"
)
yield
finally:
cluster.shutdown()
def reset_index_page_server_stats(port=8087):
resolver_id = cluster.get_container_id("resolver")
cluster.exec_in_container(
resolver_id,
["curl", "-s", f"http://localhost:{port}/__reset__"],
)
def get_index_page_server_stats(port=8087):
resolver_id = cluster.get_container_id("resolver")
return json.loads(
cluster.exec_in_container(
resolver_id,
["curl", "-s", f"http://localhost:{port}/__stats__"],
)
)
def with_url_wildcard_setting(query):
if "SETTINGS" in query:
return f"{query}, {URL_WILDCARD_EXPERIMENTAL_SETTING}"
return f"{query} SETTINGS {URL_WILDCARD_EXPERIMENTAL_SETTING}"
def test_partition_by():
result = node1.query(
"select * from url('http://nginx:80/test_1', 'TSV', 'column1 UInt32, column2 UInt32, column3 UInt32')"
)
assert result.strip() == "3\t2\t1"
result = node1.query(
"select * from url('http://nginx:80/test_2', 'TSV', 'column1 UInt32, column2 UInt32, column3 UInt32')"
)
assert result.strip() == "1\t3\t2"
result = node1.query(
"select * from url('http://nginx:80/test_3', 'TSV', 'column1 UInt32, column2 UInt32, column3 UInt32')"
)
assert result.strip() == "1\t2\t3"
def test_url_cluster():
result = node1.query(
"select * from urlCluster('test_cluster_two_shards', 'http://nginx:80/test_1', 'TSV', 'column1 UInt32, column2 UInt32, column3 UInt32')"
)
assert result.strip() == "3\t2\t1"
result = node1.query(
"select * from urlCluster('test_cluster_two_shards', 'http://nginx:80/test_2', 'TSV', 'column1 UInt32, column2 UInt32, column3 UInt32')"
)
assert result.strip() == "1\t3\t2"
result = node1.query(
"select * from urlCluster('test_cluster_two_shards', 'http://nginx:80/test_3', 'TSV', 'column1 UInt32, column2 UInt32, column3 UInt32')"
)
assert result.strip() == "1\t2\t3"
def test_url_cluster_rejects_url_wildcard_from_index_pages():
error = node1.query_and_get_error(
with_url_wildcard_setting(
"SELECT count() FROM urlCluster('test_cluster_two_shards', "
"'http://resolver:8087/data/**/part*.tsv', 'TSV', 'x UInt64')"
)
)
assert "`urlCluster` does not support wildcard expansion from HTTP index pages" in error
def test_url_cluster_secure():
query_id = f"{uuid.uuid4()}"
METADATA_SERVER_HOSTNAME = "node_imds"
METADATA_SERVER_PORT = 8080
url = f"http://adminka:secretPasswordick@{METADATA_SERVER_HOSTNAME}:{METADATA_SERVER_PORT}"
node1.query(
f"CREATE TABLE leaked_secret_test (column1 UInt32, column2 UInt32, column3 UInt32) ENGINE = URL('{url}', 'TSV')",
query_id=query_id
)
node1.query("SYSTEM FLUSH LOGS")
result = node1.query(
f"select query from clusterAllReplicas(test_cluster_one_shard_three_replicas_localhost,system.query_log) where query_id='{query_id}'"
)
assert 'leaked_secret_test' in result
assert 'secretPasswordick' not in result
node1.query("DROP TABLE leaked_secret_test")
def test_url_cluster_with_named_collection():
result = node1.query(
"select * from urlCluster(test_cluster_one_shard_three_replicas_localhost, test_url)"
)
assert result.strip() == "3\t2\t1"
result = node1.query(
"select * from urlCluster(test_cluster_one_shard_three_replicas_localhost, test_url, structure='auto')"
)
assert result.strip() == "3\t2\t1"
def test_url_wildcard_from_index_pages():
result = node1.query(
with_url_wildcard_setting("SELECT sum(x) FROM url('http://resolver:8087/data/**/part*.tsv', 'TSV', 'x UInt64')")
)
assert result.strip() == "12"
def test_url_wildcard_schema_inference():
result = node1.query(
with_url_wildcard_setting("DESCRIBE TABLE url('http://resolver:8087/data/**/part*.tsv', 'TSV')")
)
assert result == TSV([["c1", "Nullable(Int64)"]])
result = node1.query(
with_url_wildcard_setting("SELECT sum(c1) FROM url('http://resolver:8087/data/**/part*.tsv', 'TSV')")
)
assert result.strip() == "12"
def test_url_wildcard_schema_inference_checks_headers_before_reading():
reset_index_page_server_stats()
error = node1.query_and_get_error(
with_url_wildcard_setting(
"DESCRIBE TABLE url("
"'http://resolver:8087/data/**/part*.tsv', "
"'TSV', "
"headers('X-Forbidden-Url-Wildcard'='1'))"
)
)
assert "HTTP header" in error
assert "X-Forbidden-Url-Wildcard" in error
assert "http_forbid_headers" in error
stats = get_index_page_server_stats()
assert stats == {}
def test_url_wildcard_engine_checks_headers_before_reading():
reset_index_page_server_stats()
node1.query("DROP TABLE IF EXISTS url_wildcard_engine_forbidden_header")
error = node1.query_and_get_error(
"CREATE TABLE url_wildcard_engine_forbidden_header "
"ENGINE = URL("
"'http://resolver:8087/data/**/part*.tsv', "
"'TSV', "
"headers('X-Forbidden-Url-Wildcard'='1'))",
settings={"allow_experimental_url_wildcard_from_index_pages": 1},
)
assert "HTTP header" in error
assert "X-Forbidden-Url-Wildcard" in error
assert "http_forbid_headers" in error
# The header filter must be enforced before schema/format inference reads any index page.
stats = get_index_page_server_stats()
assert stats == {}
def test_url_engine_persists_inferred_format_with_compression_argument():
table_name = "url_engine_auto_format_with_compression"
node1.query(f"DROP TABLE IF EXISTS {table_name}")
try:
node1.query(
f"CREATE TABLE {table_name} (x UInt64) "
"ENGINE = URL('http://resolver:8087/data/2025/part1.tsv', 'auto', 'none')"
)
result = node1.query(f"SELECT sum(x) FROM {table_name}")
assert result.strip() == "3"
# `SHOW CREATE TABLE` without an explicit format is returned as `TabSeparated`, which escapes
# the single quotes (`'` becomes `\'`). Use `TSVRaw` so the create statement is returned verbatim
# and the positional `format`/`compression` arguments can be matched as written.
create_query = node1.query(f"SHOW CREATE TABLE {table_name} FORMAT TSVRaw")
assert "'auto', 'none'" not in create_query
assert "'TSV', 'none'" in create_query
finally:
node1.query(f"DROP TABLE IF EXISTS {table_name}")
def test_url_wildcard_time_virtual_column_is_null_without_last_modified():
# The mock index server never sends a `Last-Modified` header, so the modification time is
# unknown and `_time` must be reported as NULL rather than the default epoch `1970-01-01`.
result = node1.query(
with_url_wildcard_setting(
"SELECT any(_time) IS NULL "
"FROM url('http://resolver:8087/data/**/part*.tsv', 'TSV', 'x UInt64')"
)
)
assert result.strip() == "1"
def test_url_wildcard_size_virtual_column():
result = node1.query(
with_url_wildcard_setting("SELECT sum(size) FROM ("
"SELECT _file, any(_size) AS size "
"FROM url('http://resolver:8087/data/**/part*.tsv', 'TSV', 'x UInt64') "
"GROUP BY _file)")
)
assert result.strip() == "8"
def test_url_wildcard_body_without_content_length():
result = node1.query(
with_url_wildcard_setting(
"SELECT sum(x) "
"FROM url('http://resolver:8087/data/no_content_length_get/**/part*.tsv', 'TSV', 'x UInt64')"
)
)
assert result.strip() == "29"
def test_url_wildcard_unknown_size_virtual_column():
result = node1.query(
with_url_wildcard_setting(
"SELECT sum(x), any(_size) IS NULL "
"FROM url('http://resolver:8087/data/unknown_size/**/part*.tsv', 'TSV', 'x UInt64')"
)
)
assert result.strip() == "31\t1"
def test_url_wildcard_headers_virtual_column():
result = node1.query(
with_url_wildcard_setting(
"SELECT sum(length(mapKeys(_headers))) "
"FROM url('http://resolver:8087/data/**/part*.tsv', 'TSV', 'x UInt64') "
)
)
assert int(result.strip()) > 0
def test_url_wildcard_empty_listing():
result = node1.query(
with_url_wildcard_setting("SELECT count() FROM url('http://resolver:8087/data/empty/**/part*.tsv', 'TSV', 'x UInt64')")
)
assert result.strip() == "0"
def test_url_wildcard_missing_listing():
error = node1.query_and_get_error(
with_url_wildcard_setting("SELECT count() FROM url('http://resolver:8087/missing/**/part*.tsv', 'TSV', 'x UInt64')")
)
assert "There is no path" in error
def test_url_wildcard_oversize_index_page():
error = node1.query_and_get_error(
with_url_wildcard_setting("SELECT count() FROM url('http://resolver:8087/data/oversize/**/part*.tsv', 'TSV', 'x UInt64')")
)
assert "exceeds max_http_index_page_size" in error
def test_url_wildcard_query_fragment_matching():
result = node1.query(
with_url_wildcard_setting("SELECT sum(x) FROM url('http://resolver:8087/data/query/part*.tsv', 'TSV', 'x UInt64')")
)
assert result.strip() == "6"
def test_url_wildcard_falls_back_to_plain_url_scan_when_href_links_are_invalid():
result = node1.query(
with_url_wildcard_setting(
"SELECT sum(x) FROM url('http://resolver:8087/data/invalid_href_fallback/part*.tsv', 'TSV', 'x UInt64')"
)
)
assert result.strip() == "12"
def test_url_wildcard_reads_files_when_head_is_not_supported():
result = node1.query(
with_url_wildcard_setting(
"SELECT sum(x) FROM url('http://resolver:8087/data/head_fallback/part*.tsv', 'TSV', 'x UInt64')"
)
)
assert result.strip() == "7"
def test_url_wildcard_with_headers():
result = node1.query(
with_url_wildcard_setting("SELECT sum(x) FROM url("
"'http://resolver:8087/data/headers/**/part*.tsv', "
"'TSV', "
"'x UInt64', "
"headers('X-Test-Header'='1'))")
)
assert result.strip() == "15"
def test_url_wildcard_with_all_positional_args_and_headers():
# Regression: the wildcard path used to validate the raw argument count before stripping the
# optional `headers(...)` argument, so a full positional signature plus `headers(...)` was
# wrongly rejected with NUMBER_OF_ARGUMENTS_DOESNT_MATCH even though the plain `url` path
# accepts it. Here url + format + structure + compression + headers = 5 AST children.
result = node1.query(
with_url_wildcard_setting("SELECT sum(x) FROM url("
"'http://resolver:8087/data/headers/**/part*.tsv', "
"'TSV', "
"'x UInt64', "
"'none', "
"headers('X-Test-Header'='1'))")
)
assert result.strip() == "15"
def test_url_wildcard_expands_query_templates():
result = node1.query(
with_url_wildcard_setting("SELECT sum(x) FROM url('http://resolver:8087/data/**/part*.tsv?x={1,2}', 'TSV', 'x UInt64')")
)
assert result.strip() == "24"
def test_url_wildcard_expands_address_templates():
result = node1.query(
with_url_wildcard_setting(
"SELECT sum(x) FROM url('http://{resolver,resolver}:8087/data/source_query/**/part*.tsv?token=abc', 'TSV', 'x UInt64')"
)
)
assert result.strip() == "26"
def test_url_wildcard_combines_address_templates_failover_options_and_globs():
result = node1.query(
with_url_wildcard_setting(
"SELECT sum(x) FROM url('http://{resolver,resolver}:8087/data/source_query/**/part*.tsv?token={bad|abc}', 'TSV', 'x UInt64')"
)
)
assert result.strip() == "26"
def test_url_wildcard_expands_numeric_ranges_with_index_pages():
result = node1.query(
with_url_wildcard_setting("SELECT sum(x) FROM url('http://resolver:8087/data/range/**/part{1..2}.tsv', 'TSV', 'x UInt64')")
)
assert result.strip() == "30"
def test_url_wildcard_glob_patterns_with_recursive_index_pages():
result = node1.query(
with_url_wildcard_setting("SELECT sum(x) FROM url('http://resolver:8087/data/recursive_glob/**/part{a,b}.tsv', 'TSV', 'x UInt64')")
)
assert result.strip() == "3"
result = node1.query(
with_url_wildcard_setting("SELECT sum(x) FROM url('http://resolver:8087/data/recursive_glob/**/part{1..2}.tsv', 'TSV', 'x UInt64')")
)
assert result.strip() == "30"
def test_url_wildcard_question_mark_is_not_path_glob():
error = node1.query_and_get_error(
with_url_wildcard_setting("SELECT count() FROM url('http://resolver:8087/data/glob/part?.tsv', 'TSV', 'x UInt64')")
)
assert "No such file" in error or "HTTP status code: 404" in error
def test_url_wildcard_rejects_invalid_url_template_range():
error = node1.query_and_get_error(
with_url_wildcard_setting("SELECT count() FROM url('http://resolver:8087/data/range/**/part*.tsv?x={a..2}', 'TSV', 'x UInt64')")
)
assert "Incorrect left number" in error
def test_url_wildcard_glob_patterns():
# '?' is reserved in URLs as a query delimiter, so use '*' to cover wildcard matching in URL paths.
result = node1.query(
with_url_wildcard_setting("SELECT sum(x) FROM url('http://resolver:8087/data/glob/part*.tsv', 'TSV', 'x UInt64')")
)
assert result.strip() == "15"
result = node1.query(
with_url_wildcard_setting("SELECT sum(x) FROM url('http://resolver:8087/data/glob/part{a,b,c}.tsv', 'TSV', 'x UInt64')")
)
assert result.strip() == "6"
result = node1.query(
with_url_wildcard_setting("SELECT sum(x) FROM url('http://resolver:8087/data/glob/part{1..2}.tsv', 'TSV', 'x UInt64')")
)
assert result.strip() == "9"
def test_url_wildcard_deduplicates_normalized_links():
result = node1.query(
with_url_wildcard_setting("SELECT sum(x) FROM url('http://resolver:8087/data/duplicates/part*.tsv', 'TSV', 'x UInt64')")
)
assert result.strip() == "9"
def test_url_wildcard_listing_order():
# The HTML index lists `a/` before `b/`, so recursive `**` expansion must read
# `a/part1.tsv` (10) before `b/part1.tsv` (20). Read single-threaded so the output
# order reflects the listing order (`glob_expansion_max_elements` is a hard limit
# that now raises an exception when exceeded, so it can no longer be used to
# truncate the listing to its first element).
result = node1.query(
with_url_wildcard_setting(
"SELECT groupArray(x) FROM url('http://resolver:8087/data/order/**/part*.tsv', 'TSV', 'x UInt64') "
"SETTINGS max_threads=1, max_download_threads=1"
)
)
assert result.strip() == "[10,20]"
def test_url_wildcard_normalizes_leading_slashes():
result = node1.query(
with_url_wildcard_setting("SELECT sum(x) FROM url('http://resolver:8087///data/**/part*.tsv', 'TSV', 'x UInt64')")
)
assert result.strip() == "12"
def test_url_wildcard_uses_head_for_metadata_probe():
reset_index_page_server_stats()
result = node1.query(
with_url_wildcard_setting(
"SELECT sum(x) FROM url('http://resolver:8087/data/2025/part*.tsv', 'TSV', 'x UInt64') "
"SETTINGS use_hive_partitioning=0"
)
)
assert result.strip() == "12"
stats = get_index_page_server_stats()
assert stats["HEAD /data/2025/part1.tsv"] == 1
assert stats["HEAD /data/2025/part2.tsv"] == 1
assert stats["GET /data/2025/part1.tsv"] == 1
assert stats["GET /data/2025/part2.tsv"] == 1
def test_url_wildcard_resets_headers_between_files():
result = node1.query(
with_url_wildcard_setting(
"SELECT _file, any(_headers['X-Source-File']), any(_headers['X-Probe-Method']) "
"FROM url('http://resolver:8087/data/mixed_headers/part*.tsv', 'TSV', 'x UInt64') "
"GROUP BY _file "
"ORDER BY _file"
)
)
assert result == "part1.tsv\tpart1.tsv\tGET\npart2.tsv\tpart2.tsv\tGET\n"
def test_url_wildcard_headers_are_not_read_from_page_cache():
reset_index_page_server_stats()
warmup_result = node1.query(
with_url_wildcard_setting(
"SELECT sum(x), countIf(_etag = 'mixed-part1.tsv'), countIf(_etag = 'mixed-part2.tsv') "
"FROM url('http://resolver:8087/data/mixed_headers/part*.tsv', 'TSV', 'x UInt64') "
"SETTINGS use_page_cache_for_object_storage=1"
)
)
assert warmup_result == "3\t1\t1\n"
result = node1.query(
with_url_wildcard_setting(
"SELECT _file, any(_headers['X-Source-File']), any(_headers['X-Probe-Method']) "
"FROM url('http://resolver:8087/data/mixed_headers/part*.tsv', 'TSV', 'x UInt64') "
"GROUP BY _file "
"ORDER BY _file "
"SETTINGS use_page_cache_for_object_storage=1"
)
)
assert result == "part1.tsv\tpart1.tsv\tGET\npart2.tsv\tpart2.tsv\tGET\n"
stats = get_index_page_server_stats()
assert stats["GET /data/mixed_headers/part1.tsv"] == 2
assert stats["GET /data/mixed_headers/part2.tsv"] == 2
def test_url_wildcard_page_cache_uses_web_source_identity():
# The two web sources (`?shard=0` / `?shard=1`) expose the same object path (`part.tsv`) with the
# same ETag but different contents. With `use_page_cache_for_object_storage`, the page cache must
# key on the web source identity (the shard's base URL and query), otherwise the second shard would
# be served the first shard's cached page (the path and ETag collide) and the sum would be wrong.
result = node1.query(
with_url_wildcard_setting(
"SELECT sum(x) FROM url('"
"http://resolver:8087/data/page_cache_identity/part*.tsv?shard={0,1}', "
"'TSV', 'x UInt64') "
"SETTINGS use_page_cache_for_object_storage=1"
)
)
assert result.strip() == "303"
def test_url_wildcard_limits_directory_traversal():
error = node1.query_and_get_error(
with_url_wildcard_setting(
"SELECT count() FROM url('http://resolver:8087/data/deep/**/part*.tsv', 'TSV', 'x UInt64') "
"SETTINGS url_wildcard_max_directories_to_read=3"
)
)
assert "Too many directories while expanding URL wildcard" in error
assert "url_wildcard_max_directories_to_read" in error
def test_url_wildcard_ignores_apache_sort_links():
# An Apache `mod_autoindex` page exposes ordering anchors such as `?C=N;O=D` that resolve to the
# current listing directory with only a different query. They must not be expanded as child
# directories: otherwise recursive expansion re-fetches the same page and exhausts the directory
# budget. With a tight budget the real files are still listed and read.
# With the fix only `apache_sort/` and `subdir/` are listed (2 directories). Without it, the three
# ordering anchors are also followed as directories, exceeding this tight budget.
result = node1.query(
with_url_wildcard_setting(
"SELECT sum(x) FROM url('http://resolver:8087/data/apache_sort/**/part*.tsv', 'TSV', 'x UInt64') "
"SETTINGS url_wildcard_max_directories_to_read=3"
)
)
assert result.strip() == "18"
def test_url_wildcard_preserves_index_entry_query():
result = node1.query(
with_url_wildcard_setting("SELECT sum(x) FROM url('http://resolver:8087/data/query_override/part*.tsv?x=1', 'TSV', 'x UInt64')")
)
assert result.strip() == "6"
def test_url_wildcard_file_filter_uses_visible_file_name():
result = node1.query(
with_url_wildcard_setting(
"SELECT _file, sum(x) FROM url('http://resolver:8087/data/query_override/part*.tsv?x=1', 'TSV', 'x UInt64') "
"WHERE _file = 'part1.tsv' GROUP BY _file"
)
)
assert result == "part1.tsv\t6\n"
def test_url_wildcard_path_filter_uses_visible_path():
visible_path = node1.query(
with_url_wildcard_setting(
"SELECT DISTINCT _path FROM url('http://resolver:8087/data/query_override/part*.tsv?x=1', 'TSV', 'x UInt64')"
)
).strip()
assert visible_path != ""
result = node1.query(
with_url_wildcard_setting(
"SELECT _path, sum(x) FROM url('http://resolver:8087/data/query_override/part*.tsv?x=1', 'TSV', 'x UInt64') "
f"WHERE _path = '{visible_path}' GROUP BY _path"
)
)
assert result == f"{visible_path}\t6\n"
def test_url_wildcard_preserves_query_for_directory_listing():
result = node1.query(
with_url_wildcard_setting("SELECT sum(x) FROM url('http://resolver:8087/data/query_directory/**/part*.tsv', 'TSV', 'x UInt64')")
)
assert result.strip() == "11"
def test_url_wildcard_preserves_source_query_for_directory_listing():
result = node1.query(
with_url_wildcard_setting("SELECT sum(x) FROM url('http://resolver:8087/data/source_query/**/part*.tsv?token=abc', 'TSV', 'x UInt64')")
)
assert result.strip() == "13"
def test_url_wildcard_deduplicates_after_source_query_inheritance():
result = node1.query(
with_url_wildcard_setting(
"SELECT sum(x) FROM url('http://resolver:8087/data/source_query_duplicate/**/part*.tsv?token=abc', 'TSV', 'x UInt64')"
)
)
assert result.strip() == "17"
def test_url_wildcard_deduplicates_after_successful_failover_query_inheritance():
result = node1.query(
with_url_wildcard_setting(
"SELECT sum(x) FROM url('http://resolver:8087/data/source_query_duplicate/**/part*.tsv?token={fail|abc}', "
"'TSV', 'x UInt64')"
)
)
assert result.strip() == "17"
def test_url_wildcard_preserves_failover_options():
result = node1.query(
with_url_wildcard_setting(
"SELECT sum(x) FROM url('http://resolver:8087/data/source_query/**/part*.tsv?token={bad|abc}', 'TSV', 'x UInt64')"
)
)
assert result.strip() == "13"
def test_url_wildcard_failover_resets_credentials():
result = node1.query(
with_url_wildcard_setting(
"SELECT sum(x) FROM url('http://{user:pass@resolver:8087|resolver:8087}/data/auth_failover/part*.tsv', "
"'TSV', 'x UInt64')"
)
)
assert result.strip() == "23"
def test_url_wildcard_archive_metadata_uses_shard_identity():
result = node1.query(
with_url_wildcard_setting(
"SELECT sum(x) FROM url('"
"http://resolver:8087/data/archive_identity/archive*.zip?shard={0,1} :: value.tsv', "
"'TSV', 'x UInt64')"
)
)
assert result.strip() == "303"
def test_url_wildcard_rejects_unknown_size_archive():
error = node1.query_and_get_error(
with_url_wildcard_setting(
"SELECT sum(x) FROM url('"
"http://resolver:8087/data/unknown_size_archive/archive*.zip :: value.tsv', "
"'TSV', 'x UInt64')"
)
)
assert "Cannot read archive" in error
assert "size is unknown" in error
def test_url_wildcard_ignores_failed_failover_option_after_success():
result = node1.query(
with_url_wildcard_setting(
"SELECT sum(x) FROM url('http://resolver:8087/data/source_query/**/part*.tsv?token={fail|abc}', 'TSV', 'x UInt64')"
)
)
assert result.strip() == "13"
def test_url_wildcard_does_not_ignore_missing_shard():
error = node1.query_and_get_error(
with_url_wildcard_setting(
"SELECT sum(x) FROM url('"
"http://resolver:8087/data/source_query/**/part*.tsv?token={bad,abc}', "
"'TSV', 'x UInt64')"
)
)
assert "There is no path" in error
def test_url_engine_wildcard_preserves_failover_options():
table_name = "url_wildcard_failover"
node1.query(f"DROP TABLE IF EXISTS {table_name}")
try:
node1.query(
f"CREATE TABLE {table_name} (x UInt64) "
"ENGINE = URL('http://resolver:8087/data/source_query/**/part*.tsv?token={bad|abc}', 'TSV')",
settings={"allow_experimental_url_wildcard_from_index_pages": 1},
)
result = node1.query(f"SELECT sum(x) FROM {table_name}")
assert result.strip() == "13"
finally:
node1.query(f"DROP TABLE IF EXISTS {table_name}")
def test_url_engine_wildcard_limit_uses_query_setting():
table_name = "url_wildcard_directory_limit"
node1.query(f"DROP TABLE IF EXISTS {table_name}")
try:
node1.query(
f"CREATE TABLE {table_name} (x UInt64) "
"ENGINE = URL('http://resolver:8087/data/deep/**/part*.tsv', 'TSV')",
settings={"allow_experimental_url_wildcard_from_index_pages": 1},
)
error = node1.query_and_get_error(
f"SELECT count() FROM {table_name} "
"SETTINGS allow_experimental_url_wildcard_from_index_pages=1, url_wildcard_max_directories_to_read=3"
)
assert "Too many directories while expanding URL wildcard" in error
assert "url_wildcard_max_directories_to_read" in error
finally:
node1.query(f"DROP TABLE IF EXISTS {table_name}")
def test_url_wildcard_rejects_cross_origin_index_redirect():
reset_index_page_server_stats()
reset_index_page_server_stats(8088)
error = node1.query_and_get_error(
with_url_wildcard_setting(
"SELECT count() FROM url("
"'http://user:pass@resolver:8087/data/cross_origin_redirect/**/part*.tsv', "
"'TSV', "
"'x UInt64', "
"headers('X-Test-Header'='1')) "
"SETTINGS max_http_get_redirects=1"
)
)
assert "redirected to a different origin" in error
redirect_target_stats = get_index_page_server_stats(8088)
assert "GET /data/cross_origin_target/" not in redirect_target_stats
def test_url_wildcard_reads_index_file_redirect():
result = node1.query(
with_url_wildcard_setting(
"SELECT sum(x) FROM url('http://resolver:8087/data/index_redirect/part*.tsv', 'TSV', 'x UInt64') "
"SETTINGS max_http_get_redirects=1"
)
)
assert result.strip() == "41"
def test_url_wildcard_preserves_redirected_recursive_match_path():
result = node1.query(
with_url_wildcard_setting(
"SELECT sum(x) FROM url('http://resolver:8087/data/recursive_redirect/**/part*.tsv', 'TSV', 'x UInt64') "
"SETTINGS max_http_get_redirects=1"
)
)
assert result.strip() == "43"
def test_url_engine_wildcard_redirect_uses_query_setting():
table_name = "url_wildcard_redirect"
node1.query(f"DROP TABLE IF EXISTS {table_name}")
try:
node1.query(
f"CREATE TABLE {table_name} (x UInt64) "
"ENGINE = URL('http://resolver:8087/data/redirect/part*.tsv', 'TSV')",
settings={"allow_experimental_url_wildcard_from_index_pages": 1},
)
error = node1.query_and_get_error(
f"SELECT sum(x) FROM {table_name} "
"SETTINGS allow_experimental_url_wildcard_from_index_pages=1, max_http_get_redirects=0"
)
assert "Too many redirects while trying to access" in error
result = node1.query(
f"SELECT sum(x) FROM {table_name} "
"SETTINGS allow_experimental_url_wildcard_from_index_pages=1, max_http_get_redirects=1"
)
assert result.strip() == "19"
finally:
node1.query(f"DROP TABLE IF EXISTS {table_name}")
def test_url_wildcard_is_experimental():
error = node1.query_and_get_error(
"SELECT sum(x) FROM url('http://resolver:8087/data/**/part*.tsv', 'TSV', 'x UInt64')"
)
assert "allow_experimental_url_wildcard_from_index_pages" in error
def test_table_function_url_access_rights():
node1.query("CREATE USER OR REPLACE u1")
expected_error = "necessary to have the grant READ ON URL"
assert expected_error in node1.query_and_get_error(
"SELECT * FROM url('http://nginx:80/test_1', 'TSV', 'column1 UInt32, column2 UInt32, column3 UInt32')",
user="u1",
)
expected_error = "necessary to have the grant READ ON URL"
assert expected_error in node1.query_and_get_error(
"SELECT * FROM url('http://nginx:80/test_1', 'TSV')", user="u1"
)
expected_error = "necessary to have the grant READ ON URL"
assert expected_error in node1.query_and_get_error(
"DESCRIBE TABLE url('http://nginx:80/test_1', 'TSV', 'column1 UInt32, column2 UInt32, column3 UInt32')",
user="u1",
)
expected_error = "necessary to have the grant READ ON URL"
assert expected_error in node1.query_and_get_error(
"DESCRIBE TABLE url('http://nginx:80/test_1', 'TSV')", user="u1"
)
node1.query("GRANT READ ON URL TO u1")
assert node1.query(
"DESCRIBE TABLE url('http://nginx:80/test_1', 'TSV')",
user="u1",
) == TSV(
[
["c1", "Nullable(Int64)"],
["c2", "Nullable(Int64)"],
["c3", "Nullable(Int64)"],
]
)
@pytest.mark.parametrize("file_format", ["Parquet", "CSV", "TSV", "JSONEachRow"])
def test_file_formats(file_format):
# Generate random URL with timestamp to make test idempotent
# Note: we could have just deleted a file using requests.delete(url)
# But it seems we can do it only from inside the container (this is not reliable)
timestamp = int(time.time() * 1000000)
url = f"http://nginx:80/{file_format}_file_{timestamp}"
values = ", ".join([f"({i}, {i + 1}, {i + 2})" for i in range(100)])
node1.query(
f"insert into table function url(url_file, url = '{url}', format = '{file_format}') values",
stdin=values,
)
for download_threads in [1, 4, 16]:
result = node1.query(
f"""
SELECT *
FROM url('{url}', '{file_format}')
LIMIT 10
SETTINGS remote_read_min_bytes_for_seek = 1, max_read_buffer_size = 1, max_download_buffer_size = 1, max_download_threads = {download_threads}
"""
)
expected_result = ""
for i in range(10):
expected_result += f"{i}\t{i + 1}\t{i + 2}\n"
assert result == expected_result