diff --git a/ci/jobs/functional_tests.py b/ci/jobs/functional_tests.py
index b73f6f308211..227bc50bfc17 100644
--- a/ci/jobs/functional_tests.py
+++ b/ci/jobs/functional_tests.py
@@ -506,6 +506,14 @@ def main():
# for local run check if stateful tests are present to skip prepare_stateful_data and start faster if not
has_stateful_tests = True
+ if info.is_local_run and not tests:
+ # A local run of the WHOLE suite cannot prepare the stateful datasets: `create.sql` attaches
+ # them from a web disk on `dockerhub-proxy.dockerhub-proxy-zone`, which resolves only inside
+ # CI, so the step dies with DNS_ERROR before a single test runs. Skipping it lets the
+ # stateless suite run locally; the tests that genuinely need `test.hits`/`test.visits` fail
+ # and are triaged as environment rather than taking the whole job with them.
+ print("Local full-suite run: skipping stateful data preparation (datasets are CI-hosted)")
+ has_stateful_tests = False
if tests and info.is_local_run:
from glob import glob
diff --git a/ci/jobs/scripts/clickhouse_proc.py b/ci/jobs/scripts/clickhouse_proc.py
index d4ce6313bf24..4a7740603c3e 100644
--- a/ci/jobs/scripts/clickhouse_proc.py
+++ b/ci/jobs/scripts/clickhouse_proc.py
@@ -839,7 +839,9 @@ def prepare_stateful_data(self, with_s3_storage, is_db_replicated):
command = bootstrap_vars + command
if with_s3_storage:
command = "USE_S3_STORAGE_FOR_MERGE_TREE=1\n" + command
- return Shell.check(command)
+ # verbose: this step loads the stateful datasets and it is the only place in the job
+ # that can fail without printing anything at all, which is exactly what happened.
+ return Shell.check(command, verbose=True)
def insert_system_zookeeper_config(self):
for _ in range(10):
diff --git a/docs/en/antalya/cas/architecture/manifests-and-refs.md b/docs/en/antalya/cas/architecture/manifests-and-refs.md
index df86d82e575d..ba476cffe88a 100644
--- a/docs/en/antalya/cas/architecture/manifests-and-refs.md
+++ b/docs/en/antalya/cas/architecture/manifests-and-refs.md
@@ -101,7 +101,7 @@ swept for that root.
flowchart TD
A["LIST one page of cas/manifests/
freeze candidates with exact GET"] --> B{"build-prefix eligible?
durable watermark fact only"}
B -->|"epoch less than lease epoch"| ELIG["eligible, old-epoch debris"]
- B -->|"same epoch, min_active clears build_seq"| ELIG
+ B -->|"same epoch, min_active_build_sequence clears build_seq"| ELIG
B -->|"no lease, or epoch ahead, or build may be live"| SKIP["skip"]
ELIG --> C["protection view: committed manifests
plus live precommits
plus manifests with an unfolded minus-one"]
C -->|"key protected"| SKIP2["skip"]
diff --git a/docs/en/antalya/cas/architecture/mounts-and-leases.md b/docs/en/antalya/cas/architecture/mounts-and-leases.md
index d778756ce323..66e540a5464f 100644
--- a/docs/en/antalya/cas/architecture/mounts-and-leases.md
+++ b/docs/en/antalya/cas/architecture/mounts-and-leases.md
@@ -68,7 +68,7 @@ Two failure modes this closes:
One object, `gc/server-roots//mount`, carries **both** the liveness lease and the build
watermark — there is no separate watermark object. `MountLease` fields: `server_uuid`,
`writer_epoch`, `write_attempt_id`, `hostname`, `pid`, `started_at_ms`, renewal `seq`,
-`expires_at_ms`, `min_active` (the build-watermark floor), and `gc_fenced`.
+`expires_at_ms`, `min_active_build_sequence` (the build-watermark floor), and `gc_fenced`.
- **Logical renewal identity.** Each holder-originated body has a fresh nonzero
`write_attempt_id`. One logical renewal fixes one immutable `(key, bytes, expected token,
@@ -133,9 +133,9 @@ into "not found".
Global build ordering is the **pair** `(writer_epoch, build_seq)` compared lexicographically — the
exact comparison GC uses for eligibility. The durable authority for both is the mount object
-itself: no mount means no deletion authority means nothing is swept. `min_active`, the oldest
+itself: no mount means no deletion authority means nothing is swept. `min_active_build_sequence`, the oldest
in-flight `build_seq`, rides in the same mount object as the watermark floor; `UINT64_MAX` in
-`min_active` is the farewell/retired sentinel, not a real build.
+`min_active_build_sequence` is the farewell/retired sentinel, not a real build.
## Mount claim outcomes {#claim-outcomes}
@@ -153,7 +153,7 @@ a `MountClaimResult::Kind` together with a `MountPriorState` describing which ce
| `MountPriorState` | Certificate that justified the reclaim |
|---|---|
| `None` | no reclaim needed (fresh claim or same-epoch refresh) |
-| `Clean` | the predecessor's own graceful farewell (`min_active == UINT64_MAX`) |
+| `Clean` | the predecessor's own graceful farewell (`min_active_build_sequence == UINT64_MAX`) |
| `Fenced` | GC's own threshold-gated fence-out (`gc_fenced`) |
| `UncleanObserved` | this claimant's own token-stability observation held for the full `TTL + drift` window |
@@ -168,7 +168,7 @@ stateDiagram-v2
Absent --> Live: claimMount putIfAbsent, seq=1
Live --> Live: keeper beat, putOverwrite seq+1
Live --> Fenced: GC observes a stable token past threshold, gc_fenced=1, body preserved
- Live --> Terminated: certified drain, terminal farewell (expires_at=now, min_active=MAX)
+ Live --> Terminated: certified drain, terminal farewell (expires_at=now, min_active_build_sequence=MAX)
Fenced --> Live: same-uuid claim with a fresh writer_epoch, instant reclaim
Terminated --> Live: same-uuid claim with a fresh writer_epoch, instant reclaim
Live --> Live: same-uuid claim, proven-dead token via UncleanObserved
@@ -218,7 +218,7 @@ processed before renewal resumes.
**Clean unmount:** request stop and join both persistent workers, drain the ref lanes, and only if
the drain *certified* quiescence call `MountLeaseKeeper::release` on an `Active` keeper to write the
-terminal farewell (`expires_at_ms` already expired, `min_active = UINT64_MAX`). That sentinel is what
+terminal farewell (`expires_at_ms` already expired, `min_active_build_sequence = UINT64_MAX`). That sentinel is what
lets a successor reclaim instantly. A `RenewalTerminal` keeper, an unresolved ref write, or a sent
renewal ambiguity writes no farewell — an unearned farewell would let a successor start mutating
while a stale conditional request from the predecessor is still in flight.
diff --git a/docs/en/antalya/cas/architecture/storage-layout.md b/docs/en/antalya/cas/architecture/storage-layout.md
index e4d20725836b..b136552acf37 100644
--- a/docs/en/antalya/cas/architecture/storage-layout.md
+++ b/docs/en/antalya/cas/architecture/storage-layout.md
@@ -45,7 +45,7 @@ namespace's shape and never interprets its contents.
| `gc/gen//attempt//outcomes//.zst` | GC outcome log | `cas_gc_outcomes` | GC |
| `gc/server-roots//owner` | server-root owner singleton | `cas_owner` | mount |
| `gc/server-roots//epoch` | server-root epoch singleton | `cas_epoch` | mount |
-| `gc/server-roots//mount` | mount lease (incl. `min_active` watermark) | `cas_mount_lease` | mount |
+| `gc/server-roots//mount` | mount lease (incl. `min_active_build_sequence` watermark) | `cas_mount_lease` | mount |
| `roots/` | loose mountpoint object, verbatim | — (never interpreted) | upper layers |
| `staging//…` | S3-native upload staging scratch | — | writer, own mount only |
diff --git a/docs/en/antalya/cas/index.md b/docs/en/antalya/cas/index.md
index 2bc71046494b..cb1d563eebf0 100644
--- a/docs/en/antalya/cas/index.md
+++ b/docs/en/antalya/cas/index.md
@@ -64,6 +64,14 @@ Two consequences for planning:
Each prefix is a fully independent pool (its own refs, leases, and `GC`), so rounds stay short
regardless of the total fleet size.
+:::tip
+For replicated tables on `CAS`, enable
+[`execute_merges_on_single_replica_time_threshold`](/operations/settings/merge-tree-settings#execute_merges_on_single_replica_time_threshold).
+This lets one replica perform each merge while the others wait for and fetch the resulting part,
+avoiding redundant merge work across replicas. Set the threshold higher than the usual merge
+duration for your workload.
+:::
+
## Status {#status}
`CAS` is **experimental**. It ships in Altinity Antalya builds. Experimental means the on-disk
diff --git a/docs/en/operations/system-tables/cas_gc_log.md b/docs/en/operations/system-tables/cas_gc_log.md
index 5fd04b4fb11d..eb422cddc4d1 100644
--- a/docs/en/operations/system-tables/cas_gc_log.md
+++ b/docs/en/operations/system-tables/cas_gc_log.md
@@ -38,7 +38,7 @@ specified (it is enabled by default in the shipped `config.xml`).
- `gc_id` ([String](/sql-reference/data-types/string)) — The GC scheduler instance id (which mounter ran the round).
- `trigger` ([Enum8](/sql-reference/data-types/enum)) — `Scheduled` (background tick) or `Manual` (`SYSTEM` command).
- `round` ([UInt64](/sql-reference/data-types/int-uint)) — The GC round number (`0` on a `Start` row).
-- `outcome` ([Enum8](/sql-reference/data-types/enum)) — `Unknown` (on a `Start` row), `Success` (led, folded, and completed), `NotALeader` (another replica holds the GC lease), `Deferred` (led but took the skip-unchanged fast path — no fold ran, because no changed shard reached the fold threshold and no graduation was due), or `Error` (the round threw).
+- `outcome` ([Enum8](/sql-reference/data-types/enum)) — `Unknown` (on a `Start` row), `Success` (led, folded, and completed), `NotALeader` (another replica holds the GC lease), `Deferred` (led but took the skip-unchanged fast path — no fold ran, because no changed shard reached the fold threshold and no graduation was due), `Aborted` (the round threw a transient error — backend unavailability, a lost lease, a concurrent leader; the next scheduled round retries it), or `Error` (the round threw a non-transient error — investigate).
- `candidates_marked` ([UInt64](/sql-reference/data-types/int-uint)) — Objects retired (marked) this round.
- `objects_deleted` ([UInt64](/sql-reference/data-types/int-uint)) — Objects physically deleted this round.
- `objects_absent` ([UInt64](/sql-reference/data-types/int-uint)) — Retire candidates found already absent.
@@ -51,7 +51,8 @@ specified (it is enabled by default in the shipped `config.xml`).
- `fence_outs` ([UInt64](/sql-reference/data-types/int-uint)) — Expired mounts fenced out by this round's heartbeat floor.
- `anomalies` ([UInt64](/sql-reference/data-types/int-uint)) — Fold clamps surfaced (and survived) this round. A steady non-zero value warrants a look at the round log details.
- `duration_ms` ([UInt64](/sql-reference/data-types/int-uint)) — The round wall-clock duration (on a `Finish` row).
-- `error` ([String](/sql-reference/data-types/string)) — The exception text when `outcome = 'Error'`.
+- `error` ([String](/sql-reference/data-types/string)) — The exception text when `outcome = 'Aborted'` or `'Error'`.
+- `error_code` ([Int32](/sql-reference/data-types/int-uint)) — The exception code when `outcome = 'Aborted'` or `'Error'`; `0` otherwise. Key monitoring on this column rather than on the `error` text. On an `Aborted` or `Error` row the counters still report everything the round completed before it threw, and `round != 0` on such a row means the round's closing compare-and-swap committed and the failure hit only post-commit cleanup.
- `ProfileEvents` ([Map(LowCardinality(String), UInt64)](/sql-reference/data-types/map)) — On a `Start`/`Finish` row, the per-round `ProfileEvents` delta (the `CAS*` counters and S3/disk events for this round). On a `Phase` row, **that phase's** delta, so `GROUP BY phase` over `ProfileEvents['S3ListObjects']` attributes the round's `LIST` budget to the phase that spent it.
- `round_id` ([String](/sql-reference/data-types/string)) — The correlator for every row of one round attempt: its `Start`, each of its `Phase` rows, and its `Finish`. Minted per attempt, so unlike `round` it exists even for a round that never committed and for a round that never led. Group by this column to reconstruct one round.
- `phase` ([LowCardinality(String)](/sql-reference/data-types/lowcardinality)) — The GC phase this row describes; empty on `Start`/`Finish`. See [Per-phase rows](#per-phase-rows) for the phase list.
diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Backend/CasObjectStorageBackend.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Backend/CasObjectStorageBackend.cpp
index b7b04fdb401b..d27e01b412b0 100644
--- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Backend/CasObjectStorageBackend.cpp
+++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Backend/CasObjectStorageBackend.cpp
@@ -252,13 +252,6 @@ PutResult ObjectStorageBackend::nativeConditionalPut(const String & key, const S
namespace
{
-/// Keep the emulated backend's publication memory bound to one materialized body at a time.
-std::mutex & emulatedBlobPublicationMutex()
-{
- static std::mutex mutex;
- return mutex;
-}
-
}
/// True when an exception from `IObjectStorage::readObject` means "the object is simply not there".
@@ -485,7 +478,7 @@ Token ObjectStorageBackend::emuWrite(const String & key, const String & bytes, c
return emuMintToken(key, metadata ? metadata->etag : String{}, /*just_wrote=*/true);
}
-void ObjectStorageBackend::emuPublishBlobAtomically(const String & key, const String & bytes)
+void ObjectStorageBackend::emuPublishBlobAtomically(const String & key, const String & envelope, ReadBuffer & payload, uint64_t payload_size)
{
if (object_storage->getType() != ObjectStorageType::Local)
throw Exception(
@@ -497,14 +490,30 @@ void ObjectStorageBackend::emuPublishBlobAtomically(const String & key, const St
const String root = object_storage->getCommonKeyPrefix();
const String destination_path = resolvePathRelativelyToBase(destination_object, root);
const String temporary_path = resolvePathRelativelyToBase(temporary_object, root);
- const auto existing_token_state = emu_token_state.find(key);
+ /// The body is STREAMED into the temporary file -- envelope, then a bounded copy of the payload --
+ /// never materialized in memory. (An earlier revision accumulated envelope+payload in one String,
+ /// whose growth doubling made the peak allocation up to 2x the payload, and serialized every
+ /// publication behind a dedicated mutex just to bound that peak to one body at a time; streaming
+ /// removes both.) The destination stays untouched until the byte count has been validated: a short
+ /// or long source aborts on the temporary file, which is then removed.
try
{
auto out = object_storage->writeObject(StoredObject(temporary_object), WriteMode::Rewrite);
- out->write(bytes.data(), bytes.size());
+ out->write(envelope.data(), envelope.size());
+ const auto copy_result = blob_publication_detail::copyBlobPayloadBounded(payload, *out, payload_size);
+ if (!copy_result.exact(payload_size))
+ {
+ out->cancel();
+ throw Exception(
+ ErrorCodes::CORRUPTED_DATA,
+ "ObjectStorageBackend::publishBlob: source yielded {}{} payload bytes for {}, declared {} -- nothing was published",
+ copy_result.has_excess ? "more than " : "",
+ copy_result.copied,
+ key,
+ payload_size);
+ }
out->finalize();
- std::filesystem::rename(temporary_path, destination_path);
}
catch (...)
{
@@ -517,7 +526,21 @@ void ObjectStorageBackend::emuPublishBlobAtomically(const String & key, const St
/// existing disambiguator is sufficient: if the next observation sees the same ETag, it returns
/// a token distinct from the old incarnation; if the ETag changed, emuMintToken resets the state
/// to that new ETag. With no existing state, this backend has issued no same-process stale token
- /// that needs fencing. The post-rename increment cannot allocate or throw.
+ /// that needs fencing. The post-rename increment cannot allocate or throw. `emu_mutex` spans the
+ /// rename and the bump so a concurrent emulated observation never sees the new incarnation with
+ /// the old disambiguator.
+ std::lock_guard lock(emu_mutex);
+ const auto existing_token_state = emu_token_state.find(key);
+ try
+ {
+ std::filesystem::rename(temporary_path, destination_path);
+ }
+ catch (...)
+ {
+ std::error_code cleanup_error;
+ std::filesystem::remove(temporary_path, cleanup_error);
+ throw;
+ }
if (existing_token_state != emu_token_state.end())
++existing_token_state->second.second;
}
@@ -872,32 +895,9 @@ void ObjectStorageBackend::publishBlob(const BlobPublishRequest & request)
if (mode != Mode::Native)
{
- /// The emulated adapter's writes are whole-body operations. Serialize materialization so
- /// concurrent publications retain the existing one-body peak-memory bound.
- std::lock_guard publish_lock(emulatedBlobPublicationMutex());
-
- String body = streaming->fresh_envelope;
- blob_publication_detail::BlobPayloadCopyResult copy_result;
- {
- WriteBufferFromString out(body, AppendModeTag{});
- copy_result = blob_publication_detail::copyBlobPayloadBounded(*payload, out, streaming->payload_size);
- if (copy_result.exact(streaming->payload_size))
- out.finalize();
- else
- out.cancel();
- }
-
- if (!copy_result.exact(streaming->payload_size))
- throw Exception(
- ErrorCodes::CORRUPTED_DATA,
- "ObjectStorageBackend::publishBlob: source yielded {}{} payload bytes for {}, declared {} -- nothing was published",
- copy_result.has_excess ? "more than " : "",
- copy_result.copied,
- request.destination_key,
- streaming->payload_size);
-
- std::lock_guard lock(emu_mutex);
- emuPublishBlobAtomically(request.destination_key, body);
+ /// Streams straight into the temporary file and renames -- see emuPublishBlobAtomically.
+ emuPublishBlobAtomically(
+ request.destination_key, streaming->fresh_envelope, *payload, streaming->payload_size);
return;
}
diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Backend/CasObjectStorageBackend.h b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Backend/CasObjectStorageBackend.h
index bd369d4e3603..7344c8fbede4 100644
--- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Backend/CasObjectStorageBackend.h
+++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Backend/CasObjectStorageBackend.h
@@ -264,7 +264,11 @@ class ObjectStorageBackend final : public Backend
/// Write a complete blob body to a sibling temporary local object, then atomically replace `key`
/// and advance any existing same-ETag disambiguator. A failure before the rename leaves the old
/// destination and its token state untouched and cleans the temporary.
- void emuPublishBlobAtomically(const String & key, const String & bytes);
+ /// Streams `envelope` + exactly `payload_size` bytes of `payload` into a temporary sibling of
+ /// `key`, then renames it into place -- nothing is visible at the destination until the byte count
+ /// has been validated, and the rename keeps publication atomic. Takes `emu_mutex` itself (for the
+ /// rename + token-state bump only); the caller must NOT hold it.
+ void emuPublishBlobAtomically(const String & key, const String & envelope, ReadBuffer & payload, uint64_t payload_size);
/// Return the current emulated token for a key we just read/HEAD'd, reflecting its on-disk etag —
/// does NOT advance the same-etag disambiguator (that only applies to a just-completed write).
Token emuObserveToken(const String & key);
diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.cpp
index c9cf2b166389..447fe908e45e 100644
--- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.cpp
+++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedMetadataStorage.cpp
@@ -547,6 +547,9 @@ Cas::GcRoundLogger ContentAddressedMetadataStorage::makeGcRoundLogger() const
case Cas::GcRoundLogRecord::Outcome::Deferred:
e.outcome = ContentAddressedGarbageCollectionLogElement::DEFERRED;
break;
+ case Cas::GcRoundLogRecord::Outcome::Aborted:
+ e.outcome = ContentAddressedGarbageCollectionLogElement::ABORTED;
+ break;
}
e.round = r.round;
e.candidates_marked = r.candidates_marked;
@@ -562,6 +565,7 @@ Cas::GcRoundLogger ContentAddressedMetadataStorage::makeGcRoundLogger() const
e.anomalies = r.anomalies;
e.duration_ms = r.duration_ms;
e.error = r.error;
+ e.error_code = r.error_code;
e.profile_events = r.profile_events;
e.round_id = r.round_id;
e.phase = r.phase;
diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedTransaction.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedTransaction.cpp
index 1216906194b4..437c4d536250 100644
--- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedTransaction.cpp
+++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/ContentAddressedTransaction.cpp
@@ -113,7 +113,7 @@ ContentAddressedTransaction::~ContentAddressedTransaction()
/// backstop for aborted/exception-unwound transactions whose publishStaging never ran.
cleanupPendingTempFiles();
- /// An uncommitted transaction's uploads become min_active-spared debris: abandon every
+ /// An uncommitted transaction's uploads become min_active_build_sequence-spared debris: abandon every
/// still-open PartWriteTxn so its build_seq is retired. This replaces the former pin machinery.
if (committed)
return;
@@ -759,8 +759,8 @@ std::string ContentAddressedTransaction::buildS3StagingBlobHeader(
header.kind = Cas::ObjectKind::Blob;
header.incarnation_tag = (static_cast(thread_local_rng()) << 64) | thread_local_rng();
header.build_id = 0; /// not known at stream time; diagnostic-only (not read by GC/read paths)
- /// ch = the real ClickHouse VERSION_INTEGER (diagnostic-only; consistent with `PartWriteTxn::buildHeader`).
- /// The v3 envelope drops hash_algo/domain_id/writer_version, so forensics ride on ch + bld.
+ /// `chver` = the real ClickHouse VERSION_INTEGER (diagnostic-only; consistent with `PartWriteTxn::buildHeader`).
+ /// The envelope drops hash_algo/domain_id/writer_version, so forensics ride on `chver` + `build`.
header.provenance = Cas::Provenance{
/*created_at_ms*/ 0, cfg.server_id, VERSION_INTEGER, Cas::ProvenanceOp::Other};
header.intended_ref = route.ns.string() + "/" + route.ref;
diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasBlobEnvelopeFormat.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasBlobEnvelopeFormat.cpp
index 65176572e896..523912c519c4 100644
--- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasBlobEnvelopeFormat.cpp
+++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasBlobEnvelopeFormat.cpp
@@ -1,7 +1,11 @@
#include
+#include
#include
+#include
#include
#include
+#include
+#include
namespace DB
{
@@ -20,31 +24,86 @@ namespace
constexpr std::string_view kBlobType = "cas_blob";
-std::string_view opToWord(ProvenanceOp op)
+namespace EnvelopeWire
{
- switch (op)
- {
- case ProvenanceOp::Other: return "other";
- case ProvenanceOp::Insert: return "insert";
- case ProvenanceOp::Merge: return "merge";
- case ProvenanceOp::Mutation: return "mutation";
- case ProvenanceOp::Attach: return "attach";
- case ProvenanceOp::Repack: return "repack";
- }
- throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS blob envelope: unknown ProvenanceOp {}", static_cast(op));
+ constexpr WireKey type{"type"};
+ constexpr WireKey version{"v"};
+ constexpr WireKey tag{"tag"};
+ constexpr WireKey build{"build"};
+ constexpr WireKey time_ms{"time_ms"};
+ constexpr WireKey creator{"creator"};
+ constexpr WireKey op{"op"};
+ constexpr WireKey chver{"chver"};
+ constexpr WireKey ref{"ref"};
+ /// Not a field this build understands: written only to exercise the reader's `!`-key policy.
+ constexpr WireKey unknown_critical{"!x"};
}
-ProvenanceOp opFromWord(std::string_view w)
+constexpr EnumWireTable kProvenanceOpWords{{{
+ {ProvenanceOp::Other, "other"},
+ {ProvenanceOp::Insert, "insert"},
+ {ProvenanceOp::Merge, "merge"},
+ {ProvenanceOp::Mutation, "mutation"},
+ {ProvenanceOp::Attach, "attach"},
+ {ProvenanceOp::Repack, "repack"},
+}}};
+
+static_assert(casEnumTableCoversEnum());
+
+/// Byte cost of one JSON key as written by `CasJsonWriter::key`: the leading `{`/`,` separator (1)
+/// plus the opening quote (1), the key text, and the closing quote and colon (2).
+constexpr size_t keyCost(WireKey key)
{
- if (w == "other") return ProvenanceOp::Other;
- if (w == "insert") return ProvenanceOp::Insert;
- if (w == "merge") return ProvenanceOp::Merge;
- if (w == "mutation") return ProvenanceOp::Mutation;
- if (w == "attach") return ProvenanceOp::Attach;
- if (w == "repack") return ProvenanceOp::Repack;
- throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS blob envelope: unknown op '{}'", w);
+ return 4 + key.text.size();
}
+/// A quoted `hex128Value` is always exactly this wide -- 2 quote bytes plus 2 hex digits per
+/// `UInt128` byte -- because `writeHexUIntLowercase` zero-pads; there is no smaller or larger case.
+constexpr size_t kQuotedHex128Len = 2 + sizeof(UInt128) * 2;
+
+/// Maximum decimal digits an unquoted `writeIntText` value can produce for each integer width the
+/// envelope persists, taken from the type itself rather than re-typed as a literal.
+constexpr size_t kMaxU64DecimalLen = std::numeric_limits::digits10 + 1;
+constexpr size_t kMaxU32DecimalLen = std::numeric_limits::digits10 + 1;
+
+/// The longest persisted `op` word, found by walking the table rather than hardcoding one -- the
+/// worst case must track `kProvenanceOpWords` even if a future entry outgrows "mutation".
+constexpr size_t maxProvenanceOpWordLen()
+{
+ size_t max_len = 0;
+ for (const auto & entry : kProvenanceOpWords.entries)
+ max_len = std::max(max_len, entry.word.size());
+ return max_len;
+}
+
+/// Mandatory (always-written whenever `provenance` is set) non-`ref` fields at type maxima, in the
+/// exact field order `encodeEnvelopeHeader` writes them. `CasPoolMetaFormat.cpp` records why 240 was
+/// chosen as the floor above this bound.
+constexpr size_t kMandatoryNonRefWorstCase =
+ keyCost(EnvelopeWire::type) + 2 + kBlobType.size()
+ + keyCost(EnvelopeWire::version) + kMaxU32DecimalLen
+ + keyCost(EnvelopeWire::tag) + kQuotedHex128Len
+ + keyCost(EnvelopeWire::build) + kQuotedHex128Len
+ + keyCost(EnvelopeWire::time_ms) + kMaxU64DecimalLen
+ + keyCost(EnvelopeWire::creator) + kQuotedHex128Len
+ + keyCost(EnvelopeWire::op) + 2 + maxProvenanceOpWordLen()
+ + keyCost(EnvelopeWire::chver) + kMaxU32DecimalLen;
+
+/// The encoder always frames `ref`, even when empty: the key (`,"ref":`), the empty quotes, the
+/// closing `}`, and the trailing '\n' reserved at byte `blob_header_len - 1`.
+constexpr size_t kRefFramingAndTerminator = keyCost(EnvelopeWire::ref) + 2 + 1 + 1;
+
+/// The worst-case byte count `encodeEnvelopeHeader` can ever produce before the diagnostic `ref`
+/// gets any budget at all. Proven, not merely documented: the static_assert below fails the BUILD if
+/// a future key or type change ever closes the margin under `kMinBlobHeaderLen`.
+constexpr size_t kMandatoryDescriptorWorstCase = kMandatoryNonRefWorstCase + kRefFramingAndTerminator;
+
+static_assert(kMandatoryDescriptorWorstCase <= kMinBlobHeaderLen - 1,
+ "the mandatory blob-envelope fields plus the empty-ref framing must fit under kMinBlobHeaderLen "
+ "(the trailing '\\n' is already counted above, so the spare byte is the diagnostic ref's floor "
+ "budget, not the newline); if a field grew, either shrink it back or "
+ "raise kMinBlobHeaderLen (CasEnvelopeLimits.h) to match");
+
/// The escaped byte-length of one raw ref char under the frozen envelope alphabet (see writeEnvelopeRefField).
size_t escapedLen(char c)
{
@@ -96,7 +155,20 @@ void writeEnvelopeRefField(String & json, size_t budget, std::string_view raw_re
}
-String encodeEnvelopeHeader(EnvelopeHeader & header, uint32_t blob_header_len)
+const size_t mandatory_descriptor_worst_case = kMandatoryDescriptorWorstCase;
+
+std::string_view provenanceOpToWireWord(ProvenanceOp op)
+{
+ return kProvenanceOpWords.toWord(op, "CAS blob envelope");
+}
+
+ProvenanceOp provenanceOpFromWireWord(std::string_view w)
+{
+ return kProvenanceOpWords.fromWord(w, "CAS blob envelope");
+}
+
+String encodeEnvelopeHeader(EnvelopeHeader & header, uint32_t blob_header_len,
+ std::optional version_override)
{
if (header.kind != ObjectKind::Blob)
throw Exception(ErrorCodes::LOGICAL_ERROR,
@@ -108,23 +180,21 @@ String encodeEnvelopeHeader(EnvelopeHeader & header, uint32_t blob_header_len)
{
CasJsonWriter buf(256);
bool first = true;
- writeKey(buf, "type", first); writeStringValue(buf, kBlobType);
- writeKey(buf, "v", first); writeIntText(currentCompatibilityVersion(), buf);
- writeKey(buf, "tag", first); writeHex128Value(buf, header.incarnation_tag);
- writeKey(buf, "bld", first); writeHex128Value(buf, header.build_id);
+ writeStringField(buf, EnvelopeWire::type, kBlobType, first);
+ writeNumberField(buf, EnvelopeWire::version, version_override.value_or(currentCompatibilityVersion()), first);
+ writeHex128Field(buf, EnvelopeWire::tag, header.incarnation_tag, first);
+ writeHex128Field(buf, EnvelopeWire::build, header.build_id, first);
if (header.provenance)
{
- writeKey(buf, "ts", first); writeIntText(header.provenance->created_at_ms, buf);
- writeKey(buf, "by", first); writeHex128Value(buf, header.provenance->creator_server_id);
- writeKey(buf, "op", first); writeStringValue(buf, opToWord(header.provenance->op));
- writeKey(buf, "ch", first); writeIntText(header.provenance->ch_version, buf);
+ writeNumberField(buf, EnvelopeWire::time_ms, header.provenance->created_at_ms, first);
+ writeHex128Field(buf, EnvelopeWire::creator, header.provenance->creator_server_id, first);
+ writeStringField(buf, EnvelopeWire::op, provenanceOpToWireWord(header.provenance->op), first);
+ writeNumberField(buf, EnvelopeWire::chver, header.provenance->ch_version, first);
}
/// Test-only critical extension: an unknown `!`-key BEFORE `ref`.
if (header.emit_unknown_critical_key)
- {
- writeKey(buf, "!x", first); writeStringValue(buf, "1");
- }
- json = std::move(buf).take(); /// e.g. {"type":"cas_blob","v":3,...,"ch":26006001 (no ref, no closing brace)
+ writeStringField(buf, EnvelopeWire::unknown_critical, "1", first);
+ json = std::move(buf).take(); /// e.g. {"type":"cas_blob","v":1,...,"chver":26006001 (no ref, no closing brace)
}
/// Optional `ref`, truncated to the exact remaining budget. Layout after this block:
@@ -132,15 +202,18 @@ String encodeEnvelopeHeader(EnvelopeHeader & header, uint32_t blob_header_len)
/// (byte blob_header_len-1 is reserved for '\n'; the pad zone fills the gap with spaces).
if (header.intended_ref)
{
- static constexpr std::string_view ref_key = ",\"ref\":";
+ /// 4 = the `,"` before and `":` after the key text — the `,"ref":` framing minus the key itself.
+ constexpr size_t ref_key_size = 4 + EnvelopeWire::ref.text.size();
/// +3 = opening quote + closing quote + closing brace.
- const size_t fixed = json.size() + ref_key.size() + 3;
+ const size_t fixed = json.size() + ref_key_size + 3;
if (blob_header_len < 1 || fixed > static_cast(blob_header_len) - 1)
throw Exception(ErrorCodes::LOGICAL_ERROR,
"CAS blob envelope: non-ref fields ({} bytes) do not fit blob_header_len {} before the ref",
fixed, blob_header_len);
const size_t budget = (static_cast(blob_header_len) - 1) - fixed;
- json += ref_key;
+ json += ",\"";
+ json += EnvelopeWire::ref.text;
+ json += "\":";
writeEnvelopeRefField(json, budget, *header.intended_ref);
}
json += '}';
@@ -173,7 +246,7 @@ EnvelopeHeader decodeEnvelopeHeader(std::string_view head_bytes, uint64_t /*obje
String key;
while (r.nextKey(key))
{
- if (key == "type")
+ if (key == EnvelopeWire::type)
{
const String t = r.readString();
if (t != kBlobType)
@@ -181,37 +254,37 @@ EnvelopeHeader decodeEnvelopeHeader(std::string_view head_bytes, uint64_t /*obje
"CAS blob envelope: object is a '{}', not a '{}'", t, kBlobType);
saw_type = true;
}
- else if (key == "v")
+ else if (key == EnvelopeWire::version)
{
h.compatibility_version = r.readU32Number();
checkCompatibility(h.compatibility_version, "blob envelope");
saw_v = true;
}
- else if (key == "tag")
+ else if (key == EnvelopeWire::tag)
h.incarnation_tag = r.readHex128();
- else if (key == "bld")
+ else if (key == EnvelopeWire::build)
h.build_id = r.readHex128();
- else if (key == "ts")
+ else if (key == EnvelopeWire::time_ms)
{
prov.created_at_ms = r.readU64Number();
have_prov = true;
}
- else if (key == "by")
+ else if (key == EnvelopeWire::creator)
{
prov.creator_server_id = r.readHex128();
have_prov = true;
}
- else if (key == "op")
+ else if (key == EnvelopeWire::op)
{
- prov.op = opFromWord(r.readString());
+ prov.op = provenanceOpFromWireWord(r.readString());
have_prov = true;
}
- else if (key == "ch")
+ else if (key == EnvelopeWire::chver)
{
prov.ch_version = static_cast(r.readU64Number());
have_prov = true;
}
- else if (key == "ref")
+ else if (key == EnvelopeWire::ref)
h.intended_ref = r.readString();
else
r.skipUnknown(key); /// `!`-key -> UNKNOWN_FORMAT_VERSION; unknown plain key -> skipped (tolerant)
diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasBlobEnvelopeFormat.h b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasBlobEnvelopeFormat.h
index 19250fe69ddd..e968bbec5dbc 100644
--- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasBlobEnvelopeFormat.h
+++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasBlobEnvelopeFormat.h
@@ -1,4 +1,5 @@
#pragma once
+#include
#include
#include
#include
@@ -32,6 +33,20 @@ enum class ProvenanceOp : uint8_t
Repack = 5,
};
+/// Returns the persisted wire word for a validated provenance operation.
+/// The largest descriptor `encodeEnvelopeHeader` can ever produce before the diagnostic `ref` gets any
+/// budget: every mandatory field at its type maximum, the longest provenance word, the `ref` framing
+/// with empty quotes, the closing brace and the trailing newline. A `static_assert` beside its
+/// definition proves it fits under `kMinBlobHeaderLen`; this declaration exists so the boundary test
+/// can confirm the SAME number against bytes the encoder actually produced, which is the half a
+/// compile-time proof cannot do — an understated formula satisfies the assert quite happily.
+extern const size_t mandatory_descriptor_worst_case;
+
+std::string_view provenanceOpToWireWord(ProvenanceOp op);
+
+/// Its fail-closed inverse: an unknown word is `CORRUPTED_DATA`.
+ProvenanceOp provenanceOpFromWireWord(std::string_view w);
+
/// Optional diagnostic metadata recorded with an envelope. The fields identify when and where the
/// incarnation was created, the ClickHouse build that wrote it, and the operation that produced it;
/// none of them participates in object identity or a protocol decision.
@@ -55,19 +70,20 @@ struct Provenance
/// algorithm and digest are already present in the object key and manifest reference, `domain_id` had
/// no validating consumer, and `header_hash` had no consumer once the CityHash64 check left the
/// envelope. Writer forensics are represented
-/// by `ch` and `bld`, so a separate `writer_version` is unnecessary. The `v` field is the sole format
-/// compatibility gate; a reader rejects a version it does not understand before interpreting the body.
+/// by `chver` and `build`, so a separate `writer_version` is unnecessary. The `v` field is the sole
+/// format compatibility gate; a reader rejects a version it does not understand before interpreting
+/// the body.
struct EnvelopeHeader
{
ObjectKind kind = ObjectKind::Blob;
/// Set by decode from the header `v`; encode stamps `currentCompatibilityVersion`. A reader
/// fails closed (UNKNOWN_FORMAT_VERSION) when `v` exceeds what this build understands.
uint32_t compatibility_version = 0;
- UInt128 incarnation_tag{}; /// `tag`
- UInt128 build_id{}; /// `bld`
- std::optional provenance; /// `ts` / `by` / `op` / `ch`
- std::optional intended_ref; /// `ref` (diagnostic; truncated on encode to fit the header)
- uint32_t header_len = 0; /// filled by encode/decode = blob_header_len (payload offset)
+ UInt128 incarnation_tag{}; /// `tag`
+ UInt128 build_id{}; /// `build`
+ std::optional provenance; /// `time_ms` / `creator` / `op` / `chver`
+ std::optional intended_ref; /// `ref` (diagnostic; truncated on encode to fit the header)
+ uint32_t header_len = 0; /// filled by encode/decode = blob_header_len (payload offset)
/// Test-only knob: emit an unknown `!`-critical key. Decoding the resulting header must fail
/// closed with `UNKNOWN_FORMAT_VERSION`, exercising the compatibility rule for critical extensions.
bool emit_unknown_critical_key = false;
@@ -78,7 +94,14 @@ struct EnvelopeHeader
/// diagnostic `ref` is the only truncatable field and is shortened, never dropped, when necessary to
/// preserve the fixed layout. The header is built without payload bytes, so an upload can stage the
/// header before the payload is streamed.
-String encodeEnvelopeHeader(EnvelopeHeader & header, uint32_t blob_header_len);
+/// `version_override` exists for one caller: the boundary test that has to see what the descriptor
+/// costs at the WIDEST version the budget reserves room for. The budget is sized for a ten-digit
+/// version; production has only ever written a one-digit one, so a test that encodes at the current
+/// version and adds the missing digits arithmetically never sends the boundary through the encoder
+/// at all -- it re-derives the formula it is supposed to be checking. Production passes nothing and
+/// gets `currentCompatibilityVersion()`.
+String encodeEnvelopeHeader(EnvelopeHeader & header, uint32_t blob_header_len,
+ std::optional version_override = {});
/// Parses and validates the JSON descriptor, its expected `type`, and its compatibility version.
/// Derives `header_len` from the terminating '\n' and requires every preceding byte in the pad zone to
diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasBlobMetaFormat.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasBlobMetaFormat.cpp
index b62fd3b82424..2b40584e0199 100644
--- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasBlobMetaFormat.cpp
+++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasBlobMetaFormat.cpp
@@ -1,5 +1,6 @@
#include
#include
+#include
#include
#include
@@ -17,26 +18,30 @@ namespace DB::Cas
namespace
{
-std::string_view metaStateToWord(MetaState s)
+namespace BlobMetaWire
{
- switch (s)
- {
- case MetaState::Clean: return "clean";
- case MetaState::Condemned: return "condemned";
- }
- // The enum is persisted as a closed vocabulary. Do not silently invent a spelling for a value
- // added without a corresponding format decision: that would make the writer emit data older
- // readers cannot classify.
- throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS blob meta: unknown MetaState {}", static_cast(s));
+ constexpr WireKey state{"state"};
+ constexpr WireKey condemn_round{"condemn_round"};
+ constexpr WireKey size{"size"};
}
-MetaState metaStateFromWord(std::string_view w)
+constexpr EnumWireTable kMetaStateWords{{{
+ {MetaState::Clean, "clean"},
+ {MetaState::Condemned, "condemned"},
+}}};
+
+static_assert(casEnumTableCoversEnum());
+
+}
+
+std::string_view metaStateToWireWord(MetaState state)
{
- if (w == "clean") return MetaState::Clean;
- if (w == "condemned") return MetaState::Condemned;
- throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS blob meta: unknown state '{}'", w);
+ return kMetaStateWords.toWord(state, "CAS blob meta");
}
+MetaState metaStateFromWireWord(std::string_view w)
+{
+ return kMetaStateWords.fromWord(w, "CAS blob meta");
}
String encodeBlobMeta(const BlobMeta & meta)
@@ -46,12 +51,9 @@ String encodeBlobMeta(const BlobMeta & meta)
// `version` is represented by the header line. The JSON body contains only fields that describe
// the current marker and its accounting data.
bool first = true;
- writeKey(out, "st", first);
- writeStringValue(out, metaStateToWord(meta.state));
- writeKey(out, "cr", first);
- writeU64StringValue(out, meta.condemn_round);
- writeKey(out, "sz", first);
- writeU64StringValue(out, meta.size);
+ writeWordField(out, BlobMetaWire::state, metaStateToWireWord(meta.state), first);
+ writeU64StringField(out, BlobMetaWire::condemn_round, meta.condemn_round, first);
+ writeU64StringField(out, BlobMetaWire::size, meta.size, first);
closeObject(out, first);
writeChar('\n', out);
return std::move(out).take();
@@ -72,20 +74,20 @@ BlobMeta decodeBlobMeta(std::string_view bytes)
String key;
while (r.nextKey(key))
{
- if (key == "st")
+ if (key == BlobMetaWire::state)
{
- m.state = metaStateFromWord(r.readString());
+ m.state = metaStateFromWireWord(r.readString());
saw_state = true;
}
- else if (key == "cr")
+ else if (key == BlobMetaWire::condemn_round)
m.condemn_round = r.readU64String();
- else if (key == "sz")
+ else if (key == BlobMetaWire::size)
m.size = r.readU64String();
else
r.skipUnknown(key);
}
if (!saw_state)
- throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS blob meta: missing st");
+ throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS blob meta: missing state");
if (!body_in.eof() || !in.eof())
throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS blob meta: trailing bytes");
return m;
diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasBlobMetaFormat.h b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasBlobMetaFormat.h
index 6694fbafeb33..9176dc641203 100644
--- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasBlobMetaFormat.h
+++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasBlobMetaFormat.h
@@ -19,6 +19,13 @@ enum class MetaState : uint8_t
/// so a writer may republish it by replacing the body and updating this marker.
};
+/// Convert a meta-state discriminator to its canonical wire word. Throws `LOGICAL_ERROR` for an
+/// out-of-range enum value.
+std::string_view metaStateToWireWord(MetaState state);
+
+/// Its fail-closed inverse: an unknown word is `CORRUPTED_DATA`.
+MetaState metaStateFromWireWord(std::string_view w);
+
/// The durable per-hash meta record. Its text representation consists of a format header followed by
/// one JSON object with the state word, the GC condemnation round, and the raw body size. `size` is
/// retained for introspection, fsck, and GC accounting; reads of the blob never consult the meta.
diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasEnvelopeLimits.h b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasEnvelopeLimits.h
new file mode 100644
index 000000000000..92e6b252ab1b
--- /dev/null
+++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasEnvelopeLimits.h
@@ -0,0 +1,16 @@
+#pragma once
+
+#include
+
+namespace DB::Cas
+{
+
+/// The pool-wide floor for `blob_header_len`. One compile-time owner, read by BOTH
+/// `validatePoolBlobHeaderLen` (pool creation / decode) and the blob-envelope codec, so the
+/// mandatory-descriptor worst-case proof and the enforced floor can never guard different numbers.
+/// The byte-for-byte worst-case derivation (`kMandatoryDescriptorWorstCase`) lives beside the
+/// envelope key constants in `CasBlobEnvelopeFormat.cpp`; `CasPoolMetaFormat.cpp` records why 240
+/// (rather than the bare worst case) was chosen as the floor.
+inline constexpr uint64_t kMinBlobHeaderLen = 240;
+
+}
diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasFoldSealFormat.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasFoldSealFormat.cpp
index b4bba2bff08f..b2c0bf1f9756 100644
--- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasFoldSealFormat.cpp
+++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasFoldSealFormat.cpp
@@ -2,8 +2,10 @@
#include
#include
#include
+#include
#include
#include
+#include
#include
#include
@@ -20,43 +22,57 @@ namespace ErrorCodes
namespace DB::Cas
{
-std::string_view holdReasonToWord(HoldReason r)
-{
- switch (r)
- {
- case HoldReason::GapBelowWitness: return "gap_below_witness";
- case HoldReason::UnconsumedSealCrossing: return "unconsumed_seal_crossing";
- case HoldReason::WitnessDisappeared: return "witness_disappeared";
- case HoldReason::BodyUndecodable: return "body_undecodable";
- case HoldReason::ManifestBodyMissing: return "manifest_body_missing";
- case HoldReason::CheckpointUndecodable: return "checkpoint_undecodable";
- }
- throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS fold seal: unknown hold reason {}", static_cast(r));
-}
-
namespace
{
-HoldReason holdReasonFromWord(std::string_view w)
+namespace FoldSealWire
{
- if (w == "gap_below_witness") return HoldReason::GapBelowWitness;
- if (w == "unconsumed_seal_crossing") return HoldReason::UnconsumedSealCrossing;
- if (w == "witness_disappeared") return HoldReason::WitnessDisappeared;
- if (w == "body_undecodable") return HoldReason::BodyUndecodable;
- if (w == "manifest_body_missing") return HoldReason::ManifestBodyMissing;
- if (w == "checkpoint_undecodable") return HoldReason::CheckpointUndecodable;
- throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS fold seal: unknown hold reason '{}'", w);
+ constexpr WireKey generation{"generation"};
+ constexpr WireKey parent_generation{"parent_generation"};
+ constexpr WireKey kind{"kind"};
+ constexpr WireKey run_key{"key"};
+ constexpr WireKey checksum{"checksum"};
+ constexpr WireKey shard{"shard"};
+ constexpr WireKey key_generation{"key_generation"};
+ constexpr WireKey life{"life"};
+ constexpr WireKey classification{"class"};
+ constexpr WireKey fold_epoch{"fold_epoch"};
+ constexpr WireKey fold_seq{"fold_seq"};
+ constexpr WireKey hold_reason{"hold_reason"};
+ constexpr WireKey hold_epoch{"hold_epoch"};
+ constexpr WireKey hold_seq{"hold_seq"};
+ constexpr WireKey retries{"retries"};
+ constexpr WireKey retry_round{"retry_round"};
+ constexpr WireKey remove_epoch{"remove_epoch"};
+ constexpr WireKey remove_seq{"remove_seq"};
+ constexpr WireKey condemned_total{"condemned"};
+ constexpr WireKey pending_total{"pending"};
+ constexpr WireKey oldest_round{"oldest_round"};
}
-/// The classification set is CLOSED. Every consumer of a coverage row branches on exact values — the
-/// sweep's §6 deletion premise refuses a row by testing `== 4` and then `== 0` — so a value outside the
-/// set is not an unknown variant to be tolerated forward: it is a row that passes every refusal written
-/// in terms of the set and reaches the irreversible delete. One predicate, used by both directions, so
-/// the writer's self-check and the reader's fail-close can never name different sets.
-bool isKnownClassification(uint64_t classification)
-{
- return classification == 0 || classification == 1 || classification == 2 || classification == 4;
-}
+constexpr std::string_view kRefLifeTag = "ref_life";
+constexpr std::string_view kBlobRunTag = "blob_run";
+constexpr std::string_view kCondemnedTag = "condemned";
+
+constexpr EnumWireTable kHoldReasonWords{{{
+ {HoldReason::GapBelowWitness, "gap_below_witness"},
+ {HoldReason::UnconsumedSealCrossing, "unconsumed_seal_crossing"},
+ {HoldReason::WitnessDisappeared, "witness_disappeared"},
+ {HoldReason::BodyUndecodable, "body_undecodable"},
+ {HoldReason::ManifestBodyMissing, "manifest_body_missing"},
+ {HoldReason::CheckpointUndecodable, "checkpoint_undecodable"},
+}}};
+
+static_assert(casEnumTableCoversEnum());
+
+constexpr EnumWireTable kCoverageClassWords{{{
+ {CoverageClass::Absent, "absent"},
+ {CoverageClass::Unchanged, "unchanged"},
+ {CoverageClass::Folded, "folded"},
+ {CoverageClass::Clamped, "clamped"},
+}}};
+
+static_assert(casEnumTableCoversEnum());
/// A hold names a position the fold must resolve, and both components of that id are nonzero (the
/// canonical `RefTxnId` rule `renderRefTxnId` enforces for every id that becomes a key). A zero
@@ -83,16 +99,16 @@ void insertRecordOnce(Map & map, const Key & key, Value && value, std::string_vi
what, key);
}
-/// Emit one run record (`k` = "btr") WITHOUT its line terminator; the caller closes (and measures) the
+/// Emit one run record (`kind` = `blob_run`) WITHOUT its line terminator; the caller closes (and measures) the
/// line, and sorts the vector by key first.
void writeRun(CasJsonWriter & out, std::string_view kind, const RunRef & r)
{
bool first = true;
- writeKey(out, "k", first); writeStringValue(out, kind);
- writeKey(out, "key", first); writeStringValue(out, r.key);
- writeKey(out, "ck", first); writeHex128Value(out, r.checksum);
- writeKey(out, "shard", first); writeIntText(r.shard, out);
- writeKey(out, "gen", first); writeU64StringValue(out, r.generation);
+ writeWordField(out, FoldSealWire::kind, kind, first);
+ writeStringField(out, FoldSealWire::run_key, r.key, first);
+ writeHex128Field(out, FoldSealWire::checksum, r.checksum, first);
+ writeNumberField(out, FoldSealWire::shard, r.shard, first);
+ writeU64StringField(out, FoldSealWire::key_generation, r.key_generation, first);
closeObject(out, first);
}
@@ -106,7 +122,7 @@ void validateFoldSealStructure(
std::vector run_seen(gc_shards, false);
for (const RunRef & run : seal.blob_target_runs)
{
- if (run.key.empty() || run.generation == 0)
+ if (run.key.empty() || run.key_generation == 0)
throw Exception(error_code,
"CAS fold seal {}: blob-target run requires a nonempty key and nonzero physical generation",
source);
@@ -121,10 +137,10 @@ void validateFoldSealStructure(
run_seen[run.shard] = true;
const auto parsed = layout.parseBlobTargetRunKey(run.key);
- if (!parsed || parsed->generation != run.generation || parsed->shard != run.shard || parsed->seq != 0)
+ if (!parsed || parsed->generation != run.key_generation || parsed->shard != run.shard || parsed->seq != 0)
throw Exception(error_code,
"CAS fold seal {}: blob-target run key '{}' is not canonical for generation {}, shard {}, sequence 0",
- source, run.key, run.generation, run.shard);
+ source, run.key, run.key_generation, run.shard);
}
if (seal.condemned_summary.size() != gc_shards)
@@ -154,6 +170,41 @@ void validateFoldSealStructure(
}
+std::string_view holdReasonToWord(HoldReason r)
+{
+ return kHoldReasonWords.toWord(r, "CAS fold seal hold reason");
+}
+
+HoldReason holdReasonFromWord(std::string_view w)
+{
+ return kHoldReasonWords.fromWord(w, "CAS fold seal hold reason");
+}
+
+std::string_view coverageClassToWord(CoverageClass c)
+{
+ return kCoverageClassWords.toWord(c, "CAS fold seal classification");
+}
+
+CoverageClass coverageClassFromWord(std::string_view w)
+{
+ return kCoverageClassWords.fromWord(w, "CAS fold seal classification");
+}
+
+namespace
+{
+/// A seal can carry thousands of `ref_life` rows, so a rejected word names the row that carries it --
+/// "unknown word" alone leaves an operator scanning the object by hand.
+CoverageClass coverageClassInRow(std::string_view w, std::string_view life_hex)
+{
+ return kCoverageClassWords.fromWord(w, fmt::format("CAS fold seal: ref_life '{}' classification", life_hex));
+}
+
+HoldReason holdReasonInRow(std::string_view w, std::string_view life_hex)
+{
+ return kHoldReasonWords.fromWord(w, fmt::format("CAS fold seal: ref_life '{}' hold_reason", life_hex));
+}
+}
+
FoldSealCaps foldSealCaps()
{
const FormatTraits & t = traitsFor(FormatId::FoldSeal);
@@ -205,8 +256,8 @@ String encodeFoldSeal(const CasFoldSeal & seal)
/// meta line
{
bool first = true;
- writeKey(out, "g", first); writeU64StringValue(out, seal.generation);
- writeKey(out, "pg", first); writeU64StringValue(out, seal.parent_generation);
+ writeU64StringField(out, FoldSealWire::generation, seal.generation, first);
+ writeU64StringField(out, FoldSealWire::parent_generation, seal.parent_generation, first);
closeObject(out, first);
closeLine("meta");
}
@@ -227,22 +278,19 @@ String encodeFoldSeal(const CasFoldSeal & seal)
/// process, not corruption arriving from a store — and none of these shapes is repairable once
/// durable, so none is ever written.
///
- /// A classification outside the closed set first, because the two checks after it are stated in
- /// terms of the set and a row they cannot classify makes their answers meaningless.
- if (!isKnownClassification(cov.classification))
- throw Exception(ErrorCodes::LOGICAL_ERROR,
- "CAS fold seal: coverage '{}' has classification {}, which is not one of the four the "
- "fold grammar defines (0 absent, 1 unchanged, 2 folded, 4 clamped) — every consumer "
- "branches on those exact values, so this row would pass refusals meant to stop it",
- life_hex, cov.classification);
- /// A classification-4 row whose hold was dropped is indistinguishable, once durable, from a
- /// namespace that stopped for no reason — and a hold on any other classification claims a stop
- /// that did not happen.
- if ((cov.classification == 4) != cov.hold.has_value())
+ /// A classification outside the four named values first, because the two checks after it are
+ /// stated in terms of those names and a row they cannot classify makes their answers meaningless.
+ /// `coverageClassToWord` IS that range check (it throws `LOGICAL_ERROR` for a value the table
+ /// does not index), so capturing its result here also gives the record its wire value below.
+ const std::string_view classification_word = coverageClassToWord(cov.classification);
+ /// A clamped row whose hold was dropped is indistinguishable, once durable, from a namespace that
+ /// stopped for no reason — and a hold on any other classification claims a stop that did not
+ /// happen.
+ if ((cov.classification == CoverageClass::Clamped) != cov.hold.has_value())
throw Exception(ErrorCodes::LOGICAL_ERROR,
"CAS fold seal: coverage '{}' has classification {} and {} hold — the hold fields are "
- "required for classification 4 and forbidden otherwise",
- life_hex, cov.classification, cov.hold ? "a" : "no");
+ "required for classification clamped and forbidden otherwise",
+ life_hex, classification_word, cov.hold ? "a" : "no");
/// A hold that names no position resolves itself on the next round (nothing sorts below
/// `{0, 0}`) and cannot be rendered where the sweep reports why it retained a manifest.
if (cov.hold && !isCanonicalHoldPosition(cov.hold->offending_position))
@@ -263,28 +311,26 @@ String encodeFoldSeal(const CasFoldSeal & seal)
life_state.cleanup_evidence->remove_txn_id.ref_sequence);
bool first = true;
- writeKey(out, "k", first); writeStringValue(out, "rfl");
- writeKey(out, "life", first); writeHex128Value(out, life_id);
- writeKey(out, "cls", first); writeIntText(static_cast(cov.classification), out);
- writeKey(out, "lfe", first); writeU64StringValue(out, cov.last_folded_ref_id.writer_epoch);
- writeKey(out, "lfs", first); writeU64StringValue(out, cov.last_folded_ref_id.ref_sequence);
+ writeWordField(out, FoldSealWire::kind, kRefLifeTag, first);
+ writeHex128Field(out, FoldSealWire::life, life_id, first);
+ writeWordField(out, FoldSealWire::classification, classification_word, first);
+ writeU64StringField(out, FoldSealWire::fold_epoch, cov.last_folded_ref_id.writer_epoch, first);
+ writeU64StringField(out, FoldSealWire::fold_seq, cov.last_folded_ref_id.ref_sequence, first);
if (cov.hold)
{
- writeKey(out, "hr", first); writeStringValue(out, holdReasonToWord(cov.hold->reason));
- writeKey(out, "hpe", first); writeU64StringValue(out, cov.hold->offending_position.writer_epoch);
- writeKey(out, "hps", first); writeU64StringValue(out, cov.hold->offending_position.ref_sequence);
- writeKey(out, "hrc", first); writeIntText(cov.hold->retry_count, out);
- writeKey(out, "hnr", first); writeU64StringValue(out, cov.hold->next_retry_round);
+ writeWordField(out, FoldSealWire::hold_reason, holdReasonToWord(cov.hold->reason), first);
+ writeU64StringField(out, FoldSealWire::hold_epoch, cov.hold->offending_position.writer_epoch, first);
+ writeU64StringField(out, FoldSealWire::hold_seq, cov.hold->offending_position.ref_sequence, first);
+ writeNumberField(out, FoldSealWire::retries, cov.hold->retry_count, first);
+ writeU64StringField(out, FoldSealWire::retry_round, cov.hold->next_retry_round, first);
}
if (life_state.cleanup_evidence)
{
- writeKey(out, "rte", first);
- writeU64StringValue(out, life_state.cleanup_evidence->remove_txn_id.writer_epoch);
- writeKey(out, "rts", first);
- writeU64StringValue(out, life_state.cleanup_evidence->remove_txn_id.ref_sequence);
+ writeU64StringField(out, FoldSealWire::remove_epoch, life_state.cleanup_evidence->remove_txn_id.writer_epoch, first);
+ writeU64StringField(out, FoldSealWire::remove_seq, life_state.cleanup_evidence->remove_txn_id.ref_sequence, first);
}
closeObject(out, first);
- closeLine("rfl");
+ closeLine(kRefLifeTag);
++n;
}
@@ -293,8 +339,8 @@ String encodeFoldSeal(const CasFoldSeal & seal)
std::sort(runs.begin(), runs.end(), [](const RunRef & a, const RunRef & b) { return a.key < b.key; });
for (const RunRef & r : runs)
{
- writeRun(out, "btr", r);
- closeLine("btr");
+ writeRun(out, kBlobRunTag, r);
+ closeLine(kBlobRunTag);
}
}
n += seal.blob_target_runs.size();
@@ -303,13 +349,13 @@ String encodeFoldSeal(const CasFoldSeal & seal)
for (const auto & [shard, s] : seal.condemned_summary)
{
bool first = true;
- writeKey(out, "k", first); writeStringValue(out, "cnd");
- writeKey(out, "shard", first); writeIntText(shard, out);
- writeKey(out, "ct", first); writeIntText(s.condemned_total, out);
- writeKey(out, "pt", first); writeIntText(s.pending_total, out);
- writeKey(out, "ocr", first); writeU64StringValue(out, s.oldest_nonpending_condemn_round);
+ writeWordField(out, FoldSealWire::kind, kCondemnedTag, first);
+ writeNumberField(out, FoldSealWire::shard, shard, first);
+ writeNumberField(out, FoldSealWire::condemned_total, s.condemned_total, first);
+ writeNumberField(out, FoldSealWire::pending_total, s.pending_total, first);
+ writeU64StringField(out, FoldSealWire::oldest_round, s.oldest_nonpending_condemn_round, first);
closeObject(out, first);
- closeLine("cnd");
+ closeLine(kCondemnedTag);
++n;
}
@@ -338,18 +384,24 @@ CasFoldSeal decodeFoldSeal(std::string_view data, std::optional expect
String key;
while (r.nextKey(key))
{
- if (key == "g") seal.generation = r.readU64String();
- else if (key == "pg") seal.parent_generation = r.readU64String();
+ if (key == FoldSealWire::generation) seal.generation = r.readU64String();
+ else if (key == FoldSealWire::parent_generation) seal.parent_generation = r.readU64String();
else r.skipUnknown(key); /// Strict => any unknown key is CORRUPTED_DATA
}
}
uint64_t seen = 0;
+ /// One line scratch and one reader for the whole loop: a decoder that rebuilds them per
+ /// row pays an allocation per row for the seen-key store and the line, which profiling put
+ /// at about a fifth of the instructions executed inside a row.
+ String row_line;
+ JsonObjectReader row_reader;
while (true)
{
- const String line = readLine(in, line_cap, "fold seal");
- ReadBufferFromMemory l(line.data(), line.size());
- JsonObjectReader r(l, KeyStrictness::Strict, "fold seal");
+ readLineInto(in, row_line, line_cap, "fold seal");
+ ReadBufferFromMemory l(row_line.data(), row_line.size());
+ row_reader.reset(l, KeyStrictness::Strict, "fold seal");
+ JsonObjectReader & r = row_reader;
String key;
if (!r.nextKey(key))
throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS fold seal: empty line");
@@ -370,22 +422,19 @@ CasFoldSeal decodeFoldSeal(std::string_view data, std::optional expect
seal.generation, *expected_generation);
return seal;
}
- if (key != "k")
- throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS fold seal: record must start with \"k\"");
+ if (key != FoldSealWire::kind)
+ throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS fold seal: record must start with \"kind\"");
const String kind = r.readString();
- if (kind == "rfl")
+ if (kind == kRefLifeTag)
{
std::optional life_id;
RefCoverage cov;
- /// Read WIDE and validated before it is narrowed to the persisted byte. `cls` is the field
- /// every consumer branches on, and a plain `static_cast` maps 258 onto 2 ("all
- /// records through the cursor were folded") and 256 onto 0 — a forged or damaged seal would
- /// buy full coverage with an integer no reader ever sees.
- std::optional classification;
/// The hold fields are read individually so the grammar can be checked on WHICH of them
/// arrived, not merely on how many. `JsonObjectReader` already rejects a duplicate key, so
- /// a second `hr` can never quietly rewrite the reason.
+ /// a second `hold_reason` can never quietly rewrite the reason.
+ std::optional classification_word;
+ std::optional hold_reason_word;
std::optional hold_reason;
std::optional hold_epoch;
std::optional hold_sequence;
@@ -395,18 +444,21 @@ CasFoldSeal decodeFoldSeal(std::string_view data, std::optional expect
std::optional remove_txn_sequence;
while (r.nextKey(key))
{
- if (key == "life") life_id = r.readHex128();
- else if (key == "cls") classification = r.readU64Number();
- else if (key == "lfe") cov.last_folded_ref_id.writer_epoch = r.readU64String();
- else if (key == "lfs") cov.last_folded_ref_id.ref_sequence = r.readU64String();
- else if (key == "hr") hold_reason = holdReasonFromWord(r.readString());
- else if (key == "hpe") hold_epoch = r.readU64String();
- else if (key == "hps") hold_sequence = r.readU64String();
- else if (key == "hrc") hold_retry_count = r.readU32Number();
- else if (key == "hnr") hold_next_retry_round = r.readU64String();
- else if (key == "rte") remove_txn_epoch = r.readU64String();
- else if (key == "rts") remove_txn_sequence = r.readU64String();
- else throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS fold seal: unknown rfl key '{}'", key);
+ if (key == FoldSealWire::life) life_id = r.readHex128();
+ /// The two word-valued fields are collected as words and converted BELOW, once the row's
+ /// life id is known: a seal can carry thousands of rows, so an unknown word has to say
+ /// WHICH row carries it.
+ else if (key == FoldSealWire::classification) classification_word = r.readString();
+ else if (key == FoldSealWire::fold_epoch) cov.last_folded_ref_id.writer_epoch = r.readU64String();
+ else if (key == FoldSealWire::fold_seq) cov.last_folded_ref_id.ref_sequence = r.readU64String();
+ else if (key == FoldSealWire::hold_reason) hold_reason_word = r.readString();
+ else if (key == FoldSealWire::hold_epoch) hold_epoch = r.readU64String();
+ else if (key == FoldSealWire::hold_seq) hold_sequence = r.readU64String();
+ else if (key == FoldSealWire::retries) hold_retry_count = r.readU32Number();
+ else if (key == FoldSealWire::retry_round) hold_next_retry_round = r.readU64String();
+ else if (key == FoldSealWire::remove_epoch) remove_txn_epoch = r.readU64String();
+ else if (key == FoldSealWire::remove_seq) remove_txn_sequence = r.readU64String();
+ else throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS fold seal: unknown ref_life key '{}'", key);
}
if (!life_id || *life_id == 0)
@@ -414,16 +466,13 @@ CasFoldSeal decodeFoldSeal(std::string_view data, std::optional expect
"CAS fold seal: a ref-life row is missing a nonzero opaque life id");
const String life_hex = u128ToHex(*life_id);
- /// `cls` is required, not defaulted: an absent one would read as 0 ("no round folded this
- /// namespace"), which is a claim about a fold, not the absence of one.
- if (!classification)
- throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS fold seal: rfl '{}' missing cls", life_hex);
- if (!isKnownClassification(*classification))
- throw Exception(ErrorCodes::CORRUPTED_DATA,
- "CAS fold seal: coverage '{}' has classification {}, which is not one of the four "
- "the fold grammar defines (0 absent, 1 unchanged, 2 folded, 4 clamped)",
- life_hex, *classification);
- cov.classification = static_cast(*classification); /// in range, so narrowing is exact
+ /// `class` is required, not defaulted: an absent one would read as `absent` ("no round
+ /// folded this namespace"), which is a claim about a fold, not the absence of one.
+ if (!classification_word)
+ throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS fold seal: ref_life '{}' missing class", life_hex);
+ if (hold_reason_word)
+ hold_reason = holdReasonInRow(*hold_reason_word, life_hex);
+ cov.classification = coverageClassInRow(*classification_word, life_hex);
/// The same strict grammar the encoder enforces, applied to bytes we did not write. A
/// PARTIAL hold is corruption, never a hold with defaults: a hold whose offending position
@@ -432,11 +481,11 @@ CasFoldSeal decodeFoldSeal(std::string_view data, std::optional expect
|| hold_retry_count || hold_next_retry_round;
const bool every_hold_field = hold_reason && hold_epoch && hold_sequence
&& hold_retry_count && hold_next_retry_round;
- if (cov.classification == 4)
+ if (cov.classification == CoverageClass::Clamped)
{
if (!every_hold_field)
throw Exception(ErrorCodes::CORRUPTED_DATA,
- "CAS fold seal: coverage '{}' is held (classification 4) but its hold is "
+ "CAS fold seal: coverage '{}' is held (classification clamped) but its hold is "
"incomplete — reason, offending position, retry count and next retry round are "
"all required", life_hex);
/// PRESENT is not enough: the position must be one a fold can actually retry. `{0, 0}`
@@ -456,8 +505,8 @@ CasFoldSeal decodeFoldSeal(std::string_view data, std::optional expect
else if (any_hold_field)
throw Exception(ErrorCodes::CORRUPTED_DATA,
"CAS fold seal: coverage '{}' carries hold fields at classification {} — they are "
- "forbidden on anything but a held (classification 4) row",
- life_hex, cov.classification);
+ "forbidden on anything but a held (classification clamped) row",
+ life_hex, coverageClassToWord(cov.classification));
const bool any_cleanup_field = remove_txn_epoch || remove_txn_sequence;
const bool every_cleanup_field = remove_txn_epoch && remove_txn_sequence;
@@ -478,7 +527,7 @@ CasFoldSeal decodeFoldSeal(std::string_view data, std::optional expect
"CAS fold seal: a second ref-life record for '{}' -- a life id appears at most once",
life_hex);
}
- else if (kind == "btr")
+ else if (kind == kBlobRunTag)
{
std::optional run_key;
std::optional checksum;
@@ -486,19 +535,19 @@ CasFoldSeal decodeFoldSeal(std::string_view data, std::optional expect
std::optional generation;
while (r.nextKey(key))
{
- if (key == "key") run_key = r.readString();
- else if (key == "ck") checksum = r.readHex128();
- else if (key == "shard") shard = r.readU64Number();
- else if (key == "gen") generation = r.readU64String();
+ if (key == FoldSealWire::run_key) run_key = r.readString();
+ else if (key == FoldSealWire::checksum) checksum = r.readHex128();
+ else if (key == FoldSealWire::shard) shard = r.readU64Number();
+ else if (key == FoldSealWire::key_generation) generation = r.readU64String();
else throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS fold seal: unknown run key '{}'", key);
}
if (!run_key || !checksum || !shard || !generation)
throw Exception(ErrorCodes::CORRUPTED_DATA,
- "CAS fold seal: btr requires key, ck, shard, and gen");
+ "CAS fold seal: blob_run requires key, checksum, shard, and key_generation");
seal.blob_target_runs.push_back(RunRef{
- .key = std::move(*run_key), .checksum = *checksum, .shard = *shard, .generation = *generation});
+ .key = std::move(*run_key), .checksum = *checksum, .shard = *shard, .key_generation = *generation});
}
- else if (kind == "cnd")
+ else if (kind == kCondemnedTag)
{
std::optional shard;
std::optional condemned_total;
@@ -506,15 +555,15 @@ CasFoldSeal decodeFoldSeal(std::string_view data, std::optional expect
std::optional oldest_nonpending_condemn_round;
while (r.nextKey(key))
{
- if (key == "shard") shard = r.readU64Number();
- else if (key == "ct") condemned_total = r.readU64Number();
- else if (key == "pt") pending_total = r.readU64Number();
- else if (key == "ocr") oldest_nonpending_condemn_round = r.readU64String();
- else throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS fold seal: unknown cnd key '{}'", key);
+ if (key == FoldSealWire::shard) shard = r.readU64Number();
+ else if (key == FoldSealWire::condemned_total) condemned_total = r.readU64Number();
+ else if (key == FoldSealWire::pending_total) pending_total = r.readU64Number();
+ else if (key == FoldSealWire::oldest_round) oldest_nonpending_condemn_round = r.readU64String();
+ else throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS fold seal: unknown condemned key '{}'", key);
}
if (!shard || !condemned_total || !pending_total || !oldest_nonpending_condemn_round)
throw Exception(ErrorCodes::CORRUPTED_DATA,
- "CAS fold seal: cnd requires shard, ct, pt, and ocr");
+ "CAS fold seal: condemned requires shard, condemned, pending, and oldest_round");
insertRecordOnce(seal.condemned_summary, *shard, CondemnedSummary{
.condemned_total = *condemned_total,
.pending_total = *pending_total,
diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasFoldSealFormat.h b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasFoldSealFormat.h
index f6d00a6e3b50..02a043e9b2b2 100644
--- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasFoldSealFormat.h
+++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasFoldSealFormat.h
@@ -27,7 +27,7 @@ struct RunRef
String key;
UInt128 checksum{};
uint64_t shard = 0; /// gc-shard this run belongs to (REQUIRED for blob_target_runs)
- uint64_t generation = 0; /// generation whose key namespace physically holds the object (for retention)
+ uint64_t key_generation = 0; /// generation whose key namespace physically holds the object (for retention)
bool operator==(const RunRef &) const = default;
};
@@ -36,10 +36,12 @@ struct RunRef
/// correlating logs. Persisted as a word, so an unknown word is `CORRUPTED_DATA` rather than a silently
/// reinterpreted integer.
///
-/// THESE ARE WIRE VALUES, AND THEY ARE APPEND-ONLY. A durable seal written by one build is read by
-/// another, so a renumbered value or a reused word makes an older seal describe a hold that is not the
-/// one it recorded — and a hold's whole job is to say truthfully what stopped a namespace and where.
-/// Add new reasons at the end; never renumber, never repurpose a retired word.
+/// THE WORDS ARE THE WIRE, AND THE WORD VOCABULARY IS APPEND-ONLY. A durable seal written by one build
+/// is read by another, so a reused or repurposed word makes an older seal describe a hold that is not
+/// the one it recorded — and a hold's whole job is to say truthfully what stopped a namespace and
+/// where. The enumerator NUMBERS never leave memory; they are constrained only by the wire table's
+/// density-and-order proof, so inserting a value in the middle is a compile-time question, not a
+/// durability one. Add new reasons freely; never reuse or repurpose a retired word.
enum class HoldReason : uint8_t
{
GapBelowWitness = 1, /// 404 at the expected id with a durable witness above it, same epoch
@@ -55,6 +57,34 @@ enum class HoldReason : uint8_t
/// namespace — and a second rendering of these words elsewhere would be a second place for them to drift.
std::string_view holdReasonToWord(HoldReason r);
+/// Its fail-closed inverse: an unknown word is `CORRUPTED_DATA`. Paired with the renderer so a caller
+/// that needs to prove the vocabulary round-trips does not have to reach for the table itself.
+HoldReason holdReasonFromWord(std::string_view w);
+
+/// What the current round did for one life-keyed `CasFoldSeal::ref_lives` row. A BOUNDED enum: the type
+/// itself is the closed set, so a producer cannot construct a fifth shape without an explicit cast, and
+/// the decoder's wire-word lookup refuses anything else as `CORRUPTED_DATA` rather than silently
+/// reinterpreting an integer.
+///
+/// THE WORDS ARE THE WIRE, AND THE WORD VOCABULARY IS APPEND-ONLY, for the same reason `HoldReason`'s
+/// is: a durable seal written by one build is read by another. The enumerator numbers never leave
+/// memory. `Clamped` sits at 3, not the 4 a retired byte-valued wire used for it: nothing outside this
+/// JSON ever persisted the raw byte, and a dense range is what makes the wire table's lookup a direct
+/// index rather than a search.
+enum class CoverageClass : uint8_t
+{
+ Absent = 0, /// no round has folded a ref cursor for this namespace
+ Unchanged = 1, /// folded, but nothing moved this round
+ Folded = 2, /// every record through the observed cursor was folded
+ Clamped = 3, /// folding stopped below the ref-log cursor; must be read again next round
+};
+
+/// The wire word one `CoverageClass` is persisted as; `fromWord` rejects anything else as
+/// `CORRUPTED_DATA`. Exported for the same reason `holdReasonToWord` is: the classification is rendered
+/// outside the codec too (`cas-inspect`, the sweep's retention messages).
+std::string_view coverageClassToWord(CoverageClass c);
+CoverageClass coverageClassFromWord(std::string_view w);
+
/// The durable hold on one namespace. It rides `RefCoverage` across rounds and across `REBUILD`, and
/// clears ONLY by folding through `offending_position` and adopting the result in `gc/state` — never by
/// observing another absent, because an absent is exactly the observation a lying store produces.
@@ -87,21 +117,17 @@ struct RefHold
bool operator==(const RefHold &) const = default;
};
-/// Records what the current round did for one life-keyed `CasFoldSeal::ref_lives` row.
-/// `classification` is a persisted byte:
-/// 0 means absent, 1 means unchanged, 2 means all records through the observed cursor were folded, and 4
-/// means folding was clamped below the ref-log cursor. A clamped entry must be read again in the next
-/// round, because an unfolded event may become foldable by then.
+/// Records what the current round did for one life-keyed `CasFoldSeal::ref_lives` row. See
+/// `CoverageClass` for what each value means.
///
-/// THE SET {0, 1, 2, 4} IS CLOSED, and both codecs enforce it (decode `CORRUPTED_DATA`, encode
-/// `LOGICAL_ERROR`). Every consumer branches on exact values — the sweep's §6 deletion premise refuses a
-/// row by testing `== 4` and `== 0` — so an unrecognized byte is not a variant to tolerate: it passes
-/// every refusal stated in terms of the set and reaches the delete. The decoder also validates BEFORE
-/// narrowing to the byte, because a wide integer on the wire (258, say) truncates into the set and would
-/// otherwise claim a coverage the fold never proved.
+/// Every consumer branches on exact values — the sweep's §6 deletion premise refuses a row by testing
+/// `== Clamped` and `== Absent` — so the CLOSED set matters beyond the codec, and it is now the type
+/// itself: `CoverageClass` names exactly the four shapes, both codecs go through the shared wire table
+/// (decode `CORRUPTED_DATA`, encode `LOGICAL_ERROR` on the one path that still reaches an out-of-range
+/// value, an explicit cast), and an unrecognized wire word is refused before it ever reaches a consumer.
struct RefCoverage
{
- uint8_t classification = 0;
+ CoverageClass classification = CoverageClass::Absent;
/// The greatest `RefTxnId` whose owner changes have contributed their manifest-edge deltas. There is
/// one ref-log stream per namespace life, so this cursor is stored in that life-keyed row.
@@ -109,11 +135,11 @@ struct RefCoverage
/// offending transaction so the complete transaction is retried rather than partially applied.
RefTxnId last_folded_ref_id{};
- /// STRICT GRAMMAR: present if and only if `classification == 4`. Both directions enforce it — the
- /// encoder refuses to write a classification-4 row without a hold (a clamp whose reason was lost is
- /// indistinguishable from a clean cursor once it is durable) and refuses to write a hold on any
- /// other classification (`LOGICAL_ERROR`); the decoder rejects both shapes as `CORRUPTED_DATA`. The
- /// pairing lives in the type, not only in the codec, so no producer can construct the forbidden
+ /// STRICT GRAMMAR: present if and only if `classification == CoverageClass::Clamped`. Both directions
+ /// enforce it — the encoder refuses to write a clamped row without a hold (a clamp whose reason was
+ /// lost is indistinguishable from a clean cursor once it is durable) and refuses to write a hold on
+ /// any other classification (`LOGICAL_ERROR`); the decoder rejects both shapes as `CORRUPTED_DATA`.
+ /// The pairing lives in the type, not only in the codec, so no producer can construct the forbidden
/// combination by forgetting a field.
std::optional hold = std::nullopt;
@@ -147,7 +173,7 @@ struct RefLifeFoldState
/// must not be interpreted as zero.
struct CondemnedSummary
{
- uint64_t condemned_total = 0; /// count of `kCondemned` rows in this shard's sealed run
+ uint64_t condemned_total = 0; /// count of `RunMarker::Condemned` rows in this shard's sealed run
uint64_t pending_total = 0; /// how many of those are `delete_pending` (a graduation is due)
uint64_t oldest_nonpending_condemn_round = UINT64_MAX; /// min condemn_round over non-pending; UINT64_MAX = none
bool operator==(const CondemnedSummary &) const = default;
@@ -189,10 +215,10 @@ FoldSealCaps foldSealCaps();
void checkFoldSealObjectBytes(uint64_t encoded_bytes);
/// Encodes a fold seal as a strict, raw text control object. The header and meta lines are followed by
-/// tagged records in the fixed `rfl`/`btr`/`cnd` order and a record-count trailer. Map iteration and
+/// tagged records in the fixed `ref_life`/`blob_run`/`condemned` order and a record-count trailer. Map iteration and
/// run references are sorted so retries produce byte-identical output for write-once adoption.
///
-/// Enforces the whole coverage grammar — the closed classification set, the classification-4 hold
+/// Enforces the whole coverage grammar — the closed classification set, the clamped-classification hold
/// pairing, and the hold's canonical offending position — and BOTH byte caps: every emitted line against
/// `line_cap` — header, meta, records and trailer alike, with no exception — and the whole object
/// against `object_cap`. Both PUT sites go through this function, so the gate cannot be bypassed by
diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasFormat.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasFormat.cpp
index 362306691473..23b19ddc3508 100644
--- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasFormat.cpp
+++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasFormat.cpp
@@ -1,6 +1,8 @@
#include
#include
+#include
+
namespace DB
{
namespace ErrorCodes
@@ -16,57 +18,13 @@ namespace DB::Cas
namespace
{
-/// Generation-1 baseline for every class. A future format change appends to that class's array and
+/// Generation-1 baseline for every class. A future format change appends to that class's own array and
/// bumps `G_BUILD`: additive changes use the previous reader floor, while breaking changes use the
-/// new generation as the floor. Existing entries are immutable history.
+/// new generation as the floor. Existing entries are immutable history. Every class currently shares
+/// this baseline; a class that outgrows it gets its own named array again, the way the pre-reset
+/// history once had.
constexpr FormatChangePoint BASELINE[] = {{1, 1}};
-/// The two ref classes changed at generation 4 (INV-1, per-namespace contiguous ids) AND AGAIN at
-/// generation 5 (Stage B's recreate-only "format bump B": the ref layer re-keyed under
-/// `//`). Both changes are BREAKING even though not one byte of the encoding moved
-/// either time -- a generation-3 stream's ids came from a pool-wide counter and legitimately skip,
-/// which a generation-4 reader reports as corruption, and a generation-4 key names no incarnation at
-/// all, which a generation-5 reader also reports as corruption (`Layout::parseRefObjectKey`). Each
-/// floor is the change generation itself.
-constexpr FormatChangePoint REF_STREAM[] = {
- {1, 1},
- {kContiguousRefStreamsGeneration, kContiguousRefStreamsGeneration},
- {kNamespaceLifeKeyedGeneration, kNamespaceLifeKeyedGeneration},
- {kOpaqueNamespaceLifeLayoutGeneration, kOpaqueNamespaceLifeLayoutGeneration},
-};
-
-/// `cas_ref_ckpt` is BORN at generation 4, so it has no generation-1 baseline to inherit: there is no
-/// such thing as a generation-1 `_ckpt` object, and claiming one would say a generation-1 reader could
-/// read it. Generation 5 re-keys it under `//` exactly like `REF_STREAM` above, for
-/// the same reason and with the same floor.
-constexpr FormatChangePoint REF_CKPT[] = {
- {kContiguousRefStreamsGeneration, kContiguousRefStreamsGeneration},
- {kNamespaceLifeKeyedGeneration, kNamespaceLifeKeyedGeneration},
- {kOpaqueNamespaceLifeLayoutGeneration, kOpaqueNamespaceLifeLayoutGeneration},
- {kCommittedRefFrontierGeneration, kCommittedRefFrontierGeneration},
-};
-
-/// `cas_ref_catalog` is BORN at generation 4, one generation BEFORE the bump that makes namespace
-/// existence catalog-authoritative (Stage B's Task 4, "format bump B" -- `kNamespaceLifeKeyedGeneration`):
-/// Task 2 introduced the catalog OBJECT while `G_BUILD` was still the value
-/// `kContiguousRefStreamsGeneration` names, and Task 4 is the later change that actually wires
-/// discovery to read it and bumps the floor. The catalog's own encoding is unaffected by that bump (it
-/// reuses `kContiguousRefStreamsGeneration` as its birth generation, not a second constant named after
-/// itself, for the same reason `REF_CKPT` originally did), so it carries no second change point here.
-constexpr FormatChangePoint REF_CATALOG[] = {{kContiguousRefStreamsGeneration, kContiguousRefStreamsGeneration}};
-constexpr FormatChangePoint GC_MAINTENANCE_STATE[] = {{kUnifiedRefLifeFoldGeneration, kUnifiedRefLifeFoldGeneration}};
-constexpr FormatChangePoint POOL_META[] = {
- {1, 1},
- {kPoolGcShardsGeneration, kPoolGcShardsGeneration},
- {kCommittedRefFrontierGeneration, kCommittedRefFrontierGeneration},
- {kMountWriteAttemptIdGeneration, kMountWriteAttemptIdGeneration},
-};
-
-constexpr FormatChangePoint MOUNT_LEASE[] = {
- {1, 1},
- {kMountWriteAttemptIdGeneration, kMountWriteAttemptIdGeneration},
-};
-
}
std::span changePoints(FormatId id)
@@ -75,17 +33,11 @@ std::span changePoints(FormatId id)
{
case FormatId::RefLog:
case FormatId::RefSnapshot:
- return REF_STREAM;
case FormatId::RefCkpt:
- return REF_CKPT;
case FormatId::RefCatalog:
- return REF_CATALOG;
case FormatId::GcMaintenanceState:
- return GC_MAINTENANCE_STATE;
case FormatId::PoolMeta:
- return POOL_META;
case FormatId::MountLease:
- return MOUNT_LEASE;
case FormatId::Blob:
case FormatId::GcState:
case FormatId::Roster:
@@ -197,6 +149,18 @@ const FormatTraits * traitsForType(std::string_view type)
return nullptr;
}
+std::span allRegisteredFormatIds()
+{
+ static const auto ids = []
+ {
+ std::array out{};
+ for (size_t i = 0; i < std::size(TRAITS); ++i)
+ out[i] = TRAITS[i].id;
+ return out;
+ }();
+ return ids;
+}
+
std::string_view storedSuffix(FormatId id)
{
return traitsFor(id).compression == CompressionPolicy::Always ? ".zst" : "";
diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasFormat.h b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasFormat.h
index 1acc2b3925de..7b287a01ae9d 100644
--- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasFormat.h
+++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasFormat.h
@@ -15,87 +15,10 @@ namespace DB::Cas
/// compatibility_version <= G_BUILD. Bump this (and append a change-point in CasFormat.cpp) when a new
/// format generation is introduced.
///
-/// Generation 2 is the first generation that understands mixed-algorithm pools: the schema-3
-/// source-edge settlement key includes the algorithm prefix, so a generation-1 reader can open the
-/// pool but cannot decode its GC state. Pool admission CAS-raises `min_reader_generation` to this
-/// build's own floor (`G_BUILD`), and a persisted floor above `G_BUILD` fails closed.
-///
-/// Generation 3 replaced mutable ref-shard objects with immutable `_log` and `_snap` objects.
-///
-/// Generation 4 makes each namespace's ref-log ids per-namespace and CONTIGUOUS within a writer epoch
-/// (INV-1). The bytes of a `_log`/`_snap` object did not change, but their MEANING did: a generation-3
-/// pool's ids were drawn from a pool-wide counter and are full of legitimate holes, which this build
-/// reads as a truncated -- i.e. corrupt -- stream. The per-object forward gate cannot reject such a
-/// pool (its version is not in the future), so pool-meta decoding applies
-/// `kContiguousRefStreamsGeneration` as a backward floor. Pools below the floor must be recreated;
-/// there is no migration path in the pre-release format.
-///
-/// Generation 5 (Stage B's own recreate-only bump, the plan's "format bump B") re-keys the ref layer
-/// under `//` (spec INV-3: the whole-pool namespace catalog mints the incarnation).
-/// Again the bytes of `_log`/`_snap`/`_ckpt` objects did not change, but the KEY SHAPE they live under
-/// did: a generation-4 key named a namespace directly (`cas/refs//_log/`), while this
-/// generation's reader recognizes only the incarnation-qualified shape
-/// (`cas/refs///_log/`) -- `Layout::parseRefObjectKey`/`parseRefCkptKey` already
-/// refuse the un-incarnated shape with `CORRUPTED_DATA` (Stage B Tasks 1/1c landed that refusal ahead
-/// of this bump, deliberately: the pre-release format carries zero persisted data and zero compat
-/// obligation, so the key shapes and the bump that makes them the ONLY readable shape need not land in
-/// the same commit). `kNamespaceLifeKeyedGeneration` is the backward floor for this change, applied the
-/// same way `kContiguousRefStreamsGeneration` is.
-///
-/// Generation 6 replaces that namespace-bearing grammar with opaque pool-wide life identifiers and
-/// splits hot ref streams from point-read state: `cas/ns/stream//...` contains `_log`, `_snap`
-/// while `cas/ns/state//...` contains `_ckpt` and `_files`. A generation-5
-/// pool must be recreated; no dual parser or copy-forward path exists.
-///
-/// Generation 7 replaces the fold seal's independent namespace-keyed coverage and cleanup
-/// collections with one opaque-life-keyed row and removes the retired terminal-marker object class. A generation-6
-/// pool must be recreated; there is no dual reader for the split grammar.
-///
-/// Generation 8 persists the creation-time `gc_shards` authority in `_pool_meta`. Generation-7 pools
-/// must be recreated because namespace admission can precede creation of `gc/state`; accepting a
-/// metadata object without this field would leave different openers charging different seal bounds.
-/// Generation 9 adds `_ckpt.committed_through`, the exact recovery frontier. Generation-8 pools
-/// must be recreated: the absence of this field has the incompatible meaning that no transaction has
-/// entered durable logical history. Generation 10 adds the required `write_attempt_id` to mount
-/// leases. Generation-9 pools must be recreated because a missing attempt identity makes ambiguous
-/// mount writes impossible to distinguish from a different body under the same writer incarnation.
-constexpr uint32_t G_BUILD = 10;
-
-/// The pool-format generation at which ref-log ids became per-namespace and contiguous. Pool metadata
-/// below this value cannot be opened, because its ref streams carry holes this build reports as
-/// corruption; the backward-floor check is applied by `decodePoolMeta`. Named separately from `G_BUILD`
-/// so a later generation that CAN still read a generation-4 pool does not silently move the floor with
-/// it.
-constexpr uint32_t kContiguousRefStreamsGeneration = 4;
-
-/// The pool-format generation at which the ref layer (and, per Stage B's Task 4b, namespace files)
-/// became incarnation-scoped under `//`. Pool metadata below this value cannot be
-/// opened: its ref-object keys carry no incarnation segment, which this build's parsers refuse as
-/// corruption rather than read as a compatibility case (see the `G_BUILD` doc above). The backward-
-/// floor check is applied by `decodePoolMeta`, exactly mirroring `kContiguousRefStreamsGeneration`;
-/// named separately for the same reason that one is -- so a later generation that can still read a
-/// generation-5 pool does not silently move this floor with it. Pools below the floor must be
-/// recreated; there is no migration path in the pre-release format.
-constexpr uint32_t kNamespaceLifeKeyedGeneration = 5;
-
-/// The recreate-only generation at which namespace text disappeared from physical life keys and hot
-/// ref streams were separated from point-read namespace state.
-constexpr uint32_t kOpaqueNamespaceLifeLayoutGeneration = 6;
-
-/// The recreate-only generation at which one unified ref-life row replaced the split coverage and
-/// namespace-cleanup grammar.
-constexpr uint32_t kUnifiedRefLifeFoldGeneration = 7;
-
-/// The recreate-only generation at which `_pool_meta` became the authority for `gc_shards`.
-constexpr uint32_t kPoolGcShardsGeneration = 8;
-
-/// The recreate-only generation at which `_ckpt` gained its exact committed-transaction frontier.
-constexpr uint32_t kCommittedRefFrontierGeneration = 9;
-
-/// The recreate-only generation at which mount leases gained their required durable holder-write
-/// identity. The pool-level reader floor rejects every older pool before it can interpret a mount
-/// body without this field.
-constexpr uint32_t kMountWriteAttemptIdGeneration = 10;
+/// 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;
/// 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:
@@ -151,20 +74,20 @@ 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
/// MEANING of what it encodes changed, and a reader must understand at least `min_reader` to read an
/// object written at that generation. Additive changes retain the previous reader floor; breaking
-/// changes set the floor to the change generation itself. Generation 4's ref-stream entry is the
-/// worked example of the second kind: not one byte of `cas_ref_log` moved, but its ids became dense,
-/// so an older stream is unreadable to this build and the floor is the change generation.
+/// changes set the floor to the change generation itself — even when not one byte of the encoding
+/// moves: a change that makes ids dense, for example, leaves the bytes readable but their MEANING
+/// unreadable to an older build, so the floor is the change generation.
struct FormatChangePoint
{
uint16_t generation;
uint16_t min_reader;
};
-/// Returns the append-only change-point history for `id`, oldest first. A class's history begins at
-/// the generation it was BORN in, not at 1: the classes that existed from the start carry the frozen
-/// `{1, 1}` baseline, while `RefCkpt` — introduced at generation 4 — begins at `{4, 4}`, because there
-/// is no such thing as a generation-1 `_ckpt` and claiming one would say a generation-1 reader could
-/// read it. Future changes append entries without editing old ones.
+/// Returns the append-only change-point history for `id`, oldest first. After the pre-release
+/// generation reset every class carries the shared `{1, 1}` baseline. A class born LATER than the
+/// current baseline must begin its history at its birth generation, not at 1 — claiming an earlier
+/// entry would say an older reader could read an object kind that did not yet exist. Future changes
+/// append entries without editing old ones.
std::span changePoints(FormatId id);
/// The text-format registry has one row per decodable persisted object. Each row is the single source
@@ -202,6 +125,7 @@ const FormatTraits & traitsFor(FormatId id);
/// Looks up a header-line `type` string. Returns nullptr for an unregistered type; it does not throw
/// because callers use this result to classify the input before decoding it.
const FormatTraits * traitsForType(std::string_view type);
+std::span allRegisteredFormatIds();
/// Returns the storage-key suffix for `id`: `.zst` for `Always`, and an empty suffix otherwise.
/// Key builders use this policy directly so a point lookup never has to inspect the object body or
/// try multiple keys.
diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasGcMaintenanceStateFormat.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasGcMaintenanceStateFormat.cpp
index c5dda3286ad6..cc13021b020d 100644
--- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasGcMaintenanceStateFormat.cpp
+++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasGcMaintenanceStateFormat.cpp
@@ -13,6 +13,11 @@ namespace DB::ErrorCodes
namespace DB::Cas
{
+namespace GcMaintenanceWire
+{
+ constexpr WireKey janitor_cursor{"janitor_cursor"};
+}
+
String encodeGcMaintenanceState(const GcMaintenanceState & state)
{
if (state.janitor_cursor.size() > kMaxGcMaintenanceCursorBytes)
@@ -22,8 +27,7 @@ String encodeGcMaintenanceState(const GcMaintenanceState & state)
CasJsonWriter out;
writeHeaderLine(out, FormatId::GcMaintenanceState);
bool first = true;
- writeKey(out, "cur", first);
- writeStringValue(out, state.janitor_cursor);
+ writeStringField(out, GcMaintenanceWire::janitor_cursor, state.janitor_cursor, first);
closeObject(out, first);
writeChar('\n', out);
return std::move(out).take();
@@ -46,7 +50,7 @@ GcMaintenanceState decodeGcMaintenanceState(std::string_view data)
String key;
while (reader.nextKey(key))
{
- if (key == "cur")
+ if (key == GcMaintenanceWire::janitor_cursor)
{
result.janitor_cursor = reader.readString();
has_cursor = true;
@@ -55,7 +59,7 @@ GcMaintenanceState decodeGcMaintenanceState(std::string_view data)
reader.skipUnknown(key);
}
if (!has_cursor)
- throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS gc maintenance state: missing cur");
+ throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS gc maintenance state: missing janitor_cursor");
if (!body_in.eof() || !in.eof())
throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS gc maintenance state: trailing bytes");
if (result.janitor_cursor.size() > kMaxGcMaintenanceCursorBytes)
diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasGcOutcomesFormat.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasGcOutcomesFormat.cpp
index e69ea98a799e..2f9aa0e141d2 100644
--- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasGcOutcomesFormat.cpp
+++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasGcOutcomesFormat.cpp
@@ -1,6 +1,7 @@
#include
#include
#include
+#include
#include
#include
@@ -18,27 +19,31 @@ namespace DB::Cas
namespace
{
-std::string_view outcomeKindToWord(OutcomeKind o)
+namespace GcOutcomesWire
{
- switch (o)
- {
- case OutcomeKind::Deleted: return "deleted";
- case OutcomeKind::Absent: return "absent";
- case OutcomeKind::Replaced: return "replaced";
- case OutcomeKind::Spared: return "spared";
- }
- throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS outcome log: unknown OutcomeKind {}", static_cast(o));
+ constexpr WireKey kind{"kind"};
+ constexpr WireKey outcome{"outcome"};
}
-OutcomeKind outcomeKindFromWord(std::string_view w)
+constexpr EnumWireTable kOutcomeKindWords{{{
+ {OutcomeKind::Deleted, "deleted"},
+ {OutcomeKind::Absent, "absent"},
+ {OutcomeKind::Replaced, "replaced"},
+ {OutcomeKind::Spared, "spared"},
+}}};
+
+static_assert(casEnumTableCoversEnum());
+
+}
+
+std::string_view outcomeKindToWireWord(OutcomeKind outcome)
{
- if (w == "deleted") return OutcomeKind::Deleted;
- if (w == "absent") return OutcomeKind::Absent;
- if (w == "replaced") return OutcomeKind::Replaced;
- if (w == "spared") return OutcomeKind::Spared;
- throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS outcome log: unknown outcome '{}'", w);
+ return kOutcomeKindWords.toWord(outcome, "CAS outcome log outcome kind");
}
+OutcomeKind outcomeKindFromWireWord(std::string_view w)
+{
+ return kOutcomeKindWords.fromWord(w, "CAS outcome log outcome kind");
}
String encodeOutcomeLog(const OutcomeLog & log)
@@ -48,12 +53,10 @@ String encodeOutcomeLog(const OutcomeLog & log)
for (const OutcomeEntry & e : log.entries)
{
bool first = true;
- writeKey(out, "k", first);
- writeStringValue(out, objectKindToWord(e.kind));
- writeBlobRefFields(out, first, e.ref); /// ha + h
- writeTokenFields(out, first, e.token); /// tt + tv
- writeKey(out, "oc", first);
- writeStringValue(out, outcomeKindToWord(e.outcome));
+ writeWordField(out, GcOutcomesWire::kind, objectKindToWord(e.kind), first);
+ writeBlobRefFields(out, first, e.ref); /// algo + digest
+ writeTokenFields(out, first, e.token); /// token_type + token
+ writeWordField(out, GcOutcomesWire::outcome, outcomeKindToWireWord(e.outcome), first);
closeObject(out, first);
writeChar('\n', out);
}
@@ -68,14 +71,19 @@ OutcomeLog decodeOutcomeLog(std::string_view data)
const uint64_t line_cap = traitsFor(FormatId::GcOutcomes).line_cap;
OutcomeLog log;
+ /// One line scratch and one reader for the whole loop, as the other row decoders do:
+ /// rebuilding them per row costs an allocation per row for the seen-key store and the line.
+ String row_line;
+ JsonObjectReader row_reader;
while (true)
{
- const String line = readLine(in, line_cap, "outcome log");
- ReadBufferFromMemory line_in(line.data(), line.size());
- JsonObjectReader r(line_in, KeyStrictness::Tolerant, "outcome log");
+ readLineInto(in, row_line, line_cap, "outcome log");
+ ReadBufferFromMemory line_in(row_line.data(), row_line.size());
+ row_reader.reset(line_in, KeyStrictness::Tolerant, "outcome log");
+ JsonObjectReader & r = row_reader;
String key;
- /// The first key distinguishes a trailer ("n") from a record ("k").
+ /// The first key distinguishes a trailer (`n`) from a record (`kind`).
if (!r.nextKey(key))
throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS outcome log: empty line");
if (key == "n")
@@ -92,34 +100,19 @@ OutcomeLog decodeOutcomeLog(std::string_view data)
}
OutcomeEntry e;
- String ha;
- String hhex;
- String tv;
- bool have_ha = false;
- bool have_h = false;
- bool have_tt = false;
- TokenType tt{};
+ BlobRefFields blob_ref_fields;
+ TokenFields token_fields;
do
{
- if (key == "k") e.kind = objectKindFromWord(r.readString(), "outcome log");
- else if (key == "ha") { ha = r.readString(); have_ha = true; }
- else if (key == "h") { hhex = r.readString(); have_h = true; }
- else if (key == "tt") { tt = tokenTypeFromWord(r.readString(), "outcome log"); have_tt = true; }
- else if (key == "tv") tv = r.readString();
- else if (key == "oc") e.outcome = outcomeKindFromWord(r.readString());
+ if (key == GcOutcomesWire::kind) e.kind = objectKindFromWord(r.readString(), "outcome log");
+ else if (matchBlobRefFields(key, r, blob_ref_fields)) {}
+ else if (matchTokenFields(key, r, token_fields)) {}
+ else if (key == GcOutcomesWire::outcome) e.outcome = outcomeKindFromWireWord(r.readString());
else r.skipUnknown(key);
} while (r.nextKey(key));
- if (!have_ha || !have_h || !have_tt)
- throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS outcome log: record missing ha/h/tt");
- const BlobHashAlgo algo = blobHashAlgoFromWord(ha, "outcome log");
- /// Validate the digest width before `fromHex`: a width mismatch must surface as the
- /// CORRUPTED_DATA required for malformed serialized input, not fromHex's BAD_ARGUMENTS.
- if (hhex.size() != blobHashLenFor(algo) * 2)
- throw Exception(ErrorCodes::CORRUPTED_DATA,
- "CAS outcome log: digest width {} does not match algo '{}'", hhex.size(), ha);
- e.ref = BlobRef{algo, codecFor(algo).fromHex(hhex)};
- e.token = Token{tv, tt};
+ e.ref = blob_ref_fields.build("outcome log");
+ e.token = token_fields.build("outcome log");
if (!line_in.eof())
throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS outcome log: junk after record");
log.entries.push_back(std::move(e));
diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasGcOutcomesFormat.h b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasGcOutcomesFormat.h
index 09a850ee66ff..edefc816cff2 100644
--- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasGcOutcomesFormat.h
+++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasGcOutcomesFormat.h
@@ -27,6 +27,12 @@ enum class OutcomeKind : uint8_t
Spared = 4, /// The merge found a positive in-degree, so the candidate was kept alive.
};
+/// Canonical wire word for one `OutcomeKind`.
+std::string_view outcomeKindToWireWord(OutcomeKind outcome);
+
+/// Its fail-closed inverse: an unknown word is `CORRUPTED_DATA`.
+OutcomeKind outcomeKindFromWireWord(std::string_view w);
+
/// One observation about a blob incarnation considered by GC. `token` identifies the exact
/// incarnation that GC examined, while `ref` identifies the content address; retaining both lets
/// replay and inspection distinguish an absent object from a replacement that won a race with GC.
diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasGcStateFormat.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasGcStateFormat.cpp
index 7012c6787f70..5cc268a4d9c3 100644
--- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasGcStateFormat.cpp
+++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasGcStateFormat.cpp
@@ -16,6 +16,24 @@ namespace ErrorCodes
namespace DB::Cas
{
+namespace GcStateWire
+{
+ constexpr WireKey round{"round"};
+ constexpr WireKey gc_shards{"gc_shards"};
+ constexpr WireKey snap_generation{"snap_generation"};
+ constexpr WireKey snap_pruned_through{"snap_pruned_through"};
+ constexpr WireKey snap_attempt{"snap_attempt"};
+ constexpr WireKey manifest_sweep_cursor{"manifest_sweep_cursor"};
+ constexpr WireKey lease_owner{"lease_owner"};
+ constexpr WireKey lease_seq{"lease_seq"};
+}
+
+namespace GcHeartbeatWire
+{
+ constexpr WireKey owner{"owner"};
+ constexpr WireKey hb_seq{"hb_seq"};
+}
+
String encodeGcState(const GcState & state)
{
if (state.gc_shards < 1)
@@ -23,14 +41,14 @@ String encodeGcState(const GcState & state)
CasJsonWriter out(256);
writeHeaderLine(out, FormatId::GcState);
bool first = true;
- writeKey(out, "rnd", first); writeU64StringValue(out, state.round);
- writeKey(out, "gcs", first); writeIntText(state.gc_shards, out);
- writeKey(out, "sg", first); writeU64StringValue(out, state.snap_generation);
- writeKey(out, "spt", first); writeU64StringValue(out, state.snap_pruned_through);
- writeKey(out, "sa", first); writeU64StringValue(out, state.snap_attempt);
- writeKey(out, "msc", first); writeStringValue(out, state.manifest_sweep_cursor);
- writeKey(out, "lo", first); writeHex128Value(out, state.lease.owner);
- writeKey(out, "ls", first); writeU64StringValue(out, state.lease.seq);
+ writeU64StringField(out, GcStateWire::round, state.round, first);
+ writeNumberField(out, GcStateWire::gc_shards, state.gc_shards, first);
+ writeU64StringField(out, GcStateWire::snap_generation, state.snap_generation, first);
+ writeU64StringField(out, GcStateWire::snap_pruned_through, state.snap_pruned_through, first);
+ writeU64StringField(out, GcStateWire::snap_attempt, state.snap_attempt, first);
+ writeStringField(out, GcStateWire::manifest_sweep_cursor, state.manifest_sweep_cursor, first);
+ writeHex128Field(out, GcStateWire::lease_owner, state.lease.owner, first);
+ writeU64StringField(out, GcStateWire::lease_seq, state.lease.seq, first);
closeObject(out, first);
writeChar('\n', out);
return std::move(out).take();
@@ -49,20 +67,32 @@ GcState decodeGcState(std::string_view data)
String key;
while (r.nextKey(key))
{
- if (key == "rnd") state.round = r.readU64String();
- else if (key == "gcs") { state.gc_shards = r.readU64Number(); saw_gcs = true; }
- else if (key == "sg") state.snap_generation = r.readU64String();
- else if (key == "spt") state.snap_pruned_through = r.readU64String();
- else if (key == "sa") state.snap_attempt = r.readU64String();
- else if (key == "msc") state.manifest_sweep_cursor = r.readString();
- else if (key == "lo") state.lease.owner = r.readHex128();
- else if (key == "ls") state.lease.seq = r.readU64String();
- else r.skipUnknown(key);
+ if (key == GcStateWire::round)
+ state.round = r.readU64String();
+ else if (key == GcStateWire::gc_shards)
+ {
+ state.gc_shards = r.readU64Number();
+ saw_gcs = true;
+ }
+ else if (key == GcStateWire::snap_generation)
+ state.snap_generation = r.readU64String();
+ else if (key == GcStateWire::snap_pruned_through)
+ state.snap_pruned_through = r.readU64String();
+ else if (key == GcStateWire::snap_attempt)
+ state.snap_attempt = r.readU64String();
+ else if (key == GcStateWire::manifest_sweep_cursor)
+ state.manifest_sweep_cursor = r.readString();
+ else if (key == GcStateWire::lease_owner)
+ state.lease.owner = r.readHex128();
+ else if (key == GcStateWire::lease_seq)
+ state.lease.seq = r.readU64String();
+ else
+ r.skipUnknown(key);
}
- /// Fail closed on an absent gcs: the writer always emits it, so a missing key means a corrupt object.
+ /// Fail closed on an absent gc_shards: the writer always emits it, so a missing key means a corrupt object.
/// Do NOT silently keep the struct default (1) — that would hide corruption (no-fallback principle).
if (!saw_gcs)
- throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS gc/state: missing gcs");
+ throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS gc/state: missing gc_shards");
if (state.gc_shards == 0)
throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS gc/state: gc_shards must be >= 1");
if (!body_in.eof() || !in.eof())
@@ -75,8 +105,8 @@ String encodeGcHeartbeat(const GcHeartbeat & hb)
CasJsonWriter out(256);
writeHeaderLine(out, FormatId::GcHeartbeat);
bool first = true;
- writeKey(out, "by", first); writeHex128Value(out, hb.owner);
- writeKey(out, "seq", first); writeU64StringValue(out, hb.hb_seq);
+ writeHex128Field(out, GcHeartbeatWire::owner, hb.owner, first);
+ writeU64StringField(out, GcHeartbeatWire::hb_seq, hb.hb_seq, first);
closeObject(out, first);
writeChar('\n', out);
return std::move(out).take();
@@ -96,12 +126,12 @@ GcHeartbeat decodeGcHeartbeat(std::string_view data)
String key;
while (r.nextKey(key))
{
- if (key == "by")
+ if (key == GcHeartbeatWire::owner)
{
hb.owner = r.readHex128();
saw_by = true;
}
- else if (key == "seq")
+ else if (key == GcHeartbeatWire::hb_seq)
{
hb.hb_seq = r.readU64String();
saw_seq = true;
diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasGcStateFormat.h b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasGcStateFormat.h
index 88bce088ed13..ca38e649b82c 100644
--- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasGcStateFormat.h
+++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasGcStateFormat.h
@@ -45,7 +45,7 @@ String encodeGcState(const GcState & state);
/// Decode a complete `cas_gc_state` text object. The header and size limits are checked before the
/// body is parsed; unknown non-reserved fields are tolerated for forward evolution, but malformed
-/// input, trailing bytes, a missing `gcs`, or a zero shard count raises `CORRUPTED_DATA` rather than
+/// input, trailing bytes, a missing `gc_shards`, or a zero shard count raises `CORRUPTED_DATA` rather than
/// falling back to a default state.
GcState decodeGcState(std::string_view data);
@@ -53,7 +53,7 @@ GcState decodeGcState(std::string_view data);
/// independently of round progress, because its lease renewal counter can remain unchanged during a
/// long fold. A follower that observes the heartbeat advance backs off from stealing the lease; this
/// prevents mistaking an alive, mid-round leader for a stalled one. The value is persisted as the
-/// versioned `cas_gc_hb` text object, whose body contains `by` and `seq` string values, replacing the
+/// versioned `cas_gc_hb` text object, whose body contains `owner` and `hb_seq` string values, replacing the
/// former unversioned 24-byte record.
struct GcHeartbeat
{
diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasLayout.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasLayout.cpp
index 5bd928ec01a3..d3c92e767c12 100644
--- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasLayout.cpp
+++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasLayout.cpp
@@ -1,4 +1,5 @@
#include
+#include
#include
#include
#include
@@ -67,14 +68,16 @@ std::optional Layout::parseBlobKey(std::string_view key) const
if (shard.size() != 2 || hex.size() < 2 || shard != hex.substr(0, 2))
return std::nullopt; /// malformed shard/hex shape -- not ours
- /// `` -> `BlobHashAlgo`: the small enum-value set makes a linear scan against
- /// `blobHashAlgoName` (the ONE name authority) cheaper and safer than a second name table that
- /// could drift from it.
+ /// `` -> `BlobHashAlgo` through the wire table itself, whose coverage is proven against
+ /// the enum at compile time. A hand-written candidate list here would be a second enumeration that
+ /// a new algorithm could silently outgrow: the parser would reject a segment the writer emits.
+ /// This path answers "is this key ours?", so an unknown segment is `nullopt` -- debris, not
+ /// corruption -- which is why it scans rather than calling the throwing `fromWord`.
std::optional algo;
- for (BlobHashAlgo candidate : {BlobHashAlgo::CityHash128, BlobHashAlgo::XXH3_128, BlobHashAlgo::Sha256})
- if (algo_name == blobHashAlgoName(candidate))
+ for (const auto & entry : kBlobHashAlgoWords.entries)
+ if (algo_name == entry.word)
{
- algo = candidate;
+ algo = entry.value;
break;
}
if (!algo)
@@ -200,8 +203,8 @@ NamespaceLifePhysicalId Layout::namespaceLifePhysicalIdOf(std::string_view key,
if (!incarnation)
throw DB::Exception(DB::ErrorCodes::CORRUPTED_DATA,
"CasLayout: object '{}' names no life: '{}' is not 32 lower-case hex digits of a nonzero "
- "life id. Generation-5 namespace-bearing pools are rejected by the pool-metadata format "
- "gate before this generation-6 physical-key parser is reached",
+ "life id. Pools whose keys predate the opaque-life layout are rejected by the "
+ "pool-metadata format gate before this physical-key parser is reached",
key, segment);
return *incarnation;
}
diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasPartManifestFormat.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasPartManifestFormat.cpp
index 5e7f9ff5ffbf..e0722add9bdc 100644
--- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasPartManifestFormat.cpp
+++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasPartManifestFormat.cpp
@@ -2,6 +2,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -22,41 +23,37 @@ namespace DB::Cas
namespace
{
-std::string_view placementToWord(EntryPlacement p)
+namespace PartManifestWire
{
- switch (p)
- {
- case EntryPlacement::Inline: return "inline";
- case EntryPlacement::Blob: return "blob";
- }
- throw Exception(ErrorCodes::CORRUPTED_DATA, "PartManifest: unknown placement {}", static_cast(p));
+ constexpr WireKey ns{"namespace"};
+ constexpr WireKey payload_digest{"payload_digest"};
+ constexpr WireKey path{"path"};
+ constexpr WireKey place{"place"};
+ constexpr WireKey size{"size"};
}
-EntryPlacement placementFromWord(std::string_view w)
-{
- if (w == "inline") return EntryPlacement::Inline;
- if (w == "blob") return EntryPlacement::Blob;
- throw Exception(ErrorCodes::CORRUPTED_DATA, "PartManifest: unknown placement '{}'", w);
-}
+constexpr EnumWireTable kEntryPlacementWords{{{
+ {EntryPlacement::Inline, "inline"},
+ {EntryPlacement::Blob, "blob"},
+}}};
+
+static_assert(casEnumTableCoversEnum());
-/// One entry-record line: {"p","pm", then either the Blob's "ha"/"h"/"sz" or the Inline's "il"}.
+/// One entry-record line: `path`/`place`, followed by either `algo`/`digest`/`size` for a Blob or
+/// `size` for Inline bytes.
void writeEntryRecord(CasJsonWriter & out, const ManifestEntry & e)
{
bool first = true;
- writeKey(out, "p", first);
- writeStringValue(out, e.path);
- writeKey(out, "pm", first);
- writeStringValue(out, placementToWord(e.placement));
+ writeStringField(out, PartManifestWire::path, e.path, first);
+ writeWordField(out, PartManifestWire::place, entryPlacementToWireWord(e.placement), first);
if (e.placement == EntryPlacement::Blob)
{
- writeBlobRefFields(out, first, e.ref); /// ha + h
- writeKey(out, "sz", first);
- writeIntText(e.blob_size, out);
+ writeBlobRefFields(out, first, e.ref); /// algo + digest
+ writeNumberField(out, PartManifestWire::size, e.blob_size, first);
}
else
{
- writeKey(out, "il", first);
- writeIntText(e.inline_bytes.size(), out);
+ writeNumberField(out, PartManifestWire::size, e.inline_bytes.size(), first);
}
closeObject(out, first);
writeChar('\n', out);
@@ -72,7 +69,7 @@ String bannerFor(std::string_view path, uint64_t n)
CasJsonWriter w(path.size() + 32);
w.append("==> ");
w.stringValue(path);
- w.append(" il=");
+ w.append(" size=");
w.u64Number(n);
w.append(" <==");
return std::move(w).take();
@@ -80,6 +77,16 @@ String bannerFor(std::string_view path, uint64_t n)
}
+std::string_view entryPlacementToWireWord(EntryPlacement placement)
+{
+ return kEntryPlacementWords.toWord(placement, "PartManifest: EntryPlacement");
+}
+
+EntryPlacement entryPlacementFromWireWord(std::string_view w)
+{
+ return kEntryPlacementWords.fromWord(w, "PartManifest: EntryPlacement");
+}
+
String encodePartManifest(const PartManifest & m)
{
/// Canonical path order plus duplicate-path rejection makes the encoded record sequence
@@ -97,15 +104,13 @@ String encodePartManifest(const PartManifest & m)
CasJsonWriter out(256);
writeHeaderLine(out, FormatId::PartManifest);
- /// descriptor meta line: ManifestRef (me/mb/mo, shared rendering with refsnaplog) + root
+ /// descriptor meta line: ManifestRef (epoch/build/ord, shared rendering with refsnaplog) + root
/// namespace + payload digest.
{
bool first = true;
- writeManifestRefFields(out, first, "", m.ref);
- writeKey(out, "ns", first);
- writeStringValue(out, m.root_namespace_id.string());
- writeKey(out, "pd", first);
- writeHex128Value(out, m.payload_digest);
+ writeManifestRefFields(out, first, kBareManifestRefKeys, m.ref);
+ writeStringField(out, PartManifestWire::ns, m.root_namespace_id.string(), first);
+ writeHex128Field(out, PartManifestWire::payload_digest, m.payload_digest, first);
closeObject(out, first);
writeChar('\n', out);
}
@@ -144,43 +149,44 @@ PartManifest decodePartManifest(std::string_view data)
const String meta = readLine(in, line_cap, "cas_part_manifest");
ReadBufferFromMemory mm(meta.data(), meta.size());
JsonObjectReader r(mm, KeyStrictness::Tolerant, "cas_part_manifest");
- std::optional me;
- std::optional mb;
- std::optional mo;
+ ManifestRefFields fields;
std::optional ns;
std::optional pd;
String key;
while (r.nextKey(key))
{
- if (key == "me") me = r.readU64String();
- else if (key == "mb") mb = r.readU64String();
- else if (key == "mo") mo = r.readU64Number();
- else if (key == "ns") ns = r.readString();
- else if (key == "pd") pd = r.readHex128();
+ if (matchManifestRefFields(key, r, kBareManifestRefKeys, fields)) {}
+ else if (key == PartManifestWire::ns) ns = r.readString();
+ else if (key == PartManifestWire::payload_digest) pd = r.readHex128();
else r.skipUnknown(key);
}
- if (!me || !mb || !mo)
- throw Exception(ErrorCodes::CORRUPTED_DATA, "PartManifest: descriptor missing me/mb/mo");
if (!ns)
- throw Exception(ErrorCodes::CORRUPTED_DATA, "PartManifest: descriptor missing ns");
+ throw Exception(ErrorCodes::CORRUPTED_DATA, "PartManifest: descriptor missing namespace");
if (!pd)
- throw Exception(ErrorCodes::CORRUPTED_DATA, "PartManifest: descriptor missing pd");
- m.ref = manifestRefFromFields(*me, *mb, *mo, "PartManifest", "descriptor");
+ throw Exception(ErrorCodes::CORRUPTED_DATA, "PartManifest: descriptor missing payload_digest");
+ m.ref = fields.buildRef("PartManifest", "descriptor");
m.root_namespace_id = RootNamespace(*ns);
m.payload_digest = *pd;
if (!mm.eof())
throw Exception(ErrorCodes::CORRUPTED_DATA, "PartManifest: junk after descriptor line");
}
- /// entry record lines, until the trailer. Inline entries remember their declared `il` length so
+ /// entry record lines, until the trailer. Inline entries remember their declared `size` so
/// the payload zone below can read exactly that many raw bytes back into `inline_bytes`.
/// Index-aligned with `m.entries` (Blob entries push an unused 0 placeholder).
std::vector inline_lens;
+ String blob_ref_what; /// reused across Blob entries so the error context does not allocate per row
+ /// One line scratch and one reader for the whole loop: a decoder that rebuilds them per
+ /// row pays an allocation per row for the seen-key store and the line, which profiling put
+ /// at about a fifth of the instructions executed inside a row.
+ String row_line;
+ JsonObjectReader row_reader;
while (true)
{
- const String line = readLine(in, line_cap, "cas_part_manifest");
- ReadBufferFromMemory l(line.data(), line.size());
- JsonObjectReader r(l, KeyStrictness::Tolerant, "cas_part_manifest");
+ readLineInto(in, row_line, line_cap, "cas_part_manifest");
+ ReadBufferFromMemory l(row_line.data(), row_line.size());
+ row_reader.reset(l, KeyStrictness::Tolerant, "cas_part_manifest");
+ JsonObjectReader & r = row_reader;
String key;
if (!r.nextKey(key))
throw Exception(ErrorCodes::CORRUPTED_DATA, "PartManifest: empty line");
@@ -198,8 +204,8 @@ PartManifest decodePartManifest(std::string_view data)
break;
}
- if (key != "p")
- throw Exception(ErrorCodes::CORRUPTED_DATA, "PartManifest: record must start with \"p\"");
+ if (key != PartManifestWire::path)
+ throw Exception(ErrorCodes::CORRUPTED_DATA, "PartManifest: record must start with \"path\"");
ManifestEntry e;
e.path = r.readString();
@@ -218,48 +224,37 @@ PartManifest decodePartManifest(std::string_view data)
}
std::optional pm;
- std::optional ha;
- std::optional h;
- std::optional sz;
- std::optional il;
+ BlobRefFields blob_ref;
+ std::optional size;
while (r.nextKey(key))
{
- if (key == "pm") pm = r.readString();
- else if (key == "ha") ha = r.readString();
- else if (key == "h") h = r.readString();
- else if (key == "sz") sz = r.readU64Number();
- else if (key == "il") il = r.readU64Number();
+ if (key == PartManifestWire::place) pm = r.readString();
+ else if (matchBlobRefFields(key, r, blob_ref)) {}
+ else if (key == PartManifestWire::size) size = r.readU64Number();
else r.skipUnknown(key);
}
if (!l.eof())
throw Exception(ErrorCodes::CORRUPTED_DATA, "PartManifest: junk after record");
if (!pm)
- throw Exception(ErrorCodes::CORRUPTED_DATA, "PartManifest: entry '{}' missing pm", e.path);
- e.placement = placementFromWord(*pm);
+ throw Exception(ErrorCodes::CORRUPTED_DATA, "PartManifest: entry '{}' missing place", e.path);
+ e.placement = entryPlacementFromWireWord(*pm);
if (e.placement == EntryPlacement::Blob)
{
- if (!ha || !h || !sz)
- throw Exception(ErrorCodes::CORRUPTED_DATA, "PartManifest: blob entry '{}' missing ha/h/sz", e.path);
- const BlobHashAlgo algo = blobHashAlgoFromWord(*ha, "PartManifest entry");
- /// Validate the digest width before calling `fromHex`. A width mismatch otherwise
- /// produces `BAD_ARGUMENTS` instead of the `CORRUPTED_DATA` required for malformed
- /// serialized input, allowing an invalid manifest to escape the decoder's fail-closed
- /// error contract.
- const uint64_t expected_hex_len = blobHashLenFor(algo) * 2;
- if (h->size() != expected_hex_len)
- throw Exception(ErrorCodes::CORRUPTED_DATA,
- "PartManifest: entry '{}' digest hex width {} does not match algo width {}",
- e.path, h->size(), expected_hex_len);
- e.ref = BlobRef{algo, codecFor(algo).fromHex(*h)};
- e.blob_size = *sz;
+ if (!size)
+ throw Exception(ErrorCodes::CORRUPTED_DATA, "PartManifest: blob entry '{}' missing size", e.path);
+ blob_ref_what.assign("PartManifest entry '");
+ blob_ref_what += e.path;
+ blob_ref_what += '\'';
+ e.ref = blob_ref.build(blob_ref_what);
+ e.blob_size = *size;
inline_lens.push_back(0); /// unused for Blob; keeps inline_lens index-aligned with entries
}
else
{
- if (!il)
- throw Exception(ErrorCodes::CORRUPTED_DATA, "PartManifest: inline entry '{}' missing il", e.path);
- inline_lens.push_back(*il); /// bytes filled from the payload zone below
+ if (!size)
+ throw Exception(ErrorCodes::CORRUPTED_DATA, "PartManifest: inline entry '{}' missing size", e.path);
+ inline_lens.push_back(*size); /// bytes filled from the payload zone below
}
/// Canonical ascending-order and no-duplicate-path enforcement: compare only against the
@@ -277,7 +272,7 @@ PartManifest decodePartManifest(std::string_view data)
}
/// payload zone: for each Inline entry, in the same order it appeared above, a banner line then
- /// exactly `il` raw bytes then a terminating '\n'.
+ /// exactly `size` raw bytes then a terminating '\n'.
for (size_t i = 0; i < m.entries.size(); ++i)
{
if (m.entries[i].placement != EntryPlacement::Inline)
diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasPartManifestFormat.h b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasPartManifestFormat.h
index f2a416d15743..a26597e1bebe 100644
--- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasPartManifestFormat.h
+++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasPartManifestFormat.h
@@ -15,14 +15,14 @@ namespace DB::Cas
/// stable for the surrounding CAS protocol.
///
/// header line {"type":"cas_part_manifest","v":N}
-/// descriptor meta line {"me","mb","mo"} (the ManifestRef, shared rendering with
-/// refsnaplog, `CasWireVocab.h`) + "ns" (root namespace) + "pd"
+/// descriptor meta line {"epoch","build","ord"} (the ManifestRef, shared rendering with
+/// refsnaplog, `CasWireVocab.h`) + `root_namespace` + `payload_digest`
/// (payload digest, 32 lowercase hex)
-/// one entry-record line each {"p":path,"pm":placement-word, then either the Blob's
-/// {"ha","h","sz"} or the Inline's {"il"}}, in canonical path order
+/// one entry-record line each {"path":path,"place":placement-word, then either the Blob's
+/// {"algo","digest","size"} or the Inline's {"size"}}, in canonical path order
/// trailer line {"n":entry-count}
/// PAYLOAD ZONE (raw, follows the trailer): for each Inline entry, in path order, a
-/// `head -v`-style banner line `==> "" il= <==\n`, then
+/// `head -v`-style banner line `==> "" size= <==\n`, then
/// exactly `n` raw bytes, then `\n`. The path uses the same writer as
/// the entry-record line, so decode can rebuild the banner byte-wise.
/// Blob entries carry no
@@ -42,6 +42,12 @@ enum class EntryPlacement : uint8_t
Blob = 2, /// bytes stored as a content-addressed blob at `blobKey`
};
+/// Canonical wire word for one manifest entry placement.
+std::string_view entryPlacementToWireWord(EntryPlacement placement);
+
+/// Its fail-closed inverse: an unknown word is `CORRUPTED_DATA`.
+EntryPlacement entryPlacementFromWireWord(std::string_view w);
+
/// One file entry inside a part manifest. `ref` is meaningful only for `Blob`; `inline_bytes` only
/// for `Inline`. `blob_size` is the raw `Blob` byte count (0 for `Inline` — decode never fills it for
/// an inline entry, since the wire format carries no redundant size for inline bytes). Use `size()`
diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasPoolMetaFormat.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasPoolMetaFormat.cpp
index 50e9843e9254..80f9572c4547 100644
--- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasPoolMetaFormat.cpp
+++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasPoolMetaFormat.cpp
@@ -1,8 +1,10 @@
+#include
#include
#include
#include
#include
#include
+#include
namespace DB
{
@@ -16,29 +18,28 @@ namespace ErrorCodes
namespace DB::Cas
{
-/// Minimum `blob_header_len` that provably fits the v3 `cas_blob` JSON envelope's mandatory (always-
-/// written) non-ref fields, computed at type maxima from `encodeEnvelopeHeader` (CasBlobEnvelopeFormat.cpp):
-/// {"type":"cas_blob" 18
-/// ,"v": 5 + 10 (currentCompatibilityVersion) 15
-/// ,"tag":"<32 hex>" 7 + 34 41
-/// ,"bld":"<32 hex>" 7 + 34 41
-/// ,"ts": 6 + 20 (created_at_ms) 26
-/// ,"by":"<32 hex>" 7 + 34 41
-/// ,"op":"" 6 + 10 (longest op word "mutation") 16
-/// ,"ch": 6 + 10 (VERSION_INTEGER) 16
-/// non-ref JSON = 214 bytes
-/// The encoder then always frames the ref: `,"ref":` (7) + `""` (2) + `}` (1), and reserves byte
-/// blob_header_len-1 for '\n' (1) = 11 bytes. So the mandatory content needs 214 + 11 = 225 bytes;
-/// below that, encodeEnvelopeHeader throws LOGICAL_ERROR on the FIRST blob write (the old drop-and-retry
-/// that used to mask this is gone). We floor at 240 (a multiple of 8 comfortably above 225, leaving
-/// >= 15 bytes for the diagnostic ref even at type maxima, and well under the 256 default) so a
-/// misconfigured pool fails at CREATION with BAD_ARGUMENTS, not at first write with LOGICAL_ERROR.
-static constexpr uint64_t kMinBlobHeaderLen = 240;
+namespace PoolMetaWire
+{
+ constexpr WireKey pool_id{"pool_id"};
+ constexpr WireKey blob_header_len{"blob_header_len"};
+ constexpr WireKey gc_shards{"gc_shards"};
+ constexpr WireKey min_reader_generation{"min_reader_generation"};
+ constexpr WireKey algos_used{"algos_used"};
+}
+
+/// Minimum `blob_header_len` that provably fits the `cas_blob` JSON envelope's mandatory-descriptor
+/// worst case. The byte-for-byte derivation (`kMandatoryDescriptorWorstCase`, currently 239 bytes) lives
+/// beside the envelope key constants in `CasBlobEnvelopeFormat.cpp`, next to the compile-time proof that
+/// it fits under this floor; below that bound, `encodeEnvelopeHeader` throws `LOGICAL_ERROR` on the
+/// FIRST blob write (the old drop-and-retry that used to mask this is gone). We floor at 240 (a
+/// multiple of 8 comfortably above the worst case, leaving at least one byte for the diagnostic `ref`
+/// even at type maxima, and well under the 256 default) so a misconfigured pool fails at CREATION with
+/// `BAD_ARGUMENTS`, not at first write with `LOGICAL_ERROR`.
void validatePoolBlobHeaderLen(uint64_t blob_header_len, int error_code, std::string_view what)
{
if (blob_header_len < kMinBlobHeaderLen)
- throw Exception(error_code, "CAS {}: blob_header_len must be >= {} (v3 envelope minimum), got {}",
+ throw Exception(error_code, "CAS {}: blob_header_len must be >= {} (blob envelope minimum), got {}",
what, kMinBlobHeaderLen, blob_header_len);
if (blob_header_len % 8 != 0)
throw Exception(error_code, "CAS {}: blob_header_len must be a multiple of 8, got {}", what, blob_header_len);
@@ -52,14 +53,15 @@ void validatePoolAlgosUsed(const std::vector & algos_used, int error_co
throw Exception(error_code, "CAS {}: algos_used must be non-empty", what);
for (size_t i = 0; i < algos_used.size(); ++i)
{
- try
- {
- blobHashAlgoName(static_cast(algos_used[i]));
- }
- catch (const Exception &)
- {
+ /// A direct membership scan, not `blobHashAlgoName`: that throws `LOGICAL_ERROR`, which
+ /// aborts at construction under a sanitizer/debug build before any catch can run, but
+ /// this function validates a raw byte vector, so it must reject cleanly rather than abort.
+ bool known = false;
+ for (const auto & entry : kBlobHashAlgoWords.entries)
+ if (static_cast(entry.value) == algos_used[i])
+ known = true;
+ if (!known)
throw Exception(error_code, "CAS {}: algos_used contains an unknown algo {}", what, algos_used[i]);
- }
if (i > 0 && algos_used[i] <= algos_used[i - 1])
throw Exception(error_code,
"CAS {}: algos_used must be strictly sorted with no duplicates, got {} at index {} not after {}",
@@ -69,30 +71,23 @@ void validatePoolAlgosUsed(const std::vector & algos_used, int error_co
String encodePoolMeta(const PoolMeta & pm)
{
+ validatePoolAlgosUsed(pm.algos_used, ErrorCodes::CORRUPTED_DATA, "pool meta");
+
CasJsonWriter out(256);
writeHeaderLine(out, FormatId::PoolMeta);
bool first = true;
- writeKey(out, "pid", first);
- writeHex128Value(out, pm.pool_id);
- writeKey(out, "hln", first);
- writeIntText(pm.blob_header_len, out);
- writeKey(out, "gcs", first);
- writeIntText(pm.gc_shards, out);
- writeKey(out, "mrg", first);
- writeIntText(pm.min_reader_generation, out);
- writeKey(out, "alg", first);
- {
- /// Comma-joined algo words (tiny list, <=3): "ch128" or "ch128,sha256".
- String joined;
- for (size_t i = 0; i < pm.algos_used.size(); ++i)
- {
- if (i != 0)
- joined += ',';
- joined += blobHashAlgoName(static_cast(pm.algos_used[i]));
- }
- writeStringValue(out, joined);
- }
+ writeHex128Field(out, PoolMetaWire::pool_id, pm.pool_id, first);
+ writeNumberField(out, PoolMetaWire::blob_header_len, pm.blob_header_len, first);
+ writeNumberField(out, PoolMetaWire::gc_shards, pm.gc_shards, first);
+ writeNumberField(out, PoolMetaWire::min_reader_generation, pm.min_reader_generation, first);
+ /// Sized by the whole algo vocabulary and safe to index by `algos_used`: the validation above
+ /// admits only known algo bytes in strictly increasing order, so the vector cannot be longer
+ /// than the table. Relaxing that check to non-strict ordering would overrun this array.
+ std::array algo_words;
+ for (size_t i = 0; i < pm.algos_used.size(); ++i)
+ algo_words[i] = kBlobHashAlgoWords.toWord(static_cast(pm.algos_used[i]), "CAS pool meta");
+ writeWordArrayField(out, PoolMetaWire::algos_used, std::span{algo_words}.first(pm.algos_used.size()), first);
closeObject(out, first);
writeChar('\n', out);
@@ -104,18 +99,14 @@ PoolMeta decodePoolMeta(std::string_view data)
ReadBufferFromMemory in(data.data(), data.size());
const TextHeader header = expectHeaderLine(in, FormatId::PoolMeta);
- /// An older pool predates a breaking ref-layer change this build cannot reconcile, so
- /// reject it before reading the metadata body. Writers always emit the current generation, while
- /// `expectHeaderLine` separately rejects a future generation that this build cannot understand.
- /// Generation 10 is the latest recreate-only authority floor and rejects old pools before any
- /// mount lease body lacking its durable write-attempt identity can be interpreted.
- if (header.v < kMountWriteAttemptIdGeneration)
+ /// The format-generation baseline is 1; a header below it cannot have been written by any build
+ /// this codec understands. `expectHeaderLine` above already rejects the symmetric FUTURE case
+ /// (`v > G_BUILD`); reject the backward case here, before the metadata body is read.
+ if (header.v < 1)
throw Exception(ErrorCodes::UNKNOWN_FORMAT_VERSION,
- "CAS pool format {} predates generation-10 mount-attempt-identity floor; recreate the pool. "
- "This build requires the durable mount write attempt identity "
- "in the generation-10 format "
- "(generation {}+), and CAS is pre-release: there is no in-place migration.",
- header.v, kMountWriteAttemptIdGeneration);
+ "CAS pool format {} predates the format-generation baseline; recreate the pool "
+ "(CAS is pre-release, so there is no in-place migration)",
+ header.v);
const String body = readLine(in, traitsFor(FormatId::PoolMeta).line_cap, "pool meta");
ReadBufferFromMemory body_in(body.data(), body.size());
@@ -127,43 +118,32 @@ PoolMeta decodePoolMeta(std::string_view data)
String key;
while (r.nextKey(key))
{
- if (key == "pid")
+ if (key == PoolMetaWire::pool_id)
{
pm.pool_id = r.readHex128();
saw_pid = true;
}
- else if (key == "hln")
+ else if (key == PoolMetaWire::blob_header_len)
pm.blob_header_len = r.readU64Number();
- else if (key == "gcs")
+ else if (key == PoolMetaWire::gc_shards)
{
pm.gc_shards = r.readU64Number();
saw_gc_shards = true;
}
- else if (key == "mrg")
+ else if (key == PoolMetaWire::min_reader_generation)
pm.min_reader_generation = r.readU64Number();
- else if (key == "alg")
+ else if (key == PoolMetaWire::algos_used)
{
- const String joined = r.readString();
- size_t start = 0;
- while (start <= joined.size())
- {
- const size_t comma = joined.find(',', start);
- const String word = joined.substr(start, comma == String::npos ? String::npos : comma - start);
- if (word.empty())
- throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS pool meta: empty algo word in '{}'", joined);
+ for (const String & word : r.readStringArray())
pm.algos_used.push_back(static_cast(blobHashAlgoFromWord(word, "pool meta algo")));
- if (comma == String::npos)
- break;
- start = comma + 1;
- }
}
else
r.skipUnknown(key);
}
if (!saw_pid)
- throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS pool meta: missing pid");
+ throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS pool meta: missing pool_id");
if (!saw_gc_shards || pm.gc_shards == 0)
- throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS pool meta: missing or zero gcs");
+ throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS pool meta: missing or zero gc_shards");
if (!body_in.eof())
throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS pool meta: junk after body object");
if (!in.eof())
diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasPoolMetaFormat.h b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasPoolMetaFormat.h
index 2ca0894d2f01..f17cb5b87fb1 100644
--- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasPoolMetaFormat.h
+++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasPoolMetaFormat.h
@@ -14,8 +14,9 @@ class Backend;
class Layout;
/// `_pool_meta` — the pool identity and the pool-wide constants that every reader and writer must
-/// agree on. The v3 text representation is a header line followed by one JSON body object:
-/// {"pid":"<32hex>","hln":,"mrg":,"alg":""}.
+/// agree on. The text representation is a header line followed by one JSON body object:
+/// {"pool_id":"<32hex>","blob_header_len":,"gc_shards":,
+/// "min_reader_generation":,"algos_used":["",...]}.
///
/// The persisted object is authoritative after creation. On reopen, `createOrValidate` uses its
/// `blob_header_len` and reader-generation floor rather than replacing them with local configuration;
@@ -37,7 +38,7 @@ struct PoolMeta
/// 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` (spec §2 [C4][D2]) gates the create-if-absent path: minting a fresh `_pool_meta` is a
+ /// `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
/// writable startup path that has just passed the zero-write residual proof (`Pool::open`). Every
/// non-bootstrap caller — a read-only/observe open, `openForDecommission` — passes `false`; an absent
@@ -67,7 +68,7 @@ struct PoolMeta
/// Serializes valid pool metadata as the versioned `_pool_meta` text object. The output includes the
/// format header, one JSON body line, and its terminating newline; it is suitable for a conditional
-/// backend write and preserves the sorted algorithm set as comma-separated vocabulary words.
+/// backend write and preserves the sorted algorithm set as a JSON array of vocabulary words.
String encodePoolMeta(const PoolMeta &);
/// Parses and validates a persisted `_pool_meta` object. Unknown JSON keys are tolerated for additive
@@ -76,11 +77,12 @@ String encodePoolMeta(const PoolMeta &);
/// corruption or compatibility error code.
PoolMeta decodePoolMeta(std::string_view);
-/// Checks the fixed blob-envelope size invariant. The length must be 8-byte aligned, at most 16 KiB,
-/// and at least 240 bytes: v3's mandatory envelope fields, framing, and newline consume 225 bytes at
-/// type maxima, while 240 leaves room for a diagnostic `ref`. The caller supplies the error code so
-/// persisted violations can be reported as `CORRUPTED_DATA` and bad creation arguments as
-/// `BAD_ARGUMENTS`.
+/// Checks the fixed blob-envelope size invariant: 8-byte aligned, at most 16 KiB, and at least
+/// `kMinBlobHeaderLen`. That floor and the worst case it must clear are derived once beside the
+/// envelope encoder, which also proves the relation at compile time — no number is restated here,
+/// because a second copy is exactly what a single owner exists to prevent. The caller supplies the
+/// error code so persisted violations can be reported as `CORRUPTED_DATA` and bad creation arguments
+/// as `BAD_ARGUMENTS`.
void validatePoolBlobHeaderLen(uint64_t blob_header_len, int error_code, std::string_view what);
/// Checks that every admitted hash algorithm is known, that the set is non-empty, and that its numeric
diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasRecordStreamFormat.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasRecordStreamFormat.cpp
index b21458aaf6e2..78dedf30a4b2 100644
--- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasRecordStreamFormat.cpp
+++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasRecordStreamFormat.cpp
@@ -1,6 +1,7 @@
#include
#include
#include
+#include
#include
#include
@@ -19,6 +20,32 @@ namespace DB::Cas
namespace
{
+namespace RunWire
+{
+ constexpr WireKey ref{"ref"};
+ constexpr WireKey src{"src"};
+ constexpr WireKey mark{"mark"};
+ constexpr WireKey pending{"pending"};
+ constexpr WireKey size{"size"};
+ constexpr WireKey condemn_round{"condemn_round"};
+ constexpr WireKey confirmed{"confirmed"};
+}
+
+namespace RunHeaderWire
+{
+ constexpr WireKey type{"type"};
+ constexpr WireKey version{"v"};
+ constexpr WireKey kind{"kind"};
+}
+
+constexpr EnumWireTable kRunMarkerWords{{{
+ {RunMarker::Zero, "zero"},
+ {RunMarker::Edge, "edge"},
+ {RunMarker::Condemned, "condemned"},
+}}};
+
+static_assert(casEnumTableCoversEnum());
+
UInt128 toWideChecksum(CityHash_v1_0_2::uint128 h)
{
/// Keep the high and low halves in the same order for the write-side helper and the streaming
@@ -34,20 +61,21 @@ int hexNibble(char c)
return -1;
}
+/// The run `ref` carries the algorithm as a raw leading byte, so this is the byte-side counterpart of
+/// the word table -- and it walks that same table rather than listing the enumerators again. A second
+/// list is how the writer and the reader come to disagree about which algorithms exist: `renderB`
+/// writes whatever the enum holds, and a hand-written switch here would reject exactly what a new
+/// enumerator adds.
BlobHashAlgo algoFromByte(uint8_t b, std::string_view what)
{
- switch (b)
- {
- case static_cast(BlobHashAlgo::CityHash128): return BlobHashAlgo::CityHash128;
- case static_cast(BlobHashAlgo::XXH3_128): return BlobHashAlgo::XXH3_128;
- case static_cast(BlobHashAlgo::Sha256): return BlobHashAlgo::Sha256;
- default:
- throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS {}: unknown algo byte {} in record key", what, b);
- }
+ for (const auto & entry : kBlobHashAlgoWords.entries)
+ if (static_cast(entry.value) == b)
+ return entry.value;
+ throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS {}: unknown algo byte {} in record key", what, b);
}
-/// `b` = the algo byte as two lowercase hex chars, then the digest hex at the algo's width. The algo
-/// byte leads so that string-sorting `b` reproduces the binary (algo, digest) byte order.
+/// `ref` = the algo byte as two lowercase hex chars, then the digest hex at the algo's width. The
+/// algo byte leads so that string-sorting `ref` reproduces the binary (algo, digest) byte order.
String renderB(const BlobRef & ref)
{
static constexpr char H[] = "0123456789abcdef";
@@ -72,32 +100,25 @@ BlobRef parseB(std::string_view b)
if (digest_hex.size() != static_cast(blobHashLenFor(algo)) * 2)
throw Exception(ErrorCodes::CORRUPTED_DATA,
"CAS cas_run: digest hex width {} does not match algo width {}", digest_hex.size(), blobHashLenFor(algo) * 2);
+ for (const char c : digest_hex)
+ if (!isLowercaseHexChar(c))
+ throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS cas_run: non-lowercase-hex digest in record key");
BlobRef ref;
ref.algo = algo;
ref.digest = codecFor(algo).fromHex(String(digest_hex));
return ref;
}
-std::string_view markerToWord(char m)
-{
- switch (m)
- {
- case kEdgeActive: return "edge";
- case kZeroMarker: return "zero";
- case kCondemned: return "condemned";
- default:
- throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS cas_run: unknown row marker 0x{:02x}", static_cast(m));
- }
}
-char markerFromWord(std::string_view w)
+std::string_view runMarkerToWireWord(RunMarker marker)
{
- if (w == "edge") return kEdgeActive;
- if (w == "zero") return kZeroMarker;
- if (w == "condemned") return kCondemned;
- throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS cas_run: unknown row marker '{}'", w);
+ return kRunMarkerWords.toWord(marker, "CAS cas_run: RunMarker");
}
+RunMarker runMarkerFromWireWord(std::string_view w)
+{
+ return kRunMarkerWords.fromWord(w, "CAS cas_run: RunMarker");
}
void writeRunHeaderLine(WriteBuffer & out, std::string_view kind)
@@ -105,12 +126,9 @@ void writeRunHeaderLine(WriteBuffer & out, std::string_view kind)
const FormatTraits & t = traitsFor(FormatId::RunFile);
CasJsonWriter line(64);
bool first = true;
- writeKey(line, "type", first);
- writeStringValue(line, t.type);
- writeKey(line, "v", first);
- writeIntText(currentCompatibilityVersion(), line);
- writeKey(line, "kind", first);
- writeStringValue(line, kind);
+ writeStringField(line, RunHeaderWire::type, t.type, first);
+ writeNumberField(line, RunHeaderWire::version, currentCompatibilityVersion(), first);
+ writeStringField(line, RunHeaderWire::kind, kind, first);
closeObject(line, first);
writeChar('\n', line);
const std::string_view line_view = line.view();
@@ -174,23 +192,16 @@ void SourceEdgeRunWriter::append(const SourceEdgeRecord & rec)
scratch.clear();
bool first = true;
- writeKey(scratch, "b", first);
- writeStringValue(scratch, renderB(rec.ref));
- writeKey(scratch, "s", first);
- writeHex128Value(scratch, rec.source_id);
- writeKey(scratch, "m", first);
- writeStringValue(scratch, markerToWord(rec.marker));
- if (rec.marker == kCondemned)
+ writeStringField(scratch, RunWire::ref, renderB(rec.ref), first);
+ writeHex128Field(scratch, RunWire::src, rec.source_id, first);
+ writeWordField(scratch, RunWire::mark, runMarkerToWireWord(rec.marker), first);
+ if (rec.marker == RunMarker::Condemned)
{
- writeKey(scratch, "pend", first);
- writeBoolValue(scratch, rec.delete_pending);
- writeTokenFields(scratch, first, rec.token); /// tt + tv
- writeKey(scratch, "sz", first);
- writeIntText(rec.size, scratch);
- writeKey(scratch, "cr", first);
- writeU64StringValue(scratch, rec.condemn_round);
- writeKey(scratch, "mc", first);
- writeBoolValue(scratch, rec.marker_confirmed);
+ writeBoolField(scratch, RunWire::pending, rec.delete_pending, first);
+ writeTokenFields(scratch, first, rec.token); /// token_type + token
+ writeNumberField(scratch, RunWire::size, rec.size, first);
+ writeU64StringField(scratch, RunWire::condemn_round, rec.condemn_round, first);
+ writeBoolField(scratch, RunWire::confirmed, rec.marker_confirmed, first);
}
closeObject(scratch, first);
writeChar('\n', scratch);
@@ -235,9 +246,12 @@ bool SourceEdgeRunReader::next(SourceEdgeRecord & rec)
if (done)
return false;
- const String line = readLine(hashing, traitsFor(FormatId::RunFile).line_cap, "cas_run");
- ReadBufferFromMemory line_in(line.data(), line.size());
- JsonObjectReader r(line_in, KeyStrictness::Strict, "cas_run");
+ readLineInto(hashing, scratch, traitsFor(FormatId::RunFile).line_cap, "cas_run");
+ ReadBufferFromMemory line_in(scratch.data(), scratch.size());
+ /// Re-point the reader rather than building one per row: a fresh reader re-allocates its
+ /// seen-key store and value scratch every row, and this loop runs once per record.
+ reader.reset(line_in, KeyStrictness::Strict, "cas_run");
+ JsonObjectReader & r = reader;
String key;
if (!r.nextKey(key))
@@ -263,41 +277,37 @@ bool SourceEdgeRunReader::next(SourceEdgeRecord & rec)
SourceEdgeRecord out;
String b;
- String tv;
- bool have_b = false;
- bool have_s = false;
- bool have_m = false;
- bool have_pend = false;
- bool have_tt = false;
- bool have_tv = false;
- bool have_sz = false;
- bool have_cr = false;
- bool have_mc = false;
- TokenType tt{};
+ TokenFields token_fields;
+ bool have_ref = false;
+ bool have_src = false;
+ bool have_mark = false;
+ bool have_pending = false;
+ bool have_size = false;
+ bool have_condemn_round = false;
+ bool have_confirmed = false;
do
{
- if (key == "b") { b = r.readString(); have_b = true; }
- else if (key == "s") { out.source_id = r.readHex128(); have_s = true; }
- else if (key == "m") { out.marker = markerFromWord(r.readString()); have_m = true; }
- else if (key == "pend") { out.delete_pending = r.readBool(); have_pend = true; }
- else if (key == "tt") { tt = tokenTypeFromWord(r.readString(), "cas_run"); have_tt = true; }
- else if (key == "tv") { tv = r.readString(); have_tv = true; }
- else if (key == "sz") { out.size = r.readU64Number(); have_sz = true; }
- else if (key == "cr") { out.condemn_round = r.readU64String(); have_cr = true; }
- else if (key == "mc") { out.marker_confirmed = r.readBool(); have_mc = true; }
+ if (key == RunWire::ref) { b = r.readString(); have_ref = true; }
+ else if (key == RunWire::src) { out.source_id = r.readHex128(); have_src = true; }
+ else if (key == RunWire::mark) { out.marker = runMarkerFromWireWord(r.readString()); have_mark = true; }
+ else if (key == RunWire::pending) { out.delete_pending = r.readBool(); have_pending = true; }
+ else if (matchTokenFields(key, r, token_fields)) {}
+ else if (key == RunWire::size) { out.size = r.readU64Number(); have_size = true; }
+ else if (key == RunWire::condemn_round) { out.condemn_round = r.readU64String(); have_condemn_round = true; }
+ else if (key == RunWire::confirmed) { out.marker_confirmed = r.readBool(); have_confirmed = true; }
else r.skipUnknown(key); /// Strict => any unknown key is CORRUPTED_DATA
} while (r.nextKey(key));
- if (!have_b || !have_s || !have_m)
- throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS cas_run: record missing b/s/m");
+ if (!have_ref || !have_src || !have_mark)
+ throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS cas_run: record missing ref/src/mark");
out.ref = parseB(b);
- if (out.marker == kCondemned)
+ if (out.marker == RunMarker::Condemned)
{
- if (!have_pend || !have_tt || !have_tv || !have_sz || !have_cr || !have_mc)
- throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS cas_run: condemned record missing pend/tt/tv/sz/cr/mc");
- out.token = Token{tv, tt};
+ if (!have_pending || !have_size || !have_condemn_round || !have_confirmed)
+ throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS cas_run: condemned record missing pending/size/condemn_round/confirmed");
+ out.token = token_fields.build("cas_run");
}
- else if (have_pend || have_tt || have_tv || have_sz || have_cr || have_mc)
+ else if (have_pending || token_fields.type_word || token_fields.value || have_size || have_condemn_round || have_confirmed)
throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS cas_run: non-condemned record carries condemned fields");
if (!line_in.eof())
diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasRecordStreamFormat.h b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasRecordStreamFormat.h
index d5f9a4801caf..dbf9fd8fc9d8 100644
--- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasRecordStreamFormat.h
+++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasRecordStreamFormat.h
@@ -1,7 +1,9 @@
#pragma once
#include
#include
+#include
#include
+#include
#include
#include
#include
@@ -11,6 +13,11 @@
#include
#include
+namespace DB::ErrorCodes
+{
+ extern const int CORRUPTED_DATA;
+}
+
namespace DB::Cas
{
@@ -18,14 +25,30 @@ namespace DB::Cas
/// format, shared by this codec and the GC fold that interprets the rows.
///
/// Source-edge rows use `source_id == 0` as a sentinel key. A real active edge must never use that key;
-/// both sentinel tags are restricted to it. `kZeroMarker` describes a zero transition for the current
-/// generation and is dropped when the row is carried forward. `kCondemned` carries the condemned
+/// both sentinel tags are restricted to it. `RunMarker::Zero` describes a zero transition for the current
+/// generation and is dropped when the row is carried forward. `RunMarker::Condemned` carries the condemned
/// incarnation at the sentinel key across generations until settlement; its payload contains the full
/// deletion token and other condemned-row state. A condemned row subsumes the zero marker for that
/// generation.
-constexpr char kEdgeActive = 0x01;
-constexpr char kZeroMarker = 0x00;
-constexpr char kCondemned = 0x02;
+enum class RunMarker : char
+{
+ Zero = 0x00,
+ Edge = 0x01,
+ Condemned = 0x02,
+};
+
+constexpr char runMarkerByte(RunMarker marker)
+{
+ return static_cast(marker);
+}
+
+inline RunMarker runMarkerFromByte(char byte, std::string_view what)
+{
+ if (byte != runMarkerByte(RunMarker::Zero) && byte != runMarkerByte(RunMarker::Edge)
+ && byte != runMarkerByte(RunMarker::Condemned))
+ throw Exception(ErrorCodes::CORRUPTED_DATA, "{}: unknown marker byte {}", what, static_cast(byte));
+ return static_cast(byte);
+}
/// The `cas_run` codec represents the GC source-edge in-degree data plane as sorted NDJSON. This is
/// the `RecordStream` family
@@ -40,27 +63,27 @@ constexpr char kCondemned = 0x02;
/// leaking into the format implementation.
///
/// File shape:
-/// {"type":"cas_run","v":3,"kind":"source_edge"} header line (type + v + kind gate)
-/// {"b":"01","s":"<32hex>","m":"edge"} an active-edge / zero-marker row
-/// {"b":"01","s":"00000000000000000000000000000000","m":"condemned","pend":false,"tt":"etag","tv":"...","sz":123,"cr":"7","mc":false}
+/// {"type":"cas_run","v":1,"kind":"source_edge"} header line (type + v + kind gate)
+/// {"ref":"01","src":"<32hex>","mark":"edge"} an active-edge / zero-marker row
+/// {"ref":"01","src":"00000000000000000000000000000000","mark":"condemned","pending":false,"token_type":"etag","token":"...","size":123,"condemn_round":"7","confirmed":false}
/// {"n":184267} trailer: record count
///
-/// The record key `b` is the algo BYTE as two lowercase hex chars followed by the digest hex at the
-/// algo's width; `s` is the 32-hex source id. String-sorting records by (b, s) reproduces the current
+/// The record key `ref` is the algo BYTE as two lowercase hex chars followed by the digest hex at the
+/// algo's width; `src` is the 32-hex source id. String-sorting records by (`ref`, `src`) reproduces the current
/// `(algorithm, digest, source_id)` byte order (lowercase hex preserves unsigned byte order and the
/// algorithm byte is emitted first) — the invariant the fold's two-cursor merge depends on. The row-tag word
-/// `m` maps to the `kEdgeActive`/`kZeroMarker`/`kCondemned` bytes; a `condemned` row additionally
-/// carries the retired incarnation (`pend`/`tt`/`tv`/`sz`/`cr`) and the durable condemn-marker
-/// confirmation bit (`mc`).
+/// `mark` maps to the `RunMarker` bytes; a `condemned` row additionally
+/// carries the retired incarnation (`pending`/`token_type`/`token`/`size`/`condemn_round`) and the durable condemn-marker
+/// confirmation bit (`confirmed`).
/// One decoded source-edge row. All fields are identifier-layer types so the codec stays backend-free.
/// The condemned-only fields (`delete_pending`/`token`/`size`/`condemn_round`/`marker_confirmed`) are
-/// meaningful only when `marker == kCondemned`.
+/// meaningful only when `marker == RunMarker::Condemned`.
struct SourceEdgeRecord
{
BlobRef ref{};
UInt128 source_id{};
- char marker = kEdgeActive;
+ RunMarker marker = RunMarker::Edge;
bool delete_pending = false;
Token token{};
uint64_t size = 0;
@@ -71,6 +94,12 @@ struct SourceEdgeRecord
/// The header-line `kind` word for the only live `cas_run` kind.
inline constexpr std::string_view kSourceEdgeKindWord = "source_edge";
+/// Canonical wire word for one source-edge run marker.
+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
/// 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.
@@ -159,6 +188,12 @@ class SourceEdgeRunReader
HashingReadBuffer hashing;
uint64_t seen = 0;
bool done = false;
+ /// Reused line scratch, mirroring the writer's: `readLineInto` clears it without releasing its
+ /// buffer, so a run of any length allocates only up to the longest line it has actually seen.
+ String scratch;
+ /// Reused object reader, for the same reason: its per-object buffers then cost one allocation
+ /// for the whole run rather than one per row. It starts unbound and every row re-points it.
+ JsonObjectReader reader;
};
}
diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasRefCatalogFormat.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasRefCatalogFormat.cpp
index c5b119ba44ca..6cf44651508e 100644
--- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasRefCatalogFormat.cpp
+++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasRefCatalogFormat.cpp
@@ -3,6 +3,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -20,30 +21,30 @@ namespace ErrorCodes
namespace DB::Cas
{
-std::string_view nsStateToWord(NsState s)
+namespace
{
- switch (s)
- {
- case NsState::Creating: return "creating";
- case NsState::Live: return "live";
- case NsState::Removing: return "removing";
- }
- /// Every value reaching here came from a live `NsState` or from `nsStateFromWord`, which already
- /// validated it on decode -- so this is a bug in THIS process, not corruption arriving from a
- /// store.
- throw Exception(ErrorCodes::LOGICAL_ERROR, "CAS ref catalog: unknown ns state {}", static_cast(s));
-}
-NsState nsStateFromWord(std::string_view w)
+namespace RefCatalogWire
{
- if (w == "creating") return NsState::Creating;
- if (w == "live") return NsState::Live;
- if (w == "removing") return NsState::Removing;
- throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS ref catalog: unknown ns state '{}'", w);
+ constexpr WireKey kind{"kind"};
+ constexpr WireKey ns{"ns"};
+ constexpr WireKey state{"state"};
+ constexpr WireKey life{"life"};
+ constexpr WireKey remove_round{"remove_round"};
+ constexpr WireKey creator{"creator"};
+ constexpr WireKey creator_epoch{"creator_epoch"};
+ constexpr WireKey creator_fence{"creator_fence"};
}
-namespace
-{
+constexpr std::string_view kEntryTag = "entry";
+
+constexpr EnumWireTable kNsStateWords{{{
+ {NsState::Creating, "creating"},
+ {NsState::Live, "live"},
+ {NsState::Removing, "removing"},
+}}};
+
+static_assert(casEnumTableCoversEnum());
/// `creator` is required iff `state == Creating`, forbidden otherwise -- one predicate, used by both
/// directions of the codec, so the writer's self-check and the reader's fail-close can never disagree.
@@ -70,6 +71,16 @@ bool isCanonicalCatalogOrder(const std::vector & entries)
}
+std::string_view nsStateToWord(NsState s)
+{
+ return kNsStateWords.toWord(s, "CAS ref catalog");
+}
+
+NsState nsStateFromWord(std::string_view w)
+{
+ return kNsStateWords.fromWord(w, "CAS ref catalog ns state");
+}
+
String encodeRefCatalog(const RefCatalog & catalog)
{
const uint64_t line_cap = traitsFor(FormatId::RefCatalog).line_cap;
@@ -136,22 +147,20 @@ String encodeRefCatalog(const RefCatalog & catalog)
e.ns.string(), nsStateToWord(e.state), e.removal_started_round ? "carries" : "lacks");
bool first = true;
- writeKey(out, "k", first); writeStringValue(out, "ent");
- writeKey(out, "ns", first); writeStringValue(out, e.ns.string());
- writeKey(out, "st", first); writeStringValue(out, nsStateToWord(e.state));
- writeKey(out, "inc", first); writeHex128Value(out, e.incarnation);
+ writeStringField(out, RefCatalogWire::kind, kEntryTag, first);
+ writeStringField(out, RefCatalogWire::ns, e.ns.string(), first);
+ writeStringField(out, RefCatalogWire::state, nsStateToWord(e.state), first);
+ writeHex128Field(out, RefCatalogWire::life, e.incarnation, first);
if (e.removal_started_round)
- {
- writeKey(out, "rsr", first); writeU64StringValue(out, *e.removal_started_round);
- }
+ writeU64StringField(out, RefCatalogWire::remove_round, *e.removal_started_round, first);
if (e.creator)
{
- writeKey(out, "csr", first); writeStringValue(out, e.creator->server_root_id);
- writeKey(out, "cwe", first); writeU64StringValue(out, e.creator->writer_epoch);
- writeKey(out, "cfg", first); writeU64StringValue(out, e.creator->fence_generation);
+ writeStringField(out, RefCatalogWire::creator, e.creator->server_root_id, first);
+ writeU64StringField(out, RefCatalogWire::creator_epoch, e.creator->writer_epoch, first);
+ writeU64StringField(out, RefCatalogWire::creator_fence, e.creator->fence_generation, first);
}
closeObject(out, first);
- closeLine("ent");
+ closeLine("entry");
}
const size_t trailer_start = out.size();
@@ -169,11 +178,17 @@ RefCatalog decodeRefCatalog(std::string_view data)
RefCatalog catalog;
uint64_t seen = 0;
+ /// One line scratch and one reader for the whole loop: a decoder that rebuilds them per
+ /// row pays an allocation per row for the seen-key store and the line, which profiling put
+ /// at about a fifth of the instructions executed inside a row.
+ String row_line;
+ JsonObjectReader row_reader;
for (;;)
{
- const String line = readLine(in, line_cap, "ref catalog");
- ReadBufferFromMemory l(line.data(), line.size());
- JsonObjectReader r(l, KeyStrictness::Strict, "ref catalog");
+ readLineInto(in, row_line, line_cap, "ref catalog");
+ ReadBufferFromMemory l(row_line.data(), row_line.size());
+ row_reader.reset(l, KeyStrictness::Strict, "ref catalog");
+ JsonObjectReader & r = row_reader;
String key;
if (!r.nextKey(key))
throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS ref catalog: empty line");
@@ -190,10 +205,10 @@ RefCatalog decodeRefCatalog(std::string_view data)
"CAS ref catalog: trailer count {} != {} records", n, seen);
return catalog;
}
- if (key != "k")
- throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS ref catalog: record must start with \"k\"");
+ if (key != RefCatalogWire::kind)
+ throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS ref catalog: record must start with \"kind\"");
const String kind = r.readString();
- if (kind != "ent")
+ if (kind != kEntryTag)
throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS ref catalog: unknown record kind '{}'", kind);
String ns_str;
@@ -205,20 +220,20 @@ RefCatalog decodeRefCatalog(std::string_view data)
std::optional removal_started_round;
while (r.nextKey(key))
{
- if (key == "ns") ns_str = r.readString();
- else if (key == "st") st_word = r.readString();
- else if (key == "inc") inc = r.readHex128();
- else if (key == "csr") csr = r.readString();
- else if (key == "cwe") cwe = r.readU64String();
- else if (key == "cfg") cfg = r.readU64String();
- else if (key == "rsr") removal_started_round = r.readU64String();
- else throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS ref catalog: unknown ent key '{}'", key);
+ if (key == RefCatalogWire::ns) ns_str = r.readString();
+ else if (key == RefCatalogWire::state) st_word = r.readString();
+ else if (key == RefCatalogWire::life) inc = r.readHex128();
+ else if (key == RefCatalogWire::creator) csr = r.readString();
+ else if (key == RefCatalogWire::creator_epoch) cwe = r.readU64String();
+ else if (key == RefCatalogWire::creator_fence) cfg = r.readU64String();
+ else if (key == RefCatalogWire::remove_round) removal_started_round = r.readU64String();
+ else throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS ref catalog: unknown entry key '{}'", key);
}
if (!l.eof())
throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS ref catalog: junk after record");
if (!st_word)
- throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS ref catalog: entry '{}' missing st", ns_str);
+ throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS ref catalog: entry '{}' missing state", ns_str);
const NsState state = nsStateFromWord(*st_word); /// throws CORRUPTED_DATA on an unknown word
/// A missing "ns" key reads as the same empty string a present-but-empty one would, and both
@@ -234,7 +249,7 @@ RefCatalog decodeRefCatalog(std::string_view data)
ns_str, ns_str.size(), kMaxNamespaceBytes);
if (!inc)
- throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS ref catalog: entry '{}' missing inc", ns_str);
+ throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS ref catalog: entry '{}' missing life", ns_str);
if (*inc == 0)
throw Exception(ErrorCodes::CORRUPTED_DATA,
"CAS ref catalog: namespace '{}' has a zero incarnation -- 0 never names a life", ns_str);
@@ -315,7 +330,7 @@ uint64_t worstCaseEntryFoldReservationBytes()
/// coverage record plus terminal cleanup evidence, all numeric fields at maximum width.
seal.ref_lives[std::numeric_limits::max()] = RefLifeFoldState{
.coverage = RefCoverage{
- .classification = 4,
+ .classification = CoverageClass::Clamped,
.last_folded_ref_id = RefTxnId{kU64Max, kU64Max},
.hold = RefHold{.reason = HoldReason::UnconsumedSealCrossing,
.offending_position = RefTxnId{kU64Max, kU64Max},
@@ -340,7 +355,7 @@ uint64_t widestBlobTargetRunReservationBytes(const Layout & layout, uint64_t gc_
.key = layout.blobTargetRunKey(max, max, gc_shards - 1, 0),
.checksum = std::numeric_limits::max(),
.shard = gc_shards - 1,
- .generation = max});
+ .key_generation = max});
return encodeFoldSeal(seal).size() - encodeFoldSeal(CasFoldSeal{}).size();
}
@@ -368,7 +383,7 @@ void checkFoldSealReservation(
/// wrap to a remainder far smaller than the true reservation, which would answer "fits" for an
/// `entry_count` that plainly does not.
const uint64_t ref_lives = mulByteBudget(entry_count, worstCaseEntryFoldReservationBytes());
- /// `validateFoldSealStructure` permits at most one canonical seq-0 `btr` per shard, so charging
+ /// `validateFoldSealStructure` permits at most one canonical seq-0 `blob_run` per shard, so charging
/// one widest row for every shard covers the full legal run domain without per-entry arithmetic.
const uint64_t blob_target_runs = mulByteBudget(
gc_shards, widestBlobTargetRunReservationBytes(layout, gc_shards));
diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasRefCatalogFormat.h b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasRefCatalogFormat.h
index ca1fbe5a6ddc..0e93845731f4 100644
--- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasRefCatalogFormat.h
+++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasRefCatalogFormat.h
@@ -16,7 +16,7 @@ class Layout;
/// The byte bound every namespace name admitted into `ref_catalog` must satisfy (spec INV-3:
/// "namespace names get a byte bound"). It keeps the catalog's operator-visible row and line grammar
/// bounded, and both directions of the codec enforce it. Logical namespace bytes do NOT enter
-/// predicate (2): fold-seal `rfl` rows are keyed only by the fixed-width opaque life id.
+/// predicate (2): fold-seal `ref_life` rows are keyed only by the fixed-width opaque life id.
constexpr size_t kMaxNamespaceBytes = 512;
/// One namespace's catalog lifecycle state (spec INV-3, §3). `Creating` blocks publication and
@@ -44,7 +44,7 @@ std::string_view nsStateToWord(NsState s);
/// Inverse of `nsStateToWord`; throws `CORRUPTED_DATA` for anything but the three registered words.
NsState nsStateFromWord(std::string_view w);
-/// The fence identity of the mounted writer CREATING one namespace (spec §3): the server root plus
+/// The fence identity of the mounted writer CREATING one namespace: the server root plus
/// the writer epoch and admission fence generation captured at the moment `Creating` was minted. It
/// is what a reconciler compares against `CasServerRoot`'s liveness/fence machinery before a stalled
/// `Creating` entry may be CAS-reconciled away (INV-3: "stalled creators occupy entries until
@@ -92,7 +92,7 @@ struct RefCatalog
bool operator==(const RefCatalog &) const = default;
};
-/// Encodes `catalog` as the canonical `cas_ref_catalog` text object: a header line, one "ent" record
+/// Encodes `catalog` as the canonical `cas_ref_catalog` text object: a header line, one "entry" record
/// per entry in canonical (ns-sorted) order, and a record-count trailer -- the same tagged-record
/// container `encodeFoldSeal` uses. Enforces the FULL strict grammar on the way out: canonical order
/// and no duplicate namespace, a non-empty namespace within the `kMaxNamespaceBytes` bound, nonzero
@@ -103,8 +103,8 @@ struct RefCatalog
/// instead) -- but deliberately does NOT enforce the whole-object cap itself: that predicate must
/// name the namespace under admission, which only a caller of `checkCatalogAdmission` knows.
///
-/// These bytes go to and come from the backend DIRECTLY, exactly like `cas_ref_ckpt`: the Pool-side
-/// `CasRefCatalog::read`/`casUpdateImpl` (`Pool/CasRefCatalog.cpp`) bypass `sealObject`/`openObject`,
+/// These bytes go to and come from the backend DIRECTLY: the catalog read and update paths bypass
+/// `sealObject`/`openObject`,
/// which are the identity under this class's `CompressionPolicy::Never` and would add nothing. A
/// policy flip to `Always` therefore breaks this silently -- and is caught, because `storedSuffix`
/// would stop being empty and the registry test asserting `storedSuffix(FormatId::RefCatalog) == ""`
@@ -144,7 +144,7 @@ uint64_t widestCondemnedSummaryReservationBytes(uint64_t gc_shards);
/// PRE-PUT GATE, predicate (2) of INV-3's additive admission. Reserves the widest fixed frame, one
/// widest ref-life row per candidate catalog entry, and one widest blob-target plus condemned-summary
-/// row per authoritative GC shard. The `btr` multiplier follows the authoritative fold-seal grammar:
+/// row per authoritative GC shard. The `blob_run` multiplier follows the authoritative fold-seal grammar:
/// at most one canonical sequence-0 run is legal for each shard. Equality is accepted; refuses
/// (`LIMIT_EXCEEDED`, naming `ns`) one entry over. Every multiplication and addition saturates, so an
/// unreachable-in-practice count can never wrap into something that reads as "fits".
diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasRefCkptFormat.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasRefCkptFormat.cpp
index 6ff7fa5dda43..4034f5c244c1 100644
--- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasRefCkptFormat.cpp
+++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasRefCkptFormat.cpp
@@ -15,6 +15,22 @@ namespace ErrorCodes
namespace DB::Cas
{
+namespace
+{
+
+namespace RefCkptWire
+{
+ constexpr WireKey life_epoch{"life_epoch"};
+ constexpr WireKey committed_epoch{"committed_epoch"};
+ constexpr WireKey committed_seq{"committed_seq"};
+ constexpr WireKey snapshot_epoch{"snapshot_epoch"};
+ constexpr WireKey snapshot_seq{"snapshot_seq"};
+ constexpr WireKey seal_epoch{"seal_epoch"};
+ constexpr WireKey seal_seq{"seal_seq"};
+}
+
+}
+
void checkRefCkptInvariants(const RefCkpt & ckpt, std::string_view what)
{
/// PRESENT means REAL. `life_epoch` may be absent (no writer of this object knew the namespace's
@@ -89,16 +105,13 @@ String encodeRefCkpt(const RefCkpt & ckpt)
/// written by the one shared `RefTxnId` writer the `_log` and `_snap` formats also use, so the
/// three ref formats cannot disagree on the encoding.
if (ckpt.life_epoch)
- {
- writeKey(out, "le", first);
- writeU64StringValue(out, *ckpt.life_epoch);
- }
+ writeU64StringField(out, RefCkptWire::life_epoch, *ckpt.life_epoch, first);
if (ckpt.committed_through)
- writeRefTxnIdFields(out, first, "cte", "cts", *ckpt.committed_through);
+ writeRefTxnIdFields(out, first, RefCkptWire::committed_epoch, RefCkptWire::committed_seq, *ckpt.committed_through);
if (ckpt.checkpoint_snapshot_id)
- writeRefTxnIdFields(out, first, "cse", "css", *ckpt.checkpoint_snapshot_id);
+ writeRefTxnIdFields(out, first, RefCkptWire::snapshot_epoch, RefCkptWire::snapshot_seq, *ckpt.checkpoint_snapshot_id);
if (ckpt.last_epoch_seal)
- writeRefTxnIdFields(out, first, "lse", "lss", *ckpt.last_epoch_seal);
+ writeRefTxnIdFields(out, first, RefCkptWire::seal_epoch, RefCkptWire::seal_seq, *ckpt.last_epoch_seal);
closeObject(out, first);
writeChar('\n', out);
@@ -127,22 +140,22 @@ RefCkpt decodeRefCkpt(std::string_view data)
JsonObjectReader r(body_in, KeyStrictness::Strict, "cas_ref_ckpt");
RefCkpt ckpt;
- std::optional cse;
- std::optional css;
- std::optional lse;
- std::optional lss;
- std::optional cte;
- std::optional cts;
+ std::optional snapshot_epoch;
+ std::optional snapshot_seq;
+ std::optional seal_epoch;
+ std::optional seal_seq;
+ std::optional committed_epoch;
+ std::optional committed_seq;
String key;
while (r.nextKey(key))
{
- if (key == "le") ckpt.life_epoch = r.readU64String();
- else if (key == "cte") cte = r.readU64String();
- else if (key == "cts") cts = r.readU64String();
- else if (key == "cse") cse = r.readU64String();
- else if (key == "css") css = r.readU64String();
- else if (key == "lse") lse = r.readU64String();
- else if (key == "lss") lss = r.readU64String();
+ if (key == RefCkptWire::life_epoch) ckpt.life_epoch = r.readU64String();
+ else if (key == RefCkptWire::committed_epoch) committed_epoch = r.readU64String();
+ else if (key == RefCkptWire::committed_seq) committed_seq = r.readU64String();
+ else if (key == RefCkptWire::snapshot_epoch) snapshot_epoch = r.readU64String();
+ else if (key == RefCkptWire::snapshot_seq) snapshot_seq = r.readU64String();
+ else if (key == RefCkptWire::seal_epoch) seal_epoch = r.readU64String();
+ else if (key == RefCkptWire::seal_seq) seal_seq = r.readU64String();
else r.skipUnknown(key);
}
@@ -151,23 +164,23 @@ RefCkpt decodeRefCkpt(std::string_view data)
/// deletable" today and as "recovery has no base" tomorrow -- both of which a reader would trust.
/// Fail closed instead. (A missing whole field is a legitimate absence, not truncation: every field
/// of this object is optional, so there is nothing to miss.)
- if (cse || css)
+ if (snapshot_epoch || snapshot_seq)
{
- if (!cse || !css)
- throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS cas_ref_ckpt: checkpoint_snapshot_id needs both cse and css");
- ckpt.checkpoint_snapshot_id = RefTxnId{*cse, *css};
+ if (!snapshot_epoch || !snapshot_seq)
+ throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS cas_ref_ckpt: checkpoint_snapshot_id needs both snapshot_epoch and snapshot_seq");
+ ckpt.checkpoint_snapshot_id = RefTxnId{*snapshot_epoch, *snapshot_seq};
}
- if (cte || cts)
+ if (committed_epoch || committed_seq)
{
- if (!cte || !cts)
- throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS cas_ref_ckpt: committed_through needs both cte and cts");
- ckpt.committed_through = RefTxnId{*cte, *cts};
+ if (!committed_epoch || !committed_seq)
+ throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS cas_ref_ckpt: committed_through needs both committed_epoch and committed_seq");
+ ckpt.committed_through = RefTxnId{*committed_epoch, *committed_seq};
}
- if (lse || lss)
+ if (seal_epoch || seal_seq)
{
- if (!lse || !lss)
- throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS cas_ref_ckpt: last_epoch_seal needs both lse and lss");
- ckpt.last_epoch_seal = RefTxnId{*lse, *lss};
+ if (!seal_epoch || !seal_seq)
+ throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS cas_ref_ckpt: last_epoch_seal needs both seal_epoch and seal_seq");
+ ckpt.last_epoch_seal = RefTxnId{*seal_epoch, *seal_seq};
}
if (!body_in.eof() || !in.eof())
throw Exception(ErrorCodes::CORRUPTED_DATA, "CAS cas_ref_ckpt: trailing bytes");
diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasRefLogFormat.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasRefLogFormat.cpp
index be7ee5567575..8c9d010ee678 100644
--- a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasRefLogFormat.cpp
+++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Formats/CasRefLogFormat.cpp
@@ -1,6 +1,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -20,28 +21,27 @@ namespace DB::Cas
namespace
{
-std::string_view opKindToWord(RefOpKind k)
+namespace RefLogWire
{
- switch (k)
- {
- case RefOpKind::NamespaceBirth: return "namespace_birth";
- case RefOpKind::OwnerTransition: return "owner_transition";
- case RefOpKind::SetPublishedAt: return "set_published_at";
- case RefOpKind::RemoveNamespace: return "remove_namespace";
- case RefOpKind::EpochSeal: return "epoch_seal";
- }
- throw Exception(ErrorCodes::CORRUPTED_DATA, "RefLogTxn: unknown op kind {}", static_cast(k));
+ constexpr WireKey ns{"namespace"};
+ constexpr WireKey txn_epoch{"txn_epoch"};
+ constexpr WireKey txn_seq{"txn_seq"};
+ constexpr WireKey prev_epoch{"!prev_epoch"};
+ constexpr WireKey prev_seq{"!prev_seq"};
+ constexpr WireKey op{"op"};
+ constexpr WireKey ref{"ref"};
+ constexpr WireKey published_ms{"published_ms"};
}
-RefOpKind opKindFromWord(std::string_view w)
-{
- if (w == "namespace_birth") return RefOpKind::NamespaceBirth;
- if (w == "owner_transition") return RefOpKind::OwnerTransition;
- if (w == "set_published_at") return RefOpKind::SetPublishedAt;
- if (w == "remove_namespace") return RefOpKind::RemoveNamespace;
- if (w == "epoch_seal") return RefOpKind::EpochSeal;
- throw Exception(ErrorCodes::CORRUPTED_DATA, "RefLogTxn: unknown op kind '{}'", w);
-}
+constexpr EnumWireTable kRefOpWords{{{
+ {RefOpKind::NamespaceBirth, "namespace_birth"},
+ {RefOpKind::OwnerTransition, "owner_transition"},
+ {RefOpKind::SetPublishedAt, "set_published_at"},
+ {RefOpKind::RemoveNamespace, "remove_namespace"},
+ {RefOpKind::EpochSeal, "epoch_seal"},
+}}};
+
+static_assert(casEnumTableCoversEnum());
/// Byte budget over the encoded text. A removal-class transaction uses the larger complete-table
/// budget and has neither an op-count nor a per-op cap; normal transactions are bounded by
@@ -69,22 +69,10 @@ void checkBudget(const std::vector & ops, size_t encoded_bytes)
}
}
-void writeBindingFields(CasJsonWriter & out, bool & first, std::string_view prefix, const RefOwnerBinding & b)
-{
- checkCanonicalRefName(b.ref_name, "RefLogTxn", "owner binding ref_name");
- checkManifestRef(b.manifest_ref, "RefLogTxn", "owner binding manifest_ref");
- out.key(prefix, "bk", first);
- writeStringValue(out, refOwnerKindToWord(b.kind));
- out.key(prefix, "rn", first);
- writeStringValue(out, b.ref_name);
- writeManifestRefFields(out, first, prefix, b.manifest_ref);
-}
-
void writeOp(CasJsonWriter & out, const RefOp & op)
{
bool first = true;
- writeKey(out, "op", first);
- writeStringValue(out, opKindToWord(op.kind));
+ writeWordField(out, RefLogWire::op, refOpKindToWireWord(op.kind), first);
switch (op.kind)
{
case RefOpKind::NamespaceBirth:
@@ -93,78 +81,59 @@ void writeOp(CasJsonWriter & out, const RefOp & op)
break;
case RefOpKind::OwnerTransition:
if (op.old_binding)
- writeBindingFields(out, first, "o", *op.old_binding);
+ writeBindingFields(out, first, kOldBindingKeys, *op.old_binding);
if (op.new_binding)
- writeBindingFields(out, first, "n", *op.new_binding);
+ writeBindingFields(out, first, kNewBindingKeys, *op.new_binding);
break;
case RefOpKind::SetPublishedAt:
checkCanonicalRefName(op.ref_name, "RefLogTxn", "set_published_at ref_name");
checkManifestRef(op.expected_manifest_ref, "RefLogTxn", "set_published_at manifest_ref");
- writeKey(out, "rn", first);
- writeStringValue(out, op.ref_name);
- writeManifestRefFields(out, first, "", op.expected_manifest_ref);
- writeKey(out, "ts", first);
- writeIntText(op.published_at_ms, out);
+ writeStringField(out, RefLogWire::ref, op.ref_name, first);
+ writeManifestRefFields(out, first, kBareManifestRefKeys, op.expected_manifest_ref);
+ writeNumberField(out, RefLogWire::published_ms, op.published_at_ms, first);
break;
}
closeObject(out, first);
writeChar('\n', out);
}
-/// Collector for a ManifestRef's three flat fields under an optional prefix.
-struct ManifestFields
-{
- std::optional me;
- std::optional mb;
- std::optional mo;
-
- bool any() const { return me || mb || mo; }
- ManifestRef build(std::string_view what) const
- {
- if (!me || !mb || !mo)
- throw Exception(ErrorCodes::CORRUPTED_DATA, "RefLogTxn: {} manifest_ref missing me/mb/mo", what);
- return manifestRefFromFields(*me, *mb, *mo, "RefLogTxn", what);
- }
-};
-
/// Collector for one binding (old/new) under a prefix.
struct BindingFields
{
- std::optional