Skip to content

[ISSUE #11168] Fix tiered storage commit failure reporting and related hazards - #11169

Open
lizhimins wants to merge 2 commits into
apache:developfrom
lizhimins:zhimin/tiered-store-fixes
Open

lizhimins wants to merge 2 commits into
apache:developfrom
lizhimins:zhimin/tiered-store-fixes

Conversation

@lizhimins

Copy link
Copy Markdown
Member

Which Issue(s) This PR Fixes

Brief Description

Six defects in the tiered storage module, found while analysing 24h of production tiered_store.log in one region (302M lines, of which 16,902 ERROR). None of them affect durability — the retry path is correct and loses no data — but together they made the most frequent upload failure in production unreadable, and two are latent hazards.

1. FileSegment#handleCommitException no longer performs a remote lookup, which fixes the misleading message and an IO-thread hazard at once.

TieredStoreException defaults position to -1, colliding with GET_FILE_SIZE_ERROR = -1L. A provider that only sets position when it received an HTTP error response leaves it at -1 for transport failures, so those took the same branch as a genuinely failed HEAD and were logged as get file size error after commit — a lookup that never ran. In production, 7,379 such lines in 24h, every one with expect == commit + content, i.e. all values local.

The same ternary's other side calls this.getSize(), a synchronous object-metadata request, from a callback registered via .exceptionally(...) with no executor. It therefore runs on whichever thread completed the future — a netty IO thread for a network provider, as the production thread names confirm (AsyncHttpClient-3-N).

The handler now reconciles only from the length the provider reported on the exception, which costs nothing, and otherwise leaves the input stream in place. commitPosition is not advanced on failure, so needCommit() stays true and the next commitAsync performs the lookup on the dispatcher thread, where that logic already existed. GET_FILE_SIZE_ERROR regains a single meaning, so a failed lookup can only be reported by the code that performs it. For a transport failure the number of remote lookups is unchanged: that path never queried the size here either.

2. One log line per commit failure, with a shared vocabulary.

handleCommitException logged three differently worded messages with three different field sets, one of which described a success (the append landed remotely and only the response was lost). Both commit-failure paths now emit a single line with the same fields and a result discriminator: REMOTE_LANDED, RETRY_AFTER_REWIND, RETRY_AFTER_RECONCILE from handleCommitException, and SIZE_LOOKUP_FAILED from commitAsync — which previously named the same quantity buffer instead of content and omitted expect and remote.

commit is now logged as of the append rather than after correctPosition overwrote it, so commit + content == expect holds on every path and can be read against remote.

The !sizeKnown case is tested first, which also makes correctPosition(-1) unreachable by construction rather than by a short-circuit that needs explaining.

3. FlatAppendFile#destroyExpiredFile unregisters metadata before deleting the object.

The opposite order leaves a metadata row pointing at a deleted object if the process dies in between. recover() reloads it on every restart and every later read of that segment fails with NoSuchKey. Production showed 844 such failures in 24h against only 4 distinct object names, concentrated on 3 of 11 affected instances — permanent phantom metadata, not transient errors. The new order fails safe: the worst case is an orphaned object, which costs storage instead of breaking reads.

4. FileSegment#readAsync logs a shortened read at WARN instead of DEBUG.

The truncated buffer surfaces much later as a splitMessageBuffer failure whose message carries no topic, queueId or offset, so the cause was invisible: 391 message buffer offset exceeded limit errors in 24h on one instance that could not be attributed to a queue from the logs.

5. cacheBusy compares bytes against bytes.

fetcherCache.estimatedSize() counts entries while memoryMaxSize * 0.8 is a byte count, so the check was effectively always false. It now uses the weighted size, which is what maximumWeight bounds via the SelectBufferResult#getSize weigher.

How Did You Test This Change?

mvn -pl tieredstore test — 127 tests, 0 failures, 0 errors. mvn -pl tieredstore validate reports 0 checkstyle violations against style/rmq_checkstyle.xml.

FileSegmentTest#handleCommitExceptionTest already covers all three outcomes and continues to pass unchanged: a provider exception without a position returns false, one carrying setPosition(size * 2) equal to the expected position returns true, and a non-TieredStoreException cause returns false. I extended the third case with Mockito.verify(fileSpySegment, Mockito.never()).getSize(), replacing a getSize() stub that the new code no longer reaches, so the no-remote-IO property is guarded by a test rather than only by the commit message.

The production figures quoted above come from aggregating 24h of tiered_store.log for one region and clustering all 16,902 ERROR lines by root cause.

…related hazards

Found while analysing 24h of production tiered-store logs in cn-hangzhou
(302M lines, of which 16,902 ERROR).

A transient upload failure was reported as "get file size error after
commit" although no size lookup ever took place. TieredStoreException
defaults its position to -1, which collides with GET_FILE_SIZE_ERROR, so
a provider-reported failure and a failed lookup shared one branch and one
message. handleCommitException now performs no remote lookup at all: it
reconciles from the length the provider reported on the exception, and
otherwise leaves the input stream in place for the next commitAsync,
which already did that lookup on the dispatcher thread. This matters
because the handler is registered via exceptionally with no executor, so
it runs on a netty IO thread for a network provider. GET_FILE_SIZE_ERROR
keeps its single meaning, and a failed lookup can only be reported by the
code that performs it.

The same method logged three differently worded messages with three
different field sets, one of which described a success. Both commit
failure paths now emit one line with a shared field set and a result
discriminator: REMOTE_LANDED, RETRY_AFTER_REWIND and RETRY_AFTER_RECONCILE
from handleCommitException, SIZE_LOOKUP_FAILED from commitAsync, which
previously called the same quantity "buffer" instead of "content" and
omitted expect and remote. The commit position is logged as of the append
rather than after correctPosition overwrote it, so commit, content and
expect stay mutually consistent.

FlatAppendFile#destroyExpiredFile deleted the remote object before
unregistering its metadata. A crash in between leaves a metadata row
pointing at a deleted object, reloaded on every restart, after which
every read of that segment fails with NoSuchKey. Reverse the order so the
worst case becomes an orphaned object.

FileSegment#readAsync shortened a read to the committed length and logged
it at DEBUG. The truncated buffer surfaces later as a splitMessageBuffer
failure whose own message carries no topic, queueId or offset, so the
cause was invisible in production. Raise it to WARN.

MessageStoreFetcherImpl compared fetcherCache.estimatedSize(), a count of
entries, against memoryMaxSize * 0.8, a number of bytes, so cacheBusy was
always false. Compare the weighted size, which is what maximumWeight
bounds.
@codecov-commenter

codecov-commenter commented Sep 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 68.29268% with 13 lines in your changes missing coverage. Please review.
✅ Project coverage is 49.42%. Comparing base (80e1ae5) to head (cff4194).
⚠️ Report is 1 commits behind head on develop.

Files with missing lines Patch % Lines
...che/rocketmq/tieredstore/index/IndexStoreFile.java 9.09% 10 Missing ⚠️
...che/rocketmq/tieredstore/provider/FileSegment.java 90.00% 1 Missing and 1 partial ⚠️
...mq/tieredstore/exception/TieredStoreException.java 80.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@              Coverage Diff              @@
##             develop   #11169      +/-   ##
=============================================
- Coverage      49.52%   49.42%   -0.11%     
+ Complexity     14293    14271      -22     
=============================================
  Files           1390     1390              
  Lines         103129   103174      +45     
  Branches       13485    13490       +5     
=============================================
- Hits           51075    50989      -86     
- Misses         45902    46012     +110     
- Partials        6152     6173      +21     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@RockteMQ-AI RockteMQ-AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

This PR fixes six defects in the tiered storage module, all identified through production log analysis (302M lines, 16,902 ERRORs in 24h). The changes are well-motivated, correctly scoped, and the test coverage properly validates the key behavioral change (no synchronous getSize() call on the exception handler thread).

The most critical fix is defect #1 — removing the synchronous remote metadata lookup from the exceptionally callback, which was running on netty IO threads and blocking them. Deferring reconciliation to the next commitAsync() on the dispatcher thread is the correct approach.

The crash-safety fix in FlatAppendFile#destroyExpiredFile (metadata removal before object deletion) is also important — the previous ordering left metadata pointing at deleted objects if the process died between the two operations.

Overall, this is a high-quality PR with exceptional documentation. Each fix is backed by production evidence, and the unified logging format will make future debugging significantly easier.

Findings

  • [Info] FlatAppendFile.java:295 — Orphaned object cleanup after metadata deletion may need a background sweep
  • [Info] MessageStoreFetcherImpl.java:464 — Cache weight fix is correct; note about unbounded cache behavior in tests

Suggestions

  • Consider adding a background sweep for orphaned objects in destroyExpiredFile to handle the case where destroyFile() throws after metadata is already deleted
  • The unified logging format (REMOTE_LANDED, RETRY_AFTER_REWIND, RETRY_AFTER_RECONCILE, SIZE_LOOKUP_FAILED) is excellent — consider documenting these result codes in a developer guide or README

Automated review by RockteMQ-AI

long baseOffset = fileSegment.getBaseOffset();
fileSegmentTable.remove(0);
metadataStore.deleteFileSegment(filePath, fileType, baseOffset);
fileSegment.destroyFile();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Info] The ordering change (metadata removal before object deletion) is correct for crash safety — an orphaned object is indeed harmless while orphaned metadata causes NoSuchKey on every subsequent read.

One consideration: if metadataStore.deleteFileSegment() succeeds but fileSegment.destroyFile() throws, the warn log fires but the segment is already unregistered from fileSegmentTable. This means the orphaned object will never be retried for cleanup. Consider whether a background sweep or a retry loop for destroyFile() would be worthwhile for production hygiene, though this is not a correctness issue.

@RockteMQ-AI RockteMQ-AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Six defects in the tiered storage module, all found from production log analysis (302M lines, 16,902 ERRORs). None affect durability — the retry path is correct — but they made the most frequent upload failure unreadable and two are latent hazards. Well-motivated, production-tested fixes.

Observations

  • Cache weight fix (MessageStoreFetcherImpl): Correct — the cache is bounded by maximumWeight (bytes via SelectBufferResult#getSize weigher), so estimatedSize() (entry count) was the wrong metric. Using weightedSize() from the eviction policy is the right fix.
  • handleCommitException no longer does remote lookup: Significant improvement — removing a network call from the exception handler eliminates a potential cascading failure. Returning false when size is unknown and letting the next commit reconcile is the right approach.
  • FILE_NOT_FOUND handling (IndexStoreFile): Correct — objects can be deleted while reads are in flight. Downgrading to INFO and returning empty results avoids log noise. The hasErrorCode utility walks the cause chain with cycle detection (cause != cause.getCause()).
  • readAsync log upgrade: Debug → warn when request exceeds commit position is appropriate for production observability.
  • Tests: New tests for cache weight, hasErrorCode, and the updated commit exception test that verifies getSize is NOT called on the exception path. Good coverage.

LGTM.


Automated review by github-manager-bot

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Tiered storage misreports transient upload failures as file-size lookup errors

3 participants