This is an automated email from the ASF dual-hosted git repository.
tkhurana pushed a commit to branch PHOENIX-7562-feature-new
in repository https://gitbox.apache.org/repos/asf/phoenix.git
The following commit(s) were added to refs/heads/PHOENIX-7562-feature-new by
this push:
new 7007a9804b PHOENIX-7989 Fix close-resurrection double-writer in
ReplicationLogGroup (#2601)
7007a9804b is described below
commit 7007a9804b318aa0adf56b801eb167407f2f09f9
Author: tkhurana <[email protected]>
AuthorDate: Tue Aug 18 11:15:28 2026 -0700
PHOENIX-7989 Fix close-resurrection double-writer in ReplicationLogGroup
(#2601)
close() removed the instance from INSTANCES at the top, before a
multi-second disruptor drain. A concurrent get() during that window
missed the cache and constructed a second live instance for the same
shard, producing two writers (confirmed in a captured abort log).
Remove from the cache only after teardown, in a finally so a throwing
teardown step cannot strand a closed instance (which would break the
active->standby->active re-promotion path without a JVM restart). The
removal is value-specific (remove(key, this)) so a later instance for
the same key is never clobbered. The closed flag is still set first, so
a get() during the drain returns the still-closing instance whose
append()/sync() fail fast rather than a fresh writer.
Add a cacheForTesting() seam and two regression tests:
- testConcurrentGetDuringCloseDoesNotResurrectInstance
- testCloseRemovesFromCacheEvenWhenTeardownThrows
---
.../phoenix/replication/ReplicationLogGroup.java | 65 ++++++---
.../replication/ReplicationLogGroupTest.java | 149 +++++++++++++++++++++
2 files changed, 198 insertions(+), 16 deletions(-)
diff --git
a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/ReplicationLogGroup.java
b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/ReplicationLogGroup.java
index add7d514d8..d77977b7a9 100644
---
a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/ReplicationLogGroup.java
+++
b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/ReplicationLogGroup.java
@@ -243,6 +243,17 @@ public class ReplicationLogGroup {
return serverIdentity(serverName) + "|" + haGroupName;
}
+ /**
+ * Register a pre-built (typically subclassed/spied) group in the instance
cache under its own
+ * identity, so a subsequent {@link #get} for the same (server, HA group)
returns it and
+ * {@link #close()} removes it via the normal path. Test-only seam that
avoids exposing the cache
+ * map or its key scheme.
+ */
+ @VisibleForTesting
+ static void cacheForTesting(ReplicationLogGroup group) {
+ INSTANCES.put(instanceKey(group.serverName, group.haGroupName), group);
+ }
+
protected final Configuration conf;
protected final ServerName serverName;
protected final String haGroupName;
@@ -995,15 +1006,16 @@ public class ReplicationLogGroup {
* Close the ReplicationLogGroup. Drains pending events from the Disruptor
with a bounded timeout
* (which triggers onShutdown → modeImpl.onExit → ReplicationLog.close),
then cleans up all
* resources. When the event handler has a fatal exception the drain
completes instantly because
- * each event hits the short-circuit path. The instance is removed from the
INSTANCES cache and
- * subsequent append()/sync() calls throw IOException.
+ * each event hits the short-circuit path. The closed flag is set
immediately so append()/sync()
+ * calls throw IOException, but the instance is removed from the INSTANCES
cache only after
+ * teardown completes, so a concurrent get() during the drain cannot
resurrect a second live
+ * instance.
*/
public void close() {
if (!closed.compareAndSet(false, true)) {
return;
}
LOG.info("Closing HAGroup {}", this);
- INSTANCES.remove(instanceKey(serverName, haGroupName));
// Wait for the consumer thread to enter its run loop before shutting
down. Otherwise a close()
// that races init() can halt() before the consumer starts; run()'s
opening clearAlert() then
// wipes the alert and onShutdown (writer close) never runs.
@@ -1017,20 +1029,41 @@ public class ReplicationLogGroup {
+ "proceeding with shutdown without the start gate", this, e);
Thread.currentThread().interrupt();
}
- // Remove every LOCAL state subscription registered in
subscribeToStateChanges().
- stateUnsubscribers.forEach(Runnable::run);
- stateUnsubscribers.clear();
-
try {
- disruptor.shutdown(shutdownTimeoutMs, TimeUnit.MILLISECONDS);
- } catch (com.lmax.disruptor.TimeoutException e) {
- LOG.warn("HAGroup {} shutdown timed out after {}ms, halting", this,
shutdownTimeoutMs);
- disruptor.halt();
- }
- shutdownDisruptorExecutor();
- logForwarder.stop();
- logForwarder.close();
- metrics.close();
+ // Remove every LOCAL state subscription registered in
subscribeToStateChanges(). Swallow an
+ // unchecked throw here: this runs before disruptor.shutdown() closes
the writer, so letting
+ // it
+ // propagate would skip the writer teardown and jump straight to the
finally cache removal --
+ // leaving a live consumer + writer while a racing get() mints a second
instance, the exact
+ // double-writer this close ordering exists to prevent.
+ try {
+ stateUnsubscribers.forEach(Runnable::run);
+ stateUnsubscribers.clear();
+ } catch (RuntimeException e) {
+ LOG.warn("HAGroup {} error unsubscribing from state changes during
close; "
+ + "continuing with disruptor shutdown", this, e);
+ }
+
+ try {
+ disruptor.shutdown(shutdownTimeoutMs, TimeUnit.MILLISECONDS);
+ } catch (com.lmax.disruptor.TimeoutException e) {
+ LOG.warn("HAGroup {} shutdown timed out after {}ms, halting", this,
shutdownTimeoutMs);
+ disruptor.halt();
+ }
+ shutdownDisruptorExecutor();
+ logForwarder.stop();
+ logForwarder.close();
+ metrics.close();
+ } finally {
+ // Remove from the cache only after teardown, so a concurrent get()
during the (potentially
+ // multi-second) drain finds this still-closing instance -- whose
append()/sync() fail fast on
+ // the closed flag -- instead of a cache miss that would mint a second
live instance for the
+ // same group, producing a double-writer against the same shard. In a
finally so a teardown
+ // step that throws can never strand a closed instance: on an
active->standby->active cycle
+ // without a JVM restart, get() would otherwise return the dead instance
forever. Value-
+ // specific remove so a later instance created for this key is never
clobbered.
+ INSTANCES.remove(instanceKey(serverName, haGroupName), this);
+ }
LOG.info("HAGroup {} closed", this);
}
diff --git
a/phoenix-core/src/test/java/org/apache/phoenix/replication/ReplicationLogGroupTest.java
b/phoenix-core/src/test/java/org/apache/phoenix/replication/ReplicationLogGroupTest.java
index 855fe9dccb..b59e4cb96d 100644
---
a/phoenix-core/src/test/java/org/apache/phoenix/replication/ReplicationLogGroupTest.java
+++
b/phoenix-core/src/test/java/org/apache/phoenix/replication/ReplicationLogGroupTest.java
@@ -25,6 +25,7 @@ import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
@@ -36,6 +37,7 @@ import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.timeout;
@@ -75,6 +77,7 @@ import
org.apache.phoenix.replication.log.LogFileReaderContext;
import org.apache.phoenix.replication.log.LogFileTestUtil;
import org.apache.phoenix.replication.log.LogFileWriter;
import org.apache.phoenix.replication.log.LogFileWriterContext;
+import org.apache.phoenix.replication.metrics.MetricsReplicationLogGroupSource;
import org.apache.phoenix.replication.metrics.ReplicationLogMetricValues;
import org.junit.Assume;
import org.junit.Test;
@@ -1461,6 +1464,152 @@ public class ReplicationLogGroupTest extends
ReplicationLogBaseTest {
g1_3.close();
}
+ /**
+ * A get() concurrent with a still-draining close() must NOT resurrect a
second live instance for
+ * the same group. close() sets the closed flag first but removes the
instance from the cache only
+ * after teardown, so a get() during that window returns the same (closing)
instance rather than
+ * minting a new one -- avoiding the double-writer against the same shard.
+ * <p>
+ * The window is made deterministic by blocking the last teardown step
(metrics.close()) on a
+ * latch, so close() is provably parked inside teardown -- after the closed
flag is set, before
+ * the cache removal -- while the racing get() runs.
+ */
+ @Test(timeout = 60000)
+ public void testConcurrentGetDuringCloseDoesNotResurrectInstance() throws
Exception {
+ final String haGroupId = "testResurrectionGuard";
+ final CountDownLatch closeInTeardown = new CountDownLatch(1);
+ final CountDownLatch releaseClose = new CountDownLatch(1);
+ MetricsReplicationLogGroupSource blockingMetrics =
mock(MetricsReplicationLogGroupSource.class);
+ doAnswer(inv -> {
+ closeInTeardown.countDown();
+ releaseClose.await();
+ return null;
+ }).when(blockingMetrics).close();
+
+ ReplicationLogGroup group =
+ new TestableLogGroup(conf, serverName, haGroupId, haGroupStoreManager,
useAlignedRotation()) {
+ @Override
+ protected MetricsReplicationLogGroupSource createMetricsSource() {
+ return blockingMetrics;
+ }
+ };
+ group.init();
+ ReplicationLogGroup.cacheForTesting(group);
+ try {
+ Thread closer = new Thread(group::close, "closer");
+ closer.start();
+
+ // Wait until close() is provably parked in teardown (flag set, cache
entry not yet removed).
+ assertTrue("close() should reach the teardown step",
+ closeInTeardown.await(30, TimeUnit.SECONDS));
+ assertTrue("closed flag must be set before teardown removes the cache
entry",
+ group.isClosed());
+
+ // A get() in this window must return the SAME (closing) instance, not a
fresh one.
+ ReplicationLogGroup duringClose =
+ ReplicationLogGroup.get(conf, serverName, haGroupId,
haGroupStoreManager).get();
+ assertSame("A get() during close()'s teardown must not resurrect a
second instance", group,
+ duringClose);
+
+ releaseClose.countDown();
+ closer.join(10000);
+ assertFalse("closer thread should have finished", closer.isAlive());
+
+ // After close() fully completes, the entry is gone, so a later get()
creates a fresh
+ // instance.
+ ReplicationLogGroup afterClose =
+ ReplicationLogGroup.get(conf, serverName, haGroupId,
haGroupStoreManager).get();
+ assertNotSame("A get() after close() completes must create a new
instance", group,
+ afterClose);
+ afterClose.close();
+ } finally {
+ releaseClose.countDown();
+ group.close();
+ }
+ }
+
+ /**
+ * A teardown step that throws inside close() must still remove the instance
from the cache;
+ * otherwise a dangling closed instance is stranded and an
active->standby->active cycle (which
+ * calls close() on demotion) would, on re-promotion without a JVM restart,
hand the dead instance
+ * back to get() and every append()/sync() would throw "Closed" forever. The
removal lives in a
+ * finally to guarantee this.
+ */
+ @Test
+ public void testCloseRemovesFromCacheEvenWhenTeardownThrows() throws
Exception {
+ final String haGroupId = "testTeardownThrows";
+ // metrics.close() is the last teardown step in close(); make it throw.
+ MetricsReplicationLogGroupSource throwingMetrics =
mock(MetricsReplicationLogGroupSource.class);
+ doThrow(new RuntimeException("simulated metrics.close()
failure")).when(throwingMetrics)
+ .close();
+
+ ReplicationLogGroup group =
+ new TestableLogGroup(conf, serverName, haGroupId, haGroupStoreManager,
useAlignedRotation()) {
+ @Override
+ protected MetricsReplicationLogGroupSource createMetricsSource() {
+ return throwingMetrics;
+ }
+ };
+ group.init();
+ ReplicationLogGroup.cacheForTesting(group);
+
+ try {
+ group.close();
+ fail("close() should propagate the teardown failure");
+ } catch (RuntimeException expected) {
+ assertEquals("simulated metrics.close() failure", expected.getMessage());
+ }
+
+ assertTrue("Group should still be marked closed", group.isClosed());
+ // Despite the thrown teardown, the cache entry must be gone: a fresh
get() creates a NEW,
+ // usable instance rather than returning the stranded closed one.
+ ReplicationLogGroup afterClose =
+ ReplicationLogGroup.get(conf, serverName, haGroupId,
haGroupStoreManager).get();
+ assertNotSame("A stranded closed instance must not be returned after a
throwing close()", group,
+ afterClose);
+ assertFalse("Re-created instance must be usable, not closed",
afterClose.isClosed());
+ afterClose.close();
+ }
+
+ /**
+ * An unchecked throw from the state-unsubscribe step -- which runs before
disruptor.shutdown()
+ * closes the writer -- must not skip the writer teardown. If it propagated,
close() would jump
+ * straight to the finally cache removal while the consumer thread and
writer were still alive,
+ * and a racing get() would mint a second live instance: the double-writer
this close ordering
+ * exists to prevent. The throw is swallowed so shutdown still runs and the
writer is closed.
+ */
+ @Test(timeout = 60000)
+ public void testCloseUnsubscribeThrowStillClosesWriter() throws Exception {
+ final String haGroupId = "testUnsubscribeThrows";
+ ReplicationLogGroup group =
+ new TestableLogGroup(conf, serverName, haGroupId, haGroupStoreManager,
useAlignedRotation());
+ group.init();
+ ReplicationLogGroup.cacheForTesting(group);
+
+ LogFileWriter innerWriter = group.getActiveLog().getWriter();
+ assertNotNull("Inner writer should not be null", innerWriter);
+
+ // subscribeToStateChanges() registers unsubscribers that call
unsubscribeFromTargetState;
+ // make the first one throw so the unsubscribe step throws before
disruptor.shutdown().
+ doThrow(new RuntimeException("simulated unsubscribe
failure")).when(haGroupStoreManager)
+ .unsubscribeFromTargetState(eq(haGroupId), any(HAGroupState.class),
eq(ClusterType.LOCAL),
+ any(HAGroupStateListener.class));
+
+ // close() must not propagate the unsubscribe failure.
+ group.close();
+
+ // The writer was still closed despite the thrown unsubscribe.
+ verify(innerWriter, times(1)).close();
+ assertTrue("Group should be marked closed", group.isClosed());
+
+ // Cache entry removed: a fresh get() creates a NEW instance.
+ ReplicationLogGroup afterClose =
+ ReplicationLogGroup.get(conf, serverName, haGroupId,
haGroupStoreManager).get();
+ assertNotSame("close() must remove the cache entry even when unsubscribe
throws", group,
+ afterClose);
+ afterClose.close();
+ }
+
@Test
public void testInFlightAppendsReplayAfterModeSwitch() throws Exception {
final String tableName = "TESTTBL";