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 73037634d9 PHOENIX-7978 Harden replay/forward poll scheduling against
wall-clock… (#2598)
73037634d9 is described below
commit 73037634d99cf75ff9436f66b686b9978a880f3c
Author: Himanshu Gwalani <[email protected]>
AuthorDate: Thu Aug 20 04:39:00 2026 +0530
PHOENIX-7978 Harden replay/forward poll scheduling against wall-clock…
(#2598)
* PHOENIX-7978 Harden replay/forward poll scheduling against wall-clock and
scheduler drift
The round-eligibility gate for replication replay/forward becomes eligible
when
currentTime - lastRoundEndTimestamp >= roundTimeMills + bufferMillis,
evaluated on the
wall clock. PHOENIX-7813 aligned the scheduler wake to that grid, but the
wake is fired on
the monotonic clock (System.nanoTime) and was computed with zero margin, so
small
nanoTime-vs-wall-clock drift could tip a wake just below the boundary and
the region server
would lose a full (~60s) cycle. Most damaging during planned failover.
Two fixes, both in the shared base class ReplicationLogDiscovery (inherited
by
ReplicationLogDiscoveryReplay and ReplicationLogDiscoveryForwarder):
- Epsilon margin on the aligned wake instant: anchor the delay at
bufferMillis + epsilon
(via Math.floorMod) so the wake lands just after the eligibility boundary
rather than
exactly on it. New config
phoenix.replication.discovery.aligned.delay.epsilon.millis
(default 500).
- Per-cycle re-anchor: replace scheduleAtFixedRate with a self-rescheduling
one-shot chain
that recomputes the aligned delay every cycle, re-pinning each wake to
the wall-clock grid
instead of letting a one-time misalignment persist. Uses a
ScheduledThreadPoolExecutor with
setExecuteExistingDelayedTasksAfterShutdownPolicy(false) so stop() is
deterministic.
Each replay cycle is bound to the scheduler generation it was launched on
and reschedules
only if isRunning && owner == scheduler, preventing a stale in-flight cycle
from grafting a
second chain onto a new scheduler after a stop()->start() restart (which
would otherwise
double the effective poll rate).
Testing: ReplicationLogDiscoveryTest 48/48 (incl. stale-generation,
start()-rollback, and
replay/reschedule error-swallow regressions);
ReplicationLogDiscoveryReplayTestIT 48/48;
StoreAndForwardFailoverIT 1/1; spotless:check green on phoenix-core and
phoenix-core-server.
* PHOENIX-7978 Harden replay poll rescheduling, error handling, and epsilon
validation
- Self-heal on mid-chain reschedule failure: if scheduleNextReplay() throws
after a healthy start, mark the discovery not-running and shut the
executor
down so the ReplicationLogReplayService supervisor rebuilds it, instead of
leaving isRunning=true with an idle executor that wedges polling until an
RS restart.
- Guard against re-selecting the just-processed grid point: track
lastAlignedTargetMillis and advance one round when a wake lands on/before
it;
derive the delay and the absolute target from a single clock read.
- Validate the aligned-delay epsilon: clamp non-numeric / out-of-range
[0, roundTimeMills) values to the default with a one-shot WARN rather than
throwing (which would wedge the group in the supervisor-retry loop).
- Split the replay() catch: Exception logs and continues the chain; Error is
logged (it would otherwise vanish into the discarded Future), tears the
chain
down, and is rethrown; the reschedule moved out of the finally block.
- Downgrade the per-cycle "Scheduling next replay" INFO line to DEBUG.
---------
Co-authored-by: Himanshu Gwalani <[email protected]>
---
.../replication/ReplicationLogDiscovery.java | 251 +++++++++++++--
.../ReplicationLogDiscoveryReplayTestIT.java | 29 --
.../replication/ReplicationLogDiscoveryTest.java | 353 +++++++++++++++++++--
3 files changed, 550 insertions(+), 83 deletions(-)
diff --git
a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/ReplicationLogDiscovery.java
b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/ReplicationLogDiscovery.java
index 638d95280f..21e1117b87 100644
---
a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/ReplicationLogDiscovery.java
+++
b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/ReplicationLogDiscovery.java
@@ -22,10 +22,13 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
-import java.util.concurrent.Executors;
+import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import javax.annotation.concurrent.GuardedBy;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.hbase.util.EnvironmentEdgeManager;
@@ -99,15 +102,53 @@ public abstract class ReplicationLogDiscovery {
public static final int DEFAULT_IN_PROGRESS_FILE_MIN_AGE_SECONDS = 60;
+ /**
+ * Configuration key for the epsilon margin (milliseconds) added to the
aligned scheduler wake
+ * instant. The replay scheduler fires on a {@code System.nanoTime()} grid
while the round
+ * eligibility gate reads the wall clock ({@code
EnvironmentEdgeManager.currentTime()}). Aligning
+ * exactly to the eligibility instant lets a few ms of
nanoTime-vs-wall-clock skew tip a wake-up
+ * just below the boundary, which costs a full poll cycle. Waking epsilon
after the eligibility
+ * instant absorbs that skew.
+ */
+ public static final String REPLICATION_ALIGNED_DELAY_EPSILON_MILLIS_KEY =
+ "phoenix.replication.discovery.aligned.delay.epsilon.millis";
+
+ /**
+ * Default epsilon margin in milliseconds. 500ms comfortably exceeds the
small (single- to
+ * low-tens-of-milliseconds) nanoTime-vs-wall-clock skew this margin
absorbs, yet stays under 1%
+ * of a 60s round, so best-case first-pickup latency is essentially
unchanged. The margin is
+ * absolute (it offsets clock skew, which does not scale with round
duration), so for atypically
+ * short custom round durations operators may lower it via
+ * {@link #REPLICATION_ALIGNED_DELAY_EPSILON_MILLIS_KEY} to keep epsilon a
small fraction of the
+ * round.
+ */
+ public static final long DEFAULT_ALIGNED_DELAY_EPSILON_MILLIS = 500L;
+
protected final Configuration conf;
protected final String haGroupName;
protected final ReplicationLogTracker replicationLogTracker;
+ @GuardedBy("this")
protected ScheduledExecutorService scheduler;
protected volatile boolean isRunning = false;
protected volatile ReplicationRound lastRoundProcessed;
protected MetricsReplicationLogDiscovery metrics;
protected long roundTimeMills;
protected long bufferMillis;
+ /**
+ * Wall-clock instant (ms) of the round-eligibility grid point the most
recently scheduled cycle
+ * targets, or {@link Long#MIN_VALUE} before the first schedule of the
current generation. Used by
+ * {@link #scheduleNextReplay()} to guarantee each reschedule advances to a
grid point strictly
+ * after the previous one, so a wake landing exactly on (delay 0) or
slightly before (nanoTime
+ * skew) the boundary it just processed does not re-select the same grid
point and run a redundant
+ * cycle. Reset in {@link #start()} so a restarted generation re-anchors
from scratch.
+ */
+ @GuardedBy("this")
+ protected long lastAlignedTargetMillis = Long.MIN_VALUE;
+ /**
+ * One-shot guard so a misconfigured {@link
#REPLICATION_ALIGNED_DELAY_EPSILON_MILLIS_KEY} logs its
+ * fall-back WARN once rather than every scheduling cycle (the epsilon is
read live per cycle).
+ */
+ private final AtomicBoolean warnedInvalidEpsilon = new AtomicBoolean(false);
public ReplicationLogDiscovery(final ReplicationLogTracker
replicationLogTracker) {
this.replicationLogTracker = replicationLogTracker;
@@ -132,9 +173,10 @@ public abstract class ReplicationLogDiscovery {
}
/**
- * Starts the replication log discovery service by initializing the
scheduler and scheduling
- * periodic replay operations. Creates a thread pool with configured thread
count and schedules
- * replay tasks at fixed intervals.
+ * Starts the replication log discovery service. Creates a scheduler with
the configured thread
+ * count and launches a self-rescheduling one-shot replay chain (see
+ * {@link #scheduleNextReplay()}) that re-anchors each replay to the aligned
round-eligibility
+ * grid every cycle, rather than firing at a fixed period.
* @throws IOException if there's an error during initialization
*/
public void start() throws IOException {
@@ -143,22 +185,29 @@ public abstract class ReplicationLogDiscovery {
LOG.warn("ReplicationLogDiscovery is already running for haGroup: {}",
haGroupName);
return;
}
- // Initialize and schedule the executors
- scheduler = Executors.newScheduledThreadPool(getExecutorThreadCount(),
- new
ThreadFactoryBuilder().setNameFormat(getExecutorThreadNameFormat()).build());
- long initialDelayMs = computeAlignedInitialDelay();
- long replayIntervalMs = getReplayIntervalMillis();
- LOG.info("Scheduling replay for haGroup: {} with initialDelay={}ms,
interval={}ms",
- haGroupName, initialDelayMs, replayIntervalMs);
- scheduler.scheduleAtFixedRate(() -> {
- try {
- replay();
- } catch (Exception e) {
- LOG.error("Error during replay", e);
- }
- }, initialDelayMs, replayIntervalMs, TimeUnit.MILLISECONDS);
-
+ // Single-shot rescheduling chain (see scheduleNextReplay). Discard any
queued
+ // (not-yet-started) delayed task on shutdown so stop() is deterministic
and no replay
+ // fires after we intend to stop.
+ ScheduledThreadPoolExecutor executor =
+ new ScheduledThreadPoolExecutor(getExecutorThreadCount(),
+ new
ThreadFactoryBuilder().setNameFormat(getExecutorThreadNameFormat()).build());
+ executor.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
+ scheduler = executor;
isRunning = true;
+ // Re-anchor the aligned-target guard so this fresh generation schedules
from the next grid
+ // point rather than being constrained by a target left over from a
previous start()/stop().
+ lastAlignedTargetMillis = Long.MIN_VALUE;
+ try {
+ scheduleNextReplay();
+ } catch (RuntimeException | Error e) {
+ // Scheduling the first cycle failed (e.g. a bad epsilon config
value). Roll back so we
+ // don't leave a live idle executor with isRunning==true (which
reports healthy while
+ // nothing polls) and so a later start() can retry cleanly.
+ isRunning = false;
+ scheduler = null;
+ executor.shutdownNow();
+ throw e;
+ }
LOG.info("ReplicationLogDiscovery started for haGroup: {}", haGroupName);
}
}
@@ -196,6 +245,101 @@ public abstract class ReplicationLogDiscovery {
LOG.info("ReplicationLogDiscovery stopped for haGroup: {}", haGroupName);
}
+ /**
+ * Schedules the next replay as a single-shot task whose delay is recomputed
each cycle via
+ * {@link #computeAlignedInitialDelay()}. Recomputing every cycle re-pins
each wake-up to the
+ * wall-clock round-eligibility grid, correcting scheduler/wall-clock drift
instead of letting a
+ * one-time misalignment persist for the life of the process (which
fixed-rate scheduling does).
+ * All region servers still converge on the same grid, preserving
PHOENIX-7813's shared wake-up.
+ */
+ @GuardedBy("this")
+ protected void scheduleNextReplay() {
+ long now = EnvironmentEdgeManager.currentTime();
+ long delayMs = computeAlignedInitialDelay(now);
+ // Guarantee the next fire targets a grid point strictly after the one the
previous cycle
+ // targeted. A wake landing exactly on (delayMs == 0) or slightly before
(nanoTime skew, small
+ // positive delay) the boundary we just processed would otherwise
re-select the same grid point
+ // and run a redundant cycle before the clock advances past it; bump one
full round in that
+ // case. targetMillis is derived from the same clock read as delayMs, so
the two never skew.
+ long targetMillis = now + delayMs;
+ if (targetMillis <= lastAlignedTargetMillis) {
+ delayMs += roundTimeMills;
+ targetMillis += roundTimeMills;
+ }
+ lastAlignedTargetMillis = targetMillis;
+ // Bind this cycle to the current scheduler generation. A stop()->start()
restart
+ // swaps in a new scheduler; a cycle launched on the old one must
reschedule onto
+ // that same (now shut-down) scheduler, not the new one.
+ ScheduledExecutorService owner = scheduler;
+ LOG.debug("Scheduling next replay for haGroup: {} in {}ms", haGroupName,
delayMs);
+ owner.schedule(() -> runReplayCycle(owner), delayMs,
TimeUnit.MILLISECONDS);
+ }
+
+ /**
+ * Runs one replay pass and, unless the service has been stopped, schedules
the next aligned pass.
+ * A recoverable {@link Exception} from {@link #replay()} is logged and the
chain continues, so a
+ * single failed round does not break it. A fatal {@link Error} (OOM, stack
overflow, linkage) is
+ * logged, tears the chain down (marking the service not-running so the
supervisor can rebuild it),
+ * and is rethrown -- never rescheduled onto a potentially corrupted JVM.
+ * The reschedule is guarded by the same lock stop() uses; if stop() shut
the scheduler down
+ * first, {@link #isRunning} is false and we do not reschedule (and a
concurrent shutdown that
+ * rejects the submission is caught and treated as "stop the chain").
+ * @param owner the scheduler this cycle was launched on. If a
stop()->start() restart has since
+ * swapped in a new scheduler, {@code owner} no longer equals
{@link #scheduler} and
+ * this stale cycle must not reschedule onto the new generation
(which would create a
+ * second concurrent chain and double the effective poll rate).
+ */
+ protected void runReplayCycle(ScheduledExecutorService owner) {
+ try {
+ replay();
+ } catch (Exception e) {
+ // Recoverable failure: log and fall through to reschedule so a single
failed round does not
+ // break the self-rescheduling chain.
+ LOG.error("Error during replay for haGroup: {}", haGroupName, e);
+ } catch (Error e) {
+ // Fatal JVM condition (OutOfMemoryError, StackOverflowError, linkage
failure). Do not swallow
+ // it and do not reschedule another replay on a potentially corrupted
JVM. Log it first --
+ // otherwise the executor's discarded Future would hide it entirely --
then tear the chain
+ // down exactly as a broken reschedule does below (mark the service
not-running and shut this
+ // executor down so the ReplicationLogReplayService supervisor can
rebuild a fresh one) and
+ // rethrow. Guarded on owner == scheduler so a stale cycle does not tear
down a newer
+ // generation's scheduler.
+ LOG.error("Fatal error during replay for haGroup: {}; replay polling
stopped, will be "
+ + "restarted by the replay service supervisor", haGroupName, e);
+ synchronized (this) {
+ if (owner == scheduler) {
+ isRunning = false;
+ owner.shutdown();
+ }
+ }
+ throw e;
+ }
+ // Reached only on normal completion or a caught (recoverable) Exception
-- never after a fatal
+ // Error, which propagates out above without rescheduling.
+ synchronized (this) {
+ if (isRunning && owner == scheduler) {
+ try {
+ scheduleNextReplay();
+ } catch (RejectedExecutionException ree) {
+ // benign: stop() shut the scheduler down between the guard check
and submit
+ LOG.debug("Scheduler shutting down, skipping reschedule for haGroup:
{}", haGroupName);
+ } catch (Throwable t) {
+ // scheduleNextReplay() failed unexpectedly (something other than
the benign
+ // RejectedExecutionException handled above). The poll chain is
already broken, so mark
+ // the service not-running and shut down this now-idle executor. The
+ // ReplicationLogReplayService supervisor re-invokes start() on its
fixed-rate cadence
+ // and, seeing isRunning==false, rebuilds a fresh executor --
self-healing instead of
+ // silently wedging with isRunning==true (which would keep
isRunning() reporting healthy
+ // while nothing polls, and make every later start() no-op as
"already running").
+ LOG.error("Failed to schedule next replay for haGroup: {}; replay
polling stopped, "
+ + "will be restarted by the replay service supervisor",
haGroupName, t);
+ isRunning = false;
+ owner.shutdown();
+ }
+ }
+ }
+ }
+
/**
* Executes a replay operation for the next set of replication rounds. This
method continuously
* retrieves and processes rounds using getNextRoundToProcess() until: - No
more rounds are ready
@@ -484,15 +628,6 @@ public abstract class ReplicationLogDiscovery {
return DEFAULT_EXECUTOR_THREAD_NAME_FORMAT;
}
- /**
- * Returns the replay interval in milliseconds. Subclasses can override this
method to provide
- * custom intervals. Defaults to the round duration.
- * @return The replay interval in milliseconds.
- */
- public long getReplayIntervalMillis() {
- return roundTimeMills;
- }
-
/**
* Returns the shutdown timeout in seconds. Subclasses can override this
method to provide custom
* timeout values.
@@ -524,13 +659,27 @@ public abstract class ReplicationLogDiscovery {
* Computes initial delay to align the scheduler to round-eligible
boundaries so all RS wake up at
* the same wall-clock moment. A round becomes eligible when currentTime >=
roundEndTime +
* bufferMillis, and rounds repeat every roundTimeMills. This gives a
universal grid of eligible
- * ticks at bufferMillis, bufferMillis + roundTimeMills, bufferMillis +
2*roundTimeMills, etc.
- * from epoch. All RS compute the same grid regardless of when start() is
called.
+ * ticks at bufferMillis + epsilon, bufferMillis + epsilon + roundTimeMills,
bufferMillis +
+ * epsilon + 2*roundTimeMills, etc. from epoch. All RS compute the same grid
regardless of when
+ * start() is called.
* @return the initial delay in milliseconds until the next round-eligible
tick
*/
protected long computeAlignedInitialDelay() {
- long now = EnvironmentEdgeManager.currentTime();
- long elapsed = (now - bufferMillis) % roundTimeMills;
+ return computeAlignedInitialDelay(EnvironmentEdgeManager.currentTime());
+ }
+
+ /**
+ * Overload of {@link #computeAlignedInitialDelay()} that aligns against a
caller-supplied
+ * {@code now}, so a caller can derive both the delay and the absolute
target grid instant
+ * ({@code now + delay}) from a single clock read without the two skewing
across reads.
+ * @param now the reference wall-clock instant in milliseconds
+ * @return the delay in milliseconds until the next round-eligible tick at
or after {@code now}
+ */
+ protected long computeAlignedInitialDelay(long now) {
+ // Anchor epsilon past the eligibility instant (bufferMillis past a round
line) so that a
+ // scheduler firing slightly early (nanoTime skew) still clears the
wall-clock gate.
+ long anchor = bufferMillis + getAlignedDelayEpsilonMillis();
+ long elapsed = Math.floorMod(now - anchor, roundTimeMills);
return (elapsed == 0) ? 0 : roundTimeMills - elapsed;
}
@@ -544,6 +693,44 @@ public abstract class ReplicationLogDiscovery {
DEFAULT_IN_PROGRESS_FILE_MIN_AGE_SECONDS);
}
+ /**
+ * Returns the epsilon margin (milliseconds) added to the aligned scheduler
wake instant. Guards
+ * against a misconfigured value: a non-numeric string, a negative value
(which would move wakes
+ * <em>before</em> eligibility and reintroduce missed rounds), or a value
{@code >= roundTimeMills}
+ * (which wraps through {@link Math#floorMod} and no longer represents the
documented "epsilon
+ * after the boundary") all fall back to {@link
#DEFAULT_ALIGNED_DELAY_EPSILON_MILLIS} with a
+ * one-shot WARN. Riding over the misconfiguration keeps replay polling on a
safe default rather
+ * than wedging the group, while the WARN makes the bad config visible.
+ * @return the epsilon margin in milliseconds, always within {@code [0,
roundTimeMills)}.
+ */
+ public long getAlignedDelayEpsilonMillis() {
+ long epsilon;
+ try {
+ epsilon = conf.getLong(REPLICATION_ALIGNED_DELAY_EPSILON_MILLIS_KEY,
+ DEFAULT_ALIGNED_DELAY_EPSILON_MILLIS);
+ } catch (NumberFormatException e) {
+ // Hadoop's getLong throws (rather than returning the default) when the
key is present but
+ // not parseable as a number.
+ warnInvalidEpsilon("non-numeric value \""
+ + conf.get(REPLICATION_ALIGNED_DELAY_EPSILON_MILLIS_KEY) + "\"");
+ return DEFAULT_ALIGNED_DELAY_EPSILON_MILLIS;
+ }
+ if (epsilon < 0 || epsilon >= roundTimeMills) {
+ warnInvalidEpsilon(epsilon + "ms");
+ return DEFAULT_ALIGNED_DELAY_EPSILON_MILLIS;
+ }
+ return epsilon;
+ }
+
+ private void warnInvalidEpsilon(String badValueDescription) {
+ if (warnedInvalidEpsilon.compareAndSet(false, true)) {
+ LOG.warn(
+ "Invalid {} ({}) for haGroup: {}; must be within [0, {}). Falling back
to default {}ms.",
+ REPLICATION_ALIGNED_DELAY_EPSILON_MILLIS_KEY, badValueDescription,
haGroupName,
+ roundTimeMills, DEFAULT_ALIGNED_DELAY_EPSILON_MILLIS);
+ }
+ }
+
public ReplicationLogTracker getReplicationLogFileTracker() {
return this.replicationLogTracker;
}
diff --git
a/phoenix-core/src/it/java/org/apache/phoenix/replication/reader/ReplicationLogDiscoveryReplayTestIT.java
b/phoenix-core/src/it/java/org/apache/phoenix/replication/reader/ReplicationLogDiscoveryReplayTestIT.java
index 98209280f4..e65525d284 100644
---
a/phoenix-core/src/it/java/org/apache/phoenix/replication/reader/ReplicationLogDiscoveryReplayTestIT.java
+++
b/phoenix-core/src/it/java/org/apache/phoenix/replication/reader/ReplicationLogDiscoveryReplayTestIT.java
@@ -116,35 +116,6 @@ public class ReplicationLogDiscoveryReplayTestIT extends
HABaseIT {
"Phoenix-ReplicationLogDiscoveryReplay-%d", result);
}
- /**
- * Tests that replay interval always matches the configured round duration.
- */
- @Test
- public void testGetReplayIntervalMillis() throws IOException {
- // Test with default round duration
- TestableReplicationLogTracker fileTracker =
- createReplicationLogTracker(conf1, haGroupName, rootFs, rootUri);
- ReplicationLogDiscoveryReplay discovery = new
ReplicationLogDiscoveryReplay(fileTracker);
- long expectedRoundMillis =
-
fileTracker.getReplicationShardDirectoryManager().getReplicationRoundDurationSeconds()
- * 1000L;
- assertEquals("Replay interval should match round duration",
expectedRoundMillis,
- discovery.getReplayIntervalMillis());
-
- // Test with custom round duration
-
conf1.setInt(ReplicationShardDirectoryManager.PHOENIX_REPLICATION_ROUND_DURATION_SECONDS_KEY,
- 120);
- try {
- TestableReplicationLogTracker fileTracker2 =
- createReplicationLogTracker(conf1, haGroupName, rootFs, rootUri);
- ReplicationLogDiscoveryReplay discovery2 = new
ReplicationLogDiscoveryReplay(fileTracker2);
- assertEquals("Replay interval should match custom round duration",
120_000L,
- discovery2.getReplayIntervalMillis());
- } finally {
-
conf1.unset(ReplicationShardDirectoryManager.PHOENIX_REPLICATION_ROUND_DURATION_SECONDS_KEY);
- }
- }
-
/**
* Tests the shutdown timeout configuration with default and custom values.
*/
diff --git
a/phoenix-core/src/test/java/org/apache/phoenix/replication/ReplicationLogDiscoveryTest.java
b/phoenix-core/src/test/java/org/apache/phoenix/replication/ReplicationLogDiscoveryTest.java
index 4af410e56e..db9ae36fc2 100644
---
a/phoenix-core/src/test/java/org/apache/phoenix/replication/ReplicationLogDiscoveryTest.java
+++
b/phoenix-core/src/test/java/org/apache/phoenix/replication/ReplicationLogDiscoveryTest.java
@@ -23,6 +23,7 @@ import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
import static org.mockito.Mockito.*;
import java.io.IOException;
@@ -139,10 +140,6 @@ public class ReplicationLogDiscoveryTest {
assertTrue("Thread name should contain ReplicationLogDiscovery",
threadName.contains("ReplicationLogDiscovery"));
- // Verify replay interval
- long replayInterval = discovery.getReplayIntervalMillis();
- assertEquals("Replay interval should be 60000 milliseconds", 60_000L,
replayInterval);
-
// 6. Ensure starting again does not create a new scheduler (and also
should not throw any
// exception)
ScheduledExecutorService originalScheduler = discovery.getScheduler();
@@ -161,15 +158,208 @@ public class ReplicationLogDiscoveryTest {
assertFalse("Discovery should not be running after stop",
discovery.isRunning());
}
+ @Test
+ public void testStartRollsBackWhenSchedulingFails() throws IOException {
+ // setUp() stubs isRunning() to always return true; read the real field
for this lifecycle test.
+ Mockito.doCallRealMethod().when(discovery).isRunning();
+ doThrow(new NumberFormatException("bad
epsilon")).when(discovery).scheduleNextReplay();
+ NumberFormatException thrown = null;
+ try {
+ discovery.start();
+ } catch (NumberFormatException e) {
+ thrown = e;
+ }
+ assertNotNull("start() should propagate the scheduling failure", thrown);
+ assertFalse("start() must roll back isRunning on failure",
discovery.isRunning());
+ assertNull("start() must roll back the scheduler on failure",
discovery.getScheduler());
+ }
+
+ @Test
+ public void testRunReplayCycleReschedulesWhenRunning() throws IOException {
+ ScheduledExecutorService owner = mock(ScheduledExecutorService.class);
+ discovery.setScheduler(owner);
+ discovery.setRunning(true);
+ doNothing().when(discovery).replay();
+ doNothing().when(discovery).scheduleNextReplay();
+
+ discovery.runReplayCycle(owner);
+
+ verify(discovery, times(1)).replay();
+ verify(discovery, times(1)).scheduleNextReplay();
+ }
+
+ @Test
+ public void testRunReplayCycleDoesNotRescheduleWhenStopped() throws
IOException {
+ ScheduledExecutorService owner = mock(ScheduledExecutorService.class);
+ discovery.setScheduler(owner);
+ discovery.setRunning(false);
+ doNothing().when(discovery).replay();
+ doNothing().when(discovery).scheduleNextReplay();
+
+ discovery.runReplayCycle(owner);
+
+ verify(discovery, times(1)).replay();
+ verify(discovery, never()).scheduleNextReplay();
+ }
+
+ @Test
+ public void testRunReplayCycleReschedulesAfterReplayThrows() throws
IOException {
+ ScheduledExecutorService owner = mock(ScheduledExecutorService.class);
+ discovery.setScheduler(owner);
+ discovery.setRunning(true);
+ doThrow(new IOException("boom")).when(discovery).replay();
+ doNothing().when(discovery).scheduleNextReplay();
+
+ discovery.runReplayCycle(owner); // must not propagate the exception
+
+ verify(discovery, times(1)).replay();
+ verify(discovery, times(1)).scheduleNextReplay();
+ }
+
+ @Test
+ public void testRunReplayCyclePropagatesErrorAndStopsChain() throws
IOException {
+ // setUp() stubs isRunning() to always return true; read the real field
for this lifecycle test.
+ Mockito.doCallRealMethod().when(discovery).isRunning();
+ ScheduledExecutorService owner = mock(ScheduledExecutorService.class);
+ discovery.setScheduler(owner);
+ discovery.setRunning(true);
+ // A fatal Error (OOME/StackOverflow/linkage) from replay() must NOT be
swallowed and must NOT
+ // reschedule another replay on a possibly-corrupted JVM. It is logged and
rethrown, and the
+ // chain is torn down (isRunning=false + executor shutdown) so the
supervisor rebuilds it.
+ Error boom = new Error("boom");
+ doThrow(boom).when(discovery).replay();
+ doNothing().when(discovery).scheduleNextReplay();
+
+ try {
+ discovery.runReplayCycle(owner);
+ fail("fatal Error from replay() must propagate out of runReplayCycle");
+ } catch (Error e) {
+ assertSame(boom, e);
+ }
+
+ verify(discovery, times(1)).replay();
+ verify(discovery, never()).scheduleNextReplay();
+ assertFalse("fatal Error must mark the service not-running for supervisor
restart",
+ discovery.isRunning());
+ verify(owner, times(1)).shutdown();
+ }
+
+ @Test
+ public void testRunReplayCycleDoesNotPropagateWhenRescheduleThrows() throws
IOException {
+ // setUp() stubs isRunning() to always return true; read the real field
for this lifecycle test.
+ Mockito.doCallRealMethod().when(discovery).isRunning();
+ ScheduledExecutorService owner = mock(ScheduledExecutorService.class);
+ discovery.setScheduler(owner);
+ discovery.setRunning(true);
+ doNothing().when(discovery).replay();
+ // An unexpected failure in the reschedule path (e.g.
computeAlignedInitialDelay -> getLong
+ // throwing NumberFormatException) must be caught, not swallowed by the
executor into the
+ // discarded Future. The chain is already broken, so runReplayCycle marks
the service
+ // not-running and shuts this executor down, letting the
ReplicationLogReplayService supervisor
+ // restart it on its next tick -- instead of silently wedging with
isRunning==true.
+ doThrow(new NumberFormatException("bad
epsilon")).when(discovery).scheduleNextReplay();
+
+ discovery.runReplayCycle(owner); // must not propagate the reschedule
failure
+
+ verify(discovery, times(1)).replay();
+ verify(discovery, times(1)).scheduleNextReplay();
+ assertFalse("reschedule failure must mark the service not-running for
supervisor restart",
+ discovery.isRunning());
+ verify(owner, times(1)).shutdown();
+ }
+
+ @Test
+ public void testRunReplayCycleDoesNotRescheduleForStaleSchedulerGeneration()
throws IOException {
+ ScheduledExecutorService staleOwner = mock(ScheduledExecutorService.class);
+ ScheduledExecutorService currentScheduler =
mock(ScheduledExecutorService.class);
+ discovery.setScheduler(currentScheduler);
+ discovery.setRunning(true);
+ doNothing().when(discovery).replay();
+ doNothing().when(discovery).scheduleNextReplay();
+
+ discovery.runReplayCycle(staleOwner); // stale generation: owner !=
current scheduler
+
+ verify(discovery, times(1)).replay();
+ verify(discovery, never()).scheduleNextReplay();
+ }
+
+ @Test
+ public void testStaleCycleDoesNotRescheduleAfterRealRestart() throws
IOException {
+ // Real start()/stop()/start() establishes the generation state (not
hand-set fields).
+ // Far-future aligned delay => the auto-scheduled cycle never fires during
the test.
+
doReturn(TimeUnit.HOURS.toMillis(1)).when(discovery).computeAlignedInitialDelay(anyLong());
+ doNothing().when(discovery).replay();
+
+ discovery.start(); // gen-1
+ ScheduledExecutorService s1 = discovery.getScheduler();
+ discovery.stop(); // shuts down s1
+ discovery.start(); // gen-2, isRunning flipped back true
+ ScheduledExecutorService s2 = discovery.getScheduler();
+ assertTrue("restart must create a new scheduler generation", s1 != s2);
+ assertTrue("old scheduler must be shut down", s1.isShutdown());
+ assertTrue("discovery must be running after restart",
discovery.isRunning());
+
+ // gen-1's in-flight cycle reaches its finally AFTER the restart:
isRunning is true again,
+ // but owner(s1) != scheduler(s2), so it must NOT graft a second chain
onto s2.
+ discovery.runReplayCycle(s1);
+
+ verify(discovery, times(2)).scheduleNextReplay(); // one per start();
stale cycle adds none
+ discovery.stop();
+ }
+
+ @Test
+ public void testScheduleNextReplayUsesAlignedDelay() {
+ ScheduledExecutorService mockScheduler =
mock(ScheduledExecutorService.class);
+ discovery.setScheduler(mockScheduler);
+ long knownDelay = 1_234L;
+ doReturn(knownDelay).when(discovery).computeAlignedInitialDelay(anyLong());
+
+ discovery.scheduleNextReplay();
+
+ verify(mockScheduler, times(1)).schedule(any(Runnable.class),
eq(knownDelay),
+ eq(TimeUnit.MILLISECONDS));
+ }
+
+ @Test
+ public void testScheduleNextReplayAdvancesPastLastTargetedGrid() {
+ // A wake that lands exactly on the boundary it just processed (aligned
delay == 0) must not
+ // re-select the same grid point: the next reschedule at that same instant
advances a full
+ // round instead of scheduling 0 again (which would run a redundant, no-op
cycle immediately).
+ ScheduledExecutorService mockScheduler =
mock(ScheduledExecutorService.class);
+ discovery.setScheduler(mockScheduler);
+ long roundTimeMs = discovery.roundTimeMills;
+ // Pin wall-clock exactly on an epsilon-shifted grid tick so
computeAlignedInitialDelay == 0.
+ long onTick = 5 * roundTimeMs + discovery.bufferMillis +
discovery.getAlignedDelayEpsilonMillis();
+ AtomicLong mockTime = new AtomicLong(onTick);
+ EnvironmentEdgeManager.injectEdge(new EnvironmentEdge() {
+ @Override
+ public long currentTime() {
+ return mockTime.get();
+ }
+ });
+ try {
+ discovery.scheduleNextReplay(); // on tick -> delay 0, targets grid
point `onTick`
+ discovery.scheduleNextReplay(); // same instant -> must bump one full
round, not schedule 0
+
+ verify(mockScheduler, times(1)).schedule(any(Runnable.class), eq(0L),
+ eq(TimeUnit.MILLISECONDS));
+ verify(mockScheduler, times(1)).schedule(any(Runnable.class),
eq(roundTimeMs),
+ eq(TimeUnit.MILLISECONDS));
+ } finally {
+ EnvironmentEdgeManager.reset();
+ }
+ }
+
@Test
public void testComputeAlignedInitialDelay() {
long roundTimeMs = discovery.roundTimeMills;
long bufferMs = discovery.bufferMillis;
+ long epsilon = discovery.getAlignedDelayEpsilonMillis();
// RS initialize at different times within the same round window.
// All should align to the same next tick.
- // With roundTimeMs=60000 and bufferMs=9000, ticks are at 9000, 69000,
129000, ...
- // Place all 3 RS between tick 69000 and tick 129000 so they all target
129000.
+ // With roundTimeMs=60000 and bufferMs=9000, ticks are at 9000+epsilon,
69000+epsilon, ...
+ // Place all 3 RS between tick 69000+epsilon and 129000+epsilon so they
target 129000+epsilon.
AtomicLong mockTime = new AtomicLong();
EnvironmentEdgeManager.injectEdge(new EnvironmentEdge() {
@Override
@@ -194,10 +384,10 @@ public class ReplicationLogDiscoveryTest {
long delay3 = discovery.computeAlignedInitialDelay();
long tick3 = mockTime.get() + delay3;
- // All should align to the same tick (129000)
+ // All should align to the same tick (129000 + epsilon)
assertEquals("RS-1 and RS-2 should align to the same tick", tick1,
tick2);
assertEquals("RS-2 and RS-3 should align to the same tick", tick2,
tick3);
- assertEquals("All should target tick at 129000", 129_000L, tick1);
+ assertEquals("All should target the epsilon-shifted tick", 129_000L +
epsilon, tick1);
// Delay should always be > 0 and <= roundTimeMs
assertTrue("Delay should be positive", delay1 > 0);
@@ -205,9 +395,37 @@ public class ReplicationLogDiscoveryTest {
assertTrue("Delay should be positive", delay2 > 0);
assertTrue("Delay should not exceed round time", delay2 <= roundTimeMs);
- // The aligned tick should be at a multiple of roundTimeMs offset by
bufferMs
- assertEquals("Tick should be aligned to round-eligible boundary", 0,
- (tick1 - bufferMs) % roundTimeMs);
+ // The aligned tick should be at a multiple of roundTimeMs offset by
bufferMs + epsilon
+ assertEquals("Tick should be aligned to the epsilon-shifted grid", 0,
+ (tick1 - bufferMs - epsilon) % roundTimeMs);
+ } finally {
+ EnvironmentEdgeManager.reset();
+ }
+ }
+
+ @Test
+ public void testComputeAlignedInitialDelayWhenNowBeforeAnchor() {
+ // Regression lock-in for Math.floorMod: when now < the first anchor
(bufferMillis + epsilon),
+ // now - anchor is negative; floorMod keeps elapsed in [0, roundTimeMills)
so we target the
+ // first tick. Plain (now - bufferMillis) % roundTimeMills would go
negative -> delay > a round.
+ long roundTimeMs = discovery.roundTimeMills;
+ long bufferMs = discovery.bufferMillis;
+ long epsilon = discovery.getAlignedDelayEpsilonMillis();
+ AtomicLong mockTime = new AtomicLong();
+ EnvironmentEdgeManager.injectEdge(new EnvironmentEdge() {
+ @Override
+ public long currentTime() {
+ return mockTime.get();
+ }
+ });
+ try {
+ mockTime.set(100L); // before the very first anchor (bufferMs + epsilon
= 9500)
+ long delay = discovery.computeAlignedInitialDelay();
+ long targetTick = 100L + delay;
+ assertTrue("Delay should be positive", delay > 0);
+ assertTrue("Delay should not exceed round time", delay <= roundTimeMs);
+ assertEquals("Should target the first epsilon-shifted tick", bufferMs +
epsilon, targetTick);
+ assertEquals("Target should be aligned", 0, (targetTick - bufferMs -
epsilon) % roundTimeMs);
} finally {
EnvironmentEdgeManager.reset();
}
@@ -227,11 +445,12 @@ public class ReplicationLogDiscoveryTest {
});
try {
- // Set time to exactly on a round-eligible tick boundary
- long exactTick = roundTimeMs * 5 + bufferMs;
+ // Set time to exactly on an epsilon-shifted round-eligible tick boundary
+ long epsilon = discovery.getAlignedDelayEpsilonMillis();
+ long exactTick = roundTimeMs * 5 + bufferMs + epsilon;
mockTime.set(exactTick);
long delay = discovery.computeAlignedInitialDelay();
- assertEquals("Delay should be 0 when exactly on a tick", 0, delay);
+ assertEquals("Delay should be 0 when exactly on an epsilon-shifted
tick", 0, delay);
} finally {
EnvironmentEdgeManager.reset();
}
@@ -253,21 +472,22 @@ public class ReplicationLogDiscoveryTest {
});
try {
- // Round-eligible tick is at roundTimeMs * 5 + bufferMs
+ // Round-eligible tick is at roundTimeMs * 5 + bufferMs + epsilon
// Set time to 3s past that tick (12s into the round, buffer is 9s)
- long tick = roundTimeMs * 5 + bufferMs;
+ long epsilon = discovery.getAlignedDelayEpsilonMillis();
+ long tick = roundTimeMs * 5 + bufferMs + epsilon;
long now = tick + 3_000L;
mockTime.set(now);
long delay = discovery.computeAlignedInitialDelay();
// Should wait until the next tick: tick + roundTimeMs
long expectedDelay = roundTimeMs - 3_000L;
- assertEquals("Should wait until next round-eligible tick",
expectedDelay, delay);
+ assertEquals("Should wait until next epsilon-shifted tick",
expectedDelay, delay);
// Verify the target tick is correct
long targetTick = now + delay;
assertEquals("Target should be next tick", tick + roundTimeMs,
targetTick);
- assertEquals("Target should be aligned", 0, (targetTick - bufferMs) %
roundTimeMs);
+ assertEquals("Target should be aligned", 0, (targetTick - bufferMs -
epsilon) % roundTimeMs);
} finally {
EnvironmentEdgeManager.reset();
}
@@ -289,20 +509,101 @@ public class ReplicationLogDiscoveryTest {
});
try {
- // Round-eligible tick is at roundTimeMs * 5 + bufferMs
+ // Round-eligible tick is at roundTimeMs * 5 + bufferMs + epsilon
// Set time to 3s before that tick (6s into the round, buffer is 9s)
- long tick = roundTimeMs * 5 + bufferMs;
+ long epsilon = discovery.getAlignedDelayEpsilonMillis();
+ long tick = roundTimeMs * 5 + bufferMs + epsilon;
long now = tick - 3_000L;
mockTime.set(now);
long delay = discovery.computeAlignedInitialDelay();
// Should wait 3s until the upcoming tick
- assertEquals("Should wait until upcoming round-eligible tick", 3_000L,
delay);
+ assertEquals("Should wait until upcoming epsilon-shifted tick", 3_000L,
delay);
// Verify the target tick is correct
long targetTick = now + delay;
assertEquals("Target should be the upcoming tick", tick, targetTick);
- assertEquals("Target should be aligned", 0, (targetTick - bufferMs) %
roundTimeMs);
+ assertEquals("Target should be aligned", 0, (targetTick - bufferMs -
epsilon) % roundTimeMs);
+ } finally {
+ EnvironmentEdgeManager.reset();
+ }
+ }
+
+ @Test
+ public void testAlignedDelayEpsilonDefaultAndConfig() {
+ // Default value.
+ assertEquals("Default epsilon should be 500ms",
+ ReplicationLogDiscovery.DEFAULT_ALIGNED_DELAY_EPSILON_MILLIS,
+ discovery.getAlignedDelayEpsilonMillis());
+
+ // Custom value from configuration.
+
conf.setLong(ReplicationLogDiscovery.REPLICATION_ALIGNED_DELAY_EPSILON_MILLIS_KEY,
1_500L);
+ assertEquals("Epsilon should be read from config", 1_500L,
+ discovery.getAlignedDelayEpsilonMillis());
+
conf.unset(ReplicationLogDiscovery.REPLICATION_ALIGNED_DELAY_EPSILON_MILLIS_KEY);
+ }
+
+ @Test
+ public void testAlignedDelayEpsilonInvalidFallsBackToDefault() {
+ long roundTimeMs = discovery.roundTimeMills;
+ String key =
ReplicationLogDiscovery.REPLICATION_ALIGNED_DELAY_EPSILON_MILLIS_KEY;
+ long defaultEpsilon =
ReplicationLogDiscovery.DEFAULT_ALIGNED_DELAY_EPSILON_MILLIS;
+
+ // Valid boundary values pass through unchanged.
+ conf.setLong(key, 0L);
+ assertEquals("0 is in range and must pass through", 0L,
+ discovery.getAlignedDelayEpsilonMillis());
+ conf.setLong(key, roundTimeMs - 1);
+ assertEquals("roundTimeMills - 1 is in range and must pass through",
roundTimeMs - 1,
+ discovery.getAlignedDelayEpsilonMillis());
+
+ // Negative would move wakes before eligibility -> clamp to default.
+ conf.setLong(key, -1L);
+ assertEquals("negative epsilon must fall back to default", defaultEpsilon,
+ discovery.getAlignedDelayEpsilonMillis());
+
+ // >= roundTimeMills wraps through floorMod -> clamp to default (boundary
and beyond).
+ conf.setLong(key, roundTimeMs);
+ assertEquals("epsilon == roundTimeMills must fall back to default",
defaultEpsilon,
+ discovery.getAlignedDelayEpsilonMillis());
+ conf.setLong(key, roundTimeMs + 5_000L);
+ assertEquals("epsilon > roundTimeMills must fall back to default",
defaultEpsilon,
+ discovery.getAlignedDelayEpsilonMillis());
+
+ // Non-numeric value: Hadoop's getLong throws NumberFormatException ->
clamp to default.
+ conf.set(key, "not-a-number");
+ assertEquals("non-numeric epsilon must fall back to default",
defaultEpsilon,
+ discovery.getAlignedDelayEpsilonMillis());
+
+ conf.unset(key);
+ }
+
+ @Test
+ public void testComputeAlignedInitialDelayWakesEpsilonAfterEligibility() {
+ // Reproduces the near-miss: "now" is EXACTLY the old zero-margin
eligibility instant
+ // (bufferMs past a round line). With the epsilon margin the scheduler
must NOT target this
+ // instant (delay 0) and must NOT skip a whole cycle — it targets epsilon
later.
+ long roundTimeMs = discovery.roundTimeMills;
+ long bufferMs = discovery.bufferMillis;
+ long epsilon = discovery.getAlignedDelayEpsilonMillis();
+
+ AtomicLong mockTime = new AtomicLong();
+ EnvironmentEdgeManager.injectEdge(new EnvironmentEdge() {
+ @Override
+ public long currentTime() {
+ return mockTime.get();
+ }
+ });
+
+ try {
+ long oldEligibilityInstant = roundTimeMs * 5 + bufferMs; // where a bare
tick used to land
+ mockTime.set(oldEligibilityInstant);
+ long delay = discovery.computeAlignedInitialDelay();
+
+ assertEquals("Should wake epsilon after the eligibility instant, not on
it", epsilon, delay);
+ long targetTick = mockTime.get() + delay;
+ assertEquals("Target must be on the epsilon-shifted grid", 0,
+ (targetTick - bufferMs - epsilon) % roundTimeMs);
} finally {
EnvironmentEdgeManager.reset();
}
@@ -2421,6 +2722,14 @@ public class ReplicationLogDiscoveryTest {
return super.scheduler;
}
+ public void setScheduler(ScheduledExecutorService schedulerToUse) {
+ this.scheduler = schedulerToUse;
+ }
+
+ public void setRunning(boolean running) {
+ this.isRunning = running;
+ }
+
private Boolean mockShouldProcessInProgressDirectory = null;
@Override