fix(cubestore): stop reporting remote files nothing references - #11601
fix(cubestore): stop reporting remote files nothing references#11601waralexrom wants to merge 5 commits into
Conversation
A download that fails because the object is gone is not a transient failure, but the only signal callers had was CorruptData, which they cannot act on: Cube Cloud retries such a download ten times with exponential backoff, and the startup warmup logs it at ERROR. A single file that compaction removed while the warmup walks its snapshot therefore costs ~51s of sleeps, 20 remote calls and 10 log lines, which slows the pass down enough to make the rest of the snapshot staler. Give it CubeErrorCauseType::FileNotFound so the process holding the remote fs can tell the two apart. A listing stays the only classifier, since object stores answer 404 both for a missing key and for a missing bucket. Nothing acts on the new cause yet, so behaviour is unchanged: it still counts as corrupt data for is_corrupt_data(), and it keeps the name and index of CorruptData everywhere it leaves the process - the wire, which a node of any version has to be able to read, and Display, whose output the scheduler matches on to deactivate a table whose import job failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deleting a file that is already gone is the state the caller asked for, and S3 reports it that way: it answers 204 for a missing key. GCS answers 404 instead, so every GC task and every cleanup pass that raced with an earlier deletion logged an error nobody could act on. On one cluster those lines were 76% of all ERROR output, enough on their own to fire the error-rate alert. Report an absent object as a successful delete, and keep dropping the local copy so callers see the same end state on either driver. The check is a function of its own so a test can pin it to the payload a bucket really answers with: the reason mapping lives in the client crate, and if it ever changes the flood comes back silently. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ng warmup The startup warmup takes one metastore snapshot and then walks it one download at a time, which on a large node runs for hours. Everything compaction replaces in the meantime is gone from remote storage by the time the pass asks for it, and every one of those was logged at ERROR, so a worker kept reporting errors about files nothing referenced any more for as long as the pass lasted. Ask the metastore what the file's row looks like now. A row that is inactive or gone means compaction replaced it, which is routine and belongs in debug output. A row that is still active means the data is really missing, which is the case worth an error and worth a metric of its own - it is the one this alert was supposed to be about. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The warmup check read one row per absent file and treated any failure to read it as proof the row was gone. Both halves were wrong in the same direction: warmup runs at startup, when a select worker reaches the metastore over an RPC link that may still be coming up, so a flaky link made every missing file look routine and counted it as such - fail-silent, in a check whose whole purpose is to report data that is really missing. And a node whose snapshot compaction had long moved on issued a router point-get per stale file, with every warming worker doing it at once, exactly while the router was busy recovering. Re-read the rows in one batch per partition with the out of queue readers, which leave out ids they no longer hold. That gives the classification its third answer: an id missing from the result is a deleted row, while an error is a check that did not happen and is now reported as such instead of being folded into either verdict. Also note in the GCS delete path that a missing bucket carries the same reason code as a missing object and that the client crate keeps the detail private, so the two cannot be told apart there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The check ran per partition, which on the path the pass hits most is still one or two requests to the main node for every group of files compaction has replaced. A worker returning after a long absence finds its whole snapshot replaced, so the count follows the number of partitions it holds, and it pays them while the main node is busy with the rest of the cluster coming up. Slowing the pass down is also what makes files go stale in the first place, so the check was working against the fix it belongs to. Collect absent files as the pass goes and look them up once per 256, which is the same verdict for a fraction of the round trips. A cancelled pass drops what it has not looked up: the next start walks the whole list again. Report per batch as well. A table whose objects an operator or a lifecycle rule really removed would otherwise produce an error line per chunk, which is the flood this check exists to remove wearing a different message. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @waralexrom's task in 5m 35s —— View job Review: no blocking issues — 0 high, 1 medium, 3 low (4 inline comments)The compatibility story checks out: I verified Full review — findings, verification notes, and checklistChecklist
Verification notesThe stale/active classification is correct, including a subtlety worth recording. Recheck-by-id is only valid because a row's file name is stable:
GCS. Treating an absent object as a completed delete matches S3's 204 and still drops the local copy. The missing-bucket conflation is real but correctly argued away in the PR body — a wrong bucket fails every upload and download loudly. Tests. Good coverage of the parts that are easy to get wrong later: byte-equality of the flexbuffer against Gaps: Findings
Not verifiedI did not compile or run the test suite — cubestore has no prebuilt Security: nothing relevant — no new inputs, no auth paths, no user-controlled data. Performance: two extra out-of-queue metastore reads per 256 absent files, against one download each, so negligible; memory is bounded by the batch plus one partition's chunks. |
| Err(e) => { | ||
| // Assuming either verdict would either hide missing data or report a batch of live | ||
| // files as lost, so report that the check itself did not happen. | ||
| log::warn!( | ||
| "Could not check {} absent warmup file(s) against the metastore: {}", | ||
| absent.len(), | ||
| e | ||
| ); |
There was a problem hiding this comment.
A failed recheck discards the whole batch, including the ids that may be the genuinely-missing ones. This runs at startup, i.e. exactly when the RPC to the router is most likely to blip, so it isn't a rare path — and there's no retry and no second pass, so for that run the active signal for those ids is lost with only a warn that names no ids.
Cheap mitigation: log the ids alongside the count (they're bounded by ABSENT_BATCH), so an operator chasing a missing table has something to check by hand:
| Err(e) => { | |
| // Assuming either verdict would either hide missing data or report a batch of live | |
| // files as lost, so report that the check itself did not happen. | |
| log::warn!( | |
| "Could not check {} absent warmup file(s) against the metastore: {}", | |
| absent.len(), | |
| e | |
| ); | |
| Err(e) => { | |
| // Assuming either verdict would either hide missing data or report a batch of live | |
| // files as lost, so report that the check itself did not happen. | |
| log::warn!( | |
| "Could not check {} absent warmup file(s) against the metastore: {}. \ | |
| Unchecked partitions: {:?}, chunks: {:?}", | |
| absent.len(), | |
| e, | |
| absent.partition_ids, | |
| absent.chunk_ids | |
| ); | |
| return; | |
| } |
| // A listing is the only way to tell an absent object from a remote | ||
| // that can't be reached at all: object stores answer 404 for both a | ||
| // missing key and a missing bucket. | ||
| if self.remote_fs.list(file.clone()).await?.is_empty() { |
There was a problem hiding this comment.
The ? on the listing means a failed list replaces the original download error entirely, so the caller sees "connection reset while listing" instead of the actual download failure. Pre-existing, but this classification is now load-bearing: with the new warmup path, a flaky list also silently downgrades a real FileNotFound into a generic error (and vice versa hides the true cause). Preserving err when the probe itself fails keeps both signals honest:
| if self.remote_fs.list(file.clone()).await?.is_empty() { | |
| match self.remote_fs.list(file.clone()).await { | |
| Ok(listing) if listing.is_empty() => { | |
| return Err(CubeError::file_not_found(format!( | |
| "File {} doesn't exist in remote file system", | |
| file | |
| ))); | |
| } | |
| Ok(_) => {} | |
| Err(list_err) => { | |
| log::warn!( | |
| "Could not check whether {} exists in remote fs: {}", | |
| file, | |
| list_err | |
| ); | |
| } | |
| } |
| Ok(_) => false, | ||
| Err(e) if e.is_file_not_found() => true, | ||
| Err(e) => { | ||
| log::error!("Error: {:?}", e); |
There was a problem hiding this comment.
Nit, but this is the one line an operator now sees for a real warmup failure. ack_error! logged the same bare "Error: {:?}", so it's parity — yet the surrounding code just gained the file identity, and only some CubeErrors from download_file carry the path in their message (the size-check ones do; an underlying io/transport error may not). Since the caller knows the remote path, passing it in would make the surviving error line self-contained.
Also worth noting: warmup_file_is_absent reads as a pure predicate but logs as a side effect — a name like report_unless_absent (or returning an enum) would say what it does.
| // A cancelled pass drops what it has not looked up yet, since the next start walks the | ||
| // whole list again anyway and the metastore is on its way down with us. | ||
| self.report_absent_warmup_files(absent).await; |
There was a problem hiding this comment.
The comment sits above the flush, but the two is_cancelled() returns at L2124/L2137 mean this line never runs on a cancelled pass — so the intent reads correctly only if you already know those returns exist. Consider stating it at the return sites, or restructuring the loop to break so the single flush covers both exits. Behaviourally fine (shutdown, next start re-walks), just easy to misread.
Codecov Report✅ All modified and coverable lines are covered by tests.
Additional details and impacted files@@ Coverage Diff @@
## master #11601 +/- ##
===========================================
- Coverage 79.58% 59.28% -20.31%
===========================================
Files 480 228 -252
Lines 99544 18200 -81344
Branches 3636 3681 +45
===========================================
- Hits 79224 10790 -68434
+ Misses 19801 6861 -12940
- Partials 519 549 +30
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Summary
Two paths log an ERROR for a file that was removed legitimately. The startup warmup takes one metastore snapshot and then walks it one download at a time, so on a node with many partitions the pass runs long enough that whatever compaction replaces meanwhile is already gone from remote storage by the time the pass asks for it. And
GCTask::RemoveRemoteFilelogs an error whenever the object is already gone, which on GCS is a 404 where S3 answers 204. Neither is a problem, and together they bury the case that is one: a file the metastore still points at.Changes
CubeErrorCauseType::FileNotFoundfor an absent remote object. It still counts as corrupt data foris_corrupt_data(), and it keeps the name and index ofCorruptDataeverywhere it leaves the process: the wire, which a node of any version has to be able to read, andDisplay, whose outputschedulermatches on to deactivate a table whose import job failed. A listing stays the only classifier, since object stores answer 404 both for a missing key and for a missing bucket, and mistaking the second for the first would deactivate every table.cs.warmup.missing.staleandcs.warmup.missing.active. The second is the signal worth alerting on.Testing
cargo test -p cubestore --lib— 320 pass.Displaycompatibility of the new cause; two pin the GCS detection against the payload a bucket really answers with, since the reason mapping lives in the client crate; one drives a table through compaction, so the stale-vs-active classification is checked against what compaction actually does to the rows rather than against an assumption about it.Heads up for CI:
metastore::tests::delete_old_snapshotsandsql::tests::decimal_partition_pruningflake on repeated parallel runs without this branch too — same rate with the new test skipped.