mirror of
https://github.com/facebook/rocksdb.git
synced 2026-07-07 14:47:40 +08:00
6cceef9497
Summary:
Experimental embedded ("same-file") blob SST support (https://github.com/facebook/rocksdb/issues/14851) overloads blob file number 0 as both kInvalidBlobFileNumber and the same-file sentinel kCurrentFileBlobIndexFileNumber. Same-file references must be resolved by EmbeddedBlobResolvingIterator before they reach any generic file-metadata or integrated-BlobDB path, all of which reject file number 0 as invalid.
When resolving a same-file blob hit an error (e.g. a db_stress-injected blob-region read fault), EmbeddedBlobResolvingIterator::value() fell back to returning the RAW, unresolved same-file value, surfacing the error only via status()/Valid(). Compaction consumed the raw value before consulting status, leaking an unresolved same-file reference into the output stream. Depending on the value type this produced two different crash-test failures, both from this one root cause:
* T277566778 -- wide-column entity variant. The raw same-file entity (kTypeWideColumnEntity) reached FileMetaData::UpdateBoundaries, whose blob-ref scan (correctly) rejected file number 0: Flush failed: Corruption: Invalid blob file number (also observed from a background compaction). The tripwire fired on the leak, so the real injected read error was masked behind a misleading corruption status.
* T277310719 -- whole-value BlobIndex variant, and more dangerous because it escapes that tripwire. ResolveKeyType() rewrites the key type kTypeBlobIndex -> kTypeValue with no I/O (so it always succeeds); on the masked error the emitted entry is therefore {kTypeValue, raw BlobIndex bytes}. UpdateBoundaries only scans kTypeBlobIndex/entity types, so it does NOT reject this record: the corruption is silently written to the compaction output and persists. A later point lookup reads those raw BlobIndex bytes back as the value, which db_stress detects as db_stress: expected_value.cc:102: Assertion `ExpectedValue::IsValueBaseValid(value_base)' failed from TestGet. In the captured crash the aborting db_stress process had injected no faults itself -- it read corruption persisted by an earlier faults-on run (blackbox reuses the DB directory), confirming the persistence route.
Fix: EmbeddedBlobResolvingIterator now resolves eagerly for callers that do not opt into unprepared values (allow_unprepared_value=false, e.g. compaction), via a new EagerEmbeddedBlobResolvingIterator (the caller is selected in BlockBasedTable::NewIterator). Eager callers resolve the value during positioning, so a resolution error (blob-region I/O or corruption) is observable through status()/Valid() BEFORE value() is consumed, and value() never exposes an unresolved same-file BlobIndex. Lazy callers (allow_unprepared_value=true, user iteration) keep value laziness but must honor PrepareValue()'s result. This keeps the "callers never see an unresolved same-file blob" invariant structural, even on the error path. Using a template for EmbeddedBlobResolvingIterator minimizes unnecessary overheads.
Also hardens and documents the integrity tripwires that caught the leak, so they are not "fixed" by weakening them -- doing so would mask real corruption and could persist unresolvable same-file references into ordinary (non-embedded) SSTs, turning a transient error into permanent data loss: FileMetaData::UpdateBoundaries (write/output path), Version::GetBlob and Version::MultiGetBlob (integrated-BlobDB read path), plus the contract note in blob_constants.h.
Adds a test-only sync point
"BlockBasedTable::MaybeResolveEmbeddedValue:InjectError" (compiled out under NDEBUG) to simulate a blob-region resolution fault deterministically.
Pull Request resolved: https://github.com/facebook/rocksdb/pull/14906
Test Plan:
New unit tests in db_blob_index_test, both of which fail without the fix and pass with it:
* DBBlobIndexTest.EmbeddedBlobResolveErrorDuringCompactionNotMasked -- entity variant. Without the fix CompactRange fails with the masked "Invalid blob file number"; with the fix it fails with the injected IOError.
* DBBlobIndexTest.EmbeddedBlobResolveErrorWholeValueDuringCompactionNotMasked -- whole-value variant. Without the fix CompactRange SUCCEEDS (silently masking the error and persisting {kTypeValue, raw BlobIndex}); with the fix it fails with the injected IOError.
Confirmed the tests exercise the real root cause by temporarily neutralizing the eager resolution (MaybeEagerlyMaterialize): both tests then fail in their respective pre-fix modes (entity -> "Invalid blob file number"; whole-value -> compaction succeeds), then pass again once restored.
End-to-end: the db_stress reproducer for T277566778 (blackbox crash test with embedded-blob ingestion and fault injection) reproduced the "Invalid blob file number" crash before the fix and no longer reproduces with it.
Reviewed By: anand1976
Differential Revision: D110361228
Pulled By: pdillinger
fbshipit-source-id: 1faaa9a9ead726f17b611ff150418fa59ed75fd4
428 lines
16 KiB
C++
428 lines
16 KiB
C++
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
// This source code is licensed under both the GPLv2 (found in the
|
|
// COPYING file in the root directory) and Apache 2.0 License
|
|
// (found in the LICENSE.Apache file in the root directory).
|
|
|
|
// Copyright (c) 2011-present, Facebook, Inc. All rights reserved.
|
|
// This source code is licensed under both the GPLv2 (found in the
|
|
// COPYING file in the root directory) and Apache 2.0 License
|
|
// (found in the LICENSE.Apache file in the root directory).
|
|
#pragma once
|
|
|
|
#include <limits>
|
|
#include <string>
|
|
|
|
#include "db/blob/blob_index.h"
|
|
#include "db/dbformat.h"
|
|
#include "db/pinned_iterators_manager.h"
|
|
#include "rocksdb/slice.h"
|
|
#include "table/block_based/block_based_table_reader.h"
|
|
#include "table/internal_iterator.h"
|
|
|
|
namespace ROCKSDB_NAMESPACE {
|
|
|
|
// Wraps a block-based table iterator and resolves same-file ("embedded") blob
|
|
// references so that callers never see an unresolved same-file BlobIndex. It
|
|
// makes an embedded entry look like an ordinary key/value entry:
|
|
// - key(): a whole-value same-file BlobIndex's internal key has its value
|
|
// type rewritten from kTypeBlobIndex back to kTypeValue.
|
|
// - value(): the referenced same-file blob payload is materialized (a wide-
|
|
// column entity's same-file blob columns are rewritten inline).
|
|
// Traditional (separate-file) BlobIndex entries and plain entries pass through
|
|
// untouched.
|
|
//
|
|
// Why a wrapper instead of putting this in BlockBasedTableIterator:
|
|
// - The wrapped iterator is created with allow_unprepared_value=false, so it
|
|
// always materializes the data block and never sits in the "first key from
|
|
// index" deferred state. That keeps the "never expose an unresolved
|
|
// same-file blob index" invariant structural rather than relying on every
|
|
// downstream consumer re-checking the key type after PrepareValue().
|
|
// - For lazy callers (allow_unprepared_value=true) the wrapper preserves
|
|
// value laziness: key() does only the cheap key-type rewrite (no
|
|
// blob-region I/O) and the payload is read lazily in value()/PrepareValue()
|
|
// (such callers must honor PrepareValue()'s result). For eager callers
|
|
// (allow_unprepared_value=false, e.g. compaction) the wrapper resolves the
|
|
// value during positioning, so a resolution error (blob-region I/O or
|
|
// corruption) surfaces via status()/Valid() BEFORE value() is consumed and
|
|
// value() never exposes an unresolved same-file BlobIndex.
|
|
// - It keeps the embedded-blob concern out of the hot, complex
|
|
// BlockBasedTableIterator, and is only instantiated for SSTs that actually
|
|
// carry an embedded blob segment.
|
|
//
|
|
// Only created when the table advertises an embedded blob segment and the
|
|
// caller is not the SST dump tool (which must keep seeing raw BlobIndex
|
|
// values).
|
|
//
|
|
// kAllowUnpreparedValue mirrors the caller's allow_unprepared_value and is a
|
|
// template parameter so the lazy (hot, user-iteration) path carries no
|
|
// eager-resolution branch: when false (eager, e.g. compaction) the wrapper
|
|
// resolves during positioning; when true (lazy) it resolves in
|
|
// value()/PrepareValue(). Prefer the EagerEmbeddedBlobResolvingIterator /
|
|
// LazyEmbeddedBlobResolvingIterator aliases (below) at call sites.
|
|
template <bool kAllowUnpreparedValue>
|
|
class EmbeddedBlobResolvingIterator : public InternalIterator {
|
|
public:
|
|
// `iter` is owned by this wrapper. `arena_mode` must match how `iter` (and
|
|
// this wrapper) were allocated so the destructor frees `iter` correctly.
|
|
EmbeddedBlobResolvingIterator(const BlockBasedTable* table,
|
|
const ReadOptions& read_options,
|
|
InternalIterator* iter, bool arena_mode)
|
|
: table_(table),
|
|
read_options_(read_options),
|
|
iter_(iter),
|
|
arena_mode_(arena_mode) {
|
|
status_.PermitUncheckedError();
|
|
}
|
|
|
|
~EmbeddedBlobResolvingIterator() override {
|
|
if (arena_mode_) {
|
|
iter_->~InternalIteratorBase<Slice>();
|
|
} else {
|
|
delete iter_;
|
|
}
|
|
}
|
|
|
|
// No copying allowed
|
|
EmbeddedBlobResolvingIterator(const EmbeddedBlobResolvingIterator&) = delete;
|
|
EmbeddedBlobResolvingIterator& operator=(
|
|
const EmbeddedBlobResolvingIterator&) = delete;
|
|
|
|
bool Valid() const override { return iter_->Valid() && status_.ok(); }
|
|
|
|
void SeekToFirst() override {
|
|
ResetState();
|
|
iter_->SeekToFirst();
|
|
MaybeEagerlyMaterialize();
|
|
}
|
|
void SeekToLast() override {
|
|
ResetState();
|
|
iter_->SeekToLast();
|
|
MaybeEagerlyMaterialize();
|
|
}
|
|
void Seek(const Slice& target) override {
|
|
ResetState();
|
|
iter_->Seek(target);
|
|
MaybeEagerlyMaterialize();
|
|
}
|
|
void SeekForPrev(const Slice& target) override {
|
|
ResetState();
|
|
iter_->SeekForPrev(target);
|
|
MaybeEagerlyMaterialize();
|
|
}
|
|
void Next() override {
|
|
ResetState();
|
|
iter_->Next();
|
|
MaybeEagerlyMaterialize();
|
|
}
|
|
bool NextAndGetResult(IterateResult* result) override {
|
|
ResetState();
|
|
iter_->Next();
|
|
if (!Valid()) {
|
|
return false;
|
|
}
|
|
// For eager callers, resolve now so a resolution error is visible via
|
|
// Valid()/status() before value() is read.
|
|
MaybeEagerlyMaterialize();
|
|
if (!Valid()) {
|
|
return false;
|
|
}
|
|
result->key = key();
|
|
result->bound_check_result = iter_->UpperBoundCheckResult();
|
|
// Eager callers (kAllowUnpreparedValue=false) get a fully-resolved value;
|
|
// lazy callers must call PrepareValue().
|
|
result->value_prepared = !kAllowUnpreparedValue;
|
|
// key()/MaybeEagerlyMaterialize() may have set a status while resolving.
|
|
return Valid();
|
|
}
|
|
void Prev() override {
|
|
ResetState();
|
|
iter_->Prev();
|
|
MaybeEagerlyMaterialize();
|
|
}
|
|
|
|
Slice key() const override {
|
|
assert(Valid());
|
|
if (ResolveKeyType() && key_resolved_) {
|
|
return Slice(resolved_internal_key_);
|
|
}
|
|
// ResolveKeyType() may have set a (corruption-only, no I/O) error. This
|
|
// const accessor falls back to the raw key; the error is still surfaced
|
|
// through status()/Valid(), so permit it from being unchecked here.
|
|
status_.PermitUncheckedError();
|
|
return iter_->key();
|
|
}
|
|
|
|
Slice user_key() const override { return ExtractUserKey(key()); }
|
|
|
|
Slice value() const override {
|
|
assert(Valid());
|
|
if (MaterializeValue() && value_resolved_) {
|
|
// The resolved value -- a whole-value blob payload (pinned in the blob
|
|
// cache or an owned buffer) or a rebuilt wide-column value (an owned
|
|
// buffer) -- lives in resolved_pinned_value_ and is returned without a
|
|
// copy.
|
|
return Slice(resolved_pinned_value_);
|
|
}
|
|
// MaterializeValue() may have set an error (corruption or blob-region I/O).
|
|
// Fall back to the raw value; the error is surfaced via status()/Valid().
|
|
status_.PermitUncheckedError();
|
|
return iter_->value();
|
|
}
|
|
|
|
bool PrepareValue() override {
|
|
assert(Valid());
|
|
if (!iter_->PrepareValue()) {
|
|
return false;
|
|
}
|
|
return MaterializeValue();
|
|
}
|
|
|
|
Status status() const override {
|
|
if (!status_.ok()) {
|
|
return status_;
|
|
}
|
|
return iter_->status();
|
|
}
|
|
|
|
uint64_t write_unix_time() const override { return iter_->write_unix_time(); }
|
|
|
|
bool IsKeyPinned() const override {
|
|
// The rewritten key is owned by this wrapper, so it is not pinned.
|
|
if (key_resolved_) {
|
|
return false;
|
|
}
|
|
return iter_->IsKeyPinned();
|
|
}
|
|
bool IsValuePinned() const override {
|
|
if (value_resolved_) {
|
|
// The resolved value -- a whole-value blob payload (pinned in the blob
|
|
// cache or an owned buffer) or a rebuilt wide-column value (an owned
|
|
// buffer) -- lives in resolved_pinned_value_. The IsValuePinned()
|
|
// contract requires the value to stay valid until the iterator is deleted
|
|
// / ReleasePinnedData is called, so only advertise it as pinned when a
|
|
// PinnedIteratorsManager is active to take over the pin's cleanup across
|
|
// repositioning (see ResetState).
|
|
return value_is_pinned_ && pinned_iters_mgr_ != nullptr &&
|
|
pinned_iters_mgr_->PinningEnabled();
|
|
}
|
|
return iter_->IsValuePinned();
|
|
}
|
|
|
|
void SetPinnedItersMgr(PinnedIteratorsManager* pinned_iters_mgr) override {
|
|
pinned_iters_mgr_ = pinned_iters_mgr;
|
|
iter_->SetPinnedItersMgr(pinned_iters_mgr);
|
|
}
|
|
|
|
IterBoundCheck UpperBoundCheckResult() override {
|
|
return iter_->UpperBoundCheckResult();
|
|
}
|
|
bool MayBeOutOfLowerBound() override { return iter_->MayBeOutOfLowerBound(); }
|
|
|
|
void SetRangeDelReadSeqno(SequenceNumber read_seqno) override {
|
|
iter_->SetRangeDelReadSeqno(read_seqno);
|
|
}
|
|
bool IsDeleteRangeSentinelKey() const override {
|
|
return iter_->IsDeleteRangeSentinelKey();
|
|
}
|
|
|
|
void GetReadaheadState(ReadaheadFileInfo* readahead_file_info) override {
|
|
iter_->GetReadaheadState(readahead_file_info);
|
|
}
|
|
void SetReadaheadState(ReadaheadFileInfo* readahead_file_info) override {
|
|
iter_->SetReadaheadState(readahead_file_info);
|
|
}
|
|
|
|
Status GetProperty(std::string prop_name, std::string* prop) override {
|
|
return iter_->GetProperty(std::move(prop_name), prop);
|
|
}
|
|
|
|
void Prepare(const MultiScanArgs* scan_opts) override {
|
|
iter_->Prepare(scan_opts);
|
|
}
|
|
|
|
private:
|
|
// Cleanup for a heap-allocated std::string holding a rebuilt (wide-column)
|
|
// value pinned into resolved_pinned_value_.
|
|
static void ReleaseResolvedValueBuffer(void* arg1, void* /*arg2*/) {
|
|
delete static_cast<std::string*>(arg1);
|
|
}
|
|
|
|
// For eager callers (kAllowUnpreparedValue=false), resolve the current
|
|
// entry's value during positioning. This makes a resolution error (blob
|
|
// I/O or corruption) observable through status()/Valid() before value() is
|
|
// consumed, upholding the "callers never see an unresolved same-file
|
|
// BlobIndex" invariant even on error. Compiled out for lazy callers, which
|
|
// resolve in value()/PrepareValue() and must honor PrepareValue()'s result.
|
|
void MaybeEagerlyMaterialize() {
|
|
if constexpr (!kAllowUnpreparedValue) {
|
|
if (iter_->Valid()) {
|
|
MaterializeValue();
|
|
}
|
|
}
|
|
}
|
|
|
|
void ResetState() {
|
|
status_.PermitUncheckedError();
|
|
status_ = Status::OK();
|
|
key_prepared_ = false;
|
|
value_prepared_ = false;
|
|
key_resolved_ = false;
|
|
value_resolved_ = false;
|
|
resolved_internal_key_.clear();
|
|
if (value_is_pinned_) {
|
|
// If a PinnedIteratorsManager is active it has been told (via
|
|
// IsValuePinned) that the previous entry's pinned value stays valid until
|
|
// ReleasePinnedData; hand the pin's cleanup to it so the slice outlives
|
|
// this reposition. Otherwise just release the pin now.
|
|
if (pinned_iters_mgr_ != nullptr && pinned_iters_mgr_->PinningEnabled()) {
|
|
resolved_pinned_value_.DelegateCleanupsTo(pinned_iters_mgr_);
|
|
}
|
|
value_is_pinned_ = false;
|
|
}
|
|
resolved_pinned_value_.Reset();
|
|
}
|
|
|
|
// Cheap, key-only resolution: for a whole-value same-file BlobIndex entry,
|
|
// computes the logical internal key with its value type rewritten from
|
|
// kTypeBlobIndex to kTypeValue. Only touches data already in the data block
|
|
// (the key and the inline serialized BlobIndex) -- it does NOT read the blob
|
|
// region. No-op (returns true) for entries that are not a same-file
|
|
// BlobIndex. Returns false and sets `status_` (corruption only) on a
|
|
// malformed key/BlobIndex.
|
|
bool ResolveKeyType() const {
|
|
if (!iter_->Valid()) {
|
|
return true;
|
|
}
|
|
if (key_prepared_) {
|
|
return status_.ok();
|
|
}
|
|
key_prepared_ = true;
|
|
|
|
const Slice current_key = iter_->key();
|
|
if (ExtractValueType(current_key) != kTypeBlobIndex) {
|
|
return true;
|
|
}
|
|
|
|
ParsedInternalKey parsed_key;
|
|
// Only data-corruption errors are possible here (no I/O).
|
|
status_ = ParseInternalKey(current_key, &parsed_key, false /* log_err */);
|
|
if (!status_.ok()) {
|
|
return false;
|
|
}
|
|
|
|
BlobIndex blob_index;
|
|
// Decodes the inline BlobIndex from the data block; still no blob-region
|
|
// IO.
|
|
status_ = blob_index.DecodeFrom(iter_->value());
|
|
if (!status_.ok()) {
|
|
return false;
|
|
}
|
|
if (!blob_index.IsSameFile()) {
|
|
// Traditional (separate-file) BlobDB index; leave the key type as
|
|
// kTypeBlobIndex for the higher layers to resolve.
|
|
return true;
|
|
}
|
|
|
|
InternalKey resolved_key(parsed_key.user_key, parsed_key.sequence,
|
|
kTypeValue);
|
|
const Slice encoded_resolved_key = resolved_key.Encode();
|
|
resolved_internal_key_.assign(encoded_resolved_key.data(),
|
|
encoded_resolved_key.size());
|
|
key_resolved_ = true;
|
|
return true;
|
|
}
|
|
|
|
// Heavyweight value resolution: materializes the logical value for the
|
|
// current entry. For a same-file BlobIndex entry this reads the blob payload
|
|
// (may do I/O); for a wide-column entity it rewrites same-file blob columns
|
|
// inline. For a BlobIndex entry it first resolves the key type so key() and
|
|
// value() agree. No-op (returns true) for plain entries. Returns false and
|
|
// sets `status_` (corruption or I/O error) on failure.
|
|
bool MaterializeValue() const {
|
|
if (!iter_->Valid()) {
|
|
return true;
|
|
}
|
|
|
|
const Slice current_key = iter_->key();
|
|
const ValueType value_type = ExtractValueType(current_key);
|
|
if (value_type != kTypeBlobIndex && value_type != kTypeWideColumnEntity) {
|
|
return true;
|
|
}
|
|
|
|
if (value_type == kTypeBlobIndex && !ResolveKeyType()) {
|
|
return false;
|
|
}
|
|
if (value_prepared_) {
|
|
return status_.ok();
|
|
}
|
|
value_prepared_ = true;
|
|
|
|
std::string resolved_key;
|
|
std::string resolved_value;
|
|
bool resolved = false;
|
|
bool value_pinned = false;
|
|
status_ = table_->MaybeResolveEmbeddedValue(
|
|
read_options_, current_key, iter_->value(), &resolved_key,
|
|
&resolved_value, &resolved, &resolved_pinned_value_, &value_pinned);
|
|
if (!status_.ok()) {
|
|
return false;
|
|
}
|
|
if (!resolved) {
|
|
return true;
|
|
}
|
|
|
|
if (!resolved_key.empty() && !key_resolved_) {
|
|
resolved_internal_key_ = std::move(resolved_key);
|
|
key_resolved_ = true;
|
|
}
|
|
if (!value_pinned) {
|
|
// Built (wide-column) value: move it into a heap buffer and pin it into
|
|
// resolved_pinned_value_ so the value is pinnable like a whole-value
|
|
// blob. DBIter requires a pinned value to back up over an entry
|
|
// (Prev/SeekForPrev); the buffer's cleanup is handed to the
|
|
// PinnedIteratorsManager across repositioning (see ResetState).
|
|
auto* owned_value = new std::string(std::move(resolved_value));
|
|
resolved_pinned_value_.PinSlice(Slice(*owned_value),
|
|
&ReleaseResolvedValueBuffer, owned_value,
|
|
nullptr);
|
|
}
|
|
// Either way the resolved value now lives, pinned, in
|
|
// resolved_pinned_value_.
|
|
value_is_pinned_ = true;
|
|
value_resolved_ = true;
|
|
return true;
|
|
}
|
|
|
|
const BlockBasedTable* table_;
|
|
const ReadOptions& read_options_;
|
|
InternalIterator* iter_;
|
|
const bool arena_mode_;
|
|
PinnedIteratorsManager* pinned_iters_mgr_ = nullptr;
|
|
|
|
// Cached resolution state for the current entry, reset on every reposition.
|
|
// Mutable because resolution is driven lazily from the const key()/value()
|
|
// accessors.
|
|
mutable Status status_;
|
|
mutable std::string resolved_internal_key_;
|
|
// Holds the resolved value for the current entry without a copy: a
|
|
// whole-value same-file blob payload (pinned in the blob cache, or an owned
|
|
// buffer when no cache is configured) or a rebuilt wide-column value (an
|
|
// owned heap buffer). Populated whenever value_is_pinned_ is true.
|
|
mutable PinnableSlice resolved_pinned_value_;
|
|
mutable bool key_prepared_ = false;
|
|
mutable bool value_prepared_ = false;
|
|
mutable bool key_resolved_ = false;
|
|
mutable bool value_resolved_ = false;
|
|
mutable bool value_is_pinned_ = false;
|
|
};
|
|
|
|
// Eager variant (allow_unprepared_value=false, e.g. compaction): resolves the
|
|
// value during positioning so a resolution error surfaces via status()/Valid()
|
|
// before value() is consumed.
|
|
using EagerEmbeddedBlobResolvingIterator = EmbeddedBlobResolvingIterator<false>;
|
|
// Lazy variant (allow_unprepared_value=true, e.g. user iteration): resolves in
|
|
// value()/PrepareValue(); callers must honor PrepareValue()'s result.
|
|
using LazyEmbeddedBlobResolvingIterator = EmbeddedBlobResolvingIterator<true>;
|
|
|
|
} // namespace ROCKSDB_NAMESPACE
|