From 2856f19cf43f32cf64d5746594f74a9117722c37 Mon Sep 17 00:00:00 2001 From: Mikhail Filimonov Date: Fri, 23 Oct 2026 03:20:51 +0200 Subject: [PATCH 01/20] Replace unwrapTableProxy with castStorage in SYSTEM commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The base resolves lazily loaded tables with castStorage(…, DeferredTable::Load) at the same sites, so the fork-only helper is dropped: four sites call castStorage directly, and flushDistributed loads the proxy through castStorage instead of a bare dynamic_cast. Introduced by 4fb813a9988 and 3d35dfce282 (PR #2159), ported in c770be91e83. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Mikhail Filimonov --- src/Interpreters/InterpreterSystemQuery.cpp | 25 +++++---------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/src/Interpreters/InterpreterSystemQuery.cpp b/src/Interpreters/InterpreterSystemQuery.cpp index dc11c4a3c55a3..443d9d2d78a42 100644 --- a/src/Interpreters/InterpreterSystemQuery.cpp +++ b/src/Interpreters/InterpreterSystemQuery.cpp @@ -86,7 +86,6 @@ #include #include #include -#include #include #include #include @@ -291,18 +290,6 @@ AccessType getRequiredAccessType(StorageActionBlockType action_type) constexpr std::string_view table_is_not_replicated = "Table {} is not replicated"; -/// A table in a database created with `lazy_load_tables = 1` stays wrapped in a `StorageTableProxy` -/// until its first access, so a `dynamic_cast` to the real engine (e.g. `StorageReplicatedMergeTree`) -/// fails and a `SYSTEM` verb that targets one specific named table misreports it as not replicated. -/// Materialize the proxy before such a cast; generic query paths already materialize on read by -/// design and must not go through this helper. -StoragePtr unwrapTableProxy(const StoragePtr & storage) -{ - if (const auto * proxy = dynamic_cast(storage.get())) - return proxy->getNested(); - return storage; -} - } /// Implements SYSTEM [START|STOP] @@ -1513,7 +1500,7 @@ void InterpreterSystemQuery::restoreReplica() { getContext()->checkAccess(AccessType::SYSTEM_RESTORE_REPLICA, table_id); - const StoragePtr table_ptr = unwrapTableProxy(DatabaseCatalog::instance().getTable(table_id, getContext())); + const StoragePtr table_ptr = DatabaseCatalog::instance().getTable(table_id, getContext()); auto * const table_replicated_ptr = castStorage(table_ptr, DeferredTable::Load).get(); @@ -1804,7 +1791,7 @@ void InterpreterSystemQuery::dropReplica(ASTSystemQuery & query) if (!table_id.empty()) { getContext()->checkAccess(AccessType::SYSTEM_DROP_REPLICA, table_id); - StoragePtr table = unwrapTableProxy(DatabaseCatalog::instance().getTable(table_id, getContext())); + StoragePtr table = DatabaseCatalog::instance().getTable(table_id, getContext()); if (!dropStorageReplica(query.replica, table)) throw Exception(ErrorCodes::BAD_ARGUMENTS, table_is_not_replicated.data(), table_id.getNameForLogs()); @@ -2379,7 +2366,7 @@ void InterpreterSystemQuery::syncReplica(ASTSystemQuery & query) void InterpreterSystemQuery::waitLoadingParts() { getContext()->checkAccess(AccessType::SYSTEM_WAIT_LOADING_PARTS, table_id); - StoragePtr table = unwrapTableProxy(DatabaseCatalog::instance().getTable(table_id, getContext())); + StoragePtr table = DatabaseCatalog::instance().getTable(table_id, getContext()); if (auto * merge_tree = castStorage(table, DeferredTable::Load).get()) { @@ -2915,7 +2902,7 @@ void InterpreterSystemQuery::loadOrUnloadPrimaryKeysImpl(bool load) if (!table_id.empty()) { getContext()->checkAccess(load ? AccessType::SYSTEM_LOAD_PRIMARY_KEY : AccessType::SYSTEM_UNLOAD_PRIMARY_KEY, table_id.database_name, table_id.table_name); - StoragePtr table = unwrapTableProxy(DatabaseCatalog::instance().getTable(table_id, getContext())); + StoragePtr table = DatabaseCatalog::instance().getTable(table_id, getContext()); if (auto * merge_tree = castStorage(table, DeferredTable::Load).get()) { @@ -3066,8 +3053,8 @@ void InterpreterSystemQuery::flushDistributed(ASTSystemQuery & query) /// reference here (DROP on an Atomic database does not take the exclusive drop_lock, /// and the flush does not hold an async-insert lock), so a concurrent DROP could /// otherwise destroy the table while flushClusterNodesAllData is still running. - auto table = unwrapTableProxy(DatabaseCatalog::instance().getTable(table_id, getContext())); - if (auto * storage_distributed = dynamic_cast(table.get())) + auto table = DatabaseCatalog::instance().getTable(table_id, getContext()); + if (auto * storage_distributed = castStorage(table, DeferredTable::Load).get()) storage_distributed->flushClusterNodesAllData(getContext(), settings_changes); else throw Exception(ErrorCodes::BAD_ARGUMENTS, "Table {} is not distributed", table_id.getNameForLogs()); From 9b6ea634a38ff635ad552219bfdaca48e659b470 Mon Sep 17 00:00:00 2001 From: Mikhail Filimonov Date: Fri, 23 Oct 2026 03:20:53 +0200 Subject: [PATCH 02/20] Drop ThreadGroup::parent_thread_group ThreadGroup::parent (ThreadStatus.h) already retains the parent group for the raw tracker pointers; this member held a second shared_ptr to the same object and was never read. The constructor parameter takes the member's old name, and the two comments that carried a fork ticket tag go. Introduced by 002f387b4f6 (PR #2159), ported in c770be91e83. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Mikhail Filimonov --- src/Common/ThreadStatus.h | 8 -------- src/Interpreters/ThreadStatusExt.cpp | 12 ++++-------- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/src/Common/ThreadStatus.h b/src/Common/ThreadStatus.h index 871aeaa72f498..c8f60bcd18d54 100644 --- a/src/Common/ThreadStatus.h +++ b/src/Common/ThreadStatus.h @@ -96,14 +96,6 @@ class ThreadGroup const Int32 os_threads_nice_value; - /// A borrowed child group (materialized view / async-insert flush) parents its `memory_tracker` - /// and `performance_counters` at the parent group's trackers via RAW pointers. Retain a shared_ptr - /// to the parent so those trackers cannot be freed while any thread is still attached to this child - /// group — otherwise a detached task (e.g. an S3 upload scheduled via `threadPoolCallbackRunnerUnsafe`) - /// that attaches the child group can walk a freed parent tracker chain (use-after-free, B90). Null for - /// a top-level query/background group, whose parent is a process-lifetime tracker (user/total/background). - ThreadGroupPtr parent_thread_group; - MemorySpillSchedulerPtr memory_spill_scheduler; ProfileEvents::Counters performance_counters{VariableContext::Process}; MemoryTracker memory_tracker{VariableContext::Process}; diff --git a/src/Interpreters/ThreadStatusExt.cpp b/src/Interpreters/ThreadStatusExt.cpp index 3719b2521e0d9..36b18da613167 100644 --- a/src/Interpreters/ThreadStatusExt.cpp +++ b/src/Interpreters/ThreadStatusExt.cpp @@ -131,15 +131,13 @@ ThreadGroup::ThreadGroup(ContextPtr query_context_, Int32 os_threads_nice_value_ }; } -ThreadGroup::ThreadGroup(ThreadGroupPtr parent_) - : parent(std::move(parent_)) +ThreadGroup::ThreadGroup(ThreadGroupPtr parent_thread_group) + : parent(std::move(parent_thread_group)) , master_thread_id(parent->master_thread_id) , query_context(parent->query_context) , global_context(parent->global_context) , fatal_error_callback(parent->fatal_error_callback) , os_threads_nice_value(parent->os_threads_nice_value) - /// Keep the parent group alive: this child parents its trackers at the parent's via raw pointers (B90). - , parent_thread_group(parent) , memory_spill_scheduler(parent->memory_spill_scheduler) , performance_counters(VariableContext::Process, &parent->performance_counters) , memory_tracker(&parent->memory_tracker, VariableContext::Process, /*log_peak_memory_usage_in_destructor*/ false) @@ -147,15 +145,13 @@ ThreadGroup::ThreadGroup(ThreadGroupPtr parent_) { } -ThreadGroup::ThreadGroup(ContextPtr query_context_, ThreadGroupPtr parent_) - : parent(std::move(parent_)) +ThreadGroup::ThreadGroup(ContextPtr query_context_, ThreadGroupPtr parent_thread_group) + : parent(std::move(parent_thread_group)) , master_thread_id(CurrentThread::get().thread_id) , query_context(query_context_) , global_context(query_context_->getGlobalContext()) , fatal_error_callback(parent->fatal_error_callback) , os_threads_nice_value(parent->os_threads_nice_value) - /// Keep the parent group alive: this child parents its trackers at the parent's via raw pointers (B90). - , parent_thread_group(parent) , memory_spill_scheduler(parent->memory_spill_scheduler) , performance_counters(VariableContext::Process, &parent->performance_counters) , memory_tracker(&parent->memory_tracker, VariableContext::Process, /*log_peak_memory_usage_in_destructor*/ false) From e840c3a57b4530caea6cb57e93a049b5200e7e66 Mon Sep 17 00:00:00 2001 From: Mikhail Filimonov Date: Fri, 23 Oct 2026 03:20:56 +0200 Subject: [PATCH 03/20] Remove dead CAS symbols from upstream files MetadataStorageFromCacheObjectStorage is never built over a content-addressed storage (DiskObjectStorageCache.cpp reuses that storage directly), REPLICATION_PROTOCOL_VERSION_WITH_CA_RELINK gates nothing (the DataPartsExchange comment that explained it is rewritten around what remains), CASConditionalWriteFenceLostPostWrite is never incremented, and MergeTask's projection_uses_parent_transaction is written and read inside one function. Introduced by edb79bed048, 9fd2b915c97 and 93d545b7446 (PR #2159), ported in c770be91e83. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Mikhail Filimonov --- docs/en/antalya/metrics_and_events.md | 1 - src/Common/ProfileEvents.cpp | 1 - .../MetadataStorageFromCacheObjectStorage.cpp | 7 ------- .../MetadataStorageFromCacheObjectStorage.h | 1 - src/Storages/MergeTree/DataPartsExchange.cpp | 16 +++------------- src/Storages/MergeTree/MergeTask.cpp | 11 ++++------- src/Storages/MergeTree/MergeTask.h | 4 ---- 7 files changed, 7 insertions(+), 34 deletions(-) diff --git a/docs/en/antalya/metrics_and_events.md b/docs/en/antalya/metrics_and_events.md index dad8442ef7a59..337091c894032 100644 --- a/docs/en/antalya/metrics_and_events.md +++ b/docs/en/antalya/metrics_and_events.md @@ -327,7 +327,6 @@ These counters cover conditional writes made without transparent SDK retries. | `CASConditionalWriteAttempts` | `Number` | Number of CAS conditional-write HTTP attempts made without transparent SDK retries. Growth indicates CAS metadata or reference updates. | | `CASConditionalWriteCommitted` | `Number` | Number of CAS conditional writes completed successfully. Growth indicates successful conditional-update activity. | | `CASConditionalWriteDefiniteFailure` | `Number` | Number of CAS conditional writes rejected with certainty before applying. A non-zero value indicates invalid requests, oversized entities, or access denial. | -| `CASConditionalWriteFenceLostPostWrite` | `Number` | Number of CAS writes that succeeded but lost the final mount-fence check. A non-zero value indicates late responses after the mount lifecycle changed. | | `CASConditionalWriteUnresolved` | `Number` | Number of CAS conditional writes with an unknown outcome after conflict, timeout, connection loss, or server error. A non-zero value indicates backend instability or state requiring resolution. | #### Hot-key lane {#cas-hot-key} diff --git a/src/Common/ProfileEvents.cpp b/src/Common/ProfileEvents.cpp index 258f4c9d4b54c..9e56c3480081d 100644 --- a/src/Common/ProfileEvents.cpp +++ b/src/Common/ProfileEvents.cpp @@ -975,7 +975,6 @@ The server successfully detected this situation and will download merged part fr M(CASConditionalWriteCommitted, "Number of CAS conditional writes completed successfully. Growth indicates successful conditional-update activity.", ValueType::Number) \ M(CASConditionalWriteDefiniteFailure, "Number of CAS conditional writes rejected with certainty before applying. A non-zero value indicates invalid requests, oversized entities, or access denial.", ValueType::Number) \ M(CASConditionalWriteUnresolved, "Number of CAS conditional writes with an unknown outcome after conflict, timeout, connection loss, or server error. A non-zero value indicates backend instability or state requiring resolution.", ValueType::Number) \ - M(CASConditionalWriteFenceLostPostWrite, "Number of CAS writes that succeeded but lost the final mount-fence check. A non-zero value indicates late responses after the mount lifecycle changed.", ValueType::Number) \ M(CASRequestAttempt, "Number of physical requests the CAS request contract started. Each one was admitted by the mount fence and reserved against the call's deadline before it was sent.", ValueType::Number) \ M(CASRequestReissue, "Number of CAS requests re-sent: after a jittered backoff for an ordinary failure, after a flat pause for a connect-failure hint, or at once with no pause at all for a first-attempt fuse. Growth means the object store is throttling, failing, or contended.", ValueType::Number) \ M(CASRequestConflictPause, "Number of clean lost races the CAS request contract repaid after a flat jitter instead of a growing backoff: the resolve read had settled the conflict and no transport fault preceded it.", ValueType::Number) \ diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/Cache/MetadataStorageFromCacheObjectStorage.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/Cache/MetadataStorageFromCacheObjectStorage.cpp index e22e466383e6d..d5c308dc8bea5 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/Cache/MetadataStorageFromCacheObjectStorage.cpp +++ b/src/Disks/DiskObjectStorage/MetadataStorages/Cache/MetadataStorageFromCacheObjectStorage.cpp @@ -184,13 +184,6 @@ bool MetadataStorageFromCacheObjectStorage::isReadOnly() const return underlying->isReadOnly(); } -bool MetadataStorageFromCacheObjectStorage::isContentAddressed() const -{ - /// Defense-in-depth: never lie about content-addressing if this wrapper is ever constructed over a - /// CA storage. The primary fix (DiskObjectStorage::wrapWithCache) bypasses this wrapper for CA disks. - return underlying->isContentAddressed(); -} - 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 f77feec06e741..3c0944561a01e 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/Cache/MetadataStorageFromCacheObjectStorage.h +++ b/src/Disks/DiskObjectStorage/MetadataStorages/Cache/MetadataStorageFromCacheObjectStorage.h @@ -66,7 +66,6 @@ class MetadataStorageFromCacheObjectStorage : public IMetadataStorage std::optional getStorageObjectsIfExist(const std::string & path) const override; bool isReadOnly() const override; - bool isContentAddressed() const override; bool isTransactional() const override; bool isPlain() const override; bool isWriteOnce() const override; diff --git a/src/Storages/MergeTree/DataPartsExchange.cpp b/src/Storages/MergeTree/DataPartsExchange.cpp index f78543316c682..6ce267fd194ce 100644 --- a/src/Storages/MergeTree/DataPartsExchange.cpp +++ b/src/Storages/MergeTree/DataPartsExchange.cpp @@ -98,16 +98,6 @@ constexpr auto REPLICATION_PROTOCOL_VERSION_WITH_PARTS_PROJECTION = 7; constexpr auto REPLICATION_PROTOCOL_VERSION_WITH_METADATA_VERSION = 8; constexpr auto REPLICATION_PROTOCOL_VERSION_WITH_COLUMNS_SUBSTREAMS = 9; constexpr auto REPLICATION_PROTOCOL_VERSION_WITH_INVALIDATED_SYSTEM_COLUMNS = 10; -/// CAS replication 2b: fetch-by-relink. The receiver advertises its content-addressed pool identity -/// (`cas_pool_uuid`) and, if it matches the sender's own pool, the sender sends only the -/// part's content id (`part_id`) + the mutable header — no file bytes — and the receiver "fetches" by -/// publishing its own ref to the blobs already present in the shared pool (the CA analogue of the -/// zero-copy metadata-only fetch). Everything is gated behind a matching pool_uuid, so a non-CA fetch -/// is byte-for-byte unchanged. -/// Kept although nothing gates on it any more: the offer gate moved to `..._WITH_CA_CONFIRM` below, but -/// 10 is a version peers still advertise, and deleting the record of what it meant would leave the next -/// reader unable to tell what an incoming 10 promises (a relink it will NOT confirm). -[[maybe_unused]] constexpr auto REPLICATION_PROTOCOL_VERSION_WITH_CA_RELINK = 10; /// CAS replication, publish-then-confirm. A relink offer is now accompanied by a /// source token, and the endpoint answers a second, part-less request that asks whether that token is /// still exactly what the sender's ref names. A server advertising this version serves the confirm @@ -412,10 +402,10 @@ void Service::processQuery(const HTMLForm & params, ReadBufferPtr body, WriteBuf /// Strictly gated on a matching pool_uuid: a non-CA part, a CA part on a different pool, or a /// receiver without the capability all fall through to the unchanged byte path below. /// - /// The gate is `..._WITH_CA_CONFIRM`, not `..._WITH_CA_RELINK`: a receiver is offered a relink + /// The gate is `..._WITH_CA_CONFIRM`: a receiver is offered a relink /// only once it advertises that it will confirm the offer before promoting it. A receiver that - /// still advertises `..._WITH_CA_RELINK` gets the bytes — mixed versions degrade to bytes, never - /// to an unconfirmed relink. This gate and the version the client advertises + /// still advertises 10 (a build whose relink had no confirm step) gets the bytes — mixed versions + /// degrade to bytes, never to an unconfirmed relink. This gate and the version the client advertises /// (`fetchSelectedPart`) are one change in two places; separated in either order they either /// disable relink outright or hand an unconfirmed relink to a receiver that claimed it confirms. if (client_protocol_version >= REPLICATION_PROTOCOL_VERSION_WITH_CA_CONFIRM diff --git a/src/Storages/MergeTree/MergeTask.cpp b/src/Storages/MergeTree/MergeTask.cpp index 8445cd6d1a8c6..2ffed947bfba7 100644 --- a/src/Storages/MergeTree/MergeTask.cpp +++ b/src/Storages/MergeTree/MergeTask.cpp @@ -594,15 +594,12 @@ bool MergeTask::ExecuteAndFinalizeHorizontalPart::prepare() const std::optional builder; if (global_ctx->parent_part) { - /// On a content-addressed disk a part is one atomic unit (one manifest + one ref), so the - /// projection sub-part must be written through the PARENT part's whole-part transaction -- - /// mirroring the INSERT path -- for its files to land in the parent manifest and survive a - /// reload. On any other disk the projection keeps its own sub-transaction, as before. - global_ctx->projection_uses_parent_transaction - = global_ctx->parent_part->getDataPartStorage().isContentAddressed(); + /// On a content-addressed disk a part is one atomic unit, so a projection sub-part is written + /// through the parent part's transaction; elsewhere it keeps its own sub-transaction. + const bool use_parent_transaction = global_ctx->parent_part->getDataPartStorage().isContentAddressed(); /// Non-initializing, so nothing is seeded from an existing directory. auto data_part_storage = global_ctx->parent_part->getDataPartStorage().getProjectionNoInitialize( - local_tmp_part_basename, /* use_parent_transaction */ global_ctx->projection_uses_parent_transaction); + local_tmp_part_basename, use_parent_transaction); builder.emplace(*global_ctx->data, global_ctx->future_part->name, data_part_storage, getReadSettings(), PartDirIntent::CreateFresh); builder->withParentPart(global_ctx->parent_part); } diff --git a/src/Storages/MergeTree/MergeTask.h b/src/Storages/MergeTree/MergeTask.h index 1212e3f785cda..0ffa0f7fcd60b 100644 --- a/src/Storages/MergeTree/MergeTask.h +++ b/src/Storages/MergeTree/MergeTask.h @@ -216,10 +216,6 @@ class MergeTask ProjectionDescriptionRawPtr projection{nullptr}; /// This will be either nullptr or new_data_part, so raw pointer is ok. IMergeTreeDataPart * parent_part{nullptr}; - /// True only when this MergeTask builds a projection sub-part (`parent_part != nullptr`) whose - /// parent lives on a content-addressed disk: the sub-part then shares the parent's whole-part - /// transaction and must NOT begin/commit its own (B58). False for non-CA disks and top-level parts. - bool projection_uses_parent_transaction{false}; MergedPartOffsetsPtr merged_part_offsets; ContextPtr context{nullptr}; time_t time_of_merge{0}; From cbfcedbdcf95085ea96b4f357c04dd250f9fd3d9 Mon Sep 17 00:00:00 2001 From: Mikhail Filimonov Date: Fri, 23 Oct 2026 03:20:58 +0200 Subject: [PATCH 04/20] Remove stray includes and comment noise in S3 object storage Unused include and extern, a one-use local and blank lines, from PR #1718's port. Introduced by 069f021bf71 and 6c07482bb47, ported in c770be91e83. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Mikhail Filimonov --- .../DiskObjectStorage/ObjectStorages/S3/S3ObjectStorage.cpp | 5 +---- src/IO/S3/copyS3File.cpp | 2 -- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/src/Disks/DiskObjectStorage/ObjectStorages/S3/S3ObjectStorage.cpp b/src/Disks/DiskObjectStorage/ObjectStorages/S3/S3ObjectStorage.cpp index 95aead7d2815a..f2d67cea6728c 100644 --- a/src/Disks/DiskObjectStorage/ObjectStorages/S3/S3ObjectStorage.cpp +++ b/src/Disks/DiskObjectStorage/ObjectStorages/S3/S3ObjectStorage.cpp @@ -35,7 +35,6 @@ #include #include #include -#include #include #include #include @@ -99,7 +98,6 @@ namespace ErrorCodes extern const int CANNOT_READ_ALL_DATA; extern const int LOGICAL_ERROR; extern const int NOT_IMPLEMENTED; - extern const int S3_ERROR; } namespace @@ -303,8 +301,7 @@ auto S3ObjectStorage::refreshAndRetryOnExpiredCredentials(Fn && fn) const bool S3ObjectStorage::exists(const StoredObject & object) const { auto settings_ptr = s3_settings.get(); - const bool e = S3::objectExists(*client.get(), uri.bucket, object.remote_path, {}); - return e; + return S3::objectExists(*client.get(), uri.bucket, object.remote_path, {}); } std::unique_ptr S3ObjectStorage::readObject( /// NOLINT diff --git a/src/IO/S3/copyS3File.cpp b/src/IO/S3/copyS3File.cpp index 812c5c78e97fd..545979a97be35 100644 --- a/src/IO/S3/copyS3File.cpp +++ b/src/IO/S3/copyS3File.cpp @@ -723,7 +723,6 @@ namespace request.SetContentType("binary/octet-stream"); client_ptr->setKMSHeaders(request); - } void processCopyRequest(S3::CopyObjectRequest & request) @@ -760,7 +759,6 @@ namespace outcome.GetError().GetMessage(), outcome.GetError().GetErrorType(), outcome.GetError().GetExceptionName()); - LOG_INFO( log, "Multipart upload using copy is not supported, will try regular upload for Bucket: {}, Key: {}, Object size: " From 8d4025dd159ba7370951781791d4f367edae3972 Mon Sep 17 00:00:00 2001 From: Mikhail Filimonov Date: Fri, 23 Oct 2026 03:21:01 +0200 Subject: [PATCH 05/20] Drop internal-tag comments from MergeTree and disk files Comments and one exception message carried fork-internal ticket tags (B16, B33, B34, B59, B168 and others) and history; the constraints they explain stay as one line each where the code still relies on them, and a comment that only restated history goes. No code change. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Mikhail Filimonov --- src/Common/ProfileEvents.cpp | 4 ++-- .../DiskObjectStorageTransaction.cpp | 13 ++++------- .../DiskObjectStorageTransaction.h | 2 +- .../MetadataStorages/IMetadataStorage.h | 2 +- .../Local/LocalObjectStorage.cpp | 4 +--- src/IO/ReadBufferFromS3.cpp | 5 +--- src/IO/S3/Client.cpp | 6 ++--- src/IO/S3/tests/gtest_aws_s3_client.cpp | 2 +- .../MergeTree/DataPartStorageOnDiskBase.cpp | 23 +++++-------------- .../MergeTree/DataPartStorageOnDiskFull.cpp | 6 +---- src/Storages/MergeTree/IDataPartStorage.h | 6 ++--- .../MergeTree/MergeProjectionPartsTask.cpp | 5 +--- src/Storages/MergeTree/MergeTask.cpp | 5 ---- .../MergeTree/MergeTreeDataWriter.cpp | 2 -- .../MergeTree/MergeTreeDeduplicationLog.cpp | 7 +++--- src/Storages/MergeTree/MutateTask.cpp | 3 --- .../gtest_deduplication_log_null_writer.cpp | 2 +- src/Storages/StorageReplicatedMergeTree.cpp | 12 ---------- 18 files changed, 27 insertions(+), 82 deletions(-) diff --git a/src/Common/ProfileEvents.cpp b/src/Common/ProfileEvents.cpp index 9e56c3480081d..f891e1ab8c3c8 100644 --- a/src/Common/ProfileEvents.cpp +++ b/src/Common/ProfileEvents.cpp @@ -819,8 +819,8 @@ The server successfully detected this situation and will download merged part fr M(S3ListObjects, "Number of S3 API ListObjects calls.", ValueType::Number) \ M(S3ListObjectsMicroseconds, "Time of S3 API ListObjects execution.", ValueType::Microseconds) \ M(S3HeadObject, "Number of S3 API HeadObject calls.", ValueType::Number) \ - /* CA per-namespace S3 op instrumentation (B168 P0). 80 = 8 namespaces × 10 ops; attributes the */ \ - /* S3 op-count (PUTs/HEADs/404s/412s/LISTs) by CA operation type via the InstrumentedBackend seam. */ \ + /* CA per-namespace S3 op instrumentation: one event per (namespace, op) cell of the table in */ \ + /* CasInstrumentedBackend.cpp; attributes PUTs/HEADs/404s/412s/LISTs to the CA operation type. */ \ M(CASBlobPut, "Number of CAS blob PUT requests. Grows with blob uploads and indicates storage write traffic.", ValueType::Number) \ M(CASBlobPutDeduplicated, "Number of CAS blob deduplicating PUT requests. Grows when uploads reuse existing content.", ValueType::Number) \ M(CASBlobOverwrite, "Number of CAS blob overwrite requests. Growing values indicate repeated replacement writes.", ValueType::Number) \ diff --git a/src/Disks/DiskObjectStorage/DiskObjectStorageTransaction.cpp b/src/Disks/DiskObjectStorage/DiskObjectStorageTransaction.cpp index c20e854fe9802..fe015fca187e0 100644 --- a/src/Disks/DiskObjectStorage/DiskObjectStorageTransaction.cpp +++ b/src/Disks/DiskObjectStorage/DiskObjectStorageTransaction.cpp @@ -291,7 +291,7 @@ std::unique_ptr DiskObjectStorageTransaction::writeFile WriteSettings enriched_settings = updateIOSchedulingSettings(settings, read_resource_name, write_resource_name); - /// [TXN-ONE-PIPELINE] Give the metadata storage a chance to own the write (e.g. a content-addressed + /// Give the metadata storage a chance to own the write (e.g. a content-addressed /// hash-on-write buffer whose blob key is known only after the last byte). It returns a fully-wrapped /// buffer (hash-on-write + append RMW + inline/blob split + autocommit/lifetime pin using `owner`), or /// nullptr to fall through to the generic up-front-key streaming path below. Called BEFORE the append @@ -468,9 +468,7 @@ void DiskObjectStorageTransaction::writeFileUsingBlobWritingFunction( /// We always use mode Rewrite because we simulate append using metadata and different files object.bytes_size = std::move(write_blob_function)(blob_path, WriteMode::Rewrite, /*object_attributes=*/std::nullopt); - /// [TXN-ONE-PIPELINE] Routed through addOperation for uniformity. Unreachable on CA (Audit 6): - /// generateObjectKeyForPath above throws NOT_IMPLEMENTED first, so CA never reaches this metadata - /// effect and never queues. On ordinary storage addOperation queues exactly as before. + /// Unreachable on a content-addressed storage: generateObjectKeyForPath above throws NOT_IMPLEMENTED first. addOperation([object, mode](MetadataTransactionPtr tx) { if (mode == WriteMode::Rewrite) @@ -492,7 +490,7 @@ void DiskObjectStorageTransaction::createHardLink(const std::string & src_path, /// For CA `addOperation` runs eagerly (call-time), which is load-bearing for read-your-writes: a /// carried-forward projection hardlinked into the open whole-part transaction during a mutation must /// be visible to `loadProjections` (same finalize, before commit) via the directory overlay. Deferring - /// it to commit replay would hide it until after `loadProjections` ran (B58/B63). The metadata-level + /// it to commit replay would hide it until after `loadProjections` ran. The metadata-level /// `createHardLink` is an idempotent map assignment, so eager staging is equivalent to the queued /// replay — commit publishes the manifest from the staging. addOperation([src_path, dst_path](MetadataTransactionPtr tx) @@ -643,10 +641,7 @@ void DiskObjectStorageTransaction::copyFileImpl( return; } - /// [TXN-ONE-PIPELINE] Routed through addOperation for uniformity. Unreachable on CA (Audit 6): - /// copyFileImpl calls generateObjectKeyForPath above, which throws NOT_IMPLEMENTED on CA before this - /// point (and the empty-source case returns via the real writeFile above), so CA never queues here. - /// On ordinary storage addOperation queues exactly as before. + /// Unreachable on a content-addressed storage: copyFileImpl's generateObjectKeyForPath throws NOT_IMPLEMENTED first. addOperation([blobs_to_create, missing_locations, to_file_path](MetadataTransactionPtr tx) { for (const auto & blob : blobs_to_create) diff --git a/src/Disks/DiskObjectStorage/DiskObjectStorageTransaction.h b/src/Disks/DiskObjectStorage/DiskObjectStorageTransaction.h index d8cb9068c8692..d4260fb8cc007 100644 --- a/src/Disks/DiskObjectStorage/DiskObjectStorageTransaction.h +++ b/src/Disks/DiskObjectStorage/DiskObjectStorageTransaction.h @@ -121,7 +121,7 @@ struct DiskObjectStorageTransaction : public IDiskTransaction, public std::enabl void setReadOnly(const std::string & path) override; void createHardLink(const std::string & src_path, const std::string & dst_path) override; - /// B59 in-flight read-your-writes: forward to the metadata transaction (e.g. a CA part-build + /// In-flight read-your-writes: forward to the metadata transaction (e.g. a CA part-build /// transaction resolving its own staged-but-uncommitted files). std::optional tryGetInFlightStorageObjects(const std::string & path) const override; std::unique_ptr tryReadFileInFlight( diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/IMetadataStorage.h b/src/Disks/DiskObjectStorage/MetadataStorages/IMetadataStorage.h index 1939a99fc41bd..4622d4e476659 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/IMetadataStorage.h +++ b/src/Disks/DiskObjectStorage/MetadataStorages/IMetadataStorage.h @@ -117,7 +117,7 @@ class IMetadataTransaction : private boost::noncopyable throwNotImplemented(); } - /// [TXN-ONE-PIPELINE] Optional per-metadata write buffer. Returns a ready-to-use buffer when the + /// Optional per-metadata write buffer. Returns a ready-to-use buffer when the /// metadata implementation owns its write mechanism (e.g. a content-addressed hash-on-write buffer /// whose blob key is known only after the last byte). `owner` is the disk transaction that must be /// kept alive for the returned buffer's lifetime and, when `autocommit`, committed from the finalize diff --git a/src/Disks/DiskObjectStorage/ObjectStorages/Local/LocalObjectStorage.cpp b/src/Disks/DiskObjectStorage/ObjectStorages/Local/LocalObjectStorage.cpp index da07d7f24341f..f586193b1f763 100644 --- a/src/Disks/DiskObjectStorage/ObjectStorages/Local/LocalObjectStorage.cpp +++ b/src/Disks/DiskObjectStorage/ObjectStorages/Local/LocalObjectStorage.cpp @@ -733,9 +733,7 @@ std::optional LocalObjectStorage::tryGetObjectMetadata(const std auto resolved_path = resolvePathRelativelyToKeyPrefix(path); LOG_TEST(log, "Getting metadata for path: {}", resolved_path); - /// A directory is not an object: fs::file_size would throw "Is a directory". Treat it as a - /// missing object (nullopt) so callers probing whether a path is a readable object do not get - /// a raw filesystem error (B38: system.remote_data_paths traversal on a CAS pool). + /// A directory is not an object; report it as absent instead of surfacing "Is a directory". std::error_code error; if (fs::is_directory(resolved_path, error)) return {}; diff --git a/src/IO/ReadBufferFromS3.cpp b/src/IO/ReadBufferFromS3.cpp index 0445dd2250c19..3062b737b4566 100644 --- a/src/IO/ReadBufferFromS3.cpp +++ b/src/IO/ReadBufferFromS3.cpp @@ -376,10 +376,7 @@ bool ReadBufferFromS3::processException(size_t read_offset, size_t attempt) cons bucket, key, version_id.empty() ? "Latest" : version_id, read_offset, attempt, request_settings[S3RequestSetting::max_single_read_retries].value, getCurrentExceptionMessage(/* with_stacktrace = */ false)); - /// Stop retrying once the query is cancelled (B117): otherwise a killed query's reads keep - /// retrying a transient error (e.g. a dropped connection) for many attempts with backoff, - /// zombying for minutes and adding load. The SDK's own RetryStrategy makes the same check - /// (src/IO/S3/Client.cpp), but this outer ReadBufferFromS3 retry loop did not. + /// A cancelled query must not keep retrying a transient read error (the SDK's RetryStrategy applies the same rule). if (CurrentThread::isInitialized() && CurrentThread::get().isQueryCanceled()) return false; diff --git a/src/IO/S3/Client.cpp b/src/IO/S3/Client.cpp index 17b3cd249357c..50a37ea7427fe 100644 --- a/src/IO/S3/Client.cpp +++ b/src/IO/S3/Client.cpp @@ -108,10 +108,8 @@ bool Client::RetryStrategy::ShouldRetry(const Aws::Client::AWSErrorgetDisk(); - /// B34: the temporary-hard-link BACKUP path (used for Ordinary, non-UUID databases) calls + /// The temporary-hard-link BACKUP path (used for Ordinary, non-UUID databases) calls /// disk->createHardLink with a non-part-shaped temp path, which on a CAS disk /// would otherwise surface as a raw LOGICAL_ERROR. Fail closed with a clear message instead. /// The pointer-holding path (make_temporary_hard_links=false, used by Atomic/UUID databases) @@ -505,7 +505,7 @@ void DataPartStorageOnDiskBase::backup( if (make_temporary_hard_links && disk->isContentAddressed()) throw Exception( ErrorCodes::SUPPORT_IS_DISABLED, - "BACKUP via temporary hard links is not supported on a CAS disk yet (B16/B34); " + "BACKUP via temporary hard links is not supported on a CAS disk yet; " "use an Atomic database (which backs up via pointer-holding) instead; disk '{}'", disk->getName()); @@ -615,12 +615,8 @@ MutableDataPartStoragePtr DataPartStorageOnDiskBase::freeze( { auto disk = volume->getDisk(); - /// A CAS disk models a part as one atomic unit (N files -> one manifest -> one ref). - /// The per-file createHardLink autocommit Backup uses with no enclosing transaction would publish a - /// one-file ref per file and overwrite the destination, leaving the clone with only its last file - /// (the B21 corruption mode — seen as system.detached_parts listing metadata_version.txt instead of - /// the detached part dir, B36). When the caller did not supply a transaction, run the whole clone - /// through ONE self-created disk transaction so all files land in a single content-addressed part. + /// A content-addressed disk publishes a part as one unit, so a clone without an enclosing transaction + /// runs through one self-created transaction instead of per-file autocommits. DiskTransactionPtr owned_transaction; if (!params.external_transaction && disk->isContentAddressed()) owned_transaction = disk->createTransaction(); @@ -661,15 +657,8 @@ MutableDataPartStoragePtr DataPartStorageOnDiskBase::freeze( clone_transaction->removeFileIfExists(fs::path(to) / dir_path / IMergeTreeDataPart::METADATA_VERSION_FILE_NAME); IMergeTreeDataPart::writeInvalidatedSystemColumnsFile(*clone_transaction, fs::path(to) / dir_path, params.invalidated_columns_to_write, write_settings); - /// When the caller wants a fresh metadata version written into the clone (the Replicated queue - /// clone path — `executeReplaceRange`/`replacePartitionFrom`/`movePartitionToTable` set - /// `metadata_version_to_write`), write `metadata_version.txt` INSIDE the clone transaction so it - /// is part of the single whole-part commit. On a content-addressed disk the part is published - /// atomically at `commit`; a separate post-clone autocommit `writeFile` of this part file (what - /// `cloneAndLoadDataPart` does for non-CA disks) would hit the per-file-autocommit guard (B21). - /// `cloneAndLoadDataPart`'s own post-clone write now runs unconditionally (the freeze special - /// case was dropped with all-tree Task 10): identical bytes land as a byte-equal repoint - /// no-op, differing bytes as a legal repoint. + /// Write `metadata_version.txt` inside the clone transaction so it is part of the single whole-part + /// commit; `cloneAndLoadDataPart`'s own later write lands as a byte-equal no-op. if (params.metadata_version_to_write.has_value()) { chassert(!params.keep_metadata_version); diff --git a/src/Storages/MergeTree/DataPartStorageOnDiskFull.cpp b/src/Storages/MergeTree/DataPartStorageOnDiskFull.cpp index b041372481eef..b58ba19c3fccb 100644 --- a/src/Storages/MergeTree/DataPartStorageOnDiskFull.cpp +++ b/src/Storages/MergeTree/DataPartStorageOnDiskFull.cpp @@ -254,11 +254,7 @@ void DataPartStorageOnDiskFull::createProjection(const std::string & name) void DataPartStorageOnDiskFull::beginTransaction() { - /// A borrowed projection sub-part shares the PARENT part's whole-part transaction (on a - /// content-addressed disk a part is one atomic unit: one manifest + one ref). It must not open its - /// own — riding the parent transaction is the point (B58) — so begin is a no-op here. This - /// centralizes the rule the 6 merge/mutate call sites used to duplicate as - /// `if (!isContentAddressed()) beginTransaction()`. + /// A borrowed projection sub-part shares the parent part's transaction and must not open its own. if (has_shared_transaction) return; diff --git a/src/Storages/MergeTree/IDataPartStorage.h b/src/Storages/MergeTree/IDataPartStorage.h index e338c62551f07..2be34fe62c879 100644 --- a/src/Storages/MergeTree/IDataPartStorage.h +++ b/src/Storages/MergeTree/IDataPartStorage.h @@ -199,10 +199,8 @@ class IDataPartStorage : public boost::noncopyable virtual std::string getDiskName() const = 0; virtual std::string getDiskType() const = 0; virtual bool isStoredOnRemoteDisk() const { return false; } - /// True when the underlying disk stores a part as one atomic content-addressed unit (one manifest - /// + one ref). On such disks a projection sub-part must be written through the PARENT part's - /// whole-part transaction rather than its own sub-transaction (otherwise the projection is lost - /// from the committed manifest — B58). + /// True when the underlying disk stores a part as one atomic content-addressed unit; a projection + /// sub-part is then written through the parent part's transaction rather than its own. virtual bool isContentAddressed() const { return false; } /// True when the underlying disk publishes a file write atomically in one shot (no partial /// content ever becomes visible under the file's final name). Such disks do not need the diff --git a/src/Storages/MergeTree/MergeProjectionPartsTask.cpp b/src/Storages/MergeTree/MergeProjectionPartsTask.cpp index 12c67e6c0c434..1c79f05e4c3af 100644 --- a/src/Storages/MergeTree/MergeProjectionPartsTask.cpp +++ b/src/Storages/MergeTree/MergeProjectionPartsTask.cpp @@ -128,10 +128,7 @@ bool MergeProjectionPartsTask::executeStep() /// FIXME (alesapin) we should use some temporary storage for this, /// not commit each subprojection part - /// - /// A borrowed (CA) recursively-merged projection sub-part shares the parent part's whole-part - /// transaction (the nested MergeTask skipped its own begin), so it is committed by the parent's - /// single commit; the storage makes commitTransaction a no-op there, so this is unconditional (B58). + /// On a content-addressed disk the sub-part shares the parent's transaction, where commitTransaction is a no-op. next_level_parts.back()->getDataPartStorage().commitTransaction(); next_level_parts.back()->is_temp = true; next_level_parts.back()->temp_projection_block_number = block_num; diff --git a/src/Storages/MergeTree/MergeTask.cpp b/src/Storages/MergeTree/MergeTask.cpp index 2ffed947bfba7..750cf747c978d 100644 --- a/src/Storages/MergeTree/MergeTask.cpp +++ b/src/Storages/MergeTree/MergeTask.cpp @@ -1562,9 +1562,6 @@ void MergeTask::ExecuteAndFinalizeHorizontalPart::calculateProjectionForBlock( *global_ctx->data, result, projection, global_ctx->new_data_part.get(), ++ctx->projection_block_num, global_ctx->context); tmp_part->finalize(); - /// A borrowed (CA) temp projection sub-part rides the parent's whole-part transaction and is - /// committed by the parent's single commit; the storage makes commitTransaction a no-op there, - /// so this can be called unconditionally (B58). tmp_part->part->getDataPartStorage().commitTransaction(); ctx->projection_parts[projection.name].emplace_back(std::move(tmp_part->part)); } @@ -1607,8 +1604,6 @@ void MergeTask::ExecuteAndFinalizeHorizontalPart::finalizeProjections() const *global_ctx->data, result, projection, global_ctx->new_data_part.get(), ++ctx->projection_block_num, global_ctx->context); temp_part->finalize(); - /// See the matching note above: a borrowed (CA) temp projection sub-part rides the parent - /// transaction, so commitTransaction is a no-op and can be called unconditionally. temp_part->part->getDataPartStorage().commitTransaction(); ctx->projection_parts[projection.name].emplace_back(std::move(temp_part->part)); } diff --git a/src/Storages/MergeTree/MergeTreeDataWriter.cpp b/src/Storages/MergeTree/MergeTreeDataWriter.cpp index e703c47e48a83..11850d4c3dec8 100644 --- a/src/Storages/MergeTree/MergeTreeDataWriter.cpp +++ b/src/Storages/MergeTree/MergeTreeDataWriter.cpp @@ -1127,8 +1127,6 @@ MergeTreeTemporaryPartPtr MergeTreeDataWriter::writeProjectionPartImpl( auto projection_part_storage = new_data_part->getDataPartStoragePtr(); auto data_settings = data.getSettings(&projection.settings_changes); - /// A temp projection sub-part opens a transaction only if it owns one; a borrowed (CA) projection - /// storage makes beginTransaction a no-op, so the `isContentAddressed()` branch is no longer needed. if (is_temp) projection_part_storage->beginTransaction(); diff --git a/src/Storages/MergeTree/MergeTreeDeduplicationLog.cpp b/src/Storages/MergeTree/MergeTreeDeduplicationLog.cpp index a4ff5691bf4a1..76d25cfa3b3b9 100644 --- a/src/Storages/MergeTree/MergeTreeDeduplicationLog.cpp +++ b/src/Storages/MergeTree/MergeTreeDeduplicationLog.cpp @@ -275,9 +275,8 @@ std::vector MergeTreeDeduplicationLog: throw Exception(ErrorCodes::ABORTED, "Storage has been shutdown when we add this part."); } - /// A disk that cannot host the append-mode log leaves current_writer null; the release-build - /// chassert above is a no-op, so dereferencing it would segfault. Fail closed with a clear - /// exception instead of crashing the server (B37). + /// A disk that cannot host the append-mode log leaves current_writer null; the chassert above is a + /// no-op in release builds, so fail closed with an exception instead. if (!current_writer) throw Exception( ErrorCodes::LOGICAL_ERROR, @@ -321,7 +320,7 @@ void MergeTreeDeduplicationLog::dropPart(const MergeTreePartInfo & drop_part_inf throw Exception(ErrorCodes::ABORTED, "Storage has been shutdown when we drop this part."); } - /// As in addPart: a null writer must produce a clear exception, never a segfault (B37). + /// As in addPart: a null writer must produce an exception, never a null dereference. if (!current_writer) throw Exception( ErrorCodes::LOGICAL_ERROR, diff --git a/src/Storages/MergeTree/MutateTask.cpp b/src/Storages/MergeTree/MutateTask.cpp index 524a0758f9d1e..0e5d228f1508e 100644 --- a/src/Storages/MergeTree/MutateTask.cpp +++ b/src/Storages/MergeTree/MutateTask.cpp @@ -2194,9 +2194,6 @@ void PartMergerWriter::writeTempProjectionPart(size_t projection_idx, Chunk chun ctx->context); tmp_part->finalize(); - /// A borrowed (CA) temp projection sub-part shares the new (parent) part's whole-part transaction - /// (see `IMergeTreeDataPart::getProjectionPartBuilder`) and is committed by the parent's single - /// commit; the storage makes commitTransaction a no-op there, so this is called unconditionally (B58). tmp_part->part->getDataPartStorage().commitTransaction(); projection_parts[projection.name].emplace_back(std::move(tmp_part->part)); } diff --git a/src/Storages/MergeTree/tests/gtest_deduplication_log_null_writer.cpp b/src/Storages/MergeTree/tests/gtest_deduplication_log_null_writer.cpp index 7a2f72313a190..0930b6b673ffa 100644 --- a/src/Storages/MergeTree/tests/gtest_deduplication_log_null_writer.cpp +++ b/src/Storages/MergeTree/tests/gtest_deduplication_log_null_writer.cpp @@ -22,7 +22,7 @@ namespace { constexpr auto FORMAT_VERSION = MERGE_TREE_DATA_MIN_FORMAT_VERSION_WITH_CUSTOM_PARTITIONING; -/// B37 regression: a `MergeTreeDeduplicationLog` whose `current_writer` is null (the disk could not +/// Regression: a `MergeTreeDeduplicationLog` whose `current_writer` is null (the disk could not /// host the append-mode log -- see `MergeTreeDeduplicationLog::load()`'s early-return path for a /// `DiskObjectStorage` whose metadata storage type is neither `Plain` nor `ContentAddressed`) used to /// be dereferenced unconditionally by `addPart`/`dropPart`: a release-build `chassert` is a no-op, so diff --git a/src/Storages/StorageReplicatedMergeTree.cpp b/src/Storages/StorageReplicatedMergeTree.cpp index 415031ecb7a19..dfcce7ddeb26b 100644 --- a/src/Storages/StorageReplicatedMergeTree.cpp +++ b/src/Storages/StorageReplicatedMergeTree.cpp @@ -533,18 +533,6 @@ StorageReplicatedMergeTree::StorageReplicatedMergeTree( { if (disk->getDataSourceDescription().metadata_type == MetadataStorageType::Keeper) throw Exception(ErrorCodes::BAD_ARGUMENTS, "ReplicatedMergeTree doesn't work with 's3_with_keeper' disk type"); - - /// B33 (lifted, CAS replication 2b + Phase 3.2): ReplicatedMergeTree on a content-addressed disk - /// is allowed. INSERT/SELECT/merge/mutation and fetch-by-relink (the CA analogue of zero-copy - /// replication) route through the working whole-part CA transaction / the relink path. The - /// replication-queue CLONE paths (queue-driven REPLACE/MOVE/ATTACH PARTITION FROM, the - /// cloneAndLoadDataPart-on-the-queue path) were audited in Phase 3.2: they reach the SAME - /// whole-part ContentAddressedTransaction the non-replicated stack uses (see - /// `MergeTreeData::checkAlterPartitionIsPossible`, reached here by dynamic dispatch — the - /// Phase 3.2 fail-closed override in this class was a pure delegation and was deleted by the - /// tail de-patch), NOT the per-file-autocommit B21 mode, so they are now permitted. The - /// zero-copy lockSharedData/unlockSharedData calls these reach are safe no-ops on CA (they - /// early-return on !supportZeroCopyReplication, which CA is). } initializeDirectoriesAndFormatVersion(relative_data_path_, LoadingStrictnessLevel::ATTACH <= mode, date_column_name); From b9f50afe11018f4d2e046e0e1aa7fc12495d82e3 Mon Sep 17 00:00:00 2001 From: Mikhail Filimonov Date: Fri, 23 Oct 2026 03:21:03 +0200 Subject: [PATCH 06/20] Find the minio client in the CAS-S3 stateless job The rustfs bucket step called /mc; the 26.8 stateless-test image ships the client at /opt/aistor/mc, so the job failed at "Start ClickHouse Server" with "/mc: No such file or directory". Probe both locations and fail the step when neither exists instead of trying a bare mc from PATH. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Mikhail Filimonov --- ci/jobs/scripts/clickhouse_proc.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/ci/jobs/scripts/clickhouse_proc.py b/ci/jobs/scripts/clickhouse_proc.py index 72c457ed3dacc..8d6cde9550184 100644 --- a/ci/jobs/scripts/clickhouse_proc.py +++ b/ci/jobs/scripts/clickhouse_proc.py @@ -321,9 +321,14 @@ def start_rustfs(self): ): print("Failed to start rustfs") return False - # The `test` bucket the storage policy expects. + # The `test` bucket the storage policy expects. The stateless-test image ships the client at + # /opt/aistor/mc or /mc. + mc = next((p for p in ("/opt/aistor/mc", "/mc") if Path(p).is_file()), None) + if mc is None: + print("minio client not found at /opt/aistor/mc or /mc") + return False res = Shell.check( - "/mc alias set carustfs http://localhost:11121 clickhouse clickhouse && /mc mb --ignore-existing carustfs/test", + f"{mc} alias set carustfs http://localhost:11121 clickhouse clickhouse && {mc} mb --ignore-existing carustfs/test", verbose=True, ) if not res: From 5a1ffb222a567ec36f04751864a7b8f92472bdaf Mon Sep 17 00:00:00 2001 From: Mikhail Filimonov Date: Fri, 23 Oct 2026 03:21:05 +0200 Subject: [PATCH 07/20] Allow every partition command, EXPORT PART included, on a CAS disk checkAlterPartitionIsPossible kept a whitelist of partition commands for CAS disks because early clones copied part files one by one outside a transaction. Every command now clones through freeze, freezeRemote or clonePart, which publish the clone as one part, and EXPORT PART only reads the source (ExportPartTask over MergeTreeSequentialSource), so the whitelist and its history-laden comment go; one comment at the case says what a new command type must not do. EXPORT PARTITION persists `partition_exports/.json` in the table directory. The CAS path parser took that directory for a part named `partition_exports`, so the scheduler's tmp + replaceFile sequence ran as two part publications. The name is reserved as a table-level directory next to `deduplication_logs`; the descriptor is then a verbatim table-level file and the scheduler is unchanged. The EXPORT PART and EXPORT PARTITION tests lose their no-cas-storage tag and run on the CAS lanes. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Mikhail Filimonov --- .../ContentAddressed/Parts/PartPathParser.cpp | 15 +++-- .../ContentAddressed/Parts/PartPathParser.h | 11 +++- src/Storages/MergeTree/MergeTreeData.cpp | 62 +------------------ .../03572_export_merge_tree_part_basic.sh | 2 +- ...ge_tree_part_limits_and_table_functions.sh | 2 +- ..._export_merge_tree_part_special_columns.sh | 2 +- ...export_merge_tree_part_filename_pattern.sh | 2 +- .../05027_export_partition_merge_tree.sh | 2 +- ...port_partition_key_collision_merge_tree.sh | 2 +- 9 files changed, 24 insertions(+), 76 deletions(-) diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Parts/PartPathParser.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Parts/PartPathParser.cpp index fc27132b211cd..b9373f0272282 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Parts/PartPathParser.cpp +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Parts/PartPathParser.cpp @@ -190,9 +190,8 @@ static std::optional findPartDirComponent(const std::vector component } @@ -352,13 +351,13 @@ std::optional parseTableFilePath(const std::string & path) if (p.size() < 2 || findPartDirComponent(p)) return std::nullopt; - // A reserved table-level subdirectory (deduplication_logs/) splits the path explicitly: the - // table id is everything before it, the tail is the reserved dir and everything under it. - // Without this the generic "last component is the file" rule would fold the subdir into the - // table id and mis-scope the log objects. Index >= 1 so the table id is never the bare root. + // A reserved table-level subdirectory (deduplication_logs/, partition_exports/) splits the path + // explicitly: the table id is everything before it, the tail is the reserved dir and everything + // under it. Without this the generic "last component is the file" rule would fold the subdir into + // the table id and mis-scope the objects. Index >= 1 so the table id is never the bare root. for (size_t i = 1; i + 1 < p.size(); ++i) { - if (p[i] == kDeduplicationLogsDirName) + if (isReservedTableSubdir(p[i])) { TableFilePath r; r.table_uuid = joinTableId(p, 0, i); diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Parts/PartPathParser.h b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Parts/PartPathParser.h index ab509e8374522..2858afcbbaaca 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Parts/PartPathParser.h +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Parts/PartPathParser.h @@ -69,10 +69,15 @@ inline constexpr std::string_view kCasArchiveSuffix = "@cas@"; /// table-dir (last) segment in both cases. Pure; no ClickHouse dependency. std::string mirroredArchiveNamespace(const std::string & table_uuid); -/// Reserved table-level subdirectory: TABLE_DIR/deduplication_logs/FILE is structurally -/// indistinguishable from a part file in the Atomic layout, so the name is reserved — never a part -/// dir; its contents are table-level verbatim files. ClickHouse part names never take this form. +/// Reserved table-level subdirectories: TABLE_DIR//FILE is structurally indistinguishable from +/// a part file in the Atomic layout, so these names are reserved — never a part dir; their contents +/// are table-level verbatim files. ClickHouse part names never take these forms. inline constexpr std::string_view kDeduplicationLogsDirName = "deduplication_logs"; +inline constexpr std::string_view kPartitionExportsDirName = "partition_exports"; /// EXPORT PARTITION descriptors +inline bool isReservedTableSubdir(std::string_view name) +{ + return name == kDeduplicationLogsDirName || name == kPartitionExportsDirName; +} /// The result of splitting a part path. `table_uuid` is a bare UUID for an Atomic table and the /// joined `data//` path for a non-Atomic table. For detached or moving paths, diff --git a/src/Storages/MergeTree/MergeTreeData.cpp b/src/Storages/MergeTree/MergeTreeData.cpp index 09541e0891936..3a47ced45ab95 100644 --- a/src/Storages/MergeTree/MergeTreeData.cpp +++ b/src/Storages/MergeTree/MergeTreeData.cpp @@ -8256,65 +8256,6 @@ void MergeTreeData::checkAlterPartitionIsPossible( can_execute_alter_on_disk = std::ranges::contains(supported_commands, command.type); break; } - case MetadataStorageType::CAS: - { - /// On a CAS disk whole-part publication is transactional. Same-disk clones use - /// `DataPartStorageOnDiskBase::freeze`; cross-disk clones use - /// `DataPartStorageOnDiskBase::freezeRemote`, which streams source bytes into ONE CA - /// transaction. When both disks share a pool, the publish dedup-resolves existing blobs - /// and becomes a ref repoint, although the sequential source read still happens. - /// `moveDirectory` re-keys the detached-staging → active rename into a complete active - /// ref — so these are SUPPORTED and verified (read back identical data, survive restart): - /// `ATTACH PARTITION`/`ATTACH PART` (re-clone of the table's own - /// detached parts), `REPLACE PARTITION`/`ATTACH PARTITION ... FROM` (parses to - /// `REPLACE_PARTITION`), and `MOVE PARTITION ... TO TABLE`. The pointer-unlink commands - /// `DROP PARTITION` / `DETACH PARTITION` / `DROP DETACHED PARTITION` are also fine. - /// `FETCH PARTITION`/`FETCH PART` is also SUPPORTED — it is a `ReplicatedMergeTree` op - /// (now supported on CA), and a `to_detached` fetch takes the byte-fetch path: the - /// downloaded files content-address into the `detached/` namespace (relink-into-detached - /// is deferred, see backlog). `ALTER ... FETCH PART` parses to the same `FETCH_PARTITION` - /// command type (with `part=true`), so this entry covers both. - /// `FREEZE PARTITION`/`FREEZE ALL` and `UNFREEZE PARTITION`/`UNFREEZE ALL` are now SUPPORTED: - /// a freeze publishes each part as its own ref in the `shadow/` namespace (a GC root sharing - /// the live blobs zero-copy — no byte copy); UNFREEZE removes the backup's refs. - /// `FORGET PARTITION` is SUPPORTED on CA — it only manipulates ZooKeeper partition metadata - /// (removes block-number nodes from ZooKeeper) and does not write, clone, or touch any part - /// files on disk, so it is safe on a content-addressed disk. - /// `EXPORT PARTITION` is SUPPORTED because the reason this list exists does not apply - /// to it. The rejection below is about commands that clone parts file-by-file; - /// exporting does not clone at all. `ExportPartTask` reads the source part through - /// `MergeTreeSequentialSource` (`MergeTreeSequentialSourceType::Export`) and writes - /// rows into the destination through a `SinkToStorage` on an ordinary query - /// pipeline, so the source's part files are only READ, under `readLockParts`, and - /// nothing is hard-linked or copied on the content-addressed disk. The command's - /// own bookkeeping is ZooKeeper-side. - /// NOTE: `MOVE_PARTITION` also admits cross-disk - /// `MOVE ... TO DISK/VOLUME` (this check cannot distinguish the destination); that uses - /// the byte-copy `clonePart` path (NOT the corrupting per-file hardlink), but only - /// same-disk `MOVE ... TO TABLE` is verified here — cross-disk is a follow-up to verify. - const static auto supported_commands = { - PartitionCommand::DROP_PARTITION, - PartitionCommand::DROP_DETACHED_PARTITION, - PartitionCommand::FORGET_PARTITION, - PartitionCommand::ATTACH_PARTITION, - PartitionCommand::REPLACE_PARTITION, - PartitionCommand::MOVE_PARTITION, - PartitionCommand::FETCH_PARTITION, - PartitionCommand::FREEZE_PARTITION, - PartitionCommand::FREEZE_ALL_PARTITIONS, - PartitionCommand::UNFREEZE_PARTITION, - PartitionCommand::UNFREEZE_ALL_PARTITIONS, - PartitionCommand::EXPORT_PARTITION, - }; - - if (!std::ranges::contains(supported_commands, command.type)) - throw Exception( - ErrorCodes::SUPPORT_IS_DISABLED, - "Partition operation ALTER TABLE {} is not supported on a CAS disk yet " - "(it clones parts file-by-file with no transaction, which would corrupt the clone); disk '{}'", - command.typeToString(), disk->getName()); - break; - } case MetadataStorageType::StaticWeb: case MetadataStorageType::WebIndex: { @@ -8325,6 +8266,9 @@ void MergeTreeData::checkAlterPartitionIsPossible( case MetadataStorageType::Local: case MetadataStorageType::Keeper: case MetadataStorageType::Memory: + /// CAS: every partition command clones parts through `freeze`, `freezeRemote` or `clonePart`, which + /// publish the clone as one part; a new command type that copies files one by one must not land here. + case MetadataStorageType::CAS: { can_execute_alter_on_disk = true; break; diff --git a/tests/queries/0_stateless/03572_export_merge_tree_part_basic.sh b/tests/queries/0_stateless/03572_export_merge_tree_part_basic.sh index d3549b2a9343a..804b1849d3165 100755 --- a/tests/queries/0_stateless/03572_export_merge_tree_part_basic.sh +++ b/tests/queries/0_stateless/03572_export_merge_tree_part_basic.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Tags: no-fasttest, no-cas-storage +# Tags: no-fasttest # Tag no-fasttest: requires s3 storage CURDIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) diff --git a/tests/queries/0_stateless/03572_export_merge_tree_part_limits_and_table_functions.sh b/tests/queries/0_stateless/03572_export_merge_tree_part_limits_and_table_functions.sh index 4e5f27b909c2b..dff7332662d03 100755 --- a/tests/queries/0_stateless/03572_export_merge_tree_part_limits_and_table_functions.sh +++ b/tests/queries/0_stateless/03572_export_merge_tree_part_limits_and_table_functions.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Tags: no-fasttest, no-cas-storage +# Tags: no-fasttest # Tag no-fasttest: requires s3 storage CURDIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) diff --git a/tests/queries/0_stateless/03572_export_merge_tree_part_special_columns.sh b/tests/queries/0_stateless/03572_export_merge_tree_part_special_columns.sh index ca5386cf52116..1dc66f49e66bc 100755 --- a/tests/queries/0_stateless/03572_export_merge_tree_part_special_columns.sh +++ b/tests/queries/0_stateless/03572_export_merge_tree_part_special_columns.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Tags: no-fasttest, no-cas-storage +# Tags: no-fasttest # Tag no-fasttest: requires s3 storage CURDIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) diff --git a/tests/queries/0_stateless/03608_export_merge_tree_part_filename_pattern.sh b/tests/queries/0_stateless/03608_export_merge_tree_part_filename_pattern.sh index e86af9fd22c48..fa8b1c7c4dafa 100755 --- a/tests/queries/0_stateless/03608_export_merge_tree_part_filename_pattern.sh +++ b/tests/queries/0_stateless/03608_export_merge_tree_part_filename_pattern.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Tags: no-fasttest, no-cas-storage +# Tags: no-fasttest # Tag no-fasttest: requires s3 storage CURDIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) diff --git a/tests/queries/0_stateless/05027_export_partition_merge_tree.sh b/tests/queries/0_stateless/05027_export_partition_merge_tree.sh index 4b03fb08957f1..515ea3c3f920a 100755 --- a/tests/queries/0_stateless/05027_export_partition_merge_tree.sh +++ b/tests/queries/0_stateless/05027_export_partition_merge_tree.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Tags: no-fasttest, no-shared-merge-tree, no-cas-storage +# Tags: no-fasttest, no-shared-merge-tree # no-fasttest: requires S3 / MinIO. # no-shared-merge-tree: this test exercises EXPORT PARTITION on a plain (non-replicated) MergeTree. diff --git a/tests/queries/0_stateless/05029_export_partition_key_collision_merge_tree.sh b/tests/queries/0_stateless/05029_export_partition_key_collision_merge_tree.sh index 02e67312fd890..f9a0d5a700b76 100755 --- a/tests/queries/0_stateless/05029_export_partition_key_collision_merge_tree.sh +++ b/tests/queries/0_stateless/05029_export_partition_key_collision_merge_tree.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Tags: no-fasttest, no-shared-merge-tree, no-cas-storage +# Tags: no-fasttest, no-shared-merge-tree # no-fasttest: requires S3 / MinIO. # no-shared-merge-tree: this test exercises EXPORT PARTITION on a plain (non-replicated) MergeTree. From ca0fdfd4dcb45d6a49ea5ff0a26cac590c5979bc Mon Sep 17 00:00:00 2001 From: Mikhail Filimonov Date: Fri, 23 Oct 2026 03:21:08 +0200 Subject: [PATCH 08/20] Disks: appliesOperationsEagerly contract for eager transactions `appliesOperationsEagerly` replaces `transactionIsStagingOverlay`: one flag says that a mutating method runs against the metadata transaction at call time and never enters operations_to_execute; the memory storage, the content-addressed storage and the path wrapper report it. A non-empty queue at commit or tryCommit is a LOGICAL_ERROR whose message names the flag (the chassert is a no-op in release). The flag implies nothing about visibility or rollback; the in-flight read API (`tryGetInFlightStorageObjects` and friends) is independent of it, and its header comments say so. Four gtests over the memory storage pin the contract. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Mikhail Filimonov --- .../DiskObjectStorageTransaction.cpp | 21 +++-- .../DiskObjectStorageTransaction.h | 2 +- .../ContentAddressedMetadataStorage.cpp | 2 +- .../ContentAddressedMetadataStorage.h | 6 +- .../MetadataStorages/IMetadataStorage.h | 27 ++++--- src/Disks/IDiskTransaction.h | 2 +- .../gtest_metadata_storage_from_memory.cpp | 76 +++++++++++++++++++ 7 files changed, 108 insertions(+), 28 deletions(-) diff --git a/src/Disks/DiskObjectStorage/DiskObjectStorageTransaction.cpp b/src/Disks/DiskObjectStorage/DiskObjectStorageTransaction.cpp index fe015fca187e0..c6300de5942a5 100644 --- a/src/Disks/DiskObjectStorage/DiskObjectStorageTransaction.cpp +++ b/src/Disks/DiskObjectStorage/DiskObjectStorageTransaction.cpp @@ -110,7 +110,7 @@ MultipleDisksObjectStorageTransaction::MultipleDisksObjectStorageTransaction( void DiskObjectStorageTransaction::addOperation(std::function op) { - if (metadata_storage->appliesOperationsEagerly() || metadata_storage->transactionIsStagingOverlay()) + if (metadata_storage->appliesOperationsEagerly()) op(metadata_transaction); else operations_to_execute.push_back(std::move(op)); @@ -663,13 +663,13 @@ void MultipleDisksObjectStorageTransaction::copyFile(const std::string & from_fi void DiskObjectStorageTransaction::commit() { - /// [TXN-ONE-PIPELINE] An eager staging-overlay transaction (e.g. CA) must route every mutating method - /// straight to the metadata transaction at call time and keep this queue empty. A non-empty queue here - /// means a mutating method bypassed `addOperation` — which would re-introduce the two-timeline split this - /// design eliminates. Fail closed with a real throw (NOT chassert, which is a no-op in release builds). - if (metadata_storage->transactionIsStagingOverlay() && !operations_to_execute.empty()) + /// An eager transaction (memory, CA) routes every mutating method to the metadata transaction at call + /// time and keeps this queue empty. A non-empty queue here means a mutating method bypassed + /// `addOperation`, which would split the transaction into two timelines. Fail closed with a real throw + /// (the chassert below is a no-op in release builds). + if (metadata_storage->appliesOperationsEagerly() && !operations_to_execute.empty()) throw Exception(ErrorCodes::LOGICAL_ERROR, - "An eager staging-overlay transaction must not queue deferred operations " + "An eager transaction must not queue deferred operations " "(a mutating method bypassed addOperation): {} queued", operations_to_execute.size()); auto component_guard = Coordination::setCurrentComponent("DiskObjectStorageTransaction::commit"); @@ -717,11 +717,10 @@ void DiskObjectStorageTransaction::commit() TransactionCommitOutcomeVariant DiskObjectStorageTransaction::tryCommit(const TransactionCommitOptionsVariant & options) { - /// [TXN-ONE-PIPELINE] See commit(): an eager staging-overlay transaction must never queue deferred - /// operations. Fail closed (real throw, not chassert) if a mutating method bypassed `addOperation`. - if (metadata_storage->transactionIsStagingOverlay() && !operations_to_execute.empty()) + /// See commit(): an eager transaction must never queue deferred operations. + if (metadata_storage->appliesOperationsEagerly() && !operations_to_execute.empty()) throw Exception(ErrorCodes::LOGICAL_ERROR, - "An eager staging-overlay transaction must not queue deferred operations " + "An eager transaction must not queue deferred operations " "(a mutating method bypassed addOperation): {} queued", operations_to_execute.size()); chassert(operations_to_execute.empty() || !metadata_storage->appliesOperationsEagerly()); diff --git a/src/Disks/DiskObjectStorage/DiskObjectStorageTransaction.h b/src/Disks/DiskObjectStorage/DiskObjectStorageTransaction.h index d4260fb8cc007..6afa2b2ae704f 100644 --- a/src/Disks/DiskObjectStorage/DiskObjectStorageTransaction.h +++ b/src/Disks/DiskObjectStorage/DiskObjectStorageTransaction.h @@ -41,7 +41,7 @@ struct DiskObjectStorageTransaction : public IDiskTransaction, public std::enabl std::unordered_map written_blobs; /// Execute the operation right away if the metadata storage applies operations eagerly - /// (or its transaction is an eager staging overlay, e.g. CA), otherwise queue it until commit. + /// (memory, CA), otherwise queue it until commit. void addOperation(std::function op); public: diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.cpp index 4e3dc902c5bd6..7644bb2567587 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.cpp +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.cpp @@ -140,7 +140,7 @@ const char * casLifecycleReasonWord(Cas::PoolLifecycle lc) /// /// ---- ContentAddressedMetadataStorage ---- /// Factory (never gated): getType, getPath, supportsChmod, supportsStat, isReadOnly, isContentAddressed, -/// transactionIsStagingOverlay, supportsAtomicFileWrites, supportsTransactionalMutableFiles, +/// appliesOperationsEagerly, supportsAtomicFileWrites, supportsTransactionalMutableFiles, /// areBlobPathsRandom, getHardlinkCount, createTransaction (I/O-free -- allocates a txn), getPoolUUID, /// serverRootId, scratchPath, stagingBackend, objectStorage, gcHealth, /// lifecycleSnapshot (both non-store()-gated introspection reads for system.cas_mounts -- diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.h b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.h index a22b6cbf90bea..653ea5362fabf 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.h +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.h @@ -265,9 +265,9 @@ class ContentAddressedMetadataStorage final : public IMetadataStorage, public IC /// mutating entries through it. CasOpAdmission checkOpAdmitted(CasOpClass op) const; - /// Content-addressed transactions are eager staging overlays: each mutating disk-transaction - /// method reaches the metadata transaction immediately rather than entering the FIFO queue. - bool transactionIsStagingOverlay() const override { return true; } + /// Each mutating disk-transaction method reaches the metadata transaction immediately rather than + /// entering the FIFO queue; the effects stay in the transaction-private staging until commit. + bool appliesOperationsEagerly() const override { return true; } bool supportsAtomicFileWrites() const override { return true; } bool supportsTransactionalMutableFiles() const override { return true; } bool areBlobPathsRandom() const override { return false; } diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/IMetadataStorage.h b/src/Disks/DiskObjectStorage/MetadataStorages/IMetadataStorage.h index 4622d4e476659..28d40443231f8 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/IMetadataStorage.h +++ b/src/Disks/DiskObjectStorage/MetadataStorages/IMetadataStorage.h @@ -171,17 +171,23 @@ class IMetadataTransaction : private boost::noncopyable throwNotImplemented(); } - /// In-flight read-your-writes for a part being assembled by THIS transaction (B59). A CA part-build + /// In-flight read-your-writes for a part being assembled by THIS transaction. A CA part-build /// transaction stages blobs (uploaded) + mutable bytes before the single commit; these let a reader /// that holds the transaction resolve those staged files before they are committed. Default: no /// in-flight visibility (the committed metadata path is authoritative). + /// Contract: the answers are additive only. A positive answer describes a file this transaction + /// staged; an empty answer means "not staged here, fall through to committed metadata" and never + /// "absent" (no tombstones: a staged removal is invisible to readers). For a staged file the size + /// from `tryGetInFlightFileSize` equals the bytes `tryReadFileInFlight` serves. Independent of + /// `IMetadataStorage::appliesOperationsEagerly`: an eager storage may offer no in-flight view. virtual std::optional tryGetInFlightStorageObjects(const std::string & /*path*/) const { return {}; } virtual std::unique_ptr tryReadFileInFlight( const std::string & /*path*/, const ReadSettings & /*settings*/, std::optional /*read_hint*/) const { return nullptr; } virtual std::optional tryGetInFlightFileSize(const std::string & /*path*/) const { return {}; } - /// Directory-granularity counterpart of the file trio: true iff this transaction has STAGED at least one - /// file under `path` for `path`'s part. Used so a carried-forward projection dir is visible to - /// loadProjections during finalize. Default: no in-flight directory visibility. + /// Directory-granularity counterpart of the file trio: true when this transaction has STAGED at least + /// one file under an INNER directory `path` of a part (a projection dir). The part directory itself + /// answers false (see `ContentAddressedTransaction::hasInFlightDirectory`). Used so a carried-forward + /// projection dir is visible to loadProjections during finalize. Default: no in-flight directory visibility. virtual bool hasInFlightDirectory(const std::string & /*path*/) const { return false; } /// Immediate-child names staged directly under `path` (one level). Used so loadProjections' /// withPartFormatFromDisk can iterate a staged projection dir to find its mark file. Default: empty. @@ -325,6 +331,12 @@ class IMetadataStorage : private boost::noncopyable virtual bool isReadOnly() const = 0; /// True if transactions apply operations immediately instead of accumulating them until commit. + /// Contract: an operation `DiskObjectStorageTransaction` submits through `addOperation` runs against + /// the metadata transaction at call time and never enters `operations_to_execute`; a non-empty queue + /// at commit is a `LOGICAL_ERROR`. Nothing more is implied — not external visibility (memory mutates + /// shared state at once, content-addressed storage stages privately until commit), not atomicity or + /// rollback, not the in-flight read API of `IMetadataTransaction`. A `writeFile` may still submit its + /// metadata effect only when the buffer is finalized. virtual bool appliesOperationsEagerly() const { return false; @@ -359,13 +371,6 @@ class IMetadataStorage : private boost::noncopyable virtual bool allowsPackedParts() const { return true; } - /// [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 - /// metadata transaction and keeps its own operations_to_execute queue empty. Default false - /// (ordinary object storage). - virtual bool transactionIsStagingOverlay() const { return false; } - /// True when a file write through this metadata storage publishes atomically, i.e. no partial /// content is ever observable under the file's final name (see `IDataPartStorage::supportsAtomicFileWrites`). virtual bool supportsAtomicFileWrites() const { return false; } diff --git a/src/Disks/IDiskTransaction.h b/src/Disks/IDiskTransaction.h index 0a8a9ee3470e3..329fb2286bab0 100644 --- a/src/Disks/IDiskTransaction.h +++ b/src/Disks/IDiskTransaction.h @@ -151,7 +151,7 @@ struct IDiskTransaction : private boost::noncopyable throw Exception(ErrorCodes::NOT_IMPLEMENTED, "Blob reference counting is not implemented for this disk transaction"); } - /// In-flight read-your-writes for a part being assembled by THIS transaction (B59). Forwarded to the + /// In-flight read-your-writes for a part being assembled by THIS transaction. Forwarded to the /// metadata transaction by object-storage disk transactions; default (e.g. local disk) is no in-flight /// visibility, so a reader falls through to the committed path. virtual std::optional tryGetInFlightStorageObjects(const std::string & /*path*/) const { return {}; } diff --git a/src/Disks/tests/gtest_metadata_storage_from_memory.cpp b/src/Disks/tests/gtest_metadata_storage_from_memory.cpp index 3ec38f3251afc..8b0a40e0faa90 100644 --- a/src/Disks/tests/gtest_metadata_storage_from_memory.cpp +++ b/src/Disks/tests/gtest_metadata_storage_from_memory.cpp @@ -22,9 +22,26 @@ namespace fs = std::filesystem; using namespace DB; +namespace DB::ErrorCodes +{ + extern const int LOGICAL_ERROR; +} + namespace { +/// Reaches the protected queue of `DiskObjectStorageTransaction` to plant the deferred operation that +/// `addOperation` never queues on an eager storage. Never instantiated; it only names the member. +struct QueueProbe : DiskObjectStorageTransaction +{ + static void plantDeferredOperation(const DiskTransactionPtr & tx) + { + auto * object_tx = dynamic_cast(tx.get()); + ASSERT_TRUE(object_tx); + (object_tx->*(&QueueProbe::operations_to_execute)).emplace_back([](MetadataTransactionPtr) {}); + } +}; + std::shared_ptr makeWritableStorage() { return std::make_shared( @@ -653,3 +670,62 @@ TEST_F(DiskObjectStorageOverMemoryMetadataTest, UndoRemovesRewrittenBlobs) tx->undo(); EXPECT_EQ(countBlobs(), 0u); } + +/// An eager storage must never see a queued operation at commit: both commit entry points fail closed +/// with LOGICAL_ERROR in release builds and abort under DEBUG_OR_SANITIZER_BUILD. +#ifndef DEBUG_OR_SANITIZER_BUILD +TEST_F(DiskObjectStorageOverMemoryMetadataTest, CommitRefusesQueuedOperationOnEagerStorage) +{ + auto wrapped = disk->wrapWithMemoryMetadata(); + ASSERT_TRUE(wrapped->getMetadataStorage()->appliesOperationsEagerly()); + auto tx = wrapped->createTransaction(); + QueueProbe::plantDeferredOperation(tx); + + try + { + tx->commit(); + FAIL() << "commit accepted a queued operation on an eager storage"; + } + catch (const Exception & e) + { + EXPECT_EQ(e.code(), ErrorCodes::LOGICAL_ERROR); + EXPECT_NE(e.message().find("must not queue deferred operations"), String::npos) << e.message(); + } +} + +TEST_F(DiskObjectStorageOverMemoryMetadataTest, TryCommitRefusesQueuedOperationOnEagerStorage) +{ + auto wrapped = disk->wrapWithMemoryMetadata(); + auto tx = wrapped->createTransaction(); + QueueProbe::plantDeferredOperation(tx); + + try + { + tx->tryCommit(NoCommitOptions{}); + FAIL() << "tryCommit accepted a queued operation on an eager storage"; + } + catch (const Exception & e) + { + EXPECT_EQ(e.code(), ErrorCodes::LOGICAL_ERROR); + EXPECT_NE(e.message().find("must not queue deferred operations"), String::npos) << e.message(); + } +} +#else +using DiskObjectStorageOverMemoryMetadataDeathTest = DiskObjectStorageOverMemoryMetadataTest; + +TEST_F(DiskObjectStorageOverMemoryMetadataDeathTest, CommitRefusesQueuedOperationOnEagerStorageAborts) +{ + auto wrapped = disk->wrapWithMemoryMetadata(); + auto tx = wrapped->createTransaction(); + QueueProbe::plantDeferredOperation(tx); + EXPECT_DEATH(tx->commit(), "must not queue deferred operations"); +} + +TEST_F(DiskObjectStorageOverMemoryMetadataDeathTest, TryCommitRefusesQueuedOperationOnEagerStorageAborts) +{ + auto wrapped = disk->wrapWithMemoryMetadata(); + auto tx = wrapped->createTransaction(); + QueueProbe::plantDeferredOperation(tx); + EXPECT_DEATH(tx->tryCommit(NoCommitOptions{}), "must not queue deferred operations"); +} +#endif From 53425e906a7d3ee92a4401cbe0f528d598edb2a0 Mon Sep 17 00:00:00 2001 From: Mikhail Filimonov Date: Fri, 23 Oct 2026 03:21:11 +0200 Subject: [PATCH 09/20] CAS: ref-log owner Move, format generation 2, CAS_OWNERSHIP_CONFLICT A committed ref changes owner with one `Move` record instead of a manifest republish. Logs carrying a Move are stamped generation 2 (`G_REF_MOVE`); the pool records `min_reader_generation`, and a generation-1 build refuses to mount a pool whose floor is above its ceiling. The raise 1 -> 2 happens in `Pool::open` right after a writable mount (one-way, logged as WARNING); a read-only mount never raises it, and `admitOrValidate` no longer touches the floor. A pool created by a 26.8 writable mount is born at 2, and only a writable mount may admit a new hash algorithm (`allow_new = blob_hash_allow_new && !read_only`); the decommission tool admits none. Every node must run 26.8 before the first writable mount; there is no per-node switch. `currentReadCeiling` is the single reader gate, the pool-meta floor check included; a `static_assert` ties the ref-log history to `G_REF_MOVE`. `classifyOwnerTransitionShape` lives in Formats/CasRefLogFormat and names five shapes (AddPrecommit, RemovePrecommit, RemoveCommitted, Promote, Move). Ownership conflicts get their own error code, `CAS_OWNERSHIP_CONFLICT` (1039): `ABORTED` is read by the replication queue as a cancelled entry. Every throw goes through `Cas::throwCasOwnershipConflict`, which counts it in `ProfileEvents::CASOwnershipConflict`; `CASRefMove` counts owner moves. A ref-log mutation that renames a ref scopes exactly its two names (`MutationScope::forEachName`/`namesForLog`), so co-batching stays possible and `confirmExactRef` refuses only those names instead of the whole namespace. Housekeeping that came with it: `rmwPoolMeta` is the one read-modify-write skeleton behind `admitOrValidate` and `raiseMinReaderGeneration`; the absent-meta case of the raise is a `LOGICAL_ERROR` like admission; `Cas::nowMs` (Primitives/CasClock.h) and `makeSetPublishedAtOp` give the ledger and the later rename path one clock and one builder. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Mikhail Filimonov --- src/Common/ErrorCodes.cpp | 3 +- src/Common/ProfileEvents.cpp | 2 + .../ContentAddressed/Backend/CasRequests.h | 23 +++ .../ContentAddressed/Formats/CasFormat.cpp | 33 ++++- .../ContentAddressed/Formats/CasFormat.h | 35 +++-- .../Formats/CasPoolMetaFormat.cpp | 4 +- .../Formats/CasPoolMetaFormat.h | 18 ++- .../Formats/CasRecordStreamFormat.h | 2 +- .../Formats/CasRefLogFormat.cpp | 53 ++++++- .../Formats/CasRefLogFormat.h | 39 +++++ .../Formats/CasTextFormat.cpp | 4 +- .../ContentAddressed/Formats/CasTextFormat.h | 3 +- .../ContentAddressed/Formats/README.md | 5 +- .../ContentAddressed/Gc/CasGc.cpp | 2 +- .../ContentAddressed/Pool/CasPool.cpp | 22 ++- .../ContentAddressed/Pool/CasPool.h | 5 +- .../ContentAddressed/Pool/CasPoolMeta.cpp | 76 ++++++---- .../ContentAddressed/Pool/CasRefLedger.cpp | 37 +++-- .../ContentAddressed/Pool/CasRefLedger.h | 2 +- .../ContentAddressed/Pool/CasRefProtocol.cpp | 84 +++++------ .../ContentAddressed/Pool/CasRefProtocol.h | 56 +++++++- .../ContentAddressed/Primitives/CasClock.h | 15 ++ .../ContentAddressed/Primitives/CasEvent.cpp | 1 + .../ContentAddressed/Primitives/CasEvent.h | 2 +- .../Tools/CasDecommission.cpp | 9 +- src/Disks/tests/cas_format_test_battery.h | 2 +- .../tests/gtest_cas_blob_envelope_format.cpp | 2 +- .../tests/gtest_cas_bootstrap_ordering.cpp | 89 ++++++++++++ src/Disks/tests/gtest_cas_envelope.cpp | 2 +- src/Disks/tests/gtest_cas_format.cpp | 19 ++- .../gtest_cas_gc_maintenance_state_format.cpp | 2 +- .../tests/gtest_cas_gc_undercount_repro.cpp | 4 +- src/Disks/tests/gtest_cas_pluggable_hash.cpp | 55 +++++-- .../tests/gtest_cas_record_stream_format.cpp | 2 +- src/Disks/tests/gtest_cas_ref_intake.cpp | 4 +- .../tests/gtest_cas_ref_statemachine.cpp | 135 ++++++++++++++++++ src/Disks/tests/gtest_cas_ref_writer.cpp | 120 ++++++++++++++++ src/Disks/tests/gtest_cas_text_format.cpp | 2 +- 38 files changed, 799 insertions(+), 174 deletions(-) create mode 100644 src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Primitives/CasClock.h diff --git a/src/Common/ErrorCodes.cpp b/src/Common/ErrorCodes.cpp index ef6117bbe9e79..b2164b9606fdd 100644 --- a/src/Common/ErrorCodes.cpp +++ b/src/Common/ErrorCodes.cpp @@ -694,6 +694,7 @@ M(1021, AZURE_OBJECT_CHANGED_DURING_READ) \ M(1037, CAS_WRITE_UNATTRIBUTED) \ M(1038, CAS_DELETE_MARKER) \ + M(1039, CAS_OWNERSHIP_CONFLICT) \ /* See END */ #ifdef APPLY_FOR_EXTERNAL_ERROR_CODES @@ -710,7 +711,7 @@ namespace ErrorCodes APPLY_FOR_ERROR_CODES(M) #undef M - constexpr ErrorCode END = 1038; + constexpr ErrorCode END = 1039; #if !defined(CLICKHOUSE_PARSER_MINIMAL_BUILD) /** One `ErrorPairHolder` per error code, each holding two `Error` structs - the last message, diff --git a/src/Common/ProfileEvents.cpp b/src/Common/ProfileEvents.cpp index f891e1ab8c3c8..8e422f29afb63 100644 --- a/src/Common/ProfileEvents.cpp +++ b/src/Common/ProfileEvents.cpp @@ -879,6 +879,8 @@ The server successfully detected this situation and will download merged part fr M(CASBlobDelete, "Number of CAS blob DELETE requests. Grows with cleanup and GC deletion activity.", ValueType::Number) \ M(CASBlobList, "Number of CAS blob LIST requests. Growing values indicate more object enumeration.", ValueType::Number) \ M(CASRefRepoint, "Number of CAS committed-reference repoints that republish a manifest. Growth indicates standalone part updates or removals.", ValueType::Number) \ + M(CASRefMove, "Number of CAS committed-reference renames done as one ref-log owner move, with no manifest republication.", ValueType::Number) \ + M(CASOwnershipConflict, "Number of CAS operations refused with CAS_OWNERSHIP_CONFLICT: the committed reference the writer had read was moved, dropped or repointed before its change landed.", ValueType::Number) \ M(CASManifestPut, "Number of CAS manifest PUT requests. Grows with manifest creation and indicates metadata write traffic.", ValueType::Number) \ M(CASManifestPutDeduplicated, "Number of CAS deduplicating manifest PUT requests. Grows when existing manifests are reused.", ValueType::Number) \ M(CASManifestOverwrite, "Number of CAS manifest overwrite requests. Growing values indicate repeated manifest replacement.", ValueType::Number) \ diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Backend/CasRequests.h b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Backend/CasRequests.h index 136f9d5d1e24e..2bcd45d5f4e73 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Backend/CasRequests.h +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Backend/CasRequests.h @@ -5,6 +5,8 @@ #include #include #include +#include +#include #include #include @@ -12,6 +14,16 @@ #include #include #include + +namespace ProfileEvents +{ + extern const Event CASOwnershipConflict; +} + +namespace DB::ErrorCodes +{ + extern const int CAS_OWNERSHIP_CONFLICT; +} #include #include #include @@ -88,6 +100,17 @@ bool isDeterministicLocalFailure(int code); /// has exactly one place that decides it. std::exception_ptr makeCasWriteRetryLaterExceptionPtr(const String & why); +/// Throw `CAS_OWNERSHIP_CONFLICT`: the committed ref a caller read moved, dropped or changed owner before the +/// caller's write landed; the caller must re-resolve the part before trying again. Counted in +/// `ProfileEvents::CASOwnershipConflict`, so every conflict site goes through here (the counter counts +/// throws; a ref-log builder that is re-run counts again). +template +[[noreturn]] void throwCasOwnershipConflict(FormatStringHelper fmt, Args &&... args) +{ + ProfileEvents::increment(ProfileEvents::CASOwnershipConflict); + throw Exception(ErrorCodes::CAS_OWNERSHIP_CONFLICT, std::move(fmt), std::forward(args)...); +} + /// Throw the recoverable "this content-addressed disk cannot serve the request right now" condition. /// Sibling of `throwCasWriteRetryLater`, same class for the same reasons, differing only in what it /// describes: that one names a WRITE whose commit did not land, this one names a DISK STATE that diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasFormat.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasFormat.cpp index da8199257817d..40d33be088775 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasFormat.cpp +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasFormat.cpp @@ -5,6 +5,7 @@ #include #include +#include namespace DB { @@ -28,6 +29,12 @@ namespace /// history once had. constexpr FormatChangePoint BASELINE[] = {{1, 1}}; +/// Generation 2 adds the committed-owner move; a log carrying one is unreadable to a generation-1 reader. +constexpr FormatChangePoint REF_LOG_HISTORY[] = {{1, 1}, {2, 2}}; +/// `encodeRefLogTxn` stamps `G_REF_MOVE` on a Move-bearing log; the history must say the same. +static_assert(REF_LOG_HISTORY[std::size(REF_LOG_HISTORY) - 1].min_reader == G_REF_MOVE); +static_assert(REF_LOG_HISTORY[std::size(REF_LOG_HISTORY) - 1].generation <= G_BUILD); + } std::span changePoints(FormatId id) @@ -35,6 +42,7 @@ std::span changePoints(FormatId id) switch (id) { case FormatId::RefLog: + return REF_LOG_HISTORY; case FormatId::RefSnapshot: case FormatId::RefCkpt: case FormatId::RefCatalog: @@ -64,17 +72,32 @@ uint32_t currentWriterVersion() uint32_t currentCompatibilityVersion() { - /// Until roster-based write-down is implemented, every object carries the current build as its - /// compatibility floor. - return G_BUILD; + return G_WRITE_FLOOR; +} + +namespace +{ +std::atomic read_ceiling_override{0}; +} + +void setReadCeilingForTest(std::optional ceiling) +{ + read_ceiling_override.store(ceiling.value_or(0)); +} + +uint32_t currentReadCeiling() +{ + const uint32_t override_ceiling = read_ceiling_override.load(); + return override_ceiling ? override_ceiling : G_BUILD; } void checkCompatibility(uint32_t compatibility_version, std::string_view what) { - if (compatibility_version > G_BUILD) + const uint32_t ceiling = currentReadCeiling(); + if (compatibility_version > ceiling) throw Exception(ErrorCodes::UNKNOWN_FORMAT_VERSION, "CAS {}: object requires reader generation {} but this build supports at most {}", - what, compatibility_version, G_BUILD); + what, compatibility_version, ceiling); } namespace diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasFormat.h b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasFormat.h index 849a5ff06d2df..fff4ab578791d 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasFormat.h +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasFormat.h @@ -1,5 +1,6 @@ #pragma once #include +#include #include #include @@ -10,15 +11,15 @@ namespace DB::Cas /// supplies the version gate and the per-object text-file traits used by the codecs in this /// directory; it does not own object bytes or storage lifecycle state. -/// The highest pool-format generation this build understands. A build keeps every decoder for -/// generations 1..G_BUILD (new code always reads old); an object is readable iff its -/// compatibility_version <= G_BUILD. Bump this (and append a change-point in CasFormat.cpp) when a new -/// format generation is introduced. -/// -/// The generation history was reset to this {1, 1} baseline: CAS is pre-release, carries no persisted -/// data, and so pays no compatibility cost for starting the count over. Every class's `changePoints` -/// begins at generation 1 until a future change appends a real entry. -constexpr uint32_t G_BUILD = 1; +/// The highest pool-format generation this build understands: an object is readable iff its +/// compatibility_version <= G_BUILD. Readers for every lower generation are kept. +constexpr uint32_t G_BUILD = 2; +/// The compatibility version stamped on ordinary objects: they carry no generation-2 construct, so +/// only a Move-bearing ref log is stamped `G_REF_MOVE`. What gates a generation-1 reader is the pool's +/// `min_reader_generation`, which a writable mount of this build raises to `G_REF_MOVE` before any Move. +constexpr uint32_t G_WRITE_FLOOR = 1; +/// The generation that introduced the committed-owner move in the ref log (`OwnerTransitionShape::Move`). +constexpr uint32_t G_REF_MOVE = 2; /// Stable identifiers for every self-describing persisted object class. The text registry uses the /// corresponding `type` string as the on-disk identity. Numeric values are part of the format history: @@ -62,13 +63,21 @@ enum class FormatId : uint16_t /// always stamps `G_BUILD`. uint32_t currentWriterVersion(); -/// Returns the compatibility generation stamped on newly written objects. Until the roster and -/// write-down-to-floor policy exist, this is always `G_BUILD`; readers reject values above `G_BUILD`. +/// Returns the compatibility generation stamped on newly written objects: always `G_WRITE_FLOOR`. +/// Readers reject values above `G_BUILD`. uint32_t currentCompatibilityVersion(); +/// Unit tests only: lets a generation-2 build act as an older reader by lowering the read ceiling. +/// `std::nullopt` restores `G_BUILD`. +void setReadCeilingForTest(std::optional ceiling); + +/// The highest generation this process reads: `G_BUILD`, or the test override. +uint32_t currentReadCeiling(); + /// Applies the common fail-closed reader gate. If an object's `compatibility_version` exceeds -/// `G_BUILD`, throws `UNKNOWN_FORMAT_VERSION` before the caller interprets the body; `what` identifies -/// the object in the exception message. +/// `currentReadCeiling()`, throws `UNKNOWN_FORMAT_VERSION` before the caller interprets the body; `what` +/// identifies the object in the exception message. The pool's `min_reader_generation` goes through the +/// same ceiling (`decodePoolMeta`). void checkCompatibility(uint32_t compatibility_version, std::string_view what); /// One append-only entry in a class's format history. At `generation`, the class's ENCODING or the diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasPoolMetaFormat.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasPoolMetaFormat.cpp index c024e8b87a3c3..89432fb84760f 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasPoolMetaFormat.cpp +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasPoolMetaFormat.cpp @@ -152,10 +152,10 @@ PoolMeta decodePoolMeta(std::string_view data) validatePoolBlobHeaderLen(pm.blob_header_len, ErrorCodes::CORRUPTED_DATA, "pool meta"); validatePoolAlgosUsed(pm.algos_used, ErrorCodes::CORRUPTED_DATA, "pool meta"); - if (G_BUILD < pm.min_reader_generation) + if (const uint32_t ceiling = currentReadCeiling(); ceiling < pm.min_reader_generation) throw Exception(ErrorCodes::UNKNOWN_FORMAT_VERSION, "CAS pool meta: pool requires reader generation {} but this build supports at most {}", - pm.min_reader_generation, G_BUILD); + pm.min_reader_generation, ceiling); return pm; } diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasPoolMetaFormat.h b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasPoolMetaFormat.h index 80acffecde308..fdbc467c0b2c7 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasPoolMetaFormat.h +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasPoolMetaFormat.h @@ -34,9 +34,10 @@ struct PoolMeta /// Creates the pool metadata if `_pool_meta` is absent, or validates and possibly admits the /// configured hash algorithm if it already exists. Initial creation validates the supplied header - /// size and records this build's reader-generation floor. Reopen ignores the supplied header size, - /// because changing it would move the blob payload offset for existing objects; a new hash algorithm - /// is rejected unless `allow_new` is set, and concurrent admission is retried from fresh metadata. + /// size and records `initial_min_reader_generation` as the reader-generation floor. Reopen ignores + /// the supplied header size, because changing it would move the blob payload offset for existing + /// objects; a new hash algorithm is rejected unless `allow_new` is set, and concurrent admission is + /// retried from fresh metadata. /// /// `allow_mint` gates the create-if-absent path: minting a fresh `_pool_meta` is a /// consequential write that establishes a brand-new pool identity, so it is permitted ONLY on the @@ -52,17 +53,22 @@ struct PoolMeta static PoolMeta createOrValidate( CasOperation &, const Layout &, uint64_t blob_header_len, uint64_t gc_shards, BlobHashAlgo blob_hash_algo = BlobHashAlgo::CityHash128, bool allow_new = false, - bool allow_mint = false); + bool allow_mint = false, uint64_t initial_min_reader_generation = G_WRITE_FLOOR); + + /// Monotone CAS-max on `min_reader_generation`: raises the floor to `generation` when it is lower, + /// never lowers it, returns the resulting meta. Separate from hash admission, which returns without + /// writing once the algorithm is admitted. + static PoolMeta raiseMinReaderGeneration(CasOperation & op, const Layout & layout, uint64_t generation); /// Convenience for single-shard callers. Production pool opening passes the configured value to /// the explicit overload above; this preserves compact single-shard codec/unit fixtures. static PoolMeta createOrValidate( CasOperation & op, const Layout & layout, uint64_t blob_header_len, BlobHashAlgo blob_hash_algo = BlobHashAlgo::CityHash128, bool allow_new = false, - bool allow_mint = false) + bool allow_mint = false, uint64_t initial_min_reader_generation = G_WRITE_FLOOR) { return createOrValidate( - op, layout, blob_header_len, /*gc_shards=*/1, blob_hash_algo, allow_new, allow_mint); + op, layout, blob_header_len, /*gc_shards=*/1, blob_hash_algo, allow_new, allow_mint, initial_min_reader_generation); } }; diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasRecordStreamFormat.h b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasRecordStreamFormat.h index 98165a27db83a..6151897546e60 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasRecordStreamFormat.h +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasRecordStreamFormat.h @@ -102,7 +102,7 @@ std::string_view runMarkerToWireWord(RunMarker marker); /// Its fail-closed inverse: an unknown word is `CORRUPTED_DATA`. RunMarker runMarkerFromWireWord(std::string_view w); -/// Write the typed header line `{"type":"cas_run","v":G_BUILD,"kind":""}\n` with a fixed key +/// Write the typed header line `{"type":"cas_run","v":,"kind":""}\n` with a fixed key /// order for byte-determinism. The `kind` field distinguishes the record schema within the run /// family, so a reader can reject a valid run of the wrong kind before interpreting any records. void writeRunHeaderLine(WriteBuffer & out, std::string_view kind); diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasRefLogFormat.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasRefLogFormat.cpp index 17fe478948813..91b9440b2869b 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasRefLogFormat.cpp +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasRefLogFormat.cpp @@ -277,13 +277,21 @@ void validateEpochSealGrammarContextual(const RefLogTxn & txn, uint64_t life_epo txn.txn_id.writer_epoch, life_epoch); } +bool refLogTxnContainsMove(const RefLogTxn & txn) +{ + for (const RefOp & op : txn.ops) + if (op.kind == RefOpKind::OwnerTransition && tryClassifyOwnerTransitionShape(op) == OwnerTransitionShape::Move) + return true; + return false; +} + String encodeRefLogTxn(const RefLogTxn & txn) { checkRefTxnIdNonzero(txn.txn_id, "RefLogTxn", "txn_id"); validateEpochSealGrammarStructural(txn); CasJsonWriter out(512); - writeHeaderLine(out, FormatId::RefLog); + writeHeaderLine(out, FormatId::RefLog, refLogTxnContainsMove(txn) ? G_REF_MOVE : currentCompatibilityVersion()); writeLogMeta(out, txn.ns, txn.txn_id, txn.prev_epoch_seal); @@ -482,4 +490,47 @@ std::optional peekRefLogMeta(const String & sealed_bytes) } } +std::optional tryClassifyOwnerTransitionShape(const RefOp & op) +{ + const bool has_old = op.old_binding.has_value(); + const bool has_new = op.new_binding.has_value(); + + if (!has_old && has_new && op.new_binding->kind == RefOwnerKind::Precommit) + return OwnerTransitionShape::AddPrecommit; + + if (has_old && !has_new && op.old_binding->kind == RefOwnerKind::Precommit) + return OwnerTransitionShape::RemovePrecommit; + + if (has_old && !has_new && op.old_binding->kind == RefOwnerKind::Committed) + return OwnerTransitionShape::RemoveCommitted; + + if (has_old && has_new && op.old_binding->kind == RefOwnerKind::Precommit + && op.new_binding->kind == RefOwnerKind::Committed + && op.old_binding->ref_name == op.new_binding->ref_name + && op.old_binding->manifest_ref == op.new_binding->manifest_ref) + return OwnerTransitionShape::Promote; + + if (has_old && has_new && op.old_binding->kind == RefOwnerKind::Committed + && op.new_binding->kind == RefOwnerKind::Committed + && op.old_binding->ref_name != op.new_binding->ref_name + && op.old_binding->manifest_ref == op.new_binding->manifest_ref) + return OwnerTransitionShape::Move; + + return std::nullopt; +} + +OwnerTransitionShape classifyOwnerTransitionShape(const RefOp & op) +{ + if (const auto shape = tryClassifyOwnerTransitionShape(op)) + return *shape; + + const bool has_old = op.old_binding.has_value(); + const bool has_new = op.new_binding.has_value(); + throw Exception(ErrorCodes::CORRUPTED_DATA, + "owner_transition does not match any legal transition shape (has_old={}, old_kind={}, " + "has_new={}, new_kind={})", + has_old, has_old ? std::to_string(static_cast(op.old_binding->kind)) : "n/a", + has_new, has_new ? std::to_string(static_cast(op.new_binding->kind)) : "n/a"); +} + } diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasRefLogFormat.h b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasRefLogFormat.h index e4949cf130b8d..50373afb3cb3b 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasRefLogFormat.h +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasRefLogFormat.h @@ -69,6 +69,17 @@ struct RefOp bool operator==(const RefOp &) const = default; }; +/// The `SetPublishedAt` op: restamps `ref_name`'s publication time while it still names `expected_manifest_ref`. +inline RefOp makeSetPublishedAtOp(String ref_name, const ManifestRef & expected_manifest_ref, uint64_t published_at_ms) +{ + RefOp op; + op.kind = RefOpKind::SetPublishedAt; + op.ref_name = std::move(ref_name); + op.expected_manifest_ref = expected_manifest_ref; + op.published_at_ms = published_at_ms; + return op; +} + /// The complete immutable body of one ref transaction log object. `ns` and `txn_id` are repeated in /// the body even though both are key-derived. `decodeRefLogTxn` compares them with the values supplied /// from the object key, rejecting a valid body copied under a different key as corruption. @@ -119,6 +130,9 @@ inline constexpr size_t ref_op_max_bytes = 4096; /// limit violation over the encoded text. String encodeRefLogTxn(const RefLogTxn & txn); +/// True iff the transaction carries an owner move; such a log is stamped `G_REF_MOVE`. +bool refLogTxnContainsMove(const RefLogTxn & txn); + /// Decode canonical text after the caller has opened the stored `.zst` object. `expected_ns` and /// `expected_txn_id` come from the object key; the decoded body must equal them, otherwise the body/key /// binding fails with CORRUPTED_DATA. Unknown non-critical fields are skipped so additive fields can be @@ -199,4 +213,29 @@ struct RefLogMetaPeek /// time they are renamed, and this reader has no output an ordinary test would miss. std::optional peekRefLogMeta(const String & sealed_bytes); +/// The five legal `owner_transition` shapes, decided purely from the (old_binding, new_binding) +/// optionals and their `RefOwnerKind`s -- no state read. The state machine, the codecs and the GC +/// fold's edge extractor all switch over this single classification instead of each carrying their +/// own shape predicates, so a shape one consumer does not recognize cannot silently acquire +/// divergent meaning in another. +enum class OwnerTransitionShape : uint8_t +{ + AddPrecommit, /// no old_binding, new_binding.kind == Precommit + RemovePrecommit, /// old_binding.kind == Precommit, no new_binding + RemoveCommitted, /// old_binding.kind == Committed, no new_binding + Promote, /// old_binding.kind == Precommit, new_binding.kind == Committed, SAME ref_name + /// and manifest_ref + Move, /// old = Committed(src), new = Committed(dst), DIFFERENT ref_name, SAME manifest_ref +}; + +/// Classify `op`'s (old_binding, new_binding) shape into one of the five legal transitions. Anything +/// else -- neither binding, old+new naming DIFFERENT manifests, a promote whose old/new ref_name +/// disagree, or any other kind combination -- throws `CORRUPTED_DATA` naming the offending combination +/// instead of falling through to a caller that would otherwise assign it accidental meaning. +[[nodiscard]] OwnerTransitionShape classifyOwnerTransitionShape(const RefOp & op); + +/// Same classification, but returns `std::nullopt` for an unrecognized shape instead of throwing. For +/// callers that must not reject a shape the codec itself does not validate. +[[nodiscard]] std::optional tryClassifyOwnerTransitionShape(const RefOp & op); + } diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasTextFormat.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasTextFormat.cpp index 6aa6eb1a00993..3bff5ed8a6430 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasTextFormat.cpp +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasTextFormat.cpp @@ -335,12 +335,12 @@ namespace ContainerWire } } -void writeHeaderLine(CasJsonWriter & out, FormatId id) +void writeHeaderLine(CasJsonWriter & out, FormatId id, uint32_t compatibility_version) { const FormatTraits & t = traitsFor(id); bool first = true; writeStringField(out, ContainerWire::type, t.type, first); - writeNumberField(out, ContainerWire::version, currentCompatibilityVersion(), first); + writeNumberField(out, ContainerWire::version, compatibility_version, first); closeObject(out, first); writeChar('\n', out); } diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasTextFormat.h b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasTextFormat.h index 1418934332279..2fbed97443d4a 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasTextFormat.h +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasTextFormat.h @@ -150,7 +150,8 @@ inline void closeObject(CasJsonWriter & out, bool & first) { out.closeObject(fir /// Argument order mirrors the IO helpers so migrated codecs keep their call shapes. inline void writeChar(char c, CasJsonWriter & out) { out.appendChar(c); } inline void writeIntText(uint64_t v, CasJsonWriter & out) { out.u64Number(v); } -void writeHeaderLine(CasJsonWriter & out, FormatId id); +void writeHeaderLine(CasJsonWriter & out, FormatId id, uint32_t compatibility_version); +inline void writeHeaderLine(CasJsonWriter & out, FormatId id) { writeHeaderLine(out, id, currentCompatibilityVersion()); } void writeTrailerLine(CasJsonWriter & out, uint64_t n); /// A wire-key carrier for migrated writer call sites. Its explicit constructor makes a codec pass a diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/README.md b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/README.md index f56b3d0c90281..03dc8f4f44c10 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/README.md +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/README.md @@ -89,7 +89,8 @@ use the corresponding `old_*` and `new_*` key bundles. ## Generation history {#generation-history} -The format's generation history was reset to a flat `{1, 1}` baseline (`G_BUILD == 1`): CAS is -pre-release, carries no persisted data, and pays no compatibility cost for starting the count over. +The generation history starts at a flat `{1, 1}` baseline (reset before 26.6.4, the first release whose +pools persist). Current generations: `G_BUILD == 2` (the highest a reader accepts), `G_WRITE_FLOOR == 1` (stamped on +ordinary objects). Only ref-log transactions that carry a `Move` op are stamped 2. Every class's `changePoints` begins at generation 1; a future breaking change appends a real entry to that class's own array and bumps `G_BUILD`, the same way it always has. diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Gc/CasGc.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Gc/CasGc.cpp index d47e19ffdd7ee..29dde00c292a5 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Gc/CasGc.cpp +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Gc/CasGc.cpp @@ -2936,7 +2936,7 @@ Gc::FoldResult Gc::fold(GcState & state, std::optional & /*state_etag*/, { txn = decodeRefLogTxn(openObject(FormatId::RefLog, got->bytes), ns_str, log_id); /// Extraction shares the decode try-block: an unrecognized owner_transition shape - /// (`manifestEdgesOfTxn` -> `classifyOwnerTransitionShape`, Pool/CasRefProtocol.cpp) is + /// (`manifestEdgesOfTxn` -> `classifyOwnerTransitionShape`, Formats/CasRefLogFormat.cpp) is /// exactly as untrustworthy as an undecodable body -- both mean this log cannot be /// folded -- so both get the SAME per-namespace hold below. Do /// NOT widen this catch over `foldManifestEdges` -- only intake, not the fold itself, diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Pool/CasPool.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Pool/CasPool.cpp index 8ea2066184928..794d412bf984d 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Pool/CasPool.cpp +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Pool/CasPool.cpp @@ -535,8 +535,10 @@ PoolPtr Pool::open(BackendPtr backend, PoolConfig config) /// closed instead (spec §2 [C4][D2]). CasOperation meta_op = bootstrap_requests.admit(); PoolMeta meta = PoolMeta::createOrValidate( - meta_op, layout, config.blob_header_len, config.gc_shards, config.blob_hash_algo, config.blob_hash_allow_new, - /*allow_mint=*/!config.read_only); + meta_op, layout, config.blob_header_len, config.gc_shards, config.blob_hash_algo, + /*allow_new=*/config.blob_hash_allow_new && !config.read_only, /// an observe open validates, never admits + /*allow_mint=*/!config.read_only, + /*initial_min_reader_generation=*/G_REF_MOVE); config.gc_shards = meta.gc_shards; const BlobHashAlgo write_algo = config.blob_hash_algo; /// `config` is moved-from just below @@ -562,6 +564,20 @@ PoolPtr Pool::open(BackendPtr backend, PoolConfig config) if (!store->config.read_only) mountWritable(store, store->config.server_id, MountClaimPolicy::WaitForExpiry); + if (!store->config.read_only && store->meta.min_reader_generation < G_REF_MOVE) + { + /// One-way, so it is done only by a node that has actually mounted (identity and lease held) and + /// can therefore emit owner moves; from here on a generation-1 build refuses to mount this pool. + CasOperation raise_op = store->gc_requests.admit(); + const uint64_t floor_before = store->meta.min_reader_generation; + const PoolMeta raised = PoolMeta::raiseMinReaderGeneration(raise_op, store->pool_layout, G_REF_MOVE); + LOG_WARNING(getLogger("CasPool"), + "Content-addressed pool '{}': min_reader_generation raised {} -> {}; generation-1 readers can no longer mount this pool", + u128ToHex(store->meta.pool_id), floor_before, raised.min_reader_generation); + /// Only the floor is written: background workers started by `mountWritable` already read the rest of `meta`. + store->meta.min_reader_generation = raised.min_reader_generation; + } + return store; } @@ -954,7 +970,7 @@ PoolPtr Pool::openForDecommission(BackendPtr backend, PoolConfig config, const S /// minting a fresh identity here (spec §2 [C4][D2]). CasOperation meta_op = bootstrap_requests.admit(); PoolMeta meta = PoolMeta::createOrValidate( - meta_op, layout, config.blob_header_len, config.gc_shards, config.blob_hash_algo, config.blob_hash_allow_new, + meta_op, layout, config.blob_header_len, config.gc_shards, config.blob_hash_algo, /*allow_new=*/false, /// a tool never admits a new algorithm /*allow_mint=*/false); config.gc_shards = meta.gc_shards; const BlobHashAlgo write_algo = config.blob_hash_algo; /// `config` is moved-from just below diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Pool/CasPool.h b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Pool/CasPool.h index c566a9164f1a7..0f565d93f3de6 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Pool/CasPool.h +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Pool/CasPool.h @@ -752,6 +752,7 @@ class Pool : public std::enable_shared_from_this /// Internal surface for PartWriteTxn (same TU family; not for the wiring): const PoolConfig & poolConfig() const { return config; } const PoolMeta & poolMeta() const { return meta; } + bool refMoveAllowed() const { return meta.min_reader_generation >= G_REF_MOVE; } const Layout & layout() const { return pool_layout; } /// ---- the three request planes ---- @@ -1221,8 +1222,8 @@ class Pool : public std::enable_shared_from_this /// CAS mixed-algo pools: monotone in-memory cache of `algos_used`, seeded from /// `meta.algos_used` at open. Guards `isAlgoAdmitted`/`refreshAdmittedAlgos` -- ITS OWN mutex, - /// not `meta`'s (there is no other mutable access to `meta` post-open; this avoids taking a - /// wider lock than the cache needs). Kept sorted (a plain vector; membership is a handful of + /// not `meta`'s (`Pool::open` writes `meta.min_reader_generation` once more, before any caller holds the + /// pool; nothing else mutates `meta` post-open, so this avoids taking a wider lock than the cache needs). Kept sorted (a plain vector; membership is a handful of /// entries, no need for a set). mutable std::mutex admitted_algos_mutex; std::vector admitted_algos; diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Pool/CasPoolMeta.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Pool/CasPoolMeta.cpp index fddb27103311b..fdfe82465f6a1 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Pool/CasPoolMeta.cpp +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Pool/CasPoolMeta.cpp @@ -62,19 +62,11 @@ String joinAlgoNames(const std::vector & algos_used) joinAlgoNames(pool.algos_used), blobHashAlgoName(config_algo)); } -/// The relaxed admission check (replaces an earlier fail-close that required the single pool algo to match): -/// re-reads `key` itself (via `readModifyWrite`'s own observe) rather than trusting a snapshot the -/// caller already holds, so a caller that only knows the key is present -- never a stale decode -- -/// can ask this to settle admission. Already a member of `algos_used` => OK, no write (steady state, -/// `decide` declines). Not a member and `!allow_new` => `BAD_ARGUMENTS` (the pool is never touched). -/// Not a member and `allow_new` => CAS-union `config_algo` into `algos_used` (recomputed from the -/// FRESH value on every retry -- union-only, so there is no ABA) and raises `min_reader_generation` to -/// THIS build's own floor (`G_BUILD`, `CasFormat.h`) in the SAME write (first registration of a -/// schema-3-bearing algo also raises `min_reader_generation` -- a build that cannot decode schema-3 -/// settlement state has an OLDER `G_BUILD` and is correctly refused by the startup gate once a future -/// generation bump lands here). A concurrent admitter's own union is folded in the same way, since -/// `decide` runs again against whatever `readModifyWrite` observes on retry. -PoolMeta admitOrValidate(CasOperation & op, const String & key, BlobHashAlgo config_algo, bool allow_new) +/// Read-modify-write of `_pool_meta` after `createOrValidate` proved it exists: `mutate` edits the decoded +/// meta in place and returns whether it changed anything (false = no write). Re-run against the fresh value +/// on every conditional-write retry. Returns the meta as last observed (mutated when it was written). +template +PoolMeta rmwPoolMeta(CasOperation & op, const String & key, std::string_view what, Mutate && mutate) { PoolMeta observed; WriteResult result = op.readModifyWrite( @@ -82,30 +74,57 @@ PoolMeta admitOrValidate(CasOperation & op, const String & key, BlobHashAlgo con [&](const std::optional & current) -> std::optional { if (!current) - throw Exception(ErrorCodes::LOGICAL_ERROR, - "CAS pool meta: '{}' vanished mid-admission (conflicting write then a concurrent delete)", key); + throw Exception(ErrorCodes::LOGICAL_ERROR, "CAS pool meta '{}' vanished during {}", key, what); observed = decodePoolMeta(current->bytes); - - if (isAlgoAdmittedIn(observed, config_algo)) + if (!mutate(observed)) return std::nullopt; - if (!allow_new) - throwNotAdmitted(observed, config_algo); - - observed.algos_used.push_back(static_cast(config_algo)); - std::sort(observed.algos_used.begin(), observed.algos_used.end()); - observed.min_reader_generation = G_BUILD; return encodePoolMeta(observed); }, Retry::standard()); - orThrow(std::move(result), fmt::format("CAS pool meta admission on '{}'", key)); + orThrow(std::move(result), fmt::format("CAS pool meta {} on '{}'", what, key)); return observed; } +/// The relaxed admission check (replaces an earlier fail-close that required the single pool algo to match): +/// re-reads `key` itself (via `readModifyWrite`'s own observe) rather than trusting a snapshot the +/// caller already holds, so a caller that only knows the key is present -- never a stale decode -- +/// can ask this to settle admission. Already a member of `algos_used` => OK, no write (steady state, +/// the mutator declines). Not a member and `!allow_new` => `BAD_ARGUMENTS` (the pool is never touched). +/// Not a member and `allow_new` => CAS-union `config_algo` into `algos_used` (recomputed from the +/// FRESH value on every retry -- union-only, so there is no ABA). Every known algorithm is readable by a +/// generation-1 build, so admission never touches `min_reader_generation`. A concurrent +/// admitter's own union is folded in the same way, since the mutator runs again against whatever +/// `readModifyWrite` observes on retry. +PoolMeta admitOrValidate(CasOperation & op, const String & key, BlobHashAlgo config_algo, bool allow_new) +{ + return rmwPoolMeta(op, key, "admission", [&](PoolMeta & meta) + { + if (isAlgoAdmittedIn(meta, config_algo)) + return false; + if (!allow_new) + throwNotAdmitted(meta, config_algo); + meta.algos_used.push_back(static_cast(config_algo)); + std::sort(meta.algos_used.begin(), meta.algos_used.end()); + return true; + }); +} + +} + +PoolMeta PoolMeta::raiseMinReaderGeneration(CasOperation & op, const Layout & layout, uint64_t generation) +{ + return rmwPoolMeta(op, layout.poolMetaKey(), "reader-floor raise", [&](PoolMeta & meta) + { + if (meta.min_reader_generation >= generation) + return false; + meta.min_reader_generation = generation; + return true; + }); } PoolMeta PoolMeta::createOrValidate( CasOperation & op, const Layout & layout, uint64_t blob_header_len, uint64_t gc_shards, - BlobHashAlgo blob_hash_algo, bool allow_new, bool allow_mint) + BlobHashAlgo blob_hash_algo, bool allow_new, bool allow_mint, uint64_t initial_min_reader_generation) { /// The passed config is the caller's responsibility — reject bad values before any I/O. validatePoolBlobHeaderLen(blob_header_len, ErrorCodes::BAD_ARGUMENTS, "pool meta"); @@ -131,9 +150,8 @@ PoolMeta PoolMeta::createOrValidate( } /// Absent => mint a pool id and try to create the object with `algos_used = {blob_hash_algo}`. - /// Every pool this build creates is schema-3-shaped from birth (schemas 1/2 do not exist in this - /// build at all), so the reader-generation floor is stamped at THIS build's `G_BUILD` at - /// creation, not left at 0. + /// The floor records which readers may mount; a new pool starts at the floor its writers need + /// (generation 2), not at this build's read ceiling. /// /// BOOTSTRAP GATE: minting is permitted ONLY on the verified bootstrap path. A /// non-bootstrap caller (a read-only/observe open, `openForDecommission`) passes `allow_mint=false` @@ -152,7 +170,7 @@ PoolMeta PoolMeta::createOrValidate( pm.pool_id = mintPoolId(); pm.blob_header_len = blob_header_len; pm.gc_shards = gc_shards; - pm.min_reader_generation = G_BUILD; + pm.min_reader_generation = initial_min_reader_generation; pm.algos_used = {static_cast(blob_hash_algo)}; WriteResult result = op.create(key, encodePoolMeta(pm), Retry::standard()); diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Pool/CasRefLedger.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Pool/CasRefLedger.cpp index 196984a9aa68c..71e6f6c045129 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Pool/CasRefLedger.cpp +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Pool/CasRefLedger.cpp @@ -520,7 +520,7 @@ ConfirmAnswer CasRefLedger::confirmExactRef(const RootNamespace & ns, const Stri return refuse(ProfileEvents::CASRelinkConfirmRefusedLaneBroken, "the lane is Writing with nothing carved"); const auto covers = [&](const MutationScope & scope) { - return scope.kind == MutationScope::Kind::WholeShard || scope.ref_name == ref_name; + return scope.kind == MutationScope::Kind::WholeShard || scope.names(ref_name); }; for (const auto & item : rt.pending) if (covers(item->scope)) @@ -2727,24 +2727,24 @@ CasRefLedger::resolveWedgeOnce(const RootNamespace & ns, const std::shared_ptrref_name != scope_ref) + if (op.old_binding && !scope.names(op.old_binding->ref_name)) return &op.old_binding->ref_name; - if (op.new_binding && op.new_binding->ref_name != scope_ref) + if (op.new_binding && !scope.names(op.new_binding->ref_name)) return &op.new_binding->ref_name; return nullptr; } - if (op.kind == RefOpKind::SetPublishedAt && op.ref_name != scope_ref) + if (op.kind == RefOpKind::SetPublishedAt && !scope.names(op.ref_name)) return &op.ref_name; return nullptr; } @@ -2973,11 +2973,15 @@ void CasRefLedger::flushRefBatch(const RootNamespace & ns, const std::shared_ptr } if (carve_hook_for_test) carve_hook_for_test(CarvePhaseForTest::PlanSeenRefs); - if (!seen_refs.insert(candidate->scope.ref_name).second) + const auto & cand_scope = candidate->scope; + bool already_seen = false; + cand_scope.forEachName([&](const String & name) { already_seen = already_seen || seen_refs.contains(name); }); + if (already_seen) { scope_cut = true; break; } + cand_scope.forEachName([&](const String & name) { seen_refs.insert(name); }); if (carve_hook_for_test) carve_hook_for_test(CarvePhaseForTest::PlanBatchGrow); batch.reserve(selected + 1); @@ -3144,12 +3148,12 @@ void CasRefLedger::flushRefBatch(const RootNamespace & ns, const std::shared_ptr /// check alone, so it is rejected here rather than left to any later one. if (op.kind == RefOpKind::RemoveNamespace) throw Exception(ErrorCodes::LOGICAL_ERROR, - "ref mutation on namespace '{}' is scoped to ref '{}' but its {} op moves every ref", - ns.string(), it->scope.ref_name, refOpKindToWireWord(op.kind)); - if (const String * other = refNamedOutsideScope(op, it->scope.ref_name)) + "ref mutation on namespace '{}' is scoped to ref {} but its {} op moves every ref", + ns.string(), it->scope.namesForLog(), refOpKindToWireWord(op.kind)); + if (const String * other = refNamedOutsideScope(op, it->scope)) throw Exception(ErrorCodes::LOGICAL_ERROR, - "ref mutation on namespace '{}' is scoped to ref '{}' but its {} op names ref '{}'", - ns.string(), it->scope.ref_name, refOpKindToWireWord(op.kind), *other); + "ref mutation on namespace '{}' is scoped to ref {} but its {} op names ref '{}'", + ns.string(), it->scope.namesForLog(), refOpKindToWireWord(op.kind), *other); } } @@ -4905,12 +4909,7 @@ void CasRefLedger::updateRefPublishedAt(const RootNamespace & ns, const String & mutator(update); - RefOp op; - op.kind = RefOpKind::SetPublishedAt; - op.ref_name = ref_name; - op.expected_manifest_ref = it->second.manifest_ref; - op.published_at_ms = update.published_at_ms; - return {op}; + return {makeSetPublishedAtOp(ref_name, it->second.manifest_ref, update.published_at_ms)}; }, RootMutationOrigin::Writer, RootMutationKind::UpdateRefPublishedAt); } diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Pool/CasRefLedger.h b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Pool/CasRefLedger.h index 73ce041c73da3..4414425109578 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Pool/CasRefLedger.h +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Pool/CasRefLedger.h @@ -504,7 +504,7 @@ class CasRefLedger if (it == ref_name_slots.end()) return false; for (const auto & item : it->second.current->carved) - if (item->scope.kind == MutationScope::Kind::Ref && item->scope.ref_name == ref_name) + if (item->scope.names(ref_name)) return item->done; return false; } diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Pool/CasRefProtocol.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Pool/CasRefProtocol.cpp index 15ffd10e9666e..7ae3f5e9644e5 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Pool/CasRefProtocol.cpp +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Pool/CasRefProtocol.cpp @@ -109,51 +109,6 @@ void checkRemoveNamespaceOrdering(const std::vector & ops) std::mutex g_recovery_replay_memory_probe_mutex; std::function g_recovery_replay_memory_probe; -/// The four legal `owner_transition` shapes, decided purely from the (old_binding, new_binding) -/// optionals and their `RefOwnerKind`s -- no state read. `RefTableState::applyOwnerTransition` (the -/// writer/replay state machine) and `manifestEdgesOfTxn` (the GC fold's edge extractor) both switch -/// over this single classification instead of each carrying their own shape predicates, so a shape -/// neither consumer recognizes cannot silently acquire divergent meaning in one of them. -enum class OwnerTransitionShape : uint8_t -{ - AddPrecommit, /// no old_binding, new_binding.kind == Precommit - RemovePrecommit, /// old_binding.kind == Precommit, no new_binding - RemoveCommitted, /// old_binding.kind == Committed, no new_binding - Promote, /// old_binding.kind == Precommit, new_binding.kind == Committed, SAME ref_name - /// and manifest_ref -}; - -/// Classify `op`'s (old_binding, new_binding) shape into one of the four legal transitions. Anything -/// else -- neither binding, old+new naming DIFFERENT manifests, a promote whose old/new ref_name -/// disagree, or any other kind combination -- throws `CORRUPTED_DATA` naming the offending combination -/// instead of falling through to a caller that would otherwise assign it accidental meaning. -[[nodiscard]] OwnerTransitionShape classifyOwnerTransitionShape(const RefOp & op) -{ - const bool has_old = op.old_binding.has_value(); - const bool has_new = op.new_binding.has_value(); - - if (!has_old && has_new && op.new_binding->kind == RefOwnerKind::Precommit) - return OwnerTransitionShape::AddPrecommit; - - if (has_old && !has_new && op.old_binding->kind == RefOwnerKind::Precommit) - return OwnerTransitionShape::RemovePrecommit; - - if (has_old && !has_new && op.old_binding->kind == RefOwnerKind::Committed) - return OwnerTransitionShape::RemoveCommitted; - - if (has_old && has_new && op.old_binding->kind == RefOwnerKind::Precommit - && op.new_binding->kind == RefOwnerKind::Committed - && op.old_binding->ref_name == op.new_binding->ref_name - && op.old_binding->manifest_ref == op.new_binding->manifest_ref) - return OwnerTransitionShape::Promote; - - throw Exception(ErrorCodes::CORRUPTED_DATA, - "owner_transition does not match any legal transition shape (has_old={}, old_kind={}, " - "has_new={}, new_kind={})", - has_old, has_old ? std::to_string(static_cast(op.old_binding->kind)) : "n/a", - has_new, has_new ? std::to_string(static_cast(op.new_binding->kind)) : "n/a"); -} - } void setRecoveryReplayMemoryProbeForTest(std::function probe) @@ -231,8 +186,8 @@ bool RefTableState::manifestAlreadyOwned(const ManifestRef & manifest_ref) const } /// The `owner_transition` op kind: dispatches on the `(old_binding, -/// new_binding)` shape to one of the four legal transitions (add precommit / remove precommit / -/// remove committed / promote). Any other shape is not a recognized transition. +/// new_binding)` shape to one of the five legal transitions (add precommit / remove precommit / +/// remove committed / promote / move). Any other shape is not a recognized transition. void RefTableState::applyOwnerTransition(const RefOp & op) { if (lifecycle != RefLifecycle::Live) @@ -328,6 +283,39 @@ void RefTableState::applyOwnerTransition(const RefOp & op) committed.emplace(b.ref_name, std::move(row)); return; } + + /// Move: a committed row changes its ref_name, keeping its manifest_ref and published_at_ms. + case OwnerTransitionShape::Move: + { + const RefOwnerBinding & from = *op.old_binding; + const RefOwnerBinding & to = *op.new_binding; + const auto it = committed.find(from.ref_name); + if (it == committed.end() || !(it->second.manifest_ref == from.manifest_ref)) + throw Exception(ErrorCodes::CORRUPTED_DATA, + "RefTableState: exact committed binding '{}' to move is absent", from.ref_name); + if (committed.contains(to.ref_name)) + throw Exception(ErrorCodes::CORRUPTED_DATA, + "RefTableState: move destination '{}' is already committed", to.ref_name); + /// Precommits are keyed by (ref_name, manifest_ref); any precommit under the destination name + /// means another build is about to claim it. + if (hasLivePrecommitFor(to.ref_name)) + throw Exception(ErrorCodes::CORRUPTED_DATA, + "RefTableState: move destination '{}' has a live precommit", to.ref_name); + /// `RefCowMap` has no node handle, so the destination is inserted before the source is + /// erased: an allocation failure in between leaves a duplicate row, never a lost one. + RefCommittedRow row = it->second; + RefCommittedRow moved = row; + moved.ref_name = to.ref_name; + committed.emplace(to.ref_name, std::move(moved)); + snapshot_body_bytes -= committedRowEncodedSize(row); + removal_body_bytes -= removalOpEncodedSize(RefOwnerKind::Committed, row.ref_name, row.manifest_ref); + row.ref_name = to.ref_name; + snapshot_body_bytes += committedRowEncodedSize(row); + removal_body_bytes += removalOpEncodedSize(RefOwnerKind::Committed, row.ref_name, row.manifest_ref); + committed.erase(from.ref_name); + /// `owned_manifests` is untouched: the manifest keeps its single owner across the rename. + return; + } } /// Reachable only if a future `OwnerTransitionShape` enumerator is added without a matching `case` /// (mirrors `applyOp`'s exhaustive-switch-then-throw shape below) -- `-Wswitch`/`-Werror` catches that @@ -766,6 +754,8 @@ std::vector manifestEdgesOfTxn(const RefLogTxn & txn) /// Same-manifest owner move: the manifest keeps an owner the whole time, so there is no /// net edge. continue; + case OwnerTransitionShape::Move: + continue; /// ownership transfer, no edge } /// Reachable only if a future `OwnerTransitionShape` enumerator is added without a matching /// `case` -- `-Wswitch`/`-Werror` catches that at compile time; this throw is the runtime diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Pool/CasRefProtocol.h b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Pool/CasRefProtocol.h index 2a819e27eff8e..1ff2c49ca6d98 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Pool/CasRefProtocol.h +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Pool/CasRefProtocol.h @@ -64,19 +64,50 @@ enum class RootMutationOrigin : uint8_t /// /// It is also safety-bearing: `CasRefLedger::confirmExactRef` refuses to certify a ref while a queued /// or carved item's scope covers it, and `flushRefBatch` fails an item whose ops name a ref outside its -/// declared scope, so a caller must name exactly the ref its ops mutate. +/// declared scope, so a caller must name exactly the ref its ops mutate. A two-ref scope exists for the +/// owner move, which mutates its source and destination names in one op. struct MutationScope { enum class Kind : uint8_t { Ref, WholeShard }; Kind kind = Kind::WholeShard; String ref_name; /// set iff kind == Ref + String second_ref_name; /// non-empty only for a two-ref scope + + /// True iff `name` is one of the refs this scope names. A whole-shard scope names none explicitly. + bool names(const String & name) const + { + return kind == Kind::Ref && (ref_name == name || (!second_ref_name.empty() && second_ref_name == name)); + } + + /// Calls `f` for every ref name this scope names (none for a whole-shard scope). + template + void forEachName(F && f) const + { + if (kind != Kind::Ref) + return; + f(ref_name); + if (!second_ref_name.empty()) + f(second_ref_name); + } + + /// The scope's ref names for a diagnostic: `'a'` or `'a', 'b'`. + String namesForLog() const + { + String out = "'" + ref_name + "'"; + if (!second_ref_name.empty()) + out += ", '" + second_ref_name + "'"; + return out; + } /// Creates a scope for a mutation that touches exactly one ref name. The name is moved into the /// scope because scopes are normally assembled as part of an append request. - static MutationScope ref(String name) { return {Kind::Ref, std::move(name)}; } + static MutationScope ref(String name) { return {Kind::Ref, std::move(name), {}}; } + + /// Creates a scope for a mutation that touches exactly two distinct ref names. + static MutationScope refs(String a, String b) { return {Kind::Ref, std::move(a), std::move(b)}; } /// Creates a scope for a mutation that cannot safely share a batch with per-ref mutations. - static MutationScope wholeShard() { return {Kind::WholeShard, {}}; } + static MutationScope wholeShard() { return {Kind::WholeShard, {}, {}}; } }; /// Kind of mutation being applied, used in diagnostic logging and metrics. Does not affect behaviour. @@ -84,6 +115,7 @@ enum class RootMutationKind : uint8_t { Publish, Drop, + Move, Precommit, Promote, Abandon, @@ -110,6 +142,7 @@ inline std::string_view toString(RootMutationKind kind) { case RootMutationKind::Publish: return "Publish"; case RootMutationKind::Drop: return "Drop"; + case RootMutationKind::Move: return "Move"; case RootMutationKind::Precommit: return "Precommit"; case RootMutationKind::Promote: return "Promote"; case RootMutationKind::Abandon: return "Abandon"; @@ -214,6 +247,13 @@ class RefTableState const RefTxnId & getGreatestApplied() const { return greatest_applied; } const RefCowMap & getCommitted() const { return committed; } const std::set> & getPrecommits() const { return precommits; } + /// True iff any precommit, whatever its manifest, is live under `ref_name`: another build is about to + /// claim that name. + bool hasLivePrecommitFor(const String & ref_name) const + { + const auto lb = precommits.lower_bound({ref_name, ManifestRef{}}); + return lb != precommits.end() && lb->first == ref_name; + } uint64_t getSnapshotBodyBytes() const { return snapshot_body_bytes; } uint64_t getRemovalBodyBytes() const { return removal_body_bytes; } @@ -274,8 +314,8 @@ class RefTableState void applyOp(const RefOp & op, const RefTxnId & txn_id); /// The `owner_transition` op kind: dispatches on the `(old_binding, new_binding)` shape to one of - /// the four legal transitions (add precommit / remove precommit / remove committed / promote). Any - /// other shape is not a recognized transition. The add-precommit arm's cross-owner uniqueness check + /// the five legal transitions (add precommit / remove precommit / remove committed / promote / move). + /// Any other shape is not a recognized transition. The add-precommit arm's cross-owner uniqueness check /// runs unconditionally (it is O(1) via `owned_manifests`). Was free. void applyOwnerTransition(const RefOp & op); @@ -603,8 +643,8 @@ struct RefManifestEdge }; /// The manifest edges of ONE decoded transaction, in operation order, reading NO manifest body. -/// `owner_transition` recognizes EXACTLY the four shapes `classifyOwnerTransitionShape` -/// (Pool/CasRefProtocol.cpp) also uses to drive `RefTableState::applyOwnerTransition` -- the SAME +/// `owner_transition` recognizes EXACTLY the five shapes `classifyOwnerTransitionShape` +/// (Formats/CasRefLogFormat.h) also uses to drive `RefTableState::applyOwnerTransition` -- the SAME /// classification, not a second copy of the shape knowledge: /// - add precommit (no old_binding, new_binding.kind == Precommit) => `+1` for `new.manifest_ref` /// - remove precommit / remove committed (old_binding set, no new_binding) @@ -612,6 +652,8 @@ struct RefManifestEdge /// - promote (old_binding.kind == Precommit, new_binding.kind == Committed, SAME ref_name and /// manifest_ref) => no edge (net zero: the /// manifest keeps an owner the whole time) +/// - move (old_binding.kind == Committed, new_binding.kind == Committed, DIFFERENT ref_name, SAME +/// manifest_ref) => no edge (ownership transfer) /// - `namespace_birth` / `set_published_at` / `remove_namespace` => no edge /// Any other `owner_transition` shape -- neither binding, old+new naming DIFFERENT manifests, a /// promote whose old/new ref_name disagree, or any other kind combination -- throws `CORRUPTED_DATA`. diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Primitives/CasClock.h b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Primitives/CasClock.h new file mode 100644 index 0000000000000..7f9e993732868 --- /dev/null +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Primitives/CasClock.h @@ -0,0 +1,15 @@ +#pragma once +#include +#include + +namespace DB::Cas +{ + +/// Wall-clock milliseconds since the epoch, the unit of every `published_at_ms` and lease timestamp. +inline uint64_t nowMs() +{ + return static_cast(std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count()); +} + +} diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Primitives/CasEvent.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Primitives/CasEvent.cpp index a981d1cd0575d..a5f371b211909 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Primitives/CasEvent.cpp +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Primitives/CasEvent.cpp @@ -28,6 +28,7 @@ String toString(CasEventType type) case CasEventType::RefPublish: return "ref_publish"; case CasEventType::RefDrop: return "ref_drop"; case CasEventType::RefRepoint: return "ref_repoint"; + case CasEventType::RefMove: return "ref_move"; case CasEventType::RootAdd: return "root_add"; case CasEventType::RootRemove: return "root_remove"; case CasEventType::RootRepoint: return "root_repoint"; diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Primitives/CasEvent.h b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Primitives/CasEvent.h index 05b9b75a89679..a67ea2f14b23e 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Primitives/CasEvent.h +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Primitives/CasEvent.h @@ -18,7 +18,7 @@ enum class CasEventType { BlobPut, BlobReuseAdopt, BlobReuseResurrect, BlobRetire, BlobRetireReplaced, BlobDelete, BlobForget, ManifestPut, ManifestDelete, - RefPublish, RefDrop, RefRepoint, RootAdd, RootRemove, RootRepoint, IndegZero, + RefPublish, RefDrop, RefRepoint, RefMove, RootAdd, RootRemove, RootRepoint, IndegZero, GcFoldBegin, GcFoldEnd, GcRetireObserve, GcRetireDecision, GcRecheckVerdict, GcFence, GcCursorAdvance, GcShardReclaim, GcFenceOut, GcRebuild, GcFoldClamp, /// One GC round anomaly, with the context of the decision it forced. `gc_fold_end` reports only a diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Tools/CasDecommission.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Tools/CasDecommission.cpp index eef2ba04d9bf4..413de03bf50f6 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Tools/CasDecommission.cpp +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Tools/CasDecommission.cpp @@ -1,3 +1,4 @@ +#include #include #include #include @@ -30,12 +31,6 @@ namespace DB::Cas namespace { -uint64_t nowMs() -{ - return static_cast(std::chrono::duration_cast( - std::chrono::system_clock::now().time_since_epoch()).count()); -} - std::string_view removalName(Removal r) { switch (r) @@ -465,7 +460,7 @@ DecommissionReport decommissionPoolMember(BackendPtr backend, PoolConfig config, if (const auto owner = op.read(owner_key, Retry::standard())) { OwnerObject tombstoned = decodeOwner(owner->bytes); - tombstoned.retired_at_ms = nowMs(); + tombstoned.retired_at_ms = Cas::nowMs(); /// `op.replace` resolves an ambiguous attempt with a resolve read on its own: /// a transient transport error here (or one whose response was simply lost) /// must not be reported as a hard failure when the write actually landed. diff --git a/src/Disks/tests/cas_format_test_battery.h b/src/Disks/tests/cas_format_test_battery.h index 3add5bbeda59c..c0f479b522d41 100644 --- a/src/Disks/tests/cas_format_test_battery.h +++ b/src/Disks/tests/cas_format_test_battery.h @@ -142,7 +142,7 @@ inline void runFormatBattery(const FormatBatteryCase & c) /// v+1 gate. const String v_now = fmt::format("\"v\":{}", currentCompatibilityVersion()); - const String v_next = fmt::format("\"v\":{}", currentCompatibilityVersion() + 1); + const String v_next = fmt::format("\"v\":{}", G_BUILD + 1); String future; if (c.make_future_version) future = c.make_future_version(text); diff --git a/src/Disks/tests/gtest_cas_blob_envelope_format.cpp b/src/Disks/tests/gtest_cas_blob_envelope_format.cpp index edfa45abf8281..be39c8ef04b3b 100644 --- a/src/Disks/tests/gtest_cas_blob_envelope_format.cpp +++ b/src/Disks/tests/gtest_cas_blob_envelope_format.cpp @@ -73,7 +73,7 @@ EnvelopeHeader maxReachableHeader(const String & ref) String blobEnvelopeWithFutureVersion(std::string_view text) { const String v_now = fmt::format("\"v\":{}", currentCompatibilityVersion()); - const String v_next = fmt::format("\"v\":{}", currentCompatibilityVersion() + 1); + const String v_next = fmt::format("\"v\":{}", G_BUILD + 1); String future(text); const size_t version_pos = future.find(v_now); if (version_pos == String::npos || v_next.size() < v_now.size()) diff --git a/src/Disks/tests/gtest_cas_bootstrap_ordering.cpp b/src/Disks/tests/gtest_cas_bootstrap_ordering.cpp index 07b71ee96cff2..dc5105a27ad95 100644 --- a/src/Disks/tests/gtest_cas_bootstrap_ordering.cpp +++ b/src/Disks/tests/gtest_cas_bootstrap_ordering.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include "cas_test_helpers.h" #include @@ -25,6 +26,8 @@ namespace DB::ErrorCodes { +extern const int BAD_ARGUMENTS; +extern const int CORRUPTED_DATA; extern const int INVALID_STATE; extern const int NOT_IMPLEMENTED; } @@ -161,6 +164,19 @@ bool headPresent(Backend & backend, const String & key) return (*op).head(key, Retry::standard()).has_value(); } +/// Rewrites `_pool_meta` with reader floor 1, as a pool a generation-1 writer (26.6) leaves it. +void lowerFloorToGenerationOne(Backend & backend) +{ + DB::Cas::tests::OperationForTest op(backend); + const auto current = (*op).read(kPoolMetaKey, Retry::once()); + ASSERT_TRUE(current.has_value()); + const auto head = (*op).head(kPoolMetaKey, Retry::once()); + ASSERT_TRUE(head.has_value()); + PoolMeta pm = decodePoolMeta(current->bytes); + pm.min_reader_generation = G_WRITE_FLOOR; + ASSERT_TRUE(std::holds_alternative((*op).replace(kPoolMetaKey, encodePoolMeta(pm), head->etag, Retry::once()))); +} + template void expectThrowsCodeContaining(int expected_code, const String & needle, F && fn); @@ -555,3 +571,76 @@ TEST(CASBootstrapOrdering, DecommissionWithAbsentMetaFailsClosedNoMint) EXPECT_EQ(backend->writeCount(), 0u) << "decommission must not mint a fresh _pool_meta"; EXPECT_FALSE(readPresent(*backend, kPoolMetaKey)); } + +TEST(CASBootstrapOrdering, WritableOpenRaisesTheFloorForOwnerMoves) +{ + auto backend = std::make_shared(); + Pool::open(backend, makeConfig()); + lowerFloorToGenerationOne(*backend); + { + PoolConfig cfg = makeConfig(); + cfg.read_only = true; + auto pool = Pool::open(backend, cfg); + EXPECT_EQ(pool->poolMeta().min_reader_generation, 1u); + EXPECT_FALSE(pool->refMoveAllowed()); + } + auto pool = Pool::open(backend, makeConfig()); + EXPECT_EQ(pool->poolMeta().min_reader_generation, 2u); + EXPECT_TRUE(pool->refMoveAllowed()); + DB::Cas::tests::OperationForTest op(*backend); + const auto stored = (*op).read(kPoolMetaKey, Retry::once()); + ASSERT_TRUE(stored.has_value()); + EXPECT_EQ(decodePoolMeta(stored->bytes).min_reader_generation, 2u) << "the raise must be durable"; +} + +TEST(CASBootstrapOrdering, ReadOnlyOpenNeverRaisesTheFloor) +{ + auto backend = std::make_shared(); + Pool::open(backend, makeConfig()); + lowerFloorToGenerationOne(*backend); + backend->clearLog(); + PoolConfig cfg = makeConfig(); + cfg.read_only = true; + auto pool = Pool::open(backend, cfg); + EXPECT_EQ(backend->writeCount(), 0u) << "an observe open must never write"; + EXPECT_EQ(pool->poolMeta().min_reader_generation, 1u); + EXPECT_FALSE(pool->refMoveAllowed()); +} + +TEST(CASBootstrapOrdering, ReadOnlyOpenNeverAdmitsAHashAlgorithm) +{ + auto backend = std::make_shared(); + Pool::open(backend, makeConfig()); + backend->clearLog(); + PoolConfig cfg = makeConfig(); + cfg.read_only = true; + cfg.blob_hash_algo = BlobHashAlgo::XXH3_128; + cfg.blob_hash_allow_new = true; + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::BAD_ARGUMENTS, [&] { Pool::open(backend, cfg); }); + EXPECT_EQ(backend->writeCount(), 0u) << "an observe open must not write pool meta even when admission is configured"; +} + +TEST(CASBootstrapOrdering, FailedMountLeavesTheFloorUnchanged) +{ + auto backend = std::make_shared(); + { + PoolConfig cfg = makeConfig(); + cfg.server_id = 1; + Pool::open(backend, cfg); + } + lowerFloorToGenerationOne(*backend); + /// The server root is owned by server_id 1; a different server_id is refused by the owner anchor + /// inside mountWritable, which must happen before the floor is touched. + PoolConfig cfg = makeConfig(); + cfg.server_id = 2; + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { Pool::open(backend, cfg); }); + DB::Cas::tests::OperationForTest op(*backend); + EXPECT_EQ(PoolMeta::createOrValidate(*op, Layout(cfg.pool_prefix), 256).min_reader_generation, 1u); +} + +TEST(CASBootstrapOrdering, NewPoolStartsAtGenerationTwo) +{ + auto backend = std::make_shared(); + auto pool = Pool::open(backend, makeConfig()); + EXPECT_EQ(pool->poolMeta().min_reader_generation, 2u); +} diff --git a/src/Disks/tests/gtest_cas_envelope.cpp b/src/Disks/tests/gtest_cas_envelope.cpp index 7dac7ddf6eed7..1958a9eb6ee5e 100644 --- a/src/Disks/tests/gtest_cas_envelope.cpp +++ b/src/Disks/tests/gtest_cas_envelope.cpp @@ -22,7 +22,7 @@ TEST(CASEnvelope, BlobRoundTripNoExtensions) const EnvelopeHeader d = decodeEnvelopeHeader(obj, obj.size(), ObjectKind::Blob); EXPECT_EQ(d.kind, ObjectKind::Blob); - EXPECT_EQ(d.compatibility_version, G_BUILD); + EXPECT_EQ(d.compatibility_version, G_WRITE_FLOOR); EXPECT_FALSE(d.provenance.has_value()); /// none set -> the ts/by/op/ch keys are absent EXPECT_FALSE(d.intended_ref.has_value()); /// none set -> the ref key is omitted EXPECT_EQ(d.header_len, 256u); diff --git a/src/Disks/tests/gtest_cas_format.cpp b/src/Disks/tests/gtest_cas_format.cpp index b3236797176cc..6257825f41d02 100644 --- a/src/Disks/tests/gtest_cas_format.cpp +++ b/src/Disks/tests/gtest_cas_format.cpp @@ -48,6 +48,8 @@ TEST(CASFormat, EveryClassResetToTheBaselineGeneration) { for (auto id : allRegisteredFormatIds()) { + if (id == FormatId::RefLog) + continue; const auto cps = changePoints(id); ASSERT_EQ(cps.size(), 1u) << "FormatId " << static_cast(id); EXPECT_EQ(cps.front().generation, 1u); @@ -63,7 +65,22 @@ TEST(CASFormat, EveryClassResetToTheBaselineGeneration) TEST(CASFormat, CurrentVersionsAreGBuild) { EXPECT_EQ(currentWriterVersion(), G_BUILD); - EXPECT_EQ(currentCompatibilityVersion(), G_BUILD); + EXPECT_EQ(currentCompatibilityVersion(), G_WRITE_FLOOR); +} + +TEST(CASFormat, RefLogHistoryRecordsTheOwnerMoveGeneration) +{ + const auto cps = changePoints(FormatId::RefLog); + ASSERT_EQ(cps.size(), 2u); + EXPECT_EQ(cps.back().generation, 2u); + EXPECT_EQ(cps.back().min_reader, 2u) << "a Move-bearing log is meaningless to a generation-1 reader"; +} + +TEST(CASFormat, ReadCeilingIsAboveTheWriteFloor) +{ + EXPECT_EQ(G_BUILD, 2u); + EXPECT_EQ(currentCompatibilityVersion(), G_WRITE_FLOOR); + EXPECT_EQ(G_WRITE_FLOOR, 1u); } TEST(CASFormat, CheckCompatibilityPassesWhenKnown) diff --git a/src/Disks/tests/gtest_cas_gc_maintenance_state_format.cpp b/src/Disks/tests/gtest_cas_gc_maintenance_state_format.cpp index e1d9894814978..2c25f003cd927 100644 --- a/src/Disks/tests/gtest_cas_gc_maintenance_state_format.cpp +++ b/src/Disks/tests/gtest_cas_gc_maintenance_state_format.cpp @@ -211,7 +211,7 @@ TEST(CASGCMaintenanceState, FutureVersionPropagatesInsteadOfResetting) auto op = requests.admit(); ASSERT_TRUE(std::holds_alternative(op.create(key, fmt::format( - "{{\"type\":\"cas_gc_maintenance_state\",\"v\":{}}}\n{{\"janitor_cursor\":\"\"}}\n", currentCompatibilityVersion() + 1), + "{{\"type\":\"cas_gc_maintenance_state\",\"v\":{}}}\n{{\"janitor_cursor\":\"\"}}\n", G_BUILD + 1), Retry::once()))); /// The seed write above lands through the same `write` primitive `CountingBackend` counts, so diff --git a/src/Disks/tests/gtest_cas_gc_undercount_repro.cpp b/src/Disks/tests/gtest_cas_gc_undercount_repro.cpp index 994b526af636a..401cd6eb6842e 100644 --- a/src/Disks/tests/gtest_cas_gc_undercount_repro.cpp +++ b/src/Disks/tests/gtest_cas_gc_undercount_repro.cpp @@ -21,7 +21,7 @@ /// twice from a blob's count, driving it to -1. Same fix — the second removal of an already-absent /// edge is a no-op. (Formerly staged the second event as a `{old=committed(r1), /// new=committed(r2)}` "repoint" -- a single op naming DIFFERENT manifests in its old/new bindings. -/// Post-classifier (see Pool/CasRefProtocol.cpp's `classifyOwnerTransitionShape`) that single-op shape +/// Post-classifier (see Formats/CasRefLogFormat.cpp's `classifyOwnerTransitionShape`) that single-op shape /// is not representable at all: `manifestEdgesOfTxn` now throws `CORRUPTED_DATA` on it, same as the /// state machine always has. The ACTUALLY representable duplicate-removal hazard -- two SEPARATE /// remove-committed events both naming `old=committed(r1)`, which the GC fold extracts blindly without @@ -341,7 +341,7 @@ TEST(CASGCUndercount, H1bFenceWindowRemovalReFoldedNextRoundUnderflows) /// ============== UNRECOGNIZED owner_transition SHAPE ABORTS THE ROUND, NEVER DELETES ================= /// /// A decodable ref log whose `owner_transition` op is SHAPE-illegal (here: neither `old_binding` nor -/// `new_binding` set) is exactly what `classifyOwnerTransitionShape` (Pool/CasRefProtocol.cpp) throws +/// `new_binding` set) is exactly what `classifyOwnerTransitionShape` (Formats/CasRefLogFormat.cpp) throws /// `CORRUPTED_DATA` on. `writeRefLogTxnRaw` -- the same codec real writers use -- never checks op-shape /// legality at encode/decode time, so this body is perfectly decodable; only `manifestEdgesOfTxn`'s /// shape classification rejects it, at GC fold time. diff --git a/src/Disks/tests/gtest_cas_pluggable_hash.cpp b/src/Disks/tests/gtest_cas_pluggable_hash.cpp index 724f5e6a905c3..433f1b1393295 100644 --- a/src/Disks/tests/gtest_cas_pluggable_hash.cpp +++ b/src/Disks/tests/gtest_cas_pluggable_hash.cpp @@ -704,28 +704,59 @@ TEST(CASPluggableHash, ForeignAlgoSegmentIsDebrisNotOurs) EXPECT_EQ(sh_obj->cls, FsckClass::PendingGc); } +TEST(CASPoolMetaFloor, CreateOrValidateUsesTheRequestedInitialFloor) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + OperationForTest meta_op(*backend); + const PoolMeta pm = PoolMeta::createOrValidate(*meta_op, layout, 256, BlobHashAlgo::CityHash128, /*allow_new*/ false, /*allow_mint*/ true, + /*initial_min_reader_generation*/ 1); + EXPECT_EQ(pm.min_reader_generation, 1u); +} + +TEST(CASPoolMetaFloor, HashAdmissionDoesNotRaiseTheFloorPastTheAlgorithmsNeed) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + OperationForTest meta_op(*backend); + const PoolMeta before = PoolMeta::createOrValidate(*meta_op, layout, 256, BlobHashAlgo::CityHash128, false, true, 1); + const PoolMeta admitted = PoolMeta::createOrValidate(*meta_op, layout, 256, BlobHashAlgo::XXH3_128, /*allow_new*/ true, /*allow_mint*/ false, 1); + ASSERT_EQ(admitted.algos_used.size(), 2u) << "the algorithm must have been admitted"; + EXPECT_EQ(admitted.min_reader_generation, before.min_reader_generation) << "admission must not touch the floor"; + EXPECT_EQ(admitted.min_reader_generation, 1u); +} + +TEST(CASPoolMetaFloor, RaiseMinReaderGenerationIsMonotone) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + OperationForTest meta_op(*backend); + PoolMeta::createOrValidate(*meta_op, layout, 256, BlobHashAlgo::CityHash128, false, true, 1); + EXPECT_EQ(PoolMeta::raiseMinReaderGeneration(*meta_op, layout, 2).min_reader_generation, 2u); + EXPECT_EQ(PoolMeta::raiseMinReaderGeneration(*meta_op, layout, 1).min_reader_generation, 2u) << "never lowered"; + EXPECT_EQ(PoolMeta::raiseMinReaderGeneration(*meta_op, layout, 2).min_reader_generation, 2u); + const PoolMeta reread = PoolMeta::createOrValidate(*meta_op, layout, 256, BlobHashAlgo::CityHash128); + EXPECT_EQ(reread.min_reader_generation, 2u); +} + /// ============================================================================================ /// CAS reader-generation gate (`CasFormat.h`'s `G_BUILD`). This test's assertions read `G_BUILD` /// itself rather than a hardcoded generation number, so a future bump does not silently make them -/// false. `PoolMeta::createOrValidate`'s open-time CAS-raise targets `G_BUILD`, and `decodePoolMeta` -/// fail-closes BOTH on a FUTURE `min_reader_generation` AND on a BACKWARD pool whose header +/// false. A new pool starts at `G_REF_MOVE`, and hash admission never raises it past the +/// algorithm's own requirement. `decodePoolMeta` fail-closes BOTH on a FUTURE `min_reader_generation` AND on a BACKWARD pool whose header /// `compatibility_version` is below the format-generation baseline (see `CasPoolMetaFormat.cpp`). -/// ============================================================================================ - -TEST(CASPluggableHash, ReaderGenerationIsRaisedToGBuild) +TEST(CASPluggableHash, ReaderGenerationFloorIsGenerationTwoAndGatesFailClosed) { - /// A freshly opened/created pool records `min_reader_generation == G_BUILD` (the open-time - /// CAS-raise, `PoolMeta::createOrValidate`, always targets this build's own floor). + /// A new pool is stamped at generation 2, not at the build's read ceiling. { auto backend = std::make_shared(); auto store = Pool::open(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); - EXPECT_EQ(store->poolMeta().min_reader_generation, G_BUILD); + EXPECT_EQ(store->poolMeta().min_reader_generation, G_REF_MOVE); const auto meta_bytes = readAt(*backend, store->layout().poolMetaKey()); ASSERT_TRUE(meta_bytes.has_value()); - EXPECT_EQ(decodePoolMeta(meta_bytes->bytes).min_reader_generation, G_BUILD); + EXPECT_EQ(decodePoolMeta(meta_bytes->bytes).min_reader_generation, G_REF_MOVE); } - /// FORWARD gate: a pool-meta carrying `min_reader_generation == G_BUILD + 1` (one generation past /// THIS build's floor) fails closed at open -- the startup gate (`decodePoolMeta`) rejects it. { @@ -750,8 +781,8 @@ TEST(CASPluggableHash, ReaderGenerationIsRaisedToGBuild) PoolMeta pm = PoolMeta::createOrValidate(*meta_op, layout, /*blob_header_len*/ 256, BlobHashAlgo::CityHash128, /*allow_new*/ false, /*allow_mint*/ true); const String fresh_bytes = encodePoolMeta(pm); - const String from = "\"v\":" + std::to_string(G_BUILD); - const String to = "\"v\":" + std::to_string(G_BUILD - 1); + const String from = "\"v\":" + std::to_string(G_WRITE_FLOOR); + const String to = "\"v\":" + std::to_string(G_WRITE_FLOOR - 1); const auto pos = fresh_bytes.find(from); ASSERT_NE(pos, String::npos); // sanity: a fresh pool stamps the header at the floor String downgraded = fresh_bytes; diff --git a/src/Disks/tests/gtest_cas_record_stream_format.cpp b/src/Disks/tests/gtest_cas_record_stream_format.cpp index 84ed94d2610c9..464845fdfff85 100644 --- a/src/Disks/tests/gtest_cas_record_stream_format.cpp +++ b/src/Disks/tests/gtest_cas_record_stream_format.cpp @@ -413,7 +413,7 @@ TEST(CASRecordStream, HeaderGates) /// Future version -> UNKNOWN_FORMAT_VERSION. { const String s = fmt::format( - "{{\"type\":\"cas_run\",\"v\":{},\"kind\":\"source_edge\"}}\n{{\"n\":0}}\n", currentCompatibilityVersion() + 1); + "{{\"type\":\"cas_run\",\"v\":{},\"kind\":\"source_edge\"}}\n{{\"n\":0}}\n", G_BUILD + 1); ReadBufferFromMemory in(s.data(), s.size()); try { diff --git a/src/Disks/tests/gtest_cas_ref_intake.cpp b/src/Disks/tests/gtest_cas_ref_intake.cpp index 49ae6bed4137e..63c371a0b9d15 100644 --- a/src/Disks/tests/gtest_cas_ref_intake.cpp +++ b/src/Disks/tests/gtest_cas_ref_intake.cpp @@ -123,8 +123,8 @@ TEST(CASRefIntake, ManifestEdgesPerOperationShape) } } -/// `manifestEdgesOfTxn` rejects every `owner_transition` shape outside the four `classifyOwnerTransitionShape` -/// recognizes (Pool/CasRefProtocol.cpp) -- it must never silently assign edge meaning to a shape the +/// `manifestEdgesOfTxn` rejects every `owner_transition` shape outside the five `classifyOwnerTransitionShape` +/// recognizes (Formats/CasRefLogFormat.cpp) -- it must never silently assign edge meaning to a shape the /// writer/replay state machine would refuse to apply. Each case throws `CORRUPTED_DATA`. TEST(CASRefIntake, ManifestEdgesRejectsUnrecognizedShapes) { diff --git a/src/Disks/tests/gtest_cas_ref_statemachine.cpp b/src/Disks/tests/gtest_cas_ref_statemachine.cpp index af79232cde2e7..f37a8a70d8202 100644 --- a/src/Disks/tests/gtest_cas_ref_statemachine.cpp +++ b/src/Disks/tests/gtest_cas_ref_statemachine.cpp @@ -1,10 +1,12 @@ #include #include +#include #include #include #include #include #include +#include #include #include #include @@ -14,10 +16,12 @@ namespace DB::ErrorCodes { extern const int CORRUPTED_DATA; +extern const int UNKNOWN_FORMAT_VERSION; } using namespace DB::Cas; using DB::Cas::tests::expectThrowsCode; +using DB::Cas::tests::expectThrowsCodeWithMessage; namespace { @@ -80,6 +84,15 @@ RefOp removeCommittedOp(const String & name, const ManifestRef & mref) return op; } +RefOp moveOp(const String & from, const String & to, const ManifestRef & mref) +{ + RefOp op; + op.kind = RefOpKind::OwnerTransition; + op.old_binding = RefOwnerBinding{RefOwnerKind::Committed, from, mref}; + op.new_binding = RefOwnerBinding{RefOwnerKind::Committed, to, mref}; + return op; +} + RefOp setPublishedAtOp(const String & name, const ManifestRef & mref, uint64_t ts = 0) { RefOp op; @@ -147,6 +160,45 @@ RefTableSnapshot buildCollidingBaseSnapshotForTest() /// NamespaceBirth /// =================================================================================== +namespace +{ +String headerLineOf(const String & encoded) +{ + return encoded.substr(0, encoded.find('\n')); +} +} + +TEST(CASRefLogGeneration, MoveTxnStampsCompatibility2) +{ + const auto m = manifestRef(1, 1, 1); + const String encoded = encodeRefLogTxn(makeTxn(kNs, RefTxnId{1, 2}, {moveOp("tmp_a", "a", m)})); + EXPECT_NE(headerLineOf(encoded).find("\"v\":2"), String::npos) << headerLineOf(encoded); + EXPECT_TRUE(refLogTxnContainsMove(makeTxn(kNs, RefTxnId{1, 2}, {moveOp("tmp_a", "a", m)}))); +} + +TEST(CASRefLogGeneration, NonMoveTxnStampsCompatibility1) +{ + const auto m = manifestRef(1, 1, 1); + const String encoded = encodeRefLogTxn(makeTxn(kNs, RefTxnId{1, 1}, {birthOp(), addPrecommitOp("a", m), promoteOp("a", m)})); + EXPECT_NE(headerLineOf(encoded).find("\"v\":1"), String::npos) << headerLineOf(encoded); +} + +TEST(CASRefLogGeneration, ReaderWithCeiling1RejectsMoveTxnBeforeParsingOps) +{ + const auto m = manifestRef(1, 1, 1); + const RefTxnId id{1, 2}; + String encoded = encodeRefLogTxn(makeTxn(kNs, id, {moveOp("tmp_a", "a", m)})); + /// Corrupt the op line (third line) so that a reader which got past the header would report a + /// body error; a generation-1 reader must reject on the header alone. + const size_t second_nl = encoded.find('\n', encoded.find('\n') + 1); + encoded.replace(second_nl + 1, 1, "?"); + setReadCeilingForTest(1); + SCOPE_EXIT({ setReadCeilingForTest(std::nullopt); }); + expectThrowsCode(DB::ErrorCodes::UNKNOWN_FORMAT_VERSION, [&] { decodeRefLogTxn(encoded, kNs, id); }); + setReadCeilingForTest(std::nullopt); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { decodeRefLogTxn(encoded, kNs, id); }); +} + TEST(CASRefStateMachine, BirthFromNeverBornAccepts) { RefTableState state; @@ -1443,3 +1495,86 @@ TEST(CASRefBudgetSize, AccessorsEqualFullEncodeRandomized) EXPECT_EQ(encodedRemovalBudgetSize(state), true_removal); } } + +TEST(CASRefStateMachine, MoveAccepts) +{ + RefTableState state; + const auto m = manifestRef(1, 1, 1); + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, {birthOp(), addPrecommitOp("tmp_a", m), promoteOp("tmp_a", m), setPublishedAtOp("tmp_a", m, 777)})); + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 2}, {moveOp("tmp_a", "a", m)})); + EXPECT_FALSE(state.getCommitted().contains("tmp_a")); + ASSERT_TRUE(state.getCommitted().contains("a")); + EXPECT_EQ(state.getCommitted().find("a")->second.manifest_ref, m); + EXPECT_EQ(state.getCommitted().find("a")->second.published_at_ms, 777u) << "publication time travels with the part"; + /// The manifest keeps its single owner: a second owner under another name is still rejected. + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 3}, {addPrecommitOp("x", m)})); }); +} + +TEST(CASRefStateMachine, MoveThenSetPublishedAtRestamps) +{ + RefTableState state; + const auto m = manifestRef(1, 1, 1); + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, {birthOp(), addPrecommitOp("tmp_a", m), promoteOp("tmp_a", m), setPublishedAtOp("tmp_a", m, 777)})); + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 2}, {moveOp("tmp_a", "a", m), setPublishedAtOp("a", m, 999)})); + EXPECT_FALSE(state.getCommitted().contains("tmp_a")); + ASSERT_TRUE(state.getCommitted().contains("a")); + EXPECT_EQ(state.getCommitted().find("a")->second.manifest_ref, m); + EXPECT_EQ(state.getCommitted().find("a")->second.published_at_ms, 999u); +} + +TEST(CASRefStateMachine, MoveRejectsStaleSource) +{ + RefTableState state; + const auto m1 = manifestRef(1, 1, 1); + const auto m2 = manifestRef(1, 2, 1); + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, {birthOp(), addPrecommitOp("a", m1), promoteOp("a", m1)})); + const RefTableState before = state; + expectThrowsCodeWithMessage(DB::ErrorCodes::CORRUPTED_DATA, "to move is absent", [&] { applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 2}, {moveOp("a", "b", m2)})); }); + expectThrowsCodeWithMessage(DB::ErrorCodes::CORRUPTED_DATA, "to move is absent", [&] { applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 2}, {moveOp("zzz", "b", m1)})); }); + expectStatesEqual(before, state); +} + +TEST(CASRefStateMachine, MoveRejectsOccupiedOrPrecommittedDestination) +{ + RefTableState state; + const auto m1 = manifestRef(1, 1, 1); + const auto m2 = manifestRef(1, 2, 1); + const auto m3 = manifestRef(1, 3, 1); + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, + {birthOp(), addPrecommitOp("a", m1), promoteOp("a", m1), addPrecommitOp("b", m2), promoteOp("b", m2), addPrecommitOp("c", m3)})); + const RefTableState before = state; + expectThrowsCodeWithMessage(DB::ErrorCodes::CORRUPTED_DATA, "is already committed", [&] { applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 2}, {moveOp("a", "b", m1)})); }); + expectThrowsCodeWithMessage(DB::ErrorCodes::CORRUPTED_DATA, "has a live precommit", [&] { applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 2}, {moveOp("a", "c", m1)})); }); + expectStatesEqual(before, state); +} + +TEST(CASRefStateMachine, MoveRejectsDifferentManifestsOrSameName) +{ + RefTableState state; + const auto m1 = manifestRef(1, 1, 1); + const auto m2 = manifestRef(1, 2, 1); + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, {birthOp(), addPrecommitOp("a", m1), promoteOp("a", m1)})); + RefOp bad = moveOp("a", "b", m1); + bad.new_binding->manifest_ref = m2; + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 2}, {bad})); }); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 2}, {moveOp("a", "a", m1)})); }); +} + +TEST(CASRefStateMachine, MoveMaintainsBodyByteCountersForUnequalNames) +{ + RefTableState moved; + RefTableState direct; + const auto m = manifestRef(1, 1, 1); + applyRefLogTxn(moved, makeTxn(kNs, RefTxnId{1, 1}, {birthOp(), addPrecommitOp("tmp_insert_all_1_1_0", m), promoteOp("tmp_insert_all_1_1_0", m)})); + applyRefLogTxn(moved, makeTxn(kNs, RefTxnId{1, 2}, {moveOp("tmp_insert_all_1_1_0", "all_1_1_0", m)})); + applyRefLogTxn(direct, makeTxn(kNs, RefTxnId{1, 1}, {birthOp(), addPrecommitOp("all_1_1_0", m), promoteOp("all_1_1_0", m)})); + EXPECT_EQ(moved.getSnapshotBodyBytes(), direct.getSnapshotBodyBytes()); + EXPECT_EQ(moved.getRemovalBodyBytes(), direct.getRemovalBodyBytes()); +} + +TEST(CASRefIntake, ManifestEdgesMoveEmitsNone) +{ + const auto m = manifestRef(1, 1, 1); + const auto edges = manifestEdgesOfTxn(makeTxn(kNs, RefTxnId{1, 2}, {moveOp("tmp_a", "a", m)})); + EXPECT_TRUE(edges.empty()); +} diff --git a/src/Disks/tests/gtest_cas_ref_writer.cpp b/src/Disks/tests/gtest_cas_ref_writer.cpp index f9af64c121adf..fe5e08ce183a6 100644 --- a/src/Disks/tests/gtest_cas_ref_writer.cpp +++ b/src/Disks/tests/gtest_cas_ref_writer.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -5268,6 +5269,125 @@ TEST(CASRefWriterAppendLane, SameRefMutationsSplitAcrossFlushes) EXPECT_TRUE(resolved->published_at_ms == 1 || resolved->published_at_ms == 2); } +namespace +{ + +/// The two ops of an owner move `src` -> `dst` of the committed manifest `m`. +std::vector ownerMoveOps(const String & src, const String & dst, const ManifestRef & m) +{ + RefOp move; + move.kind = RefOpKind::OwnerTransition; + move.old_binding = RefOwnerBinding{RefOwnerKind::Committed, src, m}; + move.new_binding = RefOwnerBinding{RefOwnerKind::Committed, dst, m}; + return {move}; +} + +/// An add/remove precommit pair on `ref`: ordinary ref-scoped work that leaves the live state unchanged. +std::vector precommitPairOps(const String & ref, uint64_t epoch) +{ + const ManifestRef m{epoch, 1, 1}; + RefOp add; + add.kind = RefOpKind::OwnerTransition; + add.new_binding = RefOwnerBinding{RefOwnerKind::Precommit, ref, m}; + RefOp remove; + remove.kind = RefOpKind::OwnerTransition; + remove.old_binding = RefOwnerBinding{RefOwnerKind::Precommit, ref, m}; + return {add, remove}; +} + +/// Queues `first` as the parked leader's own item and `second` behind it, releases the leader once both +/// are pending, and returns how many ref-log objects the two mutations needed. +uint64_t logObjectsForTwoQueuedMutations( + const PoolPtr & store, const std::shared_ptr & backend, const RootNamespace & ns, + std::function first, std::function second) +{ + std::mutex m; + std::condition_variable cv; + bool entered = false; + store->setRefPreCarveHookForTest([&] + { + std::unique_lock lk(m); + if (entered) + return; + entered = true; + cv.notify_all(); + cv.wait(lk, [&] { return store->refQueuePendingForTest(ns) >= 2; }); + }); + const uint64_t put_before = backend->putTotal(); + std::thread t_first(first); + { + std::unique_lock lk(m); + cv.wait(lk, [&] { return entered; }); + } + std::thread t_second(second); + while (store->refQueuePendingForTest(ns) < 2) + std::this_thread::yield(); + cv.notify_all(); + t_first.join(); + t_second.join(); + store->setRefPreCarveHookForTest(nullptr); + return backend->putTotal() - put_before; +} + +} + +/// An owner move names two refs, so it must not serialize unrelated refs: two moves on disjoint +/// name pairs share one `_log` object. +TEST(CASRefWriterAppendLane, TwoMovesOnDisjointRefsCoBatch) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"srv1/move_cobatch"}; + const ManifestId id_a = publishEmptyPart(store, ns, "a"); + const ManifestId id_c = publishEmptyPart(store, ns, "c"); + + const uint64_t puts = logObjectsForTwoQueuedMutations(store, backend, ns, + [&] + { + store->appendRefOps(ns, MutationScope::refs("a", "b"), + [&](const RefTableState &) { return ownerMoveOps("a", "b", id_a.ref); }, + RootMutationOrigin::Writer, RootMutationKind::Move); + }, + [&] + { + store->appendRefOps(ns, MutationScope::refs("c", "d"), + [&](const RefTableState &) { return ownerMoveOps("c", "d", id_c.ref); }, + RootMutationOrigin::Writer, RootMutationKind::Move); + }); + + EXPECT_EQ(puts, 1u) << "moves on disjoint refs must flush as one log"; + EXPECT_FALSE(store->resolveRef(ns, "a").has_value()); + EXPECT_FALSE(store->resolveRef(ns, "c").has_value()); + EXPECT_TRUE(store->resolveRef(ns, "b").has_value()); + EXPECT_TRUE(store->resolveRef(ns, "d").has_value()); +} + +/// A move and a mutation of the move's destination name touch the same ref, so they must not share a +/// batch: the second would be validated against a state that does not yet contain the first. +TEST(CASRefWriterAppendLane, MoveAndRefOpOnSameNameDoNotCoBatch) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"srv1/move_samename"}; + const ManifestId id_a = publishEmptyPart(store, ns, "a"); + + const uint64_t puts = logObjectsForTwoQueuedMutations(store, backend, ns, + [&] + { + store->appendRefOps(ns, MutationScope::refs("a", "b"), + [&](const RefTableState &) { return ownerMoveOps("a", "b", id_a.ref); }, + RootMutationOrigin::Writer, RootMutationKind::Move); + }, + [&] + { + store->appendRefOps(ns, MutationScope::ref("b"), + [&](const RefTableState &) { return precommitPairOps("b", 900000010); }, + RootMutationOrigin::Writer, RootMutationKind::Publish); + }); + + EXPECT_EQ(puts, 2u) << "a move and a mutation of its destination must flush as two logs"; +} + /// =================================================================================== /// rev.6 Task 8: the recovery seal (spec §recovery-seal / §seal-id / §seal-soundness). At an UNCLEAN /// mount, `ensureRefTableRecovered` must close every dead epoch it discovers with an immediate diff --git a/src/Disks/tests/gtest_cas_text_format.cpp b/src/Disks/tests/gtest_cas_text_format.cpp index 119dc801a1464..ad237cd2413b2 100644 --- a/src/Disks/tests/gtest_cas_text_format.cpp +++ b/src/Disks/tests/gtest_cas_text_format.cpp @@ -234,7 +234,7 @@ TEST(CASTextHeader, WriteExpectSniffGate) const String wrong = "{\"type\":\"cas_owner\",\"v\":1}\n"; DB::ReadBufferFromMemory in2(wrong.data(), wrong.size()); expectCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { expectHeaderLine(in2, FormatId::PoolMeta); }); - const String future = fmt::format("{{\"type\":\"cas_pool_meta\",\"v\":{}}}\n", currentCompatibilityVersion() + 1); + const String future = fmt::format("{{\"type\":\"cas_pool_meta\",\"v\":{}}}\n", G_BUILD + 1); DB::ReadBufferFromMemory in3(future.data(), future.size()); expectCode(DB::ErrorCodes::UNKNOWN_FORMAT_VERSION, [&] { expectHeaderLine(in3, FormatId::PoolMeta); }); From d233c70cb5df31eaa374c32f01a8f81318a50036 Mon Sep 17 00:00:00 2001 From: Mikhail Filimonov Date: Fri, 23 Oct 2026 03:21:14 +0200 Subject: [PATCH 10/20] CAS: rename a committed part with one ref-log Move A committed part on a content-addressed disk is renamed by one owner Move plus SetPublishedAt in the same append (modification time follows the rename, as on a local disk); detached/ and moving/ take the same path, cross-namespace renames keep republication. `renameCommittedRef(src, dst, expected)` owns the decision: Move inside a namespace when the destination is free, `republishRef` otherwise (another namespace, or a destination an earlier attempt already published), and `CAS_OWNERSHIP_CONFLICT` when `src` no longer names the manifest the caller read. `moveRefExact`, `dropRefExact` (returning `Cas::Removal`) and `dropRefIfMatches` (its noexcept rollback wrapper) are the part-level primitives. `partition_exports` descriptors route as table-level files through the same transaction (comments at the two sites). A rewrite of a committed part records the manifest it started from (`base_manifest_ref`) and `publishStaging` repoints only while the ref still names it; otherwise `CAS_OWNERSHIP_CONFLICT`. A committed manifest with no top-level file is a nested scratch storage (text index), not a part: `publishStaging` drops it and publishes afresh instead of merging into it. A committed part edited and renamed in one transaction keeps its untouched files: the staged source's base is renamed first (conditional on that base) and becomes the destination staging's base, so the delta merges under the new name as it does in place. A part another writer published under the staged name, or a base dropped meanwhile, is a conflict, never a silent drop or a delta-only part. `PublishPolicy` is the expected manifest or nothing: unset = first commit (another committed manifest is refused with the write-retry-later error), set = repoint while the ref still names it (`CAS_OWNERSHIP_CONFLICT` otherwise). `repointRef` requires the expected manifest; the flag/optional pair it replaces encoded the same three outcomes. Audit events after a durable append are best-effort: `emitAuditBestEffort` builds the `CasEvent` inside its guard after the sink check and locks the memory tracker in the handler, so an allocation failure after a landed Move or drop neither fails the caller nor terminates. `PoolAccessSnapshot` moves next to the private `poolAccess()` it belongs to. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Mikhail Filimonov --- .../ContentAddressedExchange.h | 5 +- .../ContentAddressedMetadataStorage.cpp | 20 +- .../ContentAddressedMetadataStorage.h | 39 +- .../ContentAddressedTransaction.cpp | 118 ++++- .../ContentAddressedTransaction.h | 5 + .../Parts/PartFolderAccess.cpp | 334 ++++++++++---- .../ContentAddressed/Parts/PartFolderAccess.h | 99 ++++- .../ContentAddressed/Pool/CasPartWriteTxn.cpp | 61 ++- .../ContentAddressed/Pool/CasPartWriteTxn.h | 21 +- .../ContentAddressed/Primitives/CasTypes.h | 21 + src/Disks/tests/gtest_ca_transaction.cpp | 132 ++++-- src/Disks/tests/gtest_ca_wiring.cpp | 247 ++++++++++- .../tests/gtest_cas_confirm_exact_ref.cpp | 102 ++++- src/Disks/tests/gtest_cas_gc_leak.cpp | 3 +- src/Disks/tests/gtest_cas_parallel_commit.cpp | 6 +- .../tests/gtest_cas_part_folder_access.cpp | 418 +++++++++++++++++- src/Disks/tests/gtest_cas_part_write.cpp | 6 +- .../tests/gtest_cas_promote_republish.cpp | 12 +- src/Disks/tests/gtest_cas_publish_policy.cpp | 157 +++++++ src/Disks/tests/gtest_cas_repoint.cpp | 11 +- 20 files changed, 1551 insertions(+), 266 deletions(-) create mode 100644 src/Disks/tests/gtest_cas_publish_policy.cpp diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedExchange.h b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedExchange.h index ac05a13bc78d6..fac2e44a923fa 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedExchange.h +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedExchange.h @@ -215,8 +215,9 @@ class IContentAddressedExchange /// nothing about the window that follows it. /// /// On `MechanismFallbackAllowed` nothing was staged and `out` is null: the manifest did not decode, - /// or the local staging hit the retryable class (a body-absent precommit, a precommit that is no - /// longer the live owner, a ref conflict -- `ABORTED`/`NETWORK_ERROR`). Any OTHER error propagates. + /// or the local staging hit the retryable class (a body-absent precommit or a precommit that is no + /// longer the live owner -- `ABORTED`; a ref already committed with other content -- write-retry-later + /// `NETWORK_ERROR`). Any OTHER error propagates, an ownership conflict included. /// /// Promotion trusts the adopted references through the durable manifest edge -- it does not re-read /// each blob body -- the same interserver trust as an ordinary `ReplicatedMergeTree` fetch. A blob diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.cpp index 7644bb2567587..e4a33e54a230d 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.cpp +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.cpp @@ -1635,7 +1635,7 @@ ContentAddressedMetadataStorage::DirRoute ContentAddressedMetadataStorage::class /// A path with a part-shaped component followed by more components: a file or nested /// directory of a live, detached or moving part IF that ref resolves (shadow is routed /// above). For an Atomic table path, the parser calls every first component after the - /// table root except `deduplication_logs` the part component; a non-Atomic table path + /// table root except a reserved subdir (`deduplication_logs`, `partition_exports`) the part component; a non-Atomic table path /// anchors on the rightmost part-shaped component instead. Either way, whether this really /// is a part is decided by the ref at answer time, not by the path: /// `existsDirectory`/`listDirectory` take the old table-subdirectory branch when it does @@ -1652,7 +1652,7 @@ ContentAddressedMetadataStorage::DirRoute ContentAddressedMetadataStorage::class /// No sub-shape matched: fall through, identical to today's post-`if (p)` continuation. } - /// A table-level SUBDIRECTORY (deduplication_logs/...). + /// A table-level SUBDIRECTORY (deduplication_logs/, partition_exports/). if (auto tf = Cas::parseTableFilePath(path)) { dr.shape = DirShape::TableSubdir; @@ -2338,17 +2338,18 @@ class PreparedRelinkOverPartWrite : public ICaPreparedRelink ref_name, e.message()); return CaRelinkPromote::Unresolved; } - /// The same retryable class the staging half classifies: a body-absent precommit, a - /// precommit binding that is no longer the live owner, or a ref conflict. `promote` has + /// The same retryable class the staging half classifies: a body-absent precommit or a precommit + /// binding that is no longer the live owner (`ABORTED`), or a ref already committed with other + /// content (write-retry-later `NETWORK_ERROR`; an ownership conflict propagates). `promote` has /// already abandoned the build on its way out, so the `+1` is released and the sender's /// bytes are a sound recovery. Anything else propagates -- it is not a known-safe /// mechanism failure, and the receiver must not silently turn it into a byte fetch. if (e.code() != ErrorCodes::ABORTED && e.code() != ErrorCodes::NETWORK_ERROR) throw; LOG_INFO(getLogger("ContentAddressedMetadataStorage"), - "Relink of part {} could not be promoted (body-absent precommit, precommit not the live " - "owner, or a ref conflict): {}; the caller may fetch the bytes from the same source", - ref_name, e.message()); + "Relink of part {} could not be promoted (one of: body-absent precommit, precommit not the live " + "owner, a ref already committed with other content, a retry-later backend outcome; code {}): {}; the caller may fetch the bytes from the same source", + ref_name, e.code(), e.message()); return CaRelinkPromote::MechanismFallbackAllowed; } } @@ -2455,7 +2456,8 @@ CaRelinkPrepare ContentAddressedMetadataStorage::prepareAdoptFromManifest( catch (const Exception & e) { /// `ABORTED` or `NETWORK_ERROR` means a body-absent precommit, a precommit binding that is no - /// longer the live owner, a ref conflict, or — since the transient-classifier round — this node's + /// longer the live owner (`ABORTED`), a ref already committed with other content (write-retry-later + /// `NETWORK_ERROR`; an ownership conflict propagates), or — since the transient-classifier round — this node's /// own mount fence refusing the work (`throwCasTransientUnavailable`): all retryable, and the /// sender still has the part, so the caller may fetch its bytes. `prepareEntries` abandons its own /// build before propagating, so nothing is staged and no `+1` is left behind. The fence case needs @@ -2466,7 +2468,7 @@ CaRelinkPrepare ContentAddressedMetadataStorage::prepareAdoptFromManifest( if (e.code() != ErrorCodes::ABORTED && e.code() != ErrorCodes::NETWORK_ERROR) throw; LOG_INFO(getLogger("ContentAddressedMetadataStorage"), "Relink of part {} deferred (body-absent precommit, " - "precommit not the live owner, or a ref conflict): {}; caller falls back to a byte fetch", part_path, e.message()); + "precommit not the live owner, or a ref already committed with other content): {}; caller falls back to a byte fetch", part_path, e.message()); return CaRelinkPrepare::MechanismFallbackAllowed; } } diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.h b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.h index 653ea5362fabf..a3420d6a7ca05 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.h +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.h @@ -243,7 +243,6 @@ class ContentAddressedMetadataStorage final : public IMetadataStorage, public IC bool isContentAddressed() const override { return true; } bool allowsPackedParts() const override { return allow_packed_parts; } - /// Fail-close gate shared by every mutating entry point (transactions, GC round, GC rebuild, /// pool-member decommission): an observe-only (``) disk must reject them all. void checkNotReadOnly(std::string_view what) const; @@ -607,6 +606,24 @@ class ContentAddressedMetadataStorage final : public IMetadataStorage, public IC } private: + /// A single coherent snapshot of the pool and its cached part-folder facade, taken under ONE + /// `pointer_mutex` acquisition (see `poolAccess()`) so no caller can observe `pool` from one mount + /// generation and `part_access` from another -- the two used to be fetched by two separate calls + /// to `store()`/`partAccess()` at some call sites, each taking its own `pointer_mutex` snapshot. + struct PoolAccessSnapshot + { + Cas::PoolPtr pool; + std::shared_ptr part_access; + }; + + /// The one place that takes a `{pool, facade}` snapshot under a SINGLE `pointer_mutex` + /// acquisition. Throws `INVALID_STATE` (via `throwStorageNotStarted`) when no pool is published + /// (before the first `startup` or after `shutdown` -- the storage-level Constructing/ShutDown + /// lifecycle), then refuses a terminal pool via `throwIfLifecycleTerminal`. `store()`/`partAccess()` + /// are thin wrappers over this and take both fields from one snapshot, so no caller can straddle a + /// startup/shutdown that changes `cas_store`/`part_access`. + PoolAccessSnapshot poolAccess() const; + const ObjectStoragePtr object_storage; const std::string storage_path_prefix; const std::string storage_path_full; @@ -664,16 +681,6 @@ class ContentAddressedMetadataStorage final : public IMetadataStorage, public IC const bool blob_hash_allow_new; /// Per-disk `` policy passed to `Cas::PoolConfig`. const bool skip_access_check; - /// A single coherent snapshot of the pool and its cached part-folder facade, taken under ONE - /// `pointer_mutex` acquisition (see `poolAccess()`) so no caller can observe `pool` from one mount - /// generation and `part_access` from another -- the two used to be fetched by two separate calls - /// to `store()`/`partAccess()` at some call sites, each taking its own `pointer_mutex` snapshot. - struct PoolAccessSnapshot - { - Cas::PoolPtr pool; - std::shared_ptr part_access; - }; - /// Set by startup (Pool::open is fail-closed; empty store == not started). shared_ptr so /// store()/partAccess() can return a by-value snapshot under `pointer_mutex` (see below) instead /// of a reference that could dangle across a concurrent `shutdown` reset. @@ -729,16 +736,6 @@ class ContentAddressedMetadataStorage final : public IMetadataStorage, public IC return physical_key_prefix + "/" + key; } - /// The one place that takes a `{pool, facade}` snapshot under a SINGLE `pointer_mutex` - /// acquisition. Throws `INVALID_STATE` (via `throwStorageNotStarted`) when no pool is published - /// (before the first `startup` or after `shutdown` -- the storage-level Constructing/ShutDown - /// lifecycle), then refuses a terminal pool via `throwIfLifecycleTerminal`. `store()`/`partAccess()` - /// are thin wrappers over this; every other caller that needs BOTH the pool and the facade for one - /// logical operation must call this ONCE and use both fields from the same snapshot, rather than - /// calling `store()` and `partAccess()` separately -- otherwise it could straddle a startup/shutdown - /// that changes `cas_store`/`part_access`. - PoolAccessSnapshot poolAccess() const; - /// Atomically unpublishes every pool-owning facade, then stops/releases/drains them in dependency /// order outside `pointer_mutex`. Each phase is independently fail-soft because this is also called /// by the destructor. diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedTransaction.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedTransaction.cpp index 3408c7f4309fe..071c1bb047dd4 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedTransaction.cpp +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedTransaction.cpp @@ -105,6 +105,19 @@ namespace /// large eager file. constexpr size_t INLINE_CAP = 1024 * 1024; /// 1 MiB +bool hasTopLevelFile(const std::vector & entries) +{ + return std::any_of(entries.begin(), entries.end(), + [](const Cas::ManifestEntry & e) { return e.path.find('/') == std::string::npos; }); +} + +/// A committed manifest with no top-level file is not a part: it is the scratch of a sub-storage nested +/// under the part's directory (`createTemporaryTextIndexStorage`), committed at the part's own name. +bool isNestedScratchManifest(const std::vector & entries) +{ + return !hasTopLevelFile(entries); +} + } ContentAddressedTransaction::ContentAddressedTransaction(ContentAddressedMetadataStorage & metadata_storage_) @@ -150,7 +163,22 @@ ContentAddressedTransaction::~ContentAddressedTransaction() ContentAddressedTransaction::PartStaging & ContentAddressedTransaction::stagingFor(const ContentAddressedMetadataStorage::Route & r) { - return parts[{r.ns.string(), r.ref}]; + const auto key = std::make_pair(r.ns.string(), r.ref); + auto it = parts.find(key); + if (it != parts.end()) + return it->second; + /// Reading through `getView` leaves the retained view behind, so the unlink burst's memoized + /// ForceFresh proof (`force_fresh_validated_refs`) can start from this read instead of repeating it. + /// The record is inserted only after the read succeeds, so a throwing `getView` leaves no empty record. + std::optional base; + if (const auto view = metadata_storage.partAccess()->getView({r.ns, r.ref}, Cas::Freshness::ForceFresh)) + { + base = view->manifestId().ref; + force_fresh_validated_refs.insert(r.refKey().cacheKey()); + } + PartStaging & st = parts[key]; + st.base_manifest_ref = base; + return st; } Cas::PartWriteTxn & ContentAddressedTransaction::buildFor( @@ -368,7 +396,25 @@ void ContentAddressedTransaction::publishStaging(const Cas::RootNamespace & ns, /// per-part file that is now an ordinary tree entry, or a marks-only removal with no writes). if (!st.entries.empty() || !st.content_removed.empty()) { - if (auto view = metadata_storage.partAccess()->getView({ns, ref}, Cas::Freshness::ForceFresh)) + auto view = metadata_storage.partAccess()->getView({ns, ref}, Cas::Freshness::ForceFresh); + /// A build that stages the part's own top-level files replaces a nested scratch manifest at the + /// same name instead of merging into it. + if (view && st.build && (!st.base_manifest_ref || *st.base_manifest_ref == view->manifestId().ref) + && isNestedScratchManifest(view->manifest()->entries) && hasTopLevelFile(st.entries)) + { + if (metadata_storage.partAccess()->dropRefExact({ns, ref}, view->manifestId().ref) + == Cas::Removal::Mismatch) + Cas::throwCasOwnershipConflict("ContentAddressedTransaction: scratch ref {}/{} was repointed while this transaction was staging the part", + ns.string(), ref); + st.base_manifest_ref.reset(); + view.reset(); + } + if (st.base_manifest_ref && (!view || !(view->manifestId().ref == *st.base_manifest_ref))) + Cas::throwCasOwnershipConflict("ContentAddressedTransaction: committed part {}/{} was moved, dropped or repointed while its rewrite was staged; " + "the operation must be re-resolved", ns.string(), ref); + if (!st.base_manifest_ref && view) + Cas::throwCasOwnershipConflict("ContentAddressedTransaction: {}/{} was published by another writer while this transaction was staging it", ns.string(), ref); + if (view) { if (st.build) { @@ -408,8 +454,8 @@ void ContentAddressedTransaction::publishStaging(const Cas::RootNamespace & ns, /// Capture the exact `CommitOutcome` IMMEDIATELY -- into the caller-provided slot, before /// the scratch-build `abandon()` below (which can itself throw), and before the test-only /// after-promote hook (which can run arbitrary test code) -- so a later throw in either - /// cannot lose it (Task 2/3's publish-before-any-throwable-post-commit-work ordering). - const Cas::CommitOutcome oc = metadata_storage.partAccess()->repointRef({ns, ref}, std::move(merged), Cas::ProvenanceOp::Other); + /// cannot lose it (publish before any throwable post-commit work). + const Cas::CommitOutcome oc = metadata_storage.partAccess()->repointRef({ns, ref}, std::move(merged), Cas::ProvenanceOp::Other, *st.base_manifest_ref); out_slot = oc; /// always created=false here: this block only runs once `view` already resolved /// Test-only: models a concurrent writer racing in right after this transaction's own /// confirm (e.g. repointing `ref` again). A no-op in production. @@ -1026,9 +1072,9 @@ void ContentAddressedTransaction::createDirectoryRecursive(const std::string &) void ContentAddressedTransaction::removeDirectory(const std::string & path) { /// CONTRACT: `removeDirectory`/`moveDirectory` mutate durable refs at CALL TIME, not at commit — - /// this is the everything-immediate model, not a missed "defer to commit" opportunity. `renameParts` - /// is the actual commit point; anything that goes wrong after one of these calls is undone by a - /// COMPENSATING operation over already-committed state, the same way upstream MergeTree's own + /// this is the everything-immediate model, not a missed "defer to commit" opportunity. A part is + /// committed after `finalize` and renamed afterwards; anything that goes wrong after one of these + /// calls is undone by a COMPENSATING operation over already-committed state, the same way upstream MergeTree's own /// `rollbackPartsToTemporaryState` and outdated-part cleanup run over committed disk state rather /// than an in-memory intent log. Recording these as staged intents and applying them at commit would /// duplicate that compensation machinery for no correctness gain. @@ -1054,6 +1100,7 @@ void ContentAddressedTransaction::removeDirectory(const std::string & path) { st->content_removed.clear(); st->entries.clear(); + st->base_manifest_ref.reset(); if (st->build) { st->build->abandon(); @@ -1156,7 +1203,7 @@ void ContentAddressedTransaction::removeRecursive(const std::string & path, cons return; } - /// Table-level SUBDIRECTORY (deduplication_logs/): remove every verbatim file under it. + /// Table-level SUBDIRECTORY (deduplication_logs/, partition_exports/): remove every verbatim file under it. if (auto tf = Cas::parseTableFilePath(path)) { /// The READABLE resolution, which is the non-creating one (`namespaceFilesLifeIfReadable` answers @@ -1361,11 +1408,15 @@ void ContentAddressedTransaction::moveDirectory(const std::string & path_from, c /// destination staging is freshly-created/empty so there is no collision at all; this only /// matters if some future op-order stages the same mutable file under BOTH keys. bool had_staged_source = false; + /// The committed manifest the source staging was based on (a rename of a committed part with + /// staged edits, e.g. a rollback's `delete_tmp_`); the re-key below drops it from the overlay. + std::optional staged_source_base; if (auto src_it = parts.find(src_key); src_it != parts.end()) { had_staged_source = true; PartStaging & dst_st = parts[dst_key]; PartStaging & src_st = src_it->second; + staged_source_base = src_st.base_manifest_ref; for (auto & entry : src_st.entries) { /// A genuine collision (both src and dst independently @@ -1418,10 +1469,9 @@ void ContentAddressedTransaction::moveDirectory(const std::string & path_from, c /// A freshly-written part finalized tmp->final is re-keyed in the /// overlay above (entries/marks/pending blobs/build moved src->dst). The durable publish /// happens only in this transaction's commit (the existing publishStaging loop), not in - /// this method. `MergeTree` calls that commit from `Transaction::renameParts` while off - /// the `data_parts` lock and before the - /// Keeper multi. No early-published ref to compensate on abort within this method - /// (see ~ContentAddressedTransaction). + /// this method. `MergeTree` commits a part right after `finalize` and renames it afterwards, + /// so this re-key serves a rename staged before any commit. No early-published ref to + /// compensate on abort within this method (see ~ContentAddressedTransaction). } if (had_staged_source) @@ -1429,16 +1479,45 @@ void ContentAddressedTransaction::moveDirectory(const std::string & path_from, c /// A nested text-index sub-storage (MergeTask/MutateTask createTemporaryTextIndexStorage) /// may have DURABLY published a committed scratch ref at THIS part's own path holding only /// `/text_index_tmp/` files. That ref is not ours and is not staged; drop it now so the - /// overlay we publish in commit() is the authoritative manifest. Independent of our publish - /// timing (it targets an already-committed foreign ref), so it stays a call-time drop. - metadata_storage.partAccess()->dropRefIfPresent(src->refKey()); + /// overlay we publish in commit() is the authoritative manifest. The same goes for the base of + /// our own staged edits (its content now rides the destination staging). Anything else committed + /// under the staged name is another writer's part: refuse, as `publishStaging` does for the same state. + const auto committed_at_src = metadata_storage.partAccess()->getView(src->refKey(), Cas::Freshness::ForceFresh); + if (staged_source_base && !committed_at_src) + Cas::throwCasOwnershipConflict("ContentAddressedTransaction: {} was dropped while its staged edits were being renamed; " + "the operation must be re-resolved", path_from); + if (committed_at_src) + { + const Cas::ManifestRef committed_ref = committed_at_src->manifestId().ref; + const auto & base_entries = committed_at_src->manifest()->entries; + const bool ours = staged_source_base && *staged_source_base == committed_ref; + if (!ours && !isNestedScratchManifest(base_entries)) + Cas::throwCasOwnershipConflict("ContentAddressedTransaction: {} was published as a part by another writer while a staged part is renamed from it", + path_from); + /// Our staged edits ride on their base: rename the committed ref now (durable at this call, like + /// every committed-part rename) and give the destination staging that manifest as its base, so + /// `publishStaging` merges the delta into it under the new name the way it does for an edit in + /// place; files neither removed nor rewritten survive. The rename is conditional on the manifest + /// the ownership check read. + if (ours && !isNestedScratchManifest(base_entries)) + { + const auto renamed = metadata_storage.partAccess()->renameCommittedRef(src->refKey(), dst->refKey(), committed_ref); + if (!renamed) + Cas::throwCasOwnershipConflict("ContentAddressedTransaction: {} was dropped while a staged part was renamed from it", path_from); + parts[dst_key].base_manifest_ref = *renamed; + return; + } + if (metadata_storage.partAccess()->dropRefExact(src->refKey(), committed_ref) == Cas::Removal::Mismatch) + Cas::throwCasOwnershipConflict("ContentAddressedTransaction: {} was repointed while a staged part was renamed from it", path_from); + } return; } - /// Move any COMMITTED source ref (a merge/mutation result rename, DETACH, ATTACH, a + /// Rename any COMMITTED source ref (a merge/mutation result rename, DETACH, ATTACH, a /// delete_tmp_ rename, an early-committed child ref being renamed away). Absent = a pure - /// staged/tmp move - nothing durable to touch. - metadata_storage.partAccess()->republishRef(src->refKey(), dst->refKey()); + /// staged/tmp move, or a rename of a part whose publish never happened (a rollback's delete_tmp_ + /// rename of an unpublished part) - nothing durable to touch. The facade picks the mechanism. + metadata_storage.partAccess()->renameCommittedRef(src->refKey(), dst->refKey()); return; } @@ -1529,6 +1608,9 @@ void ContentAddressedTransaction::moveFile(const std::string & path_from, const "ContentAddressed: moveFile requires two part-file paths: {} -> {}", path_from, path_to); auto & src_st = stagingFor(*src); + /// The destination part may already be committed (an atomic `.tmp -> final` rename of a mutable + /// file inside it), so it needs its base manifest like any other write to a committed part. A + /// directory re-key in `moveDirectory` fills `parts[dst_key]` directly: its destination is always a new name. auto & dst_st = stagingFor(*dst); /// Staged content entry re-keys in place (cross-part included; dependencies follow the entries). diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedTransaction.h b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedTransaction.h index 60e473ede5831..1d421c2d2f03d 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedTransaction.h +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedTransaction.h @@ -137,6 +137,11 @@ class ContentAddressedTransaction : public IMetadataTransaction /// Created lazily when a staged blob, inline entry, or adopted entry first needs publication. Cas::PartWriteTxnPtr build; std::vector entries; /// staged manifest entries (uploads + adoptions) + /// The committed manifest this transaction found under the part's name when it first staged a + /// write to it; a later commit requires exactly that binding. Empty for a part the transaction + /// creates (`tmp_*`), where a committed row at commit time is somebody else's part, not ours, + /// and for a staged re-key destination, which has no base by design. + std::optional base_manifest_ref; /// Paths removed from a committed part's manifest by this transaction. Publication carries /// forward all other committed entries. If `removeDirectory` drops the whole ref in the same /// transaction, that ref-drop supersedes these marks and clears them. diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Parts/PartFolderAccess.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Parts/PartFolderAccess.cpp index b29fe64b872d1..0886ffd999b8c 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Parts/PartFolderAccess.cpp +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Parts/PartFolderAccess.cpp @@ -1,12 +1,15 @@ +#include #include #include #include #include +#include #include #include #include #include #include +#include #include #include #include @@ -18,6 +21,7 @@ namespace ErrorCodes extern const int FILE_DOESNT_EXIST; extern const int ABORTED; extern const int LOGICAL_ERROR; + extern const int NETWORK_ERROR; } } @@ -30,6 +34,7 @@ namespace ProfileEvents extern const Event CASPartFolderViewInvalidations; extern const Event CASRefRollbackBestEffortDropFailed; extern const Event CASRefRepoint; + extern const Event CASRefMove; } namespace CurrentMetrics @@ -295,8 +300,7 @@ bool CachedPartFolderAccess::existsRef(const PartRefKey & key, Freshness freshne } Cas::CommitOutcome CachedPartFolderAccess::promoteBuild(Cas::PartWriteTxn & build, const PartRefKey & key, UInt128 build_id, - const Cas::ManifestId & manifest_id, bool allow_repoint, - bool * commit_recorded) + const Cas::ManifestId & manifest_id, Cas::PublishPolicy policy, bool * commit_recorded) { /// `build.promote` derives `created` INSIDE its own `appendRefOps` builder (the same in-closure /// pattern as that builder's `repoint_old`) and returns it the instant the append confirms. @@ -310,7 +314,7 @@ Cas::CommitOutcome CachedPartFolderAccess::promoteBuild(Cas::PartWriteTxn & buil /// the build and, one layer up again, reports a byte fallback for a relink that already published. Cas::CommitOutcome outcome{key.ns, key.ref, manifest_id.ref, /*created=*/false}; - const bool created = build.promote(key.ns, key.ref, build_id, manifest_id, allow_repoint); + const bool created = build.promote(key.ns, key.ref, build_id, manifest_id, std::move(policy)); /// POST-DURABLE REGION. Two plain stores, no allocation, no call that can fail. { @@ -333,6 +337,7 @@ Cas::CommitOutcome CachedPartFolderAccess::promoteBuild(Cas::PartWriteTxn & buil namespace { + /// A failed publish must not leak a live-epoch precommit binding: only `abandon` removes it (the build /// destructor merely retires the build seq; the stale-precommit sweep is prior-epoch-scoped and GC /// never touches a live precommit). `abandon` may itself fail on the same broken backend -- log and let @@ -391,7 +396,7 @@ bool PreparedPartWrite::commitIsUnresolved() const return build && build->commitState() == Cas::PartWriteTxn::CommitState::Uncertain; } -Cas::CommitOutcome PreparedPartWrite::promote(bool allow_repoint) +Cas::CommitOutcome PreparedPartWrite::promote(Cas::PublishPolicy policy) { if (terminal) throw Exception(ErrorCodes::LOGICAL_ERROR, @@ -405,7 +410,7 @@ Cas::CommitOutcome PreparedPartWrite::promote(bool allow_repoint) /// the outcome assembly and the cache invalidation that follow the append can throw, and a /// handle that had not yet recorded the commit would then take the catch below and abandon a /// build whose ref is already published. - return owner->promoteBuild(*build, key, build->buildId(), id, allow_repoint, &terminal); + return owner->promoteBuild(*build, key, build->buildId(), id, std::move(policy), &terminal); } catch (...) { @@ -465,47 +470,197 @@ PreparedPartWrite CachedPartFolderAccess::prepareEntries(const PartRefKey & dst, } Cas::CommitOutcome CachedPartFolderAccess::publishEntries(const PartRefKey & dst, - const std::vector & entries, Cas::ProvenanceOp op, bool allow_repoint) + const std::vector & entries, Cas::ProvenanceOp op, Cas::PublishPolicy policy) { /// The atomic form: prepare and promote back to back, with no window in between. `promote` carries /// the catch-abandon-rethrow discipline, so a failure here behaves exactly as it did when this was /// one function. PreparedPartWrite prepared = prepareEntries(dst, entries, op); - return prepared.promote(allow_repoint); + return prepared.promote(std::move(policy)); } bool CachedPartFolderAccess::republishRef(const PartRefKey & src, const PartRefKey & dst) { /// Content addressing has no rename, so move a committed ref by reading the source manifest /// through the pool's manifest cache after a fresh ref resolve, publishing equivalent entries at - /// the destination, and then dropping the source. The source blobs are adopted by evidence of the + /// the destination, and then removing the source. The source blobs are adopted by evidence of the /// live source edge, never re-probed; the decode is never taken from a retained view. auto resolved = store->resolveRef(src.ns, src.ref); if (!resolved) return false; + const Cas::ManifestRef src_manifest_ref = resolved->manifest_id.ref; const auto src_manifest = store->readManifestShared(resolved->manifest_id); + const auto drop_source_or_abort = [&](const std::optional & created_dst) + { + runHookForTest(TestHook::BeforeSourceDrop); + /// The source is removed only while it still names the manifest we republished. + const Removal outcome = dropRefExact(src, src_manifest_ref); + if (outcome == Removal::Removed) + return; + /// Absent: the only writer that removes a part under rename is an identical concurrent rename + /// (a queue re-drive) that saw our byte-equal destination and finished the move; MergeTree holds + /// a part being renamed, so an independent removal cannot race here. Treat it as completed and + /// never compensate — that would delete the other rename's result. + if (outcome == Removal::Gone) + return; + /// Mismatch: another writer repointed the source, so a content-equal destination is stale, + /// whoever created it. Drop it by exact manifest (a destination holding anything else is never + /// touched) and report the conflict. + if (created_dst && created_dst->created) + dropRefIfMatches(dst, created_dst->manifest_ref); + throwCasOwnershipConflict("republishRef: source '{}' was repointed concurrently while being renamed to '{}'; the part must be re-resolved", + src.ns.string() + "/" + src.ref, dst.ns.string() + "/" + dst.ref); + }; + /// If `dst` is already committed, a previous attempt may have completed its promote before the /// source drop. Compare content rather than the whole manifest, whose ref/namespace/digest - /// legitimately differ: equal content completes the move by dropping `src`; different content + /// legitimately differ: equal content completes the move by removing `src`; different content /// is a conflict and leaves the source intact. if (auto dst_resolved = store->resolveRef(dst.ns, dst.ref)) { const auto dst_manifest = store->readManifestShared(dst_resolved->manifest_id); if (dst_manifest->entries != src_manifest->entries) - throw Exception(ErrorCodes::ABORTED, - "republishRef: destination '{}' is already committed with different content — refusing " + throwCasOwnershipConflict("republishRef: destination '{}' is already committed with different content — refusing " + "(rename/attach conflict)", dst.ns.string() + "/" + dst.ref); + drop_source_or_abort(Cas::CommitOutcome{dst.ns, dst.ref, dst_resolved->manifest_id.ref, /*created=*/true}); + return true; + } + + runHookForTest(TestHook::BeforeDestinationPublish); + + std::optional oc; + try + { + oc = publishEntries(dst, src_manifest->entries, Cas::ProvenanceOp::Other); + } + catch (const Exception & e) + { + /// `throwCasWriteRetryLater` raises `NETWORK_ERROR` for many causes, so the code alone is not + /// the filter; the equality check below is. Completion requires `dst` to exist with entries equal + /// to the source manifest, which holds in exactly two cases: our promote landed but its response + /// was lost, or an identical concurrent rename promoted its content-equal manifest first and our + /// promote refused. Absent `dst` rethrows the original exception; `dst` with other content is an + /// ownership conflict, like the pre-publish check. + if (e.code() != ErrorCodes::NETWORK_ERROR) + throw; + const auto dst_resolved = store->resolveRef(dst.ns, dst.ref); + if (!dst_resolved) + throw; + if (store->readManifestShared(dst_resolved->manifest_id)->entries != src_manifest->entries) + throwCasOwnershipConflict("republishRef: destination '{}' is already committed with different content — refusing " "(rename/attach conflict)", dst.ns.string() + "/" + dst.ref); - dropRef(src); + drop_source_or_abort(Cas::CommitOutcome{dst.ns, dst.ref, dst_resolved->manifest_id.ref, /*created=*/true}); return true; } + drop_source_or_abort(oc); + return true; +} - publishEntries(dst, src_manifest->entries, Cas::ProvenanceOp::Other); - dropRef(src); +std::optional CachedPartFolderAccess::renameCommittedRef( + const PartRefKey & src, const PartRefKey & dst, const std::optional & expected) +{ + const auto resolved = resolve(src, Freshness::ForceFresh); + if (!resolved) + return std::nullopt; + if (expected && !(resolved->manifest_id.ref == *expected)) + Cas::throwCasOwnershipConflict("renameCommittedRef: source '{}' names another manifest than the one this rename read; " + "the part must be re-resolved", src.ns.string() + "/" + src.ref); + /// A writable mount always admits owner moves (`Pool::open` raises the reader floor first), so inside + /// one namespace the only reason to republish is a committed destination: an earlier attempt published + /// it by republication and crashed before dropping the source. `republishRef` completes that re-drive + /// when the content is equal and conflicts when it differs. + if (src.ns.string() == dst.ns.string() && !existsRef(dst, Freshness::ForceFresh)) + { + moveRefExact(src, dst, resolved->manifest_id.ref); + return resolved->manifest_id.ref; /// a Move keeps the manifest + } + if (!republishRef(src, dst)) + return std::nullopt; + const auto renamed = resolve(dst, Freshness::ForceFresh); + if (!renamed) + return std::nullopt; + return renamed->manifest_id.ref; +} + +bool CachedPartFolderAccess::moveRefExact(const PartRefKey & src, const PartRefKey & dst, const Cas::ManifestRef & expected) +{ + /// `renameCommittedRef` routes a cross-namespace rename to `republishRef`, and a writable mount always + /// admits owner moves; both checks stay in release builds because a wrong Move is a format-level write. + if (src.ns.string() != dst.ns.string()) + throw Exception(ErrorCodes::LOGICAL_ERROR, "moveRefExact: '{}' and '{}' are in different namespaces", src.ns.string(), dst.ns.string()); + if (!store->refMoveAllowed()) + throw Exception(ErrorCodes::LOGICAL_ERROR, "moveRefExact: owner move requested while the pool floor forbids it"); + runHookForTest(TestHook::BeforeMoveAppend); + bool moved = false; + /// Erased on every exit: an append that throws after landing must not leave a stale `src` view. + /// The guard allocates, so a memory-limit exception inside it must not terminate. + SCOPE_EXIT_MEMORY_SAFE({ + eraseView(src); + eraseView(dst); + }); + /// Two names in one operation: the scope names both so unrelated refs may still co-batch and be confirmed. + /// No completion check after a retry-later failure: the ledger's own resolve-before-reissue read already + /// proves a landed write that is readable, and an unreadable one wedges the lane until a later append + /// settles it — nothing this call could read would say more than the ledger did. + const RefTxnId txn_id = store->appendRefOps(src.ns, MutationScope::refs(src.ref, dst.ref), + [&](const RefTableState & state) -> std::vector + { + moved = false; + const auto & committed = state.getCommitted(); + const auto s = committed.find(src.ref); + const auto d = committed.find(dst.ref); + if (s == committed.end() && d != committed.end() && d->second.manifest_ref == expected) + return {}; /// already moved by an earlier attempt + if (d != committed.end()) + { + /// One manifest has one committed owner (`owned_manifests`); two rows naming `expected` is a broken table. + if (s != committed.end() && s->second.manifest_ref == expected && d->second.manifest_ref == expected) + throw Exception(ErrorCodes::CORRUPTED_DATA, + "moveRefExact: '{}' and '{}' both name manifest {}", src.ns.string() + "/" + src.ref, + dst.ns.string() + "/" + dst.ref, Cas::manifestRefDebugString(expected)); + throwCasOwnershipConflict("moveRefExact: destination '{}' is already committed with different content (rename/attach conflict)", + dst.ns.string() + "/" + dst.ref); + } + /// Precommits are keyed by (ref_name, manifest_ref): any live precommit under the destination + /// name is another build about to claim it, an ordinary race and not corruption. + if (state.hasLivePrecommitFor(dst.ref)) + throwCasOwnershipConflict("moveRefExact: destination '{}' has a live precommit", dst.ns.string() + "/" + dst.ref); + if (s == committed.end() || !(s->second.manifest_ref == expected)) + throwCasOwnershipConflict("moveRefExact: source '{}' changed concurrently ({}); the part must be re-resolved", + src.ns.string() + "/" + src.ref, s == committed.end() ? "it is gone" : "it names another manifest now"); + moved = true; + RefOp op; + op.kind = RefOpKind::OwnerTransition; + op.old_binding = RefOwnerBinding{RefOwnerKind::Committed, src.ref, expected}; + op.new_binding = RefOwnerBinding{RefOwnerKind::Committed, dst.ref, expected}; + /// The part's modification time follows the rename, as on a local disk, so detached/moving + /// aging and temporary-directory cleanup see the rename time. + return {op, makeSetPublishedAtOp(dst.ref, expected, Cas::nowMs())}; + }, + RootMutationOrigin::Writer, RootMutationKind::Move); + if (!moved) + return false; + ProfileEvents::increment(ProfileEvents::CASRefMove); + emitAuditBestEffort("moveRefExact: audit event failed after the move landed", [&] + { + Cas::CasEvent ev; + ev.type = Cas::CasEventType::RefMove; + ev.namespace_ = src.ns.string(); + ev.ref_name = src.ref; + ev.object_kind = Cas::CasEventObjectKind::Manifest; + ev.object_hash = Cas::manifestRefDebugString(expected); + ev.at_version = txn_id.ref_sequence; + ev.outcome = "ok"; + ev.reason = "committed part renamed by one owner move; the manifest is unchanged"; + ev.detail["dst_ref"] = dst.ref; + return ev; + }); return true; } -Cas::CommitOutcome CachedPartFolderAccess::repointRef(const PartRefKey & key, std::vector entries, Cas::ProvenanceOp op) +Cas::CommitOutcome CachedPartFolderAccess::repointRef(const PartRefKey & key, std::vector entries, Cas::ProvenanceOp op, + const Cas::ManifestRef & expected) { /// Compare the candidate `entries` against the currently committed manifest's /// decoded entries. This must NOT stage a candidate manifest first: `stageManifest` mints a @@ -525,41 +680,28 @@ Cas::CommitOutcome CachedPartFolderAccess::repointRef(const PartRefKey & key, st /// round-trip before comparing regardless — this is the same content comparison used by `republishRef`, /// which is symmetric for the same reason (both sides there are already decoded). auto resolved = resolve(key, Freshness::ForceFresh); - if (resolved) - { - const auto committed_manifest = store->readManifestShared(resolved->manifest_id); - Cas::PartManifest probe; - probe.ref = committed_manifest->ref; - probe.root_namespace_id = committed_manifest->root_namespace_id; - probe.entries = entries; - probe.payload_digest = Cas::computePayloadDigest(probe); - const Cas::PartManifest canonical_candidate = Cas::decodePartManifest(Cas::encodePartManifest(probe)); - if (committed_manifest->entries == canonical_candidate.entries) - /// ZERO pool mutations: the outcome describes the manifest ALREADY committed, unchanged. - return Cas::CommitOutcome{key.ns, key.ref, resolved->manifest_id.ref, /*created=*/false}; - } + if (!resolved || !(resolved->manifest_id.ref == expected)) + throwCasOwnershipConflict("committed part '{}/{}' moved or dropped while its rewrite was staged", key.ns.string(), key.ref); + const auto committed_manifest = store->readManifestShared(resolved->manifest_id); + Cas::PartManifest probe; + probe.ref = committed_manifest->ref; + probe.root_namespace_id = committed_manifest->root_namespace_id; + probe.entries = entries; + probe.payload_digest = Cas::computePayloadDigest(probe); + const Cas::PartManifest canonical_candidate = Cas::decodePartManifest(Cas::encodePartManifest(probe)); + if (committed_manifest->entries == canonical_candidate.entries) + /// ZERO pool mutations: the outcome describes the manifest ALREADY committed, unchanged. + return Cas::CommitOutcome{key.ns, key.ref, resolved->manifest_id.ref, /*created=*/false}; /// `publishEntries` takes `entries` by const reference, so the caller-owned vector remains valid. /// Capture the exact outcome IMMEDIATELY -- before the ProfileEvent/logging below -- so it is /// published ahead of any further (even if non-throwing in practice) post-commit work. - const Cas::CommitOutcome oc = publishEntries(key, entries, op, /*allow_repoint=*/true); + const Cas::CommitOutcome oc = publishEntries(key, entries, op, Cas::PublishPolicy::replaceIfCurrent(expected)); ProfileEvents::increment(ProfileEvents::CASRefRepoint); - if (resolved) - { - /// Repoint is the normal mechanism for effective standalone writes/removes on committed parts, - /// so this routine event is logged at debug level while the counter remains an operator-facing signal. - LOG_DEBUG(getLogger("CachedPartFolderAccess"), - "Repointed committed ref {}/{} ({} entries) — standalone write/remove on a committed part", - key.ns.string(), key.ref, entries.size()); - } - else - { - /// A repoint normally targets an existing ref. Keep an unexpected create-shaped call visible - /// at warning level rather than silently treating it as ordinary publication. - LOG_WARNING(getLogger("CachedPartFolderAccess"), - "repointRef published {}/{} ({} entries) with no prior committed ref to repoint — " - "unexpected call shape (repointRef requires an existing committed ref)", - key.ns.string(), key.ref, entries.size()); - } + /// Repoint is the normal mechanism for effective standalone writes/removes on committed parts, + /// so this routine event is logged at debug level while the counter remains an operator-facing signal. + LOG_DEBUG(getLogger("CachedPartFolderAccess"), + "Repointed committed ref {}/{} ({} entries) — standalone write/remove on a committed part", + key.ns.string(), key.ref, entries.size()); return oc; } @@ -605,6 +747,7 @@ void CachedPartFolderAccess::dropRefBestEffort(const PartRefKey & key) noexcept { /// Best-effort destructor/rollback cleanup: debris is GC-reclaimed, but swallowing the /// exception without a diagnostic could leave a live phantom ref after a backend outage. + LockMemoryExceptionInThread lock_memory_tracker(VariableContext::Global); ProfileEvents::increment(ProfileEvents::CASRefRollbackBestEffortDropFailed); tryLogCurrentException(getLogger("CachedPartFolderAccess"), fmt::format("CA best-effort rollback dropRef failed (ns={} ref={}); the ref may remain live", @@ -617,38 +760,57 @@ void CachedPartFolderAccess::dropRefBestEffort(const PartRefKey & key) noexcept bool CachedPartFolderAccess::dropRefIfMatches(const PartRefKey & key, const Cas::ManifestRef & expected) noexcept { - /// One `appendRefOps` builder does both the read and the conditional removal, mirroring - /// `CasRefLedger::dropRef`'s own protocol shape (read the committed binding, emit ONE - /// `OwnerTransition` removal op) but with the removal guarded on `expected` inside the SAME - /// closure -- the leader-thread read of `state.getCommitted()` is the authoritative committed - /// binding at append time, so this is race-free the same way `PartWriteTxn::promote`'s - /// `repoint_old`/idempotent-guard reads are: `build_ops` runs at most once, on the flush leader, - /// against the batch-validated state. A mismatch (repointed since `expected` was observed, or - /// already absent) returns an empty op list -- a legitimate no-op, not an error, exactly like - /// `promote`'s own idempotent-redrive branch. - bool removed = false; try { - const RefTxnId txn_id = store->appendRefOps(key.ns, MutationScope::ref(key.ref), - [&](const RefTableState & state) -> std::vector + return dropRefExact(key, expected) == Removal::Removed; + } + catch (...) + { + /// Best-effort rollback cleanup, like dropRefBestEffort: debris is GC-reclaimed, but swallowing + /// without a diagnostic could leave a live phantom ref after a backend outage. A rollback often + /// runs because of a memory limit, and the diagnostic below allocates. + LockMemoryExceptionInThread lock_memory_tracker(VariableContext::Global); + ProfileEvents::increment(ProfileEvents::CASRefRollbackBestEffortDropFailed); + tryLogCurrentException(getLogger("CachedPartFolderAccess"), + fmt::format("CA conditional rollback dropRefIfMatches failed (ns={} ref={} expected={}); " + "the ref may remain live", key.ns.string(), key.ref, Cas::manifestRefDebugString(expected))); + return false; + } +} + +Removal CachedPartFolderAccess::dropRefExact(const PartRefKey & key, const Cas::ManifestRef & expected) +{ + /// One `appendRefOps` builder does both the read and the conditional removal, mirroring + /// `CasRefLedger::dropRef`'s protocol shape (read the committed binding, emit ONE `OwnerTransition` + /// removal op) with the removal guarded on `expected` inside the SAME closure: the leader-thread read + /// of `state.getCommitted()` is the authoritative committed binding at append time, so a concurrent + /// repoint cannot slip between the check and the op. + Removal outcome = Removal::Gone; + /// Erased on every exit: an append that throws after landing must not leave a stale view. + SCOPE_EXIT_MEMORY_SAFE(eraseView(key)); + const RefTxnId txn_id = store->appendRefOps(key.ns, MutationScope::ref(key.ref), + [&](const RefTableState & state) -> std::vector + { + const auto it = state.getCommitted().find(key.ref); + if (it == state.getCommitted().end()) { - const auto it = state.getCommitted().find(key.ref); - if (it == state.getCommitted().end() || !(it->second.manifest_ref == expected)) - return {}; /// absent, or repointed away from `expected` -- leave it alone - - removed = true; - RefOp op; - op.kind = RefOpKind::OwnerTransition; - op.old_binding = RefOwnerBinding{RefOwnerKind::Committed, key.ref, expected}; - return {op}; - }, - RootMutationOrigin::Writer, RootMutationKind::Drop); - - /// Audit a successful conditional removal exactly like `CasRefLedger::dropRef` audits its own - /// unconditional one -- otherwise a rollback drop (this method's only caller, as of Task 3) - /// would be invisible in `system.cas_log`. Byte-neutral to the ref-log: this is - /// an audit event only, emitted after the removal is already durable. - if (removed && store->hasEventSink()) + outcome = Removal::Gone; + return {}; + } + if (!(it->second.manifest_ref == expected)) + { + outcome = Removal::Mismatch; + return {}; + } + outcome = Removal::Removed; + RefOp op; + op.kind = RefOpKind::OwnerTransition; + op.old_binding = RefOwnerBinding{RefOwnerKind::Committed, key.ref, expected}; + return {op}; + }, + RootMutationOrigin::Writer, RootMutationKind::Drop); + if (outcome == Removal::Removed) + emitAuditBestEffort("dropRefExact: audit event failed after the drop landed", [&] { Cas::CasEvent ev; ev.type = Cas::CasEventType::RefDrop; @@ -658,24 +820,10 @@ bool CachedPartFolderAccess::dropRefIfMatches(const PartRefKey & key, const Cas: ev.object_hash = Cas::manifestRefDebugString(expected); ev.at_version = txn_id.ref_sequence; ev.outcome = "ok"; - ev.reason = "dropRefIfMatches: conditional rollback removed the exact manifest this caller committed"; - store->emitEvent(std::move(ev)); - } - } - catch (...) - { - /// Best-effort rollback cleanup, like dropRefBestEffort: debris is GC-reclaimed, but swallowing - /// without a diagnostic could leave a live phantom ref after a backend outage. - removed = false; - ProfileEvents::increment(ProfileEvents::CASRefRollbackBestEffortDropFailed); - tryLogCurrentException(getLogger("CachedPartFolderAccess"), - fmt::format("CA conditional rollback dropRefIfMatches failed (ns={} ref={} expected={}); " - "the ref may remain live", key.ns.string(), key.ref, Cas::manifestRefDebugString(expected))); - } - /// The read above is authoritative fresh state regardless of outcome, so any locally retained view - /// is invalidated unconditionally -- cheap and conservative, matching dropRefBestEffort. - eraseView(key); - return removed; + ev.reason = "dropRefExact: committed ref removed while it still named the expected manifest"; + return ev; + }); + return outcome; } void CachedPartFolderAccess::dropNamespace(const Cas::RootNamespace & ns) diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Parts/PartFolderAccess.h b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Parts/PartFolderAccess.h index a9f4f3c40665b..f0c10c7e6223f 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Parts/PartFolderAccess.h +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Parts/PartFolderAccess.h @@ -3,8 +3,12 @@ #include #include #include +#include +#include +#include #include #include +#include #include #include #include @@ -167,7 +171,7 @@ class PreparedPartWrite /// can throw. On a failure that is PROVEN to have committed nothing the build is abandoned (the /// catch-abandon-rethrow discipline the atomic `publishEntries` has always applied); the original /// error propagates in either case. - CommitOutcome promote(bool allow_repoint = false); + CommitOutcome promote(PublishPolicy policy = {}); /// Durably abandons the write: appends the EXACT precommit removal so the manifest's `+1` is /// released. Propagates an append failure -- the caller may retry, and the destructor is the /// backstop if it does not. @@ -250,7 +254,7 @@ class CachedPartFolderAccess /// direction). Committed-ref mutations anywhere else in wiring are style-check failures. /// Completes a staged transaction with the atomic owner move and invalidates the affected view. - /// `allow_repoint` permits replacing a committed ref that names a different manifest. The returned + /// `policy` says how a committed ref under the same name is treated (see `PublishPolicy`). The returned /// `CommitOutcome` is exact: `created` is derived INSIDE `PartWriteTxn::promote`'s `appendRefOps` /// builder (the same in-closure-output pattern as that builder's own `repoint_old`), not read back /// afterward. @@ -262,7 +266,7 @@ class CachedPartFolderAccess /// between; without it an allocation failure in the post-commit work lands in the caller's /// failed-promote handler with the ref already published. CommitOutcome promoteBuild(Cas::PartWriteTxn & build, const PartRefKey & key, UInt128 build_id, - const Cas::ManifestId & manifest_id, bool allow_repoint = false, + const Cas::ManifestId & manifest_id, PublishPolicy policy = {}, bool * commit_recorded = nullptr); /// The first half of the committed-publish sequence: adopt evidence over `entries`, stage a fresh /// manifest, and precommit it -- then STOP. The receiver's `+1` is durable, the promote is deferred @@ -280,20 +284,38 @@ class CachedPartFolderAccess /// Implemented as `prepareEntries` immediately followed by `promote`, so the atomic callers and the /// confirm-interposed relink path share one protocol sequence. CommitOutcome publishEntries(const PartRefKey & dst, const std::vector & entries, - Cas::ProvenanceOp op, bool allow_repoint = false); - /// Moves a committed ref by publishing the source entries at `dst` and then dropping `src`. - /// Returns false when the source is absent; a pre-existing destination with different content - /// is rejected rather than silently discarding the source. + Cas::ProvenanceOp op, PublishPolicy policy = {}); + /// Moves a committed ref by publishing the source entries at `dst` and then dropping `src` only + /// while it still names the republished manifest. Returns false when the source is absent; a + /// pre-existing destination with different content is rejected rather than silently discarding the + /// source. Throws `CAS_OWNERSHIP_CONFLICT` (after taking back a destination this call created) when + /// the source was repointed meanwhile. bool republishRef(const PartRefKey & src, const PartRefKey & dst); - /// Republishes an already committed part with `entries` (for a standalone write or removal): - /// republishes `key`'s manifest with `entries`. Byte-equal candidate (same decoded entries as the - /// currently committed manifest) is a ZERO-pool-mutation no-op, returns false. Otherwise republishes - /// a byte-equal candidate returns false without pool mutation; an effective repoint publishes - /// through `publishEntries(allow_repoint=true)`, emits the repoint audit signals, invalidates the - /// cached view, and returns the exact `CommitOutcome` (`created` is false on both the byte-equal - /// no-op path -- `manifest_ref` names the manifest ALREADY committed, unchanged -- and on an - /// effective repoint of an existing ref). `key` must already resolve. - CommitOutcome repointRef(const PartRefKey & key, std::vector entries, Cas::ProvenanceOp op); + /// Rename of a committed ref inside the table namespace without republishing content: one ref-log + /// append (`OwnerTransitionShape::Move`). `expected` is the manifest the caller resolved for `src`; + /// any other current binding is a concurrent writer and aborts with `CAS_OWNERSHIP_CONFLICT` (never + /// falls back to dropping it). Not `ABORTED` on purpose: the replication queue logs `ABORTED` at INFO as + /// a cancelled task and does not record it on the entry; a conflict is recorded and retried. Returns + /// false when the move is already complete (src absent, dst bound to `expected`), so a retried rename + /// is idempotent. + bool moveRefExact(const PartRefKey & src, const PartRefKey & dst, const Cas::ManifestRef & expected); + /// Renames a committed ref, choosing the mechanism: one owner `Move` inside a namespace when the + /// destination is free, `republishRef` otherwise (another namespace, or a destination an earlier + /// attempt already published). `expected`, when set, is the manifest the caller read for `src`; any + /// other current binding is a concurrent writer and throws `CAS_OWNERSHIP_CONFLICT`. Returns the + /// manifest now bound to `dst`, or nullopt when `src` was absent and nothing was renamed. + std::optional renameCommittedRef( + const PartRefKey & src, const PartRefKey & dst, const std::optional & expected = std::nullopt); + /// Republishes an already committed part with `entries` (a standalone write or removal on it). + /// `expected` is the manifest the caller resolved before staging: the repoint throws + /// `CAS_OWNERSHIP_CONFLICT` unless the committed ref still names exactly it, and never creates the ref. + /// A byte-equal candidate (same decoded entries as the committed manifest) is a ZERO-pool-mutation + /// no-op. Otherwise it publishes through `publishEntries` with `PublishPolicy::replaceIfCurrent`, + /// emits the repoint audit signals and invalidates the cached view. Returns the exact `CommitOutcome`; + /// `created` is false on both paths, and on the no-op path `manifest_ref` names the manifest already + /// committed, unchanged. + CommitOutcome repointRef(const PartRefKey & key, std::vector entries, Cas::ProvenanceOp op, + const Cas::ManifestRef & expected); /// Drops a committed ref and invalidates its retained view after the drop succeeds. void dropRef(const PartRefKey & key); /// Idempotent removal: absent ref is success; a drop racing between resolve and the shard @@ -302,15 +324,21 @@ class CachedPartFolderAccess /// Best-effort destructor/rollback cleanup: never throws, logs failure, and relies on GC to reclaim /// lingering debris. The view is invalidated even when the durable ref state becomes unknown. void dropRefBestEffort(const PartRefKey & key) noexcept; - /// Conditional rollback drop: removes `key`'s committed ref ONLY if its CURRENT committed manifest - /// binding equals `expected` (typically the `manifest_ref` from a `CommitOutcome` this caller - /// itself just produced). A ref repointed by someone else since then -- or already absent -- is - /// left untouched; the caller's stale rollback attempt must never clobber a newer commit. Reads - /// the committed binding and emits the removal op inside ONE `appendRefOps` builder (mirrors - /// `dropRef`'s underlying protocol call, guarded by the equality check inside the same closure). - /// `noexcept`: best-effort rollback context, like `dropRefBestEffort` -- swallows and logs a - /// failure rather than propagating it. Returns whether it actually removed the ref. + /// Conditional rollback drop: `dropRefExact` for a best-effort context. Removes `key`'s committed ref + /// ONLY if it still names `expected` (typically the `manifest_ref` of a `CommitOutcome` this caller + /// itself just produced); a ref repointed by someone else since then -- or already absent -- is left + /// untouched, the stale rollback must never clobber a newer commit. `noexcept` like `dropRefBestEffort`: + /// a failure is logged and counted, not propagated. Returns whether it actually removed the ref. bool dropRefIfMatches(const PartRefKey & key, const ManifestRef & expected) noexcept; + /// Foreground exact removal: removes `key` only while it still names `expected` and reports what it + /// found (`Removed`, `Gone`, `Mismatch`) instead of folding every outcome into a bool. Backend failures + /// propagate — a rename decides between success and a conflict on this, so nothing is swallowed here. + Removal dropRefExact(const PartRefKey & key, const Cas::ManifestRef & expected); + /// Test seams inside a rename: each runs once right before the named step, so a test can inject a + /// racing writer there. `republishRef` publishes the destination, then drops the source; `moveRefExact` + /// appends the Move. + enum class TestHook : uint8_t { BeforeDestinationPublish, BeforeSourceDrop, BeforeMoveAppend, Count }; + void setHookForTest(TestHook hook, std::function fn) { hooks_for_test[static_cast(hook)] = std::move(fn); } /// Drops all refs in a namespace and removes every retained view belonging to that namespace. void dropNamespace(const Cas::RootNamespace & ns); @@ -360,6 +388,29 @@ class CachedPartFolderAccess const PartRefKey & key, const Cas::Resolved & resolved, Freshness freshness) const; /// Removes a retained view and records the invalidation for diagnostics. void eraseView(const PartRefKey & key); + std::array, static_cast(TestHook::Count)> hooks_for_test; + void runHookForTest(TestHook hook) const + { + if (const auto & fn = hooks_for_test[static_cast(hook)]) + fn(); + } + /// Emits the event `build` returns when a sink is installed. It runs after the durable mutation it + /// describes, so neither building nor emitting may fail the caller: both happen inside the guard, and + /// the handler locks the memory tracker before it logs. + template + void emitAuditBestEffort(const char * failure_message, Build && build) const noexcept + { + try + { + if (store->hasEventSink()) + store->emitEvent(build()); + } + catch (...) + { + LockMemoryExceptionInThread lock_memory_tracker(VariableContext::Global); + tryLogCurrentException(getLogger("CachedPartFolderAccess"), failure_message); + } + } /// Emits the same `RefResolve` `CasEvent` `CasRefLedger::resolveRef` would have emitted for /// `resolved`, a no-op when no sink is installed. Used by `getView`, which defers the emit from its /// own `resolve(..., ResolveAudit::Deferred)` call so it can skip it on a warm view-cache hit that diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Pool/CasPartWriteTxn.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Pool/CasPartWriteTxn.cpp index 516debe79ef7b..51ff507c141f4 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Pool/CasPartWriteTxn.cpp +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Pool/CasPartWriteTxn.cpp @@ -1,3 +1,4 @@ +#include #include #include #include @@ -66,12 +67,6 @@ UInt128 mintU128() return (static_cast(hi) << 64) | lo; } -uint64_t nowMs() -{ - return static_cast(std::chrono::duration_cast( - std::chrono::system_clock::now().time_since_epoch()).count()); -} - bool isDeterministicBlobPublicationFailure(const std::exception & error) { /// A refused write, EXCEPT the class a fresh credential fixes: the engine refreshes once before it @@ -311,7 +306,7 @@ BlobUploadResult PartWriteTxn::ensureBlobPresent(const BlobUploadRequest & req) header.kind = ObjectKind::Blob; header.incarnation_tag = mintU128(); header.build_id = build_id; - header.provenance = Provenance{nowMs(), pool_config.server_id, VERSION_INTEGER, info.op}; + header.provenance = Provenance{Cas::nowMs(), pool_config.server_id, VERSION_INTEGER, info.op}; header.intended_ref = info.intended_ref; return encodeEnvelopeHeader(header, static_cast(pool_meta.blob_header_len)); }; @@ -812,10 +807,10 @@ void PartWriteTxn::precommitAdd(const RootNamespace & target_ns, const String & }); } -bool PartWriteTxn::promote(const RootNamespace & target_ns, const String & final_ref_name, UInt128 promote_build_id, const ManifestId & id, bool allow_repoint) +bool PartWriteTxn::promote(const RootNamespace & target_ns, const String & final_ref_name, UInt128 promote_build_id, const ManifestId & id, + PublishPolicy policy) { requireAlive(); - if (id.root_namespace != target_ns) throw Exception(ErrorCodes::LOGICAL_ERROR, "promote: manifest namespace '{}' != target namespace '{}'", @@ -840,7 +835,7 @@ bool PartWriteTxn::promote(const RootNamespace & target_ns, const String & final /// The intended-repoint operation is an atomic composition of `WDropRef` and `WPromote`: the /// manifest the ref currently commits, - /// when this promote is retiring it via an intended repoint (`allow_repoint`) rather than the + /// when this promote is retiring it via an intended repoint (`PublishPolicy::replaceIfCurrent`) rather than the /// ordinary first-time-commit path. Set inside the closure below; read after it returns to decide /// whether the `RefRepoint` audit event fires. std::optional repoint_old; @@ -865,7 +860,7 @@ bool PartWriteTxn::promote(const RootNamespace & target_ns, const String & final /// keep `this`), and `repoint_old`/`created` are OUTPUTS read after the call returns (so they stay /// references). The ref-lane leadership guard is the real fix; both residual by-reference captures /// are safe because the guard guarantees the closure is never invoked after this frame unwinds. - [this, final_ref_name, id, promote_build_id, allow_repoint, body, &repoint_old, &created] + [this, final_ref_name, id, promote_build_id, policy, body, &repoint_old, &created] (const RefTableState & state) -> std::vector { created = !state.getCommitted().contains(final_ref_name); @@ -879,6 +874,15 @@ bool PartWriteTxn::promote(const RootNamespace & target_ns, const String & final it != state.getCommitted().end() && it->second.manifest_ref == id.ref) return {}; + if (policy.expected) + { + const auto cur = state.getCommitted().find(final_ref_name); + if (cur == state.getCommitted().end() || !(cur->second.manifest_ref == *policy.expected)) + throwCasOwnershipConflict("promote: committed ref '{}' no longer names the manifest this repoint read ({}); it was moved, " + "dropped or repointed concurrently — re-resolve the part before retrying", + final_ref_name, manifestRefDebugString(*policy.expected)); + } + /// NO writer-side view refresh here: /// Gate A): promote-time view freshness is not load-bearing — `Materialized` leaves are /// edge-protected (EDGE-BEFORE-OBSERVE), while `TrustedManifest` relies on the live source edge. @@ -951,24 +955,22 @@ bool PartWriteTxn::promote(const RootNamespace & target_ns, const String & final store->layout().blobKey(e.ref)); } - /// BUG 1a: refuse to overwrite a live committed ref that already names a DIFFERENT manifest — - /// that would orphan the old manifest (its owner-removal `-1` is never emitted) UNLESS the - /// caller opted into an intended repoint (`allow_repoint`, modeled as the `WDropRef`+`WPromote` - /// composition) -- a standalone - /// write/remove on an already-committed part. Without the flag - /// this enforces the model's `RefFreeFor` guard (`WPromote` requires it) exactly as before. A - /// re-promote of the SAME manifest_ref is idempotent and allowed regardless (the state - /// machine's own promote precondition below would reject it as "precommit absent" anyway once - /// idempotent republish skips this call — see `republishRef`). Fail-closed with ABORTED (not - /// LOGICAL_ERROR): a conflicting durable state the caller handles, never a must-not-happen - /// invariant. + /// Refuse to overwrite a live committed ref that names a DIFFERENT manifest: that would orphan the + /// old manifest (its owner-removal `-1` is never emitted). `PublishPolicy::create` keeps the + /// model's `RefFreeFor` guard (`WPromote` requires it); `replaceIfCurrent` opts into an intended + /// repoint (a standalone write/remove on an already-committed part), modeled as the + /// `WDropRef`+`WPromote` composition. A re-promote of the SAME manifest_ref is idempotent and allowed + /// regardless (the state machine's own promote precondition below would reject it as "precommit + /// absent" anyway once idempotent republish skips this call — see `republishRef`). The refusal is + /// write-retry-later, not LOGICAL_ERROR: a conflicting durable state the caller handles, never a + /// must-not-happen invariant. if (const auto it = state.getCommitted().find(final_ref_name); it != state.getCommitted().end() && !(it->second.manifest_ref == id.ref)) { - if (!allow_repoint) + if (!policy.expected) throwCasWriteRetryLater(fmt::format( "promote: ref '{}' already names a different committed manifest — refusing to overwrite " - "(unique-ref invariant; use republishRef for an intended repoint)", final_ref_name)); + "(unique-ref invariant; an intended repoint goes through repointRef)", final_ref_name)); repoint_old = it->second.manifest_ref; } @@ -1003,12 +1005,7 @@ bool PartWriteTxn::promote(const RootNamespace & target_ns, const String & final transition.new_binding = RefOwnerBinding{RefOwnerKind::Committed, final_ref_name, id.ref}; ops.push_back(transition); - RefOp set_published_at; - set_published_at.kind = RefOpKind::SetPublishedAt; - set_published_at.ref_name = final_ref_name; - set_published_at.expected_manifest_ref = id.ref; - set_published_at.published_at_ms = nowMs(); - ops.push_back(set_published_at); + ops.push_back(makeSetPublishedAtOp(final_ref_name, id.ref, Cas::nowMs())); return ops; }, RootMutationOrigin::Writer, RootMutationKind::Promote); @@ -1041,7 +1038,7 @@ bool PartWriteTxn::promote(const RootNamespace & target_ns, const String & final /// owned by `CachedPartFolderAccess::repointRef`, /// the user-facing primitive that calls into this promote -- adding them here too would double-fire /// once the caller-level instrumentation is installed. This event alone is per-call-site accurate regardless of which caller opted - /// into `allow_repoint`. + /// into a replacing `PublishPolicy`. if (repoint_old) { try @@ -1054,7 +1051,7 @@ bool PartWriteTxn::promote(const RootNamespace & target_ns, const String & final e.object_kind = CasEventObjectKind::Manifest; e.object_hash = manifestRefDebugString(id.ref); e.outcome = "repointed"; - e.reason = "promote(allow_repoint=true): committed ref retargeted from an old manifest to a " + e.reason = "promote: committed ref retargeted from an old manifest to a " "new one in one ref-log record -- standalone write/remove on a committed part"; e.detail = {{"old_manifest", manifestRefDebugString(*repoint_old)}}; }); diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Pool/CasPartWriteTxn.h b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Pool/CasPartWriteTxn.h index 953059a76ebc3..9cd10ed46e055 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Pool/CasPartWriteTxn.h +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Pool/CasPartWriteTxn.h @@ -254,14 +254,10 @@ class PartWriteTxn /// hazard — "promote a precommit whose copy did not complete" — has no code /// path to occur through: promotion is not a recoverable/resumable operation, only a synchronous /// one that either completes within the writing process or never happens at all. - /// `allow_repoint` opts into an intended - /// repoint of a committed ref that already names a DIFFERENT manifest -- a standalone write/remove - /// on an already-committed part (the committed-publish machinery's missing piece; the promote guard's - /// own error text already named it: "use republishRef for an intended repoint"). Default `false` - /// preserves the existing unique-ref guard byte-for-byte: a committed ref naming a different manifest - /// still throws ABORTED. With `true`, the guard is skipped and the old committed binding is retired - /// in the SAME ref-log record as the ordinary precommit->committed promotion, plus a - /// `CasEventType::RefRepoint` audit event -- every effective repoint is loud by construction. + /// `policy` (see `PublishPolicy`) says how a committed ref that already names a DIFFERENT manifest is + /// treated. `create()` keeps the unique-ref guard: the promote is refused. `replaceIfCurrent(m)` retires + /// the old committed binding in the SAME ref-log record as the ordinary precommit->committed promotion, + /// plus a `CasEventType::RefRepoint` audit event -- every effective repoint is loud by construction. /// /// Returns `created`: whether `final_ref_name` had NO committed row before this call. Derived /// INSIDE the `appendRefOps` builder (the same in-closure-output pattern the builder already uses @@ -270,7 +266,14 @@ class PartWriteTxn /// (true), a repoint of an existing binding (false), and the idempotent re-promote no-op (false, /// since a committed row for this exact manifest already existed). `build_ops` runs at most once, /// on the flush leader, so this single evaluation is authoritative. - bool promote(const RootNamespace & target_ns, const String & final_ref_name, UInt128 build_id, const ManifestId & id, bool allow_repoint = false); + /// + /// `replaceIfCurrent` carries the committed manifest the repointing caller resolved before staging: + /// the promote fails closed (`CAS_OWNERSHIP_CONFLICT`) unless the committed row still names exactly that + /// manifest -- an absent row means the ref was moved or dropped under the writer, a different manifest + /// means a concurrent repoint won. Either way the stale writer must re-resolve instead of recreating or + /// overwriting the binding. + bool promote(const RootNamespace & target_ns, const String & final_ref_name, UInt128 build_id, const ManifestId & id, + PublishPolicy policy = {}); /// Retire the build sequence so the GC watermark floor can advance; staged manifest debris is best-effort /// cleaned, with the orphan sweep as the durable backstop. diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Primitives/CasTypes.h b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Primitives/CasTypes.h index 50c56d280ddfe..b0449e3073f9b 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Primitives/CasTypes.h +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Primitives/CasTypes.h @@ -128,6 +128,27 @@ struct ManifestRef } }; +/// How a promote treats a ref that is already committed under the destination name. +/// create(): absent -> bind; the same manifest -> idempotent success; another manifest -> +/// refused (write-retry-later: the unique-ref invariant). +/// replaceIfCurrent(m): the ref must still name `m` (absent or another manifest -> CAS_OWNERSHIP_CONFLICT); +/// then it is repointed to the new manifest in the same ref-log record. +/// A staged manifest is always fresh, so "the same manifest" means the same ManifestRef, not the same +/// bytes; the byte-equal no-op is `CachedPartFolderAccess::repointRef`'s job. +struct PublishPolicy +{ + /// The manifest the ref must still name for an intended repoint; unset = first-time commit. + std::optional expected; + + static PublishPolicy create() { return {}; } + static PublishPolicy replaceIfCurrent(ManifestRef expected_) + { + PublishPolicy p; + p.expected = std::move(expected_); + return p; + } +}; + /// The namespace-qualified protocol identity used by GC for source edges, blob deltas, cleanup work, /// and addressing. It is the pair `(root_namespace, ManifestRef)`. /// Two namespaces may legally carry the same `ManifestRef` tuple without addressing the same object; diff --git a/src/Disks/tests/gtest_ca_transaction.cpp b/src/Disks/tests/gtest_ca_transaction.cpp index fc21603bbd3c5..de28d913e3220 100644 --- a/src/Disks/tests/gtest_ca_transaction.cpp +++ b/src/Disks/tests/gtest_ca_transaction.cpp @@ -19,6 +19,7 @@ namespace DB::ErrorCodes { extern const int NOT_IMPLEMENTED; extern const int INVALID_STATE; +extern const int CAS_OWNERSHIP_CONFLICT; } /// [TXN-ONE-PIPELINE] CA publish-at-commit lock-scope tests. @@ -182,44 +183,119 @@ TEST(CASTransactionLockScope, CommittedRefMoveDoesNotSpuriouslyPublish) EXPECT_FALSE(storage->existsDirectory("a11/a11a11a1-1111-4111-8111-111111111111/all_2_2_0")); } -/// [TXN-ONE-PIPELINE] B183 migration gate: a scratch ref durably published at the part's own (tmp) -/// BUILD path by a nested sub-storage must be dropped on the staged-source tmp->final finalize, and -/// commit() must publish the AUTHORITATIVE staged manifest (not the scratch content). This mirrors -/// `createTemporaryTextIndexStorage`, which publishes scratch under the new_data_part's STILL-TMP -/// relative path (`MergeTask.cpp` uses `getDataPartStorage().getRelativePath()`) — i.e. the SOURCE of -/// the tmp->final rename, which is exactly what `moveDirectory`'s `dropRefIfPresent(src->refKey())` -/// drops. (The plan's destination-path scenario would not reproduce this: `publishStaging`'s -/// repoint-merge would carry the scratch file forward.) +namespace +{ + +const std::string scratch_table = "a77/a77a77a7-7777-4777-8777-777777777777/"; +const std::string scratch_tmp_part = scratch_table + "tmp_merge_all_1_1_0"; + +/// The nested text-index sub-storage (`createTemporaryTextIndexStorage`): a SEPARATE transaction that +/// durably publishes a ref at the part's tmp name holding only `text_index_tmp/` files. +void commitNestedScratch(DB::ContentAddressedMetadataStorage & storage) +{ + auto scratch_tx = storage.createTransaction(); + writeFileTx(*scratch_tx, scratch_tmp_part + "/text_index_tmp/scratch.bin", "scratch"); + scratch_tx->commit(DB::NoCommitOptions{}); +} + +/// MergeTree's order after `finalize`: commit the part at its tmp name, then rename it in place. +void commitAndRenamePart(DB::ContentAddressedMetadataStorage & storage, DB::IMetadataTransaction & tx) +{ + tx.commit(DB::NoCommitOptions{}); + auto rename_tx = storage.createTransaction(); + rename_tx->moveDirectory(scratch_tmp_part, scratch_table + "all_1_1_0"); + rename_tx->commit(DB::NoCommitOptions{}); +} + +void expectAuthoritativeManifestOnly(DB::ContentAddressedMetadataStorage & storage) +{ + const auto ns = storage.liveNamespace("a77a77a7-7777-4777-8777-777777777777"); + const auto resolved = storage.store()->resolveRef(ns, "all_1_1_0"); + ASSERT_TRUE(resolved.has_value()); + const auto manifest = storage.store()->readManifest(resolved->manifest_id); + EXPECT_TRUE(findByName(manifest.entries, "data.bin")); + EXPECT_FALSE(findByName(manifest.entries, "scratch.bin")) << "the nested scratch must not be merged into the part"; + EXPECT_FALSE(storage.existsDirectory(scratch_tmp_part)); +} + +} + +/// A merge commits the nested scratch ref, then writes the part's files (`MergeTask` text-index +/// stage): the part transaction stages over the committed scratch and must publish its own manifest. TEST(CASTransactionLockScope, StagedFinalizeDropsForeignScratchRef) { auto storage = openTxStorage(); + commitNestedScratch(*storage); + ASSERT_TRUE(storage->existsDirectory(scratch_tmp_part)); - /// A SEPARATE transaction (the nested text-index sub-storage) durably publishes a committed ref at - /// the tmp BUILD path holding only a scratch file under `text_index_tmp/`. + auto tx = storage->createTransaction(); + writeFileTx(*tx, scratch_tmp_part + "/data.bin", std::string(50000, 'D')); + writeFileTx(*tx, scratch_tmp_part + "/checksums.txt", "checksums"); + commitAndRenamePart(*storage, *tx); + + expectAuthoritativeManifestOnly(*storage); +} + +/// A mutation stages the part (hardlinks, written columns) before the nested scratch commits: the +/// scratch appears under a ref the part transaction staged as new. +TEST(CASTransactionLockScope, ScratchCommittedWhilePartIsStagedIsDropped) +{ + auto storage = openTxStorage(); + auto tx = storage->createTransaction(); + writeFileTx(*tx, scratch_tmp_part + "/data.bin", std::string(50000, 'D')); + commitNestedScratch(*storage); + writeFileTx(*tx, scratch_tmp_part + "/checksums.txt", "checksums"); + commitAndRenamePart(*storage, *tx); + + expectAuthoritativeManifestOnly(*storage); +} + +/// The scratch rule covers only a manifest with no top-level file: a part another writer publishes at +/// the staged name still conflicts. +TEST(CASTransactionLockScope, PartPublishedWhileStagedStillConflicts) +{ + auto storage = openTxStorage(); + auto tx = storage->createTransaction(); + writeFileTx(*tx, scratch_tmp_part + "/data.bin", std::string(50000, 'D')); { - auto scratch_tx = storage->createTransaction(); - writeFileTx(*scratch_tx, "a77/a77a77a7-7777-4777-8777-777777777777/tmp_merge_all_1_1_0/text_index_tmp/scratch.bin", "scratch"); - scratch_tx->commit(DB::NoCommitOptions{}); + auto other = storage->createTransaction(); + writeFileTx(*other, scratch_tmp_part + "/checksums.txt", "other"); + other->commit(DB::NoCommitOptions{}); } - ASSERT_TRUE(storage->existsDirectory("a77/a77a77a7-7777-4777-8777-777777777777/tmp_merge_all_1_1_0")); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::CAS_OWNERSHIP_CONFLICT, [&] { tx->commit(DB::NoCommitOptions{}); }); + EXPECT_TRUE(storage->existsFile(scratch_tmp_part + "/checksums.txt")); +} - /// The real part build: stage the authoritative data.bin under the SAME tmp path, then finalize - /// tmp->final. The staged-source finalize drops the foreign scratch ref at the tmp path. +/// The staged-source rename applies the same rule as the commit: a scratch at the staged name is dropped, +/// a part another writer published there after staging began is a conflict, never a silent drop. +TEST(CASTransactionLockScope, RenameOfStagedPartOverACommittedPartConflicts) +{ + auto storage = openTxStorage(); auto tx = storage->createTransaction(); - writeFileTx(*tx, "a77/a77a77a7-7777-4777-8777-777777777777/tmp_merge_all_1_1_0/data.bin", std::string(50000, 'D')); - tx->moveDirectory("a77/a77a77a7-7777-4777-8777-777777777777/tmp_merge_all_1_1_0", "a77/a77a77a7-7777-4777-8777-777777777777/all_1_1_0"); - tx->commit(DB::NoCommitOptions{}); + writeFileTx(*tx, scratch_tmp_part + "/data.bin", std::string(50000, 'D')); + { + auto other = storage->createTransaction(); + writeFileTx(*other, scratch_tmp_part + "/checksums.txt", "other"); + other->commit(DB::NoCommitOptions{}); + } + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::CAS_OWNERSHIP_CONFLICT, + [&] { tx->moveDirectory(scratch_tmp_part, scratch_table + "all_1_1_0"); }); + EXPECT_TRUE(storage->existsFile(scratch_tmp_part + "/checksums.txt")); + EXPECT_FALSE(storage->existsDirectory(scratch_table + "all_1_1_0")); +} - /// The published manifest is the authoritative one (has data.bin), not the scratch ref. - const auto ns = storage->liveNamespace("a77a77a7-7777-4777-8777-777777777777"); - const auto resolved = storage->store()->resolveRef(ns, "all_1_1_0"); - ASSERT_TRUE(resolved.has_value()); - const auto manifest = storage->store()->readManifest(resolved->manifest_id); - EXPECT_TRUE(findByName(manifest.entries, "data.bin")); - EXPECT_FALSE(findByName(manifest.entries, "scratch.bin")); +/// A second scratch commit onto a scratch ref stages no top-level file, so it merges as an ordinary +/// write onto a committed ref. +TEST(CASTransactionLockScope, ScratchOntoScratchMerges) +{ + auto storage = openTxStorage(); + commitNestedScratch(*storage); + auto tx = storage->createTransaction(); + writeFileTx(*tx, scratch_tmp_part + "/text_index_tmp/scratch2.bin", "scratch2"); + tx->commit(DB::NoCommitOptions{}); - /// The foreign scratch ref at the tmp build path is gone (dropped, not carried forward). - EXPECT_FALSE(storage->existsDirectory("a77/a77a77a7-7777-4777-8777-777777777777/tmp_merge_all_1_1_0")); + EXPECT_TRUE(storage->existsFile(scratch_tmp_part + "/text_index_tmp/scratch.bin")); + EXPECT_TRUE(storage->existsFile(scratch_tmp_part + "/text_index_tmp/scratch2.bin")); } /// [TXN-ONE-PIPELINE] After a tmp->final re-key, a read THROUGH the open transaction resolves the diff --git a/src/Disks/tests/gtest_ca_wiring.cpp b/src/Disks/tests/gtest_ca_wiring.cpp index db32c32b43b54..bc617add92031 100644 --- a/src/Disks/tests/gtest_ca_wiring.cpp +++ b/src/Disks/tests/gtest_ca_wiring.cpp @@ -152,6 +152,12 @@ TEST(CASPartPathParser, ParseTableFilePathNested) ASSERT_TRUE(tf.has_value()); EXPECT_EQ(tf->table_uuid, "a11a11a1-1111-4111-8111-111111111111"); EXPECT_EQ(tf->tail, "deduplication_logs/deduplication_log_1.txt"); + // So is partition_exports/: an EXPORT PARTITION descriptor and its .tmp are table-level files. + EXPECT_FALSE(isPartFilePath("a11/a11a11a1-1111-4111-8111-111111111111/partition_exports/0123.json.tmp")); + auto pe = parseTableFilePath("a11/a11a11a1-1111-4111-8111-111111111111/partition_exports/0123.json"); + ASSERT_TRUE(pe.has_value()); + EXPECT_EQ(pe->table_uuid, "a11a11a1-1111-4111-8111-111111111111"); + EXPECT_EQ(pe->tail, "partition_exports/0123.json"); auto flat = parseTableFilePath("a11/a11a11a1-1111-4111-8111-111111111111/format_version.txt"); ASSERT_TRUE(flat.has_value()); @@ -329,7 +335,7 @@ TEST(CASPartPathParser, SplitCacheEvictionStaysCorrect) EXPECT_EQ(splitCacheMissesForTest(), misses_before_reparse + 1); } -/// ==== M-W Task 2: the read side over Cas::Pool ==== +/// ==== The read side over Cas::Pool ==== /// Fixture: publish parts through the CORE API, then read through the IMetadataStorage surface of /// the rewritten ContentAddressedMetadataStorage (real ctor over a Local object storage; the /// backend self-selects EmulatedSingleProcess token semantics). @@ -342,6 +348,7 @@ namespace DB::ErrorCodes { extern const int FILE_DOESNT_EXIST; extern const int BAD_ARGUMENTS; + extern const int CAS_OWNERSHIP_CONFLICT; } namespace DB::ContentAddressedSetting @@ -889,7 +896,7 @@ TEST(CASWiringRoute, DirShapeDispatchOrderIsStable) EXPECT_FALSE(storage->classifyDirectoryForTest("data/db/tbl/all_1_1_0/columns.txt").tf.has_value()); } -/// ==== M-W Task 3: the write path through IMetadataTransaction ==== +/// ==== The write path through IMetadataTransaction ==== namespace @@ -927,6 +934,14 @@ TEST(CASWiring, LocalStagingRemainsDefault) EXPECT_TRUE(staged.empty()); } +/// `DiskObjectStorageTransaction::addOperation` dispatches on this flag; the in-flight read-your-writes overlay and the +/// eager `createHardLink` staging both rely on CA never entering the deferred queue. +TEST(CASWiring, AppliesOperationsEagerly) +{ + auto storage = openWiringStorage(); + EXPECT_TRUE(storage->appliesOperationsEagerly()); +} + TEST(CASWiringWrite, ContentRoundTripThroughTransaction) { auto storage = openWiringStorage(); @@ -1043,7 +1058,7 @@ TEST(CASWiringWrite, VerbatimFilesDurableOnFinalizeAndAppendable) std::optional("commands\ncsn 42\n")); } -/// ==== M-W Tasks 5-7: carry-forward, renames, removals, detached/ATTACH/FREEZE ==== +/// ==== Carry-forward, renames, removals, detached/ATTACH/FREEZE ==== TEST(CASWiringOps, HardLinkCarriesForwardWithoutReupload) { @@ -1095,6 +1110,97 @@ TEST(CASWiringOps, CommittedPartRenameMovesTheRef) EXPECT_TRUE(storage->existsFile("a11/a11a11a1-1111-4111-8111-111111111111/delete_tmp_all_1_1_0/data.bin")); } +TEST(CASWiringOps, StagedRewriteAbortsWhenPartMovedBeforeCommit) +{ + auto storage = openWiringStorage(); + const String table = "a11/a11a11a1-1111-4111-8111-111111111111/"; + auto tx = storage->createTransaction(); + writeThroughTransaction(*tx, table + "all_1_1_0/data.bin", "bytes"); + tx->commit(DB::NoCommitOptions{}); + + /// A standalone rewrite of a committed part is staged ... + auto rewrite = storage->createTransaction(); + writeThroughTransaction(*rewrite, table + "all_1_1_0/txn_version.txt", "v2"); + /// ... and before it commits the part is renamed away by another transaction. + auto mover = storage->createTransaction(); + mover->moveDirectory(table + "all_1_1_0", table + "all_1_1_0_moved"); + mover->commit(DB::NoCommitOptions{}); + + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::CAS_OWNERSHIP_CONFLICT, [&] { rewrite->commit(DB::NoCommitOptions{}); }); + EXPECT_FALSE(storage->existsDirectory(table + "all_1_1_0")) << "the rewrite must not recreate the part under its old name"; + EXPECT_TRUE(storage->existsFile(table + "all_1_1_0_moved/data.bin")); +} + +/// A committed part edited and renamed in one transaction keeps every file the edit did not touch: the +/// base manifest's entries ride the destination staging (the same merge `publishStaging` applies in place). +TEST(CASWiringOps, StagedEditOfCommittedPartSurvivesRename) +{ + auto storage = openWiringStorage(); + const String table = "a11/a11a11a1-1111-4111-8111-111111111111/"; + auto tx = storage->createTransaction(); + writeThroughTransaction(*tx, table + "all_1_1_0/data.bin", "bytes"); + writeThroughTransaction(*tx, table + "all_1_1_0/count.txt", "1"); + tx->commit(DB::NoCommitOptions{}); + + auto edit = storage->createTransaction(); + writeThroughTransaction(*edit, table + "all_1_1_0/txn_version.txt", "v2"); + edit->unlinkFile(table + "all_1_1_0/count.txt", /*if_exists=*/false, /*should_remove_objects=*/true); + auto & ca_edit = dynamic_cast(*edit); + ca_edit.moveDirectory(table + "all_1_1_0", table + "all_1_1_0_moved"); + edit->commit(DB::NoCommitOptions{}); + + EXPECT_FALSE(storage->existsDirectory(table + "all_1_1_0")); + auto names = storage->listDirectory(table + "all_1_1_0_moved"); + std::sort(names.begin(), names.end()); + EXPECT_EQ(names, (std::vector{"data.bin", "txn_version.txt"})); + EXPECT_EQ(storage->tryGetInManifestBytes(table + "all_1_1_0_moved/txn_version.txt"), std::optional("v2")); + EXPECT_EQ(storage->getFileSize(table + "all_1_1_0_moved/data.bin"), 5u); +} + +/// The rename of a staged edit is conditional on the base the edit was staged against: a part dropped or +/// republished by another writer in the meantime is a conflict, never a delta-only part under the new name. +TEST(CASWiringOps, RenameOfStagedEditConflictsWhenBaseWasDropped) +{ + auto storage = openWiringStorage(); + const String table = "a11/a11a11a1-1111-4111-8111-111111111111/"; + auto tx = storage->createTransaction(); + writeThroughTransaction(*tx, table + "all_1_1_0/data.bin", "bytes"); + tx->commit(DB::NoCommitOptions{}); + + auto edit = storage->createTransaction(); + writeThroughTransaction(*edit, table + "all_1_1_0/txn_version.txt", "v2"); + const DB::Cas::PartRefKey key{storage->liveNamespace("a11a11a1-1111-4111-8111-111111111111"), "all_1_1_0"}; + const auto base = storage->partAccess()->resolve(key, DB::Cas::Freshness::ForceFresh)->manifest_id.ref; + ASSERT_EQ(storage->partAccess()->dropRefExact(key, base), DB::Cas::Removal::Removed); + + auto & ca_edit = dynamic_cast(*edit); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::CAS_OWNERSHIP_CONFLICT, + [&] { ca_edit.moveDirectory(table + "all_1_1_0", table + "all_1_1_0_moved"); }); + EXPECT_FALSE(storage->existsDirectory(table + "all_1_1_0_moved")); +} + +TEST(CASWiringOps, RenameOfStagedEditConflictsWhenBaseWasRepublished) +{ + auto storage = openWiringStorage(); + const String table = "a11/a11a11a1-1111-4111-8111-111111111111/"; + auto tx = storage->createTransaction(); + writeThroughTransaction(*tx, table + "all_1_1_0/data.bin", "bytes"); + tx->commit(DB::NoCommitOptions{}); + + auto edit = storage->createTransaction(); + writeThroughTransaction(*edit, table + "all_1_1_0/txn_version.txt", "v2"); + /// Another writer commits an edit of the same part first: the committed manifest is no longer our base. + auto other = storage->createTransaction(); + writeThroughTransaction(*other, table + "all_1_1_0/columns.txt", "c"); + other->commit(DB::NoCommitOptions{}); + + auto & ca_edit = dynamic_cast(*edit); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::CAS_OWNERSHIP_CONFLICT, + [&] { ca_edit.moveDirectory(table + "all_1_1_0", table + "all_1_1_0_moved"); }); + EXPECT_TRUE(storage->existsFile(table + "all_1_1_0/columns.txt")) << "the other writer's part is untouched"; + EXPECT_FALSE(storage->existsDirectory(table + "all_1_1_0_moved")); +} + TEST(CASWiringOps, ProjectionTmpRenameRekeysStagedEntries) { auto storage = openWiringStorage(); @@ -1239,6 +1345,27 @@ TEST(CASWiringOps, VerbatimMoveAndUnlink) EXPECT_FALSE(storage->existsFile("a11/a11a11a1-1111-4111-8111-111111111111/mutation_5.txt")); } +/// `MergeTreePartitionExportScheduler::persist` writes `partition_exports/.json.tmp` and renames it over +/// `.json`; `load` lists the directory. The reserved subdir keeps both as table-level verbatim files +/// instead of parsing `partition_exports` as a part name. +TEST(CASWiringOps, PartitionExportDescriptorIsATableLevelFile) +{ + auto storage = openWiringStorage(); + const String dir = "a11/a11a11a1-1111-4111-8111-111111111111/partition_exports"; + auto tx = storage->createTransaction(); + writeThroughTransaction(*tx, dir + "/0123.json.tmp", "{\"status\":\"PENDING\"}"); + auto & ca_tx = dynamic_cast(*tx); + ca_tx.replaceFile(dir + "/0123.json.tmp", dir + "/0123.json"); + tx->commit(DB::NoCommitOptions{}); + + EXPECT_TRUE(storage->existsDirectory(dir)); + EXPECT_EQ(storage->listDirectory(dir), (std::vector{"0123.json"})); + EXPECT_FALSE(storage->existsFile(dir + "/0123.json.tmp")); + EXPECT_EQ(storage->tryGetInManifestBytes(dir + "/0123.json"), std::optional("{\"status\":\"PENDING\"}")); + /// No part ref was minted for the reserved name. + EXPECT_FALSE(storage->partAccess()->existsRef({storage->liveNamespace("a11a11a1-1111-4111-8111-111111111111"), "partition_exports"}, DB::Cas::Freshness::ForceFresh)); +} + TEST(CASWiringOps, UnlinkHonorsIfExistsForPartFiles) { auto storage = openWiringStorage(); @@ -1482,7 +1609,7 @@ TEST(CASWiringOps, FreezeViaHardLinksIntoShadow) EXPECT_FALSE(storage->existsDirectory("shadow/bk1")); } -/// ==== M-W Task 8: in-flight read-your-writes (B59) ==== +/// ==== In-flight read-your-writes ==== TEST(CASWiringInFlight, StagedFilesVisibleBeforeCommit) { @@ -1534,7 +1661,7 @@ namespace DB::ErrorCodes extern const int NOT_IMPLEMENTED; } -/// ==== M-W Task 10: the GC scheduler end-to-end through the wiring ==== +/// ==== The GC scheduler end-to-end through the wiring ==== TEST(CASWiringGc, DroppedPartIsReclaimedByRounds) { @@ -1646,7 +1773,7 @@ TEST(CASWiringGc, DisplacedTreeBlobsReclaimedThroughRealPath) << "not just recognized as unreachable; unreachable=" << after.unreachable; } -/// ==== M-W Task 11 / B7: the DataPartsExchange facade (manifest relink, part_manifest_v2) ==== +/// ==== The DataPartsExchange facade (manifest relink, part_manifest_v2) ==== /// Publish-then-confirm (Task 14) split the receiver's adoption into `prepare` + `promote`, with the /// interserver confirm interposed between them. The confirm belongs to `Fetcher`, not to the storage, so @@ -3087,3 +3214,111 @@ TEST(CASWiring, TryFromDiskOnLocalDiskIsExceptionFreeAndCountsNoError) "a NOT_IMPLEMENTED exception — it runs per disk on every asynchronous-metrics tick"; std::filesystem::remove_all(tmp); } + +TEST(CASWiringOps, CommittedPlainRenameIsOneOwnerMove) +{ + auto storage = openWiringStorage(); + const String table = "a11/a11a11a1-1111-4111-8111-111111111111/"; + auto tx = storage->createTransaction(); + writeThroughTransaction(*tx, table + "tmp_insert_all_1_1_0/data.bin", "bytes"); + tx->commit(DB::NoCommitOptions{}); + const String blob_before = storage->getStorageObjects(table + "tmp_insert_all_1_1_0/data.bin")[0].remote_path; + + std::vector events; + storage->store()->setEventSink([&](const DB::Cas::CasEvent & e) { events.push_back(e); }); + auto tx2 = storage->createTransaction(); + tx2->moveDirectory(table + "tmp_insert_all_1_1_0", table + "all_1_1_0"); + tx2->commit(DB::NoCommitOptions{}); + storage->store()->setEventSink(nullptr); + + EXPECT_EQ(storage->getStorageObjects(table + "all_1_1_0/data.bin")[0].remote_path, blob_before); + const auto moves = std::count_if(events.begin(), events.end(), [](const auto & e) { return e.type == DB::Cas::CasEventType::RefMove; }); + const auto publishes = std::count_if(events.begin(), events.end(), [](const auto & e) { return e.type == DB::Cas::CasEventType::BuildPublish; }); + EXPECT_EQ(moves, 1); + EXPECT_EQ(publishes, 0); + const auto mv = std::find_if(events.begin(), events.end(), [](const auto & e) { return e.type == DB::Cas::CasEventType::RefMove; }); + ASSERT_NE(mv, events.end()); + EXPECT_EQ(mv->ref_name, "tmp_insert_all_1_1_0"); + EXPECT_EQ(mv->detail.at("dst_ref"), "all_1_1_0"); +} + +TEST(CASWiringOps, CrossNamespacePartDirectoryMoveRepublishes) +{ + auto storage = openWiringStorage(); + const String table_a = "a11/a11a11a1-1111-4111-8111-111111111111/"; + const String table_b = "a11/b22b22b2-2222-4222-8222-222222222222/"; + auto tx = storage->createTransaction(); + writeThroughTransaction(*tx, table_a + "all_1_1_0/data.bin", "bytes"); + tx->commit(DB::NoCommitOptions{}); + std::vector events; + storage->store()->setEventSink([&](const DB::Cas::CasEvent & e) { events.push_back(e); }); + auto tx2 = storage->createTransaction(); + tx2->moveDirectory(table_a + "all_1_1_0", table_b + "all_1_1_0"); + tx2->commit(DB::NoCommitOptions{}); + storage->store()->setEventSink(nullptr); + EXPECT_EQ(std::count_if(events.begin(), events.end(), [](const auto & e) { return e.type == DB::Cas::CasEventType::RefMove; }), 0); + EXPECT_TRUE(storage->existsDirectory(table_b + "all_1_1_0")); + EXPECT_FALSE(storage->existsDirectory(table_a + "all_1_1_0")); +} + +TEST(CASWiringOps, DetachRenameIsOneOwnerMove) +{ + auto storage = openWiringStorage(); + const String table = "a11/a11a11a1-1111-4111-8111-111111111111/"; + auto tx = storage->createTransaction(); + writeThroughTransaction(*tx, table + "all_1_1_0/data.bin", "bytes"); + tx->commit(DB::NoCommitOptions{}); + std::vector events; + storage->store()->setEventSink([&](const DB::Cas::CasEvent & e) { events.push_back(e); }); + auto tx2 = storage->createTransaction(); + tx2->moveDirectory(table + "all_1_1_0", table + "detached/all_1_1_0"); + tx2->commit(DB::NoCommitOptions{}); + storage->store()->setEventSink(nullptr); + EXPECT_EQ(std::count_if(events.begin(), events.end(), [](const auto & e) { return e.type == DB::Cas::CasEventType::RefMove; }), 1); + EXPECT_EQ(std::count_if(events.begin(), events.end(), [](const auto & e) { return e.type == DB::Cas::CasEventType::BuildPublish; }), 0); + EXPECT_TRUE(storage->existsDirectory(table + "detached/all_1_1_0")); +} + +TEST(CASWiringOps, MovingToLiveRenameIsOneOwnerMove) +{ + auto storage = openWiringStorage(); + const String table = "a11/a11a11a1-1111-4111-8111-111111111111/"; + auto tx = storage->createTransaction(); + writeThroughTransaction(*tx, table + "moving/all_1_1_0/data.bin", "bytes"); + tx->commit(DB::NoCommitOptions{}); + std::vector events; + storage->store()->setEventSink([&](const DB::Cas::CasEvent & e) { events.push_back(e); }); + auto tx2 = storage->createTransaction(); + tx2->moveDirectory(table + "moving/all_1_1_0", table + "all_1_1_0"); + tx2->commit(DB::NoCommitOptions{}); + storage->store()->setEventSink(nullptr); + EXPECT_EQ(std::count_if(events.begin(), events.end(), [](const auto & e) { return e.type == DB::Cas::CasEventType::RefMove; }), 1); + EXPECT_EQ(std::count_if(events.begin(), events.end(), [](const auto & e) { return e.type == DB::Cas::CasEventType::BuildPublish; }), 0); + EXPECT_TRUE(storage->existsDirectory(table + "all_1_1_0")); + EXPECT_FALSE(storage->existsDirectory(table + "moving/all_1_1_0")); +} + +TEST(CASWiringOps, RedriveOverEqualCommittedDestinationCompletesTheMove) +{ + auto storage = openWiringStorage(); + const String table = "a11/a11a11a1-1111-4111-8111-111111111111/"; + { + auto tx = storage->createTransaction(); + writeThroughTransaction(*tx, table + "tmp_X/data.bin", "same-bytes"); + tx->commit(DB::NoCommitOptions{}); + } + { + auto tx = storage->createTransaction(); + writeThroughTransaction(*tx, table + "X/data.bin", "same-bytes"); + tx->commit(DB::NoCommitOptions{}); + } + std::vector events; + storage->store()->setEventSink([&](const DB::Cas::CasEvent & e) { events.push_back(e); }); + auto tx2 = storage->createTransaction(); + ASSERT_NO_THROW(tx2->moveDirectory(table + "tmp_X", table + "X")); + ASSERT_NO_THROW(tx2->commit(DB::NoCommitOptions{})); + storage->store()->setEventSink(nullptr); + EXPECT_FALSE(storage->existsDirectory(table + "tmp_X")); + EXPECT_TRUE(storage->existsDirectory(table + "X")); + EXPECT_EQ(std::count_if(events.begin(), events.end(), [](const auto & e) { return e.type == DB::Cas::CasEventType::RefMove; }), 0); +} diff --git a/src/Disks/tests/gtest_cas_confirm_exact_ref.cpp b/src/Disks/tests/gtest_cas_confirm_exact_ref.cpp index cd0fee2311d95..8378ae91cca1f 100644 --- a/src/Disks/tests/gtest_cas_confirm_exact_ref.cpp +++ b/src/Disks/tests/gtest_cas_confirm_exact_ref.cpp @@ -307,7 +307,7 @@ PoolPtr openPoolWithConfig(const BackendPtr & backend, PoolConfig config) /// A legal blob-free part: an empty-entry manifest is enough to drive a real precommit+promote pair /// through the append lane and leave a committed ref behind. Returns the committed `ManifestId`. ManifestId publishEmptyPart(const PoolPtr & s, const RootNamespace & ns, const String & ref, - bool allow_repoint = false) + bool replace = false) { PartWriteInfo info; info.intended_namespace = ns; @@ -315,7 +315,7 @@ ManifestId publishEmptyPart(const PoolPtr & s, const RootNamespace & ns, const S auto build = s->beginPartWrite(info); const ManifestId id = build->stageManifest({}); build->precommitAdd(ns, ref, id); - build->promote(ns, ref, build->buildId(), id, allow_repoint); + build->promote(ns, ref, build->buildId(), id, replace ? PublishPolicy::replaceIfCurrent(s->resolveRef(ns, ref).value().manifest_id.ref) : PublishPolicy::create()); return id; } @@ -399,7 +399,7 @@ TEST(CASConfirmExactRef, RepointedRefIsNo) const ManifestId first = publishEmptyPart(store, ns, "x"); ASSERT_EQ(store->confirmExactRef(ns, "x", first.ref), ConfirmAnswer::Yes); - const ManifestId second = publishEmptyPart(store, ns, "x", /*allow_repoint=*/true); + const ManifestId second = publishEmptyPart(store, ns, "x", /*replace=*/true); ASSERT_NE(first.ref, second.ref) << "a repoint must mint a fresh ManifestRef (the ABA barrier)"; EXPECT_EQ(store->confirmExactRef(ns, "x", first.ref), ConfirmAnswer::No); @@ -1191,6 +1191,100 @@ TEST(CASConfirmExactRef, UntouchedRefConfirmsWhileAnotherRefIsQueued) } +/// A queued owner move names exactly its two refs: a confirm of any other ref still answers `Yes`, +/// while both the source and the destination are refused for as long as the move is queued. +TEST(CASConfirmExactRef, ConfirmExactRefIsNotRefusedByAMoveOnOtherRefs) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"srv1/confirm_move_scope"}; + + const ManifestId id_a = publishEmptyPart(store, ns, "a"); + const ManifestId id_c = publishEmptyPart(store, ns, "c"); + ASSERT_EQ(store->confirmExactRef(ns, "c", id_c.ref), ConfirmAnswer::Yes); + + LeaderLatch latch; + latch.arm(store); + std::thread mover([&] + { + store->appendRefOps(ns, MutationScope::refs("a", "b"), + [&](const RefTableState &) -> std::vector + { + RefOp move; + move.kind = RefOpKind::OwnerTransition; + move.old_binding = RefOwnerBinding{RefOwnerKind::Committed, "a", id_a.ref}; + move.new_binding = RefOwnerBinding{RefOwnerKind::Committed, "b", id_a.ref}; + return {move}; + }, + RootMutationOrigin::Writer, RootMutationKind::Move); + }); + latch.awaitEntered(); + + const uint64_t in_flight_before = refusalCount(ProfileEvents::CASRelinkConfirmRefusedRefMutationInFlight); + const ConfirmAnswer other = store->confirmExactRef(ns, "c", id_c.ref); + const uint64_t in_flight_after_other = refusalCount(ProfileEvents::CASRelinkConfirmRefusedRefMutationInFlight); + const ConfirmAnswer source = store->confirmExactRef(ns, "a", id_a.ref); + const ConfirmAnswer destination = store->confirmExactRef(ns, "b", id_a.ref); + const uint64_t in_flight_after_both = refusalCount(ProfileEvents::CASRelinkConfirmRefusedRefMutationInFlight); + + latch.release(); + mover.join(); + store->setRefPreCarveHookForTest(nullptr); + + EXPECT_EQ(other, ConfirmAnswer::Yes); + EXPECT_EQ(in_flight_after_other - in_flight_before, 0u); + EXPECT_EQ(source, ConfirmAnswer::Unknown); + EXPECT_EQ(destination, ConfirmAnswer::Unknown); + EXPECT_EQ(in_flight_after_both - in_flight_after_other, 2u); + EXPECT_EQ(store->confirmExactRef(ns, "b", id_a.ref), ConfirmAnswer::Yes); +} + +#ifndef DEBUG_OR_SANITIZER_BUILD +/// The flush-time scope check accepts ops naming either ref of a two-ref scope and rejects a third. +TEST(CASConfirmExactRef, MoveOpsOutsideTheirScopeFailTheFlushCheck) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"srv1/confirm_move_misscoped"}; + const ManifestId id_a = publishEmptyPart(store, ns, "a"); + + const uint64_t writes_before = backend->writeTotal(); + RefOp move; + move.kind = RefOpKind::OwnerTransition; + move.old_binding = RefOwnerBinding{RefOwnerKind::Committed, "a", id_a.ref}; + move.new_binding = RefOwnerBinding{RefOwnerKind::Committed, "c", id_a.ref}; + try + { + store->appendRefOps(ns, MutationScope::refs("a", "b"), + [move](const RefTableState &) { return std::vector{move}; }, + RootMutationOrigin::Writer, RootMutationKind::Move); + FAIL() << "a move scoped to refs 'a' and 'b' whose op binds ref 'c' must be refused"; + } + catch (const DB::Exception & e) + { + EXPECT_EQ(e.code(), DB::ErrorCodes::LOGICAL_ERROR); + } + EXPECT_EQ(backend->writeTotal(), writes_before) << "the refusal must happen before any object is written"; + EXPECT_EQ(store->confirmExactRef(ns, "a", id_a.ref), ConfirmAnswer::Yes); +} +#else +TEST(CASConfirmExactRefDeathTest, MoveOpsOutsideTheirScopeFailTheFlushCheckAborts) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"srv1/confirm_move_misscoped"}; + const ManifestId id_a = publishEmptyPart(store, ns, "a"); + RefOp move; + move.kind = RefOpKind::OwnerTransition; + move.old_binding = RefOwnerBinding{RefOwnerKind::Committed, "a", id_a.ref}; + move.new_binding = RefOwnerBinding{RefOwnerKind::Committed, "c", id_a.ref}; + EXPECT_DEATH(store->appendRefOps(ns, MutationScope::refs("a", "b"), + [move](const RefTableState &) { return std::vector{move}; }, + RootMutationOrigin::Writer, RootMutationKind::Move), + "names ref 'c'"); +} +#endif + /// Rule 3's `WholeShard` arm. A mutation that declares no ref is one that may move EVERY row, so it /// refuses every ref for as long as it is queued or carved -- unlike a `Ref`-scoped neighbour, which /// refuses only its own. In production `dropNamespaceImpl` and `sweepStalePrecommitsNow` are the two @@ -1292,7 +1386,7 @@ TEST(CASConfirmExactRef, SameRefRepointDurableButNotInstalledIsUnknown) }); ManifestId m2; - std::thread repointer([&] { m2 = publishEmptyPart(store, ns, "x", /*allow_repoint=*/true); }); + std::thread repointer([&] { m2 = publishEmptyPart(store, ns, "x", /*replace=*/true); }); bool parked = false; { std::unique_lock lk(hold->m); diff --git a/src/Disks/tests/gtest_cas_gc_leak.cpp b/src/Disks/tests/gtest_cas_gc_leak.cpp index e6353d962261b..53a8131d72658 100644 --- a/src/Disks/tests/gtest_cas_gc_leak.cpp +++ b/src/Disks/tests/gtest_cas_gc_leak.cpp @@ -152,7 +152,8 @@ ManifestId publishPartBReplacement( build->precommitAdd(ns, ref, id); build->putBlob(idOf(payload_a), BlobSource::fromString(payload_a)); build->putBlob(idOf(payload_b), BlobSource::fromString(payload_b)); - build->promote(ns, ref, build->buildId(), id, /*allow_repoint=*/true); + const auto current = s->resolveRef(ns, ref); + build->promote(ns, ref, build->buildId(), id, current ? PublishPolicy::replaceIfCurrent(current->manifest_id.ref) : PublishPolicy::create()); return id; } diff --git a/src/Disks/tests/gtest_cas_parallel_commit.cpp b/src/Disks/tests/gtest_cas_parallel_commit.cpp index 8cdb007d66238..eb6639ba2a949 100644 --- a/src/Disks/tests/gtest_cas_parallel_commit.cpp +++ b/src/Disks/tests/gtest_cas_parallel_commit.cpp @@ -89,7 +89,7 @@ struct CaWiringFixture Cas::CommitOutcome repointToFreshManifest(const Cas::PartRefKey & key) { return access.repointRef(key, {inlineEntry("f0", fmt::format("repoint-{}", ++content_counter))}, - Cas::ProvenanceOp::Other); + Cas::ProvenanceOp::Other, access.resolve(key, Cas::Freshness::ForceFresh)->manifest_id.ref); } }; @@ -149,7 +149,7 @@ TEST(CASCommitOutcome, RepointRefByteEqualNoOpReportsCurrentManifestNotCreated) const Cas::CommitOutcome oc1 = fx.commitSimplePart(key, /*blobs=*/1); const Cas::CommitOutcome oc_noop = fx.partAccess().repointRef( - key, {CaWiringFixture::inlineEntry("f0", fmt::format("payload-{}-0", key.ref))}, Cas::ProvenanceOp::Other); + key, {CaWiringFixture::inlineEntry("f0", fmt::format("payload-{}-0", key.ref))}, Cas::ProvenanceOp::Other, oc1.manifest_ref); EXPECT_FALSE(oc_noop.created); EXPECT_EQ(oc_noop.manifest_ref, oc1.manifest_ref); } @@ -221,7 +221,7 @@ struct CaTxnRollbackFixture e.ref = Cas::BlobRef{Cas::BlobHashAlgo::CityHash128, Cas::BlobDigest::fromU128(u128Of(bytes))}; e.blob_size = bytes.size(); e.inline_bytes = bytes; - const auto oc = partAccess().repointRef(key, {e}, Cas::ProvenanceOp::Other); + const auto oc = partAccess().repointRef(key, {e}, Cas::ProvenanceOp::Other, partAccess().resolve(key, Cas::Freshness::ForceFresh)->manifest_id.ref); last_repoint_manifest = oc.manifest_ref; } diff --git a/src/Disks/tests/gtest_cas_part_folder_access.cpp b/src/Disks/tests/gtest_cas_part_folder_access.cpp index c87ea8e8c9549..4ccd1c540cd1a 100644 --- a/src/Disks/tests/gtest_cas_part_folder_access.cpp +++ b/src/Disks/tests/gtest_cas_part_folder_access.cpp @@ -1,12 +1,14 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include #include #include @@ -17,6 +19,7 @@ namespace DB::ErrorCodes extern const int FILE_DOESNT_EXIST; extern const int ABORTED; extern const int BAD_ARGUMENTS; + extern const int CAS_OWNERSHIP_CONFLICT; extern const int CORRUPTED_DATA; extern const int LOGICAL_ERROR; extern const int MEMORY_LIMIT_EXCEEDED; @@ -26,6 +29,8 @@ namespace DB::ErrorCodes namespace ProfileEvents { extern const Event CASRefRollbackBestEffortDropFailed; +extern const Event CASOwnershipConflict; +extern const Event CASRefMove; } using namespace DB; @@ -328,6 +333,47 @@ TEST(CASPartFolderAccess, RepublishRefMovesCommittedRef) EXPECT_EQ(view->inlineBytes("txn_version.txt"), std::optional("v1")); /// carried over } +TEST(CASPartFolderAccess, RepointAbortsWhenCommittedRefMovedBeforePromote) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Cas::RootNamespace ns{"srv/t1"}; + Cas::CachedPartFolderAccess access(store); + + publishPart(store, ns, "p", {inlineEntry("f", "v1")}); + const Cas::ManifestRef expected_old = access.resolve({ns, "p"}, Cas::Freshness::ForceFresh)->manifest_id.ref; + + /// A standalone rewrite of a committed part stages its new manifest first ... + auto prepared = access.prepareEntries({ns, "p"}, {inlineEntry("f", "v2")}, Cas::ProvenanceOp::Other); + /// ... and while it is in flight another writer takes the committed ref away (a rename or a drop). + access.dropRef({ns, "p"}); + + expectThrowsCode(DB::ErrorCodes::CAS_OWNERSHIP_CONFLICT, [&] { prepared.promote(Cas::PublishPolicy::replaceIfCurrent(expected_old)); }); + EXPECT_FALSE(access.existsRef({ns, "p"}, Cas::Freshness::ForceFresh)) + << "a repoint must not recreate a ref that was moved away under it"; +} + +TEST(CASPartFolderAccess, RepointAbortsWhenCommittedRefRepointedBeforePromote) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Cas::RootNamespace ns{"srv/t1"}; + Cas::CachedPartFolderAccess access(store); + + publishPart(store, ns, "p", {inlineEntry("f", "v1")}); + const Cas::ManifestRef expected_old = access.resolve({ns, "p"}, Cas::Freshness::ForceFresh)->manifest_id.ref; + + auto prepared = access.prepareEntries({ns, "p"}, {inlineEntry("f", "v2")}, Cas::ProvenanceOp::Other); + access.repointRef({ns, "p"}, {inlineEntry("f", "v3")}, Cas::ProvenanceOp::Other, expected_old); /// the other writer wins + + const uint64_t conflicts_before = ProfileEvents::global_counters[ProfileEvents::CASOwnershipConflict]; + expectThrowsCode(DB::ErrorCodes::CAS_OWNERSHIP_CONFLICT, [&] { prepared.promote(Cas::PublishPolicy::replaceIfCurrent(expected_old)); }); + EXPECT_EQ(ProfileEvents::global_counters[ProfileEvents::CASOwnershipConflict], conflicts_before + 1) << "every conflict is counted"; + auto view = access.getView({ns, "p"}, Cas::Freshness::ForceFresh); + ASSERT_NE(view, nullptr); + EXPECT_EQ(view->inlineBytes("f"), std::optional("v3")) << "the newer binding must survive"; +} + TEST(CASPartFolderAccess, RepublishRefIdempotentRedriveAndConflict) { auto backend = std::make_shared(); @@ -350,10 +396,172 @@ TEST(CASPartFolderAccess, RepublishRefIdempotentRedriveAndConflict) /// Conflict: dst committed with DIFFERENT content — fail closed, src untouched. publishPart(store, ns, "src2", {inlineEntry("f", "one")}); publishPart(store, ns, "dst2", {inlineEntry("f", "two")}); - expectThrowsCode(ErrorCodes::ABORTED, [&] { access.republishRef({ns, "src2"}, {ns, "dst2"}); }); + expectThrowsCode(ErrorCodes::CAS_OWNERSHIP_CONFLICT, [&] { access.republishRef({ns, "src2"}, {ns, "dst2"}); }); EXPECT_TRUE(access.existsRef({ns, "src2"}, Cas::Freshness::ForceFresh)); } +TEST(CASPartFolderAccess, DropRefExactDistinguishesRemovedGoneMismatch) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Cas::RootNamespace ns{"srv/t1"}; + Cas::CachedPartFolderAccess access(store); + + publishPart(store, ns, "p", {inlineEntry("f", "v1")}); + const Cas::ManifestRef m1 = access.resolve({ns, "p"}, Cas::Freshness::ForceFresh)->manifest_id.ref; + access.repointRef({ns, "p"}, {inlineEntry("f", "v2")}, Cas::ProvenanceOp::Other, m1); + const Cas::ManifestRef m2 = access.resolve({ns, "p"}, Cas::Freshness::ForceFresh)->manifest_id.ref; + + EXPECT_EQ(access.dropRefExact({ns, "p"}, m1), Cas::Removal::Mismatch); + EXPECT_TRUE(access.existsRef({ns, "p"}, Cas::Freshness::ForceFresh)); + EXPECT_EQ(access.dropRefExact({ns, "p"}, m2), Cas::Removal::Removed); + EXPECT_FALSE(access.existsRef({ns, "p"}, Cas::Freshness::ForceFresh)); + EXPECT_EQ(access.dropRefExact({ns, "p"}, m2), Cas::Removal::Gone); +} + +TEST(CASPartFolderAccess, RepublishRefAbortsAndCompensatesWhenSourceRepointedAfterPublish) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Cas::RootNamespace ns{"srv/t1"}; + Cas::CachedPartFolderAccess access(store); + + publishPart(store, ns, "src", {inlineEntry("f", "same")}); + access.setHookForTest(Cas::CachedPartFolderAccess::TestHook::BeforeSourceDrop, [&] + { + access.repointRef({ns, "src"}, {inlineEntry("f", "newer")}, Cas::ProvenanceOp::Other, access.resolve({ns, "src"}, Cas::Freshness::ForceFresh)->manifest_id.ref); + }); + expectThrowsCode(DB::ErrorCodes::CAS_OWNERSHIP_CONFLICT, [&] { access.republishRef({ns, "src"}, {ns, "dst"}); }); + access.setHookForTest(Cas::CachedPartFolderAccess::TestHook::BeforeSourceDrop, nullptr); + + EXPECT_FALSE(access.existsRef({ns, "dst"}, Cas::Freshness::ForceFresh)) << "the destination this call created must be compensated"; + EXPECT_EQ(access.getView({ns, "src"}, Cas::Freshness::ForceFresh)->inlineBytes("f"), std::optional("newer")); +} + +TEST(CASPartFolderAccess, RepublishRefCompensatesPreexistingEqualDestinationOnMismatch) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Cas::RootNamespace ns{"srv/t1"}; + Cas::CachedPartFolderAccess access(store); + + publishPart(store, ns, "src", {inlineEntry("f", "same")}); + publishPart(store, ns, "dst", {inlineEntry("f", "same")}); /// a previous attempt already promoted dst + access.setHookForTest(Cas::CachedPartFolderAccess::TestHook::BeforeSourceDrop, [&] + { + access.repointRef({ns, "src"}, {inlineEntry("f", "newer")}, Cas::ProvenanceOp::Other, access.resolve({ns, "src"}, Cas::Freshness::ForceFresh)->manifest_id.ref); + }); + expectThrowsCode(DB::ErrorCodes::CAS_OWNERSHIP_CONFLICT, [&] { access.republishRef({ns, "src"}, {ns, "dst"}); }); + access.setHookForTest(Cas::CachedPartFolderAccess::TestHook::BeforeSourceDrop, nullptr); + + EXPECT_FALSE(access.existsRef({ns, "dst"}, Cas::Freshness::ForceFresh)) + << "a content-equal destination is stale once the source was repointed, whoever created it"; + EXPECT_EQ(access.getView({ns, "src"}, Cas::Freshness::ForceFresh)->inlineBytes("f"), std::optional("newer")); +} + +/// With no destination to complete, the retry-later exception reaches the caller unchanged. +TEST(CASPartFolderAccess, RepublishRefRethrowsNetworkErrorWhenDestinationIsAbsent) +{ + auto backend = std::make_shared(); + auto store = Cas::Pool::open(backend, Cas::PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + const Cas::RootNamespace ns{"srv/t1"}; + DB::Cas::tests::casAdmitRecoverableEntry(*backend, store->layout(), ns, store->liveWriterEpoch()); + Cas::CachedPartFolderAccess access(store); + publishPart(store, ns, "src", {inlineEntry("f", "same")}); + + backend->fault_key_substr = store->layout().namespaceStreamPrefix(fixture::fixtureLife(ns)) + "_log/"; + backend->skip = 1; + backend->fault_count = 2; + expectThrowsCode(ErrorCodes::NETWORK_ERROR, [&] { access.republishRef({ns, "src"}, {ns, "dst"}); }); + + EXPECT_FALSE(access.existsRef({ns, "dst"}, Cas::Freshness::ForceFresh)); + EXPECT_TRUE(access.existsRef({ns, "src"}, Cas::Freshness::ForceFresh)) << "nothing was moved"; +} + +TEST(CASPartFolderAccess, RepublishRefCompletesWhenSourceAlreadyTakenByIdenticalMove) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Cas::RootNamespace ns{"srv/t1"}; + Cas::CachedPartFolderAccess access(store); + Cas::CachedPartFolderAccess other(store); + + publishPart(store, ns, "src", {inlineEntry("f", "same")}); + /// Between our destination publish and our source drop an identical rename (a queue re-drive) sees + /// the byte-equal destination, removes the source and reports success. + access.setHookForTest(Cas::CachedPartFolderAccess::TestHook::BeforeSourceDrop, [&] { EXPECT_TRUE(other.republishRef({ns, "src"}, {ns, "dst"})); }); + EXPECT_TRUE(access.republishRef({ns, "src"}, {ns, "dst"})); + access.setHookForTest(Cas::CachedPartFolderAccess::TestHook::BeforeSourceDrop, nullptr); + + EXPECT_TRUE(access.existsRef({ns, "dst"}, Cas::Freshness::ForceFresh)) << "the completed rename must not be compensated away"; + EXPECT_FALSE(access.existsRef({ns, "src"}, Cas::Freshness::ForceFresh)); +} + +/// Two identical renames racing from the start never conflict: each either completes the move (true) or +/// finds the source already gone (false, when the other thread finished before it resolved the source). +TEST(CASPartFolderAccess, TwoIdenticalRepublishesNeverConflict) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Cas::RootNamespace ns{"srv/t1"}; + Cas::CachedPartFolderAccess a(store); + Cas::CachedPartFolderAccess b(store); + publishPart(store, ns, "src", {inlineEntry("f", "same")}); + + std::latch go{2}; + std::atomic ok{0}; + std::atomic source_gone{0}; + auto run = [&](Cas::CachedPartFolderAccess & access) + { + go.arrive_and_wait(); + if (access.republishRef({ns, "src"}, {ns, "dst"})) + ++ok; + else + ++source_gone; + }; + std::thread t1(run, std::ref(a)); + std::thread t2(run, std::ref(b)); + t1.join(); + t2.join(); + EXPECT_GE(ok.load(), 1) << "at least the first rename completes"; + EXPECT_EQ(ok.load() + source_gone.load(), 2) << "neither thread threw"; + EXPECT_TRUE(a.existsRef({ns, "dst"}, Cas::Freshness::ForceFresh)); + EXPECT_FALSE(a.existsRef({ns, "src"}, Cas::Freshness::ForceFresh)); +} + +TEST(CASPartFolderAccess, RepublishRefRejectsDestinationPublishedWithOtherContentDuringPublish) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Cas::RootNamespace ns{"srv/t1"}; + Cas::CachedPartFolderAccess access(store); + + publishPart(store, ns, "src", {inlineEntry("f", "mine")}); + access.setHookForTest(Cas::CachedPartFolderAccess::TestHook::BeforeDestinationPublish, [&] { publishPart(store, ns, "dst", {inlineEntry("f", "other")}); }); + expectThrowsCode(DB::ErrorCodes::CAS_OWNERSHIP_CONFLICT, [&] { access.republishRef({ns, "src"}, {ns, "dst"}); }); + access.setHookForTest(Cas::CachedPartFolderAccess::TestHook::BeforeDestinationPublish, nullptr); + + EXPECT_EQ(access.getView({ns, "src"}, Cas::Freshness::ForceFresh)->inlineBytes("f"), std::optional("mine")); + EXPECT_EQ(access.getView({ns, "dst"}, Cas::Freshness::ForceFresh)->inlineBytes("f"), std::optional("other")); +} + +TEST(CASPartFolderAccess, RepublishRefCompletesWhenIdenticalRenameTakesDestinationDuringPublish) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Cas::RootNamespace ns{"srv/t1"}; + Cas::CachedPartFolderAccess access(store); + Cas::CachedPartFolderAccess other(store); + + publishPart(store, ns, "src", {inlineEntry("f", "same")}); + access.setHookForTest(Cas::CachedPartFolderAccess::TestHook::BeforeDestinationPublish, [&] { EXPECT_TRUE(other.republishRef({ns, "src"}, {ns, "dst"})); }); + EXPECT_TRUE(access.republishRef({ns, "src"}, {ns, "dst"})); + access.setHookForTest(Cas::CachedPartFolderAccess::TestHook::BeforeDestinationPublish, nullptr); + + EXPECT_EQ(access.getView({ns, "dst"}, Cas::Freshness::ForceFresh)->inlineBytes("f"), std::optional("same")); + EXPECT_FALSE(access.existsRef({ns, "src"}, Cas::Freshness::ForceFresh)); +} + /// Task 7: `publishEntries`'s `catch (...) { build->abandon(); throw; }` must leave no live-epoch /// precommit binding behind when its promote fails -- only `abandon()` removes it (the build /// destructor merely retires the build seq; GC never touches a live precommit). Drives the failure @@ -1174,3 +1382,211 @@ TEST(CASPartFolderAccess, PreparedPartWriteIsNotMoveAssignable) << "a move assignment cannot discharge a terminal duty that may fail to be discharged"; EXPECT_TRUE(std::is_move_constructible_v); } + +TEST(CASPartFolderAccess, MoveRefExactMovesWithoutANewManifest) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Cas::RootNamespace ns{"srv/t1"}; + Cas::CachedPartFolderAccess access(store); + publishPart(store, ns, "tmp_insert_all_1_1_0", {inlineEntry("f", "v1")}); + const auto before = access.resolve({ns, "tmp_insert_all_1_1_0"}, Cas::Freshness::ForceFresh)->manifest_id; + + EXPECT_TRUE(access.moveRefExact({ns, "tmp_insert_all_1_1_0"}, {ns, "all_1_1_0"}, before.ref)); + EXPECT_FALSE(access.existsRef({ns, "tmp_insert_all_1_1_0"}, Cas::Freshness::ForceFresh)); + EXPECT_EQ(access.resolve({ns, "all_1_1_0"}, Cas::Freshness::ForceFresh)->manifest_id, before) << "same manifest, no republication"; + EXPECT_FALSE(access.moveRefExact({ns, "tmp_insert_all_1_1_0"}, {ns, "all_1_1_0"}, before.ref)) << "replay is idempotent"; +} + +TEST(CASPartFolderAccess, MoveRefExactRestampsPublishedAt) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Cas::RootNamespace ns{"srv/t1"}; + Cas::CachedPartFolderAccess access(store); + publishPart(store, ns, "tmp_a", {inlineEntry("f", "v1")}); + const auto before = access.resolve({ns, "tmp_a"}, Cas::Freshness::ForceFresh); + ASSERT_TRUE(before); + const uint64_t published_before = before->published_at_ms; + /// The stamp has millisecond resolution; make the move strictly later. + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + EXPECT_TRUE(access.moveRefExact({ns, "tmp_a"}, {ns, "a"}, before->manifest_id.ref)); + const auto after = access.resolve({ns, "a"}, Cas::Freshness::ForceFresh); + ASSERT_TRUE(after); + EXPECT_GT(after->published_at_ms, published_before); + EXPECT_EQ(after->manifest_id, before->manifest_id); +} + +TEST(CASPartFolderAccess, MoveRefExactAbortsOnRepointedSourceAndOccupiedDestination) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Cas::RootNamespace ns{"srv/t1"}; + Cas::CachedPartFolderAccess access(store); + publishPart(store, ns, "src", {inlineEntry("f", "v1")}); + const auto m1 = access.resolve({ns, "src"}, Cas::Freshness::ForceFresh)->manifest_id.ref; + + access.setHookForTest(Cas::CachedPartFolderAccess::TestHook::BeforeMoveAppend, [&] { access.repointRef({ns, "src"}, {inlineEntry("f", "v2")}, Cas::ProvenanceOp::Other, m1); }); + expectThrowsCode(DB::ErrorCodes::CAS_OWNERSHIP_CONFLICT, [&] { access.moveRefExact({ns, "src"}, {ns, "dst"}, m1); }); + access.setHookForTest(Cas::CachedPartFolderAccess::TestHook::BeforeMoveAppend, nullptr); + EXPECT_EQ(access.getView({ns, "src"}, Cas::Freshness::ForceFresh)->inlineBytes("f"), std::optional("v2")); + EXPECT_FALSE(access.existsRef({ns, "dst"}, Cas::Freshness::ForceFresh)); + + const auto m2 = access.resolve({ns, "src"}, Cas::Freshness::ForceFresh)->manifest_id.ref; + publishPart(store, ns, "dst", {inlineEntry("f", "other")}); + expectThrowsCode(DB::ErrorCodes::CAS_OWNERSHIP_CONFLICT, [&] { access.moveRefExact({ns, "src"}, {ns, "dst"}, m2); }); + EXPECT_TRUE(access.existsRef({ns, "src"}, Cas::Freshness::ForceFresh)); +} + +/// A cross-namespace rename (`MOVE PARTITION TO TABLE`) republishes: the facade never asks for a Move there. +TEST(CASPartFolderAccess, RenameCommittedRefRepublishesAcrossNamespaces) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Cas::RootNamespace ns{"srv/t1"}; + const Cas::RootNamespace other{"srv/t2"}; + Cas::CachedPartFolderAccess access(store); + publishPart(store, ns, "src", {inlineEntry("f", "v1")}); + const uint64_t moves_before = ProfileEvents::global_counters[ProfileEvents::CASRefMove]; + ASSERT_TRUE(access.renameCommittedRef({ns, "src"}, {other, "dst"}).has_value()); + EXPECT_EQ(ProfileEvents::global_counters[ProfileEvents::CASRefMove], moves_before); + EXPECT_FALSE(access.existsRef({ns, "src"}, Cas::Freshness::ForceFresh)); + EXPECT_EQ(access.getView({other, "dst"}, Cas::Freshness::ForceFresh)->inlineBytes("f"), std::optional("v1")); + EXPECT_FALSE(access.renameCommittedRef({ns, "src"}, {other, "dst"}).has_value()) << "an absent source renames nothing"; +} + +TEST(CASPartFolderAccess, RenameCommittedRefConflictsWhenSourceNamesAnotherManifest) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Cas::RootNamespace ns{"srv/t1"}; + Cas::CachedPartFolderAccess access(store); + publishPart(store, ns, "src", {inlineEntry("f", "v1")}); + publishPart(store, ns, "other", {inlineEntry("f", "v2")}); + const auto m_src = access.resolve({ns, "src"}, Cas::Freshness::ForceFresh)->manifest_id.ref; + const auto m_other = access.resolve({ns, "other"}, Cas::Freshness::ForceFresh)->manifest_id.ref; + const uint64_t moves_before = ProfileEvents::global_counters[ProfileEvents::CASRefMove]; + const uint64_t conflicts_before = ProfileEvents::global_counters[ProfileEvents::CASOwnershipConflict]; + /// The caller read `other`'s manifest for `src`: a concurrent repoint happened in between. + expectThrowsCode(DB::ErrorCodes::CAS_OWNERSHIP_CONFLICT, [&] { access.renameCommittedRef({ns, "src"}, {ns, "dst"}, m_other); }); + EXPECT_EQ(ProfileEvents::global_counters[ProfileEvents::CASOwnershipConflict], conflicts_before + 1); + EXPECT_EQ(ProfileEvents::global_counters[ProfileEvents::CASRefMove], moves_before) << "nothing moved"; + EXPECT_TRUE(access.existsRef({ns, "src"}, Cas::Freshness::ForceFresh)); + EXPECT_FALSE(access.existsRef({ns, "dst"}, Cas::Freshness::ForceFresh)); + /// With the manifest the source really names the rename is one Move and returns that manifest. + const auto renamed = access.renameCommittedRef({ns, "src"}, {ns, "dst"}, m_src); + ASSERT_TRUE(renamed.has_value()); + EXPECT_TRUE(*renamed == m_src); + EXPECT_EQ(ProfileEvents::global_counters[ProfileEvents::CASRefMove], moves_before + 1); +} + +TEST(CASPartFolderAccess, MoveRefExactAbortsOnDestinationPrecommit) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Cas::RootNamespace ns{"srv/t1"}; + Cas::CachedPartFolderAccess access(store); + publishPart(store, ns, "src", {inlineEntry("f", "v1")}); + const auto m = access.resolve({ns, "src"}, Cas::Freshness::ForceFresh)->manifest_id.ref; + auto prepared = access.prepareEntries({ns, "dst"}, {inlineEntry("f", "other")}, Cas::ProvenanceOp::Other); /// live precommit under dst + expectThrowsCode(DB::ErrorCodes::CAS_OWNERSHIP_CONFLICT, [&] { access.moveRefExact({ns, "src"}, {ns, "dst"}, m); }); + prepared.abort(); + EXPECT_TRUE(access.existsRef({ns, "src"}, Cas::Freshness::ForceFresh)); +} + +TEST(CASPartFolderAccess, SecondMoveFailureLeavesFirstMoved) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Cas::RootNamespace ns{"srv/t1"}; + Cas::CachedPartFolderAccess access(store); + publishPart(store, ns, "tmp_a", {inlineEntry("f", "a")}); + publishPart(store, ns, "tmp_b", {inlineEntry("f", "b")}); + const auto ma = access.resolve({ns, "tmp_a"}, Cas::Freshness::ForceFresh)->manifest_id.ref; + const auto mb = access.resolve({ns, "tmp_b"}, Cas::Freshness::ForceFresh)->manifest_id.ref; + EXPECT_TRUE(access.moveRefExact({ns, "tmp_a"}, {ns, "a"}, ma)); + access.setHookForTest(Cas::CachedPartFolderAccess::TestHook::BeforeMoveAppend, [&] { access.repointRef({ns, "tmp_b"}, {inlineEntry("f", "b2")}, Cas::ProvenanceOp::Other, mb); }); + expectThrowsCode(DB::ErrorCodes::CAS_OWNERSHIP_CONFLICT, [&] { access.moveRefExact({ns, "tmp_b"}, {ns, "b"}, mb); }); + access.setHookForTest(Cas::CachedPartFolderAccess::TestHook::BeforeMoveAppend, nullptr); + /// Documented: a multi-part rename is atomic per part only; the first move stays. + EXPECT_TRUE(access.existsRef({ns, "a"}, Cas::Freshness::ForceFresh)); + EXPECT_FALSE(access.existsRef({ns, "b"}, Cas::Freshness::ForceFresh)); +} + +TEST(CASPartFolderAccess, SnapshotAfterMoveRecoversOnlyTheDestination) +{ + auto backend = std::make_shared(); + const Cas::RootNamespace ns{"srv/t1"}; + { + auto store = openPoolForTest(backend); + Cas::CachedPartFolderAccess access(store); + publishPart(store, ns, "tmp_a", {inlineEntry("f", "a")}); + const auto ma = access.resolve({ns, "tmp_a"}, Cas::Freshness::ForceFresh)->manifest_id.ref; + EXPECT_TRUE(access.moveRefExact({ns, "tmp_a"}, {ns, "a"}, ma)); + EXPECT_TRUE(store->tryPublishSnapshotAndAdvanceCheckpointOnce(ns)); + store->waitForSnapshotPublishSettleForTest(ns); + } + auto reopened = openPoolForTest(backend); /// recovery from the snapshot, not from the move log + Cas::CachedPartFolderAccess access(reopened); + EXPECT_TRUE(access.existsRef({ns, "a"}, Cas::Freshness::ForceFresh)); + EXPECT_FALSE(access.existsRef({ns, "tmp_a"}, Cas::Freshness::ForceFresh)); +} + +TEST(CASPartFolderAccess, TwoMovesFromOneSourceOneWins) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Cas::RootNamespace ns{"srv/t1"}; + Cas::CachedPartFolderAccess access(store); + publishPart(store, ns, "src", {inlineEntry("f", "v1")}); + const auto m = access.resolve({ns, "src"}, Cas::Freshness::ForceFresh)->manifest_id.ref; + + std::latch go{2}; + std::atomic ok{0}; + std::atomic aborted{0}; + auto run = [&](const String & dst) + { + go.arrive_and_wait(); + try + { + if (access.moveRefExact({ns, "src"}, {ns, dst}, m)) ++ok; + } + catch (const DB::Exception & e) + { + if (e.code() == DB::ErrorCodes::CAS_OWNERSHIP_CONFLICT) ++aborted; else throw; + } + }; + std::thread t1(run, "a"); + std::thread t2(run, "b"); + t1.join(); + t2.join(); + EXPECT_EQ(ok.load(), 1); + EXPECT_EQ(aborted.load(), 1); + EXPECT_EQ(access.existsRef({ns, "a"}, Cas::Freshness::ForceFresh) + access.existsRef({ns, "b"}, Cas::Freshness::ForceFresh), 1); +} + +TEST(CASPartFolderAccess, MoveRetryAfterAmbiguousAppendIsIdempotent) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + auto clock = Cas::tests::VirtualRetryClock::installOn(store); + const Cas::RootNamespace ns{"srv/t1"}; + DB::Cas::tests::casAdmitRecoverableEntry(*backend, store->layout(), ns, store->liveWriterEpoch()); + Cas::CachedPartFolderAccess access(store, cacheOn()); + publishPart(store, ns, "tmp_a", {inlineEntry("f", "a")}); + const auto expected = access.resolve({ns, "tmp_a"}, Cas::Freshness::ForceFresh)->manifest_id; + + /// The move's ref-log object lands; only the acknowledgement and the verifying read are lost. + backend->fault_substr = store->layout().namespaceStreamPrefix(fixture::fixtureLife(ns)) + "_log/"; + backend->mode = Cas::tests::ChunkFaultBackend::Mode::LandedThenLost; + backend->latched = true; + expectThrowsCode(ErrorCodes::NETWORK_ERROR, [&] { access.moveRefExact({ns, "tmp_a"}, {ns, "a"}, expected.ref); }); + + backend->latched = false; + backend->mode = Cas::tests::ChunkFaultBackend::Mode::None; + backend->fault_count = 0; + backend->fail_read_once_key.clear(); + EXPECT_FALSE(access.moveRefExact({ns, "tmp_a"}, {ns, "a"}, expected.ref)) << "the landed move is not repeated"; + EXPECT_FALSE(access.existsRef({ns, "tmp_a"}, Cas::Freshness::ForceFresh)); + EXPECT_EQ(access.resolve({ns, "a"}, Cas::Freshness::ForceFresh)->manifest_id, expected); +} diff --git a/src/Disks/tests/gtest_cas_part_write.cpp b/src/Disks/tests/gtest_cas_part_write.cpp index 091867df3b1dc..a60f5b45b11ad 100644 --- a/src/Disks/tests/gtest_cas_part_write.cpp +++ b/src/Disks/tests/gtest_cas_part_write.cpp @@ -1934,7 +1934,7 @@ TEST(CASPartWriteTxn, PromoteSucceedsWhenPrecommitIsLiveOwner) /// all-tree-part-files Task 2 (TLA+ `WRepoint`): /// `promote`'s existing unique-ref guard (BUG 1a) refuses to overwrite a committed ref naming a /// DIFFERENT manifest -- correct for an ACCIDENTAL double-publish, but there is no way to perform an -/// INTENDED repoint (a standalone write/remove on an already-committed part) without it. `allow_repoint` +/// INTENDED repoint (a standalone write/remove on an already-committed part) without it. `PublishPolicy::replace` /// opts into exactly that: the guard's throw is skipped, and the committed-transition RefOp (old = /// the currently-committed manifest, new = the incoming one) is appended in the SAME ref-log record as /// the ordinary precommit->committed promotion -- the C++ realization of `WRepoint`'s one-event @@ -1966,7 +1966,7 @@ TEST(CASPartWriteTxnRepoint, PromoteRepointsCommittedRef) build2->putBlob(idOf("m2"), BlobSource::fromString("m2")); build2->putBlob(idOf("m2x"), BlobSource::fromString("m2x")); - /// allow_repoint = false (the default) -> NETWORK_ERROR (CAS write-retry-later), existing invariant + /// PublishPolicy::create (the default) -> NETWORK_ERROR (CAS write-retry-later), existing invariant /// untouched; M1 still resolves. expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { build2->promote(ns, "part_1", build2->buildId(), m2_id); }); @@ -1978,7 +1978,7 @@ TEST(CASPartWriteTxnRepoint, PromoteRepointsCommittedRef) { events->push(e); }); - EXPECT_NO_THROW(build2->promote(ns, "part_1", build2->buildId(), m2_id, /*allow_repoint=*/true)); + EXPECT_NO_THROW(build2->promote(ns, "part_1", build2->buildId(), m2_id, PublishPolicy::replaceIfCurrent(m1_id.ref))); auto resolved = s->resolveRef(ns, "part_1"); ASSERT_TRUE(resolved); EXPECT_EQ(resolved->manifest_id.ref, m2_id.ref); diff --git a/src/Disks/tests/gtest_cas_promote_republish.cpp b/src/Disks/tests/gtest_cas_promote_republish.cpp index 8c6b504535f4a..46eec55753341 100644 --- a/src/Disks/tests/gtest_cas_promote_republish.cpp +++ b/src/Disks/tests/gtest_cas_promote_republish.cpp @@ -17,14 +17,14 @@ /// between `promote(dst)` and `dropRef(src)` finds dst ALREADY committed with the (same) content it is /// about to re-publish, but re-stages+re-promotes anyway, minting a fresh manifest and orphaning the /// first attempt's manifest. The fix (Task 3) makes the re-drive idempotent (content-keyed, not -/// ManifestId-keyed) when dst matches, and fail-closed (`ABORTED`) when dst holds different content. +/// ManifestId-keyed) when dst matches, and fail-closed (`CAS_OWNERSHIP_CONFLICT`) when dst holds different content. /// /// These tests are EXPECTED TO FAIL pre-fix -- that failure IS the bug reproducing. They must not be /// weakened to pass; Tasks 2/3 make them pass. namespace DB::ErrorCodes { -extern const int ABORTED; +extern const int CAS_OWNERSHIP_CONFLICT; extern const int NETWORK_ERROR; extern const int LOGICAL_ERROR; } @@ -226,13 +226,13 @@ TEST(CASPromoteRepublish, RepublishReDriveOverCommittedDstIsIdempotent) /// between the crashed attempt and the re-drive. That side channel is gone -- `metadata_version.txt` /// etc. are ordinary manifest entries now, so a src drift of that kind changes `entries`, and /// `republishRef`'s idempotency check (`dst_manifest->entries != src_manifest->entries`) now correctly -/// treats it as a genuine content conflict (ABORTED) rather than silently resyncing a side payload -- +/// treats it as a genuine content conflict (CAS_OWNERSHIP_CONFLICT) rather than silently resyncing a side payload -- /// there is no longer a "same content, drifted sidecar" state to re-sync. `RepublishReDriveOver- /// CommittedDstIsIdempotent` above remains the live coverage for the idempotent-skip path itself. /// Companion conflict case: a re-drive where dst is committed to DIFFERENT content than src is a /// genuine conflict (an ATTACH-onto-existing-name collision), not a re-drive -- it must fail closed -/// (ABORTED), never silently drop src (which would lose src's content) nor silently overwrite dst. +/// (CAS_OWNERSHIP_CONFLICT), never silently drop src (which would lose src's content) nor silently overwrite dst. /// This scenario reaches the SAME `promote`-over-different-committed-ref guard as BUG 1a, so pre-fix it /// behaves the same way BUG 1a does: no throw (silent overwrite), which is also a leak/data-loss risk. TEST(CASPromoteRepublish, RepublishReDriveOverDifferentContentDstFailsClosed) @@ -263,11 +263,11 @@ TEST(CASPromoteRepublish, RepublishReDriveOverDifferentContentDstFailsClosed) tx->moveDirectory(src_path, dst_path); tx->commit(DB::NoCommitOptions{}); FAIL() << "PRE-FIX: republishRef silently overwrote dst's different content " - "(promote-over-committed leak); POST-FIX must throw ABORTED and leave src intact"; + "(promote-over-committed leak); POST-FIX must throw CAS_OWNERSHIP_CONFLICT and leave src intact"; } catch (const DB::Exception & e) { - EXPECT_EQ(e.code(), DB::ErrorCodes::ABORTED); + EXPECT_EQ(e.code(), DB::ErrorCodes::CAS_OWNERSHIP_CONFLICT); } } diff --git a/src/Disks/tests/gtest_cas_publish_policy.cpp b/src/Disks/tests/gtest_cas_publish_policy.cpp new file mode 100644 index 0000000000000..1f963079bc0dc --- /dev/null +++ b/src/Disks/tests/gtest_cas_publish_policy.cpp @@ -0,0 +1,157 @@ +#include +#include +#include +#include +#include +#include +#include + +namespace DB::ErrorCodes +{ +extern const int CAS_OWNERSHIP_CONFLICT; +extern const int NETWORK_ERROR; +} + +using namespace DB::Cas; + +namespace +{ + +PoolPtr openPool(const std::shared_ptr & b) +{ + return Pool::open(b, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); +} + +/// One inline-entry manifest naming `path` with content `bytes` (distinct bytes => distinct content). +std::vector inlineEntries(const String & path, const String & bytes) +{ + ManifestEntry e; + e.path = path; + e.placement = EntryPlacement::Inline; + e.inline_bytes = bytes; + return {e}; +} + +ManifestId publishCommitted(const PoolPtr & s, const RootNamespace & ns, const String & ref, const std::vector & entries) +{ + auto build = s->beginPartWrite(PartWriteInfo{.intended_ref = ns.string() + "/" + ref, .intended_namespace = ns}); + const ManifestId id = build->stageManifest(entries); + build->precommitAdd(ns, ref, id); + build->promote(ns, ref, build->buildId(), id); + return id; +} + +enum class Prior { Absent, SameBytes, Different }; +enum class Expect { Publish, Refused, Conflict }; + +struct Case +{ + bool replace; /// `replaceIfCurrent(current)` vs `create()` + Prior committed; + Expect expect; +}; + +} + +/// The outcome table of PublishPolicy for `publishEntries`, which always stages a FRESH manifest: a +/// committed ref naming the same bytes still names a different manifest, so only `replaceIfCurrent` +/// binds the new one. `Publish` = the destination ends up naming the new manifest (first bind or repoint), +/// `Refused` = the unique-ref guard's write-retry-later error (NETWORK_ERROR) and no change, +/// `Conflict` = CAS_OWNERSHIP_CONFLICT and no change. The byte-equal no-op lives one layer up, in +/// `repointRef` (tested below). +TEST(CASPublishPolicy, OutcomeTable) +{ + const std::vector cases = { + {false, Prior::Absent, Expect::Publish}, + {false, Prior::SameBytes, Expect::Refused}, + {false, Prior::Different, Expect::Refused}, + {true, Prior::Absent, Expect::Conflict}, + {true, Prior::SameBytes, Expect::Publish}, + {true, Prior::Different, Expect::Publish}, + }; + + for (const auto & c : cases) + { + SCOPED_TRACE(String(c.replace ? "replaceIfCurrent" : "create") + " / committed=" + std::to_string(static_cast(c.committed))); + auto backend = std::make_shared(); + auto store = openPool(backend); + CachedPartFolderAccess access(store); + const RootNamespace ns{"srv/tbl@cas@"}; + const PartRefKey key{ns, "all_0_0_0"}; + + std::optional current; + if (c.committed != Prior::Absent) + current = publishCommitted(store, ns, key.ref, inlineEntries("f", c.committed == Prior::SameBytes ? "NEW" : "OLD")).ref; + + PublishPolicy policy; + if (c.replace) + { + /// Absent: expect a manifest committed under another name, so nothing at `key` satisfies it. + policy = PublishPolicy::replaceIfCurrent(current ? *current : publishCommitted(store, ns, "other_0_0_0", inlineEntries("f", "OLD")).ref); + } + + try + { + const CommitOutcome oc = access.publishEntries(key, inlineEntries("f", "NEW"), ProvenanceOp::Other, policy); + ASSERT_EQ(c.expect, Expect::Publish) << "publish succeeded where a refusal was expected"; + EXPECT_EQ(oc.created, c.committed == Prior::Absent); + const auto resolved = access.resolve(key, Freshness::ForceFresh); + ASSERT_TRUE(resolved.has_value()); + EXPECT_TRUE(resolved->manifest_id.ref == oc.manifest_ref); + if (current) + EXPECT_FALSE(resolved->manifest_id.ref == *current) << "a Publish over a committed ref must rebind it to the fresh manifest"; + } + catch (const DB::Exception & e) + { + ASSERT_NE(c.expect, Expect::Publish) << e.message(); + EXPECT_EQ(e.code(), c.expect == Expect::Refused ? DB::ErrorCodes::NETWORK_ERROR : DB::ErrorCodes::CAS_OWNERSHIP_CONFLICT) << e.message(); + const auto resolved = access.resolve(key, Freshness::ForceFresh); + if (current) + { + ASSERT_TRUE(resolved.has_value()); + EXPECT_TRUE(resolved->manifest_id.ref == *current) << "a refusal must leave the committed manifest unchanged"; + } + else + EXPECT_FALSE(resolved.has_value()) << "a refusal must not create the ref"; + } + } +} + +/// ReplaceIfCurrent with a stale expectation: the ref was repointed under the caller. +TEST(CASPublishPolicy, ReplaceIfCurrentRefusesAStaleExpectation) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + CachedPartFolderAccess access(store); + const RootNamespace ns{"srv/tbl@cas@"}; + const PartRefKey key{ns, "all_0_0_0"}; + const ManifestRef first = publishCommitted(store, ns, key.ref, inlineEntries("f", "v1")).ref; + const ManifestRef second = access.repointRef(key, inlineEntries("f", "v2"), ProvenanceOp::Other, first).manifest_ref; + ASSERT_FALSE(first == second); + + try + { + access.publishEntries(key, inlineEntries("f", "v3"), ProvenanceOp::Other, PublishPolicy::replaceIfCurrent(first)); + FAIL() << "a stale expectation must not repoint"; + } + catch (const DB::Exception & e) + { + EXPECT_EQ(e.code(), DB::ErrorCodes::CAS_OWNERSHIP_CONFLICT) << e.message(); + } + EXPECT_TRUE(access.resolve(key, Freshness::ForceFresh)->manifest_id.ref == second); +} + +/// `repointRef` adds the byte-equal no-op on top of the table: same decoded entries, zero pool mutation. +TEST(CASPublishPolicy, RepointRefByteEqualIsANoOp) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + CachedPartFolderAccess access(store); + const RootNamespace ns{"srv/tbl@cas@"}; + const PartRefKey key{ns, "all_0_0_0"}; + const ManifestRef first = publishCommitted(store, ns, key.ref, inlineEntries("f", "v1")).ref; + const CommitOutcome oc = access.repointRef(key, inlineEntries("f", "v1"), ProvenanceOp::Other, first); + EXPECT_FALSE(oc.created); + EXPECT_TRUE(oc.manifest_ref == first); + EXPECT_TRUE(access.resolve(key, Freshness::ForceFresh)->manifest_id.ref == first); +} diff --git a/src/Disks/tests/gtest_cas_repoint.cpp b/src/Disks/tests/gtest_cas_repoint.cpp index f938ff30e9140..72996372d8f06 100644 --- a/src/Disks/tests/gtest_cas_repoint.cpp +++ b/src/Disks/tests/gtest_cas_repoint.cpp @@ -4,10 +4,9 @@ #include #include -/// Task 3 (all-tree-part-files plan, 2026-07-15): `CachedPartFolderAccess::repointRef` -- the audited -/// primitive a standalone write/remove on an already-COMMITTED part must go through once the mutable -/// per-part file set is empty. It republishes -/// the whole manifest with the new entry set, riding `PartWriteTxn::promote`'s `allow_repoint` mode (Task 2). +/// `CachedPartFolderAccess::repointRef` -- the audited primitive a standalone write/remove on an +/// already-COMMITTED part must go through once the mutable per-part file set is empty. It republishes +/// the whole manifest with the new entry set, riding `PartWriteTxn::promote`'s `PublishPolicy::replace` mode. namespace ProfileEvents { @@ -62,7 +61,7 @@ TEST(CASRepoint, ByteEqualIsNoOp) backend->resetCounts(); const uint64_t repoints_before = ProfileEvents::global_counters[ProfileEvents::CASRefRepoint]; - const DB::Cas::CommitOutcome oc = access.repointRef(key, {inlineEntry("checksums.txt", "cs")}, ProvenanceOp::Other); + const DB::Cas::CommitOutcome oc = access.repointRef(key, {inlineEntry("checksums.txt", "cs")}, ProvenanceOp::Other, id.ref); EXPECT_FALSE(oc.created); EXPECT_EQ(oc.manifest_ref, id.ref) << "the byte-equal outcome must name the manifest ALREADY committed, unchanged"; @@ -91,7 +90,7 @@ TEST(CASRepoint, AddFileRepoints) const uint64_t repoints_before = ProfileEvents::global_counters[ProfileEvents::CASRefRepoint]; const std::vector new_entries{inlineEntry("checksums.txt", "cs"), inlineEntry("metadata_version.txt", "7")}; - const DB::Cas::CommitOutcome oc = access.repointRef(key, new_entries, ProvenanceOp::Other); + const DB::Cas::CommitOutcome oc = access.repointRef(key, new_entries, ProvenanceOp::Other, id_before.ref); EXPECT_FALSE(oc.created); EXPECT_NE(oc.manifest_ref, id_before.ref); From f6d5d519260e18fa263c9962f04650815352788e Mon Sep 17 00:00:00 2001 From: Mikhail Filimonov Date: Fri, 23 Oct 2026 03:21:17 +0200 Subject: [PATCH 11/20] MergeTree: writers on a CAS disk commit the storage transaction before the parts lock MergeTree writers return to the upstream text: the storage transaction commits right after finalize (the sinks, the plain and replicated merge and mutation tasks, `MergeFromLogEntryTask` included), `renameParts` only renames, and the fork's writer guards and the comment block that tied disk durability to the Keeper commit order go. On a content-addressed disk the rename of a committed part is one owner Move, so a merge or mutation publishes once and moves once; a replicated merge renames inside the open transaction and publishes once under its final name. `StorageMergeTree::createEmptyParts` (TRUNCATE, DROP PART/PARTITION, MOVE PARTITION TO TABLE) left the storage transaction open, so the publish ran inside `Transaction::commit` under `DataPartsLock`; it now commits right after creation, like the sinks. `Transaction::commit` throws `LOGICAL_ERROR` when a part on a content-addressed disk still has an open storage transaction: every writer commits it before the parts lock, and the loop that used to do it silently hid two misses. Two pauseable fail points (`part_storage_pause_before_commit_transaction`, `part_storage_pause_after_commit_transaction`) let tests hold a part before and after its storage commit. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Mikhail Filimonov --- src/Common/FailPoint.cpp | 2 ++ .../MergeTree/DataPartStorageOnDiskFull.cpp | 8 ++++++ .../MergeTree/MergeFromLogEntryTask.cpp | 1 + .../MergeTree/MergePlainMergeTreeTask.cpp | 4 +-- src/Storages/MergeTree/MergeTreeData.cpp | 28 ++++++------------- src/Storages/MergeTree/MergeTreeData.h | 14 ++-------- src/Storages/MergeTree/MergeTreeSink.cpp | 4 +-- .../MergeTree/MutateFromLogEntryTask.cpp | 4 +-- .../MergeTree/MutatePlainMergeTreeTask.cpp | 3 +- .../MergeTree/ReplicatedMergeTreeSink.cpp | 4 +-- src/Storages/StorageMergeTree.cpp | 3 ++ 11 files changed, 31 insertions(+), 44 deletions(-) diff --git a/src/Common/FailPoint.cpp b/src/Common/FailPoint.cpp index 86579d23d909a..c0158edb58e73 100644 --- a/src/Common/FailPoint.cpp +++ b/src/Common/FailPoint.cpp @@ -242,6 +242,8 @@ static struct InitFiu ONCE(disk_object_storage_fail_commit_metadata_transaction) \ ONCE(disk_object_storage_fail_precommit_metadata_transaction) \ REGULAR(part_storage_fail_commit_transaction) \ + PAUSEABLE_ONCE(part_storage_pause_before_commit_transaction) \ + PAUSEABLE_ONCE(part_storage_pause_after_commit_transaction) \ ONCE(write_file_operation_fail_on_read) \ REGULAR(slowdown_parallel_replicas_local_plan_read) \ REGULAR(slowdown_skip_index_read_result_build) \ diff --git a/src/Storages/MergeTree/DataPartStorageOnDiskFull.cpp b/src/Storages/MergeTree/DataPartStorageOnDiskFull.cpp index b58ba19c3fccb..517c25c12f4d4 100644 --- a/src/Storages/MergeTree/DataPartStorageOnDiskFull.cpp +++ b/src/Storages/MergeTree/DataPartStorageOnDiskFull.cpp @@ -22,6 +22,8 @@ namespace DB namespace FailPoints { extern const char part_storage_fail_commit_transaction[]; + extern const char part_storage_pause_before_commit_transaction[]; + extern const char part_storage_pause_after_commit_transaction[]; } namespace ErrorCodes @@ -283,8 +285,14 @@ void DataPartStorageOnDiskFull::commitTransaction() throw Exception(ErrorCodes::FAULT_INJECTED, "part_storage_fail_commit_transaction"); }); + /// Lets a test hold the part inside its storage commit and prove that no MergeTree lock is held here. + FailPointInjection::pauseFailPoint(FailPoints::part_storage_pause_before_commit_transaction); + transaction->commit(); transaction.reset(); + + /// Lets a test stop the process between the durable commit and the rename that follows. + FailPointInjection::pauseFailPoint(FailPoints::part_storage_pause_after_commit_transaction); } void DataPartStorageOnDiskFull::checkpointTransaction() diff --git a/src/Storages/MergeTree/MergeFromLogEntryTask.cpp b/src/Storages/MergeTree/MergeFromLogEntryTask.cpp index 32a4897818238..4478981ac5847 100644 --- a/src/Storages/MergeTree/MergeFromLogEntryTask.cpp +++ b/src/Storages/MergeTree/MergeFromLogEntryTask.cpp @@ -413,6 +413,7 @@ bool MergeFromLogEntryTask::finalize(ReplicatedMergeMutateTaskBase::PartLogWrite #endif storage.merger_mutator.renameMergedTemporaryPart(part, parts, NO_TRANSACTION_PTR, *transaction_ptr); + part->getDataPartStorage().commitTransaction(); /// Why we reset task here? Because it holds shared pointer to part and tryRemovePartImmediately will /// not able to remove the part and will throw an exception (because someone holds the pointer). /// diff --git a/src/Storages/MergeTree/MergePlainMergeTreeTask.cpp b/src/Storages/MergeTree/MergePlainMergeTreeTask.cpp index 74e7daf8d3ef8..8be11a6be4b38 100644 --- a/src/Storages/MergeTree/MergePlainMergeTreeTask.cpp +++ b/src/Storages/MergeTree/MergePlainMergeTreeTask.cpp @@ -154,9 +154,7 @@ void MergePlainMergeTreeTask::prepare() void MergePlainMergeTreeTask::finish() { new_part = merge_task->getFuture().get(); - /// A content-addressed part is published once, under its final name, when the transaction commits after the rename. - if (!new_part->getDataPartStorage().isContentAddressed()) - new_part->getDataPartStorage().commitTransaction(); + new_part->getDataPartStorage().commitTransaction(); MergeTreeData::Transaction transaction(storage, txn.get()); storage.merger_mutator.renameMergedTemporaryPart(new_part, future_part->parts, txn, transaction); diff --git a/src/Storages/MergeTree/MergeTreeData.cpp b/src/Storages/MergeTree/MergeTreeData.cpp index 3a47ced45ab95..20f877d64adc7 100644 --- a/src/Storages/MergeTree/MergeTreeData.cpp +++ b/src/Storages/MergeTree/MergeTreeData.cpp @@ -10817,33 +10817,16 @@ void MergeTreeData::Transaction::clear() precommitted_parts_need_rename.clear(); } +/// Renames only. The part's storage transaction is committed by the writer before this call, so on a +/// content-addressed disk the rename is one owner move of an already published part. void MergeTreeData::Transaction::renameParts() { - /// Materialize every part of this transaction: perform the deferred tmp->final renames, then - /// close each part's disk-storage transaction, making the parts DURABLE on their disks. - /// - /// Contract: after renameParts returns, every part of this transaction is durable at its - /// final name. commit only flips in-memory visibility (its commitTransaction loop remains as - /// a safety net for paths that do not come through here); rollback compensates with new - /// operations over committed disk state (removing a rolled-back part reclaims its disk data; - /// on a content-addressed disk that drops the published ref). - /// - /// Ordering is load-bearing: every call site invokes renameParts BEFORE its external Keeper - /// commit decision. A part must be durable before its block_id/part-znode is registered, - /// otherwise a fault between the Keeper commit and the disk commit leaves a phantom part whose - /// surviving block_id silently dedups a byte-identical client retry (acked data loss). This - /// also keeps the disk commit (network I/O on object storages) off the data_parts lock, which - /// Transaction::commit holds. for (const auto & part_need_rename : precommitted_parts_need_rename) { LOG_TEST(data.log, "Renaming part to {}", part_need_rename->name); part_need_rename->renameTo(part_need_rename->name, true); } precommitted_parts_need_rename.clear(); - - for (const auto & part : precommitted_parts) - if (part->getDataPartStorage().hasActiveTransaction()) - part->getDataPartStorage().commitTransaction(); } MergeTreeData::DataPartsVector MergeTreeData::Transaction::commit() @@ -10865,7 +10848,14 @@ MergeTreeData::DataPartsVector MergeTreeData::Transaction::commit(DataPartsLock for (const auto & part : precommitted_parts) if (part->getDataPartStorage().hasActiveTransaction()) + { + /// A content-addressed commit is object-store I/O; every writer commits it before the parts lock. + if (part->getDataPartStorage().isContentAddressed()) + throw Exception(ErrorCodes::LOGICAL_ERROR, + "Part {} on a content-addressed disk reached Transaction::commit with an open storage transaction; " + "commit it before taking the parts lock", part->name); part->getDataPartStorage().commitTransaction(); + } /// Collect covered parts and call addNewPartAndRemoveCovered before NOEXCEPT_SCOPE, /// because lockRemovalTID inside addNewPartAndRemoveCovered can throw SERIALIZATION_ERROR. diff --git a/src/Storages/MergeTree/MergeTreeData.h b/src/Storages/MergeTree/MergeTreeData.h index 28e23b7276cde..d7d82a14b50b9 100644 --- a/src/Storages/MergeTree/MergeTreeData.h +++ b/src/Storages/MergeTree/MergeTreeData.h @@ -375,17 +375,9 @@ class MergeTreeData : public WithMutableContext, public IStorage, public IBackgr DataPartsVector commit(); DataPartsVector commit(DataPartsLock & lock); - /// Renames should be done explicitly, before calling commit, to - /// guarantee that no lock is held during the rename and the disk - /// commit (both are IO bound, while the data parts lock is the - /// bottleneck). Contract: after renameParts every part of this - /// transaction is durable on its disk at its final name; commit only - /// flips in-memory visibility, and rollback compensates via new disk - /// operations (part removal). Every caller runs this BEFORE its - /// external Keeper commit decision: a part must be durable before its - /// block_id/part-znode is registered in Keeper, otherwise a fault between the two commits - /// leaves a phantom part whose surviving block_id silently dedups a byte-identical client - /// retry (acked data loss). + /// Rename should be done explicitly, before calling commit(), to + /// guarantee that no lock held during rename (since rename is IO + /// bound, while data parts lock is the bottleneck) void renameParts(); void addPart(MutableDataPartPtr & part, bool need_rename); diff --git a/src/Storages/MergeTree/MergeTreeSink.cpp b/src/Storages/MergeTree/MergeTreeSink.cpp index 65aee80819d1d..2c073d385909b 100644 --- a/src/Storages/MergeTree/MergeTreeSink.cpp +++ b/src/Storages/MergeTree/MergeTreeSink.cpp @@ -310,9 +310,7 @@ void MergeTreeSink::finishDelayedChunk() while (true) { partition.temp_part->finalize(); - /// A content-addressed part is published once, under its final name, when the transaction commits after the rename. - if (!partition.temp_part->part->getDataPartStorage().isContentAddressed()) - partition.temp_part->part->getDataPartStorage().commitTransaction(); + partition.temp_part->part->getDataPartStorage().commitTransaction(); auto & part = partition.temp_part->part; auto deduplication_hashes = partition.deduplication_info->getDeduplicationHashes(part->info.getPartitionId(), deduplicate); diff --git a/src/Storages/MergeTree/MutateFromLogEntryTask.cpp b/src/Storages/MergeTree/MutateFromLogEntryTask.cpp index 9246acecdf2f9..10c3dfaf0ca49 100644 --- a/src/Storages/MergeTree/MutateFromLogEntryTask.cpp +++ b/src/Storages/MergeTree/MutateFromLogEntryTask.cpp @@ -303,9 +303,7 @@ bool MutateFromLogEntryTask::finalize(ReplicatedMergeMutateTaskBase::PartLogWrit data_part_storage.precommitTransaction(); storage.renameTempPartAndReplace(new_part, *transaction_ptr, /*rename_in_transaction=*/ true); - /// A content-addressed part is published once, under its final name, when the transaction commits after the rename. - if (!new_part->getDataPartStorage().isContentAddressed()) - new_part->getDataPartStorage().commitTransaction(); + new_part->getDataPartStorage().commitTransaction(); FailPointInjection::pauseFailPoint(FailPoints::rmt_mutate_task_pause_before_rename_part); diff --git a/src/Storages/MergeTree/MutatePlainMergeTreeTask.cpp b/src/Storages/MergeTree/MutatePlainMergeTreeTask.cpp index 4e633451db1c9..f55efe031afff 100644 --- a/src/Storages/MergeTree/MutatePlainMergeTreeTask.cpp +++ b/src/Storages/MergeTree/MutatePlainMergeTreeTask.cpp @@ -119,8 +119,7 @@ bool MutatePlainMergeTreeTask::executeStep() #if CLICKHOUSE_CLOUD data_part_storage.setPreferredFileOrder(new_part->getPreferredFileOrder()); #endif - /// A content-addressed part is published once, under its final name, when the transaction commits after the rename. - if (data_part_storage.hasActiveTransaction() && !data_part_storage.isContentAddressed()) + if (data_part_storage.hasActiveTransaction()) data_part_storage.commitTransaction(); MergeTreeData::Transaction transaction(storage, merge_mutate_entry->txn.get()); diff --git a/src/Storages/MergeTree/ReplicatedMergeTreeSink.cpp b/src/Storages/MergeTree/ReplicatedMergeTreeSink.cpp index 959023ad79747..1744b43ed8a3b 100644 --- a/src/Storages/MergeTree/ReplicatedMergeTreeSink.cpp +++ b/src/Storages/MergeTree/ReplicatedMergeTreeSink.cpp @@ -474,9 +474,7 @@ void ReplicatedMergeTreeSink::finishDelayed(const ZooKeeperWithFaultInjectionPtr while (true) { partition.temp_part->finalize(); - /// A content-addressed part is published once, under its final name, when the transaction commits after the rename. - if (!partition.temp_part->part->getDataPartStorage().isContentAddressed()) - partition.temp_part->part->getDataPartStorage().commitTransaction(); + partition.temp_part->part->getDataPartStorage().commitTransaction(); auto deduplication_hashes = partition.deduplication_info->getDeduplicationHashes(partition.block_with_partition.partition_id, deduplicate); auto deduplication_blocks_ids = getDeduplicationBlockIds(deduplication_hashes); diff --git a/src/Storages/StorageMergeTree.cpp b/src/Storages/StorageMergeTree.cpp index e59e159f8720f..df45b27cc02f9 100644 --- a/src/Storages/StorageMergeTree.cpp +++ b/src/Storages/StorageMergeTree.cpp @@ -2746,6 +2746,9 @@ static std::pairgetDataPartStorage().commitTransaction(); data_parts.first.emplace_back(std::move(new_data_part)); data_parts.second.emplace_back(std::move(tmp_dir_holder)); } From 80e4b8381a9f01cd07adcb13b0dd6ee77eb635c6 Mon Sep 17 00:00:00 2001 From: Mikhail Filimonov Date: Fri, 23 Oct 2026 03:21:20 +0200 Subject: [PATCH 12/20] CAS: tests for ref-log owner Move Publish-count oracles over `system.cas_log` for INSERT, merge, mutation (including an untouched-part mutation), LWU, ATTACH FROM, MOVE, DETACH/ATTACH, TRUNCATE, text-index merge and packed projections; a restart between the storage commit and the rename (test_cas_early_commit_restart); a generation-1 build refusing a pool whose reader floor is 2 (test_cas_ref_move_compat). 05387 pins that a replicated merge publishes once under its final name and moves nothing, unlike the plain merge in 05388; 05054's part-removal oracle counts the owner move a merge now makes. The tests count publishes per operation, so they run with fixed settings (no-random-settings, no-random-merge-tree-settings). Co-Authored-By: Claude Fable 5.1 Signed-off-by: Mikhail Filimonov --- .../test_cas_early_commit_restart/__init__.py | 0 .../configs/quiet_system_logs.xml | 54 ++++++ .../configs/server_root_id_node1.xml | 9 + .../configs/storage_conf.xml | 27 +++ .../test_cas_early_commit_restart/test.py | 161 ++++++++++++++++++ .../test_cas_ref_move_compat/__init__.py | 0 .../configs/server_root_id_new.xml | 9 + .../configs/server_root_id_old.xml | 9 + .../configs/storage_conf.xml | 27 +++ .../test_cas_ref_move_compat/test.py | 63 +++++++ .../05054_cas_part_removal_one_ref_drop.sh | 5 +- ...s_publish_counts_lwu_attach_move.reference | 2 + ...5386_cas_publish_counts_lwu_attach_move.sh | 47 +++++ ...87_cas_publish_counts_replicated.reference | 7 + .../05387_cas_publish_counts_replicated.sh | 47 +++++ ...blish_counts_insert_merge_mutate.reference | 12 ++ ..._cas_publish_counts_insert_merge_mutate.sh | 61 +++++++ ...cas_publish_counts_detach_attach.reference | 3 + .../05389_cas_publish_counts_detach_attach.sh | 32 ++++ ...05390_cas_text_index_merge_check.reference | 9 + .../05390_cas_text_index_merge_check.sh | 70 ++++++++ ...5391_cas_packed_projection_merge.reference | 3 + .../05391_cas_packed_projection_merge.sh | 24 +++ .../05392_cas_truncate_empty_part.reference | 2 + .../05392_cas_truncate_empty_part.sh | 31 ++++ 25 files changed, 712 insertions(+), 2 deletions(-) create mode 100644 tests/integration/test_cas_early_commit_restart/__init__.py create mode 100644 tests/integration/test_cas_early_commit_restart/configs/quiet_system_logs.xml create mode 100644 tests/integration/test_cas_early_commit_restart/configs/server_root_id_node1.xml create mode 100644 tests/integration/test_cas_early_commit_restart/configs/storage_conf.xml create mode 100644 tests/integration/test_cas_early_commit_restart/test.py create mode 100644 tests/integration/test_cas_ref_move_compat/__init__.py create mode 100644 tests/integration/test_cas_ref_move_compat/configs/server_root_id_new.xml create mode 100644 tests/integration/test_cas_ref_move_compat/configs/server_root_id_old.xml create mode 100644 tests/integration/test_cas_ref_move_compat/configs/storage_conf.xml create mode 100644 tests/integration/test_cas_ref_move_compat/test.py create mode 100644 tests/queries/0_stateless/05386_cas_publish_counts_lwu_attach_move.reference create mode 100755 tests/queries/0_stateless/05386_cas_publish_counts_lwu_attach_move.sh create mode 100644 tests/queries/0_stateless/05387_cas_publish_counts_replicated.reference create mode 100755 tests/queries/0_stateless/05387_cas_publish_counts_replicated.sh create mode 100644 tests/queries/0_stateless/05388_cas_publish_counts_insert_merge_mutate.reference create mode 100755 tests/queries/0_stateless/05388_cas_publish_counts_insert_merge_mutate.sh create mode 100644 tests/queries/0_stateless/05389_cas_publish_counts_detach_attach.reference create mode 100755 tests/queries/0_stateless/05389_cas_publish_counts_detach_attach.sh create mode 100644 tests/queries/0_stateless/05390_cas_text_index_merge_check.reference create mode 100755 tests/queries/0_stateless/05390_cas_text_index_merge_check.sh create mode 100644 tests/queries/0_stateless/05391_cas_packed_projection_merge.reference create mode 100755 tests/queries/0_stateless/05391_cas_packed_projection_merge.sh create mode 100644 tests/queries/0_stateless/05392_cas_truncate_empty_part.reference create mode 100755 tests/queries/0_stateless/05392_cas_truncate_empty_part.sh diff --git a/tests/integration/test_cas_early_commit_restart/__init__.py b/tests/integration/test_cas_early_commit_restart/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/integration/test_cas_early_commit_restart/configs/quiet_system_logs.xml b/tests/integration/test_cas_early_commit_restart/configs/quiet_system_logs.xml new file mode 100644 index 0000000000000..80b951718fbd2 --- /dev/null +++ b/tests/integration/test_cas_early_commit_restart/configs/quiet_system_logs.xml @@ -0,0 +1,54 @@ + + + + 600000 + + + 600000 + + + 600000 + + + 600000 + + + 600000 + + + 600000 + + + 600000 + + + 600000 + + + 600000 + + + 600000 + + + 600000 + + + 600000 + + + 600000 + + + 600000 + + + 600000 + + + 600000 + + + 600000 + + diff --git a/tests/integration/test_cas_early_commit_restart/configs/server_root_id_node1.xml b/tests/integration/test_cas_early_commit_restart/configs/server_root_id_node1.xml new file mode 100644 index 0000000000000..d1d5d74d3e464 --- /dev/null +++ b/tests/integration/test_cas_early_commit_restart/configs/server_root_id_node1.xml @@ -0,0 +1,9 @@ + + + + + node1 + + + + diff --git a/tests/integration/test_cas_early_commit_restart/configs/storage_conf.xml b/tests/integration/test_cas_early_commit_restart/configs/storage_conf.xml new file mode 100644 index 0000000000000..79cf9bf603cf9 --- /dev/null +++ b/tests/integration/test_cas_early_commit_restart/configs/storage_conf.xml @@ -0,0 +1,27 @@ + + + + + object_storage + s3 + cas + 30 + 10000 + http://rustfs1:11121/test/shared_pool/ + clickhouse + clickhouse + 1 + 1 + + + + + +
+ disk_cas_shared +
+
+
+
+
+
diff --git a/tests/integration/test_cas_early_commit_restart/test.py b/tests/integration/test_cas_early_commit_restart/test.py new file mode 100644 index 0000000000000..3440d2e62d7b7 --- /dev/null +++ b/tests/integration/test_cas_early_commit_restart/test.py @@ -0,0 +1,161 @@ +import shlex +import threading +import time + +import pytest +from helpers.cluster import ClickHouseCluster + +cluster = ClickHouseCluster(__file__) + +DISK = "disk_cas_shared" + + +@pytest.fixture(scope="module", autouse=True) +def start_cluster(): + cluster.add_instance( + "node1", + main_configs=["configs/storage_conf.xml", "configs/server_root_id_node1.xml", "configs/quiet_system_logs.xml"], + with_rustfs=True, + stay_alive=True, + ) + try: + cluster.start() + yield cluster + finally: + cluster.shutdown() + + +def _wait(pred, timeout=120, desc=""): + deadline = time.time() + timeout + while time.time() < deadline: + try: + if pred(): + return + except Exception: + pass + time.sleep(1) + raise AssertionError("timeout: " + desc) + + +def _cli_rows(node, query): + # SYSTEM queries take no FORMAT clause; the format is a client option. Columns are looked up by header name. + out = node.exec_in_container( + ["bash", "-c", "clickhouse client --format TSVWithNames --query " + shlex.quote(query)] + ).strip().split("\n") + header = out[0].split("\t") + return [dict(zip(header, line.split("\t"))) for line in out[1:]] + + +def _assert_fsck_clean(node): + # Same shape as tests/queries/0_stateless/04290_cas_no_leftovers.sh: drain the GC pipeline first (the + # dropped temporary ref leaves unreachable blobs that GC has not retired yet), then audit. + def drained(): + rows = _cli_rows(node, f"SYSTEM CAS GC RUN '{DISK}'") + return rows and rows[0]["pending_condemned"] == "0" + + _wait(drained, timeout=60, desc="GC retire pipeline drained") + rows = _cli_rows(node, f"SYSTEM CAS FSCK '{DISK}'") + assert rows, rows + for column in ("dangling", "unreachable", "unaccounted", "stale_edge", "corrupted_runs", "chain_broken", "lifeless_keys"): + assert rows[0][column] == "0", rows + + +def test_crash_between_early_commit_and_rename_leaves_no_part(start_cluster): + node = cluster.instances["node1"] + node.query("DROP TABLE IF EXISTS t_restart SYNC") + node.query( + "CREATE TABLE t_restart (a UInt64) ENGINE = MergeTree ORDER BY a " + "SETTINGS storage_policy = 'cas_shared', min_bytes_for_wide_part = 0, " + "temporary_directories_lifetime = 1, merge_tree_clear_old_temporary_directories_interval_seconds = 1" + ) + node.query("SYSTEM ENABLE FAILPOINT part_storage_pause_after_commit_transaction") + + def insert(): + try: + node.query("INSERT INTO t_restart SELECT number FROM numbers(1000)") + except Exception: + pass # the server is killed under this query + + t = threading.Thread(target=insert, daemon=True) + t.start() + try: + # The failpoint is global and pauses only the first part commit. Waiting on it instead of polling + # cas_log: a log flush would itself be a part commit. + node.query("SYSTEM WAIT FAILPOINT part_storage_pause_after_commit_transaction PAUSE", timeout=120) + assert ( + node.query("SELECT count() FROM system.processes WHERE query LIKE 'INSERT INTO t_restart%'").strip() == "1" + ), "the parked commit must belong to the INSERT" + node.stop_clickhouse(kill=True) + finally: + # Releases the parked INSERT if the kill was not reached; a no-op once the server is gone. + try: + node.query("SYSTEM DISABLE FAILPOINT part_storage_pause_after_commit_transaction") + except Exception: + pass + t.join(timeout=60) + node.start_clickhouse() + + assert node.query("SELECT count() FROM t_restart").strip() == "0" + _wait( + lambda: node.query( + "SYSTEM FLUSH LOGS cas_log; SELECT count() FROM system.cas_log " + "WHERE event_type = 'ref_drop' AND ref_name LIKE 'tmp_insert_%'" + ).strip() + == "1", + desc="temporary ref removed by clearOldTemporaryDirectories", + ) + assert node.query("SELECT count() FROM system.parts WHERE table = 't_restart' AND name LIKE 'tmp_%'").strip() == "0" + _assert_fsck_clean(node) + node.query("DROP TABLE t_restart SYNC") + + +def test_storage_commit_does_not_hold_parts_lock(start_cluster): + node = cluster.instances["node1"] + node.query("DROP TABLE IF EXISTS t_lock SYNC") + node.query( + "CREATE TABLE t_lock (a UInt64) ENGINE = MergeTree ORDER BY a " + "SETTINGS storage_policy = 'cas_shared', min_bytes_for_wide_part = 0" + ) + node.query("SYSTEM ENABLE FAILPOINT part_storage_pause_before_commit_transaction") + + def insert_a(): + node.query("INSERT INTO t_lock SELECT number FROM numbers(1000)") + + t = threading.Thread(target=insert_a, daemon=True) + t.start() + try: + # Pauses only the first part commit (A); B is not held by the failpoint. + node.query("SYSTEM WAIT FAILPOINT part_storage_pause_before_commit_transaction PAUSE", timeout=120) + assert ( + node.query("SELECT count() FROM system.processes WHERE query LIKE 'INSERT INTO t_lock SELECT number FROM numbers(1000)%'").strip() + == "1" + ), "the parked commit must belong to INSERT A" + # B needs DataPartsLock to commit. It runs in its own thread: `max_execution_time` cannot + # interrupt a mutex wait, so a regression must fail the join instead of hanging the runner. + b_errors = [] + + def insert_b(): + try: + node.query("INSERT INTO t_lock SELECT number FROM numbers(10)") + except Exception as e: + b_errors.append(e) + + tb = threading.Thread(target=insert_b, daemon=True) + tb.start() + tb.join(timeout=60) + if tb.is_alive(): + # The finally block releases A first, so B can finish and the thread does not leak. + pytest.fail("insert B blocked while A was parked") + assert not b_errors, b_errors + assert node.query("SELECT count() FROM t_lock").strip() == "10" + assert ( + node.query( + "SELECT count() FROM system.processes WHERE query LIKE 'INSERT INTO t_lock SELECT number FROM numbers(1000)%'" + ).strip() + == "1" + ), "A must still be parked" + finally: + node.query("SYSTEM DISABLE FAILPOINT part_storage_pause_before_commit_transaction") + t.join() + assert node.query("SELECT count() FROM t_lock").strip() == "1010" + node.query("DROP TABLE t_lock SYNC") diff --git a/tests/integration/test_cas_ref_move_compat/__init__.py b/tests/integration/test_cas_ref_move_compat/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/integration/test_cas_ref_move_compat/configs/server_root_id_new.xml b/tests/integration/test_cas_ref_move_compat/configs/server_root_id_new.xml new file mode 100644 index 0000000000000..213e06ce83de0 --- /dev/null +++ b/tests/integration/test_cas_ref_move_compat/configs/server_root_id_new.xml @@ -0,0 +1,9 @@ + + + + + new + + + + diff --git a/tests/integration/test_cas_ref_move_compat/configs/server_root_id_old.xml b/tests/integration/test_cas_ref_move_compat/configs/server_root_id_old.xml new file mode 100644 index 0000000000000..83cf31ddd4b01 --- /dev/null +++ b/tests/integration/test_cas_ref_move_compat/configs/server_root_id_old.xml @@ -0,0 +1,9 @@ + + + + + old + + + + diff --git a/tests/integration/test_cas_ref_move_compat/configs/storage_conf.xml b/tests/integration/test_cas_ref_move_compat/configs/storage_conf.xml new file mode 100644 index 0000000000000..79cf9bf603cf9 --- /dev/null +++ b/tests/integration/test_cas_ref_move_compat/configs/storage_conf.xml @@ -0,0 +1,27 @@ + + + + + object_storage + s3 + cas + 30 + 10000 + http://rustfs1:11121/test/shared_pool/ + clickhouse + clickhouse + 1 + 1 + + + + + +
+ disk_cas_shared +
+
+
+
+
+
diff --git a/tests/integration/test_cas_ref_move_compat/test.py b/tests/integration/test_cas_ref_move_compat/test.py new file mode 100644 index 0000000000000..c77580bd65b60 --- /dev/null +++ b/tests/integration/test_cas_ref_move_compat/test.py @@ -0,0 +1,63 @@ +import os + +import pytest +from helpers.cluster import ClickHouseCluster + +cluster = ClickHouseCluster(__file__) +OLD_TAG = "26.6.4.20001.altinityantalya" +CONFIG_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), "configs") + + +@pytest.fixture(scope="module", autouse=True) +def start_cluster(): + cluster.add_instance( + "old", + main_configs=["configs/storage_conf.xml", "configs/server_root_id_old.xml"], + image="altinity/clickhouse-server", + tag=OLD_TAG, + with_installed_binary=True, + with_rustfs=True, + stay_alive=True, + ) + cluster.add_instance( + "new", + # No CAS disk at start: the pool must be created by the old build first (see the test). + main_configs=[], + with_rustfs=True, + stay_alive=True, + ) + try: + cluster.start() + yield cluster + finally: + cluster.shutdown() + + +def _count(node, where): + node.query("SYSTEM FLUSH LOGS cas_log") + return int(node.query(f"SELECT count() FROM system.cas_log WHERE {where}").strip()) + + +def test_old_pool_floor_and_old_reader(start_cluster): + old, new = cluster.instances["old"], cluster.instances["new"] + # Pool created by the old build (reader floor 1). + old.query("CREATE TABLE t_old (a UInt64) ENGINE = MergeTree ORDER BY a SETTINGS storage_policy = 'cas_shared'") + old.query("INSERT INTO t_old SELECT number FROM numbers(100)") + + # The new build mounts it: the first writable mount raises the floor to 2 before any rename, so the + # new build's first rename is already an owner move. + for name in ("storage_conf.xml", "server_root_id_new.xml"): + new.copy_file_to_container(os.path.join(CONFIG_DIR, name), f"/etc/clickhouse-server/config.d/{name}") + new.restart_clickhouse() + new.query("CREATE TABLE t_new (a UInt64) ENGINE = MergeTree ORDER BY a SETTINGS storage_policy = 'cas_shared'") + new.query("INSERT INTO t_new SELECT number FROM numbers(100)") + assert _count(new, "event_type = 'ref_move'") == 1 + assert new.contains_in_log("min_reader_generation raised 1 -> 2") + + # The old node keeps serving its own table ... + old.query("INSERT INTO t_old SELECT number FROM numbers(100)") + assert old.query("SELECT count() FROM t_old").strip() == "200" + # ... and refuses to mount the pool after a restart (reader floor 2 > its ceiling). + old.stop_clickhouse() + old.start_clickhouse(expected_to_fail=True) + assert old.contains_in_log("pool requires reader generation 2 but this build supports at most 1") diff --git a/tests/queries/0_stateless/05054_cas_part_removal_one_ref_drop.sh b/tests/queries/0_stateless/05054_cas_part_removal_one_ref_drop.sh index 4a6b4d39f4993..317337ee387e2 100755 --- a/tests/queries/0_stateless/05054_cas_part_removal_one_ref_drop.sh +++ b/tests/queries/0_stateless/05054_cas_part_removal_one_ref_drop.sh @@ -8,7 +8,8 @@ # time: audit events are timestamped at delivery and can arrive after the removal returns. The test # waits for the terminal records, one `ref_drop` per outdated part, before counting. Background GC is off # on the disk. The namespace filter drops the disk's mount and watermark events and needs a table with a -# UUID (an `Atomic` or `Replicated` database). +# UUID (an `Atomic` or `Replicated` database). Publications under a `tmp_` name are not counted: a part is +# published once under its `tmp_*` name and then moved, so the oracle counts the committed name. CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=../shell_config.sh @@ -78,7 +79,7 @@ run_case() '$name', 'delete_tmp_events', countIf(position(ref_name, 'delete_tmp_') > 0), 'ref_repoint', countIf(event_type = 'ref_repoint'), - 'extra_manifest_publish', countIf(event_type = 'build_publish' AND NOT has($all_names, ref_name)), + 'extra_manifest_publish', countIf(event_type = 'build_publish' AND NOT has($all_names, ref_name) AND NOT startsWith(ref_name, 'tmp_')), 'ref_drop_per_part', countIf(event_type = 'ref_drop' AND has($outdated_names, ref_name)) / $outdated, 'active_part_ref_drops', countIf(event_type = 'ref_drop' AND ref_name = '$active_name') FROM system.cas_log diff --git a/tests/queries/0_stateless/05386_cas_publish_counts_lwu_attach_move.reference b/tests/queries/0_stateless/05386_cas_publish_counts_lwu_attach_move.reference new file mode 100644 index 0000000000000..ffea1451543f2 --- /dev/null +++ b/tests/queries/0_stateless/05386_cas_publish_counts_lwu_attach_move.reference @@ -0,0 +1,2 @@ +attach_from build_publish 1 manifest_put 1 ref_move 1 +move_to_table build_publish 2 manifest_put 2 ref_move 2 diff --git a/tests/queries/0_stateless/05386_cas_publish_counts_lwu_attach_move.sh b/tests/queries/0_stateless/05386_cas_publish_counts_lwu_attach_move.sh new file mode 100755 index 0000000000000..eb5af3fbfb05c --- /dev/null +++ b/tests/queries/0_stateless/05386_cas_publish_counts_lwu_attach_move.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Tags: no-fasttest, no-random-settings, no-random-merge-tree-settings +# A committed part rename on a content-addressed disk is one owner move (ref_move), not a second +# manifest. ref_drop is not counted: outdated parts are removed by the cleanup thread on its own +# schedule. + +CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../shell_config.sh +. "$CUR_DIR"/../shell_config.sh + +DISK="disk(type = object_storage, object_storage_type = local, metadata_type = cas, cas_server_root_id = '${CLICKHOUSE_DATABASE}_pc2', name = '${CLICKHOUSE_DATABASE}_pc2', path = '${CLICKHOUSE_DATABASE}_pc2_pool/')" + +# Prints the event counts accumulated since the previous call (nothing for an empty label), so the +# writers that fill the source table do not affect the numbers of the operation under test. +PREV="0 0 0" +counts() { + ${CLICKHOUSE_CLIENT} -q "SYSTEM FLUSH LOGS cas_log" + CUR=$(${CLICKHOUSE_CLIENT} -q "SELECT countIf(event_type = 'build_publish'), countIf(event_type = 'manifest_put'), countIf(event_type = 'ref_move') + FROM system.cas_log WHERE disk_name = '${CLICKHOUSE_DATABASE}_pc2'") + read -r p0 m0 r0 <<< "$PREV" + read -r p1 m1 r1 <<< "$CUR" + if [ -n "$1" ]; then echo "$1 build_publish $((p1 - p0)) manifest_put $((m1 - m0)) ref_move $((r1 - r0))"; fi + PREV="$CUR" +} + +for t in t_src t_dst +do + ${CLICKHOUSE_CLIENT} -q "DROP TABLE IF EXISTS $t SYNC" + ${CLICKHOUSE_CLIENT} -q "CREATE TABLE $t (a UInt64, v UInt64) ENGINE = MergeTree ORDER BY a SETTINGS disk = ${DISK}, min_bytes_for_wide_part = 0, min_bytes_for_full_part_storage = 0, enable_block_number_column = 1, enable_block_offset_column = 1" +done +${CLICKHOUSE_CLIENT} -q "SYSTEM STOP MERGES t_dst" +${CLICKHOUSE_CLIENT} -q "INSERT INTO t_src SELECT number, number FROM numbers(1000)" +${CLICKHOUSE_CLIENT} --enable_lightweight_update 1 -q "UPDATE t_src SET v = v + 1 WHERE a % 100 = 0" +counts "" +# ATTACH FROM and MOVE TO TABLE refuse a partition with unapplied patch parts. +${CLICKHOUSE_CLIENT} -q "ALTER TABLE t_src APPLY PATCHES SETTINGS mutations_sync = 2" +${CLICKHOUSE_CLIENT} -q "SYSTEM STOP MERGES t_src" +counts "" +${CLICKHOUSE_CLIENT} -q "ALTER TABLE t_dst ATTACH PARTITION tuple() FROM t_src" +# One part moves its patch-free copy in: one publish of the attached part and one owner move. +counts attach_from +${CLICKHOUSE_CLIENT} -q "ALTER TABLE t_src MOVE PARTITION tuple() TO TABLE t_dst" +# The destination gets a clone published as a new part and moved into place; the source gets an empty +# covering part, published without a move. +counts move_to_table +${CLICKHOUSE_CLIENT} -q "DROP TABLE t_src SYNC" +${CLICKHOUSE_CLIENT} -q "DROP TABLE t_dst SYNC" diff --git a/tests/queries/0_stateless/05387_cas_publish_counts_replicated.reference b/tests/queries/0_stateless/05387_cas_publish_counts_replicated.reference new file mode 100644 index 0000000000000..b1f9bd7d45701 --- /dev/null +++ b/tests/queries/0_stateless/05387_cas_publish_counts_replicated.reference @@ -0,0 +1,7 @@ +replicated_insert build_publish 1 +replicated_insert manifest_put 1 +replicated_insert ref_move 1 +fetch build_publish 1 +fetch manifest_put 1 +fetch ref_move 1 +replicated_merge 1 0 3 diff --git a/tests/queries/0_stateless/05387_cas_publish_counts_replicated.sh b/tests/queries/0_stateless/05387_cas_publish_counts_replicated.sh new file mode 100755 index 0000000000000..d04b4a331b8d2 --- /dev/null +++ b/tests/queries/0_stateless/05387_cas_publish_counts_replicated.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Tags: no-fasttest, no-random-settings, no-random-merge-tree-settings +# An inserted or fetched part is published once under a temporary name and renamed with one owner move. +# The packed part storage is pinned off: fetching a packed part onto a content-addressed disk fails +# with BAD_SIZE_OF_FILE_IN_DATA_PART (count.txt) when the randomized `min_bytes_for_full_part_storage` +# makes the part packed. + +CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../shell_config.sh +. "$CUR_DIR"/../shell_config.sh + +DISK_A="disk(type = object_storage, object_storage_type = local, metadata_type = cas, cas_server_root_id = '${CLICKHOUSE_DATABASE}_pc3a', name = '${CLICKHOUSE_DATABASE}_pc3a', path = '${CLICKHOUSE_DATABASE}_pc3a_pool/')" +DISK_B="disk(type = object_storage, object_storage_type = local, metadata_type = cas, cas_server_root_id = '${CLICKHOUSE_DATABASE}_pc3b', name = '${CLICKHOUSE_DATABASE}_pc3b', path = '${CLICKHOUSE_DATABASE}_pc3b_pool/')" + +counts() { + ${CLICKHOUSE_CLIENT} -q "SYSTEM FLUSH LOGS cas_log" + ${CLICKHOUSE_CLIENT} -q "SELECT '$2', event_type, count() FROM system.cas_log + WHERE disk_name = '${CLICKHOUSE_DATABASE}_$1' + AND event_type IN ('build_publish', 'manifest_put', 'ref_move') + GROUP BY event_type ORDER BY event_type" +} + +${CLICKHOUSE_CLIENT} -q "DROP TABLE IF EXISTS r1 SYNC" +${CLICKHOUSE_CLIENT} -q "DROP TABLE IF EXISTS r2 SYNC" +${CLICKHOUSE_CLIENT} -q "CREATE TABLE r1 (a UInt64, v UInt64) ENGINE = ReplicatedMergeTree('/clickhouse/{database}/pc3', 'r1') ORDER BY a SETTINGS disk = ${DISK_A}, min_bytes_for_wide_part = 0, min_bytes_for_full_part_storage = 0" +${CLICKHOUSE_CLIENT} -q "CREATE TABLE r2 (a UInt64, v UInt64) ENGINE = ReplicatedMergeTree('/clickhouse/{database}/pc3', 'r2') ORDER BY a SETTINGS disk = ${DISK_B}, min_bytes_for_wide_part = 0, min_bytes_for_full_part_storage = 0" +${CLICKHOUSE_CLIENT} -q "SYSTEM STOP MERGES r1" +${CLICKHOUSE_CLIENT} -q "SYSTEM STOP MERGES r2" +${CLICKHOUSE_CLIENT} -q "INSERT INTO r1 SELECT number, number FROM numbers(1000)" +${CLICKHOUSE_CLIENT} -q "SYSTEM SYNC REPLICA r2" +counts pc3a replicated_insert +counts pc3b fetch + +# A replicated merge renames its result inside the still-open storage transaction and commits once under +# the final name (MergeFromLogEntryTask::finalize): one manifest, no owner move. The plain MergeTree merge +# commits before the rename and moves (05388). +${CLICKHOUSE_CLIENT} -q "SYSTEM START MERGES r1" +${CLICKHOUSE_CLIENT} -q "INSERT INTO r1 SELECT number, number FROM numbers(1000, 1000)" +${CLICKHOUSE_CLIENT} -q "OPTIMIZE TABLE r1 FINAL" +${CLICKHOUSE_CLIENT} -q "SYSTEM FLUSH LOGS cas_log" +${CLICKHOUSE_CLIENT} -q "SELECT 'replicated_merge', + countIf(event_type = 'build_publish' AND ref_name = 'all_0_1_1') AS published_under_final_name, + countIf(event_type = 'ref_move' AND (ref_name = 'all_0_1_1' OR detail['dst_ref'] = 'all_0_1_1')) AS moves, + countIf(event_type = 'manifest_put') AS manifests_on_disk + FROM system.cas_log WHERE disk_name = '${CLICKHOUSE_DATABASE}_pc3a'" +${CLICKHOUSE_CLIENT} -q "DROP TABLE r1 SYNC" +${CLICKHOUSE_CLIENT} -q "DROP TABLE r2 SYNC" diff --git a/tests/queries/0_stateless/05388_cas_publish_counts_insert_merge_mutate.reference b/tests/queries/0_stateless/05388_cas_publish_counts_insert_merge_mutate.reference new file mode 100644 index 0000000000000..eddeac7b85eba --- /dev/null +++ b/tests/queries/0_stateless/05388_cas_publish_counts_insert_merge_mutate.reference @@ -0,0 +1,12 @@ +insert 1 1 +optimize 1 1 +mutate 1 1 +untouched_mutate 1 1 +gc_drained 1 +fsck_dangling 0 +fsck_unreachable 0 +fsck_unaccounted 0 +fsck_stale_edge 0 +fsck_corrupted_runs 0 +fsck_chain_broken 0 +fsck_lifeless_keys 0 diff --git a/tests/queries/0_stateless/05388_cas_publish_counts_insert_merge_mutate.sh b/tests/queries/0_stateless/05388_cas_publish_counts_insert_merge_mutate.sh new file mode 100755 index 0000000000000..6e1584122eed6 --- /dev/null +++ b/tests/queries/0_stateless/05388_cas_publish_counts_insert_merge_mutate.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Tags: no-fasttest, no-random-settings, no-random-merge-tree-settings +# One manifest per part on a content-addressed disk: the part is published once under its +# temporary name and renamed with one owner move. ref_drop is not counted: outdated parts are removed by +# the cleanup thread on its own schedule. `min_bytes_for_full_part_storage = 0` pins off packed parts: +# packed parts on a content-addressed disk are a known separate defect. + +CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../shell_config.sh +. "$CUR_DIR"/../shell_config.sh + +DISK="disk(type = object_storage, object_storage_type = local, metadata_type = cas, cas_server_root_id = '${CLICKHOUSE_DATABASE}_pc1', name = '${CLICKHOUSE_DATABASE}_pc1', path = '${CLICKHOUSE_DATABASE}_pc1_pool/')" + +# Per manifest (the object_hash shared by manifest_put, build_publish and ref_move): exactly one of each, +# and the publish is under a temporary name. Background merges may add parts between the steps, so the +# part count is a lower bound. +counts() { + ${CLICKHOUSE_CLIENT} -q "SYSTEM FLUSH LOGS cas_log" + ${CLICKHOUSE_CLIENT} -q "SELECT '$1', count() >= $2 AS parts_ok, min(bp = 1 AND mp = 1 AND mv = 1) AS all_one FROM + (SELECT object_hash, + countIf(event_type = 'build_publish' AND startsWith(ref_name, 'tmp_')) AS bp, + countIf(event_type = 'manifest_put') AS mp, + countIf(event_type = 'ref_move') AS mv + FROM system.cas_log + WHERE disk_name = '${CLICKHOUSE_DATABASE}_pc1' + AND event_type IN ('build_publish', 'manifest_put', 'ref_move') + GROUP BY object_hash)" +} + +${CLICKHOUSE_CLIENT} -q "DROP TABLE IF EXISTS t_pc1 SYNC" +${CLICKHOUSE_CLIENT} -q "CREATE TABLE t_pc1 (a UInt64, v UInt64, PROJECTION p1 (SELECT v % 10, sum(a) GROUP BY v % 10)) ENGINE = MergeTree ORDER BY a SETTINGS disk = ${DISK}, min_bytes_for_wide_part = 0, min_bytes_for_full_part_storage = 0" +${CLICKHOUSE_CLIENT} -q "SYSTEM STOP MERGES t_pc1" +${CLICKHOUSE_CLIENT} -q "INSERT INTO t_pc1 SELECT number, number FROM numbers(1000)" +counts insert 1 +${CLICKHOUSE_CLIENT} -q "INSERT INTO t_pc1 SELECT number + 1000, number FROM numbers(1000)" +${CLICKHOUSE_CLIENT} -q "SYSTEM START MERGES t_pc1" +${CLICKHOUSE_CLIENT} -q "OPTIMIZE TABLE t_pc1 FINAL" +counts optimize 3 +${CLICKHOUSE_CLIENT} -q "ALTER TABLE t_pc1 UPDATE v = v + 1 WHERE 1 SETTINGS mutations_sync = 2" +counts mutate 4 +# A mutation that touches no row clones the part (hardlinks) instead of rewriting it: still one +# publish under a temporary name and one owner move. +${CLICKHOUSE_CLIENT} -q "ALTER TABLE t_pc1 DELETE WHERE a < 0 SETTINGS mutations_sync = 2" +counts untouched_mutate 5 +${CLICKHOUSE_CLIENT} -q "DROP TABLE t_pc1 SYNC" + +# The renamed and merged parts leave nothing behind: drain the retire pipeline (bounded, by header name), +# then every FSCK finding column reads zero. +PENDING=1 +for _ in $(seq 1 60); do + PENDING=$(${CLICKHOUSE_CLIENT} --query "SYSTEM CAS GC RUN '${CLICKHOUSE_DATABASE}_pc1'" --format TSVWithNames \ + | awk -F'\t' 'NR==1 { for (i = 1; i <= NF; i++) col[$i] = i; next } { print $col["pending_condemned"] }') + [ "${PENDING}" = "0" ] && break + sleep 0.5 +done +echo "gc_drained $([ "${PENDING}" = "0" ] && echo 1 || echo 0)" + +${CLICKHOUSE_CLIENT} --query "SYSTEM CAS FSCK '${CLICKHOUSE_DATABASE}_pc1'" --format TSVWithNames \ + | awk -F'\t' 'NR==1 { for (i = 1; i <= NF; i++) col[$i] = i; next } + { n = split("dangling unreachable unaccounted stale_edge corrupted_runs chain_broken lifeless_keys", names, " "); + for (i = 1; i <= n; i++) print "fsck_" names[i], $col[names[i]] }' diff --git a/tests/queries/0_stateless/05389_cas_publish_counts_detach_attach.reference b/tests/queries/0_stateless/05389_cas_publish_counts_detach_attach.reference new file mode 100644 index 0000000000000..6bde4808bc47f --- /dev/null +++ b/tests/queries/0_stateless/05389_cas_publish_counts_detach_attach.reference @@ -0,0 +1,3 @@ +0 +1000 +detach_attach 2 diff --git a/tests/queries/0_stateless/05389_cas_publish_counts_detach_attach.sh b/tests/queries/0_stateless/05389_cas_publish_counts_detach_attach.sh new file mode 100755 index 0000000000000..6c68d5e629d66 --- /dev/null +++ b/tests/queries/0_stateless/05389_cas_publish_counts_detach_attach.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Tags: no-fasttest, no-random-settings, no-random-merge-tree-settings +# Renames inside detached/ on a content-addressed disk are owner moves, not republications: ATTACH renames +# the detached part to `detached/attaching_` and then to its live name, each as one `ref_move`. +# DETACH itself clones the part (a new manifest) and is not a rename. `min_bytes_for_full_part_storage = 0` +# pins off packed parts: packed parts on a content-addressed disk are a known separate defect. + +CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../shell_config.sh +. "$CUR_DIR"/../shell_config.sh + +DISK="disk(type = object_storage, object_storage_type = local, metadata_type = cas, cas_server_root_id = '${CLICKHOUSE_DATABASE}_pc2', name = '${CLICKHOUSE_DATABASE}_pc2', path = '${CLICKHOUSE_DATABASE}_pc2_pool/')" + +# Both ATTACH renames are `ref_move`s whose source is under detached/. +counts() { + ${CLICKHOUSE_CLIENT} -q "SYSTEM FLUSH LOGS cas_log" + ${CLICKHOUSE_CLIENT} -q "SELECT '$1', + countIf(event_type = 'ref_move' AND startsWith(ref_name, 'detached/')) AS detached_moves + FROM system.cas_log + WHERE disk_name = '${CLICKHOUSE_DATABASE}_pc2'" +} + +${CLICKHOUSE_CLIENT} -q "DROP TABLE IF EXISTS t_pc2 SYNC" +${CLICKHOUSE_CLIENT} -q "CREATE TABLE t_pc2 (a UInt64) ENGINE = MergeTree ORDER BY a SETTINGS disk = ${DISK}, min_bytes_for_wide_part = 0, min_bytes_for_full_part_storage = 0" +${CLICKHOUSE_CLIENT} -q "SYSTEM STOP MERGES t_pc2" +${CLICKHOUSE_CLIENT} -q "INSERT INTO t_pc2 SELECT number FROM numbers(1000)" +${CLICKHOUSE_CLIENT} -q "ALTER TABLE t_pc2 DETACH PARTITION tuple()" +${CLICKHOUSE_CLIENT} -q "SELECT count() FROM t_pc2" +${CLICKHOUSE_CLIENT} -q "ALTER TABLE t_pc2 ATTACH PARTITION tuple()" +${CLICKHOUSE_CLIENT} -q "SELECT count() FROM t_pc2" +counts detach_attach +${CLICKHOUSE_CLIENT} -q "DROP TABLE t_pc2 SYNC" diff --git a/tests/queries/0_stateless/05390_cas_text_index_merge_check.reference b/tests/queries/0_stateless/05390_cas_text_index_merge_check.reference new file mode 100644 index 0000000000000..228c68a5d489b --- /dev/null +++ b/tests/queries/0_stateless/05390_cas_text_index_merge_check.reference @@ -0,0 +1,9 @@ +t_repl 10 +1 +repl 1 1 +t_add 40 +1 +add 1 1 +t_mat 20 +1 +mat 1 1 diff --git a/tests/queries/0_stateless/05390_cas_text_index_merge_check.sh b/tests/queries/0_stateless/05390_cas_text_index_merge_check.sh new file mode 100755 index 0000000000000..76d6d181ece3a --- /dev/null +++ b/tests/queries/0_stateless/05390_cas_text_index_merge_check.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Tags: no-fasttest, no-random-settings, no-random-merge-tree-settings +# Building a text index commits a nested `text_index_tmp` sub-storage at the new part's tmp name. +# On a content-addressed disk the part's own commit must replace that scratch ref, not merge into it: +# the final part passes CHECK TABLE and its manifest is published once and never repointed. + +CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../shell_config.sh +. "$CUR_DIR"/../shell_config.sh + +CLIENT="${CLICKHOUSE_CLIENT} --allow_experimental_full_text_index=1" + +disk() { + echo "disk(type = object_storage, object_storage_type = local, metadata_type = cas, cas_server_root_id = '${CLICKHOUSE_DATABASE}_$1', name = '${CLICKHOUSE_DATABASE}_$1', path = '${CLICKHOUSE_DATABASE}_$1_pool/')" +} + +# Per manifest that was renamed to a final name: one build_publish under a tmp name, no repoint. +counts() { + ${CLIENT} -q "SYSTEM FLUSH LOGS cas_log" + ${CLIENT} -q "SELECT '$1', count() >= 1 AS moved, min(bp = 1 AND rp = 0) AS published_once FROM + (SELECT object_hash, + countIf(event_type = 'build_publish' AND startsWith(ref_name, 'tmp_')) AS bp, + countIf(event_type = 'ref_repoint') AS rp + FROM system.cas_log + WHERE disk_name = '${CLICKHOUSE_DATABASE}_$1' + AND event_type IN ('build_publish', 'ref_repoint', 'ref_move') + GROUP BY object_hash + HAVING countIf(event_type = 'ref_move') > 0)" +} + +check() { + ${CLIENT} -q "SELECT '$1', count() FROM $1 WHERE hasToken(s, 'word7')" + ${CLIENT} -q "CHECK TABLE $1 SETTINGS check_query_single_value_result = 1" +} + +# A compact horizontal merge that rebuilds the text index of the result part: the scratch ref commits +# before the part writes its first file. +${CLIENT} -q "CREATE TABLE t_repl (k UInt64, s String, INDEX idx_s (s) TYPE text(tokenizer = 'splitByNonAlpha')) + ENGINE = ReplacingMergeTree ORDER BY k SETTINGS disk = $(disk repl), min_bytes_for_full_part_storage = 0, + min_bytes_for_wide_part = '10G', min_rows_for_wide_part = 1000000000" +${CLIENT} -q "INSERT INTO t_repl SELECT number, concat('word', toString(number % 50)) FROM numbers(1000)" +${CLIENT} -q "INSERT INTO t_repl SELECT number, concat('other', toString(number % 50)) FROM numbers(500, 1000)" +${CLIENT} -q "OPTIMIZE TABLE t_repl FINAL" +check t_repl +counts repl + +# A wide vertical merge that materializes the index for a part inserted before ADD INDEX: the part +# writes its first file before the scratch ref commits. +${CLIENT} -q "CREATE TABLE t_add (k UInt64, s String) ENGINE = MergeTree ORDER BY k + SETTINGS disk = $(disk add), min_bytes_for_full_part_storage = 0, min_bytes_for_wide_part = 0, + vertical_merge_algorithm_min_rows_to_activate = 1, vertical_merge_algorithm_min_columns_to_activate = 1" +${CLIENT} -q "INSERT INTO t_add SELECT number, concat('word', toString(number % 50)) FROM numbers(1000)" +${CLIENT} -q "ALTER TABLE t_add ADD INDEX idx_s (s) TYPE text(tokenizer = 'splitByNonAlpha')" +${CLIENT} -q "INSERT INTO t_add SELECT number + 1000, concat('word', toString(number % 50)) FROM numbers(1000)" +${CLIENT} -q "OPTIMIZE TABLE t_add FINAL" +check t_add +counts add + +# A mutation of a wide part: the part is staged (hardlinks) before the scratch ref commits. +${CLIENT} -q "CREATE TABLE t_mat (k UInt64, s String) ENGINE = MergeTree ORDER BY k + SETTINGS disk = $(disk mat), min_bytes_for_full_part_storage = 0, min_bytes_for_wide_part = 0" +${CLIENT} -q "INSERT INTO t_mat SELECT number, concat('word', toString(number % 50)) FROM numbers(1000)" +${CLIENT} -q "ALTER TABLE t_mat ADD INDEX idx_s (s) TYPE text(tokenizer = 'splitByNonAlpha')" +${CLIENT} -q "ALTER TABLE t_mat MATERIALIZE INDEX idx_s SETTINGS mutations_sync = 2" +check t_mat +counts mat + +${CLIENT} -q "DROP TABLE t_repl SYNC" +${CLIENT} -q "DROP TABLE t_add SYNC" +${CLIENT} -q "DROP TABLE t_mat SYNC" diff --git a/tests/queries/0_stateless/05391_cas_packed_projection_merge.reference b/tests/queries/0_stateless/05391_cas_packed_projection_merge.reference new file mode 100644 index 0000000000000..cc693f5f8d2e0 --- /dev/null +++ b/tests/queries/0_stateless/05391_cas_packed_projection_merge.reference @@ -0,0 +1,3 @@ +parts 1 +storage Packed +rows 2000 1999000 999000 diff --git a/tests/queries/0_stateless/05391_cas_packed_projection_merge.sh b/tests/queries/0_stateless/05391_cas_packed_projection_merge.sh new file mode 100755 index 0000000000000..23ebc2f765aa0 --- /dev/null +++ b/tests/queries/0_stateless/05391_cas_packed_projection_merge.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Tags: no-fasttest, no-random-settings, no-random-merge-tree-settings +# A packed part with a projection merges on a content-addressed disk: the projection sub-part rides the +# parent's transaction and must neither open nor commit its own. CHECK TABLE and reads through the +# projection are not asserted: the packed projection sub-part fails `checkDataPart` with +# `BAD_SIZE_OF_FILE_IN_DATA_PART` on a content-addressed disk, before and after the merge. + +CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../shell_config.sh +. "$CUR_DIR"/../shell_config.sh + +DISK="disk(type = object_storage, object_storage_type = local, metadata_type = cas, cas_server_root_id = '${CLICKHOUSE_DATABASE}_pk', cas_allow_packed_parts = 1, name = '${CLICKHOUSE_DATABASE}_pk', path = '${CLICKHOUSE_DATABASE}_pk_pool/')" + +${CLICKHOUSE_CLIENT} -q "DROP TABLE IF EXISTS t_pk SYNC" +${CLICKHOUSE_CLIENT} -q "CREATE TABLE t_pk (a UInt64, v UInt64, PROJECTION p1 (SELECT v % 10, sum(a) GROUP BY v % 10)) ENGINE = MergeTree ORDER BY a SETTINGS disk = ${DISK}, min_bytes_for_wide_part = 0, min_bytes_for_full_part_storage = 1000000000" +${CLICKHOUSE_CLIENT} -q "SYSTEM STOP MERGES t_pk" +${CLICKHOUSE_CLIENT} -q "INSERT INTO t_pk SELECT number, number FROM numbers(1000)" +${CLICKHOUSE_CLIENT} -q "INSERT INTO t_pk SELECT number + 1000, number FROM numbers(1000)" +${CLICKHOUSE_CLIENT} -q "SYSTEM START MERGES t_pk" +${CLICKHOUSE_CLIENT} -q "OPTIMIZE TABLE t_pk FINAL" +${CLICKHOUSE_CLIENT} -q "SELECT 'parts', count() FROM system.parts WHERE database = currentDatabase() AND table = 't_pk' AND active" +${CLICKHOUSE_CLIENT} -q "SELECT 'storage', part_storage_type FROM system.parts WHERE database = currentDatabase() AND table = 't_pk' AND active" +${CLICKHOUSE_CLIENT} -q "SELECT 'rows', count(), sum(a), sum(v) FROM t_pk SETTINGS optimize_use_projections = 0" +${CLICKHOUSE_CLIENT} -q "DROP TABLE t_pk SYNC" diff --git a/tests/queries/0_stateless/05392_cas_truncate_empty_part.reference b/tests/queries/0_stateless/05392_cas_truncate_empty_part.reference new file mode 100644 index 0000000000000..ad4cec60ab704 --- /dev/null +++ b/tests/queries/0_stateless/05392_cas_truncate_empty_part.reference @@ -0,0 +1,2 @@ +truncate build_publish 1 manifest_put 1 ref_move 1 +0 diff --git a/tests/queries/0_stateless/05392_cas_truncate_empty_part.sh b/tests/queries/0_stateless/05392_cas_truncate_empty_part.sh new file mode 100755 index 0000000000000..fcf3772bee6b4 --- /dev/null +++ b/tests/queries/0_stateless/05392_cas_truncate_empty_part.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Tags: no-fasttest, no-random-settings, no-random-merge-tree-settings +# TRUNCATE on a content-addressed disk publishes the empty part once, under its temporary name and off the +# parts lock, and renames it with one owner move. + +CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../shell_config.sh +. "$CUR_DIR"/../shell_config.sh + +DISK="disk(type = object_storage, object_storage_type = local, metadata_type = cas, cas_server_root_id = '${CLICKHOUSE_DATABASE}_tr', name = '${CLICKHOUSE_DATABASE}_tr', path = '${CLICKHOUSE_DATABASE}_tr_pool/')" + +# Prints the event counts accumulated since the previous call (nothing for an empty label). +PREV="0 0 0" +counts() { + ${CLICKHOUSE_CLIENT} -q "SYSTEM FLUSH LOGS cas_log" + CUR=$(${CLICKHOUSE_CLIENT} -q "SELECT countIf(event_type = 'build_publish'), countIf(event_type = 'manifest_put'), countIf(event_type = 'ref_move') + FROM system.cas_log WHERE disk_name = '${CLICKHOUSE_DATABASE}_tr'") + read -r p0 m0 r0 <<< "$PREV" + read -r p1 m1 r1 <<< "$CUR" + if [ -n "$1" ]; then echo "$1 build_publish $((p1 - p0)) manifest_put $((m1 - m0)) ref_move $((r1 - r0))"; fi + PREV="$CUR" +} + +${CLICKHOUSE_CLIENT} -q "DROP TABLE IF EXISTS t_tr SYNC" +${CLICKHOUSE_CLIENT} -q "CREATE TABLE t_tr (a UInt64) ENGINE = MergeTree ORDER BY a SETTINGS disk = ${DISK}" +${CLICKHOUSE_CLIENT} -q "INSERT INTO t_tr SELECT number FROM numbers(10)" +counts "" +${CLICKHOUSE_CLIENT} -q "TRUNCATE TABLE t_tr" +counts truncate +${CLICKHOUSE_CLIENT} -q "SELECT count() FROM t_tr" +${CLICKHOUSE_CLIENT} -q "DROP TABLE t_tr SYNC" From 7d992d82434fbca5dc829741687fddd5c1644226 Mon Sep 17 00:00:00 2001 From: Mikhail Filimonov Date: Fri, 23 Oct 2026 03:21:22 +0200 Subject: [PATCH 13/20] CAS: document ref-log owner Move configuration.md: upgrading a pool from 26.6.x (the floor raise at the first writable mount, the refusal line a generation-1 build logs, no downgrade after the raise). architecture: the five owner-transition shapes, rename = Move inside a namespace and republish across (or when the destination already exists), commit-then-Move in the part lifecycle. operations/debugging.md: what `CAS_OWNERSHIP_CONFLICT` means and how to find the `ref_move` row that carries the destination name; the decommission tool never admits a new hash algorithm; the GC-upgrade section says the floor cannot be downgraded. metrics_and_events.md: `CASRefMove`, `CASOwnershipConflict`. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Mikhail Filimonov --- .../cas/architecture/manifests-and-refs.md | 7 ++-- .../cas/architecture/part-lifecycle.md | 4 ++- .../antalya/cas/architecture/replication.md | 17 ++++++---- docs/en/antalya/cas/configuration.md | 8 +++++ docs/en/antalya/cas/operations/debugging.md | 33 +++++++++++++++++-- docs/en/antalya/metrics_and_events.md | 2 ++ 6 files changed, 58 insertions(+), 13 deletions(-) diff --git a/docs/en/antalya/cas/architecture/manifests-and-refs.md b/docs/en/antalya/cas/architecture/manifests-and-refs.md index 6e6b1ed667bfa..2fc93c4c238e1 100644 --- a/docs/en/antalya/cas/architecture/manifests-and-refs.md +++ b/docs/en/antalya/cas/architecture/manifests-and-refs.md @@ -195,9 +195,10 @@ writer_epoch)` they run `1, 2, 3, …` with no holes, and a new mount epoch rest as `CORRUPTED_DATA`. The op vocabulary is deliberately tiny: `NamespaceBirth`, `OwnerTransition{old?, new?}`, -`SetPublishedAt`, `RemoveNamespace`. There are exactly four legal `OwnerTransition` shapes — add -precommit, remove precommit, remove committed, and promote — enumerated identically by the state -machine and by `GC`'s edge extractor, so the two readers of the format cannot drift. +`SetPublishedAt`, `RemoveNamespace`. There are exactly five legal `OwnerTransition` shapes — add +precommit, remove precommit, remove committed, promote, and move (a committed ref changes its name, +keeping its manifest; format generation 2) — classified once (`classifyOwnerTransitionShape`) for the +state machine and for `GC`'s edge extractor, so the two readers of the format cannot drift. Logs are pure conditional creates on write-once keys. There is no append-to-object and no `CAS`-swapped mutable pointer anywhere in the ref lane. The writer never deletes ref objects; only diff --git a/docs/en/antalya/cas/architecture/part-lifecycle.md b/docs/en/antalya/cas/architecture/part-lifecycle.md index 267e3f6f8a19e..f5ad05dc5f0fb 100644 --- a/docs/en/antalya/cas/architecture/part-lifecycle.md +++ b/docs/en/antalya/cas/architecture/part-lifecycle.md @@ -65,7 +65,9 @@ inline candidate. Blob-class files stage to local scratch by default. Explicit `cas_staging_backend = s3` requires native same-store copy at writable mount and stages a complete `[header][payload]` object. Its first publication after a destination miss may copy that object verbatim; a condemned or subsequent publication opens the staged payload, retags it, and streams. -The `tmp_ → final` rename is a pure overlay re-key; durable publication happens only in `commit`. +A rename staged before the commit (`tmp_ → final` inside one transaction) is a pure overlay re-key. +Writers commit the part right after `finalize`, under its temporary name; the rename that follows is +one ref-log `Move` of the committed ref (see [replication](replication.md)). **Step 1 — `stageManifest`.** Caps (see the [manifests-and-refs page](/antalya/cas/architecture/manifests-and-refs#part-manifests)) are diff --git a/docs/en/antalya/cas/architecture/replication.md b/docs/en/antalya/cas/architecture/replication.md index 4a3bce92213a5..5ba870b0f714d 100644 --- a/docs/en/antalya/cas/architecture/replication.md +++ b/docs/en/antalya/cas/architecture/replication.md @@ -142,13 +142,16 @@ check therefore attributes it to exactly that server root, under the same strict content, and that root can confirm its exact refs. `DETACH`, `ATTACH`, and merge-result renames all reduce to the same two -moves: re-key any *staged* source into the destination, then `republishRef(src → dst)` for any -*committed* source. `republishRef` resolves the source ref freshly and reads its manifest through -the manifest cache, publishes an -equivalent-entry manifest under the destination ref — a **new** manifest id, with blobs untouched -and adopted by evidence — then drops the source ref. A destination that already exists with -identical entries just drops the source, an idempotent re-drive; one with different entries -throws. +moves: re-key any *staged* source into the destination, then rename any *committed* source. Inside +one table namespace the rename is one ref-log `Move` record (`moveRefExact`): the committed ref +changes its name and keeps its manifest, conditional on the manifest the caller resolved — any other +current binding is `CAS_OWNERSHIP_CONFLICT`, and a retried rename that finds the move already done is +a no-op. Across namespaces (`MOVE PARTITION TO TABLE`), or when the destination already exists from an +earlier attempt, `republishRef` publishes an equivalent-entry +manifest under the destination ref — a **new** manifest id, with blobs untouched and adopted by +evidence — then drops the source ref while it still names the republished manifest. A destination +that already exists with identical entries just drops the source, an idempotent re-drive; one with +different entries throws. Manifests are therefore per-ref and never moved: a detach creates a new manifest for `detached/` and retires the old one, and the blobs' net in-degree is unchanged. diff --git a/docs/en/antalya/cas/configuration.md b/docs/en/antalya/cas/configuration.md index 939b10ae93112..4828cfff79d70 100644 --- a/docs/en/antalya/cas/configuration.md +++ b/docs/en/antalya/cas/configuration.md @@ -253,6 +253,14 @@ requiring a fresh pool. | `xxh3-128` | Maximum speed | Fastest, 128-bit, no known collision classes | | `cityhash128` (default) | ClickHouse-ecosystem compatibility, and a possible future hash-reuse mode that avoids recomputation | Fast, but has a known class of collisions that occurs far more often than an ideal hash function would predict | +## Upgrading a pool from 26.6.x {#upgrading-from-26-6} + +A 26.8 server renames a committed part with one ref-log `Move` record (`cas_log` event `ref_move`); a 26.6.x server cannot read such a record. A pool created by a writable 26.8 mount requires reader generation 2 from birth. The pool records the minimum reader generation it requires (`min_reader_generation`). The first writable mount by a 26.8 server raises it from 1 to 2 and logs a WARNING (`min_reader_generation raised 1 -> 2`); the raise is one-way and happens before any part is renamed. A mount with `readonly` set never raises it. That first writable start is the point of no return for 26.6.x mounts of the pool. + +Upgrade order: stop every 26.6.x server that mounts the pool, upgrade all of them to 26.8, then start them. There is no per-server switch: a 26.8 server always raises the floor on its first writable mount. With GC enabled on the 26.6.x servers, follow [Upgrading a pool with GC enabled](operations/debugging.md#upgrade-gc) first. A 26.6.x server that stays up after the raise cannot re-mount the pool on its own: after a lost mount lease or fence it stays unmounted until it is upgraded. + +What a 26.6.x server does if it meets a generation-2 pool: it refuses to mount after a restart (`UNKNOWN_FORMAT_VERSION` at startup: `CAS pool meta: pool requires reader generation 2 but this build supports at most 1`). An already mounted 26.6.x server keeps serving namespaces that have no `ref_move` record; direct recovery of a namespace that has one cannot cross the record until a later generation-1 snapshot gives it a new base; its GC round holds on the undecodable log and performs no deletions. Fetch-by-relink is protected by namespace ownership and the mount fence, not by the log version. No data is deleted or lost. Downgrading a pool whose floor is 2 is not supported. + ## Server-level settings {#server-settings} Source: `ServerSettings.cpp`. This setting is process-wide rather than scoped to one disk block. diff --git a/docs/en/antalya/cas/operations/debugging.md b/docs/en/antalya/cas/operations/debugging.md index 4e9d4b335b7f9..06101daaa570b 100644 --- a/docs/en/antalya/cas/operations/debugging.md +++ b/docs/en/antalya/cas/operations/debugging.md @@ -44,9 +44,33 @@ ORDER BY event_time_microseconds; `skipped`) and `reason` are the two columns to read first; `detail` is a `Map(LowCardinality(String), String)` of decision-specific facts (`condemn_round`, `superseded_token`, `code`, `site`) worth `arrayJoin(detail)` when the summary columns alone do not -explain the decision. See [`system.cas_log`](/operations/system-tables/cas_log) for the full column +explain the decision. A `ref_move` row (a committed part renamed in place) is keyed by the old part name +and carries the new one in `detail['dst_ref']`, so a search by the final name must also match `dst_ref`. See [`system.cas_log`](/operations/system-tables/cas_log) for the full column reference. +### A query failed with CAS_OWNERSHIP_CONFLICT {#ownership-conflict} + +Error code 1039. The committed reference a writer had read was moved, dropped or repointed by another +writer before the writer's own change landed: a rename found the destination name already committed +with other content, the source renamed away, or a live precommit under the destination; a standalone +write into a committed part found the part renamed or dropped meanwhile. The source part is untouched; +a destination reference the failed cross-table rename itself created is taken back. + +The writer must re-resolve the part before trying again. Background work (replication queue entries, +merges, mutations) retries on its own and records the message in `system.replication_queue.last_exception`; +a user command (`ATTACH PART`, `MOVE PARTITION TO TABLE`, `ALTER ... MATERIALIZE`) reports the error +and is rerun by the user. A conflict that repeats on every retry names two parts that really collide +under one name — look at both sides: + +```sql +SELECT event_time_microseconds, event_type, ref_name, detail['dst_ref'] AS dst_ref, reason +FROM system.cas_log +WHERE disk_name = 'cas' AND (ref_name IN ('', '') OR detail['dst_ref'] IN ('', '')) +ORDER BY event_time_microseconds; +``` + +`ProfileEvents.CASOwnershipConflict` counts every refusal. + ### Why GC is not reclaiming {#why-not-reclaiming} Two questions, in order: is this node's scheduler leading, and did its recent rounds actually fold? @@ -266,7 +290,12 @@ decommissioning a pool member is a migration/scale-down operation, not an incide A delete request sent before a restart can still reach the object store later and remove a marker GC relies on, so a blob can be lost; nothing bounds when such requests stop. Entries the old version already scheduled for -deletion are deleted by the upgraded GC without a fresh marker read. Downgrades follow the same procedure. +deletion are deleted by the upgraded GC without a fresh marker read. A downgrade within 26.6.x follows the same +procedure; a pool whose reader floor was raised to 2 by a writable 26.8 mount cannot be downgraded (see +[Upgrading a pool from 26.6.x](../configuration.md#upgrading-from-26-6)). + +The decommission tool opens the pool with the server's CAS disk configuration and never admits a new +`cas_blob_hash` into the pool: run it with the algorithm the pool already records. ## The CLICKHOUSE_USER_FILES gotcha when reproducing a test manually {#user-files-gotcha} diff --git a/docs/en/antalya/metrics_and_events.md b/docs/en/antalya/metrics_and_events.md index 337091c894032..7e968baf16c66 100644 --- a/docs/en/antalya/metrics_and_events.md +++ b/docs/en/antalya/metrics_and_events.md @@ -252,6 +252,8 @@ These counters cover ref-log appends, recovery, snapshots, and reachability edge | `CASRefRecoveryStragglerAdopted` | `Number` | Number of straggler ref-log transactions a recovery compare-and-swap walk met at the slot it tried to seal and adopted, re-sealing at the new T+1. Non-zero means writes from a dying epoch were still materializing when recovery ran. | | `CASRefRecoveryStreamHole` | `Number` | Number of times CAS ref-table recovery found a 404 BELOW a durable same-epoch witness -- a hole in a stream INV-1 makes dense. Restarted while the restart budget lasts (a racing cleanup is the innocent explanation), then reported as corruption. Any sustained non-zero value is data loss, not noise. | | `CASRefRepoint` | `Number` | Number of CAS committed-reference repoints that republish a manifest. Growth indicates standalone part updates or removals. | +| `CASRefMove` | `Number` | Number of CAS committed-reference renames done as one ref-log owner move, with no manifest republication. | +| `CASOwnershipConflict` | `Number` | Number of CAS operations refused with `CAS_OWNERSHIP_CONFLICT`: the committed reference the writer had read was moved, dropped or repointed before its change landed. | | `CASRefRollbackBestEffortDropFailed` | `Number` | Number of rollback cleanup drops that hit a backend failure. A non-zero value means refs may remain live and GC may be delayed. | | `CASRefSnapshotPublishBackoff` | `Number` | Number of CAS ref-table snapshot publications put into backoff after a non-committed result. A non-zero value indicates write failure or uncertainty. | | `CASRefSnapshotPublishDispatched` | `Number` | Number of background CAS ref-table snapshot publications started. High values indicate frequent threshold or read-triggered publishing. | From c63ad7665b6059460037e18d60d025f7507fe103 Mon Sep 17 00:00:00 2001 From: Mikhail Filimonov Date: Fri, 23 Oct 2026 07:56:07 +0200 Subject: [PATCH 14/20] clickhouse-disks: exit 1 only when a cas-* command throws; cas-fsck fails on unreachable objects `DisksApp` returned the error code of the last failing command of a non-interactive `--query`, for every command. Upstream's `04812_clickhouse_disks_move_directory` runs failing commands on purpose and expects exit 0, so it was red on this branch; the raw code also wrapped modulo 256 (256, 512, 768 read as 0). `ICommand::exits_nonzero_on_error` (set by `cas-fsck`, `cas-gc-dryrun`, `cas-gc-rebuild`, `cas-inspect`, `cas-drop-member`) makes the process exit 1 when such a command throws, whatever runs after it; other commands, an unknown command and a parse error keep the "always 0" contract. `cas-fsck` now also throws on `unreachable > 0` for a complete scan (`--namespace` and `--partial` scans classify orphans as unreachable by construction and stay silent). The contract is in `docs/en/antalya/cas/operations/debugging.md`. Test 05393: clean pool exits 0; an unreferenced blob copy exits 1; a missing referenced blob exits 1; `remove` of a missing path on a local disk exits 0; `cas-fsck; list .` exits 1. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Mikhail Filimonov --- docs/en/antalya/cas/operations/debugging.md | 5 ++ programs/disks/CommandCaDropMember.cpp | 1 + programs/disks/CommandCaGcDryRun.cpp | 1 + programs/disks/CommandCaGcRebuild.cpp | 1 + programs/disks/CommandCaInspect.cpp | 1 + programs/disks/CommandFsck.cpp | 9 +++ programs/disks/DisksApp.cpp | 14 ++-- programs/disks/ICommand.h | 4 ++ .../05393_cas_disks_exit_code.reference | 5 ++ .../0_stateless/05393_cas_disks_exit_code.sh | 64 +++++++++++++++++++ .../0_stateless/05393_cas_disks_exit_code.xml | 20 ++++++ 11 files changed, 120 insertions(+), 5 deletions(-) create mode 100644 tests/queries/0_stateless/05393_cas_disks_exit_code.reference create mode 100755 tests/queries/0_stateless/05393_cas_disks_exit_code.sh create mode 100644 tests/queries/0_stateless/05393_cas_disks_exit_code.xml diff --git a/docs/en/antalya/cas/operations/debugging.md b/docs/en/antalya/cas/operations/debugging.md index 06101daaa570b..8fbdaf9bfd536 100644 --- a/docs/en/antalya/cas/operations/debugging.md +++ b/docs/en/antalya/cas/operations/debugging.md @@ -275,6 +275,11 @@ clickhouse-disks -C config.xml --disk cas cas-inspect '' clickhouse-disks -C config.xml --disk cas cas-gc-rebuild --force ``` +Exit code of a non-interactive `--query`: 1 when any `cas-*` command in it threw (`cas-fsck` throws on +`dangling`, `chain_broken`, `corrupted_runs`, `lifeless_keys`, and on `unreachable > 0` for a complete +scan — not with `--namespace` or `--partial`), whatever ran after it; the upstream commands keep +`clickhouse-disks`'s "always 0" contract, as do an unknown command and a parse error. + `cas-drop-member` — the offline twin of `SYSTEM CAS DROP POOL MEMBER` — is covered on the [migration page](/antalya/cas/operations/migration#decommission) alongside the SQL form, since decommissioning a pool member is a migration/scale-down operation, not an incident-time tool. diff --git a/programs/disks/CommandCaDropMember.cpp b/programs/disks/CommandCaDropMember.cpp index f95693c2a02f3..60b2472f5dcab 100644 --- a/programs/disks/CommandCaDropMember.cpp +++ b/programs/disks/CommandCaDropMember.cpp @@ -24,6 +24,7 @@ class CommandCaDropMember final : public ICommand CommandCaDropMember() : ICommand("CommandCaDropMember") { command_name = "cas-drop-member"; + exits_nonzero_on_error = true; description = "Decommission a DEAD pool member: erase its namespaces, debris, staging, roots " "objects and mount slot. Refuses a live member. Open the CA disk read-only " "(the admin claim is made internally)."; diff --git a/programs/disks/CommandCaGcDryRun.cpp b/programs/disks/CommandCaGcDryRun.cpp index 2e4ba6a7849ed..0eb490211d6e2 100644 --- a/programs/disks/CommandCaGcDryRun.cpp +++ b/programs/disks/CommandCaGcDryRun.cpp @@ -20,6 +20,7 @@ class CommandCaGcDryRun final : public ICommand CommandCaGcDryRun() : ICommand("CommandCaGcDryRun") { command_name = "cas-gc-dryrun"; + exits_nonzero_on_error = true; description = "Preview the next GC round's deletes for a content-addressed pool (read-only, no deletes)."; } diff --git a/programs/disks/CommandCaGcRebuild.cpp b/programs/disks/CommandCaGcRebuild.cpp index 5e488296ac4eb..8d1d0b727761e 100644 --- a/programs/disks/CommandCaGcRebuild.cpp +++ b/programs/disks/CommandCaGcRebuild.cpp @@ -32,6 +32,7 @@ class CommandCaGcRebuild final : public ICommand CommandCaGcRebuild() : ICommand("CommandCaGcRebuild") { command_name = "cas-gc-rebuild"; + exits_nonzero_on_error = true; description = "Disaster recovery: rebuild a content-addressed pool's gc/state baseline from raw owner " "state after the GC guard has refused every round (see CORRUPTED_DATA in the gc log). " "Requires a read-only-opened disk; never run against a disk a live server has mounted."; diff --git a/programs/disks/CommandCaInspect.cpp b/programs/disks/CommandCaInspect.cpp index 32df388f2cdbf..5ebb1a6d7c391 100644 --- a/programs/disks/CommandCaInspect.cpp +++ b/programs/disks/CommandCaInspect.cpp @@ -26,6 +26,7 @@ class CommandCaInspect final : public ICommand CommandCaInspect() : ICommand("CommandCaInspect") { command_name = "cas-inspect"; + exits_nonzero_on_error = true; description = "Decode a content-addressed pool object (by its raw object-storage key) to JSON (read-only)."; options_description.add_options()("key", po::value(), "the raw object-storage key to decode (mandatory, positional)"); positional_options_description.add("key", 1); diff --git a/programs/disks/CommandFsck.cpp b/programs/disks/CommandFsck.cpp index 5727f7d69c11c..90ecdfd534b36 100644 --- a/programs/disks/CommandFsck.cpp +++ b/programs/disks/CommandFsck.cpp @@ -22,6 +22,7 @@ class CommandFsck final : public ICommand CommandFsck() : ICommand("CommandFsck") { command_name = "cas-fsck"; + exits_nonzero_on_error = true; description = "Independently verify content-addressed pool reachability (read-only). " "Exits nonzero if any reachable object is missing (dangling)."; options_description.add_options()("detail", "list per-object rows (class, key, size, reachable_from)")( @@ -177,6 +178,14 @@ class CommandFsck final : public ICommand "cas-fsck: {} key(s) under this pool name are malformed or unresolvable — no current " "writer could have produced them, or their catalog incarnation is ambiguous/unreadable " "(the keys are listed as `lifeless-key` rows under --detail)", report.lifeless_keys); + /// Debris nothing names. Only a complete scan can say so: a namespace-scoped scan classifies the + /// orphan manifests of other namespaces as unreachable by construction, and a partial scan has not + /// seen every referrer. + if (report.unreachable > 0 && namespace_prefix.empty() && !partial && !report.partial) + throw Exception( + ErrorCodes::BAD_ARGUMENTS, + "cas-fsck: {} object(s) are reachable from no ref (listed as `unreachable` rows under --detail)", + report.unreachable); } }; diff --git a/programs/disks/DisksApp.cpp b/programs/disks/DisksApp.cpp index 39f15a9a6f7e0..6025628f25b13 100644 --- a/programs/disks/DisksApp.cpp +++ b/programs/disks/DisksApp.cpp @@ -221,6 +221,7 @@ bool DisksApp::processQueryText(const String & text) for (const auto & subquery : subqueries) { std::optional error_string; + command.reset(); // std::cerr << "SUBQUERY: " << subquery << "\n"; try { @@ -235,7 +236,8 @@ bool DisksApp::processQueryText(const String & text) { int code = err.code(); error_string = getExceptionMessageForLogging(err, true, false); - last_command_exit_code = code; + if (command && command->exits_nonzero_on_error) + last_command_exit_code = 1; if (code == ErrorCodes::BAD_ARGUMENTS) { if (command.get()) @@ -252,12 +254,14 @@ bool DisksApp::processQueryText(const String & text) catch (std::exception & err) { error_string = err.what(); - last_command_exit_code = ErrorCodes::STD_EXCEPTION; + if (command && command->exits_nonzero_on_error) + last_command_exit_code = 1; } catch (...) // Ok: report unknown exception { error_string = "Unknown exception"; - last_command_exit_code = ErrorCodes::STD_EXCEPTION; + if (command && command->exits_nonzero_on_error) + last_command_exit_code = 1; } if (error_string.has_value()) { @@ -621,8 +625,8 @@ int DisksApp::main(const std::vector & /*args*/) if (log_file) log_file->close(); - /// Non-interactive runs surface a failing command as a nonzero process exit (CI/cron gating, - /// e.g. `cas-fsck` reporting dangling objects). Interactive sessions are unaffected. + /// A non-interactive run exits 1 when a `cas-*` command threw (`ICommand::exits_nonzero_on_error`), + /// whatever ran after it; every other command keeps the upstream "always 0" contract. if (query.has_value() && last_command_exit_code != 0) return last_command_exit_code; return Application::EXIT_OK; diff --git a/programs/disks/ICommand.h b/programs/disks/ICommand.h index ad840494e8cb8..0220501a92682 100644 --- a/programs/disks/ICommand.h +++ b/programs/disks/ICommand.h @@ -111,6 +111,10 @@ class ICommand String command_name; String description; ProgramOptionsDescription options_description; + /// A non-interactive `--query` exits 1 when a command with this flag throws. Upstream commands keep + /// the "always 0" contract (04812 runs failing commands on purpose); the `cas-*` tools set it so CI + /// and cron can gate on `cas-fsck`. + bool exits_nonzero_on_error = false; protected: PositionalProgramOptionsDescription positional_options_description; diff --git a/tests/queries/0_stateless/05393_cas_disks_exit_code.reference b/tests/queries/0_stateless/05393_cas_disks_exit_code.reference new file mode 100644 index 0000000000000..addaab449b941 --- /dev/null +++ b/tests/queries/0_stateless/05393_cas_disks_exit_code.reference @@ -0,0 +1,5 @@ +clean_fsck 0 +unreachable_fsck 1 +dangling_fsck 1 +remove_missing 0 +fsck_then_list 1 diff --git a/tests/queries/0_stateless/05393_cas_disks_exit_code.sh b/tests/queries/0_stateless/05393_cas_disks_exit_code.sh new file mode 100755 index 0000000000000..98e3123887a3e --- /dev/null +++ b/tests/queries/0_stateless/05393_cas_disks_exit_code.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Tags: no-fasttest +# no-fasttest: cas is an object-storage metadata type, absent from the fasttest image. + +# Exit code of `clickhouse-disks --query`: upstream commands keep the "always 0" contract +# (04812 relies on it); a `cas-*` command that throws makes the process exit 1, whatever runs after it. + +CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../shell_config.sh +. "$CUR_DIR"/../shell_config.sh + +DISK_NAME="ca_exit_${CLICKHOUSE_TEST_UNIQUE_NAME}_${RANDOM}" +POOL_DIR="${CLICKHOUSE_USER_FILES_UNIQUE}_disks_exit_${RANDOM}" +LOCAL_DIR="${CLICKHOUSE_TMP}/${CLICKHOUSE_TEST_UNIQUE_NAME}_local" +rm -rf "${POOL_DIR:?}" "${LOCAL_DIR:?}" +mkdir -p "${POOL_DIR}" "${LOCAL_DIR}" +export TEST_DISK_05393_POOL="${POOL_DIR}/" +export TEST_DISK_05393_LOCAL="${LOCAL_DIR}/" +config="$CUR_DIR/05393_cas_disks_exit_code.xml" + +${CLICKHOUSE_CLIENT} --multiquery < "; the command's own output is not part of the oracle. +function disks() +{ + local label="$1" disk="$2" query="$3" + clickhouse-disks -C "$config" --disk "$disk" --query "$query" > /dev/null 2>&1 + echo "$label $?" +} + +disks clean_fsck cas_ro_05393 "cas-fsck" + +# A canonical blob key nothing references: a copy of a live blob under another hash. +blob=$(find "${POOL_DIR}" -path '*/blobs/*' -type f ! -name '*.meta' | head -1) +stray="$(dirname "$blob")/$(basename "$blob" | tr '0-9a-f' '1-9a-f0')" +cp "$blob" "$stray" +disks unreachable_fsck cas_ro_05393 "cas-fsck" + +# A blob a committed manifest names is gone. +rm -f "$blob" +disks dangling_fsck cas_ro_05393 "cas-fsck" + +# An upstream command that throws keeps the upstream contract. +disks remove_missing local_05393 "remove no_such_path_05393" + +# One failing cas-* command decides the exit code of the whole query. +disks fsck_then_list cas_ro_05393 "cas-fsck; list ." + +${CLICKHOUSE_CLIENT} --query "DROP TABLE t_disks_exit SYNC" +${CLICKHOUSE_CLIENT} --allow_repeated_settings --send_logs_level=fatal --query "SYSTEM CAS FORGET '${DISK_NAME}'" +rm -rf "${POOL_DIR:?}" "${LOCAL_DIR:?}" diff --git a/tests/queries/0_stateless/05393_cas_disks_exit_code.xml b/tests/queries/0_stateless/05393_cas_disks_exit_code.xml new file mode 100644 index 0000000000000..e49cef03d3443 --- /dev/null +++ b/tests/queries/0_stateless/05393_cas_disks_exit_code.xml @@ -0,0 +1,20 @@ + + + + + + object_storage + local + cas + stateless-05393-ro + + true + 0 + + + local + + + + + From c9d03e629d515a914d8791ae661b0eb6336e960f Mon Sep 17 00:00:00 2001 From: Mikhail Filimonov Date: Fri, 23 Oct 2026 08:33:02 +0200 Subject: [PATCH 15/20] MergeTree: the part-commit fail points fire for Packed parts too `part_storage_fail_commit_transaction` and the two pause fail points were injected only in `DataPartStorageOnDiskFull::commitTransaction`. With a randomized `min_bytes_for_full_part_storage` the part is Packed, the fail point never fires and `05014_insert_dedup_disk_commit_failpoint` sees the INSERT succeed instead of `FAULT_INJECTED` (CI: Stateless amd_asan_ubsan, db disk, PR 2647). The Packed storage now injects the same seams before and after `transaction->commit()`. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Mikhail Filimonov --- .../MergeTree/DataPartStorageOnDiskPacked.cpp | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/Storages/MergeTree/DataPartStorageOnDiskPacked.cpp b/src/Storages/MergeTree/DataPartStorageOnDiskPacked.cpp index 48e3a087ff2cf..cd832c11ed672 100644 --- a/src/Storages/MergeTree/DataPartStorageOnDiskPacked.cpp +++ b/src/Storages/MergeTree/DataPartStorageOnDiskPacked.cpp @@ -1,4 +1,5 @@ #include +#include #include #include @@ -19,8 +20,16 @@ namespace DB { +namespace FailPoints +{ + extern const char part_storage_fail_commit_transaction[]; + extern const char part_storage_pause_before_commit_transaction[]; + extern const char part_storage_pause_after_commit_transaction[]; +} + namespace ErrorCodes { + extern const int FAULT_INJECTED; extern const int LOGICAL_ERROR; extern const int NOT_IMPLEMENTED; extern const int NOT_INITIALIZED; @@ -545,8 +554,18 @@ void DataPartStorageOnDiskPacked::commitTransaction() if (has_shared_transaction) return; + /// The same fault-injection seams as the Full storage: a test fails or holds the close of the PART's + /// disk transaction whatever storage format the randomized settings picked. + fiu_do_on(FailPoints::part_storage_fail_commit_transaction, + { + throw Exception(ErrorCodes::FAULT_INJECTED, "part_storage_fail_commit_transaction"); + }); + FailPointInjection::pauseFailPoint(FailPoints::part_storage_pause_before_commit_transaction); + transaction->commit(); + FailPointInjection::pauseFailPoint(FailPoints::part_storage_pause_after_commit_transaction); + if (!reader) reader.emplace(volume->getDisk(), getRelativeDataPath(), getReadSettings()); From cbbd815139796f750e4aa15e13ea1080692db9ad Mon Sep 17 00:00:00 2001 From: Mikhail Filimonov Date: Fri, 23 Oct 2026 09:47:39 +0200 Subject: [PATCH 16/20] cas-fsck: unreachable stays a soft finding; Packed twin of the commit fail-point test `report.unreachable` counts every present blob no ref names, which includes the GC pipeline (`pending_gc`, `awaiting_gc`) and an in-flight build's not yet adopted blobs (`unaccounted`), so exiting 1 on it made a healthy pool under load fail `cas-fsck`. The verdict is back to the hard findings (`dangling`, `chain_broken`, `corrupted_runs`, `lifeless_keys`); the command description and `debugging.md` name them. 05393 now also asserts the message of each case; its unreferenced-blob case expects exit 0 with `unreachable=1` in the summary. 05397 is the Packed twin of 05014 (a Packed part on a local disk, `part_storage_fail_commit_transaction`, `FAULT_INJECTED`, the retry inserts), so the Packed fail points have a test that does not depend on randomized settings; the pause-after fail point sits after the state reset as in the Full storage. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Mikhail Filimonov --- docs/en/antalya/cas/operations/debugging.md | 7 +++-- programs/disks/CommandFsck.cpp | 14 ++++------ .../MergeTree/DataPartStorageOnDiskPacked.cpp | 5 ++-- .../05393_cas_disks_exit_code.reference | 10 +++---- .../0_stateless/05393_cas_disks_exit_code.sh | 28 +++++++++++-------- ...397_packed_part_commit_failpoint.reference | 2 ++ .../05397_packed_part_commit_failpoint.sql | 22 +++++++++++++++ 7 files changed, 57 insertions(+), 31 deletions(-) create mode 100644 tests/queries/0_stateless/05397_packed_part_commit_failpoint.reference create mode 100644 tests/queries/0_stateless/05397_packed_part_commit_failpoint.sql diff --git a/docs/en/antalya/cas/operations/debugging.md b/docs/en/antalya/cas/operations/debugging.md index 8fbdaf9bfd536..1da3143023a31 100644 --- a/docs/en/antalya/cas/operations/debugging.md +++ b/docs/en/antalya/cas/operations/debugging.md @@ -276,9 +276,10 @@ clickhouse-disks -C config.xml --disk cas cas-gc-rebuild --force ``` Exit code of a non-interactive `--query`: 1 when any `cas-*` command in it threw (`cas-fsck` throws on -`dangling`, `chain_broken`, `corrupted_runs`, `lifeless_keys`, and on `unreachable > 0` for a complete -scan — not with `--namespace` or `--partial`), whatever ran after it; the upstream commands keep -`clickhouse-disks`'s "always 0" contract, as do an unknown command and a parse error. +`dangling`, `chain_broken`, `corrupted_runs` and `lifeless_keys`; `unreachable` is a soft count that +includes the GC pipeline and in-flight builds), and so does a `cas-*` command given bad arguments, +whatever ran after it; the upstream commands keep `clickhouse-disks`'s "always 0" contract, as does an +unknown command name. `cas-drop-member` — the offline twin of `SYSTEM CAS DROP POOL MEMBER` — is covered on the [migration page](/antalya/cas/operations/migration#decommission) alongside the SQL form, since diff --git a/programs/disks/CommandFsck.cpp b/programs/disks/CommandFsck.cpp index 90ecdfd534b36..6f7af51b92d94 100644 --- a/programs/disks/CommandFsck.cpp +++ b/programs/disks/CommandFsck.cpp @@ -24,7 +24,8 @@ class CommandFsck final : public ICommand command_name = "cas-fsck"; exits_nonzero_on_error = true; description = "Independently verify content-addressed pool reachability (read-only). " - "Exits nonzero if any reachable object is missing (dangling)."; + "Exits 1 on a hard finding: a reachable object missing (dangling), a hole in a ref-log " + "stream (chain_broken), a corrupted GC run, or a malformed key (lifeless_keys)."; options_description.add_options()("detail", "list per-object rows (class, key, size, reachable_from)")( "timeout", po::value(), "abort the scan after N seconds with a clear error instead of hanging (default 600; 0 = unbounded)")( "namespace", po::value(), "scope the scan to namespaces with this prefix (skips the pool-wide " @@ -178,14 +179,9 @@ class CommandFsck final : public ICommand "cas-fsck: {} key(s) under this pool name are malformed or unresolvable — no current " "writer could have produced them, or their catalog incarnation is ambiguous/unreadable " "(the keys are listed as `lifeless-key` rows under --detail)", report.lifeless_keys); - /// Debris nothing names. Only a complete scan can say so: a namespace-scoped scan classifies the - /// orphan manifests of other namespaces as unreachable by construction, and a partial scan has not - /// seen every referrer. - if (report.unreachable > 0 && namespace_prefix.empty() && !partial && !report.partial) - throw Exception( - ErrorCodes::BAD_ARGUMENTS, - "cas-fsck: {} object(s) are reachable from no ref (listed as `unreachable` rows under --detail)", - report.unreachable); + /// `unreachable` stays a soft finding: it counts every present blob no ref names, and that includes + /// the GC pipeline's `pending_gc`/`awaiting_gc` and an in-flight build's not-yet-adopted blobs + /// (`unaccounted`), so a healthy pool under load is never clean by that measure. } }; diff --git a/src/Storages/MergeTree/DataPartStorageOnDiskPacked.cpp b/src/Storages/MergeTree/DataPartStorageOnDiskPacked.cpp index cd832c11ed672..e807ba07c4584 100644 --- a/src/Storages/MergeTree/DataPartStorageOnDiskPacked.cpp +++ b/src/Storages/MergeTree/DataPartStorageOnDiskPacked.cpp @@ -564,13 +564,14 @@ void DataPartStorageOnDiskPacked::commitTransaction() transaction->commit(); - FailPointInjection::pauseFailPoint(FailPoints::part_storage_pause_after_commit_transaction); - if (!reader) reader.emplace(volume->getDisk(), getRelativeDataPath(), getReadSettings()); transaction.reset(); is_precommitted = false; + + /// Lets a test stop the process between the durable commit and the rename that follows. + FailPointInjection::pauseFailPoint(FailPoints::part_storage_pause_after_commit_transaction); } void DataPartStorageOnDiskPacked::checkpointTransaction() diff --git a/tests/queries/0_stateless/05393_cas_disks_exit_code.reference b/tests/queries/0_stateless/05393_cas_disks_exit_code.reference index addaab449b941..30c2608b74a09 100644 --- a/tests/queries/0_stateless/05393_cas_disks_exit_code.reference +++ b/tests/queries/0_stateless/05393_cas_disks_exit_code.reference @@ -1,5 +1,5 @@ -clean_fsck 0 -unreachable_fsck 1 -dangling_fsck 1 -remove_missing 0 -fsck_then_list 1 +clean_fsck 0 1 +unreachable_fsck 0 1 +dangling_fsck 1 1 +remove_missing 0 1 +fsck_then_next 1 2 diff --git a/tests/queries/0_stateless/05393_cas_disks_exit_code.sh b/tests/queries/0_stateless/05393_cas_disks_exit_code.sh index 98e3123887a3e..ddd1931919ad5 100755 --- a/tests/queries/0_stateless/05393_cas_disks_exit_code.sh +++ b/tests/queries/0_stateless/05393_cas_disks_exit_code.sh @@ -33,31 +33,35 @@ SETTINGS disk = disk( INSERT INTO t_disks_exit SELECT number, toString(number) FROM numbers(1000); EOF -# Prints "