mirror of
https://github.com/hanzoai/datastore.git
synced 2026-09-26 13:55:07 +00:00
180 lines
7.2 KiB
Python
180 lines
7.2 KiB
Python
"""
|
|
Regression test for graceful handling of truncated S3 objects during cached reads.
|
|
|
|
When an S3 object is overwritten with shorter content between listing and
|
|
reading, the cache layer must NOT throw LOGICAL_ERROR and must NOT crash the
|
|
server. In predownloadForFileSegment, when the bytes the reader needs lie beyond
|
|
the truncated object there is no valid data to return, so the read fails with a
|
|
regular CANNOT_READ_ALL_DATA error -- not a LOGICAL_ERROR, and not a fabricated
|
|
EOF (a short/zero read there makes the caller consume uninitialized memory,
|
|
which aborts under MemorySanitizer and returns silent garbage in release).
|
|
|
|
Two conditions are required to actually exercise the predownload path, both
|
|
discovered empirically (a naive test passes on the buggy code too):
|
|
|
|
1. The object must be truncated to a VALID PREFIX of its real content, not
|
|
overwritten with zeros. Zero-filled content fails decompression on the first
|
|
block (UNKNOWN_CODEC) before the predownload path is reached.
|
|
|
|
2. The query must SEEK to a granule located beyond the truncation offset (a
|
|
point query on the primary key), so the cache must predownload past the real
|
|
EOF. A full-scan instead fails earlier in the compression layer
|
|
(CANNOT_READ_ALL_DATA) and never reaches the predownload path.
|
|
|
|
All three cache read paths -- predownloadForFileSegment, readFromFileSegment and
|
|
readBigAt -- fail a truncated read the same way: a regular CANNOT_READ_ALL_DATA,
|
|
never a LOGICAL_ERROR and never a fabricated EOF.
|
|
|
|
NOTE on readBigAt: readBigAt is the random-access path used by the Parquet
|
|
reader over object storage, and is not reached by a MergeTree SELECT. It cannot
|
|
be exercised deterministically from a single-node query, because reading a
|
|
truncated object via the s3() table function re-fetches the object size (a fresh
|
|
HEAD returns the new, smaller size) and so never over-reads — it surfaces as a
|
|
plain INCORRECT_DATA "Not a Parquet file" error instead.
|
|
"""
|
|
|
|
import io
|
|
import logging
|
|
|
|
import pytest
|
|
|
|
from helpers.cluster import ClickHouseCluster
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
cluster = ClickHouseCluster(__file__)
|
|
|
|
node = cluster.add_instance(
|
|
"node",
|
|
main_configs=["config.d/storage.xml"],
|
|
with_minio=True,
|
|
stay_alive=True,
|
|
)
|
|
|
|
BUCKET = "root"
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def started_cluster():
|
|
try:
|
|
cluster.start()
|
|
yield cluster
|
|
finally:
|
|
cluster.shutdown()
|
|
|
|
|
|
def _get_data_objects(minio_client, prefix="data/"):
|
|
"""Return list of (object_name, size) tuples for all non-empty S3 data objects."""
|
|
objects = list(minio_client.list_objects(BUCKET, prefix=prefix, recursive=True))
|
|
return [(obj.object_name, obj.size) for obj in objects if obj.size > 0]
|
|
|
|
|
|
def _read_s3_object(minio_client, object_name):
|
|
response = minio_client.get_object(BUCKET, object_name)
|
|
try:
|
|
return response.read()
|
|
finally:
|
|
response.close()
|
|
response.release_conn()
|
|
|
|
|
|
def _truncate_to_valid_prefix(minio_client, object_name, keep_fraction=0.5):
|
|
"""
|
|
Replace an S3 object with a VALID PREFIX of its real content: the same
|
|
leading bytes, just shorter. This simulates the object being overwritten
|
|
with shorter content while the leading data is still readable — the
|
|
condition that makes the cache predownload run past the real EOF.
|
|
|
|
Replacing with zeros instead would fail decompression on the very first
|
|
block (UNKNOWN_CODEC) before the predownload path is ever reached.
|
|
"""
|
|
data = _read_s3_object(minio_client, object_name)
|
|
truncated = data[: max(1, int(len(data) * keep_fraction))]
|
|
minio_client.put_object(BUCKET, object_name, io.BytesIO(truncated), len(truncated))
|
|
logger.info(
|
|
"Truncated %s to a valid prefix: %d -> %d bytes",
|
|
object_name,
|
|
len(data),
|
|
len(truncated),
|
|
)
|
|
return len(data), len(truncated)
|
|
|
|
|
|
# Substrings of the three LOGICAL_ERROR messages that the fix prevents. The
|
|
# readBigAt message is matched on its specific "Offset:" phrasing so it is not
|
|
# confused with the benign compression-layer "Cannot read all data. Bytes read:"
|
|
# (CANNOT_READ_ALL_DATA), which is an acceptable error for a truncated object.
|
|
FORBIDDEN_LOGICAL_ERRORS = [
|
|
"Failed to predownload remaining", # predownloadForFileSegment
|
|
"Cannot read all data. Offset:", # readBigAt
|
|
"Having zero bytes, but range is not finished", # readFromFileSegment
|
|
]
|
|
|
|
|
|
def _assert_no_forbidden_logical_error(error_msg):
|
|
for needle in FORBIDDEN_LOGICAL_ERRORS:
|
|
assert needle not in error_msg, (
|
|
f"Bug: pre-fix LOGICAL_ERROR resurfaced ({needle!r}): {error_msg}"
|
|
)
|
|
|
|
|
|
def test_truncated_object_predownload_no_logical_error(started_cluster):
|
|
"""
|
|
Truncate the column data object to a valid prefix, then seek (via a point
|
|
query on the primary key) to a granule located beyond the truncation. On the
|
|
unpatched code this throws `Failed to predownload remaining ... bytes`
|
|
(LOGICAL_ERROR); the fix fails with a regular CANNOT_READ_ALL_DATA and the
|
|
server stays up. Any non-LOGICAL_ERROR (CANNOT_READ_ALL_DATA, broken part,
|
|
etc.) is acceptable; what matters is no forbidden LOGICAL_ERROR and that the
|
|
server survives (no use-of-uninitialized-value crash).
|
|
"""
|
|
minio = started_cluster.minio_client
|
|
node.query("DROP TABLE IF EXISTS t_trunc SYNC")
|
|
node.query(
|
|
"""
|
|
CREATE TABLE t_trunc (x UInt64, s String)
|
|
ENGINE = MergeTree ORDER BY x
|
|
SETTINGS storage_policy = 's3_cache',
|
|
min_bytes_for_wide_part = 0,
|
|
index_granularity = 8192
|
|
"""
|
|
)
|
|
try:
|
|
# A single INSERT well under max_insert_block_size produces one part, so
|
|
# the largest object is unambiguously that part's `s` column data.
|
|
node.query(
|
|
"INSERT INTO t_trunc SELECT number, repeat(toString(number), 100) "
|
|
"FROM numbers(100000)"
|
|
)
|
|
active_parts = node.query(
|
|
"SELECT count() FROM system.parts WHERE table = 't_trunc' AND active"
|
|
).strip()
|
|
assert active_parts == "1", f"expected a single part, got {active_parts}"
|
|
|
|
objects = _get_data_objects(minio)
|
|
objects.sort(key=lambda x: x[1], reverse=True)
|
|
target_name, target_size = objects[0]
|
|
logger.info("Truncating largest object %s (%d bytes)", target_name, target_size)
|
|
# Must be large enough that a late granule sits beyond the 50% cut.
|
|
assert target_size > 100000, f"target object too small: {target_size}"
|
|
|
|
node.query("SYSTEM DROP FILESYSTEM CACHE")
|
|
_truncate_to_valid_prefix(minio, target_name, keep_fraction=0.5)
|
|
|
|
# Point query on the PK seeks to a granule near the end of the column,
|
|
# forcing the cache to predownload past the real (truncated) EOF.
|
|
try:
|
|
node.query(
|
|
"SELECT s FROM t_trunc WHERE x = 99000 "
|
|
"SETTINGS enable_filesystem_cache = 1, max_threads = 1"
|
|
)
|
|
logger.info("Query returned without error")
|
|
except Exception as e:
|
|
_assert_no_forbidden_logical_error(str(e))
|
|
logger.info("Query raised acceptable non-LOGICAL_ERROR: %s", str(e)[:200])
|
|
|
|
# Server must stay alive.
|
|
assert node.query("SELECT 1").strip() == "1"
|
|
finally:
|
|
node.query("DROP TABLE IF EXISTS t_trunc SYNC")
|