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 90f648b86d PHOENIX-7992 Add replay-side performance metrics for HA 
replication (#2607)
90f648b86d is described below

commit 90f648b86d857ee958c702adf3943d961234dad0
Author: Himanshu Gwalani <[email protected]>
AuthorDate: Thu Sep 10 21:12:33 2026 +0530

    PHOENIX-7992 Add replay-side performance metrics for HA replication (#2607)
---
 .../replication/ReplicationLogDiscovery.java       |  60 ++++--
 .../ReplicationLogDiscoveryForwarder.java          |   8 +-
 .../phoenix/replication/ReplicationLogTracker.java |   5 +
 .../metrics/MetricsReplicationLogDiscovery.java    |  11 +
 .../MetricsReplicationLogDiscoveryImpl.java        |  10 +-
 .../MetricsReplicationLogDiscoveryReplay.java      |  35 +++
 .../MetricsReplicationLogDiscoveryReplayImpl.java  |  28 +++
 .../metrics/MetricsReplicationLogProcessor.java    |  23 ++
 .../MetricsReplicationLogProcessorImpl.java        |  19 +-
 .../metrics/MetricsReplicationLogTracker.java      |  15 ++
 .../metrics/MetricsReplicationLogTrackerImpl.java  |  12 +-
 .../ReplicationLogDiscoveryMetricValues.java       |   8 +-
 .../ReplicationLogProcessorMetricValues.java       |  14 +-
 .../metrics/ReplicationLogTrackerMetricValues.java |   9 +-
 .../reader/ReplicationLogDiscoveryReplay.java      |  77 ++++++-
 .../reader/ReplicationLogProcessor.java            |   9 +
 .../reader/ReplicationLogProcessorTestIT.java      |  75 +++++++
 .../ReplicationLogDiscoveryForwarderTest.java      |  12 +-
 .../replication/ReplicationLogDiscoveryTest.java   | 172 +++++++++++----
 .../replication/ReplicationLogTrackerTest.java     |  56 +++++
 .../metrics/ReplicationReplayPerfMetricsTest.java  | 119 +++++++++++
 ...plicationLogDiscoveryReplayProcessFileTest.java | 236 +++++++++++++++++++++
 22 files changed, 932 insertions(+), 81 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 21e1117b87..e499d16db4 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
@@ -47,7 +47,7 @@ import 
org.apache.phoenix.thirdparty.com.google.common.util.concurrent.ThreadFac
  * service Round-based Processing: - Files are organized into replication 
rounds based on timestamps
  * - Each round represents a time window (e.g., 1 minute) of replication 
activity - Processing waits
  * for round completion + buffer time before processing to ensure all files 
are available Subclasses
- * must implement: - processFile(Path): Defines how individual replication log 
files are processed -
+ * must implement: - processFile(Path, boolean): Defines how individual 
replication log files are processed -
  * createMetricsSource(): Provides metrics tracking for monitoring - 
Configuration methods: Thread
  * counts, intervals, probabilities, etc. File Processing Flow: 1. Discover 
new files for the
  * current round 2. Mark files as in-progress (move to in-progress directory) 
3. Process each file
@@ -145,8 +145,9 @@ public abstract class ReplicationLogDiscovery {
   @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).
+   * 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);
 
@@ -279,11 +280,11 @@ public abstract class ReplicationLogDiscovery {
    * 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").
+   * 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
@@ -438,13 +439,16 @@ public abstract class ReplicationLogDiscovery {
     List<Path> files = 
replicationLogTracker.getNewFilesForRound(replicationRound);
     LOG.info("Number of new files for round {} is {}", replicationRound, 
files.size());
     while (!files.isEmpty() && isRunning()) {
-      processOneRandomFile(files);
+      processOneRandomFile(files, true);
       files = replicationLogTracker.getNewFilesForRound(replicationRound);
     }
     long duration = EnvironmentEdgeManager.currentTime() - startTime;
     LOG.info("Finished new files processing for round: {} in {}ms for haGroup: 
{}",
       replicationRound, duration, haGroupName);
     getMetrics().updateTimeToProcessNewFiles(duration);
+    if (duration > roundTimeMills) {
+      getMetrics().incrementRoundsExceedingRoundTime();
+    }
   }
 
   /**
@@ -468,7 +472,7 @@ public abstract class ReplicationLogDiscovery {
       replicationLogTracker.getInProgressLogSubDirectoryName(), 
renameTimestampThreshold,
       files.size(), haGroupName);
     while (!files.isEmpty() && isRunning()) {
-      Optional<Path> failedFile = processOneRandomFile(files);
+      Optional<Path> failedFile = processOneRandomFile(files, false);
       if (failedFile.isPresent()) {
         String prefix = replicationLogTracker.getFilePrefix(failedFile.get());
         int count = failureCount.merge(prefix, 1, Integer::sum);
@@ -494,17 +498,23 @@ public abstract class ReplicationLogDiscovery {
   /**
    * Processes a single random file from the provided list. Marks the file as 
in-progress, processes
    * it, and marks it as completed or failed.
-   * @param files - List of files from which to select and process one randomly
+   * @param files      - List of files from which to select and process one 
randomly
+   * @param firstClaim - true when {@code files} are new files being claimed 
for the first time
+   *                   (from {@link #processNewFilesForRound}); false when 
they are already in the
+   *                   in-progress directory and are being reprocessed (from
+   *                   {@link #processInProgressDirectory}). Forwarded to
+   *                   {@link #processFile(Path, boolean)}.
    * @return the original path of the file that failed, or empty if processing 
succeeded
    */
-  private Optional<Path> processOneRandomFile(final List<Path> files) throws 
IOException {
+  private Optional<Path> processOneRandomFile(final List<Path> files, final 
boolean firstClaim)
+    throws IOException {
     // Pick a random file and process it
     Path file = files.get(ThreadLocalRandom.current().nextInt(files.size()));
     Optional<Path> optionalInProgressFilePath = Optional.empty();
     try {
       optionalInProgressFilePath = replicationLogTracker.markInProgress(file);
       if (optionalInProgressFilePath.isPresent()) {
-        processFile(optionalInProgressFilePath.get());
+        processFile(optionalInProgressFilePath.get(), firstClaim);
         replicationLogTracker.markCompleted(optionalInProgressFilePath.get());
       }
     } catch (IOException exception) {
@@ -518,10 +528,15 @@ public abstract class ReplicationLogDiscovery {
 
   /**
    * Handles the processing of a single file.
-   * @param path - The file to be processed
+   * @param path       - The file to be processed
+   * @param firstClaim - true when this invocation is the file's first claim 
(it was just moved into
+   *                   the in-progress directory from the new-files path); 
false when reprocessing a
+   *                   file that was already in the in-progress directory. 
Lets subclasses record
+   *                   first-claim-only signals (e.g. pickup lag) without 
double counting on
+   *                   retries.
    * @throws IOException if there's an error during file processing
    */
-  protected abstract void processFile(Path path) throws IOException;
+  protected abstract void processFile(Path path, boolean firstClaim) throws 
IOException;
 
   /** Creates a new metrics source for monitoring operations. */
   protected abstract MetricsReplicationLogDiscovery createMetricsSource();
@@ -696,11 +711,12 @@ public abstract class ReplicationLogDiscovery {
   /**
    * 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.
+   * <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() {
@@ -711,8 +727,8 @@ public abstract class ReplicationLogDiscovery {
     } 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) + "\"");
+      warnInvalidEpsilon(
+        "non-numeric value \"" + 
conf.get(REPLICATION_ALIGNED_DELAY_EPSILON_MILLIS_KEY) + "\"");
       return DEFAULT_ALIGNED_DELAY_EPSILON_MILLIS;
     }
     if (epsilon < 0 || epsilon >= roundTimeMills) {
diff --git 
a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/ReplicationLogDiscoveryForwarder.java
 
b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/ReplicationLogDiscoveryForwarder.java
index 097760bb8f..82a5347d93 100644
--- 
a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/ReplicationLogDiscoveryForwarder.java
+++ 
b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/ReplicationLogDiscoveryForwarder.java
@@ -104,8 +104,14 @@ public class ReplicationLogDiscoveryForwarder extends 
ReplicationLogDiscovery {
     super.init();
   }
 
+  /**
+   * Forwards a single log file to the peer cluster. The {@code firstClaim} 
flag (true when the file
+   * was just claimed from the new-files directory, false on an in-progress 
reclaim) is unused here:
+   * forwarding is idempotent on the stable (ts, originServerName) destination 
and records no
+   * claim-latency metrics, so first claims and reclaims are handled 
identically.
+   */
   @Override
-  protected void processFile(Path src) throws IOException {
+  protected void processFile(Path src, boolean firstClaim) throws IOException {
     FileSystem srcFS = replicationLogTracker.getFileSystem();
     FileStatus srcStat = srcFS.getFileStatus(src);
     long ts = replicationLogTracker.getFileTimestamp(srcStat.getPath());
diff --git 
a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/ReplicationLogTracker.java
 
b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/ReplicationLogTracker.java
index 00bfa4ed68..0a13a13ac9 100644
--- 
a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/ReplicationLogTracker.java
+++ 
b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/ReplicationLogTracker.java
@@ -384,6 +384,11 @@ public class ReplicationLogTracker {
         return Optional.of(newPath);
       } else {
         LOG.warn("Failed to rename file for in-progress marking: {}", file);
+        // Claim rename returned false. Usually another process claimed the 
same file first (the
+        // common collision case), but any other rename() == false outcome 
lands here too, so the
+        // counter is named for the observable condition (rename failed), not 
the presumed cause. A
+        // strict subset of the request count incremented in the finally block 
below.
+        getMetrics().incrementMarkFileInProgressRenameFailedCount();
         return Optional.empty();
       }
     } catch (IOException e) {
diff --git 
a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/metrics/MetricsReplicationLogDiscovery.java
 
b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/metrics/MetricsReplicationLogDiscovery.java
index 73371bb55b..2480c69910 100644
--- 
a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/metrics/MetricsReplicationLogDiscovery.java
+++ 
b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/metrics/MetricsReplicationLogDiscovery.java
@@ -37,6 +37,9 @@ public interface MetricsReplicationLogDiscovery extends 
BaseSource {
   String TIME_TO_PROCESS_IN_PROGRESS_FILES = "timeToProcessInProgressFilesMs";
   String TIME_TO_PROCESS_IN_PROGRESS_FILES_DESC =
     "Histogram of time taken to process in progress files in milliseconds";
+  String ROUNDS_EXCEEDING_ROUND_TIME = "roundsExceedingRoundTime";
+  String ROUNDS_EXCEEDING_ROUND_TIME_DESC =
+    "Number of rounds whose new-file processing time exceeded the round time";
 
   /**
    * Increments the counter for rounds processed. This counter tracks the 
number of rounds processed
@@ -62,6 +65,14 @@ public interface MetricsReplicationLogDiscovery extends 
BaseSource {
    */
   void updateTimeToProcessInProgressFiles(long timeMs);
 
+  /**
+   * Increments the counter for rounds whose new-file processing time exceeded 
the round time. This
+   * counter lives on the shared discovery base, so it is emitted by both the 
forwarder-side and
+   * replay-side discovery sources; a rising rate signals that that side's 
new-file processing is
+   * falling behind the cadence at which new rounds become eligible.
+   */
+  void incrementRoundsExceedingRoundTime();
+
   /**
    * Unregister this metrics source.
    */
diff --git 
a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/metrics/MetricsReplicationLogDiscoveryImpl.java
 
b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/metrics/MetricsReplicationLogDiscoveryImpl.java
index 35183d92b3..ccd9a1bd11 100644
--- 
a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/metrics/MetricsReplicationLogDiscoveryImpl.java
+++ 
b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/metrics/MetricsReplicationLogDiscoveryImpl.java
@@ -31,6 +31,7 @@ public class MetricsReplicationLogDiscoveryImpl extends 
BaseSourceImpl
   protected final MutableFastCounter numInProgressDirectoryProcessed;
   protected final MutableHistogram timeToProcessNewFiles;
   protected final MutableHistogram timeToProcessInProgressFiles;
+  protected final MutableFastCounter roundsExceedingRoundTime;
 
   public MetricsReplicationLogDiscoveryImpl(String metricsName, String 
metricsDescription,
     String metricsContext, String metricsJmxContext) {
@@ -43,6 +44,8 @@ public class MetricsReplicationLogDiscoveryImpl extends 
BaseSourceImpl
       getMetricsRegistry().newHistogram(TIME_TO_PROCESS_NEW_FILES, 
TIME_TO_PROCESS_NEW_FILES_DESC);
     timeToProcessInProgressFiles = getMetricsRegistry()
       .newHistogram(TIME_TO_PROCESS_IN_PROGRESS_FILES, 
TIME_TO_PROCESS_IN_PROGRESS_FILES_DESC);
+    roundsExceedingRoundTime = 
getMetricsRegistry().newCounter(ROUNDS_EXCEEDING_ROUND_TIME,
+      ROUNDS_EXCEEDING_ROUND_TIME_DESC, 0L);
   }
 
   @Override
@@ -65,6 +68,11 @@ public class MetricsReplicationLogDiscoveryImpl extends 
BaseSourceImpl
     timeToProcessInProgressFiles.add(timeMs);
   }
 
+  @Override
+  public void incrementRoundsExceedingRoundTime() {
+    roundsExceedingRoundTime.incr();
+  }
+
   @Override
   public void close() {
     // Unregister this metrics source
@@ -75,7 +83,7 @@ public class MetricsReplicationLogDiscoveryImpl extends 
BaseSourceImpl
   public ReplicationLogDiscoveryMetricValues getCurrentMetricValues() {
     return new ReplicationLogDiscoveryMetricValues(numRoundsProcessed.value(),
       numInProgressDirectoryProcessed.value(), timeToProcessNewFiles.getMax(),
-      timeToProcessInProgressFiles.getMax());
+      timeToProcessInProgressFiles.getMax(), roundsExceedingRoundTime.value());
   }
 
   @Override
diff --git 
a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/metrics/MetricsReplicationLogDiscoveryReplay.java
 
b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/metrics/MetricsReplicationLogDiscoveryReplay.java
index f78b762146..cf64c045bc 100644
--- 
a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/metrics/MetricsReplicationLogDiscoveryReplay.java
+++ 
b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/metrics/MetricsReplicationLogDiscoveryReplay.java
@@ -26,6 +26,16 @@ public interface MetricsReplicationLogDiscoveryReplay 
extends MetricsReplication
   String CONSISTENCY_POINT = "consistencyPoint";
   String CONSISTENCY_POINT_DESC =
     "Consistency point timestamp in milliseconds for the HA Group during 
replay";
+  String END_TO_END_REPLAY_LAG = "endToEndReplayLagMs";
+  String END_TO_END_REPLAY_LAG_DESC =
+    "Histogram of end-to-end replay lag, from when a file's round became 
eligible for processing "
+      + "to when the file finished replaying, in milliseconds. Sampled only on 
successful replay, "
+      + "so a file that keeps failing contributes no sample";
+  String PICKUP_LAG = "pickupLagMs";
+  String PICKUP_LAG_DESC =
+    "Histogram of pickup lag, from when a file's round became eligible for 
processing to when the "
+      + "file was claimed (renamed into the in-progress directory), in 
milliseconds. Sampled only "
+      + "on a file's first claim";
 
   /**
    * Updates the consistency point metric. The consistency point represents 
the timestamp up to
@@ -34,4 +44,29 @@ public interface MetricsReplicationLogDiscoveryReplay 
extends MetricsReplication
    * @param consistencyPointMs The consistency point timestamp in milliseconds
    */
   void updateConsistencyPoint(long consistencyPointMs);
+
+  /**
+   * Records a sample into the end-to-end replay lag histogram: the elapsed 
time from when a file's
+   * round became eligible for processing to when the file finished replaying.
+   * <p>
+   * Recorded only after a file replays successfully, so it is 
survivorship-biased: a file that
+   * keeps failing on every reclaim contributes no sample even though that is 
the clearest replay
+   * stall. Read it alongside {@code logFileReplayFailureCount}, {@code 
roundsExceedingRoundTime},
+   * and {@code consistencyPoint} staleness, which a healthy-looking lag 
histogram would otherwise
+   * mask.
+   * @param lagMs The end-to-end replay lag in milliseconds
+   */
+  void updateEndToEndReplayLag(long lagMs);
+
+  /**
+   * Records a sample into the pickup lag histogram: the elapsed time from 
when a file's round
+   * became eligible for processing to when the file was claimed (renamed into 
the in-progress
+   * directory).
+   * <p>
+   * Recorded only on a file's first claim (never on a reclaim), so like
+   * {@link #updateEndToEndReplayLag(long)} it cannot see a file that is 
claimed once and then never
+   * replays; read it alongside the failure and staleness signals noted there.
+   * @param lagMs The pickup lag in milliseconds
+   */
+  void updatePickupLag(long lagMs);
 }
diff --git 
a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/metrics/MetricsReplicationLogDiscoveryReplayImpl.java
 
b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/metrics/MetricsReplicationLogDiscoveryReplayImpl.java
index dc51fdae26..339078242f 100644
--- 
a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/metrics/MetricsReplicationLogDiscoveryReplayImpl.java
+++ 
b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/metrics/MetricsReplicationLogDiscoveryReplayImpl.java
@@ -22,6 +22,9 @@ import static 
org.apache.phoenix.metrics.MetricConstants.HA_GROUP_TAG_NAME;
 
 import org.apache.hadoop.metrics2.lib.Interns;
 import org.apache.hadoop.metrics2.lib.MutableGaugeLong;
+import org.apache.hadoop.metrics2.lib.MutableHistogram;
+
+import 
org.apache.phoenix.thirdparty.com.google.common.annotations.VisibleForTesting;
 
 /** Implementation of metrics source for ReplicationLogDiscoveryReplay 
operations. */
 public class MetricsReplicationLogDiscoveryReplayImpl extends 
MetricsReplicationLogDiscoveryImpl
@@ -33,6 +36,8 @@ public class MetricsReplicationLogDiscoveryReplayImpl extends 
MetricsReplication
   private static final String METRICS_JMX_CONTEXT = "RegionServer,sub=" + 
METRICS_NAME;
 
   private final MutableGaugeLong consistencyPoint;
+  private final MutableHistogram endToEndReplayLag;
+  private final MutableHistogram pickupLag;
 
   public MetricsReplicationLogDiscoveryReplayImpl(final String haGroupName) {
     super(MetricsReplicationLogDiscoveryReplayImpl.METRICS_NAME,
@@ -41,10 +46,33 @@ public class MetricsReplicationLogDiscoveryReplayImpl 
extends MetricsReplication
       MetricsReplicationLogDiscoveryReplayImpl.METRICS_JMX_CONTEXT + 
",haGroup=" + haGroupName);
     getMetricsRegistry().tag(Interns.info(HA_GROUP_TAG_NAME, 
HA_GROUP_TAG_DESC), haGroupName);
     consistencyPoint = getMetricsRegistry().newGauge(CONSISTENCY_POINT, 
CONSISTENCY_POINT_DESC, 0L);
+    endToEndReplayLag =
+      getMetricsRegistry().newHistogram(END_TO_END_REPLAY_LAG, 
END_TO_END_REPLAY_LAG_DESC);
+    pickupLag = getMetricsRegistry().newHistogram(PICKUP_LAG, PICKUP_LAG_DESC);
   }
 
   @Override
   public void updateConsistencyPoint(long consistencyPointMs) {
     consistencyPoint.set(consistencyPointMs);
   }
+
+  @Override
+  public void updateEndToEndReplayLag(long lagMs) {
+    endToEndReplayLag.add(lagMs);
+  }
+
+  @Override
+  public void updatePickupLag(long lagMs) {
+    pickupLag.add(lagMs);
+  }
+
+  @VisibleForTesting
+  MutableHistogram getEndToEndReplayLagHistogram() {
+    return endToEndReplayLag;
+  }
+
+  @VisibleForTesting
+  MutableHistogram getPickupLagHistogram() {
+    return pickupLag;
+  }
 }
diff --git 
a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/metrics/MetricsReplicationLogProcessor.java
 
b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/metrics/MetricsReplicationLogProcessor.java
index 5ed7bd8e6f..f6169e459e 100644
--- 
a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/metrics/MetricsReplicationLogProcessor.java
+++ 
b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/metrics/MetricsReplicationLogProcessor.java
@@ -41,6 +41,13 @@ public interface MetricsReplicationLogProcessor extends 
BaseSource {
   String LOG_FILE_REPLAY_TIME = "logFileReplayTimeMs";
   String LOG_FILE_REPLAY_TIME_DESC =
     "Histogram of time taken for replaying a log file in milliseconds";
+  String SUCCESSFUL_FILE_MUTATIONS_REPLAYED_COUNT = 
"successfulFileMutationsReplayedCount";
+  String SUCCESSFUL_FILE_MUTATIONS_REPLAYED_COUNT_DESC =
+    "Total number of mutations replayed across log files that completed 
successfully; mutations "
+      + "already applied by a file that then fails mid-replay are not counted";
+  String MUTATIONS_PER_FILE = "mutationsPerFile";
+  String MUTATIONS_PER_FILE_DESC =
+    "Histogram of the number of mutations replayed per log file (files with 
mutations only)";
 
   /**
    * Increments the counter for failed mutations. This counter tracks the 
number of mutations that
@@ -78,6 +85,22 @@ public interface MetricsReplicationLogProcessor extends 
BaseSource {
    */
   void updateLogFileReplayTime(long timeMs);
 
+  /**
+   * Increments the count of mutations replayed by files that completed 
successfully. Called once
+   * per file after its replay returns without error, so mutations already 
applied by a file that
+   * then fails mid-replay are not counted. Tracks replay throughput across 
fully-successful files.
+   * @param delta the number of mutations replayed for a successfully 
completed log file
+   */
+  void incrementSuccessfulFileMutationsReplayedCount(long delta);
+
+  /**
+   * Records a sample into the per-file mutation-count histogram. Called only 
for files that
+   * replayed at least one mutation, so rotation-only (zero-mutation) files do 
not skew the
+   * distribution toward zero.
+   * @param mutationsCount the number of mutations replayed for a single log 
file
+   */
+  void updateMutationsPerFile(long mutationsCount);
+
   /**
    * Unregister this metrics source.
    */
diff --git 
a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/metrics/MetricsReplicationLogProcessorImpl.java
 
b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/metrics/MetricsReplicationLogProcessorImpl.java
index 1d487efc4b..47c1fa3f7a 100644
--- 
a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/metrics/MetricsReplicationLogProcessorImpl.java
+++ 
b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/metrics/MetricsReplicationLogProcessorImpl.java
@@ -37,6 +37,8 @@ public class MetricsReplicationLogProcessorImpl extends 
BaseSourceImpl
   private final MutableFastCounter logFileReplaySuccessCount;
   private final MutableHistogram batchReplayTime;
   private final MutableHistogram logFileReplayTime;
+  private final MutableFastCounter successfulFileMutationsReplayedCount;
+  private final MutableHistogram mutationsPerFile;
 
   public MetricsReplicationLogProcessorImpl(final String haGroupName) {
     this(METRICS_NAME, METRICS_DESCRIPTION, METRICS_CONTEXT,
@@ -59,6 +61,10 @@ public class MetricsReplicationLogProcessorImpl extends 
BaseSourceImpl
     batchReplayTime = getMetricsRegistry().newHistogram(BATCH_REPLAY_TIME, 
BATCH_REPLAY_TIME_DESC);
     logFileReplayTime =
       getMetricsRegistry().newHistogram(LOG_FILE_REPLAY_TIME, 
LOG_FILE_REPLAY_TIME_DESC);
+    successfulFileMutationsReplayedCount = getMetricsRegistry().newCounter(
+      SUCCESSFUL_FILE_MUTATIONS_REPLAYED_COUNT, 
SUCCESSFUL_FILE_MUTATIONS_REPLAYED_COUNT_DESC, 0L);
+    mutationsPerFile =
+      getMetricsRegistry().newHistogram(MUTATIONS_PER_FILE, 
MUTATIONS_PER_FILE_DESC);
   }
 
   @Override
@@ -91,6 +97,16 @@ public class MetricsReplicationLogProcessorImpl extends 
BaseSourceImpl
     logFileReplayTime.add(timeMs);
   }
 
+  @Override
+  public void incrementSuccessfulFileMutationsReplayedCount(long delta) {
+    successfulFileMutationsReplayedCount.incr(delta);
+  }
+
+  @Override
+  public void updateMutationsPerFile(long mutationsCount) {
+    mutationsPerFile.add(mutationsCount);
+  }
+
   @Override
   public void close() {
     // Unregister this metrics source
@@ -101,7 +117,8 @@ public class MetricsReplicationLogProcessorImpl extends 
BaseSourceImpl
   public ReplicationLogProcessorMetricValues getCurrentMetricValues() {
     return new 
ReplicationLogProcessorMetricValues(failedMutationsCount.value(),
       logFileReplayFailureCount.value(), logFileReplaySuccessCount.value(),
-      logFileReplayTime.getMax(), batchReplayTime.getMax());
+      logFileReplayTime.getMax(), batchReplayTime.getMax(),
+      successfulFileMutationsReplayedCount.value(), mutationsPerFile.getMax());
   }
 
   @Override
diff --git 
a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/metrics/MetricsReplicationLogTracker.java
 
b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/metrics/MetricsReplicationLogTracker.java
index dcc2af2a5d..6bc7ce785e 100644
--- 
a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/metrics/MetricsReplicationLogTracker.java
+++ 
b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/metrics/MetricsReplicationLogTracker.java
@@ -29,6 +29,12 @@ public interface MetricsReplicationLogTracker extends 
BaseSource {
   String MARK_FILE_IN_PROGRESS_REQUEST_COUNT = 
"markFileInProgressRequestCount";
   String MARK_FILE_IN_PROGRESS_REQUEST_COUNT_DESC =
     "Number of requests made to mark file in progress";
+  String MARK_FILE_IN_PROGRESS_RENAME_FAILED_COUNT = 
"markFileInProgressRenameFailedCount";
+  String MARK_FILE_IN_PROGRESS_RENAME_FAILED_COUNT_DESC =
+    "Number of mark-file-in-progress attempts whose claim rename returned 
false; typically the "
+      + "claim was lost to another process. Counts only the boolean rename() 
== false outcome -- an "
+      + "IOException thrown during the rename is not counted here (that path 
only logs an error). A "
+      + "strict subset of the request count.";
   String MARK_FILE_COMPLETED_REQUEST_COUNT = "markFileCompletedRequestCount";
   String MARK_FILE_COMPLETED_REQUEST_COUNT_DESC = "Number of requests made to 
mark file completed";
   String MARK_FILE_FAILED_REQUEST_COUNT = "markFileFailedRequestCount";
@@ -52,6 +58,15 @@ public interface MetricsReplicationLogTracker extends 
BaseSource {
    */
   void incrementMarkFileInProgressRequestCount();
 
+  /**
+   * Increments the counter for mark-file-in-progress claim renames that 
returned false. Typically
+   * the claim was lost to another process (the decentralized, no-coordination 
claim design), but
+   * any other {@code rename() == false} outcome is also counted. An 
IOException thrown during the
+   * rename is not counted here (that path only logs an error and returns 
empty). A strict subset of
+   * {@link #incrementMarkFileInProgressRequestCount()}.
+   */
+  void incrementMarkFileInProgressRenameFailedCount();
+
   /**
    * Increments the counter for mark file completed requests. This counter 
tracks the number of
    * requests made to mark files completed.
diff --git 
a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/metrics/MetricsReplicationLogTrackerImpl.java
 
b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/metrics/MetricsReplicationLogTrackerImpl.java
index b938a504d4..f83b181e0a 100644
--- 
a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/metrics/MetricsReplicationLogTrackerImpl.java
+++ 
b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/metrics/MetricsReplicationLogTrackerImpl.java
@@ -28,6 +28,7 @@ public class MetricsReplicationLogTrackerImpl extends 
BaseSourceImpl
 
   protected String groupMetricsContext;
   protected final MutableFastCounter markFileInProgressRequestCount;
+  protected final MutableFastCounter markFileInProgressRenameFailedCount;
   protected final MutableFastCounter markFileCompletedRequestCount;
   protected final MutableFastCounter markFileFailedRequestCount;
   protected final MutableFastCounter markFileCompletedRequestFailedCount;
@@ -40,6 +41,9 @@ public class MetricsReplicationLogTrackerImpl extends 
BaseSourceImpl
     super(metricsName, metricsDescription, metricsContext, metricsJmxContext);
     markFileInProgressRequestCount = getMetricsRegistry().newCounter(
       MARK_FILE_IN_PROGRESS_REQUEST_COUNT, 
MARK_FILE_IN_PROGRESS_REQUEST_COUNT_DESC, 0L);
+    markFileInProgressRenameFailedCount =
+      
getMetricsRegistry().newCounter(MARK_FILE_IN_PROGRESS_RENAME_FAILED_COUNT,
+        MARK_FILE_IN_PROGRESS_RENAME_FAILED_COUNT_DESC, 0L);
     markFileCompletedRequestCount = getMetricsRegistry()
       .newCounter(MARK_FILE_COMPLETED_REQUEST_COUNT, 
MARK_FILE_COMPLETED_REQUEST_COUNT_DESC, 0L);
     markFileFailedRequestCount = 
getMetricsRegistry().newCounter(MARK_FILE_FAILED_REQUEST_COUNT,
@@ -59,6 +63,11 @@ public class MetricsReplicationLogTrackerImpl extends 
BaseSourceImpl
     markFileInProgressRequestCount.incr();
   }
 
+  @Override
+  public void incrementMarkFileInProgressRenameFailedCount() {
+    markFileInProgressRenameFailedCount.incr();
+  }
+
   @Override
   public void incrementMarkFileCompletedRequestCount() {
     markFileCompletedRequestCount.incr();
@@ -100,7 +109,8 @@ public class MetricsReplicationLogTrackerImpl extends 
BaseSourceImpl
     return new 
ReplicationLogTrackerMetricValues(markFileInProgressRequestCount.value(),
       markFileCompletedRequestCount.value(), 
markFileFailedRequestCount.value(),
       markFileCompletedRequestFailedCount.value(), 
markFileInProgressTime.getMax(),
-      markFileCompletedTime.getMax(), markFileFailedTime.getMax());
+      markFileCompletedTime.getMax(), markFileFailedTime.getMax(),
+      markFileInProgressRenameFailedCount.value());
   }
 
   @Override
diff --git 
a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/metrics/ReplicationLogDiscoveryMetricValues.java
 
b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/metrics/ReplicationLogDiscoveryMetricValues.java
index dd380a3608..f6242257bd 100644
--- 
a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/metrics/ReplicationLogDiscoveryMetricValues.java
+++ 
b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/metrics/ReplicationLogDiscoveryMetricValues.java
@@ -26,14 +26,16 @@ public class ReplicationLogDiscoveryMetricValues {
   private final long numInProgressDirectoryProcessed;
   private final long timeToProcessNewFilesMs;
   private final long timeToProcessInProgressFilesMs;
+  private final long roundsExceedingRoundTime;
 
   public ReplicationLogDiscoveryMetricValues(long numRoundsProcessed,
     long numInProgressDirectoryProcessed, long timeToProcessNewFilesMs,
-    long timeToProcessInProgressFilesMs) {
+    long timeToProcessInProgressFilesMs, long roundsExceedingRoundTime) {
     this.numRoundsProcessed = numRoundsProcessed;
     this.numInProgressDirectoryProcessed = numInProgressDirectoryProcessed;
     this.timeToProcessNewFilesMs = timeToProcessNewFilesMs;
     this.timeToProcessInProgressFilesMs = timeToProcessInProgressFilesMs;
+    this.roundsExceedingRoundTime = roundsExceedingRoundTime;
   }
 
   public long getNumRoundsProcessed() {
@@ -52,4 +54,8 @@ public class ReplicationLogDiscoveryMetricValues {
     return timeToProcessInProgressFilesMs;
   }
 
+  public long getRoundsExceedingRoundTime() {
+    return roundsExceedingRoundTime;
+  }
+
 }
diff --git 
a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/metrics/ReplicationLogProcessorMetricValues.java
 
b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/metrics/ReplicationLogProcessorMetricValues.java
index 4911ac4bd7..8a0b276a0b 100644
--- 
a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/metrics/ReplicationLogProcessorMetricValues.java
+++ 
b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/metrics/ReplicationLogProcessorMetricValues.java
@@ -25,15 +25,19 @@ public class ReplicationLogProcessorMetricValues {
   private final long logFileReplaySuccessCount;
   private final long logFileReplayTime;
   private final long logFileBatchReplayTime;
+  private final long successfulFileMutationsReplayedCount;
+  private final long mutationsPerFile;
 
   public ReplicationLogProcessorMetricValues(long failedMutationsCount,
     long logFileReplayFailureCount, long logFileReplaySuccessCount, long 
logFileReplayTime,
-    long logFileBatchReplayTime) {
+    long logFileBatchReplayTime, long successfulFileMutationsReplayedCount, 
long mutationsPerFile) {
     this.failedMutationsCount = failedMutationsCount;
     this.logFileReplayFailureCount = logFileReplayFailureCount;
     this.logFileReplaySuccessCount = logFileReplaySuccessCount;
     this.logFileReplayTime = logFileReplayTime;
     this.logFileBatchReplayTime = logFileBatchReplayTime;
+    this.successfulFileMutationsReplayedCount = 
successfulFileMutationsReplayedCount;
+    this.mutationsPerFile = mutationsPerFile;
   }
 
   public long getFailedMutationsCount() {
@@ -55,4 +59,12 @@ public class ReplicationLogProcessorMetricValues {
   public long getLogFileBatchReplayTime() {
     return logFileBatchReplayTime;
   }
+
+  public long getSuccessfulFileMutationsReplayedCount() {
+    return successfulFileMutationsReplayedCount;
+  }
+
+  public long getMutationsPerFile() {
+    return mutationsPerFile;
+  }
 }
diff --git 
a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/metrics/ReplicationLogTrackerMetricValues.java
 
b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/metrics/ReplicationLogTrackerMetricValues.java
index 76678db000..ddaa570536 100644
--- 
a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/metrics/ReplicationLogTrackerMetricValues.java
+++ 
b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/metrics/ReplicationLogTrackerMetricValues.java
@@ -29,11 +29,13 @@ public class ReplicationLogTrackerMetricValues {
   private final long markFileInProgressTimeMs;
   private final long markFileCompletedTimeMs;
   private final long markFileFailedTimeMs;
+  private final long markFileInProgressRenameFailedCount;
 
   public ReplicationLogTrackerMetricValues(long markFileInProgressRequestCount,
     long markFileCompletedRequestCount, long markFileFailedRequestCount,
     long markFileCompletedRequestFailedCount, long markFileInProgressTimeMs,
-    long markFileCompletedTimeMs, long markFileFailedTimeMs) {
+    long markFileCompletedTimeMs, long markFileFailedTimeMs,
+    long markFileInProgressRenameFailedCount) {
     this.markFileInProgressRequestCount = markFileInProgressRequestCount;
     this.markFileCompletedRequestCount = markFileCompletedRequestCount;
     this.markFileFailedRequestCount = markFileFailedRequestCount;
@@ -41,6 +43,7 @@ public class ReplicationLogTrackerMetricValues {
     this.markFileInProgressTimeMs = markFileInProgressTimeMs;
     this.markFileCompletedTimeMs = markFileCompletedTimeMs;
     this.markFileFailedTimeMs = markFileFailedTimeMs;
+    this.markFileInProgressRenameFailedCount = 
markFileInProgressRenameFailedCount;
   }
 
   public long getMarkFileInProgressRequestCount() {
@@ -71,4 +74,8 @@ public class ReplicationLogTrackerMetricValues {
     return markFileFailedTimeMs;
   }
 
+  public long getMarkFileInProgressRenameFailedCount() {
+    return markFileInProgressRenameFailedCount;
+  }
+
 }
diff --git 
a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/reader/ReplicationLogDiscoveryReplay.java
 
b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/reader/ReplicationLogDiscoveryReplay.java
index a3ae1ca1f7..833326b6b2 100644
--- 
a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/reader/ReplicationLogDiscoveryReplay.java
+++ 
b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/reader/ReplicationLogDiscoveryReplay.java
@@ -228,12 +228,85 @@ public class ReplicationLogDiscoveryReplay extends 
ReplicationLogDiscovery {
   }
 
   @Override
-  protected void processFile(Path path) throws IOException {
-    LOG.info("Starting to process file {}", path);
+  protected void processFile(Path path, boolean firstClaim) throws IOException 
{
+    LOG.info("Starting to process file {} (firstClaim={})", path, firstClaim);
+    ReplicationLogTracker tracker = getReplicationLogFileTracker();
+    final long fileTimestamp;
+    try {
+      fileTimestamp = tracker.getFileTimestamp(path);
+    } catch (NumberFormatException e) {
+      // A malformed file name cannot be anchored to a round. getFileTimestamp 
is validated on the
+      // new-files path (getNewFilesForRound skips names that fail to parse) 
but not on the reclaim
+      // path, so convert the unchecked parse failure into the IOException 
that every other per-file
+      // failure uses. That keeps a single bad name isolated to 
processOneRandomFile's catch (marked
+      // failed and retry-counted) instead of escaping as a RuntimeException 
that aborts the whole
+      // in-progress sweep for the cycle.
+      throw new IOException("Cannot extract timestamp from replication log 
file name: " + path, e);
+    }
+    long roundEligibleTime = getRoundEligibleTime(fileTimestamp);
+    // Pickup lag (eligible -> claimed) is recorded only on the first claim 
(the new-files path).
+    // markInProgress re-stamps a fresh rename timestamp on every reclaim of 
an already-in-progress
+    // file, so recording on the in-progress path (firstClaim=false) would 
sample eligible ->
+    // latest-reclaim, not eligible -> first-pickup, and multi-count the same 
file across sweeps.
+    // Skip if the in-progress name carries no rename timestamp (should not 
happen for a file that
+    // reached processFile).
+    if (firstClaim) {
+      Optional<Long> renameTs = tracker.getRenameTimestamp(path);
+      if (renameTs.isPresent()) {
+        getReplayMetrics().updatePickupLag(Math.max(0L, renameTs.get() - 
roundEligibleTime));
+      } else {
+        // A first-claim file was just renamed into the in-progress directory 
by markInProgress,
+        // which always stamps a rename timestamp, so this should not happen. 
Drop the pickup-lag
+        // sample rather than record a bogus one, but leave a breadcrumb -- 
getRenameTimestamp only
+        // logs on a malformed 4-part name, not on an unexpectedly short name.
+        LOG.warn("No rename timestamp on first-claim file {}; skipping 
pickup-lag sample", path);
+      }
+    }
+    replayLogFile(path);
+    // End-to-end lag (eligible -> replay done): recorded only after a 
successful replayLogFile
+    // return. On failure replayLogFile throws and logFileReplayFailureCount 
already fires, so an
+    // end-to-end lag sample for an unfinished replay would be misleading. 
Every successfully
+    // replayed file is sampled here -- including rotation-only / 
zero-mutation files, which the
+    // mutationsPerFile histogram deliberately excludes -- so the two 
distributions cover different
+    // file populations.
+    getReplayMetrics().updateEndToEndReplayLag(
+      Math.max(0L, EnvironmentEdgeManager.currentTime() - roundEligibleTime));
+  }
+
+  /**
+   * Replays a single log file through the {@link ReplicationLogProcessor}. 
Extracted as a seam so
+   * the lag-recording logic in {@link #processFile(Path, boolean)} can be 
unit-tested without a
+   * live processor or file system.
+   * @param path the in-progress log file to replay
+   * @throws IOException if replay fails; the caller then skips the end-to-end 
lag sample
+   */
+  protected void replayLogFile(Path path) throws IOException {
     ReplicationLogProcessor.get(getConf(), getHaGroupName())
       .processLogFile(getReplicationLogFileTracker().getFileSystem(), path);
   }
 
+  /**
+   * Returns the wall-clock instant (ms) at which the round owning a file with 
the given creation
+   * timestamp became eligible for processing: the round's end boundary plus 
the waiting buffer.
+   * This mirrors the eligibility gate used by {@link 
#getNextRoundToProcess()} (a round is eligible
+   * once {@code currentTime >= roundEnd + bufferMillis}) and is the 
zero-reference for the
+   * replay-lag metrics, so they exclude the fixed built-in wait rather than 
measuring raw file age.
+   * <p>
+   * Rounds are half-open {@code [start, end)}: a file at {@code creationTs} 
belongs to the round
+   * {@code [roundStart, roundStart + roundTimeMills)}, where {@code 
roundStart} floors
+   * {@code creationTs} down to a round boundary. This matches the on-disk 
layout, since
+   * getShardDirectory also floors the timestamp: a file that lands exactly on 
a boundary is stored
+   * in, and replayed by, the round that starts there -- not the one that ends 
there. The owning
+   * round's end is therefore always {@code roundStart + roundTimeMills}, 
including on a boundary.
+   * @param creationTs the file creation timestamp (first component of the log 
file name)
+   * @return the round-eligible wall-clock instant in milliseconds
+   */
+  private long getRoundEligibleTime(long creationTs) {
+    long roundStart = 
replicationLogTracker.getReplicationShardDirectoryManager()
+      .getNearestRoundStartTimestamp(creationTs);
+    return roundStart + roundTimeMills + bufferMillis;
+  }
+
   /**
    * Initializes lastRoundProcessed and lastRoundInSync based on the persisted 
HA group state.
    * <ul>
diff --git 
a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/reader/ReplicationLogProcessor.java
 
b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/reader/ReplicationLogProcessor.java
index 414bd1bb63..423b2202bc 100644
--- 
a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/reader/ReplicationLogProcessor.java
+++ 
b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/reader/ReplicationLogProcessor.java
@@ -278,6 +278,15 @@ public class ReplicationLogProcessor implements Closeable {
       LOG.info("Completed processing log file {}. Total mutations processed: 
{}",
         logFileReader.getContext().getFilePath(), totalProcessed);
       getMetrics().incrementLogFileReplaySuccessCount();
+      // Replay throughput for files that completed successfully. This runs 
only after the whole
+      // file replayed without error (before the catch), so mutations already 
applied by a file that
+      // then fails mid-replay are not counted (+= 0 is a no-op for 
rotation-only files). The
+      // per-file distribution below excludes zero-mutation files so they do 
not skew it toward
+      // zero.
+      
getMetrics().incrementSuccessfulFileMutationsReplayedCount(totalProcessed);
+      if (totalProcessed > 0) {
+        getMetrics().updateMutationsPerFile(totalProcessed);
+      }
     } catch (Exception e) {
       LOG.error("Error while processing replication log file", e);
       getMetrics().incrementLogFileReplayFailureCount();
diff --git 
a/phoenix-core/src/it/java/org/apache/phoenix/replication/reader/ReplicationLogProcessorTestIT.java
 
b/phoenix-core/src/it/java/org/apache/phoenix/replication/reader/ReplicationLogProcessorTestIT.java
index 089bde4d47..6168d2692b 100644
--- 
a/phoenix-core/src/it/java/org/apache/phoenix/replication/reader/ReplicationLogProcessorTestIT.java
+++ 
b/phoenix-core/src/it/java/org/apache/phoenix/replication/reader/ReplicationLogProcessorTestIT.java
@@ -628,6 +628,18 @@ public class ReplicationLogProcessorTestIT extends 
ParallelStatsDisabledIT {
         metricValues.getFailedMutationsCount());
       assertEquals("There must not be any failed files", 0,
         metricValues.getLogFileReplayFailureCount());
+      // Recording-site guard (PHOENIX-7992): 
incrementSuccessfulFileMutationsReplayedCount is
+      // written inside processLogFile before the catch, not by its callers, 
so a real replay is the
+      // only thing that exercises it. The exact replayed-mutation count is 
intentionally not
+      // asserted: generateHBaseMutations builds its Puts from a Phoenix 
mutation state that is never
+      // committed or cleared between rows, so the replayed total is an 
artifact of that helper rather
+      // than the "2 + 5" rows requested (testProcessLogFileBatchSizeLogic 
pins an exact count on a
+      // 1:1 record model). We also do not assert the mutationsPerFile 
histogram here:
+      // MutableHistogram.getMax() reads FastLongHistogram bins that the 
metrics system resets to 0 on
+      // its periodic snapshot(), so a positive max is not stable to read 
after a multi-second real
+      // replay -- only the counter (a MutableFastCounter, never reset by 
snapshot) is reliable.
+      assertTrue("A successful multi-mutation file must record positive replay 
throughput",
+        metricValues.getSuccessfulFileMutationsReplayedCount() > 0);
 
     } finally {
       replicationLogProcessor.close();
@@ -698,6 +710,13 @@ public class ReplicationLogProcessorTestIT extends 
ParallelStatsDisabledIT {
         metricValues.getFailedMutationsCount());
       assertEquals("There must not be any failed files", 0,
         metricValues.getLogFileReplayFailureCount());
+      // A rotation-only / zero-mutation file adds 0 to the throughput counter 
and, because of the
+      // `totalProcessed > 0` guard, must not populate the per-file histogram 
(its max stays 0
+      // rather than skewing the distribution toward zero).
+      assertEquals("Zero-mutation file must not add to the throughput 
counter", 0,
+        metricValues.getSuccessfulFileMutationsReplayedCount());
+      assertEquals("Zero-mutation file must not populate the mutationsPerFile 
histogram", 0,
+        metricValues.getMutationsPerFile());
     } catch (Exception e) {
       fail("Processing empty log file should not throw exception: " + 
e.getMessage());
     } finally {
@@ -970,11 +989,67 @@ public class ReplicationLogProcessorTestIT extends 
ParallelStatsDisabledIT {
       metricValues.getFailedMutationsCount());
     assertEquals("There must not be any failed files", 0,
       metricValues.getLogFileReplayFailureCount());
+    // Accumulation guard: totalProcessed sums across all three batch flushes 
(4 + 5 + 2 = 11), and
+    // is recorded on the same line as updateMutationsPerFile, so the exact 
counter value also pins
+    // the histogram's input. We assert the counter (a MutableFastCounter) 
rather than
+    // getMutationsPerFile(): the histogram max is read from FastLongHistogram 
bins that the metrics
+    // system resets to 0 on its periodic snapshot(), so a positive max is not 
a stable value to
+    // assert against.
+    assertEquals("successfulFileMutationsReplayedCount must sum across 
batches", 11,
+      metricValues.getSuccessfulFileMutationsReplayedCount());
 
     // Clean up
     spyProcessor.close();
   }
 
+  /**
+   * Guards the placement of the throughput / per-file metric writes: they sit 
after a whole-file
+   * success and before the catch, so a file that applies some batches and 
then throws mid-replay
+   * must count zero mutations. The second batch flush is stubbed to throw 
after the first has
+   * "applied", and the file must then book purely as a failure with no 
throughput recorded.
+   */
+  @Test
+  public void testProcessLogFileMidReplayFailureDoesNotCountMutations() throws 
Exception {
+    final Path filePath = new 
Path(testFolder.newFile("testProcessLogFileMidReplayFailure").toURI());
+    final String tableNameString = "T_" + generateUniqueName();
+    LogFileWriter writer = initLogFileWriter(filePath);
+    // Six single-mutation records with batch size 3 -> two full flushes; the 
second one throws.
+    for (int i = 0; i < 6; i++) {
+      Put put = new Put(Bytes.toBytes("row" + i));
+      put.addColumn(Bytes.toBytes("cf"), Bytes.toBytes("qual"), 
Bytes.toBytes("v" + i));
+      writer.append(tableNameString, i, LogFileTestUtil.cellsOf(put));
+    }
+    writer.close();
+
+    Configuration testConf = new Configuration(conf);
+    
testConf.setInt(ReplicationLogProcessor.REPLICATION_STANDBY_LOG_REPLAY_BATCH_SIZE,
 3);
+    ReplicationLogProcessor spyProcessor =
+      Mockito.spy(new ReplicationLogProcessor(testConf, testHAGroupName));
+    // First flush applies (no-op); the second throws mid-replay after the 
first already "applied".
+    Mockito.doNothing().doThrow(new IOException("injected mid-replay 
failure")).when(spyProcessor)
+      .processReplicationLogBatch(Mockito.any(Map.class));
+    try {
+      spyProcessor.processLogFile(localFs, filePath);
+      fail("processLogFile must propagate the mid-replay failure");
+    } catch (IOException expected) {
+      // expected
+    }
+
+    ReplicationLogProcessorMetricValues metricValues =
+      spyProcessor.getMetrics().getCurrentMetricValues();
+    assertEquals("A mid-replay failure books the file as a failure", 1,
+      metricValues.getLogFileReplayFailureCount());
+    assertEquals("No success is recorded for a file that failed mid-replay", 0,
+      metricValues.getLogFileReplaySuccessCount());
+    // The first batch's mutations were applied, but the throughput write sits 
after whole-file
+    // success and before the catch, so nothing is counted.
+    assertEquals("Mutations applied before a mid-replay failure must not be 
counted", 0,
+      metricValues.getSuccessfulFileMutationsReplayedCount());
+    assertEquals("A failed file must not populate the mutationsPerFile 
histogram", 0,
+      metricValues.getMutationsPerFile());
+    spyProcessor.close();
+  }
+
   /**
    * A single record's body is a flat cell stream that reconstructs into 
multiple mutations of mixed
    * type (e.g. a batch that upserts some rows and, on one of those rows, also 
nulls a column -- a
diff --git 
a/phoenix-core/src/test/java/org/apache/phoenix/replication/ReplicationLogDiscoveryForwarderTest.java
 
b/phoenix-core/src/test/java/org/apache/phoenix/replication/ReplicationLogDiscoveryForwarderTest.java
index 725a2f0662..c49b1b0f58 100644
--- 
a/phoenix-core/src/test/java/org/apache/phoenix/replication/ReplicationLogDiscoveryForwarderTest.java
+++ 
b/phoenix-core/src/test/java/org/apache/phoenix/replication/ReplicationLogDiscoveryForwarderTest.java
@@ -195,8 +195,8 @@ public class ReplicationLogDiscoveryForwarderTest extends 
ReplicationLogBaseTest
     Path srcB =
       markInProgressSource(localTracker, new Path(shardDir, ts + "_" + originB 
+ ".plog"));
 
-    forwarder.processFile(srcA);
-    forwarder.processFile(srcB);
+    forwarder.processFile(srcA, true);
+    forwarder.processFile(srcB, true);
 
     // Both files must land on the peer under their own origin identity -- no 
collision.
     Path peerShardDir = peerShardManager.getShardDirectory(ts);
@@ -314,7 +314,7 @@ public class ReplicationLogDiscoveryForwarderTest extends 
ReplicationLogBaseTest
       }
     }).when(peerFs).rename(eq(staging), eq(dst));
 
-    logGroup.getLogForwarder().processFile(src);
+    logGroup.getLogForwarder().processFile(src, true);
 
     assertTrue("rename interceptor should have run", invariantHeld[0]);
     assertTrue(".plog must exist after rename", peerFs.exists(dst));
@@ -346,7 +346,7 @@ public class ReplicationLogDiscoveryForwarderTest extends 
ReplicationLogBaseTest
       out.write(existing);
     }
 
-    logGroup.getLogForwarder().processFile(src);
+    logGroup.getLogForwarder().processFile(src, true);
 
     assertTrue("dst should still exist", peerFs.exists(dst));
     assertFalse("staging file should be cleaned up", peerFs.exists(staging));
@@ -371,7 +371,7 @@ public class ReplicationLogDiscoveryForwarderTest extends 
ReplicationLogBaseTest
       out.write("stale-garbage-bytes".getBytes());
     }
 
-    logGroup.getLogForwarder().processFile(src);
+    logGroup.getLogForwarder().processFile(src, true);
 
     assertTrue("dst should be published", peerFs.exists(dst));
     assertFalse("staging file should be gone", peerFs.exists(staging));
@@ -395,7 +395,7 @@ public class ReplicationLogDiscoveryForwarderTest extends 
ReplicationLogBaseTest
     doReturn(false).when(peerFs).rename(eq(staging), eq(dst));
 
     try {
-      logGroup.getLogForwarder().processFile(src);
+      logGroup.getLogForwarder().processFile(src, true);
       fail("processFile should have thrown on rename failure");
     } catch (IOException expected) {
       // expected
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 db9ae36fc2..dfe2aa66d9 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
@@ -329,7 +329,8 @@ public class ReplicationLogDiscoveryTest {
     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();
+    long onTick =
+      5 * roundTimeMs + discovery.bufferMillis + 
discovery.getAlignedDelayEpsilonMillis();
     AtomicLong mockTime = new AtomicLong(onTick);
     EnvironmentEdgeManager.injectEdge(new EnvironmentEdge() {
       @Override
@@ -1070,6 +1071,60 @@ public class ReplicationLogDiscoveryTest {
     }
   }
 
+  /**
+   * Metric #6 (roundsExceedingRoundTime) recording site: a round whose 
new-file processing
+   * completes within the round time must NOT increment the counter. Freezing 
the clock makes the
+   * measured duration exactly 0, which is well under {@code roundTimeMills}.
+   */
+  @Test
+  public void testProcessNewFilesForRoundDoesNotCountFastRound() throws 
IOException {
+    long baseSlowRounds =
+      
metricsLogDiscovery.getCurrentMetricValues().getRoundsExceedingRoundTime();
+
+    // Freeze the clock so startTime == endTime, giving duration == 0 (<= 
roundTimeMills).
+    EnvironmentEdge frozenEdge = () -> 1704153600000L;
+    EnvironmentEdgeManager.injectEdge(frozenEdge);
+    try {
+      // Empty round: no files created for it, so processing returns without 
entering the loop.
+      ReplicationRound emptyRound = new ReplicationRound(1704153600000L, 
1704153660000L);
+      discovery.processNewFilesForRound(emptyRound);
+    } finally {
+      EnvironmentEdgeManager.reset();
+    }
+
+    assertEquals("A fast round must not increment roundsExceedingRoundTime", 
baseSlowRounds,
+      
metricsLogDiscovery.getCurrentMetricValues().getRoundsExceedingRoundTime());
+  }
+
+  /**
+   * Metric #6 (roundsExceedingRoundTime) recording site: a round whose 
new-file processing exceeds
+   * the round time must increment the counter exactly once. An advancing 
clock whose step exceeds a
+   * full round guarantees the measured duration (end - start) strictly exceeds
+   * {@code roundTimeMills} regardless of how many times the wall clock is 
read during processing.
+   */
+  @Test
+  public void testProcessNewFilesForRoundCountsSlowRound() throws IOException {
+    long baseSlowRounds =
+      
metricsLogDiscovery.getCurrentMetricValues().getRoundsExceedingRoundTime();
+
+    long step = discovery.getRoundTimeMills() + 1L;
+    AtomicLong clock = new AtomicLong(1704153600000L);
+    EnvironmentEdge advancingEdge = () -> clock.getAndAdd(step);
+    EnvironmentEdgeManager.injectEdge(advancingEdge);
+    try {
+      // Empty round: the start/end clock reads alone span more than a full 
round, so duration
+      // strictly exceeds roundTimeMills.
+      ReplicationRound emptyRound = new ReplicationRound(1704153600000L, 
1704153660000L);
+      discovery.processNewFilesForRound(emptyRound);
+    } finally {
+      EnvironmentEdgeManager.reset();
+    }
+
+    assertEquals("A slow round must increment roundsExceedingRoundTime by 
exactly one",
+      baseSlowRounds + 1,
+      
metricsLogDiscovery.getCurrentMetricValues().getRoundsExceedingRoundTime());
+  }
+
   /**
    * Tests partial failure handling during new file processing. Validates that 
successful files are
    * marked completed while failed files are marked failed.
@@ -1086,12 +1141,14 @@ public class ReplicationLogDiscoveryTest {
     // matching
     String file1Prefix = newFilesForRound.get(1).getName().substring(0,
       newFilesForRound.get(1).getName().lastIndexOf("."));
-    Mockito.doThrow(new IOException("Processing failed for file 
1")).when(discovery)
-      .processFile(Mockito.argThat(path -> 
extractPrefix(path.getName()).equals(file1Prefix)));
+    Mockito.doThrow(new IOException("Processing failed for file 
1")).when(discovery).processFile(
+      Mockito.argThat(path -> 
extractPrefix(path.getName()).equals(file1Prefix)),
+      Mockito.anyBoolean());
     String file3Prefix = newFilesForRound.get(3).getName().substring(0,
       newFilesForRound.get(3).getName().lastIndexOf("."));
-    Mockito.doThrow(new IOException("Processing failed for file 
3")).when(discovery)
-      .processFile(Mockito.argThat(path -> 
extractPrefix(path.getName()).equals(file3Prefix)));
+    Mockito.doThrow(new IOException("Processing failed for file 
3")).when(discovery).processFile(
+      Mockito.argThat(path -> 
extractPrefix(path.getName()).equals(file3Prefix)),
+      Mockito.anyBoolean());
 
     // Process new files for the round
     discovery.processNewFilesForRound(replicationRound);
@@ -1105,15 +1162,20 @@ public class ReplicationLogDiscoveryTest {
         Mockito.argThat(path -> 
path.getName().startsWith(expectedFile.getName().split("\\.")[0])));
     }
 
-    // Verify that processFile was called for each file in the round
-    Mockito.verify(discovery, 
Mockito.times(5)).processFile(Mockito.any(Path.class));
+    // Verify that processFile was called for each file in the round, pinning 
firstClaim=true. The
+    // new-files path is the first claim; pickup lag is recorded only there 
(see
+    // ReplicationLogDiscoveryReplay#processFile). eq(true) guards the literal 
in
+    // ReplicationLogDiscovery#processNewFilesForRound against a future swap.
+    Mockito.verify(discovery, 
Mockito.times(5)).processFile(Mockito.any(Path.class),
+      Mockito.eq(true));
 
     // Verify that processFile was called for each specific file (using prefix 
matching)
     for (Path expectedFile : newFilesForRound) {
       String expectedPrefix =
         expectedFile.getName().substring(0, 
expectedFile.getName().lastIndexOf("."));
-      Mockito.verify(discovery, Mockito.times(1))
-        .processFile(Mockito.argThat(path -> 
extractPrefix(path.getName()).equals(expectedPrefix)));
+      Mockito.verify(discovery, Mockito.times(1)).processFile(
+        Mockito.argThat(path -> 
extractPrefix(path.getName()).equals(expectedPrefix)),
+        Mockito.anyBoolean());
     }
 
     // Verify that markCompleted was called for each successfully processed 
file
@@ -1176,14 +1238,16 @@ public class ReplicationLogDiscoveryTest {
       String filePrefix = file.getName().substring(0, 
file.getName().lastIndexOf("."));
       Mockito.doThrow(new IOException("Processing failed for file: " + 
file.getName()))
         .when(discovery)
-        .processFile(Mockito.argThat(path -> 
extractPrefix(path.getName()).equals(filePrefix)));
+        .processFile(Mockito.argThat(path -> 
extractPrefix(path.getName()).equals(filePrefix)),
+          Mockito.anyBoolean());
     }
 
     // Process new files for the round
     discovery.processNewFilesForRound(replicationRound);
 
     // Verify that processFile was called for each file in the round
-    Mockito.verify(discovery, 
Mockito.times(5)).processFile(Mockito.any(Path.class));
+    Mockito.verify(discovery, 
Mockito.times(5)).processFile(Mockito.any(Path.class),
+      Mockito.anyBoolean());
 
     // Verify that markInProgress was called 5 times (before processing fails)
     Mockito.verify(fileTracker, 
Mockito.times(5)).markInProgress(Mockito.any(Path.class));
@@ -1198,8 +1262,9 @@ public class ReplicationLogDiscoveryTest {
     for (Path expectedFile : newFilesForRound) {
       String expectedPrefix =
         expectedFile.getName().substring(0, 
expectedFile.getName().lastIndexOf("."));
-      Mockito.verify(discovery, Mockito.times(1))
-        .processFile(Mockito.argThat(path -> 
extractPrefix(path.getName()).equals(expectedPrefix)));
+      Mockito.verify(discovery, Mockito.times(1)).processFile(
+        Mockito.argThat(path -> 
extractPrefix(path.getName()).equals(expectedPrefix)),
+        Mockito.anyBoolean());
     }
 
     // Verify that markCompleted was NOT called for any file (all failed)
@@ -1377,11 +1442,13 @@ public class ReplicationLogDiscoveryTest {
     String file1Prefix = extractPrefix(allInProgressFiles.get(1).getName());
     Mockito.doThrow(new IOException("Processing failed for file 
1")).doCallRealMethod()
       .when(discovery)
-      .processFile(Mockito.argThat(path -> 
extractPrefix(path.getName()).equals(file1Prefix)));
+      .processFile(Mockito.argThat(path -> 
extractPrefix(path.getName()).equals(file1Prefix)),
+        Mockito.anyBoolean());
     String file3Prefix = extractPrefix(allInProgressFiles.get(3).getName());
     Mockito.doThrow(new IOException("Processing failed for file 
3")).doCallRealMethod()
       .when(discovery)
-      .processFile(Mockito.argThat(path -> 
extractPrefix(path.getName()).equals(file3Prefix)));
+      .processFile(Mockito.argThat(path -> 
extractPrefix(path.getName()).equals(file3Prefix)),
+        Mockito.anyBoolean());
 
     // Inject an advancing clock so that rename timestamps from markInProgress 
are always
     // strictly before the renameTimestampThreshold computed on the next loop 
iteration
@@ -1406,8 +1473,11 @@ public class ReplicationLogDiscoveryTest {
       }
 
       // Verify that processFile was called for each file in the directory 
(i.e. 5 + 2 times for
-      // failed once that would succeed in next retry)
-      Mockito.verify(discovery, 
Mockito.times(7)).processFile(Mockito.any(Path.class));
+      // failed once that would succeed in next retry), pinning 
firstClaim=false. The in-progress
+      // path is a reclaim, so pickup lag is deliberately not recorded there; 
eq(false) guards the
+      // literal in ReplicationLogDiscovery#processInProgressDirectory against 
a future swap.
+      Mockito.verify(discovery, 
Mockito.times(7)).processFile(Mockito.any(Path.class),
+        Mockito.eq(false));
 
       // Verify that processFile was called for each specific file (using 
prefix matching)
       // Files 1 and 3 should be called twice (fail once, succeed on retry), 
others once
@@ -1417,7 +1487,8 @@ public class ReplicationLogDiscoveryTest {
         int expectedTimes = (i == 1 || i == 3) ? 2 : 1; // Files 1 and 3 are 
called twice (fail +
                                                         // retry success)
         Mockito.verify(discovery, Mockito.times(expectedTimes)).processFile(
-          Mockito.argThat(path -> 
extractPrefix(path.getName()).equals(expectedPrefix)));
+          Mockito.argThat(path -> 
extractPrefix(path.getName()).equals(expectedPrefix)),
+          Mockito.anyBoolean());
       }
 
       // Verify that markCompleted was called for each successfully processed 
file
@@ -1471,8 +1542,9 @@ public class ReplicationLogDiscoveryTest {
 
     // Mock processFile to always throw for file 1 (persistent failure)
     String file1Prefix = extractPrefix(inProgressFiles.get(1).getName());
-    Mockito.doThrow(new IOException("Persistent failure for file 
1")).when(discovery)
-      .processFile(Mockito.argThat(path -> 
extractPrefix(path.getName()).equals(file1Prefix)));
+    Mockito.doThrow(new IOException("Persistent failure for file 
1")).when(discovery).processFile(
+      Mockito.argThat(path -> 
extractPrefix(path.getName()).equals(file1Prefix)),
+      Mockito.anyBoolean());
 
     // Process in-progress directory
     discovery.processInProgressDirectory();
@@ -1483,7 +1555,8 @@ public class ReplicationLogDiscoveryTest {
     Mockito.verify(fileTracker, 
Mockito.times(3)).markInProgress(Mockito.any(Path.class));
 
     // processFile called 3 times (all files attempted once)
-    Mockito.verify(discovery, 
Mockito.times(3)).processFile(Mockito.any(Path.class));
+    Mockito.verify(discovery, 
Mockito.times(3)).processFile(Mockito.any(Path.class),
+      Mockito.anyBoolean());
 
     // markCompleted called only for the 2 successful files
     Mockito.verify(fileTracker, 
Mockito.times(2)).markCompleted(Mockito.any(Path.class));
@@ -1492,16 +1565,19 @@ public class ReplicationLogDiscoveryTest {
     Mockito.verify(fileTracker, 
Mockito.times(1)).markFailed(Mockito.any(Path.class));
 
     // Verify the failed file was NOT retried (processFile called exactly once 
for file1)
-    Mockito.verify(discovery, Mockito.times(1))
-      .processFile(Mockito.argThat(path -> 
extractPrefix(path.getName()).equals(file1Prefix)));
+    Mockito.verify(discovery, Mockito.times(1)).processFile(
+      Mockito.argThat(path -> 
extractPrefix(path.getName()).equals(file1Prefix)),
+      Mockito.anyBoolean());
 
     // Verify the successful files were each called exactly once
     String file0Prefix = extractPrefix(inProgressFiles.get(0).getName());
-    Mockito.verify(discovery, Mockito.times(1))
-      .processFile(Mockito.argThat(path -> 
extractPrefix(path.getName()).equals(file0Prefix)));
+    Mockito.verify(discovery, Mockito.times(1)).processFile(
+      Mockito.argThat(path -> 
extractPrefix(path.getName()).equals(file0Prefix)),
+      Mockito.anyBoolean());
     String file2Prefix = extractPrefix(inProgressFiles.get(2).getName());
-    Mockito.verify(discovery, Mockito.times(1))
-      .processFile(Mockito.argThat(path -> 
extractPrefix(path.getName()).equals(file2Prefix)));
+    Mockito.verify(discovery, Mockito.times(1)).processFile(
+      Mockito.argThat(path -> 
extractPrefix(path.getName()).equals(file2Prefix)),
+      Mockito.anyBoolean());
   }
 
   /**
@@ -1522,22 +1598,26 @@ public class ReplicationLogDiscoveryTest {
 
     // Mock processFile to always throw for all files
     Mockito.doThrow(new IOException("Persistent failure")).when(discovery)
-      .processFile(Mockito.any(Path.class));
+      .processFile(Mockito.any(Path.class), Mockito.anyBoolean());
 
     // Process in-progress directory - should terminate without infinite loop
     discovery.processInProgressDirectory();
 
     // Each file attempted exactly once
     Mockito.verify(fileTracker, 
Mockito.times(3)).markInProgress(Mockito.any(Path.class));
-    Mockito.verify(discovery, 
Mockito.times(3)).processFile(Mockito.any(Path.class));
+    Mockito.verify(discovery, 
Mockito.times(3)).processFile(Mockito.any(Path.class),
+      Mockito.anyBoolean());
 
     // Verify per-prefix: each file attempted once
-    Mockito.verify(discovery, Mockito.times(1))
-      .processFile(Mockito.argThat(path -> 
extractPrefix(path.getName()).equals(file0Prefix)));
-    Mockito.verify(discovery, Mockito.times(1))
-      .processFile(Mockito.argThat(path -> 
extractPrefix(path.getName()).equals(file1Prefix)));
-    Mockito.verify(discovery, Mockito.times(1))
-      .processFile(Mockito.argThat(path -> 
extractPrefix(path.getName()).equals(file2Prefix)));
+    Mockito.verify(discovery, Mockito.times(1)).processFile(
+      Mockito.argThat(path -> 
extractPrefix(path.getName()).equals(file0Prefix)),
+      Mockito.anyBoolean());
+    Mockito.verify(discovery, Mockito.times(1)).processFile(
+      Mockito.argThat(path -> 
extractPrefix(path.getName()).equals(file1Prefix)),
+      Mockito.anyBoolean());
+    Mockito.verify(discovery, Mockito.times(1)).processFile(
+      Mockito.argThat(path -> 
extractPrefix(path.getName()).equals(file2Prefix)),
+      Mockito.anyBoolean());
 
     // All files marked as failed
     Mockito.verify(fileTracker, 
Mockito.times(3)).markFailed(Mockito.any(Path.class));
@@ -1588,7 +1668,8 @@ public class ReplicationLogDiscoveryTest {
       // Mock processFile to fail for file 1 only on the first call, succeed 
on subsequent calls
       Mockito.doThrow(new IOException("Transient failure for file 
1")).doCallRealMethod()
         .when(discovery)
-        .processFile(Mockito.argThat(path -> 
extractPrefix(path.getName()).equals(file1Prefix)));
+        .processFile(Mockito.argThat(path -> 
extractPrefix(path.getName()).equals(file1Prefix)),
+          Mockito.anyBoolean());
 
       // --- First invocation: file 1 fails, files 0 and 2 succeed ---
       discovery.processInProgressDirectory();
@@ -1602,12 +1683,15 @@ public class ReplicationLogDiscoveryTest {
         .markInProgress(Mockito.argThat(path -> 
extractPrefix(path.getName()).equals(file2Prefix)));
 
       // Verify processFile called once per file
-      Mockito.verify(discovery, Mockito.times(1))
-        .processFile(Mockito.argThat(path -> 
extractPrefix(path.getName()).equals(file0Prefix)));
-      Mockito.verify(discovery, Mockito.times(1))
-        .processFile(Mockito.argThat(path -> 
extractPrefix(path.getName()).equals(file1Prefix)));
-      Mockito.verify(discovery, Mockito.times(1))
-        .processFile(Mockito.argThat(path -> 
extractPrefix(path.getName()).equals(file2Prefix)));
+      Mockito.verify(discovery, Mockito.times(1)).processFile(
+        Mockito.argThat(path -> 
extractPrefix(path.getName()).equals(file0Prefix)),
+        Mockito.anyBoolean());
+      Mockito.verify(discovery, Mockito.times(1)).processFile(
+        Mockito.argThat(path -> 
extractPrefix(path.getName()).equals(file1Prefix)),
+        Mockito.anyBoolean());
+      Mockito.verify(discovery, Mockito.times(1)).processFile(
+        Mockito.argThat(path -> 
extractPrefix(path.getName()).equals(file2Prefix)),
+        Mockito.anyBoolean());
 
       // Verify markCompleted called for files 0 and 2 (successful)
       Mockito.verify(fileTracker, Mockito.times(1))
@@ -1665,7 +1749,7 @@ public class ReplicationLogDiscoveryTest {
         assertTrue("Second round rename timestamp should be newer than first 
round's",
           ts.get() > firstRoundRenameTs);
         return true;
-      }));
+      }), Mockito.anyBoolean());
 
       // Verify markCompleted called for file 1
       Mockito.verify(fileTracker, Mockito.times(1))
@@ -2685,7 +2769,7 @@ public class ReplicationLogDiscoveryTest {
     }
 
     @Override
-    protected void processFile(Path path) throws IOException {
+    protected void processFile(Path path, boolean firstClaim) throws 
IOException {
       // Simulate file processing
       processedFiles.add(path);
     }
diff --git 
a/phoenix-core/src/test/java/org/apache/phoenix/replication/ReplicationLogTrackerTest.java
 
b/phoenix-core/src/test/java/org/apache/phoenix/replication/ReplicationLogTrackerTest.java
index 1f2fff3158..81258ddc2e 100644
--- 
a/phoenix-core/src/test/java/org/apache/phoenix/replication/ReplicationLogTrackerTest.java
+++ 
b/phoenix-core/src/test/java/org/apache/phoenix/replication/ReplicationLogTrackerTest.java
@@ -844,6 +844,62 @@ public class ReplicationLogTrackerTest {
     assertFalse("markInProgress should return false for non-existent file", 
result.isPresent());
   }
 
+  @Test
+  public void testMarkInProgressCollisionIncrementsMetricOnRenameFailure() 
throws IOException {
+    // Initialize tracker
+    tracker.init();
+
+    // The static metrics source accumulates across tests in this class (e.g.
+    // testMarkInProgressForNonExistentFile also trips the rename-false path), 
so assert deltas.
+    long baseRenameFailures =
+      
metrics.getCurrentMetricValues().getMarkFileInProgressRenameFailedCount();
+    long baseRequests = 
metrics.getCurrentMetricValues().getMarkFileInProgressRequestCount();
+
+    // Create a real file so the outcome is driven purely by the claim rename, 
not a missing source.
+    ReplicationShardDirectoryManager shardManager = 
tracker.getReplicationShardDirectoryManager();
+    Path shardPath = shardManager.getAllShardPaths().get(0);
+    localFs.mkdirs(shardPath);
+    Path originalFile = new Path(shardPath, "1704153600000_rs1.plog");
+    localFs.create(originalFile, true).close();
+
+    // Force the claim rename to lose, as if another replayer claimed the same 
file first.
+    Mockito.doReturn(false).when(mockFs).rename(Mockito.eq(originalFile), 
Mockito.any(Path.class));
+
+    Optional<Path> result = tracker.markInProgress(originalFile);
+
+    assertFalse("markInProgress must fail when the claim rename loses", 
result.isPresent());
+    assertEquals("Rename-failed count must increment by exactly one on rename 
failure",
+      baseRenameFailures + 1,
+      
metrics.getCurrentMetricValues().getMarkFileInProgressRenameFailedCount());
+    assertEquals("Request count must also increment (collision is a strict 
subset of requests)",
+      baseRequests + 1, 
metrics.getCurrentMetricValues().getMarkFileInProgressRequestCount());
+  }
+
+  @Test
+  public void testMarkInProgressNoCollisionOnRenameSuccess() throws 
IOException {
+    // Initialize tracker
+    tracker.init();
+
+    long baseRenameFailures =
+      
metrics.getCurrentMetricValues().getMarkFileInProgressRenameFailedCount();
+    long baseRequests = 
metrics.getCurrentMetricValues().getMarkFileInProgressRequestCount();
+
+    // Create a real file that the real rename will successfully claim.
+    ReplicationShardDirectoryManager shardManager = 
tracker.getReplicationShardDirectoryManager();
+    Path shardPath = shardManager.getAllShardPaths().get(0);
+    localFs.mkdirs(shardPath);
+    Path originalFile = new Path(shardPath, "1704153600000_rs1.plog");
+    localFs.create(originalFile, true).close();
+
+    Optional<Path> result = tracker.markInProgress(originalFile);
+
+    assertTrue("markInProgress must succeed when the claim rename lands", 
result.isPresent());
+    assertEquals("Rename-failed count must not change on a successful claim", 
baseRenameFailures,
+      
metrics.getCurrentMetricValues().getMarkFileInProgressRenameFailedCount());
+    assertEquals("Request count still increments on a successful claim", 
baseRequests + 1,
+      metrics.getCurrentMetricValues().getMarkFileInProgressRequestCount());
+  }
+
   @Test
   public void testMarkCompletedSuccessfulDeletion() throws IOException {
     // Initialize tracker
diff --git 
a/phoenix-core/src/test/java/org/apache/phoenix/replication/metrics/ReplicationReplayPerfMetricsTest.java
 
b/phoenix-core/src/test/java/org/apache/phoenix/replication/metrics/ReplicationReplayPerfMetricsTest.java
new file mode 100644
index 0000000000..1b6fdf48b5
--- /dev/null
+++ 
b/phoenix-core/src/test/java/org/apache/phoenix/replication/metrics/ReplicationReplayPerfMetricsTest.java
@@ -0,0 +1,119 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.phoenix.replication.metrics;
+
+import static org.junit.Assert.assertEquals;
+
+import org.junit.Test;
+
+/**
+ * Unit tests for the replay performance metrics added under PHOENIX-7992. 
These verify the
+ * metric-source plumbing (registration, recording, and read-back) for the six 
metrics across the
+ * three replay metric sources:
+ * <ul>
+ * <li>{@link MetricsReplicationLogDiscoveryReplayImpl}: endToEndReplayLag, 
pickupLag (histograms)
+ * and roundsExceedingRoundTime (counter)</li>
+ * <li>{@link MetricsReplicationLogProcessorImpl}: 
successfulFileMutationsReplayedCount (counter)
+ * and mutationsPerFile (histogram)</li>
+ * <li>{@link MetricsReplicationLogTrackerReplayImpl}: 
markFileInProgressRenameFailedCount
+ * (counter)</li>
+ * </ul>
+ * The sources are {@code static} and constructed once (mirroring
+ * {@code ReplicationLogDiscoveryTest}) because Hadoop's {@code 
DefaultMetricsSystem} throws on
+ * duplicate source registration and these sources do not all unregister 
cleanly on close(). Counter
+ * assertions are therefore delta-based; each histogram is written by exactly 
one test method, so
+ * its {@code getMax()} is deterministic.
+ */
+public class ReplicationReplayPerfMetricsTest {
+
+  private static final String HA_GROUP = "replayPerfMetricsGroup";
+
+  private static final MetricsReplicationLogDiscoveryReplayImpl DISCOVERY =
+    new MetricsReplicationLogDiscoveryReplayImpl(HA_GROUP);
+  private static final MetricsReplicationLogProcessorImpl PROCESSOR =
+    new MetricsReplicationLogProcessorImpl(HA_GROUP);
+  private static final MetricsReplicationLogTrackerReplayImpl TRACKER =
+    new MetricsReplicationLogTrackerReplayImpl(HA_GROUP);
+
+  /**
+   * Metrics #1 (endToEndReplayLag), #2 (pickupLag), and #6 
(roundsExceedingRoundTime) live on the
+   * replay discovery source. The lag histograms report their max across 
samples; the slow-round
+   * counter increments once per call.
+   */
+  @Test
+  public void testDiscoveryReplayLagAndSlowRoundMetrics() {
+    // #1 endToEndReplayLag histogram: getMax reflects the largest recorded 
sample.
+    DISCOVERY.updateEndToEndReplayLag(1200L);
+    DISCOVERY.updateEndToEndReplayLag(800L);
+    assertEquals("endToEndReplayLag should report the max sample", 1200L,
+      DISCOVERY.getEndToEndReplayLagHistogram().getMax());
+
+    // #2 pickupLag histogram.
+    DISCOVERY.updatePickupLag(300L);
+    DISCOVERY.updatePickupLag(900L);
+    assertEquals("pickupLag should report the max sample", 900L,
+      DISCOVERY.getPickupLagHistogram().getMax());
+
+    // #6 roundsExceedingRoundTime counter.
+    long baseRounds = 
DISCOVERY.getCurrentMetricValues().getRoundsExceedingRoundTime();
+    DISCOVERY.incrementRoundsExceedingRoundTime();
+    DISCOVERY.incrementRoundsExceedingRoundTime();
+    assertEquals("roundsExceedingRoundTime should increment once per call", 
baseRounds + 2,
+      DISCOVERY.getCurrentMetricValues().getRoundsExceedingRoundTime());
+  }
+
+  /**
+   * Metrics #3 (successfulFileMutationsReplayedCount) and #4 
(mutationsPerFile) live on the log
+   * processor source. The count accumulates the supplied delta; the per-file 
histogram reports its
+   * max.
+   */
+  @Test
+  public void testProcessorMutationMetrics() {
+    // #3 successfulFileMutationsReplayedCount counter accumulates by the 
supplied delta.
+    long baseMutations =
+      
PROCESSOR.getCurrentMetricValues().getSuccessfulFileMutationsReplayedCount();
+    PROCESSOR.incrementSuccessfulFileMutationsReplayedCount(150L);
+    PROCESSOR.incrementSuccessfulFileMutationsReplayedCount(50L);
+    assertEquals("successfulFileMutationsReplayedCount should accumulate the 
supplied deltas",
+      baseMutations + 200,
+      
PROCESSOR.getCurrentMetricValues().getSuccessfulFileMutationsReplayedCount());
+
+    // #4 mutationsPerFile histogram reports the max per-file count.
+    PROCESSOR.updateMutationsPerFile(150L);
+    PROCESSOR.updateMutationsPerFile(400L);
+    PROCESSOR.updateMutationsPerFile(30L);
+    assertEquals("mutationsPerFile should report the max per-file count", 400L,
+      PROCESSOR.getCurrentMetricValues().getMutationsPerFile());
+  }
+
+  /**
+   * Metric #5 (markFileInProgressRenameFailedCount) lives on the tracker 
source and increments once
+   * per call.
+   */
+  @Test
+  public void testTrackerRenameFailedMetric() {
+    long baseRenameFailures =
+      
TRACKER.getCurrentMetricValues().getMarkFileInProgressRenameFailedCount();
+    TRACKER.incrementMarkFileInProgressRenameFailedCount();
+    TRACKER.incrementMarkFileInProgressRenameFailedCount();
+    TRACKER.incrementMarkFileInProgressRenameFailedCount();
+    assertEquals("markFileInProgressRenameFailedCount should increment once 
per call",
+      baseRenameFailures + 3,
+      
TRACKER.getCurrentMetricValues().getMarkFileInProgressRenameFailedCount());
+  }
+}
diff --git 
a/phoenix-core/src/test/java/org/apache/phoenix/replication/reader/ReplicationLogDiscoveryReplayProcessFileTest.java
 
b/phoenix-core/src/test/java/org/apache/phoenix/replication/reader/ReplicationLogDiscoveryReplayProcessFileTest.java
new file mode 100644
index 0000000000..1cefdf9340
--- /dev/null
+++ 
b/phoenix-core/src/test/java/org/apache/phoenix/replication/reader/ReplicationLogDiscoveryReplayProcessFileTest.java
@@ -0,0 +1,236 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.phoenix.replication.reader;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import java.util.Optional;
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.fs.FileSystem;
+import org.apache.hadoop.fs.Path;
+import org.apache.hadoop.hbase.util.EnvironmentEdge;
+import org.apache.hadoop.hbase.util.EnvironmentEdgeManager;
+import org.apache.phoenix.replication.ReplicationLogTracker;
+import org.apache.phoenix.replication.ReplicationShardDirectoryManager;
+import 
org.apache.phoenix.replication.metrics.MetricsReplicationLogDiscoveryReplay;
+import org.junit.After;
+import org.junit.Test;
+
+/**
+ * Recording-site tests for the replay lag metrics that drive the real
+ * {@link ReplicationLogDiscoveryReplay#processFile(Path, boolean)} rather 
than exercising the
+ * metric source in isolation. They cover:
+ * <ul>
+ * <li>the {@code getRoundEligibleTime} math the lag samples are anchored to, 
including a file whose
+ * creation timestamp lands exactly on a round boundary (owned by the round 
that starts there);</li>
+ * <li>first-claim gating: pickup lag is recorded on the new-files path 
(firstClaim=true) and not on
+ * an in-progress reclaim (firstClaim=false);</li>
+ * <li>failure handling: a file that throws mid-replay still records pickup 
lag but never records an
+ * end-to-end lag sample.</li>
+ * </ul>
+ * The round duration is configured to 60s and the waiting buffer to 15%, 
giving a round of 60000ms
+ * and a buffer of 9000ms. Rounds are half-open [start, end): a file created 
at 100000ms belongs to
+ * the round [60000, 120000) and becomes eligible at 120000 + 9000 = 129000ms; 
a file created
+ * exactly on the 120000ms boundary belongs to the next round [120000, 180000) 
and is eligible at
+ * 180000 + 9000 = 189000ms.
+ */
+public class ReplicationLogDiscoveryReplayProcessFileTest {
+
+  private static final long ROUND_SECONDS = 60L;
+  /** Eligible instant for a file whose owning round ends at 120000ms: 120000 
+ 9000 buffer. */
+  private static final long ELIGIBLE_MS = 129000L;
+  private static final Path FILE = new 
Path("/replication/inprogress/1_rs_uuid_135000.plog");
+
+  @After
+  public void tearDown() {
+    EnvironmentEdgeManager.reset();
+  }
+
+  /**
+   * First claim of a file whose creation timestamp is strictly inside a 
round: both pickup lag
+   * (rename - eligible) and end-to-end lag (now - eligible) are recorded.
+   */
+  @Test
+  public void testFirstClaimRecordsPickupAndEndToEndLag() throws IOException {
+    ReplicationLogTracker tracker = mock(ReplicationLogTracker.class);
+    MetricsReplicationLogDiscoveryReplay metrics = 
mock(MetricsReplicationLogDiscoveryReplay.class);
+    when(tracker.getFileTimestamp(any())).thenReturn(100000L);
+    when(tracker.getRenameTimestamp(any())).thenReturn(Optional.of(135000L));
+    injectNow(140000L);
+
+    newReplay(tracker, metrics).processFile(FILE, true);
+
+    // eligible = 129000; pickup = 135000 - 129000; endToEnd = 140000 - 129000.
+    verify(metrics).updatePickupLag(135000L - ELIGIBLE_MS);
+    verify(metrics).updateEndToEndReplayLag(140000L - ELIGIBLE_MS);
+  }
+
+  /**
+   * A file created exactly on a round boundary belongs to the round that 
starts at that boundary,
+   * not the one that ends there: rounds are half-open [start, end) and 
getShardDirectory floors the
+   * timestamp, so the boundary file is stored in and replayed by the later 
round. Its owning round
+   * is [120000, 180000), so it becomes eligible at 180000 + 9000 = 189000ms 
-- a full round later
+   * than a file created just inside the previous round. Regression guard 
against anchoring a
+   * boundary file to the round that ends there, which would under-count 
eligibility by one round
+   * and over-report its lag.
+   */
+  @Test
+  public void testFirstClaimOnRoundBoundaryAnchorsToStartingRound() throws 
IOException {
+    ReplicationLogTracker tracker = mock(ReplicationLogTracker.class);
+    MetricsReplicationLogDiscoveryReplay metrics = 
mock(MetricsReplicationLogDiscoveryReplay.class);
+    when(tracker.getFileTimestamp(any())).thenReturn(120000L);
+    when(tracker.getRenameTimestamp(any())).thenReturn(Optional.of(190000L));
+    injectNow(200000L);
+
+    newReplay(tracker, metrics).processFile(FILE, true);
+
+    // Owning round [120000, 180000); eligible = 180000 + 9000 buffer = 189000.
+    long eligible = 189000L;
+    verify(metrics).updatePickupLag(190000L - eligible);
+    verify(metrics).updateEndToEndReplayLag(200000L - eligible);
+  }
+
+  /**
+   * An in-progress reclaim (firstClaim=false) must not record pickup lag -- 
markInProgress
+   * re-stamps a fresh rename timestamp on every reclaim, so a pickup sample 
here would measure
+   * eligible -> latest-reclaim, not eligible -> first-pickup -- but 
end-to-end lag is still
+   * recorded.
+   */
+  @Test
+  public void testReclaimDoesNotRecordPickupLag() throws IOException {
+    ReplicationLogTracker tracker = mock(ReplicationLogTracker.class);
+    MetricsReplicationLogDiscoveryReplay metrics = 
mock(MetricsReplicationLogDiscoveryReplay.class);
+    when(tracker.getFileTimestamp(any())).thenReturn(100000L);
+    injectNow(140000L);
+
+    newReplay(tracker, metrics).processFile(FILE, false);
+
+    verify(metrics, never()).updatePickupLag(anyLong());
+    verify(metrics).updateEndToEndReplayLag(140000L - ELIGIBLE_MS);
+  }
+
+  /**
+   * When replay throws, pickup lag has already been recorded (before the 
replay call) but the
+   * end-to-end lag sample -- which represents a completed replay -- is never 
recorded, and the
+   * failure propagates.
+   */
+  @Test
+  public void testReplayFailureRecordsPickupButNotEndToEndLag() throws 
IOException {
+    ReplicationLogTracker tracker = mock(ReplicationLogTracker.class);
+    MetricsReplicationLogDiscoveryReplay metrics = 
mock(MetricsReplicationLogDiscoveryReplay.class);
+    when(tracker.getFileTimestamp(any())).thenReturn(100000L);
+    when(tracker.getRenameTimestamp(any())).thenReturn(Optional.of(135000L));
+    injectNow(140000L);
+    TestReplay replay = newReplay(tracker, metrics);
+    replay.failReplay = true;
+
+    assertThrows(IOException.class, () -> replay.processFile(FILE, true));
+
+    verify(metrics).updatePickupLag(135000L - ELIGIBLE_MS);
+    verify(metrics, never()).updateEndToEndReplayLag(anyLong());
+  }
+
+  /**
+   * A malformed in-progress file name (getFileTimestamp throws 
NumberFormatException) must surface
+   * as an IOException -- the type processOneRandomFile catches and routes 
through markFailed --
+   * rather than an unchecked exception that would escape the per-file handler 
and abort the whole
+   * sweep. Nothing is replayed and no lag sample is recorded.
+   */
+  @Test
+  public void testMalformedFileNameSurfacesAsIOException() throws IOException {
+    ReplicationLogTracker tracker = mock(ReplicationLogTracker.class);
+    MetricsReplicationLogDiscoveryReplay metrics = 
mock(MetricsReplicationLogDiscoveryReplay.class);
+    when(tracker.getFileTimestamp(any())).thenThrow(new 
NumberFormatException("bad name"));
+    TestReplay replay = newReplay(tracker, metrics);
+
+    assertThrows(IOException.class, () -> replay.processFile(FILE, true));
+
+    assertFalse("replay must not run when the file name cannot be parsed", 
replay.replayInvoked);
+    verify(metrics, never()).updatePickupLag(anyLong());
+    verify(metrics, never()).updateEndToEndReplayLag(anyLong());
+  }
+
+  private static void injectNow(long now) {
+    EnvironmentEdgeManager.injectEdge(new EnvironmentEdge() {
+      @Override
+      public long currentTime() {
+        return now;
+      }
+    });
+  }
+
+  private static TestReplay newReplay(ReplicationLogTracker tracker,
+    MetricsReplicationLogDiscoveryReplay metrics) {
+    // Use a real ReplicationShardDirectoryManager, not a mock: its 
constructor only reads config
+    // and never touches the file system, so getRoundEligibleTime's round math 
uses the real
+    // getNearestRoundStartTimestamp instead of a stubbed constant that would 
otherwise re-encode
+    // the same floor formula this test verifies.
+    Configuration conf = new Configuration(false);
+    
conf.setInt(ReplicationShardDirectoryManager.PHOENIX_REPLICATION_ROUND_DURATION_SECONDS_KEY,
+      (int) ROUND_SECONDS);
+    ReplicationShardDirectoryManager shardManager =
+      new ReplicationShardDirectoryManager(conf, mock(FileSystem.class), new 
Path("/replication"));
+    
when(tracker.getReplicationShardDirectoryManager()).thenReturn(shardManager);
+    return new TestReplay(tracker, metrics);
+  }
+
+  /**
+   * Minimal subclass that stands in a mocked metrics source and 
short-circuits the actual log-file
+   * replay, so only the lag-recording logic in {@code processFile} is under 
test. Overriding
+   * {@link #getWaitingBufferPercentage()} to a constant keeps the base 
constructor off the (unset)
+   * Configuration, fixing the buffer at 15% of the round.
+   */
+  private static final class TestReplay extends ReplicationLogDiscoveryReplay {
+
+    private final MetricsReplicationLogDiscoveryReplay replayMetricsMock;
+    private boolean failReplay;
+    private boolean replayInvoked;
+
+    TestReplay(ReplicationLogTracker tracker,
+      MetricsReplicationLogDiscoveryReplay replayMetricsMock) {
+      super(tracker);
+      this.replayMetricsMock = replayMetricsMock;
+    }
+
+    @Override
+    public double getWaitingBufferPercentage() {
+      return 15.0;
+    }
+
+    @Override
+    protected MetricsReplicationLogDiscoveryReplay getReplayMetrics() {
+      return replayMetricsMock;
+    }
+
+    @Override
+    protected void replayLogFile(Path path) throws IOException {
+      replayInvoked = true;
+      if (failReplay) {
+        throw new IOException("injected replay failure");
+      }
+    }
+  }
+}

Reply via email to