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: 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..1da3143023a31 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? @@ -251,6 +275,12 @@ 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` 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 decommissioning a pool member is a migration/scale-down operation, not an incident-time tool. @@ -266,7 +296,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 dad8442ef7a59..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. | @@ -327,7 +329,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/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..6f7af51b92d94 100644 --- a/programs/disks/CommandFsck.cpp +++ b/programs/disks/CommandFsck.cpp @@ -22,8 +22,10 @@ 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)."; + "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 " @@ -177,6 +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); + /// `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/programs/disks/DisksApp.cpp b/programs/disks/DisksApp.cpp index ef3f3ebfc9f10..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,8 +236,8 @@ bool DisksApp::processQueryText(const String & text) { int code = err.code(); error_string = getExceptionMessageForLogging(err, true, false); - if (command && command->command_name.starts_with("cas-")) - 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()) @@ -253,14 +254,14 @@ bool DisksApp::processQueryText(const String & text) catch (std::exception & err) { error_string = err.what(); - if (command && command->command_name.starts_with("cas-")) - 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"; - if (command && command->command_name.starts_with("cas-")) - last_command_exit_code = ErrorCodes::STD_EXCEPTION; + if (command && command->exits_nonzero_on_error) + last_command_exit_code = 1; } if (error_string.has_value()) { @@ -624,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/src/Common/ErrorCodes.cpp b/src/Common/ErrorCodes.cpp index ba26a7a78b042..834515a8a87f5 100644 --- a/src/Common/ErrorCodes.cpp +++ b/src/Common/ErrorCodes.cpp @@ -695,6 +695,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 @@ -711,7 +712,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/FailPoint.cpp b/src/Common/FailPoint.cpp index 8e4d52a975b6f..4afe507ca35c2 100644 --- a/src/Common/FailPoint.cpp +++ b/src/Common/FailPoint.cpp @@ -243,6 +243,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/Common/ProfileEvents.cpp b/src/Common/ProfileEvents.cpp index 0ec0ff0a24332..054a27af0ae2a 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) \ @@ -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) \ @@ -975,7 +977,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/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/Disks/DiskObjectStorage/DiskObjectStorageTransaction.cpp b/src/Disks/DiskObjectStorage/DiskObjectStorageTransaction.cpp index c20e854fe9802..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)); @@ -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) @@ -668,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"); @@ -722,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 d8cb9068c8692..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: @@ -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/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/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/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 4e3dc902c5bd6..e4a33e54a230d 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 -- @@ -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 a22b6cbf90bea..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; @@ -265,9 +264,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; } @@ -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/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/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/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/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/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/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/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/DiskObjectStorage/MetadataStorages/IMetadataStorage.h b/src/Disks/DiskObjectStorage/MetadataStorages/IMetadataStorage.h index 1939a99fc41bd..28d40443231f8 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 @@ -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/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/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/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/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_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_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_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_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_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_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_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_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_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_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_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); 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); }); diff --git a/src/Disks/tests/gtest_metadata_storage_from_memory.cpp b/src/Disks/tests/gtest_metadata_storage_from_memory.cpp index 3ec38f3251afc..c520852ddc93f 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( @@ -420,6 +437,37 @@ TEST(MetadataStorageFromMemory, TransientBuildStateDetection) EXPECT_EQ(takePendingOwnRemovals(tx), std::vector{"blobs/dropped"}); } +struct LocalDiskUnderTest +{ + std::shared_ptr object_storage; + std::shared_ptr disk; +}; + +/// A `DiskObjectStorage` over a local object storage under `dir`. Its constructor starts a +/// `BlobKillerThread` on the global context's schedule pool, whose threads live in the global thread +/// pool for the rest of the process. +static LocalDiskUnderTest makeDiskOverLocal(const String & dir) +{ + LocalDiskUnderTest result; + result.object_storage = std::make_shared( + LocalObjectStorageSettings("test", dir + "blobs", /*read_only_=*/false)); + DiskPtr metadata_disk = std::make_shared("metadata_disk", dir + "metadata"); + MetadataStoragePtr metadata_storage = std::make_shared( + metadata_disk, "/", result.object_storage->createKeyGenerator(), + /*persist_removal_queue_=*/false, /*removal_log_compaction_threshold_=*/1000); + + std::unordered_map cluster_registry = {{"main", {true, true, ""}}}; + std::unordered_map object_storage_registry = {{"main", result.object_storage}}; + + ClusterConfigurationPtr cluster = std::make_shared("local_blobs", std::move(cluster_registry)); + ObjectStorageRouterPtr object_storages = std::make_shared(std::move(object_storage_registry)); + + Poco::AutoPtr config(new Poco::Util::XMLConfiguration()); + result.disk = std::make_shared( + "local_blobs", std::move(cluster), std::move(metadata_storage), std::move(object_storages), nullptr, *config, ""); + return result; +} + /// A real disk transaction bound to a memory metadata storage: uploads hit the real object /// storage, metadata applies immediately. class DiskObjectStorageOverMemoryMetadataTest : public ::testing::Test @@ -430,23 +478,9 @@ class DiskObjectStorageOverMemoryMetadataTest : public ::testing::Test temp_dir = std::make_unique(); temp_dir->createDirectories(); - const String dir = temp_dir->path() + "/"; - object_storage = std::make_shared( - LocalObjectStorageSettings("test", dir + "blobs", /*read_only_=*/false)); - DiskPtr metadata_disk = std::make_shared("metadata_disk", dir + "metadata"); - MetadataStoragePtr metadata_storage = std::make_shared( - metadata_disk, "/", object_storage->createKeyGenerator(), - /*persist_removal_queue_=*/false, /*removal_log_compaction_threshold_=*/1000); - - std::unordered_map cluster_registry = {{"main", {true, true, ""}}}; - std::unordered_map object_storage_registry = {{"main", object_storage}}; - - ClusterConfigurationPtr cluster = std::make_shared("local_blobs", std::move(cluster_registry)); - ObjectStorageRouterPtr object_storages = std::make_shared(std::move(object_storage_registry)); - - Poco::AutoPtr config(new Poco::Util::XMLConfiguration()); - disk = std::make_shared( - "local_blobs", std::move(cluster), std::move(metadata_storage), std::move(object_storages), nullptr, *config, ""); + auto under_test = makeDiskOverLocal(temp_dir->path() + "/"); + object_storage = std::move(under_test.object_storage); + disk = std::move(under_test.disk); } size_t countBlobs() const @@ -653,3 +687,74 @@ 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 +/// Not the fixture: a death test runs before every other suite, and a `DiskObjectStorage` built in the +/// parent would leave the global schedule pool's threads in the global thread pool for the whole run, +/// so `ThreadPool.GlobalFull1`'s `global_pool.wait()` never returns. The disk exists only in the child +/// that dies. +TEST(DiskObjectStorageOverMemoryMetadataDeathTest, CommitRefusesQueuedOperationOnEagerStorageAborts) +{ + EXPECT_DEATH( + { + Poco::TemporaryFile temp_dir; + temp_dir.createDirectories(); + auto tx = makeDiskOverLocal(temp_dir.path() + "/").disk->wrapWithMemoryMetadata()->createTransaction(); + QueueProbe::plantDeferredOperation(tx); + tx->commit(); + }, + "must not queue deferred operations"); +} + +TEST(DiskObjectStorageOverMemoryMetadataDeathTest, TryCommitRefusesQueuedOperationOnEagerStorageAborts) +{ + EXPECT_DEATH( + { + Poco::TemporaryFile temp_dir; + temp_dir.createDirectories(); + auto tx = makeDiskOverLocal(temp_dir.path() + "/").disk->wrapWithMemoryMetadata()->createTransaction(); + QueueProbe::plantDeferredOperation(tx); + tx->tryCommit(NoCommitOptions{}); + }, + "must not queue deferred operations"); +} +#endif 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 02671aec6c044..e28948af5097c 100644 --- a/src/IO/S3/Client.cpp +++ b/src/IO/S3/Client.cpp @@ -109,10 +109,8 @@ bool Client::RetryStrategy::ShouldRetry(const Aws::Client::AWSErrorsetKMSHeaders(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: " diff --git a/src/IO/S3/tests/gtest_aws_s3_client.cpp b/src/IO/S3/tests/gtest_aws_s3_client.cpp index 88ccd86ef301b..000af1c95fdf8 100644 --- a/src/IO/S3/tests/gtest_aws_s3_client.cpp +++ b/src/IO/S3/tests/gtest_aws_s3_client.cpp @@ -217,7 +217,7 @@ static void testServerSideEncryption( TEST(IOTestAwsS3Client, DoesNotRetryPreconditionFailed) { - /// B166: a 412 Precondition Failed (conditional CAS/dedup writes of the content-addressed + /// A 412 Precondition Failed (conditional CAS/dedup writes of the content-addressed /// backend) must NOT be retried, even when the SDK marks it retryable because an S3-compatible /// server (e.g. RustFS) returned a body whose ExceptionName it could not parse. Retrying it is a /// storm that stalls the write path. 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()); 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) diff --git a/src/Storages/MergeTree/DataPartStorageOnDiskBase.cpp b/src/Storages/MergeTree/DataPartStorageOnDiskBase.cpp index 362597ece842f..da86ef58fee83 100644 --- a/src/Storages/MergeTree/DataPartStorageOnDiskBase.cpp +++ b/src/Storages/MergeTree/DataPartStorageOnDiskBase.cpp @@ -497,7 +497,7 @@ void DataPartStorageOnDiskBase::backup( auto disk = volume->getDisk(); - /// 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..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 @@ -254,11 +256,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; @@ -287,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/DataPartStorageOnDiskPacked.cpp b/src/Storages/MergeTree/DataPartStorageOnDiskPacked.cpp index 488459c56af84..8f20d92e4e1ea 100644 --- a/src/Storages/MergeTree/DataPartStorageOnDiskPacked.cpp +++ b/src/Storages/MergeTree/DataPartStorageOnDiskPacked.cpp @@ -1,4 +1,5 @@ #include +#include #include #include @@ -14,7 +15,6 @@ #include #include #include -#include #include namespace DB @@ -23,6 +23,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 @@ -559,10 +561,13 @@ 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(); @@ -571,6 +576,9 @@ void DataPartStorageOnDiskPacked::commitTransaction() 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/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/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/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/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 44d4c67ccf6b0..7cd316a352d4a 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); } @@ -1565,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)); } @@ -1610,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/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}; diff --git a/src/Storages/MergeTree/MergeTreeData.cpp b/src/Storages/MergeTree/MergeTreeData.cpp index 09541e0891936..20f877d64adc7 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; @@ -10873,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() @@ -10921,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/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/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/MutateTask.cpp b/src/Storages/MergeTree/MutateTask.cpp index 8d6a89b270aa1..cead60e122d59 100644 --- a/src/Storages/MergeTree/MutateTask.cpp +++ b/src/Storages/MergeTree/MutateTask.cpp @@ -2511,9 +2511,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/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/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/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)); } 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); 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_gcs/test.py b/tests/integration/test_cas_gcs/test.py index 1eb8dda44758e..ceb476f620429 100644 --- a/tests/integration/test_cas_gcs/test.py +++ b/tests/integration/test_cas_gcs/test.py @@ -863,7 +863,10 @@ def test_ordinary_goog4_traffic_keeps_upstream_semantics(): assert record["request_class"] == "ordinary_non_cas", record assert headers.get("authorization", "").startswith("GOOG4-HMAC-SHA256 "), record assert "x-goog-if-generation-match" not in headers, record - assert "if-match" not in headers, record + # Upstream 26.8 pins every read to the ETag it captured at setup (`s3_validate_etag_on_read`): + # an If-Match on a GET or HEAD is upstream semantics. Conditional writes stay CAS-only. + if record["method"] not in ("GET", "HEAD"): + assert "if-match" not in headers, record assert "if-none-match" not in headers, record assert "x-amz-copy-source" not in headers, record assert "x-goog-copy-source" not in headers, record 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..01f35f338fae0 --- /dev/null +++ b/tests/integration/test_cas_ref_move_compat/test.py @@ -0,0 +1,66 @@ +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. The server is stopped while the disk config lands: + # a running server's config reloader would mount the pool (and raise the floor) before the restart, and + # the log line asserted below would belong to a process whose log the restart discards. + new.stop_clickhouse() + 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.start_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/integration/test_database_backup/configs/backups_local_metadata.xml b/tests/integration/test_database_backup/configs/backups_local_metadata.xml new file mode 100644 index 0000000000000..359f9d82e780f --- /dev/null +++ b/tests/integration/test_database_backup/configs/backups_local_metadata.xml @@ -0,0 +1,53 @@ + + + + + local + /backups/ + + + s3_plain + http://minio1:9001/root/data/disks/disk_s3_plain/ + minio + ClickHouse_Minio_P@ssw0rd + + + object_storage + local_blob_storage + plain + /local_plain/ + + + s3_plain + http://rustfs1:11121/test/backup_disk_s3_plain_rustfs/ + clickhouse + clickhouse + + + object_storage + s3 + cas + itest-database-backup-local-metadata + http://rustfs1:11121/test/database_backup_cas_local_metadata/ + clickhouse + clickhouse + + + + + + + cas + + + + + + + backup_disk_local + backup_disk_s3_plain + backup_disk_object_storage_local_plain + backup_disk_s3_plain_rustfs + /backups + + diff --git a/tests/integration/test_database_backup/test.py b/tests/integration/test_database_backup/test.py index c13169a78e387..a2106380ec0bc 100644 --- a/tests/integration/test_database_backup/test.py +++ b/tests/integration/test_database_backup/test.py @@ -16,9 +16,10 @@ # `test_database_backup_metadata_with_quoted_locator_loads_on_restart` rewrites a database metadata file # in place, and such a file only exists when the metadata lives on the local disk, so that test runs on an # instance which keeps the local database disk. +# Its own CAS server root: two servers claiming one `cas_server_root_id` on the same bucket refuse to start. instance_local_metadata = cluster.add_instance( "instance_local_metadata", - main_configs=["configs/backups.xml"], + main_configs=["configs/backups_local_metadata.xml"], stay_alive=True, with_minio=True, with_remote_database_disk=False, diff --git a/tests/integration/test_storage_gcp_auth/test.py b/tests/integration/test_storage_gcp_auth/test.py index b5be090d95694..f898ce417ea70 100644 --- a/tests/integration/test_storage_gcp_auth/test.py +++ b/tests/integration/test_storage_gcp_auth/test.py @@ -197,13 +197,12 @@ def assert_default_oauth(requests): for request in requests: headers = request["headers"] assert headers.get("authorization", "").startswith("Bearer "), request - for name in ( - "x-goog-if-generation-match", - "if-match", - "if-none-match", - "x-amz-copy-source", - "x-goog-copy-source", - ): + forbidden = ["x-goog-if-generation-match", "if-none-match", "x-amz-copy-source", "x-goog-copy-source"] + # Upstream 26.8 pins every read to the ETag it captured at setup (`s3_validate_etag_on_read`): + # an If-Match on a GET or HEAD is upstream semantics. Conditional writes stay CAS-only. + if request["method"] not in ("GET", "HEAD"): + forbidden.append("if-match") + for name in forbidden: assert name not in headers, request assert not [name for name in headers if name.startswith("x-goog-meta-")], request 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. 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" 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..30c2608b74a09 --- /dev/null +++ b/tests/queries/0_stateless/05393_cas_disks_exit_code.reference @@ -0,0 +1,5 @@ +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 new file mode 100755 index 0000000000000..ddd1931919ad5 --- /dev/null +++ b/tests/queries/0_stateless/05393_cas_disks_exit_code.sh @@ -0,0 +1,68 @@ +#!/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 < ". +function disks() +{ + local label="$1" disk="$2" query="$3" pattern="$4" + clickhouse-disks -C "$config" --disk "$disk" --query "$query" > "$OUT" 2>&1 + local rc=$? + echo "$label $rc $(grep -c -E "$pattern" "$OUT")" +} + +disks clean_fsck cas_ro_05393 "cas-fsck" 'dangling=0 unreachable=0' + +# A canonical blob key nothing references is a soft finding (the GC pipeline and in-flight builds look the +# same from outside), reported but not an error. +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" 'dangling=0 unreachable=1' + +# A blob a committed manifest names is gone: a hard finding. +rm -f "$blob" +disks dangling_fsck cas_ro_05393 "cas-fsck" 'reachable object\(s\) MISSING' + +# An upstream command that throws keeps the upstream contract. +disks remove_missing local_05393 "remove no_such_path_05393" "no_such_path_05393 on disk local_05393 doesn't exist" + +# One failing cas-* command decides the exit code of the whole query; the next command still runs. +disks fsck_then_next cas_ro_05393 "cas-fsck; current_disk_with_path" 'MISSING|^Disk: cas_ro_05393$' +rm -f "$OUT" + +${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 + + + + + diff --git a/tests/queries/0_stateless/05397_packed_part_commit_failpoint.reference b/tests/queries/0_stateless/05397_packed_part_commit_failpoint.reference new file mode 100644 index 0000000000000..3f83103b03eaf --- /dev/null +++ b/tests/queries/0_stateless/05397_packed_part_commit_failpoint.reference @@ -0,0 +1,2 @@ +1 +Packed diff --git a/tests/queries/0_stateless/05397_packed_part_commit_failpoint.sql b/tests/queries/0_stateless/05397_packed_part_commit_failpoint.sql new file mode 100644 index 0000000000000..7f9efbb9da79d --- /dev/null +++ b/tests/queries/0_stateless/05397_packed_part_commit_failpoint.sql @@ -0,0 +1,22 @@ +-- Tags: zookeeper, no-fasttest, no-parallel, no-random-merge-tree-settings +-- no-parallel: enables a server-global fail point on the part disk-transaction commit. +-- The Packed twin of 05014: the fail point on the part's disk commit fires for a Packed part too, so a +-- failed commit never leaves a phantom dedup block id behind, and the byte-identical retry really inserts. + +DROP TABLE IF EXISTS t_packed_commit_failpoint SYNC; + +CREATE TABLE t_packed_commit_failpoint (k UInt64, v String) +ENGINE = ReplicatedMergeTree('/clickhouse/tables/{database}/t_packed_commit_failpoint', 'r1') +ORDER BY k +SETTINGS min_bytes_for_full_part_storage = 1000000000; + +SYSTEM ENABLE FAILPOINT part_storage_fail_commit_transaction; +INSERT INTO t_packed_commit_failpoint SETTINGS insert_deduplicate = 1, insert_keeper_fault_injection_probability = 0 VALUES (1, 'x'); -- { serverError FAULT_INJECTED } +SYSTEM DISABLE FAILPOINT part_storage_fail_commit_transaction; + +INSERT INTO t_packed_commit_failpoint SETTINGS insert_deduplicate = 1, insert_keeper_fault_injection_probability = 0 VALUES (1, 'x'); + +SELECT count() FROM t_packed_commit_failpoint; +SELECT DISTINCT part_storage_type FROM system.parts WHERE database = currentDatabase() AND table = 't_packed_commit_failpoint' AND active; + +DROP TABLE t_packed_commit_failpoint SYNC;