Before Creating the Bug Report
Runtime platform environment
ubuntu 24.04
RocketMQ version
branch: develop version: 5.5.0 Git commit id: a6fb9e2
JDK Version
JDK 8u202
Describe the Bug
In Controller mode, RocketMQ uses AutoSwitchHAService.
When a slave reconnects to the master, AutoSwitchHAClient#doTruncate() calculates a consistent point and truncates the local CommitLog:
final long truncateOffset =
localEpochCache.findConsistentPoint(masterEpochCache);
if (!this.messageStore.truncateFiles(truncateOffset)) {
LOGGER.error("Failed to truncate slave log to {}", truncateOffset);
return false;
}
The truncation offset is validated as a message boundary, but the actual truncation only resets the in-memory positions of the target MappedFile.
The physical bytes after the truncation offset remain unchanged on disk.
If the slave crashes again after truncation but before the master has durably overwritten the stale tail, CommitLog recovery may scan and recover the stale messages again.
1. Controller mode uses AutoSwitchHAService
if (brokerConfig.isEnableControllerMode()) {
this.haService = new AutoSwitchHAService();
}
Source:
|
private void initializeHAService() { |
|
if (!this.messageStoreConfig.isEnableDLegerCommitLog() && !this.messageStoreConfig.isDuplicationEnable()) { |
|
if (brokerConfig.isEnableControllerMode()) { |
|
this.haService = new AutoSwitchHAService(); |
|
LOGGER.warn("Load AutoSwitch HA Service: {}", AutoSwitchHAService.class.getSimpleName()); |
|
} else { |
|
this.haService = ServiceProvider.loadClass(HAService.class); |
|
if (null == this.haService) { |
|
this.haService = new DefaultHAService(); |
|
LOGGER.warn("Load default HA Service: {}", DefaultHAService.class.getSimpleName()); |
|
} |
2. AutoSwitchHAClient truncates the slave CommitLog
final long truncateOffset =
localEpochCache.findConsistentPoint(masterEpochCache);
if (!this.messageStore.truncateFiles(truncateOffset)) {
LOGGER.error("Failed to truncate slave log to {}", truncateOffset);
return false;
}
changeCurrentState(HAConnectionState.TRANSFER);
this.currentReportedOffset = truncateOffset;
Source:
|
LOGGER.info("master epoch entries is {}", masterEpochCache.getAllEntries()); |
|
LOGGER.info("local epoch entries is {}", localEpochEntries); |
|
|
|
final long truncateOffset = localEpochCache.findConsistentPoint(masterEpochCache); |
|
|
|
LOGGER.info("truncateOffset is {}", truncateOffset); |
|
|
|
if (truncateOffset < 0) { |
|
// If truncateOffset < 0, means we can't find a consistent point |
|
LOGGER.error("Failed to find a consistent point between masterEpoch:{} and slaveEpoch:{}", masterEpochEntries, localEpochEntries); |
|
return false; |
|
} |
|
if (!this.messageStore.truncateFiles(truncateOffset)) { |
|
LOGGER.error("Failed to truncate slave log to {}", truncateOffset); |
|
return false; |
|
} |
|
final long maxPhyOffset = this.messageStore.getMaxPhyOffset(); |
|
if (truncateOffset < maxPhyOffset) { |
|
this.epochCache.truncateSuffixByOffset(truncateOffset); |
|
LOGGER.info("Truncate slave log to {} success, change to transfer state", truncateOffset); |
|
} |
|
changeCurrentState(HAConnectionState.TRANSFER); |
|
this.currentReportedOffset = truncateOffset; |
|
} |
|
if (!reportSlaveMaxOffset(HAConnectionState.TRANSFER)) { |
|
LOGGER.error("AutoSwitchHAClient report max offset to master failed"); |
|
return false; |
|
} |
3. DefaultMessageStore only checks whether the offset is aligned
public boolean truncateFiles(long offsetToTruncate) {
if (offsetToTruncate >= this.getMaxPhyOffset()) {
return true;
}
if (!isOffsetAligned(offsetToTruncate)) {
return false;
}
truncateDirtyFiles(offsetToTruncate);
return true;
}
Source:
|
public boolean truncateFiles(long offsetToTruncate) throws RocksDBException { |
|
if (offsetToTruncate >= this.getMaxPhyOffset()) { |
|
LOGGER.info("no need to truncate files, truncate offset is {}, max physical offset is {}", offsetToTruncate, this.getMaxPhyOffset()); |
|
return true; |
|
} |
|
|
|
if (!isOffsetAligned(offsetToTruncate)) { |
|
LOGGER.error("offset {} is not align, truncate failed, need manual fix", offsetToTruncate); |
|
return false; |
|
} |
|
truncateDirtyFiles(offsetToTruncate); |
|
return true; |
|
} |
|
|
|
@Override |
|
public boolean isOffsetAligned(long offset) { |
|
SelectMappedBufferResult mappedBufferResult = this.getCommitLogData(offset); |
|
|
|
if (mappedBufferResult == null) { |
|
return true; |
|
} |
|
|
|
DispatchRequest dispatchRequest = this.commitLog.checkMessageAndReturnSize(mappedBufferResult.getByteBuffer(), true, false); |
|
return dispatchRequest.isSuccess(); |
The alignment check prevents truncation in the middle of a message, but it does not make the truncation durable.
4. MappedFileQueue only resets in-memory positions
public void truncateDirtyFiles(long offset) {
List<MappedFile> willRemoveFiles = new ArrayList<>();
for (MappedFile file : this.mappedFiles) {
long fileTailOffset =
file.getFileFromOffset() + this.mappedFileSize;
if (fileTailOffset > offset) {
if (offset >= file.getFileFromOffset()) {
file.setWrotePosition(
(int) (offset % this.mappedFileSize));
file.setCommittedPosition(
(int) (offset % this.mappedFileSize));
file.setFlushedPosition(
(int) (offset % this.mappedFileSize));
} else {
file.destroy(1000);
willRemoveFiles.add(file);
}
}
}
this.deleteExpiredFile(willRemoveFiles);
}
Source:
|
public void truncateDirtyFiles(long offset) { |
|
List<MappedFile> willRemoveFiles = new ArrayList<>(); |
|
|
|
for (MappedFile file : this.mappedFiles) { |
|
long fileTailOffset = file.getFileFromOffset() + this.mappedFileSize; |
|
if (fileTailOffset > offset) { |
|
if (offset >= file.getFileFromOffset()) { |
|
file.setWrotePosition((int) (offset % this.mappedFileSize)); |
|
file.setCommittedPosition((int) (offset % this.mappedFileSize)); |
|
file.setFlushedPosition((int) (offset % this.mappedFileSize)); |
|
} else { |
|
file.destroy(1000); |
|
willRemoveFiles.add(file); |
|
} |
|
} |
|
} |
|
|
|
this.deleteExpiredFile(willRemoveFiles); |
After truncating to offset T, the state is:
In-memory wrotePosition = T
In-memory committedPosition = T
In-memory flushedPosition = T
Physical bytes in [T, oldMaxOffset) remain unchanged
This also applies when T is exactly the beginning of a MappedFile.
Because the condition is:
offset >= file.getFileFromOffset()
the target file is retained with position zero instead of being deleted.
5. Existing MappedFiles are loaded as fully written after restart
mappedFile.setWrotePosition(this.mappedFileSize);
mappedFile.setFlushedPosition(this.mappedFileSize);
mappedFile.setCommittedPosition(this.mappedFileSize);
Source:
|
public boolean doLoad(List<File> files) { |
|
// ascending order |
|
files.sort(Comparator.comparing(File::getName)); |
|
|
|
for (int i = 0; i < files.size(); i++) { |
|
File file = files.get(i); |
|
if (file.isDirectory()) { |
|
continue; |
|
} |
|
|
|
if (file.length() == 0 && i == files.size() - 1) { |
|
boolean ok = file.delete(); |
|
log.warn("{} size is 0, auto delete. is_ok: {}", file, ok); |
|
continue; |
|
} |
|
|
|
if (file.length() != this.mappedFileSize) { |
|
log.warn(file + "\t" + file.length() |
|
+ " length not matched message store config value, please check it manually"); |
|
return false; |
|
} |
|
|
|
try { |
|
MappedFile mappedFile = new DefaultMappedFile(file.getPath(), mappedFileSize, runningFlags, writeWithoutMmap); |
|
|
|
mappedFile.setWrotePosition(this.mappedFileSize); |
|
mappedFile.setFlushedPosition(this.mappedFileSize); |
|
mappedFile.setCommittedPosition(this.mappedFileSize); |
|
this.mappedFiles.add(mappedFile); |
CommitLog recovery then scans the physical contents to determine the valid tail.
Because the discarded bytes were not erased or invalidated, the stale records after T may still contain:
- a valid total size;
- a valid magic code;
- a correct physical offset;
- a valid CRC.
As a result, recovery may advance beyond the intended HA truncation offset.
Failure Scenario
A possible failure sequence is:
-
The slave has CommitLog data up to offset S.
-
After a master switch or epoch divergence, AutoSwitchHAClient calculates an aligned consistent point T.
-
T is smaller than S:
-
The slave executes:
messageStore.truncateFiles(T);
-
The in-memory CommitLog maximum offset becomes T.
-
The physical bytes in [T, S) remain unchanged.
-
Before the master has durably overwritten the stale tail, the slave crashes again.
-
On restart, the MappedFile is initially loaded as fully written.
-
CommitLog recovery scans the stale records after T.
-
The recovered maximum physical offset may become greater than T again.
Steps to Reproduce
The storage behavior can be reproduced without running a complete Controller cluster.
-
Create a CommitLog MappedFile.
-
Append and flush at least two valid messages.
-
Record the physical start offset of the second message as T.
-
Call:
messageStore.truncateFiles(T);
-
Verify:
messageStore.getMaxPhyOffset() == T
-
Do not append new data from T.
-
Simulate an abrupt process termination.
-
Reload the same store directory.
-
Execute CommitLog recovery.
-
Check the recovered maximum physical offset.
Expected result:
recoveredMaxPhyOffset == T
Potential current result:
recoveredMaxPhyOffset > T
The old second message may be recovered because its physical bytes remain valid on disk.
What Did You Expect to See?
Expected Behavior
After AutoSwitchHAClient successfully truncates the slave to offset T, a subsequent restart must not recover any CommitLog record at or after T unless that record has been received again from the current master.
The HA truncation should provide a durable recovery boundary.
What Did You See Instead?
Slave commitLog keeps the stale tail.
Additional Context
No response
Before Creating the Bug Report
I found a bug, not just asking a question, which should be created in GitHub Discussions.
I have searched the GitHub Issues and GitHub Discussions of this repository and believe that this is not a duplicate.
I have confirmed that this bug belongs to the current repository, not other repositories of RocketMQ.
Runtime platform environment
ubuntu 24.04
RocketMQ version
branch: develop version: 5.5.0 Git commit id: a6fb9e2
JDK Version
JDK 8u202
Describe the Bug
In Controller mode, RocketMQ uses
AutoSwitchHAService.When a slave reconnects to the master,
AutoSwitchHAClient#doTruncate()calculates a consistent point and truncates the local CommitLog:The truncation offset is validated as a message boundary, but the actual truncation only resets the in-memory positions of the target
MappedFile.The physical bytes after the truncation offset remain unchanged on disk.
If the slave crashes again after truncation but before the master has durably overwritten the stale tail, CommitLog recovery may scan and recover the stale messages again.
1. Controller mode uses AutoSwitchHAService
Source:
rocketmq/store/src/main/java/org/apache/rocketmq/store/DefaultMessageStore.java
Lines 1978 to 1988 in a6fb9e2
2. AutoSwitchHAClient truncates the slave CommitLog
Source:
rocketmq/store/src/main/java/org/apache/rocketmq/store/ha/autoswitch/AutoSwitchHAClient.java
Lines 450 to 477 in a6fb9e2
3. DefaultMessageStore only checks whether the offset is aligned
Source:
rocketmq/store/src/main/java/org/apache/rocketmq/store/DefaultMessageStore.java
Lines 828 to 851 in a6fb9e2
The alignment check prevents truncation in the middle of a message, but it does not make the truncation durable.
4. MappedFileQueue only resets in-memory positions
Source:
rocketmq/store/src/main/java/org/apache/rocketmq/store/MappedFileQueue.java
Lines 217 to 234 in a6fb9e2
After truncating to offset
T, the state is:This also applies when
Tis exactly the beginning of aMappedFile.Because the condition is:
the target file is retained with position zero instead of being deleted.
5. Existing MappedFiles are loaded as fully written after restart
Source:
rocketmq/store/src/main/java/org/apache/rocketmq/store/MappedFileQueue.java
Lines 270 to 298 in a6fb9e2
CommitLog recovery then scans the physical contents to determine the valid tail.
Because the discarded bytes were not erased or invalidated, the stale records after
Tmay still contain:As a result, recovery may advance beyond the intended HA truncation offset.
Failure Scenario
A possible failure sequence is:
The slave has CommitLog data up to offset
S.After a master switch or epoch divergence,
AutoSwitchHAClientcalculates an aligned consistent pointT.Tis smaller thanS:The slave executes:
The in-memory CommitLog maximum offset becomes
T.The physical bytes in
[T, S)remain unchanged.Before the master has durably overwritten the stale tail, the slave crashes again.
On restart, the MappedFile is initially loaded as fully written.
CommitLog recovery scans the stale records after
T.The recovered maximum physical offset may become greater than
Tagain.Steps to Reproduce
The storage behavior can be reproduced without running a complete Controller cluster.
Create a CommitLog MappedFile.
Append and flush at least two valid messages.
Record the physical start offset of the second message as
T.Call:
Verify:
Do not append new data from
T.Simulate an abrupt process termination.
Reload the same store directory.
Execute CommitLog recovery.
Check the recovered maximum physical offset.
Expected result:
Potential current result:
The old second message may be recovered because its physical bytes remain valid on disk.
What Did You Expect to See?
Expected Behavior
After
AutoSwitchHAClientsuccessfully truncates the slave to offsetT, a subsequent restart must not recover any CommitLog record at or afterTunless that record has been received again from the current master.The HA truncation should provide a durable recovery boundary.
What Did You See Instead?
Slave commitLog keeps the stale tail.
Additional Context
No response