[Store] Make file-per-key metadata scans resilient#2987
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces concurrent write safety and metadata scan isolation in StorageBackendAdaptor using a std::shared_mutex. It also improves robustness in ScanMeta by catching exceptions from malformed files, checking for empty keys, and reporting directory traversal errors. A high-severity bug was identified in ScanMeta where reusing std::error_code variables for both the directory iterator and loop-body operations can cause errors to be silently cleared during iterator increments. It is recommended to use separate local std::error_code variables inside the loop.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| if (ec_leaf) { | ||
| LOG(ERROR) << "Failed to scan metadata directory " << leaf | ||
| << ": " << ec_leaf.message(); | ||
| return tl::make_unexpected(ErrorCode::FILE_READ_FAIL); | ||
| } | ||
| } | ||
| if (ec_d1) { | ||
| LOG(ERROR) << "Failed to scan metadata directory " << d1 << ": " | ||
| << ec_d1.message(); | ||
| return tl::make_unexpected(ErrorCode::FILE_READ_FAIL); | ||
| } | ||
| } | ||
| if (ec_root) { | ||
| LOG(ERROR) << "Failed to scan metadata root " << root << ": " | ||
| << ec_root.message(); | ||
| return tl::make_unexpected(ErrorCode::FILE_READ_FAIL); | ||
| } |
There was a problem hiding this comment.
Systematic Error Clearing Bug in Directory Traversal
There is a subtle but significant bug in how std::error_code is handled during the directory traversal in ScanMeta.
The error codes (ec_leaf, ec_d1, ec_root) are passed to both:
- The iterator itself (e.g.,
fs::directory_iterator(leaf, ec_leaf)andit.increment(ec_leaf)). - Member functions of
directory_entryinside the loop body (e.g.,it->is_regular_file(ec_leaf),fs::file_size(p, ec_leaf), andit1->is_directory(ec_root)).
The Issue:
If an error occurs inside the loop body (for example, fs::file_size fails and sets ec_leaf), the code executes continue.
During the continue, the loop's increment expression it.increment(ec_leaf) is executed. According to the C++ standard, directory_iterator::increment(ec) clears ec on success.
Consequently, any error that occurred inside the loop body is silently cleared and ignored, unless it happened on the very last file in the directory (where the loop terminates immediately without another increment, leaving ec_leaf set).
This leads to highly non-deterministic behavior where ScanMeta may or may not fail depending on the order of files returned by the filesystem.
Suggested Fix:
Use a separate, local std::error_code variable (e.g., ec_file) for any operations inside the loop body, and handle those errors explicitly (either by breaking/returning if they are fatal, or logging and continuing if they are non-fatal) so they do not interfere with the iterator's state.
For example:
std::error_code ec_file;
if (!it->is_regular_file(ec_file) || ec_file) continue;
uintmax_t sz = fs::file_size(p, ec_file);
if (ec_file) continue;There was a problem hiding this comment.
Handled in ac103c9: traversal and per-entry file operations now use separate std::error_code instances.
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
he-yufeng
left a comment
There was a problem hiding this comment.
Nice hardening — this is squarely in the same "tolerate a corrupt/partial on-disk metadata tree instead of crashing or miscounting" space as the earlier bucket-scan fixes. Two things worth a look, one confirmation and one consistency question.
Counting fix looks right. Moving from the in-place total_keys++ / total_size += to local scanned_keys / scanned_size that are assigned once at the end makes a re-scan idempotent — the previous code accumulated on top of the existing totals every time ScanMeta ran, so a second scan double-counted. Good catch, and the scan_mutex_ shared/unique split to isolate a full scan from concurrent offload/evict reads is the right shape.
1. Does struct_pb::from_pb actually throw on a malformed record? The new try { from_pb } catch (const std::exception&) only helps if a corrupt buffer surfaces as a thrown exception. A lot of the yalantinglibs deserialize entry points report failure through a returned std::errc / expected rather than throwing, in which case a truncated file would sail past the catch, leave kv partially/default-initialized, and then be treated as a real record (or skipped only by the kv.key.empty() guard). Worth confirming the deserialize overload in use here throws — if it can also fail by return value, the resilience is only half there and the return should be checked too.
2. Partial flush() vs. rolled-back totals on a mid-scan failure. flush() hands each accumulated batch to handler(...) during the walk, but total_keys / total_size are only committed after the whole tree scans cleanly. If a later directory hits ec_leaf / ec_d1 / ec_root and the function returns FILE_READ_FAIL, the batches already flushed have been consumed by the handler, yet the totals are left at their old values (never updated to reflect the partial work). So on a partial failure the handler's persisted state and the adaptor's counters can diverge. Is the contract that the caller treats a failed ScanMeta as "retry a full scan" and the handler is idempotent enough to absorb the re-flush? If so it'd be good to say that in a comment; if not, the flushed-but-uncounted window looks like a real inconsistency (handler sees keys the counters don't). Either committing the partial totals before returning, or not flushing until the scan is known-good, would close it.
Neither blocks the direction — the scan is clearly more robust than before. Mostly want to make sure the deserialize failure mode is really exception-based and that the partial-flush semantics are intentional.
|
@he-yufeng Confirmed that this |
Description
Fixes #2982.
ScanMeta()so rescans do not double-count and failed traversals preserve the previous totals.Module
mooncake-store)Type of Change
How Has This Been Tested?
Test commands:
Test results (Ubuntu):
storage_backend_test: 73/73)Checklist
./scripts/code_format.shpre-commit run --all-filesand all hooks pass (touched-file hooks pass)AI Assistance Disclosure