Files
Alexey Milovidov bc34412258 Address the AI review: cover the aligned reserve-ahead path of use_real_disk_size
`test_cache_size` only asserted the completed-segment projection of the contract: by the
time it reads `FilesystemCacheSize`, every touched file segment is fully downloaded, so
`reserved_size == downloaded_size` and a bug in the block-aligned accounting of the
reserve-ahead surplus would leave the test green.

Add `test_cache_size_partial_segment_reserve_ahead`, which uses a cache disk with an
explicit 4Mi `reserve_granularity` and reads a single granule out of a table whose column
files are several times larger. That leaves the file segment in the
`reserved_size > downloaded_size` state, so completing it has to return
`alignFileSize(reserved_size) - alignFileSize(downloaded_size)` rather than the raw
difference; the test pins both `FilesystemCacheSize` and `current_size` in
`system.filesystem_cache_settings` to the block-aligned downloaded footprint, and asserts
that the read really was partial.

Also disable background download in `test_cache_size` so nothing can fill the cache
between reading the metric and computing the expectation, and state in a comment which
part of the contract that test covers.
2026-09-02 08:03:13 +00:00

327 lines
11 KiB
Python

import pytest
from helpers.cluster import ClickHouseCluster
cluster = ClickHouseCluster(__file__)
node = cluster.add_instance(
"node",
main_configs=["configs/s3_cache.xml"],
stay_alive=True,
with_minio=True,
)
@pytest.fixture(scope="module")
def started_cluster():
try:
cluster.start()
yield cluster
finally:
cluster.shutdown()
def test_cache_size(started_cluster):
# Covers the completed-segment projection of the `use_real_disk_size` contract: after the
# query below every touched file segment is fully downloaded, so the aligned reservation
# footprint coincides with the block-aligned downloaded size, both live and after a restart.
# The partial (`reserved_size > downloaded_size`) case is covered by
# `test_cache_size_partial_segment_reserve_ahead`. Background download is disabled so that
# nothing fills the cache between reading the metric and computing the expectation.
table_name = "test_aligned_cache_size_s3"
def expected_cache_size():
block_size = int(
node.exec_in_container(
["bash", "-c", "stat -f -c %S /tmp/s3_aligned_cache"],
privileged=True,
user="root",
).strip()
)
return int(
node.query(
f"""
SELECT sum(intDiv(downloaded_size + {block_size} - 1, {block_size}) * {block_size})
FROM system.filesystem_cache
WHERE cache_name = 'aligned_cache'
"""
).strip()
)
# drop full cache to count cache size later correctly
node.query(
"""SYSTEM DROP FILESYSTEM CACHE;""",
)
cache_size = node.query(
"""
SELECT value FROM system.metrics WHERE name = 'FilesystemCacheSize';
"""
)
assert int(cache_size) == 0
node.query(
f"""
DROP TABLE IF EXISTS {table_name};
""",
)
node.query(
f"""
CREATE TABLE {table_name}
(
`key` String,
`value` String
)
ENGINE = MergeTree
PRIMARY KEY key
SETTINGS storage_policy='external';
""",
)
node.query(
f"""
INSERT INTO {table_name} VALUES ('key1', 'value1');
""",
)
node.query(
f"""
SELECT * FROM {table_name};
""",
settings={"filesystem_cache_allow_background_download": 0},
)
cache_size = node.query(
"""
SELECT value FROM system.metrics WHERE name = 'FilesystemCacheSize';
"""
)
assert int(cache_size) == expected_cache_size()
node.restart_clickhouse()
cache_size = node.query(
"""
SELECT value FROM system.metrics WHERE name = 'FilesystemCacheSize';
"""
)
assert int(cache_size) == expected_cache_size()
def test_aligned_cache_sub_block_write(started_cluster):
# An encrypted disk on top of the aligned cache writes its small header file during
# `checkAccess` at startup and then writes more bytes into the same filesystem block.
# With `use_real_disk_size` the disk-accounted delta of such a sub-block write is zero,
# which used to abort the cache reservation with `Logical error: 'size'`. Just having the
# server start up with this disk already exercises the regression; the insert/read below
# additionally drives sub-block writes through the cache.
table_name = "test_aligned_cache_encrypted"
node.query(f"DROP TABLE IF EXISTS {table_name} SYNC;")
node.query(
f"""
CREATE TABLE {table_name}
(
`key` String,
`value` String
)
ENGINE = MergeTree
PRIMARY KEY key
SETTINGS storage_policy='external_encrypted';
""",
)
node.query(f"INSERT INTO {table_name} VALUES ('key1', 'value1');")
assert node.query(f"SELECT value FROM {table_name} WHERE key = 'key1';").strip() == "value1"
# The server must still be alive (no logical error during the cache reservation).
assert node.query("SELECT 1").strip() == "1"
def test_aligned_eviction_telemetry(started_cluster):
# Regression for eviction telemetry under `use_real_disk_size`.
#
# `FilesystemCacheSize` is accounted in filesystem-block-aligned units, so the eviction
# counters (`FilesystemCacheEvictedBytes` and the Prometheus totals) must report the same
# unit. The aligned size has to be captured *before* the evicted segment is detached: a
# detached segment loses its key metadata and `FileSegment::getDiskAccountedSize` then falls
# back to the raw reserved size, which silently undercounts sub-block evictions.
#
# `aligned_cache_evict` has file segments smaller than one filesystem block and a `max_size`
# far below the table footprint, so every evicted segment occupies exactly one block on disk.
# The evicted-byte counter must therefore equal `evicted_segments * block_size`; the buggy raw
# accounting reports strictly less.
table_name = "test_aligned_eviction_telemetry"
cache_path = "/tmp/s3_aligned_cache_evict"
node.query("SYSTEM DROP FILESYSTEM CACHE;")
node.query(f"DROP TABLE IF EXISTS {table_name} SYNC;")
node.query(
f"""
CREATE TABLE {table_name}
(
`key` UInt64,
`value` String
)
ENGINE = MergeTree
ORDER BY key
SETTINGS storage_policy='external_evict';
""",
)
# Random strings do not compress, so the on-disk (and cached) footprint stays well above the
# 32Ki cache size, which guarantees eviction regardless of the actual filesystem block size.
node.query(
f"INSERT INTO {table_name} SELECT number, randomString(64) FROM numbers(8192);"
)
# Read the whole table (all columns) to fill the cache and force eviction. Background download
# is disabled so all downloads and evictions happen synchronously and are attributed to this
# query in `system.query_log`.
query_id = "aligned_eviction_telemetry_query"
node.query(
f"SELECT * FROM {table_name} FORMAT Null",
query_id=query_id,
settings={"filesystem_cache_allow_background_download": 0},
)
node.query("SYSTEM FLUSH LOGS;")
evicted_bytes, evicted_segments = (
node.query(
f"""
SELECT
ProfileEvents['FilesystemCacheEvictedBytes'],
ProfileEvents['FilesystemCacheEvictedFileSegments']
FROM system.query_log
WHERE query_id = '{query_id}' AND type = 'QueryFinish'
ORDER BY event_time_microseconds DESC
LIMIT 1
"""
)
.strip()
.split("\t")
)
evicted_bytes = int(evicted_bytes)
evicted_segments = int(evicted_segments)
block_size = int(
node.exec_in_container(
["bash", "-c", f"stat -f -c %S {cache_path}"],
privileged=True,
user="root",
).strip()
)
# `max_file_segment_size` (512) is not larger than any real filesystem block, so every evicted
# segment is charged for exactly one block. Aligned accounting reports `segments * block_size`;
# the buggy raw accounting reports the (smaller) sum of written bytes.
assert block_size >= 512
assert evicted_segments > 0
assert evicted_bytes == evicted_segments * block_size
def test_cache_size_partial_segment_reserve_ahead(started_cluster):
# Regression for the aligned *reservation* accounting, which `test_cache_size` cannot see.
#
# `use_real_disk_size` charges a file segment by the block-aligned `reserved_size`, not by the
# downloaded size. With reserve-ahead (`reserve_granularity`) a read reserves a whole granule
# past the download offset, so a partial read of a large file leaves the file segment in the
# `reserved_size > downloaded_size` state. When the segment is completed, the surplus has to be
# returned in the same unit it was charged in, that is
# `alignFileSize(reserved_size) - alignFileSize(downloaded_size)`. Returning the raw
# `reserved_size - downloaded_size` instead leaves `FilesystemCacheSize` (and `current_size` in
# `system.filesystem_cache_settings`) permanently out of step with what the cache occupies on
# disk.
#
# `aligned_cache_reserve_ahead` sets `reserve_granularity` to 4Mi and the table below is much
# larger than that, while the query reads a single granule, so the reserve-ahead surplus is
# guaranteed to be non-trivial.
table_name = "test_aligned_cache_reserve_ahead"
cache_name = "aligned_cache_reserve_ahead"
cache_path = "/tmp/s3_aligned_cache_reserve_ahead"
reserve_granularity = 4 * 1024 * 1024
node.query("SYSTEM DROP FILESYSTEM CACHE;")
node.query(f"DROP TABLE IF EXISTS {table_name} SYNC;")
node.query(
f"""
CREATE TABLE {table_name}
(
`key` UInt64,
`value` String
)
ENGINE = MergeTree
ORDER BY key
SETTINGS storage_policy='external_reserve_ahead';
""",
)
# Random strings do not compress, so the column file is far larger than one reserve granule.
node.query(
f"INSERT INTO {table_name} SELECT number, randomString(64) FROM numbers(400000);"
)
bytes_on_disk = int(
node.query(
f"""
SELECT sum(bytes_on_disk) FROM system.parts
WHERE table = '{table_name}' AND active
"""
).strip()
)
assert bytes_on_disk > 4 * reserve_granularity
# A single-granule read: it downloads a small prefix of a multi-megabyte file segment while
# reserve-ahead reserves a whole granule. Background download is disabled, otherwise the rest
# of the segment would be filled in and the segment would no longer be partial.
node.query(
f"SELECT value FROM {table_name} WHERE key = 0 FORMAT Null",
settings={"filesystem_cache_allow_background_download": 0},
)
block_size = int(
node.exec_in_container(
["bash", "-c", f"stat -f -c %S {cache_path}"],
privileged=True,
user="root",
).strip()
)
downloaded_sizes = [
int(line)
for line in node.query(
f"""
SELECT downloaded_size FROM system.filesystem_cache
WHERE cache_name = '{cache_name}'
"""
).split()
]
assert downloaded_sizes
expected_cache_size = sum(
(size + block_size - 1) // block_size * block_size for size in downloaded_sizes
)
# The read really was partial: it downloaded far less than the reserved granule, so the cache
# must hold much less than the table's on-disk footprint.
assert max(downloaded_sizes) < reserve_granularity
assert 0 < expected_cache_size < bytes_on_disk // 2
cache_size = int(
node.query(
"SELECT value FROM system.metrics WHERE name = 'FilesystemCacheSize'"
).strip()
)
assert cache_size == expected_cache_size
current_size = int(
node.query(
f"""
SELECT current_size FROM system.filesystem_cache_settings
WHERE cache_name = '{cache_name}'
"""
).strip()
)
assert current_size == expected_cache_size