[Not yet upstream] Serial replication can get stuck on empty ranges - #252
Merged
Conversation
…k after consecutive RS crashes testTwoConsecutiveRSCrashesNoWritesBetween reliably reproduces the bug: when an RS crashes twice with no data writes between crashes, the REGION_OPEN marker's seqId from the first restart matches the barrier value from the second restart. This causes SerialReplicationChecker.canPush() to check the wrong barrier endpoint (off-by-one from binary search index increment on exact match), permanently blocking replication. This is the root cause of the Mamba CDC investigation — the stuck first RS entry causes head-of-line blocking for all subsequent entries. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…p-1 empty range When an RS crashes and the region reopens, consecutive crashes with no user writes between them can produce two barriers that differ by exactly 1. The range between them contains only the openSeqNum, which never has a WAL entry (mvcc.advanceTo sets the base, mvcc.begin increments before the first write). isRangeFinished for this empty range requires pushedSeqId >= endBarrier - 1, which can never be satisfied — blocking replication permanently. Fix: when the binary search finds an exact barrier match and the gap to the previous barrier is 1, skip the index increment so isRangeFinished checks the range before the empty one instead. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When Region A's WAL entry is stuck in the serial replication checker, all subsequent entries in the same WAL — including entries for unrelated Region B — are head-of-line blocked. This test reproduces the incident where moving a healthy region onto an RS with a stuck region caused the healthy region's replication to also stall. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
cathturner
approved these changes
Jul 23, 2026
| } | ||
| } | ||
|
|
||
| private void abortRSHostingRegion(TableName tableName, RegionInfo region) throws Exception { |
There was a problem hiding this comment.
nit: looks like we don't use tableName here
| long seqId = entry.getKey().getSequenceId(); | ||
| Long prev = lastSeqIdByRegion.get(region); | ||
| assertTrue( | ||
| "Sequence id go backwards for region " + region + " from " + prev + " to " + seqId, |
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
charlesconnell
pushed a commit
that referenced
this pull request
Aug 4, 2026
charlesconnell
pushed a commit
that referenced
this pull request
Aug 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This is a long PR description, but the summary is that serial replication can get stuck if a server crashes twice without having edits in between. Basically, if the situation is:
Fix serial replication stuck after consecutive RegionServer crashes
Problem
Serial replication can get permanently stuck after an RS is restarted (gracefully or via SIGKILL) multiple times without user writes between restarts. Once stuck, the affected RS's WAL reader blocks indefinitely in
waitUntilCanPush(), causing head-of-line blocking for all regions sharing that WAL — including unrelated regions moved onto the stuck RS after the fact.Mamba CDC outage
On an hbase cluster, a CDC peer was created on Jul 13 while a rolling Hadoop datanode upgrade was in progress. The upgrade uses a drain/return pattern: displacing all ~95 regions from each RS to temporary RSs, rolling WALs, stopping the RS, restarting, then returning regions. Each upgrade creates two barriers per region (one on drain, one on return).
Over Jul 14-16, the upgrade job restarted 33 RSs. Each drain/return cycle created intermediate barrier ranges with no user writes between them. WAL reader threads became stuck in
SerialReplicationChecker.waitUntilCanPush()on REGION_OPEN entries for these intermediate ranges, with the log message:Thread dumps showed WAL readers in
TIMED_WAITINGwith waited counts of 65K-104K (looping for days). The stuck entries blocked all subsequent WAL entries via head-of-line blocking, cascading from 4 stuck RSs on Jul 14 to all 33 RSs by Jul 15 22:00. The only resolution was deleting and re-creating the CDC peer.Example from a host:
Stuck range
[862703835, 862704390)= 555 seqIds spanning 13 minutes (Jul 15 10:59-11:12), created by a drain/return bounce during the upgrade.Root cause
When an RS restarts and opens a region, a new replication barrier (
openSeqNum) is written tohbase:meta, and aREGION_OPENmarker is appended to the WAL. The marker's seqId is alwaysopenSeqNum + 1becausemvcc.advanceTo(openSeqNum)sets the base, thenmvcc.begin()increments before the write.If the RS restarts again before any user data is written, the next RS computes its
openSeqNumfrom recovery, which lands atpreviousOpenSeqNum + 1. This creates two consecutive barriers that differ by exactly 1.The range between these barriers (e.g.,
[38, 39)) contains only seqId 38, theopenSeqNumitself. No WAL entry is ever written at theopenSeqNum— it's set viamvcc.advanceTo(), notmvcc.begin()— so this range is empty.Meanwhile, the
REGION_OPENmarker from the previous restart has seqIdopenSeqNum + 1, which exactly matches the new barrier.In
SerialReplicationChecker.canPush(),Arrays.binarySearch(barriers, seqId)finds this exact match. The code doesindex++to convert from 0-based to 1-based range indexing, causingisRangeFinished(barriers[index-1])to check the empty range:No WAL entry at seqId 38 exists, so
pushedSeqIdnever reaches 38. The check fails permanently, andwaitUntilCanPushspins indefinitely.This blocks the WAL reader thread entirely. Since
SerialReplicationSourceWALReaderis single-threaded and processes entries sequentially, any entries after the stuck one — including entries for unrelated regions — are head-of-line blocked.See HBASE-29499 for further details and independent reproduction by the Apache HBase community.
Fix
In
SerialReplicationChecker.canPush(): whenseqIdexactly matches a barrier at positionindex > 0andbarriers[index] - barriers[index-1] == 1, don't increment the index. This makesisRangeFinishedcheck the range before the empty gap-1 range instead of the empty range itself.The
gap == 1condition precisely identifies empty ranges:[X, X+1)isX, which is theopenSeqNumopenSeqNumnever has a WAL entry (mvcc.advanceTodoesn't write;mvcc.beginincrements past it)barrier = maxPreviousSeqId + 1and the data entry is one of those previous seqIds, sobarrier > dataSeqIdFor
gap > 1, the range contains at least theREGION_OPENmarker, which gets processed by the WAL reader and advancespushedSeqId— so the normalindex++path works correctly.Why skipping the empty range is safe
The fix skips the
isRangeFinishedcheck for gap-1 ranges. This cannot cause out-of-order replication or dropped messages because:The range is provably empty. A gap-1 range
[X, X+1)contains only seqIdX, which is theopenSeqNum. TheopenSeqNumis set viamvcc.advanceTo(X)— this advances the MVCC write point without callingmvcc.begin(), so no WAL entry is written at that seqId. The first actual WAL entry is theREGION_OPENmarker atX+1(frommvcc.begin()which increments the write point). Since the range contains zero WAL entries, there is nothing to replicate and nothing to skip.Prior data is still checked. The fix doesn't remove the ordering check — it shifts it one range back. With the un-incremented index,
isRangeFinished(barriers[index-1])verifies that all data from the range before the empty range has been fully replicated. If that prior range still has unreplicated entries,canPushreturns false and the WAL reader waits, preserving serial ordering.Entries within the same WAL are ordered. The stuck entry (the
REGION_OPENmarker atseqId == barrier) and any preceding data entries from the same RS incarnation are in the same WAL file. TheSerialReplicationSourceWALReaderprocesses WAL entries sequentially, so by the time it reaches theREGION_OPENmarker, all earlier entries from that WAL have already been read into the batch. They are shipped to the peer in order before the marker's seqId is recorded.Cross-WAL ordering is enforced by the prior range check. Data from earlier RS incarnations lives in different WALs, processed by separate claimed-queue readers. The
isRangeFinishedcheck that the fix preserves (against the range before the empty one) ensures that all data from those earlier incarnations has been replicated before the boundary entry is allowed through. The empty gap-1 range sits between the completed prior data and the current entry — skipping its check doesn't change the fact that the prior data must finish first.