Add AuditType::RangeDigest: a storage-server-side content fingerprint - #13866
Add AuditType::RangeDigest: a storage-server-side content fingerprint#13866saintstack wants to merge 2 commits into
Conversation
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Introduces a new audit type, RangeDigest, that computes a content
fingerprint of the user keyspace locally on each storage server,
coordinated by the Data Distributor through the existing audit_storage
framework. Only 32-byte per-range digests cross the network, so hashing
throughput scales with the cluster's own disks instead of being bound by
an external client reading every key (the Phase 0 approach).
Digest construction (an incremental multiset / "AdHash" hash):
leaf = SHA-256( u32be len(key) | key | u32be len(value) | value )
range/cluster digest = sum of leaf hashes mod 2^256
Because modular addition is associative and commutative and keys are
unique, the cluster root depends only on the multiset of key-values, not
on how the data is partitioned into shards. This makes a before-backup vs
after-restore comparison valid even though shard boundaries move, with no
boundary recording/replay. Assumes a quiescent cluster.
Contents:
- fdbclient/RangeDigest.{h,cpp}: leaf hash + modular-add combine + hex.
- Audit.h: RangeDigest audit type; digest/kvCount/byteCount on
AuditStorageState (appended for flatbuffers compatibility).
- AuditUtils: getRangeDigestSummary() combines the persisted per-range
digests into the cluster root. A sub-range counts only if it is phase
Complete, carries a 32-byte digest, and still describes exactly the
boundary range it was read at; anything else is reported as incomplete
coverage. The last two conditions matter because both read as Complete
without being audited data: skipAuditOnRange persists Complete with no
digest, which parses as the additive identity, and krmSetRange re-anchors
a wider entry at a narrower entry's boundary, which would count a region
twice.
- storageserver: auditRangeDigestQ folds locally-owned key-values at the
server's current version and persists a per-range digest. Cancellation
is handled at the top level (audit_storage_cancelled / task_outdated),
and the error-persist is guarded, so a cancelled audit never crashes
the storage server. The same cancellation handling is added to
auditRestoreQ, which had the identical latent gap.
- DataDistribution: dispatch/schedule/skip/assert wiring for the new type;
a single owning server is chosen per shard (no comparison peers). On
completion the per-range digests are combined into the top-level audit
record (the per-range progress is cleared when Complete is persisted),
and a coverage guard retries rather than publishing a root if the
persisted ranges do not fully tile the audit range. The combine runs
before the phase moves to Complete, because that retry re-enters
runAuditStorage(), which requires Running. RangeDigest is excluded from
the simulation DD-restart fault injection, as ValidateRestore already is,
so its test can assert the audit completed.
- fdbcli: `audit_storage range_digest ...` and
`get_audit_status range_digest root <id>`.
- RangeDigestValidation workload + tests/fast/RangeDigestValidation.toml:
loads known data, runs the audit, cross-checks the cluster root against
an independent client-side computation, and re-runs to confirm the root
is stable. Hard-fails on a completed audit whose root/counts disagree,
and check() also fails if the comparison never ran, so a digest that
never completes cannot pass as a skip.
RangeDigest.h now spells out the precondition a caller must meet: the digest is only meaningful over a quiescent cluster, because each storage server folds its key-values at its own read version and the additive combine assumes every key-value is folded exactly once. Concurrent writes break the first invariant; in-flight shard movement breaks the second, since a key in transit can be counted by both the losing and the gaining server or by neither. That is not hypothetical -- digesting during data-distribution churn produced a root mismatch at 100M scale, both sides counting ~104.8M key-values against a true ~100M. A TODO records how a pinned cluster read version with a matching ownership snapshot would make the digest correct online; it is unnecessary for backup/restore, which compares two settled states. design/range-digest.md covers the rest: the leaf encoding and 256-bit additive accumulator, why partition-independence forces an additive multiset hash rather than a Merkle tree over shards, why 256 bits, mismatch bisection, and the alternatives considered. On the external prototype that preceded this, the doc gives its measured cost rather than an adjective: the client-side Phase-0 fingerprint did the 3B dataset in 9h11m at ~79 MB/s aggregate, put all of that read load on the path under validation, and a before/after comparison needs two such runs. It also records what that measurement does NOT show. 79 MB/s was ~4 MB/s across 20 disks that sat ~98% idle -- 8 pods, each a single-network-thread client unable to hide cold-read latency -- so the prototype was under-parallelized rather than at a client bandwidth ceiling, and the gap to the server-side digest's ~2770 MB/s is not like-for-like. The argument that survives without the ratio is the architectural one: an external fingerprint scales with whatever bandwidth one client pool is given and spends read capacity production traffic needs, whereas hashing on the servers that already hold the data scales with storage server count and moves only 32-byte digests per range. The doc also records what the simulation workload actually asserts -- an independent client-side computation of the same digest, plus a second audit whose matching root is a determinism check that covers shard-independence only on the seeds where data distribution happened to move something -- and two properties needed to read a run correctly: an audit that never reaches Complete fails check() rather than skipping, so a broken digest cannot report green; and the committed configuration is small enough that some seeds keep a single shard and so never exercise the cross-server combine. The workload waits for the data to split into shards but not for movement to drain, so it does not establish the quiescence precondition in simulation. The backup/clear/restore cycle is covered by the Kubernetes test, not in simulation. Measured at 10B: 9.26 TB over 9.81 billion key-values digested in 53m07s, ~2770 MB/s aggregate, and the same dataset re-digested to an identical root on a later run with no restore involved.
d3ea5ed to
f2dfd9a
Compare
Result of foundationdb-pr-clang-ide on Linux RHEL 9
|
|
Here is the failure which seems unrelated... RandomSeed="1982037524" SourceVersion="7da2679aa6d5d1238141fb4de86c7527fe907e8b" Time="1786734739" BuggifyEnabled="1" DeterminismCheck="0" FaultInjectionEnabled="1" TestFile="tests/slow/RyowCorrectness.toml" |
Result of foundationdb-pr-clang-arm on Linux RHEL 9
|
Result of foundationdb-pr-macos-m1 on macOS 14.x
|
Result of foundationdb-pr-clang on Linux RHEL 9
|
Result of foundationdb-pr on Linux RHEL 9
|
Result of foundationdb-pr-cluster-tests on Linux RHEL 9
|
Result of foundationdb-pr-macos on macOS 14.x
macOS stat flags, not linux compatiblelock_mtime() { stat -f %m $LOCK || echo 0 }
|
Adds
AuditType::RangeDigest, a 256-bit content fingerprint of the key-value multiset computedin parallel by the storage servers that already hold the data. A root taken before a backup equals
the root taken after a restore iff the restored data is identical as a set — regardless of how
the two clusters shard the keyspace.
This gives backup/restore validation a check that is both rigorous (every key-value, no sampling)
and affordable at multi-terabyte scale: only 32-byte digests cross the network, never user data, so
throughput scales with the number of storage servers rather than one client's bandwidth. Useful
testing restore is same as original state.
Design doc:
design/range-digest.md(included in this PR).Motivation
A backup of a multi-terabyte database is only trustworthy if the restore can be confirmed to
reproduce it. The obvious check — a client reads the whole keyspace and hashes it — was built first
as an external prototype (
fdbfingerprint) and produced matching roots at 1B and 3B. It wasabandoned as the mechanism on measured cost: the 3B dataset took 9h11m at ~79 MB/s
aggregate, with all of that read load landing on the very path under validation, and a
before/after comparison needs two such runs.
Two qualifications, since the numbers invite an overclaim. That 79 MB/s was not a hard client
ceiling — it was ~4 MB/s across 20 disks that sat ~98% idle, i.e. an under-parallelized prototype
(8 pods, each a single-network-thread client unable to hide cold-read latency); a tuned client would
be considerably faster. And while the server-side digest measures ~2770 MB/s at 10B, that is against
the untuned client, so the ~35× gap is not like-for-like.
The architectural argument stands without the ratio: an external fingerprint scales with whatever
bandwidth one client pool is given and spends read capacity production traffic needs, while hashing
on the servers that already hold the data scales with storage-server count and moves only 32-byte
digests.
The check must also be partition-independent: a restore lands the same logical data under
completely different shard boundaries, so any fingerprint that depends on the physical partition
(Merkle tree over a shard list, per-shard hash comparison) cannot be compared across a restore at
all. Hence an additive multiset hash.
Design
Addition mod 2²⁵⁶ is associative and commutative, so the root is independent of grouping, ordering
and shard layout — the partition-independence requirement — and per-range digests combine upward
with no boundary replay. 256 bits matches the SHA-256 leaf (no truncation) and leaves a ~2¹²⁸
accidental-collision margin against datasets of ~2³³ keys, for 32 bytes per range on the wire.
The leaf encoding is byte-for-byte identical to the Phase-0
fdbfingerprinttool, which is retainedas an independent cross-check.
Explicit non-goal: collision resistance against an adversary who chooses inputs. Additive
multiset hashes are vulnerable to subset-sum/lattice attacks; the threat model here is accidental
corruption of FDB's own data on a trusted cluster, so a keyed MAC's key management and cost buy
nothing in scope. This is stated in the design doc rather than left implicit.
Precondition: quiescence
The digest must be taken over a quiescent cluster (writes stopped,
moving_data → 0). Twoindependent invariants require it:
there is no cluster-wide pinned version, so concurrent writes would hash at inconsistent
versions and the root would correspond to no single snapshot.
cluster-wide. Under in-flight shard movement a key can be folded by both the losing and gaining
server, or by neither at the instant it is read.
This is not theoretical: at 100M scale, digesting during data-distribution churn produced a root
mismatch, with both sides counting ~104.8M key-values against a true ~100M. Callers must settle
first — natural for backup/restore, where both sides are settled datasets.
RangeDigest.hcarries a TODO for the generalization: folding every server against a single pinnedcluster read version, with an ownership snapshot consistent with that version, would restore both
invariants online. Unnecessary for backup/restore, and it would not change the leaf encoding or the
root of a quiesced dataset, so fingerprints taken today stay comparable.
What's in the PR
fdbclient/RangeDigest.{h,cpp}addKeyValue/combinefdbserver/storageserver/storageserver.cppauditRangeDigestQ, dispatched fromserveAuditStorageRequests; batched and rate-limitedfdbserver/datadistributor/DataDistribution.cppfdbclient/AuditUtils.cpp,Audit.h,AuditUtils.hAuditType::RangeDigest = 7; digest/kvCount/byteCount on the audit record; range-digest summary readfdbcli/AuditStorageCommand.cpp,GetAuditStatusCommand.cpprange_digestaudit type; newget_audit_status range_digest root <id>fdbserver/workloads/RangeDigestValidation.cpp,tests/fast/RangeDigestValidation.tomldesign/range-digest.mdRate limiting and batching reuse the existing audit controls
(
AUDIT_STORAGE_RATE_PER_SERVER_MAXviaSpeedLimit,AUDIT_RESTORE_BATCH_KEY_LIMIT,REPLY_BYTE_LIMIT) rather than introducing new knobs.CLI surface:
rootdeliberately refuses to print unless the audit isComplete: per-range progress is clearedat completion, so a root read earlier could be computed over partial coverage. Relatedly, the
combine is guarded — if the persisted per-range digests do not tile the whole audit range, the audit
retries rather than publishing a root that silently omits keys.
Compatibility
AuditStorageState::serializegains three appended fields (digest,kvCount,byteCount).With flatbuffers +
IncludeVersion, old readers ignore trailing fields and new readersdefault-initialize them for records written by older versions; existing field order is untouched.
The fields are empty/zero for every other audit type.
Otherwise additive and opt-in: a new
AuditTypeinvoked on demand, no on-disk format change to userdata, no effect on a cluster that never requests it. Rollback is simply not issuing the audit.
Testing
tests/fast/RangeDigestValidation.toml): the workload writes a known key-valueset, then (a) cross-checks the audit's combined root against an independent client-side
computation of the same additive digest — scanning every key-value and applying the canonical
leaf encoding, which validates the storage-server fold and the combine — and (b) runs a
second audit and asserts the root is identical, which demonstrates partition-independence
since data distribution may have moved shards in between. It waits for quiescence before each
digest. The backup → clear → restore → recompute cycle is covered by the Kubernetes test, not in
simulation.
digest tasks): 0
SevError, all roots matching the independent computation.test_backup_restore_rangedigest):root_after == root_beforeverified end-to-end at 100M, 1B, 3B and ~4.3B key-values. A 10B run(~9.8e9 KV / ~9.26 TB,
root_before = fa1bee7a…) is in flight.53m07s — ~2770 MB/s aggregate — on a 30-machine cluster with 4 storage disks per machine. The
external Phase-0 client did the smaller 3B dataset in 9h11m at ~79 MB/s, but from an
under-parallelized client, so treat that as context rather than a tuned comparison.
against the same cluster, with no restore involved — so a matching root is not an artifact of
digesting twice in quick succession.
Reviewer notes — known gaps, stated rather than glossed
logs
RangeDigestValidationInconclusiveand returns without asserting. The content assertions sitdeliberately outside that
try, so a genuine mismatch is never swallowed — but it does mean agreen result should be confirmed by the presence of
RangeDigestValidationSuccessrather than theabsence of errors. Worth deciding whether inconclusive runs should be made noisier.
.tomluses a modest dataset (nodeCount=10000) so it stays cheap across theJoshua matrix; some seeds will keep the data in a single shard, in which case that seed does
not exercise the cross-server combine. The multi-shard evidence above comes from a larger
dedicated run with
strictShardCheck=true. Worth deciding whether a strict variant should gatehere.
low-memory configuration makes it disk-bound. An observability note, not a defect.
Companion PR
The Kubernetes scale test that drives this (
test_backup_restore_rangedigestinFoundationDB/fdb-kubernetes-tests).