Repository navigation
DefaultEntryLogger can publish stale entry locations after BufferedChannel partial flush failure #4855
Description
BUG REPORT
Describe the bug
In a BookKeeper 4.16.7 based deployment using DbLedgerStorage and DefaultEntryLogger, we observed an entrylog where the entry bytes are still physically present and mostly parseable, but both the RocksDB location index and the entrylog header ledgersMapOffset point to positions that are consistently too early by the same offset.
The suspected code-level issue is in BufferedChannel. BufferedChannel.write() can call flush() before updating its logical position. If flush() advances the underlying FileChannel position and then throws IOException, the physical file position has moved forward but BufferedChannel.position has not. The channel is not marked failed, so later writes can continue using the same stale logical position.
Relevant code shape:
// BufferedChannel.write(...)
writeBuffer.writeBytes(...);
if (!writeBuffer.isWritable()) {
flush();
}
position += copied;// BufferedChannel.flush(...)
ByteBuffer toWrite = writeBuffer.internalNioBuffer(0, writeBuffer.writerIndex());
do {
fileChannel.write(toWrite);
} while (toWrite.hasRemaining());
writeBuffer.clear();
writeBufferStartPosition.set(fileChannel.position());If fileChannel.write(toWrite) performs a partial local write and then throws, writeBuffer.clear() and writeBufferStartPosition.set(...) are skipped, and the caller in write() also skips position += copied.
This can affect the main entrylog writer in two places:
// EntryLogManagerBase.addEntry(...)
logChannel.write(sizeBuffer);
long pos = logChannel.position(); // used as the entry body location
logChannel.write(entry);
return (logChannel.getLogId() << 32L) | pos;// BufferedLogChannel.appendLedgersMap(...)
long ledgerMapOffset = this.position();
write(serializedMap);
super.flush();
fileChannel.write(mapInfo, LEDGERS_MAP_OFFSET_POSITION);So the same stale logical position can be published as:
- entry locations in RocksDB, and
- the entrylog header
ledgersMapOffset.
This makes acknowledged entries unreadable even though their bytes may still exist later in the same entrylog file.
To Reproduce
A full production reproduction is hard because it requires a partial local write followed by an IOException at a specific point. However, the bad state is reachable with a small fault-injected FileChannel.
A reproducer-only PR is available to demonstrate the issue on the BookKeeper test path:
https://github.com/apache/bookkeeper/pull/4854
Steps:
- Check out
branch-4.16. - Apply the reproducer from PR Add reproducer for BufferedChannel partial flush position drift #4854.
- Run:
mvn -pl bookkeeper-server -Dtest=org.apache.bookkeeper.bookie.BufferedChannelTest test- The reproducer injects a
FileChannelthat writes part of the buffer, advances its physical position, then throwsIOExceptionduringBufferedChannel.flush(). testPositionCanLagFileChannelAfterPartialFlushFailureshows that after the exception,FileChannel.position()can be ahead ofBufferedChannel.position(), and a later write can still succeed on the same channel.testLedgersMapHeaderUsesStalePositionAfterPartialFlushFailureshows thatBufferedLogChannel.appendLedgersMap()can write a staleledgersMapOffsetto the entrylog header after that drift.
The tests currently assert the reachable bad state. After a fix, these tests should be rewritten to assert fail-closed behavior instead.
Expected behavior
After an IOException from a write path where the local writer state is no longer trustworthy, BookKeeper should fail closed instead of continuing to reuse the same entrylog writer.
At minimum:
BufferedChannelshould enter a failed state afterwrite(),flush(), orforceWrite()fails withIOException.- A failed
BufferedChannelshould reject subsequentwrite(),flush(),forceWrite(), andappendLedgersMap()attempts. EntryLogger.addEntry()should not return a location after the underlying log channel has failed.appendLedgersMap()should not publish a header pointing to a stale logical position.- The direct positioned header write in
appendLedgersMap()should use full-write semantics and failure handling, becauseFileChannel.write(ByteBuffer, position)is not guaranteed to write all bytes in one call. - The main entrylog writer failure should be propagated out of
DbLedgerStorage/SyncThreadinstead of being only logged and then continuing to accept writes.
For the main entrylog writer, this likely needs fatal/fail-stop handling for the affected bookie process. Continuing to accept writes can persist stale entry locations into RocksDB. Compaction entrylog failures may need a different policy: abort compaction and keep the source entrylog, rather than immediately shutting down the bookie.
Screenshots
Physical vs logical position drift diagram:
Additional context
Production evidence from the corrupted entrylog:
BookKeeper version: 4.16.7 based deployment
Ledger storage: DbLedgerStorage
Entry logger: DefaultEntryLogger + BufferedChannel
Entrylog file: 855.log
EntryLogId: 2133 decimal / 0x855 hex
writeBufferBytes: 65536
flushEntrylogBytes: 268435456
The same delta appears independently in the ledgers map header and in RocksDB entry locations:
delta = 34553
header.ledgersMapOffset = 1073741388
real ledgers map start = 1073775941
delta = 34553
L15069 actual body position - RocksDB indexed position = 34553
The delta also matches the 64 KiB write-buffer boundary at the corruption point:
L240727/E17719 sizeStart = 30979
L240727/E17719 bodyStart = 30983
writeBufferBytes = 65536
65536 - 30983 = 34553
Physical entry parsing shows that the file becomes a continuous valid physical entry chain again at the 64 KiB boundary:
65536 L38854/E11622 size=11309 next=76849
76849 L38854/E11623 size=24693 next=101546
101546 L38854/E11624 size=19210 next=120760
120760 L38854/E11625 size=32121 next=152885
...
1073770033 L39095/E6725 size=5904 next=1073775941
1073775941 real ledgers map start
Independent scan result:
chain_65536_to_map_count = 411693
chain break count = 0
last entry next = 1073775941
For ledger 15069, every RocksDB location in entrylog 2133 becomes valid after adding the same delta:
total_index_rows_log2133 = 569
ok_after_plus_34553 = 569
bad_count = 0
Samples:
| entry | RocksDB index body pos | actual body pos | actual sizeStart | size | lid | eid | lac | delta |
|---|---|---|---|---|---|---|---|---|
| E0 | 1025196194 | 1025230747 | 1025230743 | 41 | 15069 | 0 | -1 | 34553 |
| E2 | 1025196239 | 1025230792 | 1025230788 | 41 | 15069 | 2 | 1 | 34553 |
| E4 | 1025196284 | 1025230837 | 1025230833 | 41 | 15069 | 4 | 3 | 34553 |
Additional validation:
569 / 569 L15069 entries pass BK V3 + CRC32C validation after +34553.
sum(4 + size) for L15069 = 26164.
The real ledgers map records L15069 TotalSize = 26164.
The real ledgers map is also internally consistent with the file size:
real map start = 1073775941
map size = 3236 = 20 + 16 * 201
map bytes = 3240 = 4 + 3236
file size = 1073779181
1073775941 + 3240 = 1073779181
Two independent verification passes checked the same evidence:
- One pass independently confirmed the big-endian header parsing, real ledgers map offset, L15069 CRC32C validation, and
sum(4 + size) = 26164. - Another pass independently mmap-scanned the file and confirmed
65536..1073775941is a continuous physical entry chain, withdelta_buffer = delta_map = 34553.
This issue is about the BookKeeper write-path failure mode and fail-closed behavior. It is not a request for corrupted-data repair tooling.
Activity
yangxianjungree commentedon Aug 4, 2026
Friendly ping on this.
We have a local fix prototype following the design above, including focused tests for
SyncThread,DbLedgerStoragebackground flush, and theBookieImplwrite path.If there are no objections to treating uncertain entrylog write/flush failures as bookie-fatal, I can submit the patch as a PR for review.