Skip to content

HBASE-30341: Fix FSHLog WAL lockup on uncaught exception in publishSyncOnRingBuffer - #8611

Open
sidkhillon wants to merge 3 commits into
apache:branch-2from
HubSpot:HBASE-30341-fshlog-wal-lockup-branch-2
Open

sidkhillon wants to merge 3 commits into
apache:branch-2from
HubSpot:HBASE-30341-fshlog-wal-lockup-branch-2

Conversation

@sidkhillon

Copy link
Copy Markdown
Contributor

publishSyncOnRingBuffer claims a Disruptor sequence via RingBuffer.next() and previously called getSyncFuture() before the try whose finally publishes the sequence. An exception from getSyncFuture (seen in production as an NPE from a corrupted Guava cache in SyncFutureCache) left the claimed slot unpublished, so the consumer could never advance and the whole WAL deadlocked.

  • Move getSyncFuture inside the try so the sequence is always published.
  • Make the RingBufferEventHandler tolerate the resulting empty truck by logging and falling through (matching master's AbstractFSWAL.consume) rather than failing outstanding syncs.
  • Make SyncFutureCache.getIfPresentOrNew non-throwing: the cache is purely an allocation optimisation, so fall back to a new SyncFuture if it throws.

Adds a regression test for the WAL lockup and a test for the SyncFutureCache fallback. The FSHLog parts were incidentally fixed on master by HBASE-27231, never backported.

skhillon and others added 2 commits September 3, 2026 11:38
…ncOnRingBuffer (not yet upstream)

publishSyncOnRingBuffer claims a Disruptor sequence via RingBuffer.next() and
previously called getSyncFuture() before the try whose finally publishes the
sequence. An exception from getSyncFuture (seen in production as an NPE from a
corrupted Guava cache in SyncFutureCache) left the claimed slot unpublished, so
the consumer could never advance and the whole WAL deadlocked.

- Move getSyncFuture inside the try so the sequence is always published.
- Make the RingBufferEventHandler tolerate the resulting empty truck by logging
  and falling through (matching master's AbstractFSWAL.consume) rather than
  failing outstanding syncs.
- Make SyncFutureCache.getIfPresentOrNew non-throwing: the cache is purely an
  allocation optimisation, so fall back to a new SyncFuture if it throws.

Adds a regression test for the WAL lockup and a test for the SyncFutureCache
fallback. The FSHLog parts were incidentally fixed on master by HBASE-27231,
never backported.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…reCache test

Guava's Cache is annotated @DoNotMock, so Mockito.mock(Cache.class) fails the
errorProne compile that Yetus runs. Fake it with a ForwardingCache subclass
whose asMap() throws instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sidkhillon
sidkhillon force-pushed the HBASE-30341-fshlog-wal-lockup-branch-2 branch from c181ff4 to f4ffa5f Compare September 3, 2026 15:42
@sidkhillon
sidkhillon marked this pull request as ready for review September 3, 2026 18:44
cleanupOutstandingSyncsOnException(sequence,
new IllegalStateException("Neither append nor sync"));
// Return to keep processing.
return;

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.

Should we keep the return here? I'm pretty sure falling through isn't problematic, but it might be cleaner/easier to reason about if we simply returned here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed, it should be the same but more readable if we return

return;
// Empty truck: publishSyncOnRingBuffer claimed the sequence but threw before loading it.
// Fall through rather than failing syncs, so the empty slot is harmless.
LOG.warn("RingBufferTruck with unexpected type: {}", truck.type());

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.

Would it be better to have an explicit case for this? Maybe something like

} else if (truck.type() == RingBufferTruck.Type.EMPTY) {
    LOG.warn("Empty RingBufferTruck at sequence {}", sequence);
    return;
} else {
    cleanupOutstandingSyncsOnException(sequence,
        new IllegalStateException("Unexpected truck type: " + truck.type()));
    return;
}

That way we still cleanup on truly exceptional cases?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Sure! Just took your suggestion

Per review, use an explicit RingBufferTruck.Type.EMPTY case that logs and
returns, keeping the cleanup path for any other truck type.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@hgromer

hgromer commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

This looks good to me, I've added Chenglei and Duo as reviewers in case they'd like to comment.

// it even if this throws, else the consumer wedges.
SyncFuture syncFuture = null;
try {
syncFuture = getSyncFuture(sequence, forceSync);

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.

What exception will getSyncFuture throw? Seems the method only has memory operations...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It's all in-memory, but the memory it mutates is Guava's LocalCache, and its write-order queue (present because SyncFutureCache uses expireAfterWrite) can NPE under a race. We hit it in production on branch-2.6 (hbase.wal.provider=filesystem):

java.lang.NullPointerException: Cannot invoke "org.apache.hbase.thirdparty.com.google.common.cache.ReferenceEntry.setNextInWriteQueue(org.apache.hbase.thirdparty.com.google.common.cache.ReferenceEntry)" because "previous" is null
    at org.apache.hbase.thirdparty.com.google.common.cache.LocalCache.connectWriteOrder(LocalCache.java:1818)
    at org.apache.hbase.thirdparty.com.google.common.cache.LocalCache$WriteQueue.remove(LocalCache.java:3725)
    at org.apache.hbase.thirdparty.com.google.common.cache.LocalCache$Segment.removeValueFromChain(LocalCache.java:3249)
    at org.apache.hbase.thirdparty.com.google.common.cache.LocalCache$Segment.remove(LocalCache.java:3079)
    at org.apache.hbase.thirdparty.com.google.common.cache.LocalCache.remove(LocalCache.java:4273)
    at org.apache.hadoop.hbase.regionserver.wal.SyncFutureCache.getIfPresentOrNew(SyncFutureCache.java:61)
    at org.apache.hadoop.hbase.regionserver.wal.AbstractFSWAL.getSyncFuture(AbstractFSWAL.java:1093)
    at org.apache.hadoop.hbase.regionserver.wal.FSHLog.publishSyncOnRingBuffer(FSHLog.java:789)
    at org.apache.hadoop.hbase.regionserver.wal.FSHLog.publishSyncOnRingBuffer(FSHLog.java:784)
    at org.apache.hadoop.hbase.regionserver.wal.FSHLog.publishSyncThenBlockOnCompletion(FSHLog.java:801)
    at org.apache.hadoop.hbase.regionserver.wal.FSHLog.doSync(FSHLog.java:836)
    at org.apache.hadoop.hbase.regionserver.wal.AbstractFSWAL.sync(AbstractFSWAL.java:605)
    at org.apache.hadoop.hbase.regionserver.HRegion.doWALAppend(HRegion.java:7956)
    at org.apache.hadoop.hbase.regionserver.HRegion.batchMutate(HRegion.java:4597)
    ... RSRpcServices.multi -> RpcServer.call -> CallRunner.run -> RpcHandler.run

// Invalidate the entry if a mapping exists. We do not want it to be reused at the same time.
SyncFuture future = syncFutureCache.asMap().remove(Thread.currentThread());
return (future == null) ? new SyncFuture() : future;
} catch (RuntimeException e) {

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.

Where does the RuntimeException come from?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

There can be the same NPE from Guava's LocalCache write-queue

java.lang.NullPointerException: Cannot invoke "...ReferenceEntry.setNextInWriteQueue(...)" because "previous" is null
    at org.apache.hbase.thirdparty.com.google.common.cache.LocalCache.connectWriteOrder(LocalCache.java:1818)
    at org.apache.hbase.thirdparty.com.google.common.cache.LocalCache$WriteQueue.remove(LocalCache.java:3725)
    at org.apache.hbase.thirdparty.com.google.common.cache.LocalCache.remove(LocalCache.java:4273)
    at org.apache.hadoop.hbase.regionserver.wal.SyncFutureCache.getIfPresentOrNew(SyncFutureCache.java:61)

I'm happy to reduce this to just a NPE catch if you'd like. I kept it broad so any exception falls back to a new SyncFuture()

@sidkhillon
sidkhillon requested a review from Apache9 September 17, 2026 13:16
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.

3 participants