From 5a1ed93678031275ae7330298a52c5a9a6dc282f Mon Sep 17 00:00:00 2001 From: Konstantin Morozov Date: Tue, 22 Sep 2026 13:47:41 +0200 Subject: [PATCH 01/30] add test to reproduce Signed-off-by: Konstantin Morozov --- .../__init__.py | 0 .../configs/storage_conf.xml | 30 ++++ .../test_cas_backup_s3_native_copy/test.py | 147 ++++++++++++++++++ 3 files changed, 177 insertions(+) create mode 100644 tests/integration/test_cas_backup_s3_native_copy/__init__.py create mode 100644 tests/integration/test_cas_backup_s3_native_copy/configs/storage_conf.xml create mode 100644 tests/integration/test_cas_backup_s3_native_copy/test.py diff --git a/tests/integration/test_cas_backup_s3_native_copy/__init__.py b/tests/integration/test_cas_backup_s3_native_copy/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/integration/test_cas_backup_s3_native_copy/configs/storage_conf.xml b/tests/integration/test_cas_backup_s3_native_copy/configs/storage_conf.xml new file mode 100644 index 000000000000..f50227cedd3c --- /dev/null +++ b/tests/integration/test_cas_backup_s3_native_copy/configs/storage_conf.xml @@ -0,0 +1,30 @@ + + + + + object_storage + s3 + cas + 30 + 10000 + itest-cas-backup-s3-native-copy + + http://rustfs1:11121/test/cas_backup_data/ + clickhouse + clickhouse + + + + + +
+ disk_cas_backup_s3 +
+
+
+
+
+
diff --git a/tests/integration/test_cas_backup_s3_native_copy/test.py b/tests/integration/test_cas_backup_s3_native_copy/test.py new file mode 100644 index 000000000000..4b0541f33666 --- /dev/null +++ b/tests/integration/test_cas_backup_s3_native_copy/test.py @@ -0,0 +1,147 @@ +"""BACKUP/RESTORE of a CAS table to an S3 destination on the same object store. + +When the backup destination and the CAS pool share an S3 authority, +`DataSourceDescription::sameKind` matches and `BackupWriterS3::copyFileFromDisk` copies +the source object server-side instead of reading it through the CAS read path. + +A CAS blob object is `[blob_header_len bytes of envelope][payload]`, and the payload is +what the MergeTree file actually is. `BlobLocation` carries that `offset`, but +`ContentAddressedMetadataStorage::getStorageObjects` returns only `key` and `length` -- +the offset is dropped. Small per-part files are inline manifest entries and get a +placeholder with an empty remote key. Neither shape survives a server-side copy that +addresses the object from byte 0. + +`test_native_copy_round_trip` is the oracle: the restored table must equal the source. +`test_native_copy_branch_is_reached` guards it against going vacuous -- if the native +branch is never entered, the oracle proves nothing about this path. +""" + +import uuid + +import pytest + +from helpers.cluster import ClickHouseCluster + +cluster = ClickHouseCluster(__file__) + +STORAGE_POLICY = "cas_backup_s3" + +S3_AUTHORITY = "http://rustfs1:11121" +S3_CREDENTIALS = "'clickhouse', 'clickhouse'" + +NUM_ROWS = 200000 + +RUN_TOKEN = uuid.uuid4().hex + +FORCE_SINGLE_OPERATION_COPY = {"s3_max_single_operation_copy_size": 5 * 1024**3} + + +@pytest.fixture(scope="module", autouse=True) +def start_cluster(): + cluster.add_instance( + "node", + main_configs=["configs/storage_conf.xml"], + with_rustfs=True, + stay_alive=True, + ) + try: + cluster.start() + yield + finally: + cluster.shutdown() + + +def backup_destination(name): + return f"S3('{S3_AUTHORITY}/test/backups/{RUN_TOKEN}/{name}', {S3_CREDENTIALS})" + + +def create_and_fill(node, table): + node.query(f"DROP TABLE IF EXISTS {table} SYNC") + node.query( + f""" + CREATE TABLE {table} (k UInt64, v String) + ENGINE = MergeTree ORDER BY k + SETTINGS storage_policy = '{STORAGE_POLICY}', min_bytes_for_wide_part = 0 + """ + ) + node.query( + f""" + INSERT INTO {table} + SELECT number, randomPrintableASCII(64) FROM numbers({NUM_ROWS}) + """ + ) + + +def fingerprint(node, table): + return node.query( + f"SELECT count(), sum(k), sum(cityHash64(v)) FROM {table}" + ).strip() + + +@pytest.mark.parametrize("allow_native_copy", [True, False]) +def test_native_copy_round_trip(allow_native_copy): + node = cluster.instances["node"] + suffix = "native" if allow_native_copy else "buffered" + table = f"cas_backup_{suffix}" + restored = f"{table}_restored" + destination = backup_destination(suffix) + + create_and_fill(node, table) + expected = fingerprint(node, table) + + node.query( + f"BACKUP TABLE {table} TO {destination} " + f"SETTINGS allow_s3_native_copy = {int(allow_native_copy)}", + settings=FORCE_SINGLE_OPERATION_COPY, + ) + + node.query(f"DROP TABLE IF EXISTS {restored} SYNC") + node.query( + f"RESTORE TABLE {table} AS {restored} FROM {destination} " + f"SETTINGS allow_s3_native_copy = {int(allow_native_copy)}", + settings=FORCE_SINGLE_OPERATION_COPY, + ) + + assert fingerprint(node, restored) == expected + assert ( + node.query( + f"CHECK TABLE {restored} SETTINGS check_query_single_value_result = 1" + ).strip() + == "1" + ) + + node.query(f"DROP TABLE {table} SYNC") + node.query(f"DROP TABLE {restored} SYNC") + + +def test_native_copy_branch_is_reached(): + node = cluster.instances["node"] + table = "cas_backup_probe" + destination = backup_destination("probe") + + create_and_fill(node, table) + + query_id = f"cas_backup_native_copy_{RUN_TOKEN}" + node.query( + f"BACKUP TABLE {table} TO {destination} SETTINGS allow_s3_native_copy = 1", + query_id=query_id, + settings=FORCE_SINGLE_OPERATION_COPY, + ) + node.query("SYSTEM FLUSH LOGS query_log") + + copy_object_events = node.query( + f""" + SELECT ProfileEvents['S3CopyObject'] FROM system.query_log + WHERE type = 'QueryFinish' AND query_id = '{query_id}' + ORDER BY event_time DESC LIMIT 1 + """ + ).strip() + + assert copy_object_events != "", "no query_log row for the backup query" + assert int(copy_object_events) > 0, ( + "BackupWriterS3 never took its native-copy branch, so " + "test_native_copy_round_trip does not cover it; check whether " + "DataSourceDescription::sameKind still matches for a CAS disk" + ) + + node.query(f"DROP TABLE {table} SYNC") From 2fcdc9e72fff25251f0d5b5db961d74fac9e91aa Mon Sep 17 00:00:00 2001 From: Konstantin Morozov Date: Thu, 24 Sep 2026 12:46:27 +0200 Subject: [PATCH 02/30] fix empty blob_key, fix blob's payload offset Signed-off-by: Konstantin Morozov --- src/Backups/BackupIO_S3.cpp | 13 ++ src/Disks/DiskBackup.cpp | 5 + src/Disks/DiskBackup.h | 1 + src/Disks/DiskEncrypted.h | 6 + src/Disks/DiskLocal.cpp | 5 + src/Disks/DiskLocal.h | 1 + .../DiskObjectStorage/DiskObjectStorage.cpp | 35 ++++- .../DiskObjectStorage/DiskObjectStorage.h | 2 + .../ContentAddressedMetadataStorage.cpp | 6 +- .../ObjectStorages/S3/S3ObjectStorage.cpp | 2 + src/Disks/IDisk.h | 3 + src/Disks/ReadOnlyDiskWrapper.h | 1 + src/IO/S3/copyS3File.cpp | 26 +++- src/IO/S3/copyS3File.h | 1 + .../ObjectStorageQueuePostProcessor.cpp | 1 + .../test_cas_backup_s3_native_copy/test.py | 142 +++++++++++++----- 16 files changed, 201 insertions(+), 49 deletions(-) diff --git a/src/Backups/BackupIO_S3.cpp b/src/Backups/BackupIO_S3.cpp index df43076d8957..2a92e5e862a4 100644 --- a/src/Backups/BackupIO_S3.cpp +++ b/src/Backups/BackupIO_S3.cpp @@ -317,6 +317,7 @@ void BackupReaderS3::copyFileToDisk(const String & path_in_backup, size_t file_s fs::path(s3_uri.key) / path_in_backup, 0, file_size, + /* src_object_offset= */ 0, /* dest_s3_client= */ destination_disk->getS3StorageClient(), /* dest_bucket= */ blob_path[1], /* dest_key= */ blob_path[0], @@ -392,12 +393,23 @@ void BackupWriterS3::copyFileFromDisk( if (auto blob_path = src_disk->getBlobPath(src_path); blob_path.size() == 2) { LOG_TRACE(log, "Copying file {} from disk {} to S3", src_path, src_disk->getName()); + + if (blob_path[0].empty()) + { + LOG_TRACE(log, "File {} has no object of its own, copying through buffers", src_path); + BackupWriterDefault::copyFileFromDisk(path_in_backup, src_disk, src_path, copy_encrypted, start_pos, length); + return; + } + + const size_t src_object_offset = src_disk->getObjectPayloadOffset(src_path); + copyS3File( /* src_s3_client */ disk_client_factory.getOrCreate(src_disk), /* src_bucket */ blob_path[1], /* src_key */ blob_path[0], start_pos, length, + src_object_offset, /* dest_s3_client */ client, /* dest_bucket */ s3_uri.bucket, /* dest_key */ fs::path(s3_uri.key) / path_in_backup, @@ -433,6 +445,7 @@ void BackupWriterS3::copyFile(const String & destination, const String & source, /* src_key= */ source_key, 0, size, + /* src_object_offset= */ 0, /* dest_s3_client= */ client, /* dest_bucket= */ s3_uri.bucket, /* dest_key= */ fs::path(s3_uri.key) / destination, diff --git a/src/Disks/DiskBackup.cpp b/src/Disks/DiskBackup.cpp index 7f5b1ec9b0d4..93efc93f0e60 100644 --- a/src/Disks/DiskBackup.cpp +++ b/src/Disks/DiskBackup.cpp @@ -170,6 +170,11 @@ std::vector DiskBackup::getBlobPath(const String &) const throw Exception(ErrorCodes::UNSUPPORTED_METHOD, "DiskBackup does not support getBlobPath method"); } +size_t DiskBackup::getObjectPayloadOffset(const String &) const +{ + throw Exception(ErrorCodes::UNSUPPORTED_METHOD, "DiskBackup does not support getObjectPayloadOffset method"); +} + void DiskBackup::writeFileUsingBlobWritingFunction(const String &, WriteMode, WriteBlobFunction &&) { throw Exception(ErrorCodes::UNSUPPORTED_METHOD, "DiskBackup does not support writeFileUsingBlobWritingFunction method"); diff --git a/src/Disks/DiskBackup.h b/src/Disks/DiskBackup.h index b2f67e17ab20..5468a7bac7aa 100644 --- a/src/Disks/DiskBackup.h +++ b/src/Disks/DiskBackup.h @@ -91,6 +91,7 @@ class DiskBackup final : public IDisk const WriteSettings & settings) override; Strings getBlobPath(const String & path) const override; + size_t getObjectPayloadOffset(const String & path) const override; bool areBlobPathsRandom() const override { return false; } void writeFileUsingBlobWritingFunction(const String & path, WriteMode mode, WriteBlobFunction && write_blob_function) override; diff --git a/src/Disks/DiskEncrypted.h b/src/Disks/DiskEncrypted.h index ece91f310985..9640c2e5740c 100644 --- a/src/Disks/DiskEncrypted.h +++ b/src/Disks/DiskEncrypted.h @@ -205,6 +205,12 @@ class DiskEncrypted : public IDisk return delegate->getBlobPath(wrapped_path); } + size_t getObjectPayloadOffset(const String & path) const override + { + auto wrapped_path = wrappedPath(path); + return delegate->getObjectPayloadOffset(wrapped_path); + } + bool areBlobPathsRandom() const override { return delegate->areBlobPathsRandom(); diff --git a/src/Disks/DiskLocal.cpp b/src/Disks/DiskLocal.cpp index 472944b64432..9eb3ef2ba3a0 100644 --- a/src/Disks/DiskLocal.cpp +++ b/src/Disks/DiskLocal.cpp @@ -447,6 +447,11 @@ std::vector DiskLocal::getBlobPath(const String & path) const return {fs_path}; } +size_t DiskLocal::getObjectPayloadOffset(const String &) const +{ + return 0; +} + void DiskLocal::writeFileUsingBlobWritingFunction(const String & path, WriteMode mode, WriteBlobFunction && write_blob_function) { auto fs_path = fs::path(disk_path) / path; diff --git a/src/Disks/DiskLocal.h b/src/Disks/DiskLocal.h index a262752e4e12..83b43687f17b 100644 --- a/src/Disks/DiskLocal.h +++ b/src/Disks/DiskLocal.h @@ -91,6 +91,7 @@ class DiskLocal : public IDisk const WriteSettings & settings) override; Strings getBlobPath(const String & path) const override; + size_t getObjectPayloadOffset(const String & path) const override; bool areBlobPathsRandom() const override { return false; } void writeFileUsingBlobWritingFunction(const String & path, WriteMode mode, WriteBlobFunction && write_blob_function) override; diff --git a/src/Disks/DiskObjectStorage/DiskObjectStorage.cpp b/src/Disks/DiskObjectStorage/DiskObjectStorage.cpp index 2c2d071a7fbb..fef09747814e 100644 --- a/src/Disks/DiskObjectStorage/DiskObjectStorage.cpp +++ b/src/Disks/DiskObjectStorage/DiskObjectStorage.cpp @@ -54,6 +54,7 @@ namespace ErrorCodes { extern const int INCORRECT_DISK_INDEX; extern const int CANNOT_RMDIR; + extern const int LOGICAL_ERROR; } namespace @@ -824,12 +825,16 @@ void DiskObjectStorage::prepareRead( if (metadata_storage->isContentAddressed()) { const auto * ca = dynamic_cast(metadata_storage.get()); - if (ca) - { - if (ca->prepareInManifestRead(path, settings, pipeline)) - return; - ca_blob_view = ca->getBlobViewPlan(path); - } + if (!ca) + throw Exception( + ErrorCodes::LOGICAL_ERROR, + "Metadata storage of disk {} reports itself content-addressed but does not implement " + "IContentAddressedExchange, so the payload window of {} cannot be resolved", + getName(), path); + + if (ca->prepareInManifestRead(path, settings, pipeline)) + return; + ca_blob_view = ca->getBlobViewPlan(path); } const auto storage_objects = ca_blob_view @@ -957,6 +962,24 @@ Strings DiskObjectStorage::getBlobPath(const String & path) const return res; } +size_t DiskObjectStorage::getObjectPayloadOffset(const String & path) const +{ + if (!metadata_storage->isContentAddressed()) + return 0; + + const auto * ca = dynamic_cast(metadata_storage.get()); + if (!ca) + throw Exception( + ErrorCodes::LOGICAL_ERROR, + "Metadata storage of disk {} reports itself content-addressed but does not implement " + "IContentAddressedExchange, so the payload offset of {} cannot be resolved", + getName(), path); + + if (auto plan = ca->getBlobViewPlan(path)) + return plan->payload_offset; + return 0; +} + bool DiskObjectStorage::areBlobPathsRandom() const { return metadata_storage->areBlobPathsRandom(); diff --git a/src/Disks/DiskObjectStorage/DiskObjectStorage.h b/src/Disks/DiskObjectStorage/DiskObjectStorage.h index 09ec478b02d0..95bf6ef366d7 100644 --- a/src/Disks/DiskObjectStorage/DiskObjectStorage.h +++ b/src/Disks/DiskObjectStorage/DiskObjectStorage.h @@ -179,6 +179,8 @@ friend class DiskObjectStorageReservation; const WriteSettings & settings) override; Strings getBlobPath(const String & path) const override; + + size_t getObjectPayloadOffset(const String & path) const override; bool areBlobPathsRandom() const override; void writeFileUsingBlobWritingFunction(const String & path, WriteMode mode, WriteBlobFunction && write_blob_function) override; diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.cpp index 8fb5a31b467c..0e88ad24e93e 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.cpp +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.cpp @@ -2103,10 +2103,10 @@ std::optional ContentAddressedMet /// `partAccess()` then `store()` pair (each an independent `pointer_mutex` acquisition) -- see /// `poolAccess()`. const auto snap = poolAccess(); - auto view = snap.part_access->getView(r->refKey(), Cas::Freshness::CachedForLoad); - if (!view) + const auto manifest_view = snap.part_access->getView(r->refKey(), Cas::Freshness::CachedForLoad); + if (!manifest_view) return std::nullopt; - if (const auto * entry = view->findFile(r->file)) + if (const auto * entry = manifest_view->findFile(r->file)) { const auto location = snap.pool->locate(*entry); BlobViewPlan plan; diff --git a/src/Disks/DiskObjectStorage/ObjectStorages/S3/S3ObjectStorage.cpp b/src/Disks/DiskObjectStorage/ObjectStorages/S3/S3ObjectStorage.cpp index 6eb155dd4c44..2f74ad9aa8f8 100644 --- a/src/Disks/DiskObjectStorage/ObjectStorages/S3/S3ObjectStorage.cpp +++ b/src/Disks/DiskObjectStorage/ObjectStorages/S3/S3ObjectStorage.cpp @@ -988,6 +988,7 @@ void S3ObjectStorage::copyObjectToAnotherObjectStorage( // NOLINT /*src_key=*/object_from.remote_path, /*src_offset=*/0, /*src_size=*/size, + /*src_object_offset=*/0, /*dest_s3_client=*/current_client, /*dest_bucket=*/dest_s3->uri.bucket, /*dest_key=*/object_to.remote_path, @@ -1062,6 +1063,7 @@ void S3ObjectStorage::copyObject( // NOLINT /*src_key=*/object_from.remote_path, /*src_offset=*/0, /*src_size=*/size, + /*src_object_offset=*/0, /*dest_s3_client=*/current_client, /*dest_bucket=*/uri.bucket, /*dest_key=*/object_to.remote_path, diff --git a/src/Disks/IDisk.h b/src/Disks/IDisk.h index b79a3171bde7..f1133e0326d5 100644 --- a/src/Disks/IDisk.h +++ b/src/Disks/IDisk.h @@ -318,6 +318,9 @@ class IDisk : public Space /// StoredObject::remote_path for each stored object combined with the name of the objects' namespace. virtual Strings getBlobPath(const String & path) const = 0; + /// Where the file's bytes begin inside the object `getBlobPath` names. + virtual size_t getObjectPayloadOffset(const String & path) const = 0; + /// Returns whether the blob paths this disk uses are randomly generated. virtual bool areBlobPathsRandom() const = 0; diff --git a/src/Disks/ReadOnlyDiskWrapper.h b/src/Disks/ReadOnlyDiskWrapper.h index 9a38e85cde77..f82a562ca6b8 100644 --- a/src/Disks/ReadOnlyDiskWrapper.h +++ b/src/Disks/ReadOnlyDiskWrapper.h @@ -29,6 +29,7 @@ class ReadOnlyDiskWrapper : public IDisk size_t getFileSize(const String & path) const override { return delegate->getFileSize(path); } Strings getBlobPath(const String & path) const override { return delegate->getBlobPath(path); } + size_t getObjectPayloadOffset(const String & path) const override { return delegate->getObjectPayloadOffset(path); } bool areBlobPathsRandom() const override { return delegate->areBlobPathsRandom(); } void writeFileUsingBlobWritingFunction(const String & path, WriteMode mode, WriteBlobFunction && write_blob_function) override { diff --git a/src/IO/S3/copyS3File.cpp b/src/IO/S3/copyS3File.cpp index 4b1f5e14ece5..df5429a2a980 100644 --- a/src/IO/S3/copyS3File.cpp +++ b/src/IO/S3/copyS3File.cpp @@ -640,8 +640,27 @@ namespace void performCopy() { LOG_TEST(log, "Copy object {} to {} using native copy", src_key, dest_key); - bool use_single_operation_copy = !supports_multipart_copy || !request_settings[S3RequestSetting::allow_multipart_copy] - || (size <= request_settings[S3RequestSetting::max_single_operation_copy_size]); + + const bool ranged = offset != 0; + const bool multipart_copy_available + = supports_multipart_copy && request_settings[S3RequestSetting::allow_multipart_copy]; + + if (ranged && !multipart_copy_available) + { + if (!allow_fallback) + throw Exception( + ErrorCodes::NOT_IMPLEMENTED, + "Native copy of a byte range requires multipart copy, which is unavailable for {}", + src_key); + + LOG_TRACE(log, "Ranged native copy needs multipart copy, falling back for {}", src_key); + fallback_method(); + return; + } + + const bool use_single_operation_copy = !ranged + && (!multipart_copy_available + || (size <= request_settings[S3RequestSetting::max_single_operation_copy_size])); if (use_single_operation_copy) performSingleOperationCopy(); @@ -863,6 +882,7 @@ void copyS3File( const String & src_key, size_t src_offset, size_t src_size, + size_t src_object_offset, std::shared_ptr dest_s3_client, const String & dest_bucket, const String & dest_key, @@ -908,7 +928,7 @@ void copyS3File( src_s3_client, src_bucket, src_key, - src_offset, + src_offset + src_object_offset, src_size, dest_bucket, dest_key, diff --git a/src/IO/S3/copyS3File.h b/src/IO/S3/copyS3File.h index d4f728377130..8c37c4187739 100644 --- a/src/IO/S3/copyS3File.h +++ b/src/IO/S3/copyS3File.h @@ -40,6 +40,7 @@ void copyS3File( const String & src_key, size_t src_offset, size_t src_size, + size_t src_object_offset, std::shared_ptr dest_s3_client, const String & dest_bucket, const String & dest_key, diff --git a/src/Storages/ObjectStorageQueue/ObjectStorageQueuePostProcessor.cpp b/src/Storages/ObjectStorageQueue/ObjectStorageQueuePostProcessor.cpp index aa748eacaedc..020904ae73c3 100644 --- a/src/Storages/ObjectStorageQueue/ObjectStorageQueuePostProcessor.cpp +++ b/src/Storages/ObjectStorageQueue/ObjectStorageQueuePostProcessor.cpp @@ -331,6 +331,7 @@ void ObjectStorageQueuePostProcessor::moveS3Objects(const StoredObjects & object /*src_key=*/ object_from.remote_path, /*src_offset=*/ 0, /*src_size=*/ object_size, + /*src_object_offset=*/ 0, /*dest_s3_client=*/ dst_client, /*dest_bucket=*/ dst_uri.bucket, /*dest_key=*/ object_to.remote_path, diff --git a/tests/integration/test_cas_backup_s3_native_copy/test.py b/tests/integration/test_cas_backup_s3_native_copy/test.py index 4b0541f33666..dba715125eda 100644 --- a/tests/integration/test_cas_backup_s3_native_copy/test.py +++ b/tests/integration/test_cas_backup_s3_native_copy/test.py @@ -1,19 +1,14 @@ -"""BACKUP/RESTORE of a CAS table to an S3 destination on the same object store. - -When the backup destination and the CAS pool share an S3 authority, -`DataSourceDescription::sameKind` matches and `BackupWriterS3::copyFileFromDisk` copies -the source object server-side instead of reading it through the CAS read path. - -A CAS blob object is `[blob_header_len bytes of envelope][payload]`, and the payload is -what the MergeTree file actually is. `BlobLocation` carries that `offset`, but -`ContentAddressedMetadataStorage::getStorageObjects` returns only `key` and `length` -- -the offset is dropped. Small per-part files are inline manifest entries and get a -placeholder with an empty remote key. Neither shape survives a server-side copy that -addresses the object from byte 0. - -`test_native_copy_round_trip` is the oracle: the restored table must equal the source. -`test_native_copy_branch_is_reached` guards it against going vacuous -- if the native -branch is never entered, the oracle proves nothing about this path. +"""BACKUP/RESTORE of a CAS table to an S3 destination sharing the pool's authority. + +`sameKind` then matches and `BackupWriterS3` copies objects server-side instead of reading +through the CAS read path. That path must handle two shapes: a blob, whose object is +`[envelope][payload]` so the file starts at a non-zero offset, and an inline manifest entry, +which has no object at all. `getStorageObjects` reports neither -- it drops the offset and +returns an empty key. + +Columns are chosen so one table yields both shapes: placement keys on the file name, so every +column makes blobs while per-part metadata stays inline. `n` and `arr` add the `.null.bin` and +`.size0.bin` substreams. """ import uuid @@ -29,7 +24,9 @@ S3_AUTHORITY = "http://rustfs1:11121" S3_CREDENTIALS = "'clickhouse', 'clickhouse'" -NUM_ROWS = 200000 +NUM_ROWS = 100000 + +COLUMNS = ["k", "s", "n", "arr"] RUN_TOKEN = uuid.uuid4().hex @@ -59,7 +56,7 @@ def create_and_fill(node, table): node.query(f"DROP TABLE IF EXISTS {table} SYNC") node.query( f""" - CREATE TABLE {table} (k UInt64, v String) + CREATE TABLE {table} (k UInt64, s String, n Nullable(Int64), arr Array(UInt32)) ENGINE = MergeTree ORDER BY k SETTINGS storage_policy = '{STORAGE_POLICY}', min_bytes_for_wide_part = 0 """ @@ -67,15 +64,23 @@ def create_and_fill(node, table): node.query( f""" INSERT INTO {table} - SELECT number, randomPrintableASCII(64) FROM numbers({NUM_ROWS}) + SELECT + number, + randomPrintableASCII(64), + if(number % 7 = 0, NULL, toInt64(number)), + [toUInt32(number), toUInt32(number + 1)] + FROM numbers({NUM_ROWS}) """ ) -def fingerprint(node, table): - return node.query( - f"SELECT count(), sum(k), sum(cityHash64(v)) FROM {table}" - ).strip() +def column_fingerprints(node, table): + """Order-independent per-column hash; reading every column fetches every blob.""" + exprs = ", ".join( + f"sum(cityHash64(ifNull(toString({column}), '')))" for column in COLUMNS + ) + row = node.query(f"SELECT count(), {exprs} FROM {table}").strip().split("\t") + return dict(zip(["count"] + COLUMNS, row)) @pytest.mark.parametrize("allow_native_copy", [True, False]) @@ -87,7 +92,7 @@ def test_native_copy_round_trip(allow_native_copy): destination = backup_destination(suffix) create_and_fill(node, table) - expected = fingerprint(node, table) + expected = column_fingerprints(node, table) node.query( f"BACKUP TABLE {table} TO {destination} " @@ -102,7 +107,12 @@ def test_native_copy_round_trip(allow_native_copy): settings=FORCE_SINGLE_OPERATION_COPY, ) - assert fingerprint(node, restored) == expected + actual = column_fingerprints(node, restored) + + assert actual["count"] == expected["count"] + differing = [c for c in COLUMNS if actual[c] != expected[c]] + assert not differing, f"columns differ after restore: {differing}" + assert ( node.query( f"CHECK TABLE {restored} SETTINGS check_query_single_value_result = 1" @@ -114,34 +124,92 @@ def test_native_copy_round_trip(allow_native_copy): node.query(f"DROP TABLE {restored} SYNC") -def test_native_copy_branch_is_reached(): +def test_blobs_use_ranged_copy_and_inline_falls_back(): + """A blob is `[envelope][payload]`, so its copy must be ranged: `UploadPartCopy`, never + `CopyObject`. Inline entries have no object and must go through buffers. + """ node = cluster.instances["node"] - table = "cas_backup_probe" - destination = backup_destination("probe") + table = "cas_backup_mechanism" + destination = backup_destination("mechanism") + query_id = f"cas_backup_mechanism_{RUN_TOKEN}" create_and_fill(node, table) + node.query( + f"BACKUP TABLE {table} TO {destination} SETTINGS allow_s3_native_copy = 1", + query_id=query_id, + ) + node.query("SYSTEM FLUSH LOGS query_log") + + events = node.query( + f""" + SELECT ProfileEvents['S3UploadPartCopy'], ProfileEvents['S3CopyObject'] + FROM system.query_log + WHERE type = 'QueryFinish' AND query_id = '{query_id}' + ORDER BY event_time DESC LIMIT 1 + """ + ).strip() + assert events, "no query_log row for the backup query" + upload_part_copy, copy_object = (int(value) for value in events.split("\t")) + + assert upload_part_copy > 0, "no ranged server-side copy happened" + assert copy_object == 0, "CopyObject has no range: the envelope would land in the backup" + assert node.contains_in_log( + "has no object of its own, copying through buffers" + ), "inline entries did not fall back" + + node.query(f"DROP TABLE {table} SYNC") + + +def test_ranged_copy_falls_back_without_multipart(): + """Only `UploadPartCopy` can express a range. With multipart copy off there is no server-side + operation left, so the copy must go through buffers instead of taking the whole object. + """ + node = cluster.instances["node"] + table = "cas_backup_no_multipart" + restored = f"{table}_restored" + destination = backup_destination("no_multipart") + query_id = f"cas_backup_no_multipart_{RUN_TOKEN}" + no_multipart = {"s3_allow_multipart_copy": 0} + + create_and_fill(node, table) + expected = column_fingerprints(node, table) - query_id = f"cas_backup_native_copy_{RUN_TOKEN}" node.query( f"BACKUP TABLE {table} TO {destination} SETTINGS allow_s3_native_copy = 1", query_id=query_id, - settings=FORCE_SINGLE_OPERATION_COPY, + settings=no_multipart, ) node.query("SYSTEM FLUSH LOGS query_log") - copy_object_events = node.query( + events = node.query( f""" - SELECT ProfileEvents['S3CopyObject'] FROM system.query_log + SELECT + ProfileEvents['S3UploadPartCopy'], + ProfileEvents['S3CopyObject'], + ProfileEvents['S3PutObject'] + ProfileEvents['S3UploadPart'] + FROM system.query_log WHERE type = 'QueryFinish' AND query_id = '{query_id}' ORDER BY event_time DESC LIMIT 1 """ ).strip() + assert events, "no query_log row for the backup query" + upload_part_copy, copy_object, uploaded = (int(value) for value in events.split("\t")) + + assert upload_part_copy == 0, "multipart copy was disabled but UploadPartCopy still ran" + assert copy_object == 0, "a ranged copy fell back to CopyObject, which would take the envelope" + assert uploaded > 0, "nothing was uploaded through the server, so nothing was copied at all" + assert node.contains_in_log( + "Ranged native copy needs multipart copy" + ), "the copy did not reach the ranged-copy fallback" - assert copy_object_events != "", "no query_log row for the backup query" - assert int(copy_object_events) > 0, ( - "BackupWriterS3 never took its native-copy branch, so " - "test_native_copy_round_trip does not cover it; check whether " - "DataSourceDescription::sameKind still matches for a CAS disk" + node.query(f"DROP TABLE IF EXISTS {restored} SYNC") + node.query( + f"RESTORE TABLE {table} AS {restored} FROM {destination}", settings=no_multipart ) + actual = column_fingerprints(node, restored) + assert actual["count"] == expected["count"] + assert not [c for c in COLUMNS if actual[c] != expected[c]] + node.query(f"DROP TABLE {table} SYNC") + node.query(f"DROP TABLE {restored} SYNC") From 8fe883f36d08be70f778f6cc27eb02740e3dadec Mon Sep 17 00:00:00 2001 From: Konstantin Morozov Date: Thu, 24 Sep 2026 13:22:43 +0200 Subject: [PATCH 03/30] update doc Signed-off-by: Konstantin Morozov --- docs/en/antalya/cas/index.md | 1 + docs/en/antalya/cas/operations/backup.md | 99 ++++++++++++++++++++++++ docs/en/antalya/cas/roadmap.md | 4 + 3 files changed, 104 insertions(+) create mode 100644 docs/en/antalya/cas/operations/backup.md diff --git a/docs/en/antalya/cas/index.md b/docs/en/antalya/cas/index.md index cb1d563eebf0..377f03b7d40f 100644 --- a/docs/en/antalya/cas/index.md +++ b/docs/en/antalya/cas/index.md @@ -94,4 +94,5 @@ per disk, so adopting it never requires migrating an existing deployment. | [Architecture overview](/antalya/cas/architecture/) | The object model, the Git analogy, and the safety invariants | | [Correctness](/antalya/cas/architecture/correctness) | How the design was verified: TLA+ models, counterexamples, soak methodology | | [Design history](/antalya/cas/architecture/design-history) | What earlier designs were tried and rejected, and why | +| [Backup](/antalya/cas/operations/backup) | How `BACKUP` and `RESTORE` behave on a content-addressed disk | | [Roadmap](/antalya/cas/roadmap) | What is shipped, planned, and deliberately not pursued | diff --git a/docs/en/antalya/cas/operations/backup.md b/docs/en/antalya/cas/operations/backup.md new file mode 100644 index 000000000000..25c019dce0c9 --- /dev/null +++ b/docs/en/antalya/cas/operations/backup.md @@ -0,0 +1,99 @@ +--- +description: 'How BACKUP and RESTORE work for a table on a content-addressed disk: what holds the data during a backup, when the copy runs inside S3, and what is not supported yet.' +sidebar_label: 'Backup' +sidebar_position: 5 +slug: /antalya/cas/operations/backup +title: 'CAS Operations — Backup' +doc_type: 'guide' +--- + +# Operations — backup {#backup} + +Ordinary `BACKUP` and `RESTORE` work for a table on a content-addressed (`CAS`) disk. This page +covers what happens during one, how it differs from a plain disk, and what the limits are. + +The `CAS`-native backup model — `snapshot` / `mirror` / `fetch` / `restore` — is designed but not +implemented and is not wired into the SQL surface. See the [roadmap](/antalya/cas/roadmap#backups). + +## What is supported {#supported} + +```sql +BACKUP TABLE t TO S3('https://bucket.s3.amazonaws.com/backups/b1', 'key', 'secret'); +RESTORE TABLE t AS t_restored FROM S3('https://bucket.s3.amazonaws.com/backups/b1', 'key', 'secret'); +``` + +The destination can be anything: `S3`, `Disk`, `File`, or an archive. A backup can be restored onto a +disk of any type, because it holds the table's files rather than the pool's objects. + +**An `Atomic` database is required.** That has been the default since 20.x. On the deprecated +`Ordinary` engine the backup fails with `SUPPORT_IS_DISABLED`: that path pins files with temporary +hard links, which object storage does not have. + +## What holds the data during a backup {#holding} + +On a plain disk a backup pins files against deletion with a hard link. `CAS` uses a different +mechanism — pointer holding: + +- the backup holds a `shared_ptr` to the table and to each part; +- the outdated-part cleanup skips those parts; +- while a part is alive so is its [ref](/antalya/cas/architecture/manifests-and-refs#ref-table) — the + name under which the part is registered in its namespace's ref table, and through which it points + at its manifest; +- while the ref is alive, garbage collection sees the manifest and its blobs as reachable. + +This mechanism lives in the process's memory and **does not survive a server restart**. An +interrupted backup leaves nothing behind in the pool, but it also stops protecting the data once the +process is gone. For durable pinning there is `FREEZE`, which publishes a real ref. + +## How the bytes move {#copy-path} + +Which path runs depends on the destination. + +**A destination outside the pool** — the common case: another bucket, a local disk, an archive. Files +are read through the `CAS` read path and written to the destination. Pool deduplication is lost: +what was one blob shared by several replicas becomes ordinary files in the backup. + +**A destination on the same `S3` endpoint as the pool** — the copy then runs inside the `S3` store +itself: the ClickHouse server issues one "copy these bytes" command, and `S3` moves the bytes +internally without sending them through ClickHouse. The files of a part fall into two categories: + +| Category | Example | How it is copied | +|---|---|---| +| Blob | `data.bin`, marks, `primary.idx` | an `UploadPartCopy` naming a byte range — only the payload moves, without the blob's internal header | +| Inside the manifest | `checksums.txt`, `count.txt`, `columns.txt` | through ClickHouse's buffers: they have no object of their own | + +If the destination cannot copy a byte range, `CAS` does not fall back to copying the whole object — +the file is read and written through ClickHouse instead. That is slower, but correct. + +Copying inside `S3` can be turned off: + +```sql +BACKUP TABLE t TO S3(...) SETTINGS allow_s3_native_copy = 0; +``` + +## Restore {#restore} + +Each part is materialized in **one disk transaction** and published as one manifest and one ref. A +partially restored part can never appear in the pool: either the whole part is published or nothing +is. + +Restored data is packed afresh — on a `CAS` disk it gets new blobs and new refs. Deduplication +against data already in the pool works as usual: identical content hashes to the same blob and is +not written twice. + +## `FREEZE` is not a backup {#freeze} + +`ALTER TABLE ... FREEZE` works on `CAS` and publishes parts into a separate shadow namespace, which +is a garbage-collection root in its own right. `DROP PARTITION` removes the live refs and leaves the +snapshot alone; `SYSTEM UNFREEZE` removes only the shadow refs. + +It is still not a snapshot of a table: there is no SQL metadata, no single commit marker, no +portable object with a listing and a restore API, and its lifetime is tied to a manual `UNFREEZE`. +It is a useful building block, not a replacement for `BACKUP`. + +## Limitations {#limitations} + +- The `CAS`-native backup model (`snapshot` / `mirror` / `fetch`) is not implemented. +- The `Ordinary` database engine is not supported. +- Pool deduplication is lost in the backup: its size follows the logical files, not the unique blobs. +- Pointer holding does not survive a server restart. diff --git a/docs/en/antalya/cas/roadmap.md b/docs/en/antalya/cas/roadmap.md index 4df03af9ab3c..14578bd42058 100644 --- a/docs/en/antalya/cas/roadmap.md +++ b/docs/en/antalya/cas/roadmap.md @@ -91,6 +91,10 @@ positioning. ## Backups {#backups} +Ordinary `BACKUP` and `RESTORE` already work for a table on a `CAS` disk — see +[backup](/antalya/cas/operations/backup) for how they behave and what the limits are. What follows is +about the `CAS`-native model, which is a different thing. + A `snapshot` / `mirror` / `fetch` / `restore` design is **approved but not implemented**. The model is deliberately git-shaped: `snapshot` is instant and free (like `git tag` — it references existing manifests, copies nothing); `mirror` is a continuous pull from a production pool into a From b130c47e25e7a36e76cda60424bd1cb3b1f9b2fb Mon Sep 17 00:00:00 2001 From: Konstantin Morozov Date: Thu, 24 Sep 2026 13:32:49 +0200 Subject: [PATCH 04/30] update test Signed-off-by: Konstantin Morozov --- tests/integration/test_cas_backup_s3_native_copy/test.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/integration/test_cas_backup_s3_native_copy/test.py b/tests/integration/test_cas_backup_s3_native_copy/test.py index dba715125eda..96900347c830 100644 --- a/tests/integration/test_cas_backup_s3_native_copy/test.py +++ b/tests/integration/test_cas_backup_s3_native_copy/test.py @@ -30,8 +30,6 @@ RUN_TOKEN = uuid.uuid4().hex -FORCE_SINGLE_OPERATION_COPY = {"s3_max_single_operation_copy_size": 5 * 1024**3} - @pytest.fixture(scope="module", autouse=True) def start_cluster(): @@ -96,15 +94,13 @@ def test_native_copy_round_trip(allow_native_copy): node.query( f"BACKUP TABLE {table} TO {destination} " - f"SETTINGS allow_s3_native_copy = {int(allow_native_copy)}", - settings=FORCE_SINGLE_OPERATION_COPY, + f"SETTINGS allow_s3_native_copy = {int(allow_native_copy)}" ) node.query(f"DROP TABLE IF EXISTS {restored} SYNC") node.query( f"RESTORE TABLE {table} AS {restored} FROM {destination} " - f"SETTINGS allow_s3_native_copy = {int(allow_native_copy)}", - settings=FORCE_SINGLE_OPERATION_COPY, + f"SETTINGS allow_s3_native_copy = {int(allow_native_copy)}" ) actual = column_fingerprints(node, restored) From 6d6674f21477d8583c817674da4729958dd22a92 Mon Sep 17 00:00:00 2001 From: Konstantin Morozov Date: Fri, 25 Sep 2026 18:54:53 +0200 Subject: [PATCH 05/30] support S3 disk Signed-off-by: Konstantin Morozov --- docs/en/antalya/cas/operations/backup.md | 52 ++++++++++-- src/Backups/BackupIO_S3.cpp | 2 +- .../DiskObjectStorage/DiskObjectStorage.cpp | 15 +--- .../DiskObjectStorageTransaction.cpp | 52 ++++++++++-- .../MetadataStorageFromCacheObjectStorage.cpp | 5 ++ .../MetadataStorageFromCacheObjectStorage.h | 1 + .../ContentAddressedMetadataStorage.cpp | 15 ++++ .../ContentAddressedMetadataStorage.h | 2 + .../MetadataStorages/IMetadataStorage.h | 2 + .../Cached/CachedObjectStorage.cpp | 6 +- .../Cached/CachedObjectStorage.h | 3 +- .../ObjectStorages/IObjectStorage.cpp | 15 +++- .../ObjectStorages/IObjectStorage.h | 3 +- .../ObjectStorages/S3/S3ObjectStorage.cpp | 17 +++- .../ObjectStorages/S3/S3ObjectStorage.h | 3 +- .../configs/storage_conf.xml | 37 +++++++++ .../test_cas_backup_s3_native_copy/test.py | 83 ++++++++++++++++++- 17 files changed, 270 insertions(+), 43 deletions(-) diff --git a/docs/en/antalya/cas/operations/backup.md b/docs/en/antalya/cas/operations/backup.md index 25c019dce0c9..757af8973534 100644 --- a/docs/en/antalya/cas/operations/backup.md +++ b/docs/en/antalya/cas/operations/backup.md @@ -1,5 +1,5 @@ --- -description: 'How BACKUP and RESTORE work for a table on a content-addressed disk: what holds the data during a backup, when the copy runs inside S3, and what is not supported yet.' +description: 'How BACKUP and RESTORE work for a table on a content-addressed disk: what holds the data during a backup, when the copy runs inside S3 for S3 and Disk destinations, and what is not supported yet.' sidebar_label: 'Backup' sidebar_position: 5 slug: /antalya/cas/operations/backup @@ -55,17 +55,40 @@ what was one blob shared by several replicas becomes ordinary files in the backu **A destination on the same `S3` endpoint as the pool** — the copy then runs inside the `S3` store itself: the ClickHouse server issues one "copy these bytes" command, and `S3` moves the bytes -internally without sending them through ClickHouse. The files of a part fall into two categories: +internally without sending them through ClickHouse. This works for both kinds of destination: -| Category | Example | How it is copied | -|---|---|---| -| Blob | `data.bin`, marks, `primary.idx` | an `UploadPartCopy` naming a byte range — only the payload moves, without the blob's internal header | -| Inside the manifest | `checksums.txt`, `count.txt`, `columns.txt` | through ClickHouse's buffers: they have no object of their own | +```sql +BACKUP TABLE t TO S3('http://s3.example.com/bucket/backups/b1', 'key', 'secret'); +BACKUP TABLE t TO Disk('backups_s3', 'b1'); +``` + +Here the pool is under `http://s3.example.com/bucket/pool/`, and the disk `backups_s3` is an `s3` +or `s3_plain` disk under `http://s3.example.com/bucket/backups/`. + +The files of a part fall into two categories: + +| Category | Example | `BACKUP ... TO S3(...)` | `BACKUP ... TO Disk(...)` | +|---|---|---|---| +| Blob | `data.bin`, marks, `primary.idx` | an `UploadPartCopy` naming a byte range — only the payload moves, without the blob's internal header | the same | +| Inside the manifest | `checksums.txt`, `count.txt`, `columns.txt` | read through the `CAS` read path and written through ClickHouse's buffers | the bytes are taken from the manifest and written as a new object on the destination disk | + +A blob object is `[header][payload]`, so a file never starts at the beginning of its object. Every +copy of a blob therefore names the payload range. A copy of the whole object would put the header +into the backup, and a later restore would read wrong data. If the destination cannot copy a byte range, `CAS` does not fall back to copying the whole object — -the file is read and written through ClickHouse instead. That is slower, but correct. +the file is read and written through ClickHouse instead. That is slower, but correct. This happens +when multipart copy is off, and when the destination is not an `S3` store. + +Which settings control the copy depends on the destination: -Copying inside `S3` can be turned off: +| Destination | Turn off the copy inside `S3` | Turn off the range copy | +|---|---|---| +| `S3(...)` | `SETTINGS allow_s3_native_copy = 0` in the `BACKUP` query | the query setting `s3_allow_multipart_copy = 0` | +| `Disk(...)` | `0` in the `CAS` disk config | `0` in the `CAS` disk config | + +For a `Disk(...)` destination the copy uses the request settings of the source `CAS` disk, not the +settings of the query. ```sql BACKUP TABLE t TO S3(...) SETTINGS allow_s3_native_copy = 0; @@ -77,6 +100,11 @@ Each part is materialized in **one disk transaction** and published as one manif partially restored part can never appear in the pool: either the whole part is published or nothing is. +Restore onto a `CAS` disk never copies objects inside `S3`, even when the backup is on the same +endpoint as the pool. Each file of the part is read from the backup and written through the `CAS` +write path, because only that path can build the manifest and the blobs. A restore onto a plain disk +works as usual and can copy inside `S3`. + Restored data is packed afresh — on a `CAS` disk it gets new blobs and new refs. Deduplication against data already in the pool works as usual: identical content hashes to the same blob and is not written twice. @@ -97,3 +125,11 @@ It is a useful building block, not a replacement for `BACKUP`. - The `Ordinary` database engine is not supported. - Pool deduplication is lost in the backup: its size follows the logical files, not the unique blobs. - Pointer holding does not survive a server restart. +- The copy inside the store works only for `S3` and `S3`-compatible stores, and only when the + destination is on the same endpoint as the pool. Other destinations get the copy through + ClickHouse's buffers. +- A blob is copied inside `S3` only with multipart copy (`UploadPartCopy`), because only it can name a + byte range. Without multipart copy every blob goes through ClickHouse's buffers. +- Restore onto a `CAS` disk always writes through ClickHouse, see [restore](#restore). +- A disk-level copy of a single file onto a `CAS` disk, outside of `RESTORE`, is rejected with + `NOT_IMPLEMENTED`: a `CAS` disk accepts part files only as a whole part in one transaction. diff --git a/src/Backups/BackupIO_S3.cpp b/src/Backups/BackupIO_S3.cpp index 2a92e5e862a4..214bf93eba04 100644 --- a/src/Backups/BackupIO_S3.cpp +++ b/src/Backups/BackupIO_S3.cpp @@ -394,7 +394,7 @@ void BackupWriterS3::copyFileFromDisk( { LOG_TRACE(log, "Copying file {} from disk {} to S3", src_path, src_disk->getName()); - if (blob_path[0].empty()) + if (src_disk->isContentAddressed() && blob_path[0].empty()) { LOG_TRACE(log, "File {} has no object of its own, copying through buffers", src_path); BackupWriterDefault::copyFileFromDisk(path_in_backup, src_disk, src_path, copy_encrypted, start_pos, length); diff --git a/src/Disks/DiskObjectStorage/DiskObjectStorage.cpp b/src/Disks/DiskObjectStorage/DiskObjectStorage.cpp index fef09747814e..284e373412c8 100644 --- a/src/Disks/DiskObjectStorage/DiskObjectStorage.cpp +++ b/src/Disks/DiskObjectStorage/DiskObjectStorage.cpp @@ -964,20 +964,7 @@ Strings DiskObjectStorage::getBlobPath(const String & path) const size_t DiskObjectStorage::getObjectPayloadOffset(const String & path) const { - if (!metadata_storage->isContentAddressed()) - return 0; - - const auto * ca = dynamic_cast(metadata_storage.get()); - if (!ca) - throw Exception( - ErrorCodes::LOGICAL_ERROR, - "Metadata storage of disk {} reports itself content-addressed but does not implement " - "IContentAddressedExchange, so the payload offset of {} cannot be resolved", - getName(), path); - - if (auto plan = ca->getBlobViewPlan(path)) - return plan->payload_offset; - return 0; + return metadata_storage->getObjectPayloadOffset(path); } bool DiskObjectStorage::areBlobPathsRandom() const diff --git a/src/Disks/DiskObjectStorage/DiskObjectStorageTransaction.cpp b/src/Disks/DiskObjectStorage/DiskObjectStorageTransaction.cpp index 6a52ca93e673..fdb33da8fc6e 100644 --- a/src/Disks/DiskObjectStorage/DiskObjectStorageTransaction.cpp +++ b/src/Disks/DiskObjectStorage/DiskObjectStorageTransaction.cpp @@ -544,12 +544,52 @@ void DiskObjectStorageTransaction::copyFileImpl( { for (const auto [src_blob, dst_blob] : std::views::zip(blobs_to_copy, blobs_to_create)) { - runner.enqueueAndKeepTrack( - [this, src_object_storages, src_blob, dst_blob, location, src_local_location, enriched_read_settings, enriched_write_settings] - { - src_object_storages->takePointingTo(src_local_location)->copyObjectToAnotherObjectStorage( - src_blob, dst_blob, *enriched_read_settings, *enriched_write_settings, *object_storages->takePointingTo(location)); - }); + if (src_metadata_storage->isContentAddressed() && src_blob.remote_path.empty()) + { + runner.enqueueAndKeepTrack( + [this, src_metadata_storage, from_file_path, src_blob, dst_blob, location, enriched_write_settings] + { + const String bytes = src_metadata_storage->readInlineDataToString(from_file_path); + if (bytes.size() != src_blob.bytes_size) + throw Exception( + ErrorCodes::LOGICAL_ERROR, + "Inline data of {} has {} bytes, but its metadata reports {}", + from_file_path, + bytes.size(), + src_blob.bytes_size); + + auto out = object_storages->takePointingTo(location)->writeObject( + dst_blob, WriteMode::Rewrite, {}, DBMS_DEFAULT_BUFFER_SIZE, *enriched_write_settings); + out->write(bytes.data(), bytes.size()); + out->finalize(); + }); + } + else + { + const size_t src_object_offset = src_metadata_storage->getObjectPayloadOffset(from_file_path); + + runner.enqueueAndKeepTrack( + [this, + src_object_storages, + src_blob, + dst_blob, + location, + src_local_location, + src_object_offset, + enriched_read_settings, + enriched_write_settings] + { + src_object_storages->takePointingTo(src_local_location) + ->copyObjectToAnotherObjectStorage( + src_blob, + dst_blob, + *enriched_read_settings, + *enriched_write_settings, + *object_storages->takePointingTo(location), + std::nullopt, + src_object_offset); + }); + } } } diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/Cache/MetadataStorageFromCacheObjectStorage.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/Cache/MetadataStorageFromCacheObjectStorage.cpp index 783ebc6f174c..bc5bb66e768b 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/Cache/MetadataStorageFromCacheObjectStorage.cpp +++ b/src/Disks/DiskObjectStorage/MetadataStorages/Cache/MetadataStorageFromCacheObjectStorage.cpp @@ -176,6 +176,11 @@ bool MetadataStorageFromCacheObjectStorage::isContentAddressed() const return underlying->isContentAddressed(); } +size_t MetadataStorageFromCacheObjectStorage::getObjectPayloadOffset(const std::string & path) const +{ + return underlying->getObjectPayloadOffset(path); +} + bool MetadataStorageFromCacheObjectStorage::isTransactional() const { return underlying->isTransactional(); diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/Cache/MetadataStorageFromCacheObjectStorage.h b/src/Disks/DiskObjectStorage/MetadataStorages/Cache/MetadataStorageFromCacheObjectStorage.h index f5e70e1e16ff..15d01ac3234e 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/Cache/MetadataStorageFromCacheObjectStorage.h +++ b/src/Disks/DiskObjectStorage/MetadataStorages/Cache/MetadataStorageFromCacheObjectStorage.h @@ -64,6 +64,7 @@ class MetadataStorageFromCacheObjectStorage : public IMetadataStorage bool isReadOnly() const override; bool isContentAddressed() const override; + size_t getObjectPayloadOffset(const std::string & path) const override; bool isTransactional() const override; bool isPlain() const override; bool isWriteOnce() const override; diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.cpp index 0e88ad24e93e..e3bde0030990 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.cpp +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.cpp @@ -1997,6 +1997,21 @@ StoredObjects ContentAddressedMetadataStorage::getStorageObjects(const std::stri throw Exception(ErrorCodes::FILE_DOESNT_EXIST, "ContentAddressed: file {} not in manifest of {}", r->file, path); } +std::string ContentAddressedMetadataStorage::readInlineDataToString(const std::string & path) const +{ + checkOpAdmitted(CasOpClass::ContentRead); + if (auto bytes = tryGetInManifestBytes(path)) + return std::move(*bytes); + throw Exception(ErrorCodes::LOGICAL_ERROR, "ContentAddressed: {} is not an in-manifest file", path); +} + +size_t ContentAddressedMetadataStorage::getObjectPayloadOffset(const std::string & path) const +{ + if (auto plan = getBlobViewPlan(path)) + return plan->payload_offset; + return 0; +} + std::optional ContentAddressedMetadataStorage::getStorageObjectsIfExist(const std::string & path) const { /// A Vanished disk answers absent (truth). Probe first so the non-part `getStorageObjects` fallback diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.h b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.h index bffb608688cc..10c37ef3206e 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.h +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.h @@ -354,6 +354,8 @@ class ContentAddressedMetadataStorage final : public IMetadataStorage, public IC /// Performs one manifest lookup for part files instead of the inherited `existsFile` plus /// `getStorageObjects` sequence. std::optional getStorageObjectsIfExist(const std::string & path) const override; + std::string readInlineDataToString(const std::string & path) const override; + size_t getObjectPayloadOffset(const std::string & path) const override; /// ==== `IContentAddressedExchange` (interserver relinking facade) ==== const String & getPoolUUID() const override { return pool_uuid; } diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/IMetadataStorage.h b/src/Disks/DiskObjectStorage/MetadataStorages/IMetadataStorage.h index 5b1a6ef6d1b0..39714c26fbaa 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/IMetadataStorage.h +++ b/src/Disks/DiskObjectStorage/MetadataStorages/IMetadataStorage.h @@ -323,6 +323,8 @@ class IMetadataStorage : private boost::noncopyable /// disk transaction delegates writes to the metadata transaction's content-addressed buffer. virtual bool isContentAddressed() const { return false; } + virtual size_t getObjectPayloadOffset(const std::string & /* path */) const { return 0; } + /// [TXN-ONE-PIPELINE] True when a transaction from this storage stages every mutation into a /// transaction-private overlay at call time (eager) rather than queuing effects for FIFO replay in /// commit. When true, DiskObjectStorageTransaction routes every mutating method straight to the diff --git a/src/Disks/DiskObjectStorage/ObjectStorages/Cached/CachedObjectStorage.cpp b/src/Disks/DiskObjectStorage/ObjectStorages/Cached/CachedObjectStorage.cpp index 317e24dac461..cd5fa1a252e5 100644 --- a/src/Disks/DiskObjectStorage/ObjectStorages/Cached/CachedObjectStorage.cpp +++ b/src/Disks/DiskObjectStorage/ObjectStorages/Cached/CachedObjectStorage.cpp @@ -174,9 +174,11 @@ void CachedObjectStorage::copyObjectToAnotherObjectStorage( // NOLINT const ReadSettings & read_settings, const WriteSettings & write_settings, IObjectStorage & object_storage_to, - std::optional object_to_attributes) + std::optional object_to_attributes, + size_t object_from_offset) { - object_storage->copyObjectToAnotherObjectStorage(object_from, object_to, read_settings, write_settings, object_storage_to, object_to_attributes); + object_storage->copyObjectToAnotherObjectStorage( + object_from, object_to, read_settings, write_settings, object_storage_to, object_to_attributes, object_from_offset); } void CachedObjectStorage::copyObject( // NOLINT diff --git a/src/Disks/DiskObjectStorage/ObjectStorages/Cached/CachedObjectStorage.h b/src/Disks/DiskObjectStorage/ObjectStorages/Cached/CachedObjectStorage.h index a4b9e824b64c..5177d7c4199a 100644 --- a/src/Disks/DiskObjectStorage/ObjectStorages/Cached/CachedObjectStorage.h +++ b/src/Disks/DiskObjectStorage/ObjectStorages/Cached/CachedObjectStorage.h @@ -76,7 +76,8 @@ class CachedObjectStorage final : public IObjectStorage const ReadSettings & read_settings, const WriteSettings & write_settings, IObjectStorage & object_storage_to, - std::optional object_to_attributes = {}) override; + std::optional object_to_attributes = {}, + size_t object_from_offset = 0) override; void listObjects(const std::string & path, RelativePathsWithMetadata & children, size_t max_keys) const override; diff --git a/src/Disks/DiskObjectStorage/ObjectStorages/IObjectStorage.cpp b/src/Disks/DiskObjectStorage/ObjectStorages/IObjectStorage.cpp index c763863d1a72..b3e7f9f3a75f 100644 --- a/src/Disks/DiskObjectStorage/ObjectStorages/IObjectStorage.cpp +++ b/src/Disks/DiskObjectStorage/ObjectStorages/IObjectStorage.cpp @@ -117,14 +117,23 @@ void IObjectStorage::copyObjectToAnotherObjectStorage( // NOLINT const ReadSettings & read_settings, const WriteSettings & write_settings, IObjectStorage & object_storage_to, - std::optional object_to_attributes) + std::optional object_to_attributes, + size_t object_from_offset) { - if (&object_storage_to == this) + if (&object_storage_to == this && object_from_offset == 0) copyObject(object_from, object_to, read_settings, write_settings, object_to_attributes); auto in = readObject(object_from, read_settings); auto out = object_storage_to.writeObject(object_to, WriteMode::Rewrite, /* attributes= */ {}, /* buf_size= */ DBMS_DEFAULT_BUFFER_SIZE, write_settings); - copyData(*in, *out); + if (object_from_offset) + { + in->seek(object_from_offset, SEEK_SET); + copyData(*in, *out, object_from.bytes_size); + } + else + { + copyData(*in, *out); + } out->finalize(); } diff --git a/src/Disks/DiskObjectStorage/ObjectStorages/IObjectStorage.h b/src/Disks/DiskObjectStorage/ObjectStorages/IObjectStorage.h index 436f8c05bd51..480fd848bb37 100644 --- a/src/Disks/DiskObjectStorage/ObjectStorages/IObjectStorage.h +++ b/src/Disks/DiskObjectStorage/ObjectStorages/IObjectStorage.h @@ -397,7 +397,8 @@ class IObjectStorage const ReadSettings & read_settings, const WriteSettings & write_settings, IObjectStorage & object_storage_to, - std::optional object_to_attributes = {}); + std::optional object_to_attributes = {}, + size_t object_from_offset = 0); virtual ~IObjectStorage() = default; diff --git a/src/Disks/DiskObjectStorage/ObjectStorages/S3/S3ObjectStorage.cpp b/src/Disks/DiskObjectStorage/ObjectStorages/S3/S3ObjectStorage.cpp index 2f74ad9aa8f8..7a2e5ebbd7d7 100644 --- a/src/Disks/DiskObjectStorage/ObjectStorages/S3/S3ObjectStorage.cpp +++ b/src/Disks/DiskObjectStorage/ObjectStorages/S3/S3ObjectStorage.cpp @@ -969,14 +969,21 @@ void S3ObjectStorage::copyObjectToAnotherObjectStorage( // NOLINT const ReadSettings & read_settings, const WriteSettings & write_settings, IObjectStorage & object_storage_to, - std::optional object_to_attributes) + std::optional object_to_attributes, + size_t object_from_offset) { + if (object_from.remote_path.empty()) + throw Exception( + ErrorCodes::LOGICAL_ERROR, + "Cannot copy {}: it has no object of its own, its bytes must be taken from the metadata", + object_from.local_path); + /// Shortcut for S3 if (auto * dest_s3 = dynamic_cast(&object_storage_to); dest_s3 != nullptr) { auto current_client = dest_s3->client.get(); auto settings_ptr = s3_settings.get(); - auto size = S3::getObjectSize(*client.get(), uri.bucket, object_from.remote_path, {}); + auto size = object_from_offset ? object_from.bytes_size : S3::getObjectSize(*client.get(), uri.bucket, object_from.remote_path, {}); auto scheduler = threadPoolCallbackRunnerUnsafe(getThreadPoolWriter(), ThreadName::S3_COPY_POOL); const auto read_settings_to_use = patchSettings(read_settings); @@ -986,7 +993,8 @@ void S3ObjectStorage::copyObjectToAnotherObjectStorage( // NOLINT /*src_s3_client=*/current_client, /*src_bucket=*/uri.bucket, /*src_key=*/object_from.remote_path, - /*src_offset=*/0, + // @todo + /*src_offset=*/object_from_offset, /*src_size=*/size, /*src_object_offset=*/0, /*dest_s3_client=*/current_client, @@ -1034,7 +1042,8 @@ void S3ObjectStorage::copyObjectToAnotherObjectStorage( // NOLINT ErrorCodes::NOT_IMPLEMENTED, "Native-only object copy requires both object storages to use the native S3 copy path"); - IObjectStorage::copyObjectToAnotherObjectStorage(object_from, object_to, read_settings, write_settings, object_storage_to, object_to_attributes); + IObjectStorage::copyObjectToAnotherObjectStorage( + object_from, object_to, read_settings, write_settings, object_storage_to, object_to_attributes, object_from_offset); } void S3ObjectStorage::copyObject( // NOLINT diff --git a/src/Disks/DiskObjectStorage/ObjectStorages/S3/S3ObjectStorage.h b/src/Disks/DiskObjectStorage/ObjectStorages/S3/S3ObjectStorage.h index 5f5903f2c6aa..e658226b8f6c 100644 --- a/src/Disks/DiskObjectStorage/ObjectStorages/S3/S3ObjectStorage.h +++ b/src/Disks/DiskObjectStorage/ObjectStorages/S3/S3ObjectStorage.h @@ -163,7 +163,8 @@ class S3ObjectStorage : public IObjectStorage const ReadSettings & read_settings, const WriteSettings & write_settings, IObjectStorage & object_storage_to, - std::optional object_to_attributes = {}) override; + std::optional object_to_attributes = {}, + size_t object_from_offset = 0) override; void shutdown() override; diff --git a/tests/integration/test_cas_backup_s3_native_copy/configs/storage_conf.xml b/tests/integration/test_cas_backup_s3_native_copy/configs/storage_conf.xml index f50227cedd3c..f7957f5f70d6 100644 --- a/tests/integration/test_cas_backup_s3_native_copy/configs/storage_conf.xml +++ b/tests/integration/test_cas_backup_s3_native_copy/configs/storage_conf.xml @@ -16,6 +16,32 @@ clickhouse clickhouse + + object_storage + s3 + cas + 30 + 10000 + itest-cas-backup-s3-native-copy-no-multipart + http://rustfs1:11121/test/cas_backup_data_no_multipart/ + clickhouse + clickhouse + 0 + + + s3_plain + http://rustfs1:11121/test/backup_disk_s3_plain/ + clickhouse + clickhouse + + + object_storage + s3 + local + http://rustfs1:11121/test/backup_disk_s3/ + clickhouse + clickhouse + @@ -25,6 +51,17 @@ + + +
+ disk_cas_backup_s3_no_multipart +
+
+
+ + backup_disk_s3_plain + backup_disk_s3 + diff --git a/tests/integration/test_cas_backup_s3_native_copy/test.py b/tests/integration/test_cas_backup_s3_native_copy/test.py index 96900347c830..766bb4797dc5 100644 --- a/tests/integration/test_cas_backup_s3_native_copy/test.py +++ b/tests/integration/test_cas_backup_s3_native_copy/test.py @@ -50,13 +50,13 @@ def backup_destination(name): return f"S3('{S3_AUTHORITY}/test/backups/{RUN_TOKEN}/{name}', {S3_CREDENTIALS})" -def create_and_fill(node, table): +def create_and_fill(node, table, storage_policy=STORAGE_POLICY): node.query(f"DROP TABLE IF EXISTS {table} SYNC") node.query( f""" CREATE TABLE {table} (k UInt64, s String, n Nullable(Int64), arr Array(UInt32)) ENGINE = MergeTree ORDER BY k - SETTINGS storage_policy = '{STORAGE_POLICY}', min_bytes_for_wide_part = 0 + SETTINGS storage_policy = '{storage_policy}', min_bytes_for_wide_part = 0 """ ) node.query( @@ -120,6 +120,85 @@ def test_native_copy_round_trip(allow_native_copy): node.query(f"DROP TABLE {restored} SYNC") +def copy_events(node, query_id): + node.query("SYSTEM FLUSH LOGS query_log") + events = node.query( + f""" + SELECT ProfileEvents['S3UploadPartCopy'], ProfileEvents['S3CopyObject'] + FROM system.query_log + WHERE type = 'QueryFinish' AND query_id = '{query_id}' + ORDER BY event_time DESC LIMIT 1 + """ + ).strip() + assert events, f"no query_log row for {query_id}" + upload_part_copy, copy_object = (int(value) for value in events.split("\t")) + return upload_part_copy, copy_object + + +@pytest.mark.parametrize( + "storage_policy, multipart_copy", + [ + pytest.param(STORAGE_POLICY, True, id="multipart_copy"), + pytest.param("cas_backup_s3_no_multipart", False, id="no_multipart_copy"), + ], +) +@pytest.mark.parametrize("backup_disk", ["backup_disk_s3_plain", "backup_disk_s3"]) +def test_backup_to_disk_on_same_authority(backup_disk, storage_policy, multipart_copy): + node = cluster.instances["node"] + table = f"cas_backup_to_{backup_disk}_{storage_policy}" + restored = f"{table}_restored" + destination = f"Disk('{backup_disk}', '{RUN_TOKEN}/{table}')" + backup_query_id = f"{table}_backup_{RUN_TOKEN}" + restore_query_id = f"{table}_restore_{RUN_TOKEN}" + + create_and_fill(node, table, storage_policy) + expected = column_fingerprints(node, table) + + node.query(f"BACKUP TABLE {table} TO {destination}", query_id=backup_query_id) + + upload_part_copy, copy_object = copy_events(node, backup_query_id) + assert copy_object == 0, "CopyObject has no range: the envelope would land in the backup" + if multipart_copy: + assert upload_part_copy > 0, "blobs were not copied with a ranged server-side copy" + else: + assert upload_part_copy == 0, "multipart copy is disabled but UploadPartCopy still ran" + + node.query(f"DROP TABLE IF EXISTS {restored} SYNC") + node.query( + f"RESTORE TABLE {table} AS {restored} FROM {destination}", + query_id=restore_query_id, + ) + + upload_part_copy, copy_object = copy_events(node, restore_query_id) + assert (upload_part_copy, copy_object) == ( + 0, + 0, + ), "RESTORE onto a CAS disk must write through the CAS path, not copy objects into the pool" + + assert ( + node.query( + f"SELECT storage_policy FROM system.tables WHERE name = '{restored}'" + ).strip() + == storage_policy + ) + + actual = column_fingerprints(node, restored) + + assert actual["count"] == expected["count"] + differing = [c for c in COLUMNS if actual[c] != expected[c]] + assert not differing, f"columns differ after restore: {differing}" + + assert ( + node.query( + f"CHECK TABLE {restored} SETTINGS check_query_single_value_result = 1" + ).strip() + == "1" + ) + + node.query(f"DROP TABLE {table} SYNC") + node.query(f"DROP TABLE {restored} SYNC") + + def test_blobs_use_ranged_copy_and_inline_falls_back(): """A blob is `[envelope][payload]`, so its copy must be ranged: `UploadPartCopy`, never `CopyObject`. Inline entries have no object and must go through buffers. From 11e2cf1a0870b3c5b9a83435d9532526bc7da2fc Mon Sep 17 00:00:00 2001 From: Konstantin Morozov Date: Mon, 28 Sep 2026 13:01:07 +0200 Subject: [PATCH 06/30] update tests Signed-off-by: Konstantin Morozov --- .../test_cas_backup_s3_native_copy/test.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/integration/test_cas_backup_s3_native_copy/test.py b/tests/integration/test_cas_backup_s3_native_copy/test.py index 766bb4797dc5..490f33319668 100644 --- a/tests/integration/test_cas_backup_s3_native_copy/test.py +++ b/tests/integration/test_cas_backup_s3_native_copy/test.py @@ -37,6 +37,7 @@ def start_cluster(): "node", main_configs=["configs/storage_conf.xml"], with_rustfs=True, + with_remote_database_disk=False, stay_alive=True, ) try: @@ -205,10 +206,12 @@ def test_blobs_use_ranged_copy_and_inline_falls_back(): """ node = cluster.instances["node"] table = "cas_backup_mechanism" + restored = f"{table}_restored" destination = backup_destination("mechanism") query_id = f"cas_backup_mechanism_{RUN_TOKEN}" create_and_fill(node, table) + expected = column_fingerprints(node, table) node.query( f"BACKUP TABLE {table} TO {destination} SETTINGS allow_s3_native_copy = 1", query_id=query_id, @@ -228,11 +231,14 @@ def test_blobs_use_ranged_copy_and_inline_falls_back(): assert upload_part_copy > 0, "no ranged server-side copy happened" assert copy_object == 0, "CopyObject has no range: the envelope would land in the backup" - assert node.contains_in_log( - "has no object of its own, copying through buffers" - ), "inline entries did not fall back" + + node.query(f"DROP TABLE IF EXISTS {restored} SYNC") + node.query(f"RESTORE TABLE {table} AS {restored} FROM {destination}") + actual = column_fingerprints(node, restored) + assert actual == expected node.query(f"DROP TABLE {table} SYNC") + node.query(f"DROP TABLE {restored} SYNC") def test_ranged_copy_falls_back_without_multipart(): @@ -273,9 +279,6 @@ def test_ranged_copy_falls_back_without_multipart(): assert upload_part_copy == 0, "multipart copy was disabled but UploadPartCopy still ran" assert copy_object == 0, "a ranged copy fell back to CopyObject, which would take the envelope" assert uploaded > 0, "nothing was uploaded through the server, so nothing was copied at all" - assert node.contains_in_log( - "Ranged native copy needs multipart copy" - ), "the copy did not reach the ranged-copy fallback" node.query(f"DROP TABLE IF EXISTS {restored} SYNC") node.query( From 1fb8ca8386eea16d393985514bdea71c5ea537a5 Mon Sep 17 00:00:00 2001 From: Konstantin Morozov Date: Mon, 28 Sep 2026 14:29:34 +0200 Subject: [PATCH 07/30] use seekable buffer Signed-off-by: Konstantin Morozov --- src/Backups/BackupIO_S3.cpp | 2 +- .../ObjectStorages/S3/S3ObjectStorage.cpp | 16 +++++++++++----- src/IO/S3/copyS3File.cpp | 6 ++++++ 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/Backups/BackupIO_S3.cpp b/src/Backups/BackupIO_S3.cpp index 214bf93eba04..6eeb77175622 100644 --- a/src/Backups/BackupIO_S3.cpp +++ b/src/Backups/BackupIO_S3.cpp @@ -409,7 +409,7 @@ void BackupWriterS3::copyFileFromDisk( /* src_key */ blob_path[0], start_pos, length, - src_object_offset, + /* src_object_offset */ src_object_offset, /* dest_s3_client */ client, /* dest_bucket */ s3_uri.bucket, /* dest_key */ fs::path(s3_uri.key) / path_in_backup, diff --git a/src/Disks/DiskObjectStorage/ObjectStorages/S3/S3ObjectStorage.cpp b/src/Disks/DiskObjectStorage/ObjectStorages/S3/S3ObjectStorage.cpp index 7a2e5ebbd7d7..19a1694e5da6 100644 --- a/src/Disks/DiskObjectStorage/ObjectStorages/S3/S3ObjectStorage.cpp +++ b/src/Disks/DiskObjectStorage/ObjectStorages/S3/S3ObjectStorage.cpp @@ -27,6 +27,7 @@ #include #include #include +#include #include #include @@ -970,7 +971,7 @@ void S3ObjectStorage::copyObjectToAnotherObjectStorage( // NOLINT const WriteSettings & write_settings, IObjectStorage & object_storage_to, std::optional object_to_attributes, - size_t object_from_offset) + const size_t object_from_offset) { if (object_from.remote_path.empty()) throw Exception( @@ -993,10 +994,9 @@ void S3ObjectStorage::copyObjectToAnotherObjectStorage( // NOLINT /*src_s3_client=*/current_client, /*src_bucket=*/uri.bucket, /*src_key=*/object_from.remote_path, - // @todo - /*src_offset=*/object_from_offset, + /*src_offset=*/0, /*src_size=*/size, - /*src_object_offset=*/0, + /*src_object_offset=*/object_from_offset, /*dest_s3_client=*/current_client, /*dest_bucket=*/dest_s3->uri.bucket, /*dest_key=*/object_to.remote_path, @@ -1004,7 +1004,13 @@ void S3ObjectStorage::copyObjectToAnotherObjectStorage( // NOLINT read_settings_to_use, BlobStorageLogWriter::create(disk_name), scheduler, - [&, this]{ return readObject(object_from, read_settings_to_use);}, + [&, this]() -> std::unique_ptr + { + auto object = readObject(object_from, read_settings_to_use); + if (!object_from_offset) + return object; + return std::make_unique(std::move(object), object_from_offset, size); + }, object_to_attributes, write_settings.object_storage_copy_mode); return; diff --git a/src/IO/S3/copyS3File.cpp b/src/IO/S3/copyS3File.cpp index df5429a2a980..645562018fb8 100644 --- a/src/IO/S3/copyS3File.cpp +++ b/src/IO/S3/copyS3File.cpp @@ -894,6 +894,12 @@ void copyS3File( const std::optional & object_metadata, ObjectStorageCopyMode copy_mode) { + if (src_key.empty()) + throw Exception( + ErrorCodes::LOGICAL_ERROR, + "Cannot copy an S3 object with an empty source key from bucket {} to {}/{}", + src_bucket, dest_bucket, dest_key); + if (!dest_s3_client) dest_s3_client = src_s3_client; From 38d8d7c815eaf5da7f3be452777be893d5108390 Mon Sep 17 00:00:00 2001 From: Konstantin Morozov Date: Mon, 28 Sep 2026 17:14:59 +0200 Subject: [PATCH 08/30] separate cas source Signed-off-by: Konstantin Morozov --- src/Backups/BackupIO_S3.cpp | 62 +++++++++++-- src/Disks/ContentAddressedFileCopySource.h | 32 +++++++ src/Disks/DiskEncrypted.h | 6 ++ .../DiskObjectStorage/DiskObjectStorage.cpp | 5 ++ .../DiskObjectStorage/DiskObjectStorage.h | 1 + .../DiskObjectStorageTransaction.cpp | 88 +++++++++++++++---- .../MetadataStorageFromCacheObjectStorage.cpp | 5 ++ .../MetadataStorageFromCacheObjectStorage.h | 1 + .../ContentAddressedMetadataStorage.cpp | 43 +++++++++ .../ContentAddressedMetadataStorage.h | 1 + .../MetadataStorages/IMetadataStorage.h | 6 ++ src/Disks/IDisk.h | 6 ++ src/Disks/ReadOnlyDiskWrapper.h | 1 + 13 files changed, 233 insertions(+), 24 deletions(-) create mode 100644 src/Disks/ContentAddressedFileCopySource.h diff --git a/src/Backups/BackupIO_S3.cpp b/src/Backups/BackupIO_S3.cpp index 6eeb77175622..0a8ecca1cfb9 100644 --- a/src/Backups/BackupIO_S3.cpp +++ b/src/Backups/BackupIO_S3.cpp @@ -388,19 +388,71 @@ void BackupWriterS3::copyFileFromDisk( auto source_data_source_description = src_disk->getDataSourceDescription(); if (source_data_source_description.sameKind(data_source_description) && (source_data_source_description.is_encrypted == copy_encrypted)) { - /// getBlobPath() can return more than 2 elements if the file is stored as multiple objects in S3 bucket. - /// In this case we can't use the native copy. - if (auto blob_path = src_disk->getBlobPath(src_path); blob_path.size() == 2) + if (src_disk->isContentAddressed()) { - LOG_TRACE(log, "Copying file {} from disk {} to S3", src_path, src_disk->getName()); + const auto source = src_disk->getContentAddressedFileCopySource(src_path); + if (!source) + throw Exception(ErrorCodes::LOGICAL_ERROR, "No CAS copy source for {} on disk {}", src_path, src_disk->getName()); - if (src_disk->isContentAddressed() && blob_path[0].empty()) + if (std::holds_alternative(*source)) { LOG_TRACE(log, "File {} has no object of its own, copying through buffers", src_path); BackupWriterDefault::copyFileFromDisk(path_in_backup, src_disk, src_path, copy_encrypted, start_pos, length); return; } + const auto * blob_source = std::get_if(&*source); + const auto & source_object = blob_source + ? blob_source->object + : std::get(*source).object; + const UInt64 source_offset = blob_source ? blob_source->payload_offset : 0; + if (blob_source && (start_pos > blob_source->payload_size || length > blob_source->payload_size - start_pos)) + { + throw Exception( + ErrorCodes::LOGICAL_ERROR, + "Requested range with offset {} and length {} is outside CAS payload of {} bytes for {}", + start_pos, + length, + blob_source->payload_size, + src_path); + } + const auto blob_path = src_disk->getBlobPath(src_path); + if (blob_path.size() != 2 || blob_path[0] != source_object.remote_path) + throw Exception(ErrorCodes::LOGICAL_ERROR, "CAS copy source for {} does not match its blob path on disk {}", src_path, src_disk->getName()); + + LOG_TRACE(log, "Copying file {} from disk {} to S3", src_path, src_disk->getName()); + copyS3File( + disk_client_factory.getOrCreate(src_disk), + blob_path[1], + source_object.remote_path, + start_pos, + length, + source_offset, + client, + s3_uri.bucket, + fs::path(s3_uri.key) / path_in_backup, + s3_settings.request_settings, + read_settings, + blob_storage_log, + threadPoolCallbackRunnerUnsafe(getBackupsIOThreadPool().get(), ThreadName::S3_BACKUP_WRITER), + [&, this] + { + LOG_TRACE(log, "Falling back to copy file {} from disk {} to S3 through buffers", src_path, src_disk->getName()); + + if (copy_encrypted) + return src_disk->readEncryptedFile(src_path, read_settings); + + return src_disk->readFile(src_path, read_settings); + }); + return; + } + + /// getBlobPath() can return more than 2 elements if the file is stored as multiple objects in S3 bucket. + /// In this case we can't use the native copy. + if (auto blob_path = src_disk->getBlobPath(src_path); blob_path.size() == 2) + { + LOG_TRACE(log, "Copying file {} from disk {} to S3", src_path, src_disk->getName()); + const size_t src_object_offset = src_disk->getObjectPayloadOffset(src_path); copyS3File( diff --git a/src/Disks/ContentAddressedFileCopySource.h b/src/Disks/ContentAddressedFileCopySource.h new file mode 100644 index 000000000000..1d844177380e --- /dev/null +++ b/src/Disks/ContentAddressedFileCopySource.h @@ -0,0 +1,32 @@ +#pragma once + +#include + +#include + +namespace DB +{ + +struct ContentAddressedInlineFileCopySource +{ + String data; +}; + +struct ContentAddressedBlobFileCopySource +{ + StoredObject object; + UInt64 payload_offset; + UInt64 payload_size; +}; + +struct ContentAddressedPlainFileCopySource +{ + StoredObject object; +}; + +using ContentAddressedFileCopySource = std::variant< + ContentAddressedInlineFileCopySource, + ContentAddressedBlobFileCopySource, + ContentAddressedPlainFileCopySource>; + +} diff --git a/src/Disks/DiskEncrypted.h b/src/Disks/DiskEncrypted.h index 9640c2e5740c..d5e8dbd006ce 100644 --- a/src/Disks/DiskEncrypted.h +++ b/src/Disks/DiskEncrypted.h @@ -211,6 +211,12 @@ class DiskEncrypted : public IDisk return delegate->getObjectPayloadOffset(wrapped_path); } + std::optional getContentAddressedFileCopySource(const String & path) const override + { + auto wrapped_path = wrappedPath(path); + return delegate->getContentAddressedFileCopySource(wrapped_path); + } + bool areBlobPathsRandom() const override { return delegate->areBlobPathsRandom(); diff --git a/src/Disks/DiskObjectStorage/DiskObjectStorage.cpp b/src/Disks/DiskObjectStorage/DiskObjectStorage.cpp index 284e373412c8..89fbb7002061 100644 --- a/src/Disks/DiskObjectStorage/DiskObjectStorage.cpp +++ b/src/Disks/DiskObjectStorage/DiskObjectStorage.cpp @@ -967,6 +967,11 @@ size_t DiskObjectStorage::getObjectPayloadOffset(const String & path) const return metadata_storage->getObjectPayloadOffset(path); } +std::optional DiskObjectStorage::getContentAddressedFileCopySource(const String & path) const +{ + return metadata_storage->getContentAddressedFileCopySource(path); +} + bool DiskObjectStorage::areBlobPathsRandom() const { return metadata_storage->areBlobPathsRandom(); diff --git a/src/Disks/DiskObjectStorage/DiskObjectStorage.h b/src/Disks/DiskObjectStorage/DiskObjectStorage.h index 95bf6ef366d7..178f728f64f6 100644 --- a/src/Disks/DiskObjectStorage/DiskObjectStorage.h +++ b/src/Disks/DiskObjectStorage/DiskObjectStorage.h @@ -181,6 +181,7 @@ friend class DiskObjectStorageReservation; Strings getBlobPath(const String & path) const override; size_t getObjectPayloadOffset(const String & path) const override; + std::optional getContentAddressedFileCopySource(const String & path) const override; bool areBlobPathsRandom() const override; void writeFileUsingBlobWritingFunction(const String & path, WriteMode mode, WriteBlobFunction && write_blob_function) override; diff --git a/src/Disks/DiskObjectStorage/DiskObjectStorageTransaction.cpp b/src/Disks/DiskObjectStorage/DiskObjectStorageTransaction.cpp index fdb33da8fc6e..d66b83bedeae 100644 --- a/src/Disks/DiskObjectStorage/DiskObjectStorageTransaction.cpp +++ b/src/Disks/DiskObjectStorage/DiskObjectStorageTransaction.cpp @@ -29,6 +29,7 @@ #include #include #include +#include #include namespace ProfileEvents @@ -519,7 +520,25 @@ void DiskObjectStorageTransaction::copyFileImpl( const auto enriched_write_settings = std::make_shared( updateIOSchedulingSettings(write_settings, read_resource_name, write_resource_name)); - const auto blobs_to_copy = src_metadata_storage->getStorageObjects(from_file_path); + const auto content_addressed_source = src_metadata_storage->isContentAddressed() + ? src_metadata_storage->getContentAddressedFileCopySource(from_file_path) + : std::nullopt; + + if (src_metadata_storage->isContentAddressed() && !content_addressed_source) + throw Exception(ErrorCodes::LOGICAL_ERROR, "No CAS copy source for {}", from_file_path); + + const auto blobs_to_copy = content_addressed_source + ? std::visit( + [&](const auto & source) -> StoredObjects + { + using Source = std::decay_t; + if constexpr (std::is_same_v) + return {StoredObject("", from_file_path, source.data.size())}; + else + return {source.object}; + }, + *content_addressed_source) + : src_metadata_storage->getStorageObjects(from_file_path); const auto blobs_to_create = blobs_to_copy | std::views::transform([&](const auto & from) { return StoredObject(metadata_transaction->generateObjectKeyForPath(to_file_path).serialize(), to_file_path, from.bytes_size); }) | std::ranges::to(); @@ -544,30 +563,61 @@ void DiskObjectStorageTransaction::copyFileImpl( { for (const auto [src_blob, dst_blob] : std::views::zip(blobs_to_copy, blobs_to_create)) { - if (src_metadata_storage->isContentAddressed() && src_blob.remote_path.empty()) + if (content_addressed_source) { - runner.enqueueAndKeepTrack( - [this, src_metadata_storage, from_file_path, src_blob, dst_blob, location, enriched_write_settings] + if (const auto * inline_source = std::get_if(&*content_addressed_source)) + { + runner.enqueueAndKeepTrack( + [this, bytes = inline_source->data, dst_blob, location, enriched_write_settings] + { + auto out = object_storages->takePointingTo(location)->writeObject( + dst_blob, WriteMode::Rewrite, {}, DBMS_DEFAULT_BUFFER_SIZE, *enriched_write_settings); + out->write(bytes.data(), bytes.size()); + out->finalize(); + }); + } + else + { + const auto * blob_source = std::get_if(&*content_addressed_source); + const auto & source_object = blob_source + ? blob_source->object + : std::get(*content_addressed_source).object; + const UInt64 source_offset = blob_source ? blob_source->payload_offset : 0; + + if (src_blob != source_object + || source_object.remote_path.empty() + || (blob_source && (blob_source->payload_offset == 0 || blob_source->payload_size != source_object.bytes_size))) { - const String bytes = src_metadata_storage->readInlineDataToString(from_file_path); - if (bytes.size() != src_blob.bytes_size) - throw Exception( - ErrorCodes::LOGICAL_ERROR, - "Inline data of {} has {} bytes, but its metadata reports {}", - from_file_path, - bytes.size(), - src_blob.bytes_size); - - auto out = object_storages->takePointingTo(location)->writeObject( - dst_blob, WriteMode::Rewrite, {}, DBMS_DEFAULT_BUFFER_SIZE, *enriched_write_settings); - out->write(bytes.data(), bytes.size()); - out->finalize(); - }); + throw Exception(ErrorCodes::LOGICAL_ERROR, "Invalid CAS copy source for {}", from_file_path); + } + + runner.enqueueAndKeepTrack( + [this, + src_object_storages, + src_blob, + dst_blob, + location, + src_local_location, + src_object_offset = source_offset, + enriched_read_settings, + enriched_write_settings] + { + src_object_storages->takePointingTo(src_local_location) + ->copyObjectToAnotherObjectStorage( + src_blob, + dst_blob, + *enriched_read_settings, + *enriched_write_settings, + *object_storages->takePointingTo(location), + std::nullopt, + src_object_offset); + }); + } } else { const size_t src_object_offset = src_metadata_storage->getObjectPayloadOffset(from_file_path); - + runner.enqueueAndKeepTrack( [this, src_object_storages, diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/Cache/MetadataStorageFromCacheObjectStorage.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/Cache/MetadataStorageFromCacheObjectStorage.cpp index bc5bb66e768b..5f8ad030e0d0 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/Cache/MetadataStorageFromCacheObjectStorage.cpp +++ b/src/Disks/DiskObjectStorage/MetadataStorages/Cache/MetadataStorageFromCacheObjectStorage.cpp @@ -181,6 +181,11 @@ size_t MetadataStorageFromCacheObjectStorage::getObjectPayloadOffset(const std:: return underlying->getObjectPayloadOffset(path); } +std::optional MetadataStorageFromCacheObjectStorage::getContentAddressedFileCopySource(const std::string & path) const +{ + return underlying->getContentAddressedFileCopySource(path); +} + bool MetadataStorageFromCacheObjectStorage::isTransactional() const { return underlying->isTransactional(); diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/Cache/MetadataStorageFromCacheObjectStorage.h b/src/Disks/DiskObjectStorage/MetadataStorages/Cache/MetadataStorageFromCacheObjectStorage.h index 15d01ac3234e..a01f0bc1ddc2 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/Cache/MetadataStorageFromCacheObjectStorage.h +++ b/src/Disks/DiskObjectStorage/MetadataStorages/Cache/MetadataStorageFromCacheObjectStorage.h @@ -65,6 +65,7 @@ class MetadataStorageFromCacheObjectStorage : public IMetadataStorage bool isReadOnly() const override; bool isContentAddressed() const override; size_t getObjectPayloadOffset(const std::string & path) const override; + std::optional getContentAddressedFileCopySource(const std::string & path) const override; bool isTransactional() const override; bool isPlain() const override; bool isWriteOnce() const override; diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.cpp index e3bde0030990..007b28f733d5 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.cpp +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.cpp @@ -2012,6 +2012,49 @@ size_t ContentAddressedMetadataStorage::getObjectPayloadOffset(const std::string return 0; } +std::optional ContentAddressedMetadataStorage::getContentAddressedFileCopySource( + const std::string & path) const +{ + checkOpAdmitted(CasOpClass::ContentRead); + + if (auto bytes = tryGetInManifestBytes(path)) + return ContentAddressedInlineFileCopySource{.data = std::move(*bytes)}; + + const auto plan = getBlobViewPlan(path); + if (!plan) + { + const auto objects = getStorageObjects(path); + if (objects.size() != 1 || objects.front().remote_path.empty()) + throw Exception(ErrorCodes::LOGICAL_ERROR, "Invalid non-blob CAS copy source for {}", path); + return ContentAddressedPlainFileCopySource{.object = objects.front()}; + } + + const UInt64 payload_offset = plan->payload_offset; + const UInt64 expected_payload_offset = store()->poolMeta().blob_header_len; + + if (plan->object.remote_path.empty() + || payload_offset != expected_payload_offset + || plan->payload_end < payload_offset) + { + throw Exception( + ErrorCodes::LOGICAL_ERROR, + "Invalid CAS copy source for {}: key is {}, payload offset is {}, expected offset is {}, payload end is {}", + path, + plan->object.remote_path.empty() ? "empty" : "set", + payload_offset, + expected_payload_offset, + plan->payload_end); + } + + const UInt64 payload_size = plan->payload_end - payload_offset; + + return ContentAddressedBlobFileCopySource{ + .object = StoredObject(plan->object.remote_path, path, payload_size), + .payload_offset = payload_offset, + .payload_size = payload_size, + }; +} + std::optional ContentAddressedMetadataStorage::getStorageObjectsIfExist(const std::string & path) const { /// A Vanished disk answers absent (truth). Probe first so the non-part `getStorageObjects` fallback diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.h b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.h index 10c37ef3206e..fc6055e6674a 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.h +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.h @@ -356,6 +356,7 @@ class ContentAddressedMetadataStorage final : public IMetadataStorage, public IC std::optional getStorageObjectsIfExist(const std::string & path) const override; std::string readInlineDataToString(const std::string & path) const override; size_t getObjectPayloadOffset(const std::string & path) const override; + std::optional getContentAddressedFileCopySource(const std::string & path) const override; /// ==== `IContentAddressedExchange` (interserver relinking facade) ==== const String & getPoolUUID() const override { return pool_uuid; } diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/IMetadataStorage.h b/src/Disks/DiskObjectStorage/MetadataStorages/IMetadataStorage.h index 39714c26fbaa..000556f28a23 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/IMetadataStorage.h +++ b/src/Disks/DiskObjectStorage/MetadataStorages/IMetadataStorage.h @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -325,6 +326,11 @@ class IMetadataStorage : private boost::noncopyable virtual size_t getObjectPayloadOffset(const std::string & /* path */) const { return 0; } + virtual std::optional getContentAddressedFileCopySource(const std::string & /* path */) const + { + return std::nullopt; + } + /// [TXN-ONE-PIPELINE] True when a transaction from this storage stages every mutation into a /// transaction-private overlay at call time (eager) rather than queuing effects for FIFO replay in /// commit. When true, DiskObjectStorageTransaction routes every mutating method straight to the diff --git a/src/Disks/IDisk.h b/src/Disks/IDisk.h index f1133e0326d5..ab11fc14465a 100644 --- a/src/Disks/IDisk.h +++ b/src/Disks/IDisk.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -321,6 +322,11 @@ class IDisk : public Space /// Where the file's bytes begin inside the object `getBlobPath` names. virtual size_t getObjectPayloadOffset(const String & path) const = 0; + virtual std::optional getContentAddressedFileCopySource(const String & /* path */) const + { + return std::nullopt; + } + /// Returns whether the blob paths this disk uses are randomly generated. virtual bool areBlobPathsRandom() const = 0; diff --git a/src/Disks/ReadOnlyDiskWrapper.h b/src/Disks/ReadOnlyDiskWrapper.h index f82a562ca6b8..b0331baa983e 100644 --- a/src/Disks/ReadOnlyDiskWrapper.h +++ b/src/Disks/ReadOnlyDiskWrapper.h @@ -30,6 +30,7 @@ class ReadOnlyDiskWrapper : public IDisk Strings getBlobPath(const String & path) const override { return delegate->getBlobPath(path); } size_t getObjectPayloadOffset(const String & path) const override { return delegate->getObjectPayloadOffset(path); } + std::optional getContentAddressedFileCopySource(const String & path) const override { return delegate->getContentAddressedFileCopySource(path); } bool areBlobPathsRandom() const override { return delegate->areBlobPathsRandom(); } void writeFileUsingBlobWritingFunction(const String & path, WriteMode mode, WriteBlobFunction && write_blob_function) override { From a6e026f1a67c75a69dad3a95dcce9b1d8194d830 Mon Sep 17 00:00:00 2001 From: Konstantin Morozov Date: Mon, 28 Sep 2026 18:10:31 +0200 Subject: [PATCH 09/30] general checks Signed-off-by: Konstantin Morozov --- src/Backups/BackupIO_S3.cpp | 14 +++--- src/Disks/ContentAddressedFileCopySource.cpp | 48 +++++++++++++++++++ src/Disks/ContentAddressedFileCopySource.h | 14 +++++- .../DiskObjectStorageTransaction.cpp | 16 ++----- 4 files changed, 71 insertions(+), 21 deletions(-) create mode 100644 src/Disks/ContentAddressedFileCopySource.cpp diff --git a/src/Backups/BackupIO_S3.cpp b/src/Backups/BackupIO_S3.cpp index 0a8ecca1cfb9..76dd4ae56bed 100644 --- a/src/Backups/BackupIO_S3.cpp +++ b/src/Backups/BackupIO_S3.cpp @@ -394,26 +394,24 @@ void BackupWriterS3::copyFileFromDisk( if (!source) throw Exception(ErrorCodes::LOGICAL_ERROR, "No CAS copy source for {} on disk {}", src_path, src_disk->getName()); - if (std::holds_alternative(*source)) + const auto window = getContentAddressedObjectWindow(*source, src_path); + if (!window) { LOG_TRACE(log, "File {} has no object of its own, copying through buffers", src_path); BackupWriterDefault::copyFileFromDisk(path_in_backup, src_disk, src_path, copy_encrypted, start_pos, length); return; } - const auto * blob_source = std::get_if(&*source); - const auto & source_object = blob_source - ? blob_source->object - : std::get(*source).object; - const UInt64 source_offset = blob_source ? blob_source->payload_offset : 0; - if (blob_source && (start_pos > blob_source->payload_size || length > blob_source->payload_size - start_pos)) + const auto & source_object = window->object; + const UInt64 source_offset = window->offset; + if (start_pos > source_object.bytes_size || length > source_object.bytes_size - start_pos) { throw Exception( ErrorCodes::LOGICAL_ERROR, "Requested range with offset {} and length {} is outside CAS payload of {} bytes for {}", start_pos, length, - blob_source->payload_size, + source_object.bytes_size, src_path); } const auto blob_path = src_disk->getBlobPath(src_path); diff --git a/src/Disks/ContentAddressedFileCopySource.cpp b/src/Disks/ContentAddressedFileCopySource.cpp new file mode 100644 index 000000000000..061c604a5712 --- /dev/null +++ b/src/Disks/ContentAddressedFileCopySource.cpp @@ -0,0 +1,48 @@ +#include + +#include + +namespace DB +{ + +namespace ErrorCodes +{ + extern const int LOGICAL_ERROR; +} + +std::optional getContentAddressedObjectWindow( + const ContentAddressedFileCopySource & source, const String & path) +{ + if (std::holds_alternative(source)) + return std::nullopt; + + ContentAddressedObjectWindow window; + if (const auto * blob = std::get_if(&source)) + { + if (blob->payload_offset == 0 || blob->payload_size != blob->object.bytes_size) + throw Exception( + ErrorCodes::LOGICAL_ERROR, + "Invalid CAS blob copy source for {}: payload offset {}, payload size {}, object size {}", + path, + blob->payload_offset, + blob->payload_size, + blob->object.bytes_size); + + window = {blob->object, blob->payload_offset}; + } + else if (const auto * plain = std::get_if(&source)) + { + window = {plain->object, 0}; + } + else + { + throw Exception(ErrorCodes::LOGICAL_ERROR, "Unhandled CAS copy source for {}", path); + } + + if (window.object.remote_path.empty()) + throw Exception(ErrorCodes::LOGICAL_ERROR, "CAS copy source for {} has an empty object key", path); + + return window; +} + +} diff --git a/src/Disks/ContentAddressedFileCopySource.h b/src/Disks/ContentAddressedFileCopySource.h index 1d844177380e..fdee8bd46062 100644 --- a/src/Disks/ContentAddressedFileCopySource.h +++ b/src/Disks/ContentAddressedFileCopySource.h @@ -2,6 +2,7 @@ #include +#include #include namespace DB @@ -15,8 +16,8 @@ struct ContentAddressedInlineFileCopySource struct ContentAddressedBlobFileCopySource { StoredObject object; - UInt64 payload_offset; - UInt64 payload_size; + UInt64 payload_offset = 0; + UInt64 payload_size = 0; }; struct ContentAddressedPlainFileCopySource @@ -29,4 +30,13 @@ using ContentAddressedFileCopySource = std::variant< ContentAddressedBlobFileCopySource, ContentAddressedPlainFileCopySource>; +struct ContentAddressedObjectWindow +{ + StoredObject object; + UInt64 offset = 0; +}; + +std::optional getContentAddressedObjectWindow( + const ContentAddressedFileCopySource & source, const String & path); + } diff --git a/src/Disks/DiskObjectStorage/DiskObjectStorageTransaction.cpp b/src/Disks/DiskObjectStorage/DiskObjectStorageTransaction.cpp index d66b83bedeae..7d173c743a21 100644 --- a/src/Disks/DiskObjectStorage/DiskObjectStorageTransaction.cpp +++ b/src/Disks/DiskObjectStorage/DiskObjectStorageTransaction.cpp @@ -539,6 +539,7 @@ void DiskObjectStorageTransaction::copyFileImpl( }, *content_addressed_source) : src_metadata_storage->getStorageObjects(from_file_path); + const auto blobs_to_create = blobs_to_copy | std::views::transform([&](const auto & from) { return StoredObject(metadata_transaction->generateObjectKeyForPath(to_file_path).serialize(), to_file_path, from.bytes_size); }) | std::ranges::to(); @@ -578,18 +579,11 @@ void DiskObjectStorageTransaction::copyFileImpl( } else { - const auto * blob_source = std::get_if(&*content_addressed_source); - const auto & source_object = blob_source - ? blob_source->object - : std::get(*content_addressed_source).object; - const UInt64 source_offset = blob_source ? blob_source->payload_offset : 0; - - if (src_blob != source_object - || source_object.remote_path.empty() - || (blob_source && (blob_source->payload_offset == 0 || blob_source->payload_size != source_object.bytes_size))) - { + const auto window = getContentAddressedObjectWindow(*content_addressed_source, from_file_path); + if (!window || src_blob != window->object) throw Exception(ErrorCodes::LOGICAL_ERROR, "Invalid CAS copy source for {}", from_file_path); - } + + const UInt64 source_offset = window->offset; runner.enqueueAndKeepTrack( [this, From 24c9f87a9319deb39cec6ec359f545284b821d39 Mon Sep 17 00:00:00 2001 From: Konstantin Morozov Date: Mon, 28 Sep 2026 18:50:29 +0200 Subject: [PATCH 10/30] use visit Signed-off-by: Konstantin Morozov --- src/Backups/BackupIO_S3.cpp | 29 +++++- src/Disks/ContentAddressedFileCopySource.cpp | 48 ---------- src/Disks/ContentAddressedFileCopySource.h | 18 +--- .../DiskObjectStorageTransaction.cpp | 94 +++++++++++-------- .../ContentAddressedMetadataStorage.cpp | 7 +- 5 files changed, 80 insertions(+), 116 deletions(-) delete mode 100644 src/Disks/ContentAddressedFileCopySource.cpp diff --git a/src/Backups/BackupIO_S3.cpp b/src/Backups/BackupIO_S3.cpp index 76dd4ae56bed..ee864c9d72fc 100644 --- a/src/Backups/BackupIO_S3.cpp +++ b/src/Backups/BackupIO_S3.cpp @@ -21,6 +21,7 @@ #include #include +#include namespace fs = std::filesystem; @@ -394,16 +395,34 @@ void BackupWriterS3::copyFileFromDisk( if (!source) throw Exception(ErrorCodes::LOGICAL_ERROR, "No CAS copy source for {} on disk {}", src_path, src_disk->getName()); - const auto window = getContentAddressedObjectWindow(*source, src_path); - if (!window) + const auto * blob_source = std::visit( + [](const Source & copy_source) -> const ContentAddressedBlobFileCopySource * + { + if constexpr (std::is_same_v) + return nullptr; + else if constexpr (std::is_same_v) + return ©_source; + else + static_assert(std::is_same_v); + }, + *source); + + if (!blob_source) { LOG_TRACE(log, "File {} has no object of its own, copying through buffers", src_path); BackupWriterDefault::copyFileFromDisk(path_in_backup, src_disk, src_path, copy_encrypted, start_pos, length); return; } - const auto & source_object = window->object; - const UInt64 source_offset = window->offset; + if (blob_source->object.remote_path.empty() + || blob_source->payload_offset == 0 + || blob_source->payload_size != blob_source->object.bytes_size) + { + throw Exception(ErrorCodes::LOGICAL_ERROR, "Invalid CAS blob copy source for {}", src_path); + } + + const auto & source_object = blob_source->object; + const UInt64 source_offset = blob_source->payload_offset; if (start_pos > source_object.bytes_size || length > source_object.bytes_size - start_pos) { throw Exception( @@ -411,7 +430,7 @@ void BackupWriterS3::copyFileFromDisk( "Requested range with offset {} and length {} is outside CAS payload of {} bytes for {}", start_pos, length, - source_object.bytes_size, + blob_source->payload_size, src_path); } const auto blob_path = src_disk->getBlobPath(src_path); diff --git a/src/Disks/ContentAddressedFileCopySource.cpp b/src/Disks/ContentAddressedFileCopySource.cpp deleted file mode 100644 index 061c604a5712..000000000000 --- a/src/Disks/ContentAddressedFileCopySource.cpp +++ /dev/null @@ -1,48 +0,0 @@ -#include - -#include - -namespace DB -{ - -namespace ErrorCodes -{ - extern const int LOGICAL_ERROR; -} - -std::optional getContentAddressedObjectWindow( - const ContentAddressedFileCopySource & source, const String & path) -{ - if (std::holds_alternative(source)) - return std::nullopt; - - ContentAddressedObjectWindow window; - if (const auto * blob = std::get_if(&source)) - { - if (blob->payload_offset == 0 || blob->payload_size != blob->object.bytes_size) - throw Exception( - ErrorCodes::LOGICAL_ERROR, - "Invalid CAS blob copy source for {}: payload offset {}, payload size {}, object size {}", - path, - blob->payload_offset, - blob->payload_size, - blob->object.bytes_size); - - window = {blob->object, blob->payload_offset}; - } - else if (const auto * plain = std::get_if(&source)) - { - window = {plain->object, 0}; - } - else - { - throw Exception(ErrorCodes::LOGICAL_ERROR, "Unhandled CAS copy source for {}", path); - } - - if (window.object.remote_path.empty()) - throw Exception(ErrorCodes::LOGICAL_ERROR, "CAS copy source for {} has an empty object key", path); - - return window; -} - -} diff --git a/src/Disks/ContentAddressedFileCopySource.h b/src/Disks/ContentAddressedFileCopySource.h index fdee8bd46062..4c0df4760d23 100644 --- a/src/Disks/ContentAddressedFileCopySource.h +++ b/src/Disks/ContentAddressedFileCopySource.h @@ -2,7 +2,6 @@ #include -#include #include namespace DB @@ -20,23 +19,8 @@ struct ContentAddressedBlobFileCopySource UInt64 payload_size = 0; }; -struct ContentAddressedPlainFileCopySource -{ - StoredObject object; -}; - using ContentAddressedFileCopySource = std::variant< ContentAddressedInlineFileCopySource, - ContentAddressedBlobFileCopySource, - ContentAddressedPlainFileCopySource>; - -struct ContentAddressedObjectWindow -{ - StoredObject object; - UInt64 offset = 0; -}; - -std::optional getContentAddressedObjectWindow( - const ContentAddressedFileCopySource & source, const String & path); + ContentAddressedBlobFileCopySource>; } diff --git a/src/Disks/DiskObjectStorage/DiskObjectStorageTransaction.cpp b/src/Disks/DiskObjectStorage/DiskObjectStorageTransaction.cpp index 7d173c743a21..fb6f7b55f22d 100644 --- a/src/Disks/DiskObjectStorage/DiskObjectStorageTransaction.cpp +++ b/src/Disks/DiskObjectStorage/DiskObjectStorageTransaction.cpp @@ -534,8 +534,10 @@ void DiskObjectStorageTransaction::copyFileImpl( using Source = std::decay_t; if constexpr (std::is_same_v) return {StoredObject("", from_file_path, source.data.size())}; - else + else if constexpr (std::is_same_v) return {source.object}; + else + static_assert(std::is_same_v); }, *content_addressed_source) : src_metadata_storage->getStorageObjects(from_file_path); @@ -566,47 +568,59 @@ void DiskObjectStorageTransaction::copyFileImpl( { if (content_addressed_source) { - if (const auto * inline_source = std::get_if(&*content_addressed_source)) - { - runner.enqueueAndKeepTrack( - [this, bytes = inline_source->data, dst_blob, location, enriched_write_settings] + std::visit( + [&](const auto & copy_source) + { + using Source = std::decay_t; + if constexpr (std::is_same_v) { - auto out = object_storages->takePointingTo(location)->writeObject( - dst_blob, WriteMode::Rewrite, {}, DBMS_DEFAULT_BUFFER_SIZE, *enriched_write_settings); - out->write(bytes.data(), bytes.size()); - out->finalize(); - }); - } - else - { - const auto window = getContentAddressedObjectWindow(*content_addressed_source, from_file_path); - if (!window || src_blob != window->object) - throw Exception(ErrorCodes::LOGICAL_ERROR, "Invalid CAS copy source for {}", from_file_path); - - const UInt64 source_offset = window->offset; - - runner.enqueueAndKeepTrack( - [this, - src_object_storages, - src_blob, - dst_blob, - location, - src_local_location, - src_object_offset = source_offset, - enriched_read_settings, - enriched_write_settings] + runner.enqueueAndKeepTrack( + [this, bytes = copy_source.data, dst_blob, location, enriched_write_settings] + { + auto out = object_storages->takePointingTo(location)->writeObject( + dst_blob, WriteMode::Rewrite, {}, DBMS_DEFAULT_BUFFER_SIZE, *enriched_write_settings); + out->write(bytes.data(), bytes.size()); + out->finalize(); + }); + } + else if constexpr (std::is_same_v) { - src_object_storages->takePointingTo(src_local_location) - ->copyObjectToAnotherObjectStorage( - src_blob, - dst_blob, - *enriched_read_settings, - *enriched_write_settings, - *object_storages->takePointingTo(location), - std::nullopt, - src_object_offset); - }); - } + if (src_blob != copy_source.object + || copy_source.object.remote_path.empty() + || copy_source.payload_offset == 0 + || copy_source.payload_size != copy_source.object.bytes_size) + { + throw Exception(ErrorCodes::LOGICAL_ERROR, "Invalid CAS copy source for {}", from_file_path); + } + + runner.enqueueAndKeepTrack( + [this, + src_object_storages, + src_blob, + dst_blob, + location, + src_local_location, + src_object_offset = copy_source.payload_offset, + enriched_read_settings, + enriched_write_settings] + { + src_object_storages->takePointingTo(src_local_location) + ->copyObjectToAnotherObjectStorage( + src_blob, + dst_blob, + *enriched_read_settings, + *enriched_write_settings, + *object_storages->takePointingTo(location), + std::nullopt, + src_object_offset); + }); + } + else + { + static_assert(std::is_same_v); + } + }, + *content_addressed_source); } else { diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.cpp index 007b28f733d5..70610f0b6720 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.cpp +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.cpp @@ -2022,12 +2022,7 @@ std::optional ContentAddressedMetadataStorage::g const auto plan = getBlobViewPlan(path); if (!plan) - { - const auto objects = getStorageObjects(path); - if (objects.size() != 1 || objects.front().remote_path.empty()) - throw Exception(ErrorCodes::LOGICAL_ERROR, "Invalid non-blob CAS copy source for {}", path); - return ContentAddressedPlainFileCopySource{.object = objects.front()}; - } + return std::nullopt; const UInt64 payload_offset = plan->payload_offset; const UInt64 expected_payload_offset = store()->poolMeta().blob_header_len; From 0dedc56d9e94c1897980aeada5ddaf2a6a00f5a2 Mon Sep 17 00:00:00 2001 From: Konstantin Morozov Date: Wed, 30 Sep 2026 16:37:10 +0200 Subject: [PATCH 11/30] test: MOVE PARTITION out of a CAS disk with an empty-array column A column of all-empty `Array` values produces a zero-size `.bin`. Placement keys on the file name, so `partFileMustStayBlob` keeps it a blob, and a ranged server-side copy of a blob reaches `calculatePartSize(0)`, which throws. `BACKUP` never meets such a file when files are deduplicated, but `IDisk::copyFile` does, so `MOVE PARTITION` out of a CAS disk hits it. Co-Authored-By: Claude Opus 5 --- .../configs/storage_conf.xml | 10 +++++ .../test_cas_backup_s3_native_copy/test.py | 45 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/tests/integration/test_cas_backup_s3_native_copy/configs/storage_conf.xml b/tests/integration/test_cas_backup_s3_native_copy/configs/storage_conf.xml index f7957f5f70d6..5f5acbbedaac 100644 --- a/tests/integration/test_cas_backup_s3_native_copy/configs/storage_conf.xml +++ b/tests/integration/test_cas_backup_s3_native_copy/configs/storage_conf.xml @@ -58,6 +58,16 @@ + + +
+ disk_cas_backup_s3 +
+ + backup_disk_s3 + +
+
diff --git a/tests/integration/test_cas_backup_s3_native_copy/test.py b/tests/integration/test_cas_backup_s3_native_copy/test.py index 490f33319668..fdf413fb99c4 100644 --- a/tests/integration/test_cas_backup_s3_native_copy/test.py +++ b/tests/integration/test_cas_backup_s3_native_copy/test.py @@ -291,3 +291,48 @@ def test_ranged_copy_falls_back_without_multipart(): node.query(f"DROP TABLE {table} SYNC") node.query(f"DROP TABLE {restored} SYNC") + + +def test_move_partition_out_of_cas_with_empty_arrays(): + """A zero-size `.bin` still becomes a blob: `partFileMustStayBlob` keys on the file name, not + the size. A ranged copy of it reaches `calculatePartSize(0)`, which throws. + """ + node = cluster.instances["node"] + table = "cas_move_empty_arrays" + + node.query(f"DROP TABLE IF EXISTS {table} SYNC") + node.query( + f""" + CREATE TABLE {table} (k UInt64, s String, empty Array(UInt32)) + ENGINE = MergeTree ORDER BY k + SETTINGS storage_policy = 'cas_then_plain', min_bytes_for_wide_part = 0 + """ + ) + node.query( + f""" + INSERT INTO {table} + SELECT number, randomPrintableASCII(64), [] + FROM numbers({NUM_ROWS}) + """ + ) + + expected = node.query( + f"SELECT count(), sum(cityHash64(s)), sum(length(empty)) FROM {table}" + ).strip() + + node.query(f"ALTER TABLE {table} MOVE PARTITION tuple() TO DISK 'backup_disk_s3'") + + assert ( + node.query( + f"SELECT count(), sum(cityHash64(s)), sum(length(empty)) FROM {table}" + ).strip() + == expected + ) + assert ( + node.query( + f"CHECK TABLE {table} SETTINGS check_query_single_value_result = 1" + ).strip() + == "1" + ) + + node.query(f"DROP TABLE {table} SYNC") From dbaa40640834549e152a421a2f07da63dae81a2c Mon Sep 17 00:00:00 2001 From: Konstantin Morozov Date: Wed, 30 Sep 2026 16:39:31 +0200 Subject: [PATCH 12/30] Gate native copy on a positive whole-object capability `DataSourceDescription::operator==` and `sameKind` compare the storage kind and the endpoint, so a content-addressed disk looks identical to a plain s3 disk on the same endpoint. Every server-side copy path then assumes a file is its whole object starting at byte 0, which is false on a CAS disk: a blob-backed file is a payload window behind a fixed-size envelope, and an inline file has no object of its own. Add `files_are_whole_objects` to `DataSourceDescription` and a predicate `canUseNativeCopyWith` that requires it from BOTH sides. A precondition holds on each side separately, so it is a conjunction rather than a comparison: two disks that both lack the property are not thereby able to use it. `operator==` and `sameKind` keep their meaning and stay reflexive, so callers that ask about disk identity are unaffected. The field is last in the struct because four call sites brace-initialize `DataSourceDescription` positionally. `BackupIO_AzureBlobStorage` does not use `sameKind` - it compares `object_storage_type` directly - so both of its conditions check the capability explicitly. This closes a reachable configuration rather than only future code: a read-only CAS mount over Azure is constructible, because `Pool::open` skips the conditional-write capability probe when the pool is opened read-only, and the Azure native path copies a whole blob with no range. Co-Authored-By: Claude Opus 5 --- src/Backups/BackupIO_AzureBlobStorage.cpp | 12 ++++++++---- src/Backups/BackupIO_S3.cpp | 8 ++++---- src/Disks/DiskLocal.cpp | 1 + src/Disks/DiskObjectStorage/DiskObjectStorage.cpp | 3 ++- src/Disks/DiskType.cpp | 10 ++++++++-- src/Disks/DiskType.h | 3 +++ 6 files changed, 26 insertions(+), 11 deletions(-) diff --git a/src/Backups/BackupIO_AzureBlobStorage.cpp b/src/Backups/BackupIO_AzureBlobStorage.cpp index cdd7b3ca326a..693b063c705c 100644 --- a/src/Backups/BackupIO_AzureBlobStorage.cpp +++ b/src/Backups/BackupIO_AzureBlobStorage.cpp @@ -37,7 +37,7 @@ BackupReaderAzureBlobStorage::BackupReaderAzureBlobStorage( const WriteSettings & write_settings_, const ContextPtr & context_) : BackupReaderDefault(read_settings_, write_settings_, getLogger("BackupReaderAzureBlobStorage")) - , data_source_description{DataSourceType::ObjectStorage, ObjectStorageType::Azure, MetadataStorageType::None, connection_params_.getConnectionURL(), false, false, ""} + , data_source_description{DataSourceType::ObjectStorage, ObjectStorageType::Azure, MetadataStorageType::None, connection_params_.getConnectionURL(), false, false, "", true} , connection_params(connection_params_) , blob_path(blob_path_) { @@ -87,7 +87,9 @@ void BackupReaderAzureBlobStorage::copyFileToDisk(const String & path_in_backup, auto destination_data_source_description = destination_disk->getDataSourceDescription(); LOG_TRACE(log, "Source description {}, destination description {}", data_source_description.description, destination_data_source_description.description); if (destination_data_source_description.object_storage_type == ObjectStorageType::Azure - && destination_data_source_description.is_encrypted == encrypted_in_backup) + && destination_data_source_description.is_encrypted == encrypted_in_backup + && destination_data_source_description.files_are_whole_objects + && data_source_description.files_are_whole_objects) { LOG_TRACE(log, "Copying {} from AzureBlobStorage to disk {}", path_in_backup, destination_disk->getName()); auto write_blob_function = [&](const Strings & dst_blob_path, WriteMode mode, const std::optional &) -> size_t @@ -133,7 +135,7 @@ BackupWriterAzureBlobStorage::BackupWriterAzureBlobStorage( const ContextPtr & context_, bool attempt_to_create_container) : BackupWriterDefault(read_settings_, write_settings_, getLogger("BackupWriterAzureBlobStorage")) - , data_source_description{DataSourceType::ObjectStorage, ObjectStorageType::Azure, MetadataStorageType::None, connection_params_.getConnectionURL(), false, false, ""} + , data_source_description{DataSourceType::ObjectStorage, ObjectStorageType::Azure, MetadataStorageType::None, connection_params_.getConnectionURL(), false, false, "", true} , connection_params(connection_params_) , blob_path(blob_path_) { @@ -165,7 +167,9 @@ void BackupWriterAzureBlobStorage::copyFileFromDisk( auto source_data_source_description = src_disk->getDataSourceDescription(); LOG_TRACE(log, "Source description {}, destination description {}", source_data_source_description.description, data_source_description.description); if (source_data_source_description.object_storage_type == ObjectStorageType::Azure - && source_data_source_description.is_encrypted == copy_encrypted) + && source_data_source_description.is_encrypted == copy_encrypted + && source_data_source_description.files_are_whole_objects + && data_source_description.files_are_whole_objects) { /// getBlobPath() can return more than 2 elements if the file is stored as multiple objects in AzureBlobStorage container. /// In this case we can't use the native copy. diff --git a/src/Backups/BackupIO_S3.cpp b/src/Backups/BackupIO_S3.cpp index ee864c9d72fc..56cd99812632 100644 --- a/src/Backups/BackupIO_S3.cpp +++ b/src/Backups/BackupIO_S3.cpp @@ -257,7 +257,7 @@ BackupReaderS3::BackupReaderS3( bool is_internal_backup) : BackupReaderDefault(read_settings_, write_settings_, getLogger("BackupReaderS3")) , s3_uri(s3_uri_) - , data_source_description{DataSourceType::ObjectStorage, ObjectStorageType::S3, MetadataStorageType::None, s3_uri.endpoint, false, false, ""} + , data_source_description{DataSourceType::ObjectStorage, ObjectStorageType::S3, MetadataStorageType::None, s3_uri.endpoint, false, false, "", true} { s3_settings.loadFromConfig(context_->getConfigRef(), "s3", context_->getSettingsRef()); @@ -300,7 +300,7 @@ void BackupReaderS3::copyFileToDisk(const String & path_in_backup, size_t file_s /// Use the native copy as a more optimal way to copy a file from S3 to S3 if it's possible. /// We don't check for `has_throttling` here because the native copy almost doesn't use network. auto destination_data_source_description = destination_disk->getDataSourceDescription(); - if (destination_data_source_description.sameKind(data_source_description) + if (destination_data_source_description.canUseNativeCopyWith(data_source_description) && (destination_data_source_description.is_encrypted == encrypted_in_backup)) { LOG_TRACE(log, "Copying {} from S3 to disk {}", path_in_backup, destination_disk->getName()); @@ -355,7 +355,7 @@ BackupWriterS3::BackupWriterS3( bool is_internal_backup) : BackupWriterDefault(read_settings_, write_settings_, getLogger("BackupWriterS3")) , s3_uri(s3_uri_) - , data_source_description{DataSourceType::ObjectStorage, ObjectStorageType::S3, MetadataStorageType::None, s3_uri.endpoint, false, false, ""} + , data_source_description{DataSourceType::ObjectStorage, ObjectStorageType::S3, MetadataStorageType::None, s3_uri.endpoint, false, false, "", true} , s3_capabilities(getCapabilitiesFromConfig(context_->getConfigRef(), "s3")) , disk_client_factory(S3BackupClientCreator(context_)) { @@ -387,7 +387,7 @@ void BackupWriterS3::copyFileFromDisk( /// Use the native copy as a more optimal way to copy a file from S3 to S3 if it's possible. /// We don't check for `has_throttling` here because the native copy almost doesn't use network. auto source_data_source_description = src_disk->getDataSourceDescription(); - if (source_data_source_description.sameKind(data_source_description) && (source_data_source_description.is_encrypted == copy_encrypted)) + if (source_data_source_description.canUseNativeCopyWith(data_source_description) && (source_data_source_description.is_encrypted == copy_encrypted)) { if (src_disk->isContentAddressed()) { diff --git a/src/Disks/DiskLocal.cpp b/src/Disks/DiskLocal.cpp index 9eb3ef2ba3a0..920c7cc6089b 100644 --- a/src/Disks/DiskLocal.cpp +++ b/src/Disks/DiskLocal.cpp @@ -663,6 +663,7 @@ DataSourceDescription DiskLocal::getLocalDataSourceDescription(const String & pa res.description = path; res.is_encrypted = false; res.is_cached = false; + res.files_are_whole_objects = true; return res; } diff --git a/src/Disks/DiskObjectStorage/DiskObjectStorage.cpp b/src/Disks/DiskObjectStorage/DiskObjectStorage.cpp index 89fbb7002061..96d823554b7c 100644 --- a/src/Disks/DiskObjectStorage/DiskObjectStorage.cpp +++ b/src/Disks/DiskObjectStorage/DiskObjectStorage.cpp @@ -129,6 +129,7 @@ DiskObjectStorage::DiskObjectStorage( .is_encrypted = false, .is_cached = object_storages->takePointingTo(cluster->getLocalLocation())->supportsCache(), .zookeeper_name = metadata_storage->getZooKeeperName(), + .files_are_whole_objects = !metadata_storage->isContentAddressed(), }; resource_changes_subscription = Context::getGlobalContextInstance()->getWorkloadEntityStoragePtr()->getAllEntitiesAndSubscribe( [this] (const std::vector & events) @@ -298,7 +299,7 @@ void DiskObjectStorage::copyFile( /// NOLINT const std::function & cancellation_hook) { auto component_guard = Coordination::setCurrentComponent("DiskObjectStorage::copyFile"); - if (getDataSourceDescription() == to_disk.getDataSourceDescription()) + if (getDataSourceDescription().canUseNativeCopyWith(to_disk.getDataSourceDescription())) { /// It may use s3-server-side copy auto & to_disk_object_storage = dynamic_cast(to_disk); diff --git a/src/Disks/DiskType.cpp b/src/Disks/DiskType.cpp index 6af38a7fa6b8..177e704b275c 100644 --- a/src/Disks/DiskType.cpp +++ b/src/Disks/DiskType.cpp @@ -51,6 +51,11 @@ bool DataSourceDescription::sameKind(const DataSourceDescription & other) const == std::tie(other.type, other.object_storage_type, other_description); } +bool DataSourceDescription::canUseNativeCopyWith(const DataSourceDescription & other) const +{ + return files_are_whole_objects && other.files_are_whole_objects && sameKind(other); +} + String DataSourceDescription::name() const { switch (type) @@ -66,8 +71,9 @@ String DataSourceDescription::name() const String DataSourceDescription::toString() const { - return fmt::format("{} (description = '{}', is_encrypted = {}, is_cached = {}, zookeeper_name = '{}')", - name(), description, is_encrypted, is_cached, zookeeper_name); + return fmt::format( + "{} (description = '{}', is_encrypted = {}, is_cached = {}, zookeeper_name = '{}', files_are_whole_objects = {})", + name(), description, is_encrypted, is_cached, zookeeper_name, files_are_whole_objects); } ObjectStorageType objectStorageTypeFromString(const std::string & type) diff --git a/src/Disks/DiskType.h b/src/Disks/DiskType.h index 003395b3a9d4..ee39b23c7029 100644 --- a/src/Disks/DiskType.h +++ b/src/Disks/DiskType.h @@ -55,8 +55,11 @@ struct DataSourceDescription String zookeeper_name; + bool files_are_whole_objects = false; + bool operator==(const DataSourceDescription & other) const; bool sameKind(const DataSourceDescription & other) const; + bool canUseNativeCopyWith(const DataSourceDescription & other) const; String name() const; From b1050f4a1b0b908901206795e0a18b98cff46c91 Mon Sep 17 00:00:00 2001 From: Konstantin Morozov Date: Wed, 30 Sep 2026 18:02:03 +0200 Subject: [PATCH 13/30] apply comments Signed-off-by: Konstantin Morozov --- src/Backups/BackupIO_S3.cpp | 155 +++++++++--------- src/Backups/BackupIO_S3.h | 10 ++ src/Disks/ContentAddressedFileCopySource.h | 26 --- src/Disks/DiskBackup.cpp | 5 - src/Disks/DiskBackup.h | 1 - src/Disks/DiskEncrypted.h | 12 -- src/Disks/DiskLocal.cpp | 5 - src/Disks/DiskLocal.h | 1 - .../DiskObjectStorage/DiskObjectStorage.cpp | 10 -- .../DiskObjectStorage/DiskObjectStorage.h | 2 - .../DiskObjectStorageTransaction.cpp | 112 +------------ .../MetadataStorageFromCacheObjectStorage.cpp | 10 -- .../MetadataStorageFromCacheObjectStorage.h | 2 - .../ContentAddressedExchange.cpp | 9 + .../ContentAddressedExchange.h | 3 + .../ContentAddressedMetadataStorage.cpp | 55 +------ .../ContentAddressedMetadataStorage.h | 3 - .../MetadataStorages/IMetadataStorage.h | 8 - .../Cached/CachedObjectStorage.cpp | 6 +- .../Cached/CachedObjectStorage.h | 3 +- .../ObjectStorages/IObjectStorage.cpp | 15 +- .../ObjectStorages/IObjectStorage.h | 3 +- .../ObjectStorages/S3/S3ObjectStorage.cpp | 25 +-- .../ObjectStorages/S3/S3ObjectStorage.h | 3 +- src/Disks/IDisk.h | 9 - src/Disks/ReadOnlyDiskWrapper.h | 2 - src/IO/S3/copyS3File.cpp | 28 ++-- src/IO/S3/copyS3File.h | 1 - src/Storages/MergeTree/DataPartsExchange.cpp | 11 -- .../ObjectStorageQueuePostProcessor.cpp | 1 - .../test_cas_backup_s3_native_copy/test.py | 9 +- 31 files changed, 137 insertions(+), 408 deletions(-) delete mode 100644 src/Disks/ContentAddressedFileCopySource.h diff --git a/src/Backups/BackupIO_S3.cpp b/src/Backups/BackupIO_S3.cpp index 56cd99812632..ab013a36da8b 100644 --- a/src/Backups/BackupIO_S3.cpp +++ b/src/Backups/BackupIO_S3.cpp @@ -15,13 +15,14 @@ #include #include #include +#include +#include #include #include #include -#include namespace fs = std::filesystem; @@ -318,7 +319,6 @@ void BackupReaderS3::copyFileToDisk(const String & path_in_backup, size_t file_s fs::path(s3_uri.key) / path_in_backup, 0, file_size, - /* src_object_offset= */ 0, /* dest_s3_client= */ destination_disk->getS3StorageClient(), /* dest_bucket= */ blob_path[1], /* dest_key= */ blob_path[0], @@ -386,99 +386,32 @@ void BackupWriterS3::copyFileFromDisk( { /// Use the native copy as a more optimal way to copy a file from S3 to S3 if it's possible. /// We don't check for `has_throttling` here because the native copy almost doesn't use network. - auto source_data_source_description = src_disk->getDataSourceDescription(); - if (source_data_source_description.canUseNativeCopyWith(data_source_description) && (source_data_source_description.is_encrypted == copy_encrypted)) + if (!copy_encrypted) { - if (src_disk->isContentAddressed()) + if (auto * ca = tryGetContentAddressedExchange(src_disk)) { - const auto source = src_disk->getContentAddressedFileCopySource(src_path); - if (!source) - throw Exception(ErrorCodes::LOGICAL_ERROR, "No CAS copy source for {} on disk {}", src_path, src_disk->getName()); - - const auto * blob_source = std::visit( - [](const Source & copy_source) -> const ContentAddressedBlobFileCopySource * - { - if constexpr (std::is_same_v) - return nullptr; - else if constexpr (std::is_same_v) - return ©_source; - else - static_assert(std::is_same_v); - }, - *source); - - if (!blob_source) - { - LOG_TRACE(log, "File {} has no object of its own, copying through buffers", src_path); - BackupWriterDefault::copyFileFromDisk(path_in_backup, src_disk, src_path, copy_encrypted, start_pos, length); + if (tryNativeCopyFromContentAddressedDisk(*ca, path_in_backup, src_disk, src_path, start_pos, length)) return; - } - - if (blob_source->object.remote_path.empty() - || blob_source->payload_offset == 0 - || blob_source->payload_size != blob_source->object.bytes_size) - { - throw Exception(ErrorCodes::LOGICAL_ERROR, "Invalid CAS blob copy source for {}", src_path); - } - const auto & source_object = blob_source->object; - const UInt64 source_offset = blob_source->payload_offset; - if (start_pos > source_object.bytes_size || length > source_object.bytes_size - start_pos) - { - throw Exception( - ErrorCodes::LOGICAL_ERROR, - "Requested range with offset {} and length {} is outside CAS payload of {} bytes for {}", - start_pos, - length, - blob_source->payload_size, - src_path); - } - const auto blob_path = src_disk->getBlobPath(src_path); - if (blob_path.size() != 2 || blob_path[0] != source_object.remote_path) - throw Exception(ErrorCodes::LOGICAL_ERROR, "CAS copy source for {} does not match its blob path on disk {}", src_path, src_disk->getName()); - - LOG_TRACE(log, "Copying file {} from disk {} to S3", src_path, src_disk->getName()); - copyS3File( - disk_client_factory.getOrCreate(src_disk), - blob_path[1], - source_object.remote_path, - start_pos, - length, - source_offset, - client, - s3_uri.bucket, - fs::path(s3_uri.key) / path_in_backup, - s3_settings.request_settings, - read_settings, - blob_storage_log, - threadPoolCallbackRunnerUnsafe(getBackupsIOThreadPool().get(), ThreadName::S3_BACKUP_WRITER), - [&, this] - { - LOG_TRACE(log, "Falling back to copy file {} from disk {} to S3 through buffers", src_path, src_disk->getName()); - - if (copy_encrypted) - return src_disk->readEncryptedFile(src_path, read_settings); - - return src_disk->readFile(src_path, read_settings); - }); + BackupWriterDefault::copyFileFromDisk(path_in_backup, src_disk, src_path, copy_encrypted, start_pos, length); return; } + } + auto source_data_source_description = src_disk->getDataSourceDescription(); + if (source_data_source_description.canUseNativeCopyWith(data_source_description) && (source_data_source_description.is_encrypted == copy_encrypted)) + { /// getBlobPath() can return more than 2 elements if the file is stored as multiple objects in S3 bucket. /// In this case we can't use the native copy. if (auto blob_path = src_disk->getBlobPath(src_path); blob_path.size() == 2) { LOG_TRACE(log, "Copying file {} from disk {} to S3", src_path, src_disk->getName()); - - const size_t src_object_offset = src_disk->getObjectPayloadOffset(src_path); - copyS3File( /* src_s3_client */ disk_client_factory.getOrCreate(src_disk), /* src_bucket */ blob_path[1], /* src_key */ blob_path[0], start_pos, length, - /* src_object_offset */ src_object_offset, /* dest_s3_client */ client, /* dest_bucket */ s3_uri.bucket, /* dest_key */ fs::path(s3_uri.key) / path_in_backup, @@ -503,6 +436,73 @@ void BackupWriterS3::copyFileFromDisk( BackupWriterDefault::copyFileFromDisk(path_in_backup, src_disk, src_path, copy_encrypted, start_pos, length); } +bool BackupWriterS3::tryNativeCopyFromContentAddressedDisk( + IContentAddressedExchange & ca, + const String & path_in_backup, + DiskPtr src_disk, + const String & src_path, + UInt64 start_pos, + UInt64 length) +{ + if (length == 0) + return false; + + const auto plan = ca.getBlobViewPlan(src_path); + if (!plan) + return false; + + const UInt64 payload_size = plan->payload_end - plan->payload_offset; + if (start_pos > payload_size || length > payload_size - start_pos) + return false; + + auto source_data_source_description = src_disk->getDataSourceDescription(); + if (!source_data_source_description.sameKind(data_source_description)) + return false; + + if (plan->object.remote_path.empty()) + return false; + + const String src_bucket = src_disk->getObjectStorage()->getObjectsNamespace(); + if (src_bucket.empty()) + return false; + + auto src_client = disk_client_factory.getOrCreate(src_disk); + if (!src_client->supportsMultiPartCopy()) + return false; + + LOG_TRACE( + log, + "Copying the payload of content-addressed file {} from disk {} to S3 as a ranged server-side copy", + src_path, + src_disk->getName()); + + copyS3File( + std::move(src_client), + src_bucket, + /* src_key */ plan->object.remote_path, + /* src_offset */ plan->payload_offset + start_pos, + length, + /* dest_s3_client */ client, + /* dest_bucket */ s3_uri.bucket, + /* dest_key */ fs::path(s3_uri.key) / path_in_backup, + s3_settings.request_settings, + read_settings, + blob_storage_log, + threadPoolCallbackRunnerUnsafe(getBackupsIOThreadPool().get(), ThreadName::S3_BACKUP_WRITER), + [&, this] + { + LOG_TRACE( + log, + "Falling back to copy the raw object of content-addressed file {} from disk {} to S3 through buffers", + src_path, + src_disk->getName()); + + return src_disk->getObjectStorage()->readObject(plan->object, read_settings); + }); + + return true; +} + void BackupWriterS3::copyFile(const String & destination, const String & source, size_t size) { LOG_TRACE(log, "Copying file inside backup from {} to {}", source, destination); @@ -514,7 +514,6 @@ void BackupWriterS3::copyFile(const String & destination, const String & source, /* src_key= */ source_key, 0, size, - /* src_object_offset= */ 0, /* dest_s3_client= */ client, /* dest_bucket= */ s3_uri.bucket, /* dest_key= */ fs::path(s3_uri.key) / destination, diff --git a/src/Backups/BackupIO_S3.h b/src/Backups/BackupIO_S3.h index 78bd9031450c..bdf0fd51584b 100644 --- a/src/Backups/BackupIO_S3.h +++ b/src/Backups/BackupIO_S3.h @@ -18,6 +18,8 @@ namespace DB { +class IContentAddressedExchange; + class S3BackupDiskClientFactory { public: @@ -108,6 +110,14 @@ class BackupWriterS3 : public BackupWriterDefault private: std::unique_ptr readFile(const String & file_name, size_t expected_file_size) override; + bool tryNativeCopyFromContentAddressedDisk( + IContentAddressedExchange & ca, + const String & path_in_backup, + DiskPtr src_disk, + const String & src_path, + UInt64 start_pos, + UInt64 length); + const S3::URI s3_uri; const DataSourceDescription data_source_description; S3Settings s3_settings; diff --git a/src/Disks/ContentAddressedFileCopySource.h b/src/Disks/ContentAddressedFileCopySource.h deleted file mode 100644 index 4c0df4760d23..000000000000 --- a/src/Disks/ContentAddressedFileCopySource.h +++ /dev/null @@ -1,26 +0,0 @@ -#pragma once - -#include - -#include - -namespace DB -{ - -struct ContentAddressedInlineFileCopySource -{ - String data; -}; - -struct ContentAddressedBlobFileCopySource -{ - StoredObject object; - UInt64 payload_offset = 0; - UInt64 payload_size = 0; -}; - -using ContentAddressedFileCopySource = std::variant< - ContentAddressedInlineFileCopySource, - ContentAddressedBlobFileCopySource>; - -} diff --git a/src/Disks/DiskBackup.cpp b/src/Disks/DiskBackup.cpp index 93efc93f0e60..7f5b1ec9b0d4 100644 --- a/src/Disks/DiskBackup.cpp +++ b/src/Disks/DiskBackup.cpp @@ -170,11 +170,6 @@ std::vector DiskBackup::getBlobPath(const String &) const throw Exception(ErrorCodes::UNSUPPORTED_METHOD, "DiskBackup does not support getBlobPath method"); } -size_t DiskBackup::getObjectPayloadOffset(const String &) const -{ - throw Exception(ErrorCodes::UNSUPPORTED_METHOD, "DiskBackup does not support getObjectPayloadOffset method"); -} - void DiskBackup::writeFileUsingBlobWritingFunction(const String &, WriteMode, WriteBlobFunction &&) { throw Exception(ErrorCodes::UNSUPPORTED_METHOD, "DiskBackup does not support writeFileUsingBlobWritingFunction method"); diff --git a/src/Disks/DiskBackup.h b/src/Disks/DiskBackup.h index 5468a7bac7aa..b2f67e17ab20 100644 --- a/src/Disks/DiskBackup.h +++ b/src/Disks/DiskBackup.h @@ -91,7 +91,6 @@ class DiskBackup final : public IDisk const WriteSettings & settings) override; Strings getBlobPath(const String & path) const override; - size_t getObjectPayloadOffset(const String & path) const override; bool areBlobPathsRandom() const override { return false; } void writeFileUsingBlobWritingFunction(const String & path, WriteMode mode, WriteBlobFunction && write_blob_function) override; diff --git a/src/Disks/DiskEncrypted.h b/src/Disks/DiskEncrypted.h index d5e8dbd006ce..ece91f310985 100644 --- a/src/Disks/DiskEncrypted.h +++ b/src/Disks/DiskEncrypted.h @@ -205,18 +205,6 @@ class DiskEncrypted : public IDisk return delegate->getBlobPath(wrapped_path); } - size_t getObjectPayloadOffset(const String & path) const override - { - auto wrapped_path = wrappedPath(path); - return delegate->getObjectPayloadOffset(wrapped_path); - } - - std::optional getContentAddressedFileCopySource(const String & path) const override - { - auto wrapped_path = wrappedPath(path); - return delegate->getContentAddressedFileCopySource(wrapped_path); - } - bool areBlobPathsRandom() const override { return delegate->areBlobPathsRandom(); diff --git a/src/Disks/DiskLocal.cpp b/src/Disks/DiskLocal.cpp index 920c7cc6089b..fee1adc17a93 100644 --- a/src/Disks/DiskLocal.cpp +++ b/src/Disks/DiskLocal.cpp @@ -447,11 +447,6 @@ std::vector DiskLocal::getBlobPath(const String & path) const return {fs_path}; } -size_t DiskLocal::getObjectPayloadOffset(const String &) const -{ - return 0; -} - void DiskLocal::writeFileUsingBlobWritingFunction(const String & path, WriteMode mode, WriteBlobFunction && write_blob_function) { auto fs_path = fs::path(disk_path) / path; diff --git a/src/Disks/DiskLocal.h b/src/Disks/DiskLocal.h index 83b43687f17b..a262752e4e12 100644 --- a/src/Disks/DiskLocal.h +++ b/src/Disks/DiskLocal.h @@ -91,7 +91,6 @@ class DiskLocal : public IDisk const WriteSettings & settings) override; Strings getBlobPath(const String & path) const override; - size_t getObjectPayloadOffset(const String & path) const override; bool areBlobPathsRandom() const override { return false; } void writeFileUsingBlobWritingFunction(const String & path, WriteMode mode, WriteBlobFunction && write_blob_function) override; diff --git a/src/Disks/DiskObjectStorage/DiskObjectStorage.cpp b/src/Disks/DiskObjectStorage/DiskObjectStorage.cpp index 96d823554b7c..67862f6d3f0b 100644 --- a/src/Disks/DiskObjectStorage/DiskObjectStorage.cpp +++ b/src/Disks/DiskObjectStorage/DiskObjectStorage.cpp @@ -963,16 +963,6 @@ Strings DiskObjectStorage::getBlobPath(const String & path) const return res; } -size_t DiskObjectStorage::getObjectPayloadOffset(const String & path) const -{ - return metadata_storage->getObjectPayloadOffset(path); -} - -std::optional DiskObjectStorage::getContentAddressedFileCopySource(const String & path) const -{ - return metadata_storage->getContentAddressedFileCopySource(path); -} - bool DiskObjectStorage::areBlobPathsRandom() const { return metadata_storage->areBlobPathsRandom(); diff --git a/src/Disks/DiskObjectStorage/DiskObjectStorage.h b/src/Disks/DiskObjectStorage/DiskObjectStorage.h index 178f728f64f6..4d4aa6d3ad02 100644 --- a/src/Disks/DiskObjectStorage/DiskObjectStorage.h +++ b/src/Disks/DiskObjectStorage/DiskObjectStorage.h @@ -180,8 +180,6 @@ friend class DiskObjectStorageReservation; Strings getBlobPath(const String & path) const override; - size_t getObjectPayloadOffset(const String & path) const override; - std::optional getContentAddressedFileCopySource(const String & path) const override; bool areBlobPathsRandom() const override; void writeFileUsingBlobWritingFunction(const String & path, WriteMode mode, WriteBlobFunction && write_blob_function) override; diff --git a/src/Disks/DiskObjectStorage/DiskObjectStorageTransaction.cpp b/src/Disks/DiskObjectStorage/DiskObjectStorageTransaction.cpp index fb6f7b55f22d..6a52ca93e673 100644 --- a/src/Disks/DiskObjectStorage/DiskObjectStorageTransaction.cpp +++ b/src/Disks/DiskObjectStorage/DiskObjectStorageTransaction.cpp @@ -29,7 +29,6 @@ #include #include #include -#include #include namespace ProfileEvents @@ -520,28 +519,7 @@ void DiskObjectStorageTransaction::copyFileImpl( const auto enriched_write_settings = std::make_shared( updateIOSchedulingSettings(write_settings, read_resource_name, write_resource_name)); - const auto content_addressed_source = src_metadata_storage->isContentAddressed() - ? src_metadata_storage->getContentAddressedFileCopySource(from_file_path) - : std::nullopt; - - if (src_metadata_storage->isContentAddressed() && !content_addressed_source) - throw Exception(ErrorCodes::LOGICAL_ERROR, "No CAS copy source for {}", from_file_path); - - const auto blobs_to_copy = content_addressed_source - ? std::visit( - [&](const auto & source) -> StoredObjects - { - using Source = std::decay_t; - if constexpr (std::is_same_v) - return {StoredObject("", from_file_path, source.data.size())}; - else if constexpr (std::is_same_v) - return {source.object}; - else - static_assert(std::is_same_v); - }, - *content_addressed_source) - : src_metadata_storage->getStorageObjects(from_file_path); - + const auto blobs_to_copy = src_metadata_storage->getStorageObjects(from_file_path); const auto blobs_to_create = blobs_to_copy | std::views::transform([&](const auto & from) { return StoredObject(metadata_transaction->generateObjectKeyForPath(to_file_path).serialize(), to_file_path, from.bytes_size); }) | std::ranges::to(); @@ -566,88 +544,12 @@ void DiskObjectStorageTransaction::copyFileImpl( { for (const auto [src_blob, dst_blob] : std::views::zip(blobs_to_copy, blobs_to_create)) { - if (content_addressed_source) - { - std::visit( - [&](const auto & copy_source) - { - using Source = std::decay_t; - if constexpr (std::is_same_v) - { - runner.enqueueAndKeepTrack( - [this, bytes = copy_source.data, dst_blob, location, enriched_write_settings] - { - auto out = object_storages->takePointingTo(location)->writeObject( - dst_blob, WriteMode::Rewrite, {}, DBMS_DEFAULT_BUFFER_SIZE, *enriched_write_settings); - out->write(bytes.data(), bytes.size()); - out->finalize(); - }); - } - else if constexpr (std::is_same_v) - { - if (src_blob != copy_source.object - || copy_source.object.remote_path.empty() - || copy_source.payload_offset == 0 - || copy_source.payload_size != copy_source.object.bytes_size) - { - throw Exception(ErrorCodes::LOGICAL_ERROR, "Invalid CAS copy source for {}", from_file_path); - } - - runner.enqueueAndKeepTrack( - [this, - src_object_storages, - src_blob, - dst_blob, - location, - src_local_location, - src_object_offset = copy_source.payload_offset, - enriched_read_settings, - enriched_write_settings] - { - src_object_storages->takePointingTo(src_local_location) - ->copyObjectToAnotherObjectStorage( - src_blob, - dst_blob, - *enriched_read_settings, - *enriched_write_settings, - *object_storages->takePointingTo(location), - std::nullopt, - src_object_offset); - }); - } - else - { - static_assert(std::is_same_v); - } - }, - *content_addressed_source); - } - else - { - const size_t src_object_offset = src_metadata_storage->getObjectPayloadOffset(from_file_path); - - runner.enqueueAndKeepTrack( - [this, - src_object_storages, - src_blob, - dst_blob, - location, - src_local_location, - src_object_offset, - enriched_read_settings, - enriched_write_settings] - { - src_object_storages->takePointingTo(src_local_location) - ->copyObjectToAnotherObjectStorage( - src_blob, - dst_blob, - *enriched_read_settings, - *enriched_write_settings, - *object_storages->takePointingTo(location), - std::nullopt, - src_object_offset); - }); - } + runner.enqueueAndKeepTrack( + [this, src_object_storages, src_blob, dst_blob, location, src_local_location, enriched_read_settings, enriched_write_settings] + { + src_object_storages->takePointingTo(src_local_location)->copyObjectToAnotherObjectStorage( + src_blob, dst_blob, *enriched_read_settings, *enriched_write_settings, *object_storages->takePointingTo(location)); + }); } } diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/Cache/MetadataStorageFromCacheObjectStorage.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/Cache/MetadataStorageFromCacheObjectStorage.cpp index 5f8ad030e0d0..783ebc6f174c 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/Cache/MetadataStorageFromCacheObjectStorage.cpp +++ b/src/Disks/DiskObjectStorage/MetadataStorages/Cache/MetadataStorageFromCacheObjectStorage.cpp @@ -176,16 +176,6 @@ bool MetadataStorageFromCacheObjectStorage::isContentAddressed() const return underlying->isContentAddressed(); } -size_t MetadataStorageFromCacheObjectStorage::getObjectPayloadOffset(const std::string & path) const -{ - return underlying->getObjectPayloadOffset(path); -} - -std::optional MetadataStorageFromCacheObjectStorage::getContentAddressedFileCopySource(const std::string & path) const -{ - return underlying->getContentAddressedFileCopySource(path); -} - bool MetadataStorageFromCacheObjectStorage::isTransactional() const { return underlying->isTransactional(); diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/Cache/MetadataStorageFromCacheObjectStorage.h b/src/Disks/DiskObjectStorage/MetadataStorages/Cache/MetadataStorageFromCacheObjectStorage.h index a01f0bc1ddc2..f5e70e1e16ff 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/Cache/MetadataStorageFromCacheObjectStorage.h +++ b/src/Disks/DiskObjectStorage/MetadataStorages/Cache/MetadataStorageFromCacheObjectStorage.h @@ -64,8 +64,6 @@ class MetadataStorageFromCacheObjectStorage : public IMetadataStorage bool isReadOnly() const override; bool isContentAddressed() const override; - size_t getObjectPayloadOffset(const std::string & path) const override; - std::optional getContentAddressedFileCopySource(const std::string & path) const override; bool isTransactional() const override; bool isPlain() const override; bool isWriteOnce() const override; diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedExchange.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedExchange.cpp index 513b0ae0a753..db451224039c 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedExchange.cpp +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedExchange.cpp @@ -1,5 +1,7 @@ #include +#include + #include namespace DB @@ -167,4 +169,11 @@ std::optional decodeCasRelinkSourceToken(std::string_view return token; } +IContentAddressedExchange * tryGetContentAddressedExchange(const DiskPtr & disk) +{ + if (!disk || !disk->isContentAddressed()) + return nullptr; + return dynamic_cast(disk->getMetadataStorage().get()); +} + } diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedExchange.h b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedExchange.h index ac05a13bc78d..b65759dd17c3 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedExchange.h +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedExchange.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -257,4 +258,6 @@ class IContentAddressedExchange virtual std::optional getBlobViewPlan(const std::string & path) const = 0; }; +IContentAddressedExchange * tryGetContentAddressedExchange(const DiskPtr & disk); + } diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.cpp index c0647562fa0c..26101e24e1ea 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.cpp +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.cpp @@ -2040,59 +2040,6 @@ StoredObjects ContentAddressedMetadataStorage::getStorageObjects(const std::stri throw Exception(ErrorCodes::FILE_DOESNT_EXIST, "ContentAddressed: file {} not in manifest of {}", r->file, path); } -std::string ContentAddressedMetadataStorage::readInlineDataToString(const std::string & path) const -{ - checkOpAdmitted(CasOpClass::ContentRead); - if (auto bytes = tryGetInManifestBytes(path)) - return std::move(*bytes); - throw Exception(ErrorCodes::LOGICAL_ERROR, "ContentAddressed: {} is not an in-manifest file", path); -} - -size_t ContentAddressedMetadataStorage::getObjectPayloadOffset(const std::string & path) const -{ - if (auto plan = getBlobViewPlan(path)) - return plan->payload_offset; - return 0; -} - -std::optional ContentAddressedMetadataStorage::getContentAddressedFileCopySource( - const std::string & path) const -{ - checkOpAdmitted(CasOpClass::ContentRead); - - if (auto bytes = tryGetInManifestBytes(path)) - return ContentAddressedInlineFileCopySource{.data = std::move(*bytes)}; - - const auto plan = getBlobViewPlan(path); - if (!plan) - return std::nullopt; - - const UInt64 payload_offset = plan->payload_offset; - const UInt64 expected_payload_offset = store()->poolMeta().blob_header_len; - - if (plan->object.remote_path.empty() - || payload_offset != expected_payload_offset - || plan->payload_end < payload_offset) - { - throw Exception( - ErrorCodes::LOGICAL_ERROR, - "Invalid CAS copy source for {}: key is {}, payload offset is {}, expected offset is {}, payload end is {}", - path, - plan->object.remote_path.empty() ? "empty" : "set", - payload_offset, - expected_payload_offset, - plan->payload_end); - } - - const UInt64 payload_size = plan->payload_end - payload_offset; - - return ContentAddressedBlobFileCopySource{ - .object = StoredObject(plan->object.remote_path, path, payload_size), - .payload_offset = payload_offset, - .payload_size = payload_size, - }; -} - std::optional ContentAddressedMetadataStorage::getStorageObjectsIfExist(const std::string & path) const { /// A Vanished disk answers absent (truth). Probe first so the non-part `getStorageObjects` fallback @@ -2204,6 +2151,8 @@ std::optional ContentAddressedMet return std::nullopt; if (const auto * entry = manifest_view->findFile(r->file)) { + if (entry->placement != Cas::EntryPlacement::Blob) + return std::nullopt; const auto location = snap.pool->locate(*entry); BlobViewPlan plan; /// bytes_size is the readable extent of THIS file's window, NOT the whole blob: a diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.h b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.h index ce9e8beb46c1..5f0aadb2d7d4 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.h +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.h @@ -354,9 +354,6 @@ class ContentAddressedMetadataStorage final : public IMetadataStorage, public IC /// Performs one manifest lookup for part files instead of the inherited `existsFile` plus /// `getStorageObjects` sequence. std::optional getStorageObjectsIfExist(const std::string & path) const override; - std::string readInlineDataToString(const std::string & path) const override; - size_t getObjectPayloadOffset(const std::string & path) const override; - std::optional getContentAddressedFileCopySource(const std::string & path) const override; /// ==== `IContentAddressedExchange` (interserver relinking facade) ==== const String & getPoolUUID() const override { return pool_uuid; } diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/IMetadataStorage.h b/src/Disks/DiskObjectStorage/MetadataStorages/IMetadataStorage.h index 000556f28a23..5b1a6ef6d1b0 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/IMetadataStorage.h +++ b/src/Disks/DiskObjectStorage/MetadataStorages/IMetadataStorage.h @@ -13,7 +13,6 @@ #include #include #include -#include #include #include #include @@ -324,13 +323,6 @@ class IMetadataStorage : private boost::noncopyable /// disk transaction delegates writes to the metadata transaction's content-addressed buffer. virtual bool isContentAddressed() const { return false; } - virtual size_t getObjectPayloadOffset(const std::string & /* path */) const { return 0; } - - virtual std::optional getContentAddressedFileCopySource(const std::string & /* path */) const - { - return std::nullopt; - } - /// [TXN-ONE-PIPELINE] True when a transaction from this storage stages every mutation into a /// transaction-private overlay at call time (eager) rather than queuing effects for FIFO replay in /// commit. When true, DiskObjectStorageTransaction routes every mutating method straight to the diff --git a/src/Disks/DiskObjectStorage/ObjectStorages/Cached/CachedObjectStorage.cpp b/src/Disks/DiskObjectStorage/ObjectStorages/Cached/CachedObjectStorage.cpp index cd5fa1a252e5..317e24dac461 100644 --- a/src/Disks/DiskObjectStorage/ObjectStorages/Cached/CachedObjectStorage.cpp +++ b/src/Disks/DiskObjectStorage/ObjectStorages/Cached/CachedObjectStorage.cpp @@ -174,11 +174,9 @@ void CachedObjectStorage::copyObjectToAnotherObjectStorage( // NOLINT const ReadSettings & read_settings, const WriteSettings & write_settings, IObjectStorage & object_storage_to, - std::optional object_to_attributes, - size_t object_from_offset) + std::optional object_to_attributes) { - object_storage->copyObjectToAnotherObjectStorage( - object_from, object_to, read_settings, write_settings, object_storage_to, object_to_attributes, object_from_offset); + object_storage->copyObjectToAnotherObjectStorage(object_from, object_to, read_settings, write_settings, object_storage_to, object_to_attributes); } void CachedObjectStorage::copyObject( // NOLINT diff --git a/src/Disks/DiskObjectStorage/ObjectStorages/Cached/CachedObjectStorage.h b/src/Disks/DiskObjectStorage/ObjectStorages/Cached/CachedObjectStorage.h index 5177d7c4199a..a4b9e824b64c 100644 --- a/src/Disks/DiskObjectStorage/ObjectStorages/Cached/CachedObjectStorage.h +++ b/src/Disks/DiskObjectStorage/ObjectStorages/Cached/CachedObjectStorage.h @@ -76,8 +76,7 @@ class CachedObjectStorage final : public IObjectStorage const ReadSettings & read_settings, const WriteSettings & write_settings, IObjectStorage & object_storage_to, - std::optional object_to_attributes = {}, - size_t object_from_offset = 0) override; + std::optional object_to_attributes = {}) override; void listObjects(const std::string & path, RelativePathsWithMetadata & children, size_t max_keys) const override; diff --git a/src/Disks/DiskObjectStorage/ObjectStorages/IObjectStorage.cpp b/src/Disks/DiskObjectStorage/ObjectStorages/IObjectStorage.cpp index b3e7f9f3a75f..c763863d1a72 100644 --- a/src/Disks/DiskObjectStorage/ObjectStorages/IObjectStorage.cpp +++ b/src/Disks/DiskObjectStorage/ObjectStorages/IObjectStorage.cpp @@ -117,23 +117,14 @@ void IObjectStorage::copyObjectToAnotherObjectStorage( // NOLINT const ReadSettings & read_settings, const WriteSettings & write_settings, IObjectStorage & object_storage_to, - std::optional object_to_attributes, - size_t object_from_offset) + std::optional object_to_attributes) { - if (&object_storage_to == this && object_from_offset == 0) + if (&object_storage_to == this) copyObject(object_from, object_to, read_settings, write_settings, object_to_attributes); auto in = readObject(object_from, read_settings); auto out = object_storage_to.writeObject(object_to, WriteMode::Rewrite, /* attributes= */ {}, /* buf_size= */ DBMS_DEFAULT_BUFFER_SIZE, write_settings); - if (object_from_offset) - { - in->seek(object_from_offset, SEEK_SET); - copyData(*in, *out, object_from.bytes_size); - } - else - { - copyData(*in, *out); - } + copyData(*in, *out); out->finalize(); } diff --git a/src/Disks/DiskObjectStorage/ObjectStorages/IObjectStorage.h b/src/Disks/DiskObjectStorage/ObjectStorages/IObjectStorage.h index 480fd848bb37..436f8c05bd51 100644 --- a/src/Disks/DiskObjectStorage/ObjectStorages/IObjectStorage.h +++ b/src/Disks/DiskObjectStorage/ObjectStorages/IObjectStorage.h @@ -397,8 +397,7 @@ class IObjectStorage const ReadSettings & read_settings, const WriteSettings & write_settings, IObjectStorage & object_storage_to, - std::optional object_to_attributes = {}, - size_t object_from_offset = 0); + std::optional object_to_attributes = {}); virtual ~IObjectStorage() = default; diff --git a/src/Disks/DiskObjectStorage/ObjectStorages/S3/S3ObjectStorage.cpp b/src/Disks/DiskObjectStorage/ObjectStorages/S3/S3ObjectStorage.cpp index 19a1694e5da6..6eb155dd4c44 100644 --- a/src/Disks/DiskObjectStorage/ObjectStorages/S3/S3ObjectStorage.cpp +++ b/src/Disks/DiskObjectStorage/ObjectStorages/S3/S3ObjectStorage.cpp @@ -27,7 +27,6 @@ #include #include #include -#include #include #include @@ -970,21 +969,14 @@ void S3ObjectStorage::copyObjectToAnotherObjectStorage( // NOLINT const ReadSettings & read_settings, const WriteSettings & write_settings, IObjectStorage & object_storage_to, - std::optional object_to_attributes, - const size_t object_from_offset) + std::optional object_to_attributes) { - if (object_from.remote_path.empty()) - throw Exception( - ErrorCodes::LOGICAL_ERROR, - "Cannot copy {}: it has no object of its own, its bytes must be taken from the metadata", - object_from.local_path); - /// Shortcut for S3 if (auto * dest_s3 = dynamic_cast(&object_storage_to); dest_s3 != nullptr) { auto current_client = dest_s3->client.get(); auto settings_ptr = s3_settings.get(); - auto size = object_from_offset ? object_from.bytes_size : S3::getObjectSize(*client.get(), uri.bucket, object_from.remote_path, {}); + auto size = S3::getObjectSize(*client.get(), uri.bucket, object_from.remote_path, {}); auto scheduler = threadPoolCallbackRunnerUnsafe(getThreadPoolWriter(), ThreadName::S3_COPY_POOL); const auto read_settings_to_use = patchSettings(read_settings); @@ -996,7 +988,6 @@ void S3ObjectStorage::copyObjectToAnotherObjectStorage( // NOLINT /*src_key=*/object_from.remote_path, /*src_offset=*/0, /*src_size=*/size, - /*src_object_offset=*/object_from_offset, /*dest_s3_client=*/current_client, /*dest_bucket=*/dest_s3->uri.bucket, /*dest_key=*/object_to.remote_path, @@ -1004,13 +995,7 @@ void S3ObjectStorage::copyObjectToAnotherObjectStorage( // NOLINT read_settings_to_use, BlobStorageLogWriter::create(disk_name), scheduler, - [&, this]() -> std::unique_ptr - { - auto object = readObject(object_from, read_settings_to_use); - if (!object_from_offset) - return object; - return std::make_unique(std::move(object), object_from_offset, size); - }, + [&, this]{ return readObject(object_from, read_settings_to_use);}, object_to_attributes, write_settings.object_storage_copy_mode); return; @@ -1048,8 +1033,7 @@ void S3ObjectStorage::copyObjectToAnotherObjectStorage( // NOLINT ErrorCodes::NOT_IMPLEMENTED, "Native-only object copy requires both object storages to use the native S3 copy path"); - IObjectStorage::copyObjectToAnotherObjectStorage( - object_from, object_to, read_settings, write_settings, object_storage_to, object_to_attributes, object_from_offset); + IObjectStorage::copyObjectToAnotherObjectStorage(object_from, object_to, read_settings, write_settings, object_storage_to, object_to_attributes); } void S3ObjectStorage::copyObject( // NOLINT @@ -1078,7 +1062,6 @@ void S3ObjectStorage::copyObject( // NOLINT /*src_key=*/object_from.remote_path, /*src_offset=*/0, /*src_size=*/size, - /*src_object_offset=*/0, /*dest_s3_client=*/current_client, /*dest_bucket=*/uri.bucket, /*dest_key=*/object_to.remote_path, diff --git a/src/Disks/DiskObjectStorage/ObjectStorages/S3/S3ObjectStorage.h b/src/Disks/DiskObjectStorage/ObjectStorages/S3/S3ObjectStorage.h index e658226b8f6c..5f5903f2c6aa 100644 --- a/src/Disks/DiskObjectStorage/ObjectStorages/S3/S3ObjectStorage.h +++ b/src/Disks/DiskObjectStorage/ObjectStorages/S3/S3ObjectStorage.h @@ -163,8 +163,7 @@ class S3ObjectStorage : public IObjectStorage const ReadSettings & read_settings, const WriteSettings & write_settings, IObjectStorage & object_storage_to, - std::optional object_to_attributes = {}, - size_t object_from_offset = 0) override; + std::optional object_to_attributes = {}) override; void shutdown() override; diff --git a/src/Disks/IDisk.h b/src/Disks/IDisk.h index ab11fc14465a..b79a3171bde7 100644 --- a/src/Disks/IDisk.h +++ b/src/Disks/IDisk.h @@ -1,7 +1,6 @@ #pragma once #include -#include #include #include #include @@ -319,14 +318,6 @@ class IDisk : public Space /// StoredObject::remote_path for each stored object combined with the name of the objects' namespace. virtual Strings getBlobPath(const String & path) const = 0; - /// Where the file's bytes begin inside the object `getBlobPath` names. - virtual size_t getObjectPayloadOffset(const String & path) const = 0; - - virtual std::optional getContentAddressedFileCopySource(const String & /* path */) const - { - return std::nullopt; - } - /// Returns whether the blob paths this disk uses are randomly generated. virtual bool areBlobPathsRandom() const = 0; diff --git a/src/Disks/ReadOnlyDiskWrapper.h b/src/Disks/ReadOnlyDiskWrapper.h index b0331baa983e..9a38e85cde77 100644 --- a/src/Disks/ReadOnlyDiskWrapper.h +++ b/src/Disks/ReadOnlyDiskWrapper.h @@ -29,8 +29,6 @@ class ReadOnlyDiskWrapper : public IDisk size_t getFileSize(const String & path) const override { return delegate->getFileSize(path); } Strings getBlobPath(const String & path) const override { return delegate->getBlobPath(path); } - size_t getObjectPayloadOffset(const String & path) const override { return delegate->getObjectPayloadOffset(path); } - std::optional getContentAddressedFileCopySource(const String & path) const override { return delegate->getContentAddressedFileCopySource(path); } bool areBlobPathsRandom() const override { return delegate->areBlobPathsRandom(); } void writeFileUsingBlobWritingFunction(const String & path, WriteMode mode, WriteBlobFunction && write_blob_function) override { diff --git a/src/IO/S3/copyS3File.cpp b/src/IO/S3/copyS3File.cpp index 645562018fb8..16ee84d11bee 100644 --- a/src/IO/S3/copyS3File.cpp +++ b/src/IO/S3/copyS3File.cpp @@ -641,26 +641,31 @@ namespace { LOG_TEST(log, "Copy object {} to {} using native copy", src_key, dest_key); - const bool ranged = offset != 0; const bool multipart_copy_available = supports_multipart_copy && request_settings[S3RequestSetting::allow_multipart_copy]; - if (ranged && !multipart_copy_available) + if (offset != 0 && !multipart_copy_available) { if (!allow_fallback) throw Exception( ErrorCodes::NOT_IMPLEMENTED, - "Native copy of a byte range requires multipart copy, which is unavailable for {}", + "Cannot copy a byte range of object {} server-side: only UploadPartCopy can express a " + "range, and multipart copy is unavailable", src_key); - LOG_TRACE(log, "Ranged native copy needs multipart copy, falling back for {}", src_key); + LOG_INFO( + log, + "Multipart copy is unavailable, so the byte range [{}, {}) of {} cannot be copied " + "server-side, will copy through the server instead", + offset, + offset + size, + src_key); fallback_method(); return; } - const bool use_single_operation_copy = !ranged - && (!multipart_copy_available - || (size <= request_settings[S3RequestSetting::max_single_operation_copy_size])); + const bool use_single_operation_copy = offset == 0 + && (!multipart_copy_available || (size <= request_settings[S3RequestSetting::max_single_operation_copy_size])); if (use_single_operation_copy) performSingleOperationCopy(); @@ -882,7 +887,6 @@ void copyS3File( const String & src_key, size_t src_offset, size_t src_size, - size_t src_object_offset, std::shared_ptr dest_s3_client, const String & dest_bucket, const String & dest_key, @@ -894,12 +898,6 @@ void copyS3File( const std::optional & object_metadata, ObjectStorageCopyMode copy_mode) { - if (src_key.empty()) - throw Exception( - ErrorCodes::LOGICAL_ERROR, - "Cannot copy an S3 object with an empty source key from bucket {} to {}/{}", - src_bucket, dest_bucket, dest_key); - if (!dest_s3_client) dest_s3_client = src_s3_client; @@ -934,7 +932,7 @@ void copyS3File( src_s3_client, src_bucket, src_key, - src_offset + src_object_offset, + src_offset, src_size, dest_bucket, dest_key, diff --git a/src/IO/S3/copyS3File.h b/src/IO/S3/copyS3File.h index 8c37c4187739..d4f728377130 100644 --- a/src/IO/S3/copyS3File.h +++ b/src/IO/S3/copyS3File.h @@ -40,7 +40,6 @@ void copyS3File( const String & src_key, size_t src_offset, size_t src_size, - size_t src_object_offset, std::shared_ptr dest_s3_client, const String & dest_bucket, const String & dest_key, diff --git a/src/Storages/MergeTree/DataPartsExchange.cpp b/src/Storages/MergeTree/DataPartsExchange.cpp index 5d7e3ba6ac93..be021038579c 100644 --- a/src/Storages/MergeTree/DataPartsExchange.cpp +++ b/src/Storages/MergeTree/DataPartsExchange.cpp @@ -158,17 +158,6 @@ constexpr auto CA_CONFIRM_ANSWER_PROVEN = "yes"; /// same safe outcome as a refusal. constexpr auto CA_CONFIRM_ANSWER_UNPROVEN = "unproven"; -/// Resolve a disk to the content-addressed exchange facade, or nullptr if the disk is not CA. The -/// cast targets the purpose-built INTERFACE (IContentAddressedExchange), never the concrete -/// metadata-storage class. Used by both the relink sender (the part's -/// disk) and the relink receiver (the target disk). -IContentAddressedExchange * tryGetContentAddressedExchange(const DiskPtr & disk) -{ - if (!disk || !disk->isContentAddressed()) - return nullptr; - return dynamic_cast(disk->getMetadataStorage().get()); -} - /// Simple functor for tracking fetch progress in system.replicated_fetches table. struct ReplicatedFetchReadCallback { diff --git a/src/Storages/ObjectStorageQueue/ObjectStorageQueuePostProcessor.cpp b/src/Storages/ObjectStorageQueue/ObjectStorageQueuePostProcessor.cpp index 020904ae73c3..aa748eacaedc 100644 --- a/src/Storages/ObjectStorageQueue/ObjectStorageQueuePostProcessor.cpp +++ b/src/Storages/ObjectStorageQueue/ObjectStorageQueuePostProcessor.cpp @@ -331,7 +331,6 @@ void ObjectStorageQueuePostProcessor::moveS3Objects(const StoredObjects & object /*src_key=*/ object_from.remote_path, /*src_offset=*/ 0, /*src_size=*/ object_size, - /*src_object_offset=*/ 0, /*dest_s3_client=*/ dst_client, /*dest_bucket=*/ dst_uri.bucket, /*dest_key=*/ object_to.remote_path, diff --git a/tests/integration/test_cas_backup_s3_native_copy/test.py b/tests/integration/test_cas_backup_s3_native_copy/test.py index fdf413fb99c4..f27cb9882971 100644 --- a/tests/integration/test_cas_backup_s3_native_copy/test.py +++ b/tests/integration/test_cas_backup_s3_native_copy/test.py @@ -158,11 +158,10 @@ def test_backup_to_disk_on_same_authority(backup_disk, storage_policy, multipart node.query(f"BACKUP TABLE {table} TO {destination}", query_id=backup_query_id) upload_part_copy, copy_object = copy_events(node, backup_query_id) - assert copy_object == 0, "CopyObject has no range: the envelope would land in the backup" - if multipart_copy: - assert upload_part_copy > 0, "blobs were not copied with a ranged server-side copy" - else: - assert upload_part_copy == 0, "multipart copy is disabled but UploadPartCopy still ran" + assert (upload_part_copy, copy_object) == ( + 0, + 0, + ), "a Disk(...) destination goes through IDisk::copyFile, which no longer copies CAS objects" node.query(f"DROP TABLE IF EXISTS {restored} SYNC") node.query( From 50f37d51d39cec214b137675bb00bcbb89c68ff2 Mon Sep 17 00:00:00 2001 From: Konstantin Morozov Date: Wed, 30 Sep 2026 18:28:41 +0200 Subject: [PATCH 14/30] fix bugs Signed-off-by: Konstantin Morozov --- docs/en/antalya/cas/operations/backup.md | 61 ++++++----- src/Backups/BackupIO_AzureBlobStorage.cpp | 6 +- src/Backups/BackupIO_S3.cpp | 8 +- .../DiskObjectStorage/DiskObjectStorage.cpp | 9 +- .../ContentAddressedExchange.cpp | 1 + .../ContentAddressedExchange.h | 4 +- src/IO/S3/copyS3File.cpp | 2 +- .../test_cas_backup_s3_native_copy/test.py | 102 +++++++++++++++++- 8 files changed, 153 insertions(+), 40 deletions(-) diff --git a/docs/en/antalya/cas/operations/backup.md b/docs/en/antalya/cas/operations/backup.md index 757af8973534..e42f4531f8e8 100644 --- a/docs/en/antalya/cas/operations/backup.md +++ b/docs/en/antalya/cas/operations/backup.md @@ -1,5 +1,5 @@ --- -description: 'How BACKUP and RESTORE work for a table on a content-addressed disk: what holds the data during a backup, when the copy runs inside S3 for S3 and Disk destinations, and what is not supported yet.' +description: 'How BACKUP and RESTORE work for a table on a content-addressed disk: what holds the data during a backup, when the copy runs inside S3, and what is not supported yet.' sidebar_label: 'Backup' sidebar_position: 5 slug: /antalya/cas/operations/backup @@ -53,47 +53,50 @@ Which path runs depends on the destination. are read through the `CAS` read path and written to the destination. Pool deduplication is lost: what was one blob shared by several replicas becomes ordinary files in the backup. -**A destination on the same `S3` endpoint as the pool** — the copy then runs inside the `S3` store -itself: the ClickHouse server issues one "copy these bytes" command, and `S3` moves the bytes -internally without sending them through ClickHouse. This works for both kinds of destination: +**An `S3(...)` destination on the same `S3` endpoint as the pool** — the copy of a blob then runs +inside the `S3` store itself: the ClickHouse server issues one "copy these bytes" command, and `S3` +moves the bytes internally without sending them through ClickHouse. ```sql BACKUP TABLE t TO S3('http://s3.example.com/bucket/backups/b1', 'key', 'secret'); -BACKUP TABLE t TO Disk('backups_s3', 'b1'); ``` -Here the pool is under `http://s3.example.com/bucket/pool/`, and the disk `backups_s3` is an `s3` -or `s3_plain` disk under `http://s3.example.com/bucket/backups/`. +Here the pool is under `http://s3.example.com/bucket/pool/`. The files of a part fall into two categories: -| Category | Example | `BACKUP ... TO S3(...)` | `BACKUP ... TO Disk(...)` | -|---|---|---|---| -| Blob | `data.bin`, marks, `primary.idx` | an `UploadPartCopy` naming a byte range — only the payload moves, without the blob's internal header | the same | -| Inside the manifest | `checksums.txt`, `count.txt`, `columns.txt` | read through the `CAS` read path and written through ClickHouse's buffers | the bytes are taken from the manifest and written as a new object on the destination disk | +| Category | Example | How it is copied | +|---|---|---| +| Blob | `data.bin`, marks, `primary.idx` | an `UploadPartCopy` naming a byte range — only the payload moves, without the blob's internal header | +| Inside the manifest | `checksums.txt`, `count.txt`, `columns.txt` | read through the `CAS` read path and written through ClickHouse's buffers | A blob object is `[header][payload]`, so a file never starts at the beginning of its object. Every copy of a blob therefore names the payload range. A copy of the whole object would put the header into the backup, and a later restore would read wrong data. -If the destination cannot copy a byte range, `CAS` does not fall back to copying the whole object — +If a byte range cannot be copied inside `S3`, `CAS` does not fall back to copying the whole object — the file is read and written through ClickHouse instead. That is slower, but correct. This happens -when multipart copy is off, and when the destination is not an `S3` store. +when multipart copy is off, and on a store whose API has no `UploadPartCopy` at all: `GCS` is one, so +by its current API a blob there always goes through ClickHouse. -Which settings control the copy depends on the destination: +| Turn off the copy inside `S3` | Turn off the range copy | +|---|---| +| `SETTINGS allow_s3_native_copy = 0` in the `BACKUP` query | the query setting `s3_allow_multipart_copy = 0` | -| Destination | Turn off the copy inside `S3` | Turn off the range copy | -|---|---|---| -| `S3(...)` | `SETTINGS allow_s3_native_copy = 0` in the `BACKUP` query | the query setting `s3_allow_multipart_copy = 0` | -| `Disk(...)` | `0` in the `CAS` disk config | `0` in the `CAS` disk config | +```sql +BACKUP TABLE t TO S3(...) SETTINGS allow_s3_native_copy = 0; +``` -For a `Disk(...)` destination the copy uses the request settings of the source `CAS` disk, not the -settings of the query. +**A `Disk(...)` destination** never copies inside `S3`, even when the disk is an `s3` or `s3_plain` +disk on the same endpoint as the pool: ```sql -BACKUP TABLE t TO S3(...) SETTINGS allow_s3_native_copy = 0; +BACKUP TABLE t TO Disk('backups_s3', 'b1'); ``` +Every file is read through the `CAS` read path and written through ClickHouse's buffers. The +`s3_allow_native_copy` and `s3_allow_multipart_copy` settings have no effect on this path. + ## Restore {#restore} Each part is materialized in **one disk transaction** and published as one manifest and one ref. A @@ -125,11 +128,15 @@ It is a useful building block, not a replacement for `BACKUP`. - The `Ordinary` database engine is not supported. - Pool deduplication is lost in the backup: its size follows the logical files, not the unique blobs. - Pointer holding does not survive a server restart. -- The copy inside the store works only for `S3` and `S3`-compatible stores, and only when the - destination is on the same endpoint as the pool. Other destinations get the copy through - ClickHouse's buffers. +- The copy inside the store works only for an `S3(...)` destination on an `S3` or `S3`-compatible + store, and only when the destination is on the same endpoint as the pool. Other destinations, + including `Disk(...)`, get the copy through ClickHouse's buffers. - A blob is copied inside `S3` only with multipart copy (`UploadPartCopy`), because only it can name a - byte range. Without multipart copy every blob goes through ClickHouse's buffers. + byte range. Without multipart copy, and on a store whose API lacks `UploadPartCopy` such as `GCS`, + every blob goes through ClickHouse's buffers. - Restore onto a `CAS` disk always writes through ClickHouse, see [restore](#restore). -- A disk-level copy of a single file onto a `CAS` disk, outside of `RESTORE`, is rejected with - `NOT_IMPLEMENTED`: a `CAS` disk accepts part files only as a whole part in one transaction. +- A disk-level copy of a single **part** file onto a `CAS` disk, outside of `RESTORE`, is rejected with + `NOT_IMPLEMENTED`: a `CAS` disk accepts part files only as a whole part in one transaction. A + `Disk(...)` destination that happens to be a `CAS` disk is a different case — a backup holds its own + files rather than part files, so `BACKUP TABLE t TO Disk('', 'b1')` writes them through the + buffers and succeeds. diff --git a/src/Backups/BackupIO_AzureBlobStorage.cpp b/src/Backups/BackupIO_AzureBlobStorage.cpp index 693b063c705c..f7397ae7a24d 100644 --- a/src/Backups/BackupIO_AzureBlobStorage.cpp +++ b/src/Backups/BackupIO_AzureBlobStorage.cpp @@ -88,8 +88,7 @@ void BackupReaderAzureBlobStorage::copyFileToDisk(const String & path_in_backup, LOG_TRACE(log, "Source description {}, destination description {}", data_source_description.description, destination_data_source_description.description); if (destination_data_source_description.object_storage_type == ObjectStorageType::Azure && destination_data_source_description.is_encrypted == encrypted_in_backup - && destination_data_source_description.files_are_whole_objects - && data_source_description.files_are_whole_objects) + && destination_data_source_description.canUseNativeCopyWith(data_source_description)) { LOG_TRACE(log, "Copying {} from AzureBlobStorage to disk {}", path_in_backup, destination_disk->getName()); auto write_blob_function = [&](const Strings & dst_blob_path, WriteMode mode, const std::optional &) -> size_t @@ -168,8 +167,7 @@ void BackupWriterAzureBlobStorage::copyFileFromDisk( LOG_TRACE(log, "Source description {}, destination description {}", source_data_source_description.description, data_source_description.description); if (source_data_source_description.object_storage_type == ObjectStorageType::Azure && source_data_source_description.is_encrypted == copy_encrypted - && source_data_source_description.files_are_whole_objects - && data_source_description.files_are_whole_objects) + && source_data_source_description.canUseNativeCopyWith(data_source_description)) { /// getBlobPath() can return more than 2 elements if the file is stored as multiple objects in AzureBlobStorage container. /// In this case we can't use the native copy. diff --git a/src/Backups/BackupIO_S3.cpp b/src/Backups/BackupIO_S3.cpp index ab013a36da8b..489f30789e0f 100644 --- a/src/Backups/BackupIO_S3.cpp +++ b/src/Backups/BackupIO_S3.cpp @@ -453,7 +453,13 @@ bool BackupWriterS3::tryNativeCopyFromContentAddressedDisk( const UInt64 payload_size = plan->payload_end - plan->payload_offset; if (start_pos > payload_size || length > payload_size - start_pos) - return false; + throw Exception( + ErrorCodes::LOGICAL_ERROR, + "Requested range [{}, {}) of content-addressed file {} lies outside its payload of {} bytes", + start_pos, + start_pos + length, + src_path, + payload_size); auto source_data_source_description = src_disk->getDataSourceDescription(); if (!source_data_source_description.sameKind(data_source_description)) diff --git a/src/Disks/DiskObjectStorage/DiskObjectStorage.cpp b/src/Disks/DiskObjectStorage/DiskObjectStorage.cpp index 67862f6d3f0b..bebb76139b64 100644 --- a/src/Disks/DiskObjectStorage/DiskObjectStorage.cpp +++ b/src/Disks/DiskObjectStorage/DiskObjectStorage.cpp @@ -299,11 +299,14 @@ void DiskObjectStorage::copyFile( /// NOLINT const std::function & cancellation_hook) { auto component_guard = Coordination::setCurrentComponent("DiskObjectStorage::copyFile"); - if (getDataSourceDescription().canUseNativeCopyWith(to_disk.getDataSourceDescription())) + const auto source_description = getDataSourceDescription(); + const auto destination_description = to_disk.getDataSourceDescription(); + auto * to_disk_object_storage = dynamic_cast(&to_disk); + if (to_disk_object_storage && source_description == destination_description + && source_description.canUseNativeCopyWith(destination_description)) { /// It may use s3-server-side copy - auto & to_disk_object_storage = dynamic_cast(to_disk); - auto transaction = createObjectStorageTransactionToAnotherDisk(to_disk_object_storage); + auto transaction = createObjectStorageTransactionToAnotherDisk(*to_disk_object_storage); try { transaction->copyFile(from_file_path, to_file_path, read_settings, write_settings); diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedExchange.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedExchange.cpp index db451224039c..b6fa4e7fd5f7 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedExchange.cpp +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedExchange.cpp @@ -1,5 +1,6 @@ #include +#include #include #include diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedExchange.h b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedExchange.h index b65759dd17c3..de714d3ede31 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedExchange.h +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedExchange.h @@ -1,7 +1,6 @@ #pragma once #include -#include #include #include #include @@ -10,6 +9,9 @@ namespace DB { +class IDisk; +using DiskPtr = std::shared_ptr; + class ReadPipeline; struct ReadSettings; diff --git a/src/IO/S3/copyS3File.cpp b/src/IO/S3/copyS3File.cpp index 16ee84d11bee..4f9e8b0a9743 100644 --- a/src/IO/S3/copyS3File.cpp +++ b/src/IO/S3/copyS3File.cpp @@ -653,7 +653,7 @@ namespace "range, and multipart copy is unavailable", src_key); - LOG_INFO( + LOG_DEBUG( log, "Multipart copy is unavailable, so the byte range [{}, {}) of {} cannot be copied " "server-side, will copy through the server instead", diff --git a/tests/integration/test_cas_backup_s3_native_copy/test.py b/tests/integration/test_cas_backup_s3_native_copy/test.py index f27cb9882971..ac1b16460709 100644 --- a/tests/integration/test_cas_backup_s3_native_copy/test.py +++ b/tests/integration/test_cas_backup_s3_native_copy/test.py @@ -219,17 +219,21 @@ def test_blobs_use_ranged_copy_and_inline_falls_back(): events = node.query( f""" - SELECT ProfileEvents['S3UploadPartCopy'], ProfileEvents['S3CopyObject'] + SELECT + ProfileEvents['S3UploadPartCopy'], + ProfileEvents['S3CopyObject'], + ProfileEvents['S3PutObject'] + ProfileEvents['S3UploadPart'] FROM system.query_log WHERE type = 'QueryFinish' AND query_id = '{query_id}' ORDER BY event_time DESC LIMIT 1 """ ).strip() assert events, "no query_log row for the backup query" - upload_part_copy, copy_object = (int(value) for value in events.split("\t")) + upload_part_copy, copy_object, uploaded = (int(value) for value in events.split("\t")) assert upload_part_copy > 0, "no ranged server-side copy happened" assert copy_object == 0, "CopyObject has no range: the envelope would land in the backup" + assert uploaded > 0, "inline entries have no object and must be uploaded through buffers" node.query(f"DROP TABLE IF EXISTS {restored} SYNC") node.query(f"RESTORE TABLE {table} AS {restored} FROM {destination}") @@ -294,7 +298,7 @@ def test_ranged_copy_falls_back_without_multipart(): def test_move_partition_out_of_cas_with_empty_arrays(): """A zero-size `.bin` still becomes a blob: `partFileMustStayBlob` keys on the file name, not - the size. A ranged copy of it reaches `calculatePartSize(0)`, which throws. + the size. Moving it off a CAS disk must copy it through buffers. """ node = cluster.instances["node"] table = "cas_move_empty_arrays" @@ -335,3 +339,95 @@ def test_move_partition_out_of_cas_with_empty_arrays(): ) node.query(f"DROP TABLE {table} SYNC") + + +def test_backup_to_s3_with_empty_arrays(): + """A zero-size `.bin` is a blob with an empty payload. A ranged copy of zero bytes reaches + `calculatePartSize(0)`, which throws, so it must be copied through buffers. + """ + node = cluster.instances["node"] + table = "cas_backup_empty_arrays" + restored = f"{table}_restored" + destination = backup_destination("empty_arrays") + fingerprint = "SELECT count(), sum(cityHash64(s)), sum(length(empty)) FROM {}" + + node.query(f"DROP TABLE IF EXISTS {table} SYNC") + node.query( + f""" + CREATE TABLE {table} (k UInt64, s String, empty Array(UInt32)) + ENGINE = MergeTree ORDER BY k + SETTINGS storage_policy = '{STORAGE_POLICY}', min_bytes_for_wide_part = 0 + """ + ) + node.query( + f""" + INSERT INTO {table} + SELECT number, randomPrintableASCII(64), [] + FROM numbers({NUM_ROWS}) + """ + ) + expected = node.query(fingerprint.format(table)).strip() + + node.query( + f"BACKUP TABLE {table} TO {destination} SETTINGS allow_s3_native_copy = 1" + ) + + node.query(f"DROP TABLE IF EXISTS {restored} SYNC") + node.query(f"RESTORE TABLE {table} AS {restored} FROM {destination}") + + assert node.query(fingerprint.format(restored)).strip() == expected + assert ( + node.query( + f"CHECK TABLE {restored} SETTINGS check_query_single_value_result = 1" + ).strip() + == "1" + ) + + node.query(f"DROP TABLE {table} SYNC") + node.query(f"DROP TABLE {restored} SYNC") + + +def test_incremental_backup_to_s3(): + """An incremental backup copies only the files that changed since the base backup, so the + ranged copy runs against a subset of the part files and the restore reads from both backups. + """ + node = cluster.instances["node"] + table = "cas_backup_incremental" + restored = f"{table}_restored" + base = backup_destination("incremental_base") + incremental = backup_destination("incremental") + + create_and_fill(node, table) + node.query( + f"BACKUP TABLE {table} TO {base} SETTINGS allow_s3_native_copy = 1" + ) + + node.query( + f""" + INSERT INTO {table} + SELECT + number, + randomPrintableASCII(64), + if(number % 5 = 0, NULL, toInt64(number)), + [toUInt32(number)] + FROM numbers({NUM_ROWS}, {NUM_ROWS}) + """ + ) + expected = column_fingerprints(node, table) + + node.query( + f"BACKUP TABLE {table} TO {incremental} " + f"SETTINGS base_backup = {base}, allow_s3_native_copy = 1" + ) + + node.query(f"DROP TABLE IF EXISTS {restored} SYNC") + node.query(f"RESTORE TABLE {table} AS {restored} FROM {incremental}") + + actual = column_fingerprints(node, restored) + assert actual["count"] == expected["count"] + differing = [c for c in COLUMNS if actual[c] != expected[c]] + assert not differing, f"columns differ after restore: {differing}" + + node.query(f"DROP TABLE {table} SYNC") + node.query(f"DROP TABLE {restored} SYNC") + From a3e96df809bcfced96491474cfe68b95a1239779 Mon Sep 17 00:00:00 2001 From: Konstantin Morozov Date: Wed, 30 Sep 2026 21:05:17 +0200 Subject: [PATCH 15/30] Cover the whole-object capability and the CAS S3 copy branch with tests Ten tests for the paths the capability gate touches, most of which no test exercised before. Three of them guard non-CAS behaviour, because the gate sits on a path every object-storage disk takes: - an ordinary s3 disk against an `encrypted` disk over the same s3. `sameKind` ignores `is_encrypted` and `DiskEncrypted` reports its delegate's description, so a predicate that replaced `operator==` rather than joining it would let the pair through and the cast to `DiskObjectStorage` would throw. - two ordinary s3 disks keep their server-side copy. The capability defaults to false, so any description that forgets to claim it loses the fast path in silence. - `BACKUP TO File(...)` keeps using `fs::copy`. Both sides take their description from one function, so a forgotten claim there gives false on both sides. The rest cover CAS: a cache disk over CAS stays out of the server-side copy, a zero-size file survives a backup when files are not deduplicated, an incremental backup exercises a non-zero `start_pos`, and a backup written into a CAS disk now succeeds where it used to be refused. The mechanism test now also asserts that something went through buffers, so it cannot pass when a part happens to hold no in-manifest entries. The unit test pins the predicate's truth table and, separately, that `operator==` stayed reflexive - the property that breaks if the conjunction is folded into it. Co-Authored-By: Claude Opus 5 --- .../tests/gtest_data_source_description.cpp | 48 +++ .../configs/storage_conf.xml | 52 ++++ .../test_cas_backup_s3_native_copy/test.py | 278 +++++++++++++++++- 3 files changed, 376 insertions(+), 2 deletions(-) create mode 100644 src/Disks/tests/gtest_data_source_description.cpp diff --git a/src/Disks/tests/gtest_data_source_description.cpp b/src/Disks/tests/gtest_data_source_description.cpp new file mode 100644 index 000000000000..8e14cdd4e50c --- /dev/null +++ b/src/Disks/tests/gtest_data_source_description.cpp @@ -0,0 +1,48 @@ +#include + +#include + +using namespace DB; + +namespace +{ + +DataSourceDescription makeS3Description(bool files_are_whole_objects) +{ + DataSourceDescription description; + description.type = DataSourceType::ObjectStorage; + description.object_storage_type = ObjectStorageType::S3; + description.description = "http://storage.example.com/bucket/"; + description.files_are_whole_objects = files_are_whole_objects; + return description; +} + +} + +TEST(DataSourceDescription, NativeCopyNeedsWholeObjectsOnBothSides) +{ + const auto whole = makeS3Description(true); + const auto windowed = makeS3Description(false); + + EXPECT_TRUE(whole.canUseNativeCopyWith(whole)); + EXPECT_FALSE(whole.canUseNativeCopyWith(windowed)); + EXPECT_FALSE(windowed.canUseNativeCopyWith(whole)); + EXPECT_FALSE(windowed.canUseNativeCopyWith(windowed)); +} + +TEST(DataSourceDescription, EqualityStaysReflexiveForWindowedFiles) +{ + const auto windowed = makeS3Description(false); + + EXPECT_TRUE(windowed == windowed); + EXPECT_TRUE(windowed.sameKind(windowed)); +} + +TEST(DataSourceDescription, NativeCopyStillNeedsTheSameKind) +{ + auto one = makeS3Description(true); + auto other = makeS3Description(true); + other.description = "http://other.example.com/bucket/"; + + EXPECT_FALSE(one.canUseNativeCopyWith(other)); +} diff --git a/tests/integration/test_cas_backup_s3_native_copy/configs/storage_conf.xml b/tests/integration/test_cas_backup_s3_native_copy/configs/storage_conf.xml index 5f5acbbedaac..e7a34c7a0df7 100644 --- a/tests/integration/test_cas_backup_s3_native_copy/configs/storage_conf.xml +++ b/tests/integration/test_cas_backup_s3_native_copy/configs/storage_conf.xml @@ -42,6 +42,26 @@ clickhouse clickhouse + + cache + disk_cas_backup_s3 + cas_cache/ + 1073741824 + + + object_storage + s3 + local + http://rustfs1:11121/test/plain_second/ + clickhouse + clickhouse + + + encrypted + backup_disk_s3 + encrypted/ + 1234567812345678 + @@ -68,10 +88,42 @@ + + +
+ disk_cas_cached +
+ + backup_disk_s3 + +
+
+ + +
+ backup_disk_s3 +
+ + disk_plain_s3_second + +
+
+ + +
+ backup_disk_s3 +
+ + disk_plain_s3_encrypted + +
+
backup_disk_s3_plain backup_disk_s3 + disk_plain_s3_encrypted + disk_cas_backup_s3 diff --git a/tests/integration/test_cas_backup_s3_native_copy/test.py b/tests/integration/test_cas_backup_s3_native_copy/test.py index ac1b16460709..1ff09229c807 100644 --- a/tests/integration/test_cas_backup_s3_native_copy/test.py +++ b/tests/integration/test_cas_backup_s3_native_copy/test.py @@ -233,6 +233,18 @@ def test_blobs_use_ranged_copy_and_inline_falls_back(): assert upload_part_copy > 0, "no ranged server-side copy happened" assert copy_object == 0, "CopyObject has no range: the envelope would land in the backup" + + uploaded = int( + node.query( + f""" + SELECT ProfileEvents['S3PutObject'] + ProfileEvents['S3UploadPart'] + FROM system.query_log + WHERE type = 'QueryFinish' AND query_id = '{query_id}' + ORDER BY event_time DESC LIMIT 1 + """ + ).strip() + ) + assert uploaded > 0, "nothing went through buffers, so no inline entry was exercised" assert uploaded > 0, "inline entries have no object and must be uploaded through buffers" node.query(f"DROP TABLE IF EXISTS {restored} SYNC") @@ -343,7 +355,8 @@ def test_move_partition_out_of_cas_with_empty_arrays(): def test_backup_to_s3_with_empty_arrays(): """A zero-size `.bin` is a blob with an empty payload. A ranged copy of zero bytes reaches - `calculatePartSize(0)`, which throws, so it must be copied through buffers. + `calculatePartSize(0)`, which throws, so it must be copied through buffers. Only a backup with + `deduplicate_files = 0` passes empty files to the writer; a deduplicated one drops them earlier. """ node = cluster.instances["node"] table = "cas_backup_empty_arrays" @@ -369,9 +382,21 @@ def test_backup_to_s3_with_empty_arrays(): expected = node.query(fingerprint.format(table)).strip() node.query( - f"BACKUP TABLE {table} TO {destination} SETTINGS allow_s3_native_copy = 1" + f"BACKUP TABLE {table} TO {destination} " + f"SETTINGS allow_s3_native_copy = 1, deduplicate_files = 0" ) + empty_files_in_backup = int( + node.query( + f""" + SELECT count() + FROM s3('{S3_AUTHORITY}/test/backups/{RUN_TOKEN}/empty_arrays/**', {S3_CREDENTIALS}, 'One') + WHERE _size = 0 + """ + ).strip() + ) + assert empty_files_in_backup > 0, "no zero-size file reached the backup writer" + node.query(f"DROP TABLE IF EXISTS {restored} SYNC") node.query(f"RESTORE TABLE {table} AS {restored} FROM {destination}") @@ -431,3 +456,252 @@ def test_incremental_backup_to_s3(): node.query(f"DROP TABLE {table} SYNC") node.query(f"DROP TABLE {restored} SYNC") + + +def test_move_between_plain_and_encrypted_s3_disks(): + """No CAS here. The capability predicate must only narrow: `sameKind` ignores `is_encrypted`, and + `DiskEncrypted` reports its delegate's description, so a predicate that replaced `operator==` + would let this pair through and the cast to `DiskObjectStorage` would throw. + """ + node = cluster.instances["node"] + table = "plain_to_encrypted" + + node.query(f"DROP TABLE IF EXISTS {table} SYNC") + node.query( + f""" + CREATE TABLE {table} (k UInt64, s String) + ENGINE = MergeTree ORDER BY k + SETTINGS storage_policy = 'plain_then_encrypted', min_bytes_for_wide_part = 0 + """ + ) + node.query( + f"INSERT INTO {table} SELECT number, randomPrintableASCII(64) FROM numbers({NUM_ROWS})" + ) + + expected = node.query(f"SELECT count(), sum(cityHash64(s)) FROM {table}").strip() + + node.query( + f"ALTER TABLE {table} MOVE PARTITION tuple() TO DISK 'disk_plain_s3_encrypted'" + ) + assert ( + node.query(f"SELECT count(), sum(cityHash64(s)) FROM {table}").strip() == expected + ) + + node.query(f"ALTER TABLE {table} MOVE PARTITION tuple() TO DISK 'backup_disk_s3'") + assert ( + node.query(f"SELECT count(), sum(cityHash64(s)) FROM {table}").strip() == expected + ) + + node.query(f"DROP TABLE {table} SYNC") + + +def test_plain_to_plain_still_copies_server_side(): + """The capability defaults to false, so a description that forgets to claim it silently loses the + server-side copy. Two ordinary s3 disks must keep it. + """ + node = cluster.instances["node"] + table = "plain_to_plain" + query_id = f"plain_to_plain_move_{RUN_TOKEN}" + + node.query(f"DROP TABLE IF EXISTS {table} SYNC") + node.query( + f""" + CREATE TABLE {table} (k UInt64, s String) + ENGINE = MergeTree ORDER BY k + SETTINGS storage_policy = 'plain_then_plain', min_bytes_for_wide_part = 0 + """ + ) + node.query( + f"INSERT INTO {table} SELECT number, randomPrintableASCII(64) FROM numbers({NUM_ROWS})" + ) + expected = node.query(f"SELECT count(), sum(cityHash64(s)) FROM {table}").strip() + + node.query( + f"ALTER TABLE {table} MOVE PARTITION tuple() TO DISK 'disk_plain_s3_second'", + query_id=query_id, + ) + + upload_part_copy, copy_object = copy_events(node, query_id) + assert ( + upload_part_copy + copy_object > 0 + ), "a non-CAS disk pair lost its server-side copy" + assert ( + node.query(f"SELECT count(), sum(cityHash64(s)) FROM {table}").strip() == expected + ) + + node.query(f"DROP TABLE {table} SYNC") + + +def test_backup_to_file_keeps_fs_copy(): + """Both sides take their description from `DiskLocal::getLocalDataSourceDescription`. A forgotten + claim there gives `false && false`, and `BackupWriterFile` silently stops using `fs::copy`. + """ + node = cluster.instances["node"] + table = "plain_local_to_file" + query_id = f"plain_local_backup_{RUN_TOKEN}" + + node.query(f"DROP TABLE IF EXISTS {table} SYNC") + node.query( + f""" + CREATE TABLE {table} (k UInt64, s String) + ENGINE = MergeTree ORDER BY k + SETTINGS min_bytes_for_wide_part = 0 + """ + ) + node.query( + f"INSERT INTO {table} SELECT number, randomPrintableASCII(64) FROM numbers({NUM_ROWS})" + ) + + node.query( + f"BACKUP TABLE {table} TO File('{RUN_TOKEN}/file_backup')", query_id=query_id + ) + node.query("SYSTEM FLUSH LOGS query_log") + + read_bytes = int( + node.query( + f""" + SELECT ProfileEvents['ReadBufferFromFileDescriptorReadBytes'] + FROM system.query_log + WHERE type = 'QueryFinish' AND query_id = '{query_id}' + ORDER BY event_time DESC LIMIT 1 + """ + ).strip() + ) + assert ( + read_bytes == 0 + ), "BACKUP TO File(...) read the data through the server instead of using fs::copy" + + node.query(f"DROP TABLE {table} SYNC") + + +def test_cached_cas_disk_is_not_whole_object(): + """`wrapWithCache` reuses the CAS metadata storage for a CAS disk, so the cache disk must inherit + the same answer and stay out of the server-side copy. + """ + node = cluster.instances["node"] + table = "cas_cached_move" + query_id = f"cas_cached_move_{RUN_TOKEN}" + + create_and_fill(node, table, "cas_cached_then_plain") + expected = column_fingerprints(node, table) + + node.query( + f"ALTER TABLE {table} MOVE PARTITION tuple() TO DISK 'backup_disk_s3'", + query_id=query_id, + ) + + upload_part_copy, copy_object = copy_events(node, query_id) + assert (upload_part_copy, copy_object) == ( + 0, + 0, + ), "a cache disk over CAS reported itself as whole-object" + assert column_fingerprints(node, table) == expected + + node.query(f"DROP TABLE {table} SYNC") + + +def test_backup_into_a_cas_disk_succeeds(): + """A behaviour change, not a goal of this work: the buffered path writes backup files rather than + part files, so the autocommit refusal for part files does not apply and the backup goes through. + """ + node = cluster.instances["node"] + table = "backup_into_cas" + restored = f"{table}_restored" + destination = f"Disk('disk_cas_backup_s3', '{RUN_TOKEN}/{table}')" + + node.query(f"DROP TABLE IF EXISTS {table} SYNC") + node.query( + f""" + CREATE TABLE {table} (k UInt64, s String) + ENGINE = MergeTree ORDER BY k + SETTINGS min_bytes_for_wide_part = 0 + """ + ) + node.query( + f"INSERT INTO {table} SELECT number, randomPrintableASCII(64) FROM numbers({NUM_ROWS})" + ) + expected = node.query(f"SELECT count(), sum(cityHash64(s)) FROM {table}").strip() + + node.query(f"BACKUP TABLE {table} TO {destination}") + + node.query(f"DROP TABLE IF EXISTS {restored} SYNC") + node.query(f"RESTORE TABLE {table} AS {restored} FROM {destination}") + assert ( + node.query(f"SELECT count(), sum(cityHash64(s)) FROM {restored}").strip() + == expected + ) + + node.query(f"DROP TABLE {table} SYNC") + node.query(f"DROP TABLE {restored} SYNC") + + +def test_backup_to_s3_with_empty_array_column(): + """A zero-size file reaches the CAS branch only when files are not deduplicated: with + `deduplicate_files = 1` the coordination drops it and this test would prove nothing. + """ + node = cluster.instances["node"] + table = "cas_backup_empty_arrays" + restored = f"{table}_restored" + destination = backup_destination("empty_arrays") + + node.query(f"DROP TABLE IF EXISTS {table} SYNC") + node.query( + f""" + CREATE TABLE {table} (k UInt64, empty Array(UInt32)) + ENGINE = MergeTree ORDER BY k + SETTINGS storage_policy = '{STORAGE_POLICY}', min_bytes_for_wide_part = 0 + """ + ) + node.query(f"INSERT INTO {table} SELECT number, [] FROM numbers({NUM_ROWS})") + expected = node.query(f"SELECT count(), sum(length(empty)) FROM {table}").strip() + + node.query( + f"BACKUP TABLE {table} TO {destination} " + f"SETTINGS allow_s3_native_copy = 1, deduplicate_files = 0" + ) + + node.query(f"DROP TABLE IF EXISTS {restored} SYNC") + node.query(f"RESTORE TABLE {table} AS {restored} FROM {destination}") + assert ( + node.query(f"SELECT count(), sum(length(empty)) FROM {restored}").strip() + == expected + ) + + node.query(f"DROP TABLE {table} SYNC") + node.query(f"DROP TABLE {restored} SYNC") + + +def test_incremental_backup_of_a_cas_table(): + """The only path that can make `start_pos` non-zero. The CAS branch computes the physical start as + `payload_offset + start_pos`, so a dropped `start_pos` would corrupt the increment. + """ + node = cluster.instances["node"] + table = "cas_incremental" + restored = f"{table}_restored" + base = backup_destination("incremental_base") + increment = backup_destination("incremental_delta") + + create_and_fill(node, table) + node.query(f"BACKUP TABLE {table} TO {base}") + + node.query( + f""" + INSERT INTO {table} + SELECT + number + {NUM_ROWS}, + randomPrintableASCII(64), + if(number % 7 = 0, NULL, toInt64(number)), + [toUInt32(number), toUInt32(number + 1)] + FROM numbers({NUM_ROWS}) + """ + ) + expected = column_fingerprints(node, table) + + node.query(f"BACKUP TABLE {table} TO {increment} SETTINGS base_backup = {base}") + + node.query(f"DROP TABLE IF EXISTS {restored} SYNC") + node.query(f"RESTORE TABLE {table} AS {restored} FROM {increment}") + assert column_fingerprints(node, restored) == expected + + node.query(f"DROP TABLE {table} SYNC") + node.query(f"DROP TABLE {restored} SYNC") From cbc9f624f94ca1905f9dbc046791b1a5d557c0d4 Mon Sep 17 00:00:00 2001 From: Konstantin Morozov Date: Wed, 30 Sep 2026 21:06:21 +0200 Subject: [PATCH 16/30] Document the ranged same-store copy as a bucket capability `BACKUP` to an `S3(...)` destination on the pool's own endpoint copies a blob's payload without its header, and only `UploadPartCopy` can name a byte range. A store without it is still supported - the blob goes through the ClickHouse server - but the distinction belongs in the capability table next to the other store requirements, where an operator choosing a backend will look for it. `GCS` is the store this applies to today: its current XML API has no `UploadPartCopy`, so `Client::supportsMultiPartCopy` reports `false`. Co-Authored-By: Claude Opus 5 --- docs/en/antalya/cas/bucket-requirements.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/en/antalya/cas/bucket-requirements.md b/docs/en/antalya/cas/bucket-requirements.md index 700d0a94d371..2a30c526955c 100644 --- a/docs/en/antalya/cas/bucket-requirements.md +++ b/docs/en/antalya/cas/bucket-requirements.md @@ -23,6 +23,7 @@ these conditions is refused rather than trusted. | Unconditional complete-object publication | `Backend::publishBlob` | An absent or condemned content-addressed body is replaced atomically; native stores may use multipart | | Native same-store copy when `cas_staging_backend = s3` | `IObjectStorage::copyObject` with `ObjectStorageCopyMode::NativeOnly` | The first absent staged publication may copy its complete object without a client-side fallback | | Exact-token delete | `Backend::deleteExact` | GC must delete only the incarnation it condemned, never a replacement | +| Ranged same-store copy, for backups only | `UploadPartCopy` through `copyS3File` | A `BACKUP` to an `S3(...)` destination on the pool's own endpoint copies a blob's payload without its header. Optional: a store that lacks it copies through the ClickHouse server instead, which is slower but correct. `GCS` lacks it by its current XML API, so `Client::supportsMultiPartCopy` reports `false` there | | Ranged `GET` | `Backend::get` / `Backend::getStream` with a `Range` | Opening one column file of a part costs one bounded read, not a whole-object fetch | | `LIST` with a resumable cursor | `Backend::list` | GC discovery and the orphan-manifest sweep page through the pool without a separate index | | No versioning / no delete markers | probed by `runCapabilityProbe`; `created_delete_marker` on `DeleteOutcome` | A delete marker over a live key would break exact-token semantics — GC would archive instead of reclaim | From 869d79517a88c8e96936362032711838e39bcac7 Mon Sep 17 00:00:00 2001 From: Konstantin Morozov Date: Wed, 30 Sep 2026 21:10:53 +0200 Subject: [PATCH 17/30] Drop two tests that duplicate existing coverage `test_backup_to_s3_with_empty_array_column` and `test_incremental_backup_of_a_cas_table` repeat what `test_backup_to_s3_with_empty_arrays` and `test_incremental_backup_to_s3` already do, and the existing pair is stronger: the empty-file one asserts that a zero-size file actually reached the backup writer, rather than only checking the data after a restore. Two more cluster round-trips for no new coverage is time every CI run pays. Co-Authored-By: Claude Opus 5 --- .../test_cas_backup_s3_native_copy/test.py | 72 ------------------- 1 file changed, 72 deletions(-) diff --git a/tests/integration/test_cas_backup_s3_native_copy/test.py b/tests/integration/test_cas_backup_s3_native_copy/test.py index 1ff09229c807..e03ddeb35a1d 100644 --- a/tests/integration/test_cas_backup_s3_native_copy/test.py +++ b/tests/integration/test_cas_backup_s3_native_copy/test.py @@ -633,75 +633,3 @@ def test_backup_into_a_cas_disk_succeeds(): node.query(f"DROP TABLE {table} SYNC") node.query(f"DROP TABLE {restored} SYNC") - - -def test_backup_to_s3_with_empty_array_column(): - """A zero-size file reaches the CAS branch only when files are not deduplicated: with - `deduplicate_files = 1` the coordination drops it and this test would prove nothing. - """ - node = cluster.instances["node"] - table = "cas_backup_empty_arrays" - restored = f"{table}_restored" - destination = backup_destination("empty_arrays") - - node.query(f"DROP TABLE IF EXISTS {table} SYNC") - node.query( - f""" - CREATE TABLE {table} (k UInt64, empty Array(UInt32)) - ENGINE = MergeTree ORDER BY k - SETTINGS storage_policy = '{STORAGE_POLICY}', min_bytes_for_wide_part = 0 - """ - ) - node.query(f"INSERT INTO {table} SELECT number, [] FROM numbers({NUM_ROWS})") - expected = node.query(f"SELECT count(), sum(length(empty)) FROM {table}").strip() - - node.query( - f"BACKUP TABLE {table} TO {destination} " - f"SETTINGS allow_s3_native_copy = 1, deduplicate_files = 0" - ) - - node.query(f"DROP TABLE IF EXISTS {restored} SYNC") - node.query(f"RESTORE TABLE {table} AS {restored} FROM {destination}") - assert ( - node.query(f"SELECT count(), sum(length(empty)) FROM {restored}").strip() - == expected - ) - - node.query(f"DROP TABLE {table} SYNC") - node.query(f"DROP TABLE {restored} SYNC") - - -def test_incremental_backup_of_a_cas_table(): - """The only path that can make `start_pos` non-zero. The CAS branch computes the physical start as - `payload_offset + start_pos`, so a dropped `start_pos` would corrupt the increment. - """ - node = cluster.instances["node"] - table = "cas_incremental" - restored = f"{table}_restored" - base = backup_destination("incremental_base") - increment = backup_destination("incremental_delta") - - create_and_fill(node, table) - node.query(f"BACKUP TABLE {table} TO {base}") - - node.query( - f""" - INSERT INTO {table} - SELECT - number + {NUM_ROWS}, - randomPrintableASCII(64), - if(number % 7 = 0, NULL, toInt64(number)), - [toUInt32(number), toUInt32(number + 1)] - FROM numbers({NUM_ROWS}) - """ - ) - expected = column_fingerprints(node, table) - - node.query(f"BACKUP TABLE {table} TO {increment} SETTINGS base_backup = {base}") - - node.query(f"DROP TABLE IF EXISTS {restored} SYNC") - node.query(f"RESTORE TABLE {table} AS {restored} FROM {increment}") - assert column_fingerprints(node, restored) == expected - - node.query(f"DROP TABLE {table} SYNC") - node.query(f"DROP TABLE {restored} SYNC") From 18d08375f9b20a9387f5e1efc762d7dad6577640 Mon Sep 17 00:00:00 2001 From: Konstantin Morozov Date: Wed, 30 Sep 2026 21:49:01 +0200 Subject: [PATCH 18/30] Keep the Azure gate on the capability alone, and forward the S3 client Three fixes from the whole-branch review. `BackupIO_AzureBlobStorage` went back to checking the capability flags directly instead of `canUseNativeCopyWith`. The predicate carries `sameKind`, which compares the description, and the two Azure sides build that string differently: a disk reports `Endpoint::getServiceEndpoint`, while the backup reports `ConnectionParams::getConnectionURL`, which for a `connection_string` disk parses that string and returns the service URL instead. Requiring them equal would have taken Azure's native copy away from a working configuration with no error and no log line. The capability is the only dimension this work argued about, so it is the only one the Azure conditions gained. `ReadOnlyDiskWrapper` now forwards `getS3StorageClient` and `tryGetS3StorageClient`. It already forwards `isContentAddressed` and `getMetadataStorage`, so a CAS disk behind the wrapper reaches the new backup branch, which then asks the disk for its client and got `NOT_IMPLEMENTED` from the base class - an exception that escapes the copy rather than falling back to buffers. `test_backup_to_file_keeps_fs_copy` asserted that the backup read zero bytes through a file descriptor. It cannot: `BackupFileInfo` checksums every entry without a precalculated hash, and `checksums.txt`, `columns.txt`, `count.txt` and the codec and version files have none. The assertion now compares what was read against the part's size on disk, which is what "the data did not go through the server" actually means. Co-Authored-By: Claude Opus 5 --- src/Backups/BackupIO_AzureBlobStorage.cpp | 6 ++-- src/Backups/BackupIO_S3.cpp | 16 +++++++--- .../ContentAddressedMetadataStorage.cpp | 4 --- src/Disks/ReadOnlyDiskWrapper.h | 7 +++++ src/Disks/tests/gtest_ca_wiring.cpp | 8 +---- .../test_cas_backup_s3_native_copy/test.py | 29 +++++++++---------- 6 files changed, 38 insertions(+), 32 deletions(-) diff --git a/src/Backups/BackupIO_AzureBlobStorage.cpp b/src/Backups/BackupIO_AzureBlobStorage.cpp index f7397ae7a24d..693b063c705c 100644 --- a/src/Backups/BackupIO_AzureBlobStorage.cpp +++ b/src/Backups/BackupIO_AzureBlobStorage.cpp @@ -88,7 +88,8 @@ void BackupReaderAzureBlobStorage::copyFileToDisk(const String & path_in_backup, LOG_TRACE(log, "Source description {}, destination description {}", data_source_description.description, destination_data_source_description.description); if (destination_data_source_description.object_storage_type == ObjectStorageType::Azure && destination_data_source_description.is_encrypted == encrypted_in_backup - && destination_data_source_description.canUseNativeCopyWith(data_source_description)) + && destination_data_source_description.files_are_whole_objects + && data_source_description.files_are_whole_objects) { LOG_TRACE(log, "Copying {} from AzureBlobStorage to disk {}", path_in_backup, destination_disk->getName()); auto write_blob_function = [&](const Strings & dst_blob_path, WriteMode mode, const std::optional &) -> size_t @@ -167,7 +168,8 @@ void BackupWriterAzureBlobStorage::copyFileFromDisk( LOG_TRACE(log, "Source description {}, destination description {}", source_data_source_description.description, data_source_description.description); if (source_data_source_description.object_storage_type == ObjectStorageType::Azure && source_data_source_description.is_encrypted == copy_encrypted - && source_data_source_description.canUseNativeCopyWith(data_source_description)) + && source_data_source_description.files_are_whole_objects + && data_source_description.files_are_whole_objects) { /// getBlobPath() can return more than 2 elements if the file is stored as multiple objects in AzureBlobStorage container. /// In this case we can't use the native copy. diff --git a/src/Backups/BackupIO_S3.cpp b/src/Backups/BackupIO_S3.cpp index 489f30789e0f..0da286fed6f7 100644 --- a/src/Backups/BackupIO_S3.cpp +++ b/src/Backups/BackupIO_S3.cpp @@ -451,6 +451,13 @@ bool BackupWriterS3::tryNativeCopyFromContentAddressedDisk( if (!plan) return false; + if (plan->object.remote_path.empty()) + throw Exception( + ErrorCodes::LOGICAL_ERROR, + "Content-addressed file {} on disk {} resolved to a blob with an empty key", + src_path, + src_disk->getName()); + const UInt64 payload_size = plan->payload_end - plan->payload_offset; if (start_pos > payload_size || length > payload_size - start_pos) throw Exception( @@ -465,12 +472,13 @@ bool BackupWriterS3::tryNativeCopyFromContentAddressedDisk( if (!source_data_source_description.sameKind(data_source_description)) return false; - if (plan->object.remote_path.empty()) - return false; - const String src_bucket = src_disk->getObjectStorage()->getObjectsNamespace(); if (src_bucket.empty()) - return false; + throw Exception( + ErrorCodes::LOGICAL_ERROR, + "Disk {} has the same S3 endpoint as the backup but no bucket for content-addressed file {}", + src_disk->getName(), + src_path); auto src_client = disk_client_factory.getOrCreate(src_disk); if (!src_client->supportsMultiPartCopy()) diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.cpp index 26101e24e1ea..c409cd318f59 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.cpp +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.cpp @@ -2155,10 +2155,6 @@ std::optional ContentAddressedMet return std::nullopt; const auto location = snap.pool->locate(*entry); BlobViewPlan plan; - /// bytes_size is the readable extent of THIS file's window, NOT the whole blob: a - /// right-bounded read stops at payload_end, and a shared blob's bytes beyond it belong - /// to other files. The caches key on the physical blob key, so payload ranges are - /// shared between every part that references the same blob. plan.object = StoredObject(physicalKey(location.key), path, location.offset + location.length); plan.payload_offset = location.offset; plan.payload_end = location.offset + location.length; diff --git a/src/Disks/ReadOnlyDiskWrapper.h b/src/Disks/ReadOnlyDiskWrapper.h index 9a38e85cde77..9d812cb7da1c 100644 --- a/src/Disks/ReadOnlyDiskWrapper.h +++ b/src/Disks/ReadOnlyDiskWrapper.h @@ -1,5 +1,7 @@ #pragma once +#include "config.h" + #include #include @@ -95,6 +97,11 @@ class ReadOnlyDiskWrapper : public IDisk /// drops out of the CAS introspection paths. bool isContentAddressed() const override { return delegate->isContentAddressed(); } +#if USE_AWS_S3 + std::shared_ptr getS3StorageClient() const override { return delegate->getS3StorageClient(); } + std::shared_ptr tryGetS3StorageClient() const override { return delegate->tryGetS3StorageClient(); } +#endif + std::unordered_map getSerializedMetadata(const std::vector & file_paths) const override { return delegate->getSerializedMetadata(file_paths); } UInt32 getRefCount(const String & path) const override { return delegate->getRefCount(path); } diff --git a/src/Disks/tests/gtest_ca_wiring.cpp b/src/Disks/tests/gtest_ca_wiring.cpp index db32c32b43b5..098be83ff5ce 100644 --- a/src/Disks/tests/gtest_ca_wiring.cpp +++ b/src/Disks/tests/gtest_ca_wiring.cpp @@ -534,13 +534,7 @@ TEST(CASWiringRead, BlobViewPlanRidesTheStandardPipeline) DB::readStringUntilEOF(manifest_bytes, *buf); } EXPECT_EQ(manifest_bytes, "u-123"); - /// Not a `getBlobViewPlan` call on the in-manifest path here (all-tree Task 6/9: uuid.txt is now - /// a real Inline manifest entry): `getBlobViewPlan`'s only production caller - /// (`DiskObjectStorage::prepareRead`) never reaches it once `prepareInManifestRead` returns true - /// above — `getBlobViewPlan`'s precondition is "confirmed not in-manifest-servable," which calling - /// it directly on an Inline path violates. Pre-Task-9 this assertion passed only by coincidence - /// (uuid.txt was not a manifest entry at all, so `findFile` returned not-found, not because - /// `getBlobViewPlan` gracefully handles an Inline entry it does find). + EXPECT_FALSE(storage->getBlobViewPlan("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/uuid.txt").has_value()); /// Blob-backed file: a real physical key and a payload-sized window whose extent equals /// the object's readable size (a right-bounded read never overshoots the window). diff --git a/tests/integration/test_cas_backup_s3_native_copy/test.py b/tests/integration/test_cas_backup_s3_native_copy/test.py index e03ddeb35a1d..f314aecb6c7f 100644 --- a/tests/integration/test_cas_backup_s3_native_copy/test.py +++ b/tests/integration/test_cas_backup_s3_native_copy/test.py @@ -233,18 +233,6 @@ def test_blobs_use_ranged_copy_and_inline_falls_back(): assert upload_part_copy > 0, "no ranged server-side copy happened" assert copy_object == 0, "CopyObject has no range: the envelope would land in the backup" - - uploaded = int( - node.query( - f""" - SELECT ProfileEvents['S3PutObject'] + ProfileEvents['S3UploadPart'] - FROM system.query_log - WHERE type = 'QueryFinish' AND query_id = '{query_id}' - ORDER BY event_time DESC LIMIT 1 - """ - ).strip() - ) - assert uploaded > 0, "nothing went through buffers, so no inline entry was exercised" assert uploaded > 0, "inline entries have no object and must be uploaded through buffers" node.query(f"DROP TABLE IF EXISTS {restored} SYNC") @@ -567,9 +555,20 @@ def test_backup_to_file_keeps_fs_copy(): """ ).strip() ) - assert ( - read_bytes == 0 - ), "BACKUP TO File(...) read the data through the server instead of using fs::copy" + bytes_on_disk = int( + node.query( + f""" + SELECT sum(bytes_on_disk) FROM system.parts + WHERE table = '{table}' AND active + """ + ).strip() + ) + + assert bytes_on_disk > 0, "the table has no active parts, so the assertion below proves nothing" + assert read_bytes * 2 < bytes_on_disk, ( + f"BACKUP TO File(...) read {read_bytes} of {bytes_on_disk} bytes through the server, " + "so it stopped using fs::copy for the data" + ) node.query(f"DROP TABLE {table} SYNC") From d912c657051722e040ca86b63a2b2b0fa272bf3f Mon Sep 17 00:00:00 2001 From: Konstantin Morozov Date: Wed, 30 Sep 2026 21:54:27 +0200 Subject: [PATCH 19/30] Fix the three integration failures CI found `BACKUP TABLE t TO Disk('', ...)` does not work, and the test that claimed it does was wrong. A backup's own layout mirrors the table's data directory - `.../data///all_1_1_0/` - and `Cas::isPartFilePath` looks for a part-directory component anywhere in the path, so a backup file counts as a part file and the autocommit refusal applies. The test now asserts the refusal, and the documented limitation says a `CAS` disk cannot be a backup destination rather than claiming the opposite. `test_backup_to_file_keeps_fs_copy` had its bound the wrong way round. Every entry pays one checksum pass through `ReadBufferFromFileDescriptor`, so one pass over the data is what a working `fs::copy` looks like, and the run that failed - 6832069 bytes read against 6830303 on disk - was the fast path doing its job. A second pass is what a lost `fs::copy` would cost, so the assertion is now against one and a half passes. Co-Authored-By: Claude Opus 5 --- docs/en/antalya/cas/operations/backup.md | 10 +++---- .../test_cas_backup_s3_native_copy/test.py | 30 +++++++------------ 2 files changed, 16 insertions(+), 24 deletions(-) diff --git a/docs/en/antalya/cas/operations/backup.md b/docs/en/antalya/cas/operations/backup.md index e42f4531f8e8..a389e4026f7c 100644 --- a/docs/en/antalya/cas/operations/backup.md +++ b/docs/en/antalya/cas/operations/backup.md @@ -135,8 +135,8 @@ It is a useful building block, not a replacement for `BACKUP`. byte range. Without multipart copy, and on a store whose API lacks `UploadPartCopy` such as `GCS`, every blob goes through ClickHouse's buffers. - Restore onto a `CAS` disk always writes through ClickHouse, see [restore](#restore). -- A disk-level copy of a single **part** file onto a `CAS` disk, outside of `RESTORE`, is rejected with - `NOT_IMPLEMENTED`: a `CAS` disk accepts part files only as a whole part in one transaction. A - `Disk(...)` destination that happens to be a `CAS` disk is a different case — a backup holds its own - files rather than part files, so `BACKUP TABLE t TO Disk('', 'b1')` writes them through the - buffers and succeeds. +- A disk-level copy of a single part file onto a `CAS` disk, outside of `RESTORE`, is rejected with + `NOT_IMPLEMENTED`: a `CAS` disk accepts part files only as a whole part in one transaction. +- A `CAS` disk cannot be a backup destination: `BACKUP TABLE t TO Disk('', 'b1')` is rejected + the same way. A backup's own layout mirrors the table's data directory, so its files sit under a part + directory as well and count as part files. diff --git a/tests/integration/test_cas_backup_s3_native_copy/test.py b/tests/integration/test_cas_backup_s3_native_copy/test.py index f314aecb6c7f..a36c397cbb4f 100644 --- a/tests/integration/test_cas_backup_s3_native_copy/test.py +++ b/tests/integration/test_cas_backup_s3_native_copy/test.py @@ -15,6 +15,7 @@ import pytest +from helpers.client import QueryRuntimeException from helpers.cluster import ClickHouseCluster cluster = ClickHouseCluster(__file__) @@ -565,9 +566,9 @@ def test_backup_to_file_keeps_fs_copy(): ) assert bytes_on_disk > 0, "the table has no active parts, so the assertion below proves nothing" - assert read_bytes * 2 < bytes_on_disk, ( - f"BACKUP TO File(...) read {read_bytes} of {bytes_on_disk} bytes through the server, " - "so it stopped using fs::copy for the data" + assert read_bytes < bytes_on_disk * 3 // 2, ( + f"BACKUP TO File(...) read {read_bytes} bytes with {bytes_on_disk} on disk. One pass is the " + "checksum pass every entry pays; a second pass means fs::copy was replaced by a buffered copy" ) node.query(f"DROP TABLE {table} SYNC") @@ -597,15 +598,13 @@ def test_cached_cas_disk_is_not_whole_object(): assert column_fingerprints(node, table) == expected node.query(f"DROP TABLE {table} SYNC") - - -def test_backup_into_a_cas_disk_succeeds(): - """A behaviour change, not a goal of this work: the buffered path writes backup files rather than - part files, so the autocommit refusal for part files does not apply and the backup goes through. +def test_backup_into_a_cas_disk_is_rejected(): + """A `CAS` disk takes a part only as a whole part in one transaction. A backup's own layout mirrors + the table's data directory, so its files sit under a part directory too and `isPartFilePath` matches + them - which is why writing a backup into a `CAS` disk is refused rather than silently accepted. """ node = cluster.instances["node"] table = "backup_into_cas" - restored = f"{table}_restored" destination = f"Disk('disk_cas_backup_s3', '{RUN_TOKEN}/{table}')" node.query(f"DROP TABLE IF EXISTS {table} SYNC") @@ -619,16 +618,9 @@ def test_backup_into_a_cas_disk_succeeds(): node.query( f"INSERT INTO {table} SELECT number, randomPrintableASCII(64) FROM numbers({NUM_ROWS})" ) - expected = node.query(f"SELECT count(), sum(cityHash64(s)) FROM {table}").strip() - node.query(f"BACKUP TABLE {table} TO {destination}") - - node.query(f"DROP TABLE IF EXISTS {restored} SYNC") - node.query(f"RESTORE TABLE {table} AS {restored} FROM {destination}") - assert ( - node.query(f"SELECT count(), sum(cityHash64(s)) FROM {restored}").strip() - == expected - ) + with pytest.raises(QueryRuntimeException) as raised: + node.query(f"BACKUP TABLE {table} TO {destination}") + assert "Autocommit writes are not supported for content part files" in str(raised.value) node.query(f"DROP TABLE {table} SYNC") - node.query(f"DROP TABLE {restored} SYNC") From 9544ebff44f6a48deaf720833b7496f03ede7666 Mon Sep 17 00:00:00 2001 From: Konstantin Morozov Date: Wed, 30 Sep 2026 21:58:08 +0200 Subject: [PATCH 20/30] Let the zero-size assertion see the object it looks for `test_backup_to_s3_with_empty_arrays` counted objects in the backup with `_size = 0` through the `s3` table function, which cannot report them: `s3_skip_empty_files` defaults to true, and the listing drops a zero-size object before the `WHERE` ever sees it. The assertion would have failed even with the file sitting in the backup. The query now turns that setting off. Co-Authored-By: Claude Opus 5 --- tests/integration/test_cas_backup_s3_native_copy/test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/integration/test_cas_backup_s3_native_copy/test.py b/tests/integration/test_cas_backup_s3_native_copy/test.py index a36c397cbb4f..94a085b37eb2 100644 --- a/tests/integration/test_cas_backup_s3_native_copy/test.py +++ b/tests/integration/test_cas_backup_s3_native_copy/test.py @@ -381,6 +381,7 @@ def test_backup_to_s3_with_empty_arrays(): SELECT count() FROM s3('{S3_AUTHORITY}/test/backups/{RUN_TOKEN}/empty_arrays/**', {S3_CREDENTIALS}, 'One') WHERE _size = 0 + SETTINGS s3_skip_empty_files = 0 """ ).strip() ) From 9490050e87fcbc4133e54e509710aa1db5029e73 Mon Sep 17 00:00:00 2001 From: Konstantin Morozov Date: Thu, 1 Oct 2026 09:44:35 +0200 Subject: [PATCH 21/30] update doc Signed-off-by: Konstantin Morozov --- docs/en/antalya/cas/bucket-requirements.md | 2 +- docs/en/antalya/cas/operations/backup.md | 17 ++++++++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/en/antalya/cas/bucket-requirements.md b/docs/en/antalya/cas/bucket-requirements.md index 2a30c526955c..ea4bfe0dbafe 100644 --- a/docs/en/antalya/cas/bucket-requirements.md +++ b/docs/en/antalya/cas/bucket-requirements.md @@ -23,7 +23,7 @@ these conditions is refused rather than trusted. | Unconditional complete-object publication | `Backend::publishBlob` | An absent or condemned content-addressed body is replaced atomically; native stores may use multipart | | Native same-store copy when `cas_staging_backend = s3` | `IObjectStorage::copyObject` with `ObjectStorageCopyMode::NativeOnly` | The first absent staged publication may copy its complete object without a client-side fallback | | Exact-token delete | `Backend::deleteExact` | GC must delete only the incarnation it condemned, never a replacement | -| Ranged same-store copy, for backups only | `UploadPartCopy` through `copyS3File` | A `BACKUP` to an `S3(...)` destination on the pool's own endpoint copies a blob's payload without its header. Optional: a store that lacks it copies through the ClickHouse server instead, which is slower but correct. `GCS` lacks it by its current XML API, so `Client::supportsMultiPartCopy` reports `false` there | +| Ranged same-store copy, for backups only | `UploadPartCopy` through `copyS3File` | A `BACKUP` to an `S3(...)` destination on the pool's own endpoint copies a blob's payload without its header. Optional, but only when the lack is recognized: `GCS` lacks it by its current XML API, so `Client::supportsMultiPartCopy` reports `false` there and the copy goes through the ClickHouse server, which is slower but correct. A store that instead refuses `UploadPartCopy` with an error other than `AccessDenied` fails the `BACKUP`; set `s3_allow_multipart_copy = 0` there | | Ranged `GET` | `Backend::get` / `Backend::getStream` with a `Range` | Opening one column file of a part costs one bounded read, not a whole-object fetch | | `LIST` with a resumable cursor | `Backend::list` | GC discovery and the orphan-manifest sweep page through the pool without a separate index | | No versioning / no delete markers | probed by `runCapabilityProbe`; `created_delete_marker` on `DeleteOutcome` | A delete marker over a live key would break exact-token semantics — GC would archive instead of reclaim | diff --git a/docs/en/antalya/cas/operations/backup.md b/docs/en/antalya/cas/operations/backup.md index a389e4026f7c..6c42609c2eb7 100644 --- a/docs/en/antalya/cas/operations/backup.md +++ b/docs/en/antalya/cas/operations/backup.md @@ -75,9 +75,14 @@ copy of a blob therefore names the payload range. A copy of the whole object wou into the backup, and a later restore would read wrong data. If a byte range cannot be copied inside `S3`, `CAS` does not fall back to copying the whole object — -the file is read and written through ClickHouse instead. That is slower, but correct. This happens -when multipart copy is off, and on a store whose API has no `UploadPartCopy` at all: `GCS` is one, so -by its current API a blob there always goes through ClickHouse. +the file is read and written through ClickHouse instead. That is slower, but correct. ClickHouse takes +that path on its own in three cases: the query sets `s3_allow_multipart_copy = 0`; the store is +recognized as `GCS`, whose current XML API has no `UploadPartCopy`; or `UploadPartCopy` is refused with +`AccessDenied`. + +A store that refuses `UploadPartCopy` with any other error fails the `BACKUP` instead, because +ClickHouse cannot tell a missing capability from a transient failure. On such a store, set +`s3_allow_multipart_copy = 0` on the `BACKUP` query. | Turn off the copy inside `S3` | Turn off the range copy | |---|---| @@ -132,8 +137,10 @@ It is a useful building block, not a replacement for `BACKUP`. store, and only when the destination is on the same endpoint as the pool. Other destinations, including `Disk(...)`, get the copy through ClickHouse's buffers. - A blob is copied inside `S3` only with multipart copy (`UploadPartCopy`), because only it can name a - byte range. Without multipart copy, and on a store whose API lacks `UploadPartCopy` such as `GCS`, - every blob goes through ClickHouse's buffers. + byte range. With `s3_allow_multipart_copy = 0`, on a store recognized as `GCS`, and when + `UploadPartCopy` is refused with `AccessDenied`, every blob goes through ClickHouse's buffers + instead. A store that refuses `UploadPartCopy` with another error fails the `BACKUP`; set + `s3_allow_multipart_copy = 0` there. - Restore onto a `CAS` disk always writes through ClickHouse, see [restore](#restore). - A disk-level copy of a single part file onto a `CAS` disk, outside of `RESTORE`, is rejected with `NOT_IMPLEMENTED`: a `CAS` disk accepts part files only as a whole part in one transaction. From 3cb053df3c705dac68ead5d648438194a10b1777 Mon Sep 17 00:00:00 2001 From: Konstantin Morozov Date: Thu, 1 Oct 2026 10:17:06 +0200 Subject: [PATCH 22/30] change check order Signed-off-by: Konstantin Morozov --- src/Backups/BackupIO_S3.cpp | 43 +++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/src/Backups/BackupIO_S3.cpp b/src/Backups/BackupIO_S3.cpp index 0da286fed6f7..6dbf2b0f2a37 100644 --- a/src/Backups/BackupIO_S3.cpp +++ b/src/Backups/BackupIO_S3.cpp @@ -386,7 +386,9 @@ void BackupWriterS3::copyFileFromDisk( { /// Use the native copy as a more optimal way to copy a file from S3 to S3 if it's possible. /// We don't check for `has_throttling` here because the native copy almost doesn't use network. - if (!copy_encrypted) + auto source_data_source_description = src_disk->getDataSourceDescription(); + + if (!copy_encrypted && !source_data_source_description.is_encrypted) { if (auto * ca = tryGetContentAddressedExchange(src_disk)) { @@ -398,7 +400,6 @@ void BackupWriterS3::copyFileFromDisk( } } - auto source_data_source_description = src_disk->getDataSourceDescription(); if (source_data_source_description.canUseNativeCopyWith(data_source_description) && (source_data_source_description.is_encrypted == copy_encrypted)) { /// getBlobPath() can return more than 2 elements if the file is stored as multiple objects in S3 bucket. @@ -447,10 +448,30 @@ bool BackupWriterS3::tryNativeCopyFromContentAddressedDisk( if (length == 0) return false; + auto source_data_source_description = src_disk->getDataSourceDescription(); + if (!source_data_source_description.sameKind(data_source_description)) + return false; + + auto src_client = disk_client_factory.getOrCreate(src_disk); + if (!src_client->supportsMultiPartCopy()) + return false; + const auto plan = ca.getBlobViewPlan(src_path); if (!plan) return false; + const UInt64 src_offset = plan->payload_offset + start_pos; + if (src_offset == 0) + return false; + + const String src_bucket = src_disk->getObjectStorage()->getObjectsNamespace(); + if (src_bucket.empty()) + throw Exception( + ErrorCodes::LOGICAL_ERROR, + "Disk {} has the same S3 endpoint as the backup but no bucket for content-addressed file {}", + src_disk->getName(), + src_path); + if (plan->object.remote_path.empty()) throw Exception( ErrorCodes::LOGICAL_ERROR, @@ -468,22 +489,6 @@ bool BackupWriterS3::tryNativeCopyFromContentAddressedDisk( src_path, payload_size); - auto source_data_source_description = src_disk->getDataSourceDescription(); - if (!source_data_source_description.sameKind(data_source_description)) - return false; - - const String src_bucket = src_disk->getObjectStorage()->getObjectsNamespace(); - if (src_bucket.empty()) - throw Exception( - ErrorCodes::LOGICAL_ERROR, - "Disk {} has the same S3 endpoint as the backup but no bucket for content-addressed file {}", - src_disk->getName(), - src_path); - - auto src_client = disk_client_factory.getOrCreate(src_disk); - if (!src_client->supportsMultiPartCopy()) - return false; - LOG_TRACE( log, "Copying the payload of content-addressed file {} from disk {} to S3 as a ranged server-side copy", @@ -494,7 +499,7 @@ bool BackupWriterS3::tryNativeCopyFromContentAddressedDisk( std::move(src_client), src_bucket, /* src_key */ plan->object.remote_path, - /* src_offset */ plan->payload_offset + start_pos, + src_offset, length, /* dest_s3_client */ client, /* dest_bucket */ s3_uri.bucket, From 17fd06835cf1fc6144a35b6a85a53c9d9c7c8f96 Mon Sep 17 00:00:00 2001 From: Konstantin Morozov Date: Thu, 1 Oct 2026 12:54:54 +0200 Subject: [PATCH 23/30] add events to test Signed-off-by: Konstantin Morozov --- src/Backups/BackupIO_S3.cpp | 14 ++++-- .../test_cas_backup_s3_native_copy/test.py | 47 ++++++++++++++++++- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/src/Backups/BackupIO_S3.cpp b/src/Backups/BackupIO_S3.cpp index 6dbf2b0f2a37..de89e951752e 100644 --- a/src/Backups/BackupIO_S3.cpp +++ b/src/Backups/BackupIO_S3.cpp @@ -460,10 +460,6 @@ bool BackupWriterS3::tryNativeCopyFromContentAddressedDisk( if (!plan) return false; - const UInt64 src_offset = plan->payload_offset + start_pos; - if (src_offset == 0) - return false; - const String src_bucket = src_disk->getObjectStorage()->getObjectsNamespace(); if (src_bucket.empty()) throw Exception( @@ -479,6 +475,14 @@ bool BackupWriterS3::tryNativeCopyFromContentAddressedDisk( src_path, src_disk->getName()); + if (plan->payload_offset == 0) + throw Exception( + ErrorCodes::LOGICAL_ERROR, + "Content-addressed file {} on disk {} resolved to a blob whose payload starts at byte 0, but a blob always " + "begins with an envelope header", + src_path, + src_disk->getName()); + const UInt64 payload_size = plan->payload_end - plan->payload_offset; if (start_pos > payload_size || length > payload_size - start_pos) throw Exception( @@ -489,6 +493,8 @@ bool BackupWriterS3::tryNativeCopyFromContentAddressedDisk( src_path, payload_size); + const UInt64 src_offset = plan->payload_offset + start_pos; + LOG_TRACE( log, "Copying the payload of content-addressed file {} from disk {} to S3 as a ranged server-side copy", diff --git a/tests/integration/test_cas_backup_s3_native_copy/test.py b/tests/integration/test_cas_backup_s3_native_copy/test.py index 94a085b37eb2..89b8e2851bef 100644 --- a/tests/integration/test_cas_backup_s3_native_copy/test.py +++ b/tests/integration/test_cas_backup_s3_native_copy/test.py @@ -83,6 +83,26 @@ def column_fingerprints(node, table): return dict(zip(["count"] + COLUMNS, row)) +def transfer_events(node, query_id): + """Which mechanism moved the bytes: a ranged or whole-object copy inside `S3`, or buffers.""" + node.query("SYSTEM FLUSH LOGS query_log") + row = node.query( + f""" + SELECT + ProfileEvents['S3UploadPartCopy'], + ProfileEvents['S3CopyObject'], + ProfileEvents['S3PutObject'] + ProfileEvents['S3UploadPart'], + ProfileEvents['S3GetObject'] + FROM system.query_log + WHERE type = 'QueryFinish' AND query_id = '{query_id}' + ORDER BY event_time DESC LIMIT 1 + """ + ).strip() + assert row, f"no query_log row for {query_id}" + names = ["ranged_copy", "whole_copy", "uploaded", "downloaded"] + return dict(zip(names, (int(value) for value in row.split("\t")))) + + @pytest.mark.parametrize("allow_native_copy", [True, False]) def test_native_copy_round_trip(allow_native_copy): node = cluster.instances["node"] @@ -90,20 +110,43 @@ def test_native_copy_round_trip(allow_native_copy): table = f"cas_backup_{suffix}" restored = f"{table}_restored" destination = backup_destination(suffix) + backup_query_id = f"{table}_backup_{RUN_TOKEN}" + restore_query_id = f"{table}_restore_{RUN_TOKEN}" create_and_fill(node, table) expected = column_fingerprints(node, table) node.query( f"BACKUP TABLE {table} TO {destination} " - f"SETTINGS allow_s3_native_copy = {int(allow_native_copy)}" + f"SETTINGS allow_s3_native_copy = {int(allow_native_copy)}", + query_id=backup_query_id, ) + backup = transfer_events(node, backup_query_id) + assert backup["whole_copy"] == 0, ( + "CopyObject cannot express a range, so the envelope would land in the backup" + ) + assert backup["uploaded"] > 0, "inline entries have no object and go through buffers" + if allow_native_copy: + assert backup["ranged_copy"] > 0, "blobs must be copied server-side with a range" + else: + assert backup["ranged_copy"] == 0, ( + "allow_s3_native_copy = 0 leaves no copy inside S3, so every file goes through buffers" + ) + node.query(f"DROP TABLE IF EXISTS {restored} SYNC") node.query( f"RESTORE TABLE {table} AS {restored} FROM {destination} " - f"SETTINGS allow_s3_native_copy = {int(allow_native_copy)}" + f"SETTINGS allow_s3_native_copy = {int(allow_native_copy)}", + query_id=restore_query_id, + ) + + restore = transfer_events(node, restore_query_id) + assert (restore["ranged_copy"], restore["whole_copy"]) == (0, 0), ( + "a restore onto a CAS disk writes every file through the CAS write path" ) + assert restore["downloaded"] > 0, "the backup must be read through buffers" + assert restore["uploaded"] > 0, "the restored part must be written through buffers" actual = column_fingerprints(node, restored) From 68af947521bb7d3f7198571d99995ae16a7f99f7 Mon Sep 17 00:00:00 2001 From: Konstantin Morozov Date: Thu, 1 Oct 2026 13:30:14 +0200 Subject: [PATCH 24/30] update tests Signed-off-by: Konstantin Morozov --- .../test_cas_backup_s3_native_copy/test.py | 240 ++++++++++-------- 1 file changed, 141 insertions(+), 99 deletions(-) diff --git a/tests/integration/test_cas_backup_s3_native_copy/test.py b/tests/integration/test_cas_backup_s3_native_copy/test.py index 89b8e2851bef..02a7b7fe0485 100644 --- a/tests/integration/test_cas_backup_s3_native_copy/test.py +++ b/tests/integration/test_cas_backup_s3_native_copy/test.py @@ -48,10 +48,14 @@ def start_cluster(): cluster.shutdown() -def backup_destination(name): +def backup_s3_destination(name): return f"S3('{S3_AUTHORITY}/test/backups/{RUN_TOKEN}/{name}', {S3_CREDENTIALS})" +def backup_disk_destination(disk, name): + return f"Disk('{disk}', '{RUN_TOKEN}/{name}')" + + def create_and_fill(node, table, storage_policy=STORAGE_POLICY): node.query(f"DROP TABLE IF EXISTS {table} SYNC") node.query( @@ -104,12 +108,12 @@ def transfer_events(node, query_id): @pytest.mark.parametrize("allow_native_copy", [True, False]) -def test_native_copy_round_trip(allow_native_copy): +def test_backup_to_s3_round_trip(allow_native_copy): node = cluster.instances["node"] suffix = "native" if allow_native_copy else "buffered" table = f"cas_backup_{suffix}" restored = f"{table}_restored" - destination = backup_destination(suffix) + s3_destination = backup_s3_destination(suffix) backup_query_id = f"{table}_backup_{RUN_TOKEN}" restore_query_id = f"{table}_restore_{RUN_TOKEN}" @@ -117,7 +121,7 @@ def test_native_copy_round_trip(allow_native_copy): expected = column_fingerprints(node, table) node.query( - f"BACKUP TABLE {table} TO {destination} " + f"BACKUP TABLE {table} TO {s3_destination} " f"SETTINGS allow_s3_native_copy = {int(allow_native_copy)}", query_id=backup_query_id, ) @@ -136,7 +140,7 @@ def test_native_copy_round_trip(allow_native_copy): node.query(f"DROP TABLE IF EXISTS {restored} SYNC") node.query( - f"RESTORE TABLE {table} AS {restored} FROM {destination} " + f"RESTORE TABLE {table} AS {restored} FROM {s3_destination} " f"SETTINGS allow_s3_native_copy = {int(allow_native_copy)}", query_id=restore_query_id, ) @@ -165,21 +169,6 @@ def test_native_copy_round_trip(allow_native_copy): node.query(f"DROP TABLE {restored} SYNC") -def copy_events(node, query_id): - node.query("SYSTEM FLUSH LOGS query_log") - events = node.query( - f""" - SELECT ProfileEvents['S3UploadPartCopy'], ProfileEvents['S3CopyObject'] - FROM system.query_log - WHERE type = 'QueryFinish' AND query_id = '{query_id}' - ORDER BY event_time DESC LIMIT 1 - """ - ).strip() - assert events, f"no query_log row for {query_id}" - upload_part_copy, copy_object = (int(value) for value in events.split("\t")) - return upload_part_copy, copy_object - - @pytest.mark.parametrize( "storage_policy, multipart_copy", [ @@ -188,36 +177,36 @@ def copy_events(node, query_id): ], ) @pytest.mark.parametrize("backup_disk", ["backup_disk_s3_plain", "backup_disk_s3"]) -def test_backup_to_disk_on_same_authority(backup_disk, storage_policy, multipart_copy): +def test_backup_to_disk_only_buffers(backup_disk, storage_policy, multipart_copy): node = cluster.instances["node"] table = f"cas_backup_to_{backup_disk}_{storage_policy}" restored = f"{table}_restored" - destination = f"Disk('{backup_disk}', '{RUN_TOKEN}/{table}')" + disk_destination = backup_disk_destination(backup_disk, table) backup_query_id = f"{table}_backup_{RUN_TOKEN}" restore_query_id = f"{table}_restore_{RUN_TOKEN}" create_and_fill(node, table, storage_policy) expected = column_fingerprints(node, table) - node.query(f"BACKUP TABLE {table} TO {destination}", query_id=backup_query_id) + node.query(f"BACKUP TABLE {table} TO {disk_destination}", query_id=backup_query_id) - upload_part_copy, copy_object = copy_events(node, backup_query_id) - assert (upload_part_copy, copy_object) == ( - 0, - 0, - ), "a Disk(...) destination goes through IDisk::copyFile, which no longer copies CAS objects" + backup = transfer_events(node, backup_query_id) + assert (backup["ranged_copy"], backup["whole_copy"]) == (0, 0), ( + "a Disk(...) destination goes through IDisk::copyFile, which no longer copies CAS objects" + ) + assert backup["uploaded"] > 0, "every file must reach the destination through buffers" node.query(f"DROP TABLE IF EXISTS {restored} SYNC") node.query( - f"RESTORE TABLE {table} AS {restored} FROM {destination}", + f"RESTORE TABLE {table} AS {restored} FROM {disk_destination}", query_id=restore_query_id, ) - upload_part_copy, copy_object = copy_events(node, restore_query_id) - assert (upload_part_copy, copy_object) == ( - 0, - 0, - ), "RESTORE onto a CAS disk must write through the CAS path, not copy objects into the pool" + restore = transfer_events(node, restore_query_id) + assert (restore["ranged_copy"], restore["whole_copy"]) == (0, 0), ( + "RESTORE onto a CAS disk must write through the CAS path, not copy objects into the pool" + ) + assert restore["uploaded"] > 0, "the restored part must be written through buffers" assert ( node.query( @@ -243,44 +232,34 @@ def test_backup_to_disk_on_same_authority(backup_disk, storage_policy, multipart node.query(f"DROP TABLE {restored} SYNC") -def test_blobs_use_ranged_copy_and_inline_falls_back(): +def test_backup_to_s3_blobs_use_ranged_copy_and_inline_falls_back(): """A blob is `[envelope][payload]`, so its copy must be ranged: `UploadPartCopy`, never `CopyObject`. Inline entries have no object and must go through buffers. """ node = cluster.instances["node"] table = "cas_backup_mechanism" restored = f"{table}_restored" - destination = backup_destination("mechanism") + s3_destination = backup_s3_destination("mechanism") query_id = f"cas_backup_mechanism_{RUN_TOKEN}" create_and_fill(node, table) expected = column_fingerprints(node, table) node.query( - f"BACKUP TABLE {table} TO {destination} SETTINGS allow_s3_native_copy = 1", + f"BACKUP TABLE {table} TO {s3_destination} SETTINGS allow_s3_native_copy = 1", query_id=query_id, ) - node.query("SYSTEM FLUSH LOGS query_log") + events = transfer_events(node, query_id) - events = node.query( - f""" - SELECT - ProfileEvents['S3UploadPartCopy'], - ProfileEvents['S3CopyObject'], - ProfileEvents['S3PutObject'] + ProfileEvents['S3UploadPart'] - FROM system.query_log - WHERE type = 'QueryFinish' AND query_id = '{query_id}' - ORDER BY event_time DESC LIMIT 1 - """ - ).strip() - assert events, "no query_log row for the backup query" - upload_part_copy, copy_object, uploaded = (int(value) for value in events.split("\t")) - - assert upload_part_copy > 0, "no ranged server-side copy happened" - assert copy_object == 0, "CopyObject has no range: the envelope would land in the backup" - assert uploaded > 0, "inline entries have no object and must be uploaded through buffers" + assert events["ranged_copy"] > 0, "no ranged server-side copy happened" + assert events["whole_copy"] == 0, ( + "CopyObject has no range: the envelope would land in the backup" + ) + assert events["uploaded"] > 0, ( + "inline entries have no object and must be uploaded through buffers" + ) node.query(f"DROP TABLE IF EXISTS {restored} SYNC") - node.query(f"RESTORE TABLE {table} AS {restored} FROM {destination}") + node.query(f"RESTORE TABLE {table} AS {restored} FROM {s3_destination}") actual = column_fingerprints(node, restored) assert actual == expected @@ -288,14 +267,14 @@ def test_blobs_use_ranged_copy_and_inline_falls_back(): node.query(f"DROP TABLE {restored} SYNC") -def test_ranged_copy_falls_back_without_multipart(): +def test_backup_to_s3_falls_back_without_multipart(): """Only `UploadPartCopy` can express a range. With multipart copy off there is no server-side operation left, so the copy must go through buffers instead of taking the whole object. """ node = cluster.instances["node"] table = "cas_backup_no_multipart" restored = f"{table}_restored" - destination = backup_destination("no_multipart") + s3_destination = backup_s3_destination("no_multipart") query_id = f"cas_backup_no_multipart_{RUN_TOKEN}" no_multipart = {"s3_allow_multipart_copy": 0} @@ -303,33 +282,25 @@ def test_ranged_copy_falls_back_without_multipart(): expected = column_fingerprints(node, table) node.query( - f"BACKUP TABLE {table} TO {destination} SETTINGS allow_s3_native_copy = 1", + f"BACKUP TABLE {table} TO {s3_destination} SETTINGS allow_s3_native_copy = 1", query_id=query_id, settings=no_multipart, ) - node.query("SYSTEM FLUSH LOGS query_log") - - events = node.query( - f""" - SELECT - ProfileEvents['S3UploadPartCopy'], - ProfileEvents['S3CopyObject'], - ProfileEvents['S3PutObject'] + ProfileEvents['S3UploadPart'] - FROM system.query_log - WHERE type = 'QueryFinish' AND query_id = '{query_id}' - ORDER BY event_time DESC LIMIT 1 - """ - ).strip() - assert events, "no query_log row for the backup query" - upload_part_copy, copy_object, uploaded = (int(value) for value in events.split("\t")) + events = transfer_events(node, query_id) - assert upload_part_copy == 0, "multipart copy was disabled but UploadPartCopy still ran" - assert copy_object == 0, "a ranged copy fell back to CopyObject, which would take the envelope" - assert uploaded > 0, "nothing was uploaded through the server, so nothing was copied at all" + assert events["ranged_copy"] == 0, ( + "multipart copy was disabled but UploadPartCopy still ran" + ) + assert events["whole_copy"] == 0, ( + "a ranged copy fell back to CopyObject, which would take the envelope" + ) + assert events["uploaded"] > 0, ( + "nothing was uploaded through the server, so nothing was copied at all" + ) node.query(f"DROP TABLE IF EXISTS {restored} SYNC") node.query( - f"RESTORE TABLE {table} AS {restored} FROM {destination}", settings=no_multipart + f"RESTORE TABLE {table} AS {restored} FROM {s3_destination}", settings=no_multipart ) actual = column_fingerprints(node, restored) @@ -340,7 +311,7 @@ def test_ranged_copy_falls_back_without_multipart(): node.query(f"DROP TABLE {restored} SYNC") -def test_move_partition_out_of_cas_with_empty_arrays(): +def test_move_partition_from_cas_to_s3_disk_with_empty_arrays(): """A zero-size `.bin` still becomes a blob: `partFileMustStayBlob` keys on the file name, not the size. Moving it off a CAS disk must copy it through buffers. """ @@ -393,7 +364,7 @@ def test_backup_to_s3_with_empty_arrays(): node = cluster.instances["node"] table = "cas_backup_empty_arrays" restored = f"{table}_restored" - destination = backup_destination("empty_arrays") + s3_destination = backup_s3_destination("empty_arrays") fingerprint = "SELECT count(), sum(cityHash64(s)), sum(length(empty)) FROM {}" node.query(f"DROP TABLE IF EXISTS {table} SYNC") @@ -414,7 +385,7 @@ def test_backup_to_s3_with_empty_arrays(): expected = node.query(fingerprint.format(table)).strip() node.query( - f"BACKUP TABLE {table} TO {destination} " + f"BACKUP TABLE {table} TO {s3_destination} " f"SETTINGS allow_s3_native_copy = 1, deduplicate_files = 0" ) @@ -431,7 +402,7 @@ def test_backup_to_s3_with_empty_arrays(): assert empty_files_in_backup > 0, "no zero-size file reached the backup writer" node.query(f"DROP TABLE IF EXISTS {restored} SYNC") - node.query(f"RESTORE TABLE {table} AS {restored} FROM {destination}") + node.query(f"RESTORE TABLE {table} AS {restored} FROM {s3_destination}") assert node.query(fingerprint.format(restored)).strip() == expected assert ( @@ -452,8 +423,8 @@ def test_incremental_backup_to_s3(): node = cluster.instances["node"] table = "cas_backup_incremental" restored = f"{table}_restored" - base = backup_destination("incremental_base") - incremental = backup_destination("incremental") + base = backup_s3_destination("incremental_base") + incremental = backup_s3_destination("incremental") create_and_fill(node, table) node.query( @@ -490,8 +461,77 @@ def test_incremental_backup_to_s3(): node.query(f"DROP TABLE {restored} SYNC") +@pytest.mark.skip( + reason="the base backup gives no prefix for count.txt yet, so the scenario is not reached" +) +def test_incremental_backup_to_s3_copies_only_the_tail(): + """No CAS here. `count.txt` holds the row count with no trailing newline, so after the table is + recreated the shorter file in the base backup is a prefix of the new one and `BackupImpl` asks for + the tail alone. `CopyObject` cannot express that range and used to copy the whole object, which + left a `count.txt` that no longer matched the part. + """ + node = cluster.instances["node"] + table = "plain_incremental_tail" + restored = f"{table}_restored" + base = backup_s3_destination("tail_base") + incremental = backup_s3_destination("tail_incremental") + query_id = f"plain_incremental_tail_{RUN_TOKEN}" -def test_move_between_plain_and_encrypted_s3_disks(): + def recreate_and_fill(rows): + node.query(f"DROP TABLE IF EXISTS {table} SYNC") + node.query( + f""" + CREATE TABLE {table} (k UInt64) + ENGINE = MergeTree ORDER BY k + SETTINGS storage_policy = 'plain_then_plain', min_bytes_for_wide_part = 0 + """ + ) + node.query(f"INSERT INTO {table} SELECT number FROM numbers({rows})") + + recreate_and_fill(100) + node.query(f"BACKUP TABLE {table} TO {base} SETTINGS allow_s3_native_copy = 1") + + recreate_and_fill(1000) + node.query( + f"BACKUP TABLE {table} TO {incremental} " + f"SETTINGS base_backup = {base}, allow_s3_native_copy = 1", + query_id=query_id, + ) + + def count_txt_files(name): + return node.query( + f""" + SELECT groupArray((_path, _size)) + FROM s3('{S3_AUTHORITY}/test/backups/{RUN_TOKEN}/{name}/**', {S3_CREDENTIALS}, 'One') + WHERE _path LIKE '%count.txt' + SETTINGS s3_skip_empty_files = 0 + """ + ).strip() + + in_base = count_txt_files("tail_base") + in_incremental = count_txt_files("tail_incremental") + + assert "'1'" in in_incremental or ",1)" in in_incremental, ( + "count.txt reached the incremental backup whole, so the base backup gave no prefix and the " + f"scenario was not reached. In the base backup: {in_base}. In the incremental one: " + f"{in_incremental}" + ) + + events = transfer_events(node, query_id) + assert events["ranged_copy"] > 0, ( + "the tail reached the backup, but not through a ranged copy inside S3" + ) + + node.query(f"DROP TABLE IF EXISTS {restored} SYNC") + node.query(f"RESTORE TABLE {table} AS {restored} FROM {incremental}") + + assert node.query(f"SELECT count(), sum(k) FROM {restored}").strip() == "1000\t499500" + + node.query(f"DROP TABLE {table} SYNC") + node.query(f"DROP TABLE {restored} SYNC") + + +def test_move_partition_between_plain_and_encrypted_s3_disks(): """No CAS here. The capability predicate must only narrow: `sameKind` ignores `is_encrypted`, and `DiskEncrypted` reports its delegate's description, so a predicate that replaced `operator==` would let this pair through and the cast to `DiskObjectStorage` would throw. @@ -528,7 +568,7 @@ def test_move_between_plain_and_encrypted_s3_disks(): node.query(f"DROP TABLE {table} SYNC") -def test_plain_to_plain_still_copies_server_side(): +def test_move_partition_between_plain_s3_disks_copies_server_side(): """The capability defaults to false, so a description that forgets to claim it silently loses the server-side copy. Two ordinary s3 disks must keep it. """ @@ -554,10 +594,10 @@ def test_plain_to_plain_still_copies_server_side(): query_id=query_id, ) - upload_part_copy, copy_object = copy_events(node, query_id) - assert ( - upload_part_copy + copy_object > 0 - ), "a non-CAS disk pair lost its server-side copy" + events = transfer_events(node, query_id) + assert events["ranged_copy"] + events["whole_copy"] > 0, ( + "a non-CAS disk pair lost its server-side copy" + ) assert ( node.query(f"SELECT count(), sum(cityHash64(s)) FROM {table}").strip() == expected ) @@ -618,7 +658,7 @@ def test_backup_to_file_keeps_fs_copy(): node.query(f"DROP TABLE {table} SYNC") -def test_cached_cas_disk_is_not_whole_object(): +def test_move_partition_from_cached_cas_to_s3_disk_only_buffers(): """`wrapWithCache` reuses the CAS metadata storage for a CAS disk, so the cache disk must inherit the same answer and stay out of the server-side copy. """ @@ -634,22 +674,24 @@ def test_cached_cas_disk_is_not_whole_object(): query_id=query_id, ) - upload_part_copy, copy_object = copy_events(node, query_id) - assert (upload_part_copy, copy_object) == ( - 0, - 0, - ), "a cache disk over CAS reported itself as whole-object" + events = transfer_events(node, query_id) + assert (events["ranged_copy"], events["whole_copy"]) == (0, 0), ( + "a cache disk over CAS reported itself as whole-object" + ) + assert events["uploaded"] > 0, "the moved part must be written through buffers" assert column_fingerprints(node, table) == expected node.query(f"DROP TABLE {table} SYNC") -def test_backup_into_a_cas_disk_is_rejected(): + + +def test_backup_to_disk_cas_is_rejected(): """A `CAS` disk takes a part only as a whole part in one transaction. A backup's own layout mirrors the table's data directory, so its files sit under a part directory too and `isPartFilePath` matches them - which is why writing a backup into a `CAS` disk is refused rather than silently accepted. """ node = cluster.instances["node"] table = "backup_into_cas" - destination = f"Disk('disk_cas_backup_s3', '{RUN_TOKEN}/{table}')" + disk_destination = backup_disk_destination("disk_cas_backup_s3", table) node.query(f"DROP TABLE IF EXISTS {table} SYNC") node.query( @@ -664,7 +706,7 @@ def test_backup_into_a_cas_disk_is_rejected(): ) with pytest.raises(QueryRuntimeException) as raised: - node.query(f"BACKUP TABLE {table} TO {destination}") + node.query(f"BACKUP TABLE {table} TO {disk_destination}") assert "Autocommit writes are not supported for content part files" in str(raised.value) node.query(f"DROP TABLE {table} SYNC") From 6853a19a13fb1fb63e6a1435aa5de5b43c523fa2 Mon Sep 17 00:00:00 2001 From: Konstantin Morozov Date: Thu, 1 Oct 2026 15:09:55 +0200 Subject: [PATCH 25/30] update tests Signed-off-by: Konstantin Morozov --- .../configs/storage_conf.xml | 19 -- .../test_cas_backup_s3_native_copy/test.py | 163 +++++++++--------- 2 files changed, 86 insertions(+), 96 deletions(-) diff --git a/tests/integration/test_cas_backup_s3_native_copy/configs/storage_conf.xml b/tests/integration/test_cas_backup_s3_native_copy/configs/storage_conf.xml index e7a34c7a0df7..44d97e47d5fe 100644 --- a/tests/integration/test_cas_backup_s3_native_copy/configs/storage_conf.xml +++ b/tests/integration/test_cas_backup_s3_native_copy/configs/storage_conf.xml @@ -16,18 +16,6 @@ clickhouse clickhouse - - object_storage - s3 - cas - 30 - 10000 - itest-cas-backup-s3-native-copy-no-multipart - http://rustfs1:11121/test/cas_backup_data_no_multipart/ - clickhouse - clickhouse - 0 - s3_plain http://rustfs1:11121/test/backup_disk_s3_plain/ @@ -71,13 +59,6 @@ - - -
- disk_cas_backup_s3_no_multipart -
-
-
diff --git a/tests/integration/test_cas_backup_s3_native_copy/test.py b/tests/integration/test_cas_backup_s3_native_copy/test.py index 02a7b7fe0485..9111c91d718d 100644 --- a/tests/integration/test_cas_backup_s3_native_copy/test.py +++ b/tests/integration/test_cas_backup_s3_native_copy/test.py @@ -88,7 +88,12 @@ def column_fingerprints(node, table): def transfer_events(node, query_id): - """Which mechanism moved the bytes: a ranged or whole-object copy inside `S3`, or buffers.""" + """How many S3 operations of each mechanism the query issued. + + `server_side_part_copy` is `UploadPartCopy`, the only copy that can name a byte range. + `server_side_object_copy` is `CopyObject`, which always takes the whole object. + `buffered_write` and `buffered_read` are the puts and gets a copy through the server pays. + """ node.query("SYSTEM FLUSH LOGS query_log") row = node.query( f""" @@ -103,12 +108,16 @@ def transfer_events(node, query_id): """ ).strip() assert row, f"no query_log row for {query_id}" - names = ["ranged_copy", "whole_copy", "uploaded", "downloaded"] + names = ["server_side_part_copy", "server_side_object_copy", "buffered_write", "buffered_read"] return dict(zip(names, (int(value) for value in row.split("\t")))) @pytest.mark.parametrize("allow_native_copy", [True, False]) def test_backup_to_s3_round_trip(allow_native_copy): + """A blob is `[envelope][payload]`, so its copy must be ranged: `UploadPartCopy`, never + `CopyObject`. Inline entries have no object and go through buffers either way, and a restore onto + a CAS disk writes every file through the CAS write path. + """ node = cluster.instances["node"] suffix = "native" if allow_native_copy else "buffered" table = f"cas_backup_{suffix}" @@ -127,14 +136,14 @@ def test_backup_to_s3_round_trip(allow_native_copy): ) backup = transfer_events(node, backup_query_id) - assert backup["whole_copy"] == 0, ( + assert backup["server_side_object_copy"] == 0, ( "CopyObject cannot express a range, so the envelope would land in the backup" ) - assert backup["uploaded"] > 0, "inline entries have no object and go through buffers" + assert backup["buffered_write"] > 0, "inline entries have no object and go through buffers" if allow_native_copy: - assert backup["ranged_copy"] > 0, "blobs must be copied server-side with a range" + assert backup["server_side_part_copy"] > 0, "blobs must be copied server-side with a range" else: - assert backup["ranged_copy"] == 0, ( + assert backup["server_side_part_copy"] == 0, ( "allow_s3_native_copy = 0 leaves no copy inside S3, so every file goes through buffers" ) @@ -146,11 +155,11 @@ def test_backup_to_s3_round_trip(allow_native_copy): ) restore = transfer_events(node, restore_query_id) - assert (restore["ranged_copy"], restore["whole_copy"]) == (0, 0), ( + assert (restore["server_side_part_copy"], restore["server_side_object_copy"]) == (0, 0), ( "a restore onto a CAS disk writes every file through the CAS write path" ) - assert restore["downloaded"] > 0, "the backup must be read through buffers" - assert restore["uploaded"] > 0, "the restored part must be written through buffers" + assert restore["buffered_read"] > 0, "the backup must be read through buffers" + assert restore["buffered_write"] > 0, "the restored part must be written through buffers" actual = column_fingerprints(node, restored) @@ -169,32 +178,25 @@ def test_backup_to_s3_round_trip(allow_native_copy): node.query(f"DROP TABLE {restored} SYNC") -@pytest.mark.parametrize( - "storage_policy, multipart_copy", - [ - pytest.param(STORAGE_POLICY, True, id="multipart_copy"), - pytest.param("cas_backup_s3_no_multipart", False, id="no_multipart_copy"), - ], -) @pytest.mark.parametrize("backup_disk", ["backup_disk_s3_plain", "backup_disk_s3"]) -def test_backup_to_disk_only_buffers(backup_disk, storage_policy, multipart_copy): +def test_backup_to_disk_only_buffers(backup_disk): node = cluster.instances["node"] - table = f"cas_backup_to_{backup_disk}_{storage_policy}" + table = f"cas_backup_to_{backup_disk}" restored = f"{table}_restored" disk_destination = backup_disk_destination(backup_disk, table) backup_query_id = f"{table}_backup_{RUN_TOKEN}" restore_query_id = f"{table}_restore_{RUN_TOKEN}" - create_and_fill(node, table, storage_policy) + create_and_fill(node, table) expected = column_fingerprints(node, table) node.query(f"BACKUP TABLE {table} TO {disk_destination}", query_id=backup_query_id) backup = transfer_events(node, backup_query_id) - assert (backup["ranged_copy"], backup["whole_copy"]) == (0, 0), ( + assert (backup["server_side_part_copy"], backup["server_side_object_copy"]) == (0, 0), ( "a Disk(...) destination goes through IDisk::copyFile, which no longer copies CAS objects" ) - assert backup["uploaded"] > 0, "every file must reach the destination through buffers" + assert backup["buffered_write"] > 0, "every file must reach the destination through buffers" node.query(f"DROP TABLE IF EXISTS {restored} SYNC") node.query( @@ -203,16 +205,16 @@ def test_backup_to_disk_only_buffers(backup_disk, storage_policy, multipart_copy ) restore = transfer_events(node, restore_query_id) - assert (restore["ranged_copy"], restore["whole_copy"]) == (0, 0), ( + assert (restore["server_side_part_copy"], restore["server_side_object_copy"]) == (0, 0), ( "RESTORE onto a CAS disk must write through the CAS path, not copy objects into the pool" ) - assert restore["uploaded"] > 0, "the restored part must be written through buffers" + assert restore["buffered_write"] > 0, "the restored part must be written through buffers" assert ( node.query( f"SELECT storage_policy FROM system.tables WHERE name = '{restored}'" ).strip() - == storage_policy + == STORAGE_POLICY ) actual = column_fingerprints(node, restored) @@ -232,41 +234,6 @@ def test_backup_to_disk_only_buffers(backup_disk, storage_policy, multipart_copy node.query(f"DROP TABLE {restored} SYNC") -def test_backup_to_s3_blobs_use_ranged_copy_and_inline_falls_back(): - """A blob is `[envelope][payload]`, so its copy must be ranged: `UploadPartCopy`, never - `CopyObject`. Inline entries have no object and must go through buffers. - """ - node = cluster.instances["node"] - table = "cas_backup_mechanism" - restored = f"{table}_restored" - s3_destination = backup_s3_destination("mechanism") - query_id = f"cas_backup_mechanism_{RUN_TOKEN}" - - create_and_fill(node, table) - expected = column_fingerprints(node, table) - node.query( - f"BACKUP TABLE {table} TO {s3_destination} SETTINGS allow_s3_native_copy = 1", - query_id=query_id, - ) - events = transfer_events(node, query_id) - - assert events["ranged_copy"] > 0, "no ranged server-side copy happened" - assert events["whole_copy"] == 0, ( - "CopyObject has no range: the envelope would land in the backup" - ) - assert events["uploaded"] > 0, ( - "inline entries have no object and must be uploaded through buffers" - ) - - node.query(f"DROP TABLE IF EXISTS {restored} SYNC") - node.query(f"RESTORE TABLE {table} AS {restored} FROM {s3_destination}") - actual = column_fingerprints(node, restored) - assert actual == expected - - node.query(f"DROP TABLE {table} SYNC") - node.query(f"DROP TABLE {restored} SYNC") - - def test_backup_to_s3_falls_back_without_multipart(): """Only `UploadPartCopy` can express a range. With multipart copy off there is no server-side operation left, so the copy must go through buffers instead of taking the whole object. @@ -288,13 +255,13 @@ def test_backup_to_s3_falls_back_without_multipart(): ) events = transfer_events(node, query_id) - assert events["ranged_copy"] == 0, ( + assert events["server_side_part_copy"] == 0, ( "multipart copy was disabled but UploadPartCopy still ran" ) - assert events["whole_copy"] == 0, ( + assert events["server_side_object_copy"] == 0, ( "a ranged copy fell back to CopyObject, which would take the envelope" ) - assert events["uploaded"] > 0, ( + assert events["buffered_write"] > 0, ( "nothing was uploaded through the server, so nothing was copied at all" ) @@ -317,6 +284,7 @@ def test_move_partition_from_cas_to_s3_disk_with_empty_arrays(): """ node = cluster.instances["node"] table = "cas_move_empty_arrays" + query_id = f"{table}_move_{RUN_TOKEN}" node.query(f"DROP TABLE IF EXISTS {table} SYNC") node.query( @@ -338,7 +306,16 @@ def test_move_partition_from_cas_to_s3_disk_with_empty_arrays(): f"SELECT count(), sum(cityHash64(s)), sum(length(empty)) FROM {table}" ).strip() - node.query(f"ALTER TABLE {table} MOVE PARTITION tuple() TO DISK 'backup_disk_s3'") + node.query( + f"ALTER TABLE {table} MOVE PARTITION tuple() TO DISK 'backup_disk_s3'", + query_id=query_id, + ) + + events = transfer_events(node, query_id) + assert (events["server_side_part_copy"], events["server_side_object_copy"]) == (0, 0), ( + "a CAS source is windowed, so no copy may run inside S3" + ) + assert events["buffered_write"] > 0, "every file must be moved through buffers" assert ( node.query( @@ -365,6 +342,7 @@ def test_backup_to_s3_with_empty_arrays(): table = "cas_backup_empty_arrays" restored = f"{table}_restored" s3_destination = backup_s3_destination("empty_arrays") + query_id = f"{table}_backup_{RUN_TOKEN}" fingerprint = "SELECT count(), sum(cityHash64(s)), sum(length(empty)) FROM {}" node.query(f"DROP TABLE IF EXISTS {table} SYNC") @@ -385,8 +363,16 @@ def test_backup_to_s3_with_empty_arrays(): expected = node.query(fingerprint.format(table)).strip() node.query( - f"BACKUP TABLE {table} TO {s3_destination} " - f"SETTINGS allow_s3_native_copy = 1, deduplicate_files = 0" + f"BACKUP TABLE {table} TO {s3_destination} SETTINGS deduplicate_files = 0", + query_id=query_id, + ) + + events = transfer_events(node, query_id) + assert events["server_side_part_copy"] > 0, ( + "a zero-size blob must not cost the other blobs their ranged copy" + ) + assert events["server_side_object_copy"] == 0, ( + "CopyObject has no range: the envelope would land in the backup" ) empty_files_in_backup = int( @@ -425,11 +411,10 @@ def test_incremental_backup_to_s3(): restored = f"{table}_restored" base = backup_s3_destination("incremental_base") incremental = backup_s3_destination("incremental") + query_id = f"{table}_backup_{RUN_TOKEN}" create_and_fill(node, table) - node.query( - f"BACKUP TABLE {table} TO {base} SETTINGS allow_s3_native_copy = 1" - ) + node.query(f"BACKUP TABLE {table} TO {base}") node.query( f""" @@ -445,9 +430,18 @@ def test_incremental_backup_to_s3(): expected = column_fingerprints(node, table) node.query( - f"BACKUP TABLE {table} TO {incremental} " - f"SETTINGS base_backup = {base}, allow_s3_native_copy = 1" + f"BACKUP TABLE {table} TO {incremental} SETTINGS base_backup = {base}", + query_id=query_id, + ) + + events = transfer_events(node, query_id) + assert events["server_side_part_copy"] > 0, ( + "the files of the new part must still be copied inside S3 with a range" ) + assert events["server_side_object_copy"] == 0, ( + "CopyObject has no range: the envelope would land in the backup" + ) + assert events["buffered_write"] > 0, "inline entries have no object and go through buffers" node.query(f"DROP TABLE IF EXISTS {restored} SYNC") node.query(f"RESTORE TABLE {table} AS {restored} FROM {incremental}") @@ -518,7 +512,7 @@ def count_txt_files(name): ) events = transfer_events(node, query_id) - assert events["ranged_copy"] > 0, ( + assert events["server_side_part_copy"] > 0, ( "the tail reached the backup, but not through a ranged copy inside S3" ) @@ -538,6 +532,8 @@ def test_move_partition_between_plain_and_encrypted_s3_disks(): """ node = cluster.instances["node"] table = "plain_to_encrypted" + to_encrypted_query_id = f"{table}_to_encrypted_{RUN_TOKEN}" + from_encrypted_query_id = f"{table}_from_encrypted_{RUN_TOKEN}" node.query(f"DROP TABLE IF EXISTS {table} SYNC") node.query( @@ -554,13 +550,25 @@ def test_move_partition_between_plain_and_encrypted_s3_disks(): expected = node.query(f"SELECT count(), sum(cityHash64(s)) FROM {table}").strip() node.query( - f"ALTER TABLE {table} MOVE PARTITION tuple() TO DISK 'disk_plain_s3_encrypted'" + f"ALTER TABLE {table} MOVE PARTITION tuple() TO DISK 'disk_plain_s3_encrypted'", + query_id=to_encrypted_query_id, + ) + to_encrypted = transfer_events(node, to_encrypted_query_id) + assert to_encrypted["buffered_write"] > 0, ( + "one side encrypts and the other does not, so the bytes must pass through the server" ) assert ( node.query(f"SELECT count(), sum(cityHash64(s)) FROM {table}").strip() == expected ) - node.query(f"ALTER TABLE {table} MOVE PARTITION tuple() TO DISK 'backup_disk_s3'") + node.query( + f"ALTER TABLE {table} MOVE PARTITION tuple() TO DISK 'backup_disk_s3'", + query_id=from_encrypted_query_id, + ) + from_encrypted = transfer_events(node, from_encrypted_query_id) + assert from_encrypted["buffered_write"] > 0, ( + "the way back decrypts, so the bytes must pass through the server again" + ) assert ( node.query(f"SELECT count(), sum(cityHash64(s)) FROM {table}").strip() == expected ) @@ -595,8 +603,9 @@ def test_move_partition_between_plain_s3_disks_copies_server_side(): ) events = transfer_events(node, query_id) - assert events["ranged_copy"] + events["whole_copy"] > 0, ( - "a non-CAS disk pair lost its server-side copy" + assert events["server_side_object_copy"] > 0, ( + "a non-CAS disk pair lost its server-side copy: a whole file is a whole object here, so " + "CopyObject is the expected operation" ) assert ( node.query(f"SELECT count(), sum(cityHash64(s)) FROM {table}").strip() == expected @@ -675,10 +684,10 @@ def test_move_partition_from_cached_cas_to_s3_disk_only_buffers(): ) events = transfer_events(node, query_id) - assert (events["ranged_copy"], events["whole_copy"]) == (0, 0), ( + assert (events["server_side_part_copy"], events["server_side_object_copy"]) == (0, 0), ( "a cache disk over CAS reported itself as whole-object" ) - assert events["uploaded"] > 0, "the moved part must be written through buffers" + assert events["buffered_write"] > 0, "the moved part must be written through buffers" assert column_fingerprints(node, table) == expected node.query(f"DROP TABLE {table} SYNC") From 7ce84b34eb23f8fc7e190072c8bfb453c97722d7 Mon Sep 17 00:00:00 2001 From: Konstantin Morozov Date: Thu, 1 Oct 2026 15:13:35 +0200 Subject: [PATCH 26/30] update tests Signed-off-by: Konstantin Morozov --- .../test_cas_backup_s3_native_copy/test.py | 70 ------------------- 1 file changed, 70 deletions(-) diff --git a/tests/integration/test_cas_backup_s3_native_copy/test.py b/tests/integration/test_cas_backup_s3_native_copy/test.py index 9111c91d718d..bcf291c80b17 100644 --- a/tests/integration/test_cas_backup_s3_native_copy/test.py +++ b/tests/integration/test_cas_backup_s3_native_copy/test.py @@ -455,76 +455,6 @@ def test_incremental_backup_to_s3(): node.query(f"DROP TABLE {restored} SYNC") -@pytest.mark.skip( - reason="the base backup gives no prefix for count.txt yet, so the scenario is not reached" -) -def test_incremental_backup_to_s3_copies_only_the_tail(): - """No CAS here. `count.txt` holds the row count with no trailing newline, so after the table is - recreated the shorter file in the base backup is a prefix of the new one and `BackupImpl` asks for - the tail alone. `CopyObject` cannot express that range and used to copy the whole object, which - left a `count.txt` that no longer matched the part. - """ - node = cluster.instances["node"] - table = "plain_incremental_tail" - restored = f"{table}_restored" - base = backup_s3_destination("tail_base") - incremental = backup_s3_destination("tail_incremental") - query_id = f"plain_incremental_tail_{RUN_TOKEN}" - - def recreate_and_fill(rows): - node.query(f"DROP TABLE IF EXISTS {table} SYNC") - node.query( - f""" - CREATE TABLE {table} (k UInt64) - ENGINE = MergeTree ORDER BY k - SETTINGS storage_policy = 'plain_then_plain', min_bytes_for_wide_part = 0 - """ - ) - node.query(f"INSERT INTO {table} SELECT number FROM numbers({rows})") - - recreate_and_fill(100) - node.query(f"BACKUP TABLE {table} TO {base} SETTINGS allow_s3_native_copy = 1") - - recreate_and_fill(1000) - node.query( - f"BACKUP TABLE {table} TO {incremental} " - f"SETTINGS base_backup = {base}, allow_s3_native_copy = 1", - query_id=query_id, - ) - - def count_txt_files(name): - return node.query( - f""" - SELECT groupArray((_path, _size)) - FROM s3('{S3_AUTHORITY}/test/backups/{RUN_TOKEN}/{name}/**', {S3_CREDENTIALS}, 'One') - WHERE _path LIKE '%count.txt' - SETTINGS s3_skip_empty_files = 0 - """ - ).strip() - - in_base = count_txt_files("tail_base") - in_incremental = count_txt_files("tail_incremental") - - assert "'1'" in in_incremental or ",1)" in in_incremental, ( - "count.txt reached the incremental backup whole, so the base backup gave no prefix and the " - f"scenario was not reached. In the base backup: {in_base}. In the incremental one: " - f"{in_incremental}" - ) - - events = transfer_events(node, query_id) - assert events["server_side_part_copy"] > 0, ( - "the tail reached the backup, but not through a ranged copy inside S3" - ) - - node.query(f"DROP TABLE IF EXISTS {restored} SYNC") - node.query(f"RESTORE TABLE {table} AS {restored} FROM {incremental}") - - assert node.query(f"SELECT count(), sum(k) FROM {restored}").strip() == "1000\t499500" - - node.query(f"DROP TABLE {table} SYNC") - node.query(f"DROP TABLE {restored} SYNC") - - def test_move_partition_between_plain_and_encrypted_s3_disks(): """No CAS here. The capability predicate must only narrow: `sameKind` ignores `is_encrypted`, and `DiskEncrypted` reports its delegate's description, so a predicate that replaced `operator==` From fe961fce1403de92cbb7ab65ae48bfa7ecc3ec2f Mon Sep 17 00:00:00 2001 From: Konstantin Morozov Date: Thu, 1 Oct 2026 15:31:04 +0200 Subject: [PATCH 27/30] update tests Signed-off-by: Konstantin Morozov --- src/Backups/BackupIO_AzureBlobStorage.cpp | 14 ++++- src/Backups/BackupIO_S3.cpp | 14 ++++- .../test_cas_backup_s3_native_copy/test.py | 58 +++++++++++++++++++ 3 files changed, 82 insertions(+), 4 deletions(-) diff --git a/src/Backups/BackupIO_AzureBlobStorage.cpp b/src/Backups/BackupIO_AzureBlobStorage.cpp index 693b063c705c..2284e231ae27 100644 --- a/src/Backups/BackupIO_AzureBlobStorage.cpp +++ b/src/Backups/BackupIO_AzureBlobStorage.cpp @@ -37,7 +37,12 @@ BackupReaderAzureBlobStorage::BackupReaderAzureBlobStorage( const WriteSettings & write_settings_, const ContextPtr & context_) : BackupReaderDefault(read_settings_, write_settings_, getLogger("BackupReaderAzureBlobStorage")) - , data_source_description{DataSourceType::ObjectStorage, ObjectStorageType::Azure, MetadataStorageType::None, connection_params_.getConnectionURL(), false, false, "", true} + , data_source_description{ + .type = DataSourceType::ObjectStorage, + .object_storage_type = ObjectStorageType::Azure, + .metadata_type = MetadataStorageType::None, + .description = connection_params_.getConnectionURL(), + .files_are_whole_objects = true} , connection_params(connection_params_) , blob_path(blob_path_) { @@ -135,7 +140,12 @@ BackupWriterAzureBlobStorage::BackupWriterAzureBlobStorage( const ContextPtr & context_, bool attempt_to_create_container) : BackupWriterDefault(read_settings_, write_settings_, getLogger("BackupWriterAzureBlobStorage")) - , data_source_description{DataSourceType::ObjectStorage, ObjectStorageType::Azure, MetadataStorageType::None, connection_params_.getConnectionURL(), false, false, "", true} + , data_source_description{ + .type = DataSourceType::ObjectStorage, + .object_storage_type = ObjectStorageType::Azure, + .metadata_type = MetadataStorageType::None, + .description = connection_params_.getConnectionURL(), + .files_are_whole_objects = true} , connection_params(connection_params_) , blob_path(blob_path_) { diff --git a/src/Backups/BackupIO_S3.cpp b/src/Backups/BackupIO_S3.cpp index de89e951752e..81a95a85650f 100644 --- a/src/Backups/BackupIO_S3.cpp +++ b/src/Backups/BackupIO_S3.cpp @@ -258,7 +258,12 @@ BackupReaderS3::BackupReaderS3( bool is_internal_backup) : BackupReaderDefault(read_settings_, write_settings_, getLogger("BackupReaderS3")) , s3_uri(s3_uri_) - , data_source_description{DataSourceType::ObjectStorage, ObjectStorageType::S3, MetadataStorageType::None, s3_uri.endpoint, false, false, "", true} + , data_source_description{ + .type = DataSourceType::ObjectStorage, + .object_storage_type = ObjectStorageType::S3, + .metadata_type = MetadataStorageType::None, + .description = s3_uri.endpoint, + .files_are_whole_objects = true} { s3_settings.loadFromConfig(context_->getConfigRef(), "s3", context_->getSettingsRef()); @@ -355,7 +360,12 @@ BackupWriterS3::BackupWriterS3( bool is_internal_backup) : BackupWriterDefault(read_settings_, write_settings_, getLogger("BackupWriterS3")) , s3_uri(s3_uri_) - , data_source_description{DataSourceType::ObjectStorage, ObjectStorageType::S3, MetadataStorageType::None, s3_uri.endpoint, false, false, "", true} + , data_source_description{ + .type = DataSourceType::ObjectStorage, + .object_storage_type = ObjectStorageType::S3, + .metadata_type = MetadataStorageType::None, + .description = s3_uri.endpoint, + .files_are_whole_objects = true} , s3_capabilities(getCapabilitiesFromConfig(context_->getConfigRef(), "s3")) , disk_client_factory(S3BackupClientCreator(context_)) { diff --git a/tests/integration/test_cas_backup_s3_native_copy/test.py b/tests/integration/test_cas_backup_s3_native_copy/test.py index bcf291c80b17..7c9a02d179b9 100644 --- a/tests/integration/test_cas_backup_s3_native_copy/test.py +++ b/tests/integration/test_cas_backup_s3_native_copy/test.py @@ -278,6 +278,64 @@ def test_backup_to_s3_falls_back_without_multipart(): node.query(f"DROP TABLE {restored} SYNC") +def test_backup_to_s3_ranged_copy_spans_several_parts(): + """A payload larger than one upload part makes the ranged copy issue several `UploadPartCopy` + requests. Every part but the first carries an offset of its own, so the payload window must be + applied to each one and not only to the first. + """ + node = cluster.instances["node"] + table = "cas_backup_multipart_range" + restored = f"{table}_restored" + s3_destination = backup_s3_destination("multipart_range") + query_id = f"{table}_backup_{RUN_TOKEN}" + small_parts = {"s3_min_upload_part_size": 5 * 1024 * 1024} + fingerprint = "SELECT count(), sum(cityHash64(s)) FROM {}" + + node.query(f"DROP TABLE IF EXISTS {table} SYNC") + node.query( + f""" + CREATE TABLE {table} (k UInt64, s String) + ENGINE = MergeTree ORDER BY k + SETTINGS storage_policy = '{STORAGE_POLICY}', min_bytes_for_wide_part = 0 + """ + ) + node.query( + f""" + INSERT INTO {table} + SELECT number, randomPrintableASCII(128) FROM numbers({NUM_ROWS}) + """ + ) + expected = node.query(fingerprint.format(table)).strip() + + node.query( + f"BACKUP TABLE {table} TO {s3_destination}", + query_id=query_id, + settings=small_parts, + ) + + events = transfer_events(node, query_id) + assert events["server_side_part_copy"] > 1, ( + "no blob was copied in more than one part, so the per-part offset stayed untested" + ) + assert events["server_side_object_copy"] == 0, ( + "CopyObject has no range: the envelope would land in the backup" + ) + + node.query(f"DROP TABLE IF EXISTS {restored} SYNC") + node.query(f"RESTORE TABLE {table} AS {restored} FROM {s3_destination}") + + assert node.query(fingerprint.format(restored)).strip() == expected + assert ( + node.query( + f"CHECK TABLE {restored} SETTINGS check_query_single_value_result = 1" + ).strip() + == "1" + ) + + node.query(f"DROP TABLE {table} SYNC") + node.query(f"DROP TABLE {restored} SYNC") + + def test_move_partition_from_cas_to_s3_disk_with_empty_arrays(): """A zero-size `.bin` still becomes a blob: `partFileMustStayBlob` keys on the file name, not the size. Moving it off a CAS disk must copy it through buffers. From 312527a59c40e7265ea0a61cd231f7d1c58467a8 Mon Sep 17 00:00:00 2001 From: Konstantin Morozov Date: Thu, 1 Oct 2026 15:37:04 +0200 Subject: [PATCH 28/30] update doc Signed-off-by: Konstantin Morozov --- docs/en/antalya/cas/operations/backup.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/en/antalya/cas/operations/backup.md b/docs/en/antalya/cas/operations/backup.md index 6c42609c2eb7..c4af4ef8078b 100644 --- a/docs/en/antalya/cas/operations/backup.md +++ b/docs/en/antalya/cas/operations/backup.md @@ -54,8 +54,9 @@ are read through the `CAS` read path and written to the destination. Pool dedupl what was one blob shared by several replicas becomes ordinary files in the backup. **An `S3(...)` destination on the same `S3` endpoint as the pool** — the copy of a blob then runs -inside the `S3` store itself: the ClickHouse server issues one "copy these bytes" command, and `S3` -moves the bytes internally without sending them through ClickHouse. +inside the `S3` store itself: the ClickHouse server issues "copy these bytes" commands - one per +upload part, so a payload larger than one part takes several - and `S3` moves the bytes internally +without sending them through ClickHouse. ```sql BACKUP TABLE t TO S3('http://s3.example.com/bucket/backups/b1', 'key', 'secret'); @@ -100,7 +101,7 @@ BACKUP TABLE t TO Disk('backups_s3', 'b1'); ``` Every file is read through the `CAS` read path and written through ClickHouse's buffers. The -`s3_allow_native_copy` and `s3_allow_multipart_copy` settings have no effect on this path. +`allow_s3_native_copy` and `s3_allow_multipart_copy` settings have no effect on this path. ## Restore {#restore} @@ -142,8 +143,10 @@ It is a useful building block, not a replacement for `BACKUP`. instead. A store that refuses `UploadPartCopy` with another error fails the `BACKUP`; set `s3_allow_multipart_copy = 0` there. - Restore onto a `CAS` disk always writes through ClickHouse, see [restore](#restore). -- A disk-level copy of a single part file onto a `CAS` disk, outside of `RESTORE`, is rejected with - `NOT_IMPLEMENTED`: a `CAS` disk accepts part files only as a whole part in one transaction. +- A disk-level write of a part file onto a `CAS` disk outside of a part transaction is rejected with + `NOT_IMPLEMENTED` and the message `Autocommit writes are not supported for content part files`: a + `CAS` disk publishes a part's manifest and ref at commit, so it has nowhere to put a single + autocommitted file. - A `CAS` disk cannot be a backup destination: `BACKUP TABLE t TO Disk('', 'b1')` is rejected the same way. A backup's own layout mirrors the table's data directory, so its files sit under a part directory as well and count as part files. From 866ddabe21b4fc2c929d496b3ca45353d1376096 Mon Sep 17 00:00:00 2001 From: Konstantin Morozov Date: Thu, 1 Oct 2026 15:45:06 +0200 Subject: [PATCH 29/30] update doc Signed-off-by: Konstantin Morozov --- docs/en/antalya/cas/operations/backup.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/en/antalya/cas/operations/backup.md b/docs/en/antalya/cas/operations/backup.md index c4af4ef8078b..0a8d64a48840 100644 --- a/docs/en/antalya/cas/operations/backup.md +++ b/docs/en/antalya/cas/operations/backup.md @@ -54,9 +54,7 @@ are read through the `CAS` read path and written to the destination. Pool dedupl what was one blob shared by several replicas becomes ordinary files in the backup. **An `S3(...)` destination on the same `S3` endpoint as the pool** — the copy of a blob then runs -inside the `S3` store itself: the ClickHouse server issues "copy these bytes" commands - one per -upload part, so a payload larger than one part takes several - and `S3` moves the bytes internally -without sending them through ClickHouse. +inside the `S3` store itself, without sending the bytes through ClickHouse. The server issues one `UploadPartCopy` per upload part, so a payload larger than one part takes several. ```sql BACKUP TABLE t TO S3('http://s3.example.com/bucket/backups/b1', 'key', 'secret'); From 90dbe76456a4e4f8dab21222a8cbccb108f4ec69 Mon Sep 17 00:00:00 2001 From: Konstantin Morozov Date: Thu, 1 Oct 2026 15:48:20 +0200 Subject: [PATCH 30/30] fix CI Signed-off-by: Konstantin Morozov --- src/Backups/BackupIO_AzureBlobStorage.cpp | 6 ++++++ src/Backups/BackupIO_S3.cpp | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/src/Backups/BackupIO_AzureBlobStorage.cpp b/src/Backups/BackupIO_AzureBlobStorage.cpp index 2284e231ae27..c3ceb2682c84 100644 --- a/src/Backups/BackupIO_AzureBlobStorage.cpp +++ b/src/Backups/BackupIO_AzureBlobStorage.cpp @@ -42,6 +42,9 @@ BackupReaderAzureBlobStorage::BackupReaderAzureBlobStorage( .object_storage_type = ObjectStorageType::Azure, .metadata_type = MetadataStorageType::None, .description = connection_params_.getConnectionURL(), + .is_encrypted = false, + .is_cached = false, + .zookeeper_name = "", .files_are_whole_objects = true} , connection_params(connection_params_) , blob_path(blob_path_) @@ -145,6 +148,9 @@ BackupWriterAzureBlobStorage::BackupWriterAzureBlobStorage( .object_storage_type = ObjectStorageType::Azure, .metadata_type = MetadataStorageType::None, .description = connection_params_.getConnectionURL(), + .is_encrypted = false, + .is_cached = false, + .zookeeper_name = "", .files_are_whole_objects = true} , connection_params(connection_params_) , blob_path(blob_path_) diff --git a/src/Backups/BackupIO_S3.cpp b/src/Backups/BackupIO_S3.cpp index 81a95a85650f..e758d6a31467 100644 --- a/src/Backups/BackupIO_S3.cpp +++ b/src/Backups/BackupIO_S3.cpp @@ -263,6 +263,9 @@ BackupReaderS3::BackupReaderS3( .object_storage_type = ObjectStorageType::S3, .metadata_type = MetadataStorageType::None, .description = s3_uri.endpoint, + .is_encrypted = false, + .is_cached = false, + .zookeeper_name = "", .files_are_whole_objects = true} { s3_settings.loadFromConfig(context_->getConfigRef(), "s3", context_->getSettingsRef()); @@ -365,6 +368,9 @@ BackupWriterS3::BackupWriterS3( .object_storage_type = ObjectStorageType::S3, .metadata_type = MetadataStorageType::None, .description = s3_uri.endpoint, + .is_encrypted = false, + .is_cached = false, + .zookeeper_name = "", .files_are_whole_objects = true} , s3_capabilities(getCapabilitiesFromConfig(context_->getConfigRef(), "s3")) , disk_client_factory(S3BackupClientCreator(context_))