This is an automated email from the ASF dual-hosted git repository.

lewismc pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/nutch.git


The following commit(s) were added to refs/heads/master by this push:
     new 101328833 NUTCH-3162 Fetcher and Parser latency metrics to properly 
merge data from all threads and tasks (#906)
101328833 is described below

commit 101328833f02e0ec59d1fb3772065a4c5fcf5ed8
Author: Lewis John McGibbney <[email protected]>
AuthorDate: Tue Jul 21 19:16:42 2026 -0700

    NUTCH-3162 Fetcher and Parser latency metrics to properly merge data from 
all threads and tasks (#906)
---
 src/java/org/apache/nutch/fetcher/Fetcher.java     |  68 ++++-
 .../org/apache/nutch/fetcher/FetcherThread.java    |  10 +-
 .../org/apache/nutch/metrics/ErrorTracker.java     |  29 ++-
 .../org/apache/nutch/metrics/LatencyTracker.java   | 145 +++++++++--
 .../org/apache/nutch/metrics/NutchMetrics.java     |   7 +
 src/java/org/apache/nutch/parse/ParseSegment.java  |  81 +++++-
 .../apache/nutch/fetcher/TestFetcherReducer.java   | 105 ++++++++
 .../org/apache/nutch/metrics/LatencyTestUtil.java  | 142 +++++++++++
 .../apache/nutch/metrics/TestLatencyTracker.java   | 284 +++++++++++++++++++++
 .../org/apache/nutch/parse/TestParseSegment.java   |  64 ++++-
 .../apache/nutch/util/ReducerContextWrapper.java   |  10 +
 11 files changed, 893 insertions(+), 52 deletions(-)

diff --git a/src/java/org/apache/nutch/fetcher/Fetcher.java 
b/src/java/org/apache/nutch/fetcher/Fetcher.java
index cffd024a0..acf8a5728 100644
--- a/src/java/org/apache/nutch/fetcher/Fetcher.java
+++ b/src/java/org/apache/nutch/fetcher/Fetcher.java
@@ -34,12 +34,14 @@ import org.apache.commons.lang3.time.StopWatch;
 import org.apache.hadoop.conf.Configuration;
 import org.apache.hadoop.fs.FileStatus;
 import org.apache.hadoop.fs.Path;
+import org.apache.hadoop.io.BytesWritable;
 import org.apache.hadoop.io.Text;
 import org.apache.hadoop.mapreduce.Counter;
 import org.apache.hadoop.mapreduce.InputSplit;
 import org.apache.hadoop.mapreduce.Job;
 import org.apache.hadoop.mapreduce.JobContext;
 import org.apache.hadoop.mapreduce.Mapper;
+import org.apache.hadoop.mapreduce.Reducer;
 import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
 import org.apache.hadoop.mapreduce.lib.input.FileSplit;
 import org.apache.hadoop.mapreduce.lib.input.SequenceFileInputFormat;
@@ -47,9 +49,12 @@ import 
org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;
 import org.apache.hadoop.util.StringUtils;
 import org.apache.hadoop.util.Tool;
 import org.apache.hadoop.util.ToolRunner;
+
+import com.tdunning.math.stats.MergingDigest;
 import org.apache.nutch.crawl.CrawlDatum;
 import org.apache.nutch.crawl.NutchWritable;
 import org.apache.nutch.metadata.Nutch;
+import org.apache.nutch.metrics.LatencyTracker;
 import org.apache.nutch.metrics.NutchMetrics;
 import org.apache.nutch.util.MimeUtil;
 import org.apache.nutch.util.NutchConfiguration;
@@ -223,9 +228,9 @@ public class Fetcher extends NutchTool implements Tool {
 
       setup(innerContext);
       initCounters(innerContext);
+      LinkedList<FetcherThread> fetcherThreads = new LinkedList<>();
       try {
         Configuration conf = innerContext.getConfiguration();
-        LinkedList<FetcherThread> fetcherThreads = new LinkedList<>();
         FetchItemQueues fetchQueues = new FetchItemQueues(conf);
         QueueFeeder feeder;
 
@@ -499,11 +504,70 @@ public class Fetcher extends NutchTool implements Tool {
         } while (activeThreads.get() > 0);
         LOG.info("-activeThreads={}", activeThreads);
       } finally {
+        // Merge all thread latency trackers and emit once; emit TDigest for 
reducer
+        LatencyTracker mergedLatencyTracker = new LatencyTracker(
+            NutchMetrics.GROUP_FETCHER, NutchMetrics.FETCHER_LATENCY);
+        for (FetcherThread fetcherThread : fetcherThreads) {
+          mergedLatencyTracker.merge(fetcherThread.getFetchLatencyTracker());
+        }
+        mergedLatencyTracker.emitCountAndSumOnly(innerContext);
+        byte[] digestBytes = mergedLatencyTracker.toBytes();
+        if (digestBytes.length > 0) {
+          innerContext.write(new Text(NutchMetrics.LATENCY_KEY),
+              new NutchWritable(new BytesWritable(digestBytes)));
+        }
         cleanup(innerContext);
       }
     }
   }
 
+  /**
+   * Reducer that passes through (url, datum) records and merges TDigests from
+   * map tasks to set job-level latency percentile counters.
+   */
+  public static class FetcherReducer extends
+      Reducer<Text, NutchWritable, Text, NutchWritable> {
+
+    private static final Text LATENCY_KEY = new Text(NutchMetrics.LATENCY_KEY);
+
+    @Override
+    public void reduce(Text key, Iterable<NutchWritable> values,
+        Context context) throws IOException, InterruptedException {
+      if (key.equals(LATENCY_KEY)) {
+        MergingDigest mergedDigest = null;
+        for (NutchWritable nutchWritable : values) {
+          if (nutchWritable.get() instanceof BytesWritable) {
+            BytesWritable digestBytesWritable = (BytesWritable) 
nutchWritable.get();
+            byte[] digestBytes = digestBytesWritable.copyBytes();
+            if (digestBytes != null && digestBytes.length > 0) {
+              MergingDigest digest = LatencyTracker.fromBytes(digestBytes);
+              if (digest != null) {
+                if (mergedDigest == null) {
+                  mergedDigest = digest;
+                } else {
+                  mergedDigest.add(digest);
+                }
+              }
+            }
+          }
+        }
+        // Set only percentile counters; count_total and sum_ms are already 
correct from task aggregation
+        if (mergedDigest != null) {
+          context.getCounter(NutchMetrics.GROUP_FETCHER,
+              NutchMetrics.FETCHER_LATENCY + 
LatencyTracker.SUFFIX_P50_MS).setValue((long) mergedDigest.quantile(0.50));
+          context.getCounter(NutchMetrics.GROUP_FETCHER,
+              NutchMetrics.FETCHER_LATENCY + 
LatencyTracker.SUFFIX_P95_MS).setValue((long) mergedDigest.quantile(0.95));
+          context.getCounter(NutchMetrics.GROUP_FETCHER,
+              NutchMetrics.FETCHER_LATENCY + 
LatencyTracker.SUFFIX_P99_MS).setValue((long) mergedDigest.quantile(0.99));
+        }
+        return;
+      }
+      for (NutchWritable value : values) {
+        context.write(key, value);
+      }
+    }
+  }
+
   public void fetch(Path segment, int threads) throws IOException,
     InterruptedException, ClassNotFoundException {
 
@@ -566,6 +630,8 @@ public class Fetcher extends NutchTool implements Tool {
     job.setInputFormatClass(InputFormat.class);
     job.setJarByClass(Fetcher.class);
     job.setMapperClass(Fetcher.FetcherRun.class);
+    job.setReducerClass(Fetcher.FetcherReducer.class);
+    job.setNumReduceTasks(1);
 
     FileOutputFormat.setOutputPath(job, segment);
     job.setOutputFormatClass(FetcherOutputFormat.class);
diff --git a/src/java/org/apache/nutch/fetcher/FetcherThread.java 
b/src/java/org/apache/nutch/fetcher/FetcherThread.java
index 4a4810d8e..12712aeca 100644
--- a/src/java/org/apache/nutch/fetcher/FetcherThread.java
+++ b/src/java/org/apache/nutch/fetcher/FetcherThread.java
@@ -551,8 +551,6 @@ public class FetcherThread extends Thread {
       if (fit != null) {
         fetchQueues.finishFetchItem(fit);
       }
-      // Emit fetch latency metrics
-      fetchLatencyTracker.emitCounters(context);
       // Emit error metrics
       errorTracker.emitCounters(context);
       activeThreads.decrementAndGet(); // count threads
@@ -561,6 +559,14 @@ public class FetcherThread extends Thread {
     }
   }
 
+  /**
+   * Returns the fetch latency tracker for this thread so the mapper can merge
+   * all thread trackers and emit job-level percentiles.
+   */
+  public LatencyTracker getFetchLatencyTracker() {
+    return fetchLatencyTracker;
+  }
+
   private Text handleRedirect(FetchItem fit, String newUrl,
       boolean temp, String redirType)
       throws MalformedURLException, URLFilterException, InterruptedException {
diff --git a/src/java/org/apache/nutch/metrics/ErrorTracker.java 
b/src/java/org/apache/nutch/metrics/ErrorTracker.java
index 192107160..efc97a09f 100644
--- a/src/java/org/apache/nutch/metrics/ErrorTracker.java
+++ b/src/java/org/apache/nutch/metrics/ErrorTracker.java
@@ -34,10 +34,12 @@ import org.apache.hadoop.mapreduce.TaskInputOutputContext;
  * based on exception type. It uses a bounded set of error categories to stay 
within
  * Hadoop's counter limits (~120 counters).
  * 
- * <p>Usage:
+ * <p><b>Usage in mapper/reducer or task threads:</b>
  * <pre>
  * // In mapper/reducer setup or thread initialization
  * errorTracker = new ErrorTracker(NutchMetrics.GROUP_FETCHER);
+ * // or with context for cached counters:
+ * errorTracker = new ErrorTracker(NutchMetrics.GROUP_FETCHER, context);
  * 
  * // When catching exceptions
  * try {
@@ -49,11 +51,20 @@ import org.apache.hadoop.mapreduce.TaskInputOutputContext;
  * // Or with manual categorization
  * errorTracker.recordError(ErrorTracker.ErrorType.NETWORK);
  * 
- * // In cleanup - emit all error counters
+ * // In cleanup - emit all error counters to the job
  * errorTracker.emitCounters(context);
  * </pre>
  * 
- * <p>Emits the following counters:
+ * <p><b>Usage in driver/client code (no task context):</b>
+ * When used in a job driver or other code that does not run inside a 
mapper/reducer,
+ * create an ErrorTracker with the single-argument constructor (counter group 
only).
+ * Call {@link #recordError(Throwable)} or {@link 
#recordError(ErrorTracker.ErrorType)}
+ * for consistent error categorization. Do <em>not</em> call {@link 
#emitCounters(TaskInputOutputContext)};
+ * Hadoop counters can only be written from within a task, so counts remain 
in-memory only.
+ * This allows the same categorization and logging pattern (e.g. with 
LOG.error) as in
+ * tasks, without emitting to job counters.
+ *
+ * <p>Emits the following counters (when used inside a task and emitCounters 
is called):
  * <ul>
  *   <li>errors_total - total number of errors across all categories</li>
  *   <li>errors_network_total - network-related errors</li>
@@ -104,9 +115,15 @@ public class ErrorTracker {
   /**
    * Creates a new ErrorTracker for the specified counter group.
    * 
-   * <p>This constructor creates an ErrorTracker without cached counters.
-   * Call {@link #initCounters(TaskInputOutputContext)} in setup() to cache
-   * counter references for better performance.
+   * <p>Use in mapper/reducer setup or thread initialization: call
+   * {@link #initCounters(TaskInputOutputContext)} in setup() to cache counter
+   * references, then {@link #emitCounters(TaskInputOutputContext)} in cleanup 
to
+   * emit counts to the job.
+   *
+   * <p>Use in driver/client code (no task context): do not call initCounters 
or
+   * emitCounters. Only {@link #recordError(Throwable)} and
+   * {@link #recordError(ErrorTracker.ErrorType)} are used; counts stay 
in-memory
+   * for consistent categorization and logging (e.g. with LOG.error).
    * 
    * @param group the Hadoop counter group name (e.g., 
NutchMetrics.GROUP_FETCHER)
    */
diff --git a/src/java/org/apache/nutch/metrics/LatencyTracker.java 
b/src/java/org/apache/nutch/metrics/LatencyTracker.java
index 3777bb29e..5725b1ddb 100644
--- a/src/java/org/apache/nutch/metrics/LatencyTracker.java
+++ b/src/java/org/apache/nutch/metrics/LatencyTracker.java
@@ -16,31 +16,36 @@
  */
 package org.apache.nutch.metrics;
 
+import java.nio.ByteBuffer;
+
 import org.apache.hadoop.mapreduce.TaskInputOutputContext;
 
+import com.tdunning.math.stats.MergingDigest;
 import com.tdunning.math.stats.TDigest;
 
 /**
  * A utility class for tracking latency metrics using TDigest for percentile
  * calculation.
- * 
- * <p>This class wraps a TDigest data structure to collect latency samples and
+ *
+ * <p>This class wraps a MergingDigest data structure to collect latency 
samples and
  * emit Hadoop counters with count, sum, and percentile values (p50, p95, p99).
- * 
+ * MergingDigest supports merging digests from multiple tasks for job-level 
percentile
+ * computation.
+ *
  * <p>Usage:
  * <pre>
  * // In mapper/reducer setup
  * latencyTracker = new LatencyTracker(NutchMetrics.GROUP_FETCHER, 
NutchMetrics.FETCHER_LATENCY);
- * 
+ *
  * // During processing
  * long start = System.currentTimeMillis();
  * // ... operation ...
  * latencyTracker.record(System.currentTimeMillis() - start);
- * 
+ *
  * // In cleanup
  * latencyTracker.emitCounters(context);
  * </pre>
- * 
+ *
  * <p>Emits the following counters:
  * <ul>
  *   <li>{prefix}_count_total - total number of samples</li>
@@ -49,7 +54,7 @@ import com.tdunning.math.stats.TDigest;
  *   <li>{prefix}_p95_ms - 95th percentile latency</li>
  *   <li>{prefix}_p99_ms - 99th percentile latency</li>
  * </ul>
- * 
+ *
  * @since 1.22
  */
 public class LatencyTracker {
@@ -57,7 +62,18 @@ public class LatencyTracker {
   /** Default compression factor for TDigest (controls accuracy vs memory). */
   private static final double DEFAULT_COMPRESSION = 100.0;
 
-  private final TDigest digest;
+  /** Counter name suffix for total sample count. */
+  public static final String SUFFIX_COUNT_TOTAL = "_count_total";
+  /** Counter name suffix for sum of latencies in milliseconds. */
+  public static final String SUFFIX_SUM_MS = "_sum_ms";
+  /** Counter name suffix for 50th percentile latency in milliseconds. */
+  public static final String SUFFIX_P50_MS = "_p50_ms";
+  /** Counter name suffix for 95th percentile latency in milliseconds. */
+  public static final String SUFFIX_P95_MS = "_p95_ms";
+  /** Counter name suffix for 99th percentile latency in milliseconds. */
+  public static final String SUFFIX_P99_MS = "_p99_ms";
+
+  private final MergingDigest digest;
   private final String group;
   private final String prefix;
   private long count = 0;
@@ -65,19 +81,19 @@ public class LatencyTracker {
 
   /**
    * Creates a new LatencyTracker.
-   * 
+   *
    * @param group the Hadoop counter group name
    * @param prefix the prefix for counter names (e.g., "fetch_latency")
    */
   public LatencyTracker(String group, String prefix) {
-    this.digest = TDigest.createDigest(DEFAULT_COMPRESSION);
+    this.digest = (MergingDigest) 
TDigest.createMergingDigest(DEFAULT_COMPRESSION);
     this.group = group;
     this.prefix = prefix;
   }
 
   /**
    * Records a latency sample.
-   * 
+   *
    * @param latencyMs the latency in milliseconds
    */
   public void record(long latencyMs) {
@@ -86,9 +102,24 @@ public class LatencyTracker {
     sum += latencyMs;
   }
 
+  /**
+   * Merges another LatencyTracker's digest and aggregates count/sum into this 
one.
+   * Used to combine per-thread or per-task metrics before emitting or 
serializing.
+   *
+   * @param other the other tracker to merge in (not modified)
+   */
+  public void merge(LatencyTracker other) {
+    if (other == null || other.count == 0) {
+      return;
+    }
+    digest.add(other.digest);
+    count += other.count;
+    sum += other.sum;
+  }
+
   /**
    * Returns the number of recorded samples.
-   * 
+   *
    * @return the count of recorded latency samples
    */
   public long getCount() {
@@ -97,7 +128,7 @@ public class LatencyTracker {
 
   /**
    * Returns the sum of all recorded latencies.
-   * 
+   *
    * @return the sum of latencies in milliseconds
    */
   public long getSum() {
@@ -106,7 +137,7 @@ public class LatencyTracker {
 
   /**
    * Returns the percentile value for the given quantile.
-   * 
+   *
    * @param quantile the quantile (0.0 to 1.0)
    * @return the percentile value in milliseconds
    */
@@ -117,28 +148,88 @@ public class LatencyTracker {
     return (long) digest.quantile(quantile);
   }
 
+  /**
+   * Serializes the digest to bytes for transmission to a reducer or side file.
+   * Returns an empty array if no samples have been recorded.
+   *
+   * @return serialized digest bytes, or empty array if count is 0
+   */
+  public byte[] toBytes() {
+    if (count == 0) {
+      return new byte[0];
+    }
+    ByteBuffer buf = ByteBuffer.allocate(digest.smallByteSize());
+    digest.asSmallBytes(buf);
+    return buf.array();
+  }
+
+  /**
+   * Deserializes a MergingDigest from bytes (as produced by {@link 
#toBytes()}).
+   *
+   * @param bytes serialized digest bytes
+   * @return MergingDigest instance, or null if bytes is null or empty
+   */
+  public static MergingDigest fromBytes(byte[] bytes) {
+    if (bytes == null || bytes.length == 0) {
+      return null;
+    }
+    return MergingDigest.fromBytes(ByteBuffer.wrap(bytes));
+  }
+
+  /**
+   * Emits only count and sum counters (not percentiles). Use in mappers when
+   * a reducer will merge TDigests and set job-level percentile counters.
+   */
+  public void emitCountAndSumOnly(TaskInputOutputContext<?, ?, ?, ?> context) {
+    context.getCounter(group, prefix + SUFFIX_COUNT_TOTAL).setValue(count);
+    context.getCounter(group, prefix + SUFFIX_SUM_MS).setValue(sum);
+  }
+
   /**
    * Emits all latency counters to the Hadoop context.
-   * 
+   *
    * <p>Should be called once during cleanup to emit aggregated metrics.
-   * 
+   *
    * @param context the Hadoop task context
    */
   public void emitCounters(TaskInputOutputContext<?, ?, ?, ?> context) {
-    context.getCounter(group, prefix + "_count_total").setValue(count);
-    context.getCounter(group, prefix + "_sum_ms").setValue(sum);
-    
+    context.getCounter(group, prefix + SUFFIX_COUNT_TOTAL).setValue(count);
+    context.getCounter(group, prefix + SUFFIX_SUM_MS).setValue(sum);
+
     if (count > 0) {
-      context.getCounter(group, prefix + "_p50_ms").setValue((long) 
digest.quantile(0.50));
-      context.getCounter(group, prefix + "_p95_ms").setValue((long) 
digest.quantile(0.95));
-      context.getCounter(group, prefix + "_p99_ms").setValue((long) 
digest.quantile(0.99));
+      context.getCounter(group, prefix + SUFFIX_P50_MS).setValue((long) 
digest.quantile(0.50));
+      context.getCounter(group, prefix + SUFFIX_P95_MS).setValue((long) 
digest.quantile(0.95));
+      context.getCounter(group, prefix + SUFFIX_P99_MS).setValue((long) 
digest.quantile(0.99));
     } else {
       // Set to 0 if no samples recorded
-      context.getCounter(group, prefix + "_p50_ms").setValue(0);
-      context.getCounter(group, prefix + "_p95_ms").setValue(0);
-      context.getCounter(group, prefix + "_p99_ms").setValue(0);
+      context.getCounter(group, prefix + SUFFIX_P50_MS).setValue(0);
+      context.getCounter(group, prefix + SUFFIX_P95_MS).setValue(0);
+      context.getCounter(group, prefix + SUFFIX_P99_MS).setValue(0);
     }
   }
-}
-
 
+  /**
+   * Sets job-level percentile counters from a merged digest (e.g. in a reducer
+   * that merged TDigests from all tasks). Uses the same counter names as
+   * {@link #emitCounters(TaskInputOutputContext)}.
+   *
+   * @param context the Hadoop task context
+   * @param mergedCount total count from merged digest
+   * @param mergedSum total sum from merged digest
+   * @param mergedDigest the merged MergingDigest (may be null if mergedCount 
is 0)
+   */
+  public static void setJobLevelCounters(TaskInputOutputContext<?, ?, ?, ?> 
context,
+      String group, String prefix, long mergedCount, long mergedSum, 
MergingDigest mergedDigest) {
+    context.getCounter(group, prefix + 
SUFFIX_COUNT_TOTAL).setValue(mergedCount);
+    context.getCounter(group, prefix + SUFFIX_SUM_MS).setValue(mergedSum);
+    if (mergedCount > 0 && mergedDigest != null) {
+      context.getCounter(group, prefix + SUFFIX_P50_MS).setValue((long) 
mergedDigest.quantile(0.50));
+      context.getCounter(group, prefix + SUFFIX_P95_MS).setValue((long) 
mergedDigest.quantile(0.95));
+      context.getCounter(group, prefix + SUFFIX_P99_MS).setValue((long) 
mergedDigest.quantile(0.99));
+    } else {
+      context.getCounter(group, prefix + SUFFIX_P50_MS).setValue(0);
+      context.getCounter(group, prefix + SUFFIX_P95_MS).setValue(0);
+      context.getCounter(group, prefix + SUFFIX_P99_MS).setValue(0);
+    }
+  }
+}
diff --git a/src/java/org/apache/nutch/metrics/NutchMetrics.java 
b/src/java/org/apache/nutch/metrics/NutchMetrics.java
index 32767c04e..620b65b6b 100644
--- a/src/java/org/apache/nutch/metrics/NutchMetrics.java
+++ b/src/java/org/apache/nutch/metrics/NutchMetrics.java
@@ -375,6 +375,13 @@ public final class NutchMetrics {
    */
   public static final String INDEXER_LATENCY = "index_latency";
 
+  /**
+   * Special key used in map output to send serialized TDigest bytes to the
+   * reducer for job-level percentile merge. Reducers detect this key and merge
+   * digests instead of writing to output.
+   */
+  public static final String LATENCY_KEY = "__LATENCY__";
+
   // =========================================================================
   // Common Error Counter Names (used with component-specific groups)
   // These constants are shared across all components for consistent error
diff --git a/src/java/org/apache/nutch/parse/ParseSegment.java 
b/src/java/org/apache/nutch/parse/ParseSegment.java
index e31830dea..944d1c073 100644
--- a/src/java/org/apache/nutch/parse/ParseSegment.java
+++ b/src/java/org/apache/nutch/parse/ParseSegment.java
@@ -20,6 +20,7 @@ import org.apache.commons.lang3.time.StopWatch;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.apache.nutch.crawl.CrawlDatum;
+import org.apache.nutch.crawl.NutchWritable;
 import org.apache.nutch.crawl.SignatureFactory;
 import org.apache.nutch.segment.SegmentChecker;
 import org.apache.nutch.util.NutchConfiguration;
@@ -46,9 +47,10 @@ import org.apache.nutch.scoring.ScoringFilterException;
 import org.apache.nutch.scoring.ScoringFilters;
 import org.apache.hadoop.conf.Configuration;
 import org.apache.hadoop.fs.Path;
+import org.apache.hadoop.io.BytesWritable;
 import org.apache.hadoop.io.Text;
-import org.apache.hadoop.io.Writable;
 import org.apache.hadoop.io.WritableComparable;
+import org.apache.hadoop.mapreduce.Partitioner;
 
 import java.io.File;
 import java.io.IOException;
@@ -83,7 +85,7 @@ public class ParseSegment extends NutchTool implements Tool {
   }
 
   public static class ParseSegmentMapper extends
-     Mapper<WritableComparable<?>, Content, Text, ParseImpl> {
+     Mapper<WritableComparable<?>, Content, Text, NutchWritable> {
 
     private ParseUtil parseUtil;
     private Text newKey = new Text();
@@ -94,7 +96,7 @@ public class ParseSegment extends NutchTool implements Tool {
     private ErrorTracker errorTracker;
 
     @Override
-    public void setup(Mapper<WritableComparable<?>, Content, Text, 
ParseImpl>.Context context) {
+    public void setup(Mapper<WritableComparable<?>, Content, Text, 
NutchWritable>.Context context) {
       Configuration conf = context.getConfiguration();
       scfilters = new ScoringFilters(conf);
       skipTruncated = conf.getBoolean(SKIP_TRUNCATED, true);
@@ -106,15 +108,19 @@ public class ParseSegment extends NutchTool implements 
Tool {
     }
 
     @Override
-    public void cleanup(Mapper<WritableComparable<?>, Content, Text, 
ParseImpl>.Context context)
+    public void cleanup(Mapper<WritableComparable<?>, Content, Text, 
NutchWritable>.Context context)
         throws IOException, InterruptedException {
-      // Emit parse latency metrics
-      parseLatencyTracker.emitCounters(context);
+      parseLatencyTracker.emitCountAndSumOnly(context);
+      byte[] digestBytes = parseLatencyTracker.toBytes();
+      if (digestBytes.length > 0) {
+        context.write(new Text(NutchMetrics.LATENCY_KEY),
+            new NutchWritable(new BytesWritable(digestBytes)));
+      }
     }
 
     @Override
     public void map(WritableComparable<?> key, Content content,
-        Context context)
+        Mapper<WritableComparable<?>, Content, Text, NutchWritable>.Context 
context)
         throws IOException, InterruptedException {
       // convert on the fly from old UTF8 keys
       if (key instanceof Text) {
@@ -191,8 +197,8 @@ public class ParseSegment extends NutchTool implements Tool 
{
 
         context.write(
             url,
-            new ParseImpl(new ParseText(parse.getText()), parse.getData(), 
parse
-                .isCanonical()));
+            new NutchWritable(new ParseImpl(new ParseText(parse.getText()),
+                parse.getData(), parse.isCanonical())));
       }
     }
   }
@@ -247,15 +253,60 @@ public class ParseSegment extends NutchTool implements 
Tool {
     return false;
   }
 
+  /** Sends LATENCY_KEY to partition 0 so one reducer merges all TDigests. */
+  public static class ParseSegmentPartitioner extends Partitioner<Text, 
NutchWritable> {
+    @Override
+    public int getPartition(Text key, NutchWritable value, int numPartitions) {
+      if (numPartitions <= 1) {
+        return 0;
+      }
+      if (key.toString().equals(NutchMetrics.LATENCY_KEY)) {
+        return 0;
+      }
+      return (key.hashCode() & Integer.MAX_VALUE) % numPartitions;
+    }
+  }
+
   public static class ParseSegmentReducer extends
-     Reducer<Text, Writable, Text, Writable> {
+     Reducer<Text, NutchWritable, Text, ParseImpl> {
+
+    private static final Text LATENCY_KEY = new Text(NutchMetrics.LATENCY_KEY);
 
     @Override
-    public void reduce(Text key, Iterable<Writable> values,
-        Context context)
+    public void reduce(Text key, Iterable<NutchWritable> values,
+        Reducer<Text, NutchWritable, Text, ParseImpl>.Context context)
         throws IOException, InterruptedException {
-      Iterator<Writable> valuesIter = values.iterator();
-      context.write(key, valuesIter.next()); // collect first value
+        if (key.equals(LATENCY_KEY)) {
+        com.tdunning.math.stats.MergingDigest merged = null;
+        for (NutchWritable w : values) {
+          if (w.get() instanceof BytesWritable) {
+            byte[] bytes = ((BytesWritable) w.get()).copyBytes();
+            if (bytes != null && bytes.length > 0) {
+              com.tdunning.math.stats.MergingDigest d = 
LatencyTracker.fromBytes(bytes);
+              if (d != null) {
+                if (merged == null) {
+                  merged = d;
+                } else {
+                  merged.add(d);
+                }
+              }
+            }
+          }
+        }
+        if (merged != null) {
+          context.getCounter(NutchMetrics.GROUP_PARSER,
+              NutchMetrics.PARSER_LATENCY + 
LatencyTracker.SUFFIX_P50_MS).setValue((long) merged.quantile(0.50));
+          context.getCounter(NutchMetrics.GROUP_PARSER,
+              NutchMetrics.PARSER_LATENCY + 
LatencyTracker.SUFFIX_P95_MS).setValue((long) merged.quantile(0.95));
+          context.getCounter(NutchMetrics.GROUP_PARSER,
+              NutchMetrics.PARSER_LATENCY + 
LatencyTracker.SUFFIX_P99_MS).setValue((long) merged.quantile(0.99));
+        }
+        return;
+      }
+      Iterator<NutchWritable> valuesIter = values.iterator();
+      if (valuesIter.hasNext()) {
+        context.write(key, (ParseImpl) valuesIter.next().get());
+      }
     }
   }
 
@@ -280,6 +331,8 @@ public class ParseSegment extends NutchTool implements Tool 
{
     job.setJarByClass(ParseSegment.class);
     job.setMapperClass(ParseSegment.ParseSegmentMapper.class);
     job.setReducerClass(ParseSegment.ParseSegmentReducer.class);
+    job.setPartitionerClass(ParseSegment.ParseSegmentPartitioner.class);
+    job.setMapOutputValueClass(NutchWritable.class);
 
     FileOutputFormat.setOutputPath(job, segment);
     job.setOutputFormatClass(ParseOutputFormat.class);
diff --git a/src/test/org/apache/nutch/fetcher/TestFetcherReducer.java 
b/src/test/org/apache/nutch/fetcher/TestFetcherReducer.java
new file mode 100644
index 000000000..a1aa5f0ac
--- /dev/null
+++ b/src/test/org/apache/nutch/fetcher/TestFetcherReducer.java
@@ -0,0 +1,105 @@
+/*
+ * 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.nutch.fetcher;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.io.BytesWritable;
+import org.apache.hadoop.io.Text;
+import org.apache.nutch.crawl.CrawlDatum;
+import org.apache.nutch.crawl.NutchWritable;
+import org.apache.nutch.metrics.NutchMetrics;
+import org.apache.nutch.metrics.LatencyTestUtil;
+import org.apache.nutch.util.NutchConfiguration;
+import org.apache.nutch.util.ReducerContextWrapper;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for {@link Fetcher.FetcherReducer}: latency key branch (merge 
TDigests,
+ * set job-level counters) and pass-through branch (write url, datum).
+ */
+class TestFetcherReducer {
+
+  @Test
+  void testReduceLatencyKeyMergesDigestsAndSetsCounters() throws IOException, 
InterruptedException {
+    Configuration conf = NutchConfiguration.create();
+    Map<Text, NutchWritable> out = new HashMap<>();
+    Fetcher.FetcherReducer reducer = new Fetcher.FetcherReducer();
+    ReducerContextWrapper<Text, NutchWritable, Text, NutchWritable> wrapper =
+        new ReducerContextWrapper<>(reducer, conf, out);
+
+    byte[] digestBytes = LatencyTestUtil.createDigestBytes(100, 200, 300);
+    List<NutchWritable> values = new ArrayList<>();
+    values.add(new NutchWritable(new BytesWritable(digestBytes)));
+
+    reducer.reduce(new Text(NutchMetrics.LATENCY_KEY), values, 
wrapper.getContext());
+
+    LatencyTestUtil.assertPercentilesInRange(wrapper.getCounters(),
+        NutchMetrics.GROUP_FETCHER, NutchMetrics.FETCHER_LATENCY, 100, 300);
+    assertEquals(0, out.size());
+  }
+
+  @Test
+  void testReduceLatencyKeyWithMultipleDigestsMergesAndSetsCounters() throws 
IOException, InterruptedException {
+    Configuration conf = NutchConfiguration.create();
+    Map<Text, NutchWritable> out = new HashMap<>();
+    Fetcher.FetcherReducer reducer = new Fetcher.FetcherReducer();
+    ReducerContextWrapper<Text, NutchWritable, Text, NutchWritable> wrapper =
+        new ReducerContextWrapper<>(reducer, conf, out);
+
+    List<BytesWritable> digestWritables = 
LatencyTestUtil.createDigestBytesWritables(
+        new long[] { 10 }, new long[] { 90 });
+    List<NutchWritable> values = new ArrayList<>();
+    for (BytesWritable bw : digestWritables) {
+      values.add(new NutchWritable(bw));
+    }
+
+    reducer.reduce(new Text(NutchMetrics.LATENCY_KEY), values, 
wrapper.getContext());
+
+    LatencyTestUtil.assertPercentilesInRange(wrapper.getCounters(),
+        NutchMetrics.GROUP_FETCHER, NutchMetrics.FETCHER_LATENCY, 10, 90);
+    assertEquals(0, out.size());
+  }
+
+  @Test
+  void testReducePassThroughWritesKeyValue() throws IOException, 
InterruptedException {
+    Configuration conf = NutchConfiguration.create();
+    Map<Text, NutchWritable> out = new HashMap<>();
+    Fetcher.FetcherReducer reducer = new Fetcher.FetcherReducer();
+    ReducerContextWrapper<Text, NutchWritable, Text, NutchWritable> wrapper =
+        new ReducerContextWrapper<>(reducer, conf, out);
+
+    Text url = new Text("http://example.com/";);
+    CrawlDatum datum = new CrawlDatum(CrawlDatum.STATUS_FETCH_SUCCESS, 0, 
0.0f);
+    List<NutchWritable> values = Collections.singletonList(new 
NutchWritable(datum));
+
+    reducer.reduce(url, values, wrapper.getContext());
+
+    assertEquals(1, out.size());
+    assertTrue(out.containsKey(url));
+    assertEquals(datum, out.get(url).get());
+  }
+}
diff --git a/src/test/org/apache/nutch/metrics/LatencyTestUtil.java 
b/src/test/org/apache/nutch/metrics/LatencyTestUtil.java
new file mode 100644
index 000000000..093c8396e
--- /dev/null
+++ b/src/test/org/apache/nutch/metrics/LatencyTestUtil.java
@@ -0,0 +1,142 @@
+/*
+ * 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.nutch.metrics;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.hadoop.io.BytesWritable;
+import org.apache.hadoop.mapreduce.Counters;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Test utility for latency-tracking tests. Reduces boilerplate when testing
+ * Fetcher, ParseSegment, and Indexer reducers that merge TDigests and set
+ * job-level percentile counters.
+ *
+ * <p>Use with the real Hadoop {@link Counters} from {@link 
org.apache.nutch.util.ReducerContextWrapper#getCounters()}
+ * (no mocks).
+ */
+public final class LatencyTestUtil {
+
+  private static final String DUMMY_GROUP = "test";
+  private static final String DUMMY_PREFIX = "latency";
+
+  private LatencyTestUtil() {}
+
+  /**
+   * Builds serialized TDigest bytes from the given samples. Uses a temporary
+   * LatencyTracker with dummy group/prefix. Callers wrap the result as needed
+   * (e.g. {@code new BytesWritable(bytes)} or {@code new NutchWritable(new 
BytesWritable(bytes))}).
+   *
+   * @param samples latency values in milliseconds to record
+   * @return serialized digest as from {@link LatencyTracker#toBytes()}
+   */
+  public static byte[] createDigestBytes(long... samples) {
+    LatencyTracker tracker = new LatencyTracker(DUMMY_GROUP, DUMMY_PREFIX);
+    for (long sample : samples) {
+      tracker.record(sample);
+    }
+    return tracker.toBytes();
+  }
+
+  /**
+   * Builds one BytesWritable per array of samples (e.g. one per map task).
+   * Useful for reducer tests that merge multiple digests.
+   *
+   * @param sampleArrays each array is recorded into one tracker and 
serialized to one BytesWritable
+   * @return list of digest BytesWritable, in order
+   */
+  public static List<BytesWritable> createDigestBytesWritables(long[]... 
sampleArrays) {
+    List<BytesWritable> list = new ArrayList<>(sampleArrays.length);
+    for (long[] samples : sampleArrays) {
+      list.add(new BytesWritable(createDigestBytes(samples)));
+    }
+    return list;
+  }
+
+  /**
+   * Asserts that the job-level percentile counters (p50, p95, p99) for the
+   * given group and prefix are in the range [minMs, maxMs]. Uses
+   * {@link LatencyTracker#SUFFIX_P50_MS} etc.
+   *
+   * @param counters counters from {@link 
org.apache.nutch.util.ReducerContextWrapper#getCounters()}
+   * @param group    counter group (e.g. {@link NutchMetrics#GROUP_FETCHER})
+   * @param prefix   counter name prefix (e.g. {@link 
NutchMetrics#FETCHER_LATENCY})
+   * @param minMs    inclusive lower bound for all percentiles (ms)
+   * @param maxMs    inclusive upper bound for all percentiles (ms)
+   */
+  public static void assertPercentilesInRange(Counters counters, String group, 
String prefix,
+      long minMs, long maxMs) {
+    long p50 = counters.findCounter(group, prefix + 
LatencyTracker.SUFFIX_P50_MS).getValue();
+    long p95 = counters.findCounter(group, prefix + 
LatencyTracker.SUFFIX_P95_MS).getValue();
+    long p99 = counters.findCounter(group, prefix + 
LatencyTracker.SUFFIX_P99_MS).getValue();
+    assertTrue(p50 >= minMs && p50 <= maxMs,
+        "p50=" + p50 + " not in [" + minMs + "," + maxMs + "]");
+    assertTrue(p95 >= minMs && p95 <= maxMs,
+        "p95=" + p95 + " not in [" + minMs + "," + maxMs + "]");
+    assertTrue(p99 >= minMs && p99 <= maxMs,
+        "p99=" + p99 + " not in [" + minMs + "," + maxMs + "]");
+  }
+
+  /**
+   * Asserts that the count and sum counters for the given group and prefix
+   * match the expected values. Uses {@link LatencyTracker#SUFFIX_COUNT_TOTAL}
+   * and {@link LatencyTracker#SUFFIX_SUM_MS}.
+   *
+   * @param counters       counters from {@link 
org.apache.nutch.util.ReducerContextWrapper#getCounters()}
+   * @param group          counter group
+   * @param prefix         counter name prefix
+   * @param expectedCount  expected _count_total value
+   * @param expectedSumMs  expected _sum_ms value
+   */
+  public static void assertCountAndSum(Counters counters, String group, String 
prefix,
+      long expectedCount, long expectedSumMs) {
+    assertEquals(expectedCount,
+        counters.findCounter(group, prefix + 
LatencyTracker.SUFFIX_COUNT_TOTAL).getValue());
+    assertEquals(expectedSumMs,
+        counters.findCounter(group, prefix + 
LatencyTracker.SUFFIX_SUM_MS).getValue());
+  }
+
+  /**
+   * Asserts that the percentile counters (p50, p95, p99) for the given group
+   * and prefix are all zero. Useful for tests that emit with zero samples.
+   *
+   * @param counters counters from {@link 
org.apache.nutch.util.ReducerContextWrapper#getCounters()}
+   * @param group    counter group
+   * @param prefix   counter name prefix
+   */
+  public static void assertPercentilesZero(Counters counters, String group, 
String prefix) {
+    assertEquals(0, counters.findCounter(group, prefix + 
LatencyTracker.SUFFIX_P50_MS).getValue());
+    assertEquals(0, counters.findCounter(group, prefix + 
LatencyTracker.SUFFIX_P95_MS).getValue());
+    assertEquals(0, counters.findCounter(group, prefix + 
LatencyTracker.SUFFIX_P99_MS).getValue());
+  }
+
+  /**
+   * Asserts that count, sum, and all percentile counters are zero.
+   *
+   * @param counters counters from {@link 
org.apache.nutch.util.ReducerContextWrapper#getCounters()}
+   * @param group    counter group
+   * @param prefix   counter name prefix
+   */
+  public static void assertCountSumAndPercentilesZero(Counters counters, 
String group, String prefix) {
+    assertCountAndSum(counters, group, prefix, 0, 0);
+    assertPercentilesZero(counters, group, prefix);
+  }
+}
diff --git a/src/test/org/apache/nutch/metrics/TestLatencyTracker.java 
b/src/test/org/apache/nutch/metrics/TestLatencyTracker.java
new file mode 100644
index 000000000..863dc3ff3
--- /dev/null
+++ b/src/test/org/apache/nutch/metrics/TestLatencyTracker.java
@@ -0,0 +1,284 @@
+/*
+ * 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.nutch.metrics;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.io.Text;
+import org.apache.hadoop.mapreduce.Reducer;
+import org.apache.nutch.util.NutchConfiguration;
+import org.apache.nutch.util.ReducerContextWrapper;
+import org.junit.jupiter.api.Test;
+
+import com.tdunning.math.stats.MergingDigest;
+import com.tdunning.math.stats.TDigest;
+
+/**
+ * Unit tests for {@link LatencyTracker} merge, serialization 
(toBytes/fromBytes),
+ * percentile behavior, and counter emission. Counter-emitting tests use the 
real
+ * Hadoop Context and Counters via {@link ReducerContextWrapper} (no mocks of 
Hadoop).
+ */
+class TestLatencyTracker {
+
+  private static final String GROUP = "test";
+  private static final String PREFIX = "test_latency";
+
+  @Test
+  void testMergeAggregatesCountAndSum() {
+    LatencyTracker a = new LatencyTracker(GROUP, PREFIX);
+    a.record(10);
+    a.record(20);
+    LatencyTracker b = new LatencyTracker(GROUP, PREFIX);
+    b.record(30);
+    a.merge(b);
+    assertEquals(3, a.getCount());
+    assertEquals(60, a.getSum());
+  }
+
+  @Test
+  void testMergeNullOrEmptyIsNoOp() {
+    LatencyTracker a = new LatencyTracker(GROUP, PREFIX);
+    a.record(5);
+    long countBefore = a.getCount();
+    a.merge(null);
+    assertEquals(countBefore, a.getCount());
+    LatencyTracker empty = new LatencyTracker(GROUP, PREFIX);
+    a.merge(empty);
+    assertEquals(countBefore, a.getCount());
+  }
+
+  @Test
+  void testToBytesFromBytesRoundTrip() {
+    LatencyTracker tracker = new LatencyTracker(GROUP, PREFIX);
+    tracker.record(100);
+    tracker.record(200);
+    tracker.record(300);
+    byte[] bytes = tracker.toBytes();
+    assertNotNull(bytes);
+    assertEquals(3, tracker.getCount());
+    com.tdunning.math.stats.MergingDigest restored = 
LatencyTracker.fromBytes(bytes);
+    assertNotNull(restored);
+    double q50 = restored.quantile(0.50);
+    assertTrue(q50 >= 100 && q50 <= 300);
+  }
+
+  @Test
+  void testToBytesEmptyReturnsEmptyArray() {
+    LatencyTracker tracker = new LatencyTracker(GROUP, PREFIX);
+    byte[] bytes = tracker.toBytes();
+    assertNotNull(bytes);
+    assertEquals(0, bytes.length);
+  }
+
+  @Test
+  void testFromBytesNullOrEmptyReturnsNull() {
+    assertNull(LatencyTracker.fromBytes(null));
+    assertNull(LatencyTracker.fromBytes(new byte[0]));
+  }
+
+  @Test
+  void testGetPercentileReturnsValueInRange() {
+    LatencyTracker tracker = new LatencyTracker(GROUP, PREFIX);
+    tracker.record(100);
+    tracker.record(200);
+    tracker.record(300);
+    long p50 = tracker.getPercentile(0.50);
+    long p95 = tracker.getPercentile(0.95);
+    long p99 = tracker.getPercentile(0.99);
+    assertTrue(p50 >= 100 && p50 <= 300);
+    assertTrue(p95 >= 100 && p95 <= 300);
+    assertTrue(p99 >= 100 && p99 <= 300);
+  }
+
+  @Test
+  void testGetPercentileWithZeroSamplesReturnsZero() {
+    LatencyTracker tracker = new LatencyTracker(GROUP, PREFIX);
+    assertEquals(0, tracker.getPercentile(0.50));
+    assertEquals(0, tracker.getPercentile(0.95));
+  }
+
+  // Integration-style tests: real Hadoop Context and Counters (no mocks).
+  // Uses ReducerContextWrapper to drive a reducer that emits latency counters.
+
+  @Test
+  void testEmitCountAndSumOnlyUpdatesJobCounters() throws IOException, 
InterruptedException {
+    Configuration conf = NutchConfiguration.create();
+    Map<Text, Text> out = new HashMap<>();
+    EmitCountAndSumOnlyReducer reducer = new EmitCountAndSumOnlyReducer(GROUP, 
PREFIX);
+    ReducerContextWrapper<Text, Text, Text, Text> wrapper =
+        new ReducerContextWrapper<>(reducer, conf, out);
+    reducer.reduce(new Text("k"), Collections.singletonList(new Text("v")), 
wrapper.getContext());
+
+    LatencyTestUtil.assertCountAndSum(wrapper.getCounters(), GROUP, PREFIX, 2, 
30);
+  }
+
+  @Test
+  void testEmitCountersUpdatesJobCounters() throws IOException, 
InterruptedException {
+    Configuration conf = NutchConfiguration.create();
+    Map<Text, Text> out = new HashMap<>();
+    EmitCountersReducer reducer = new EmitCountersReducer(GROUP, PREFIX);
+    ReducerContextWrapper<Text, Text, Text, Text> wrapper =
+        new ReducerContextWrapper<>(reducer, conf, out);
+    reducer.reduce(new Text("k"), Collections.singletonList(new Text("v")), 
wrapper.getContext());
+
+    LatencyTestUtil.assertCountAndSum(wrapper.getCounters(), GROUP, PREFIX, 3, 
600);
+    LatencyTestUtil.assertPercentilesInRange(wrapper.getCounters(), GROUP, 
PREFIX, 100, 300);
+  }
+
+  @Test
+  void testEmitCountersWithZeroSamplesSetsPercentilesToZero() throws 
IOException, InterruptedException {
+    Configuration conf = NutchConfiguration.create();
+    Map<Text, Text> out = new HashMap<>();
+    EmitCountersZeroReducer reducer = new EmitCountersZeroReducer(GROUP, 
PREFIX);
+    ReducerContextWrapper<Text, Text, Text, Text> wrapper =
+        new ReducerContextWrapper<>(reducer, conf, out);
+    reducer.reduce(new Text("k"), Collections.emptyList(), 
wrapper.getContext());
+
+    LatencyTestUtil.assertCountSumAndPercentilesZero(wrapper.getCounters(), 
GROUP, PREFIX);
+  }
+
+  @Test
+  void testSetJobLevelCountersUpdatesJobCounters() throws IOException, 
InterruptedException {
+    Configuration conf = NutchConfiguration.create();
+    Map<Text, Text> out = new HashMap<>();
+    SetJobLevelCountersReducer reducer = new SetJobLevelCountersReducer(GROUP, 
PREFIX);
+    ReducerContextWrapper<Text, Text, Text, Text> wrapper =
+        new ReducerContextWrapper<>(reducer, conf, out);
+    reducer.reduce(new Text("k"), Collections.singletonList(new Text("v")), 
wrapper.getContext());
+
+    LatencyTestUtil.assertCountAndSum(wrapper.getCounters(), GROUP, PREFIX, 3, 
600);
+    LatencyTestUtil.assertPercentilesInRange(wrapper.getCounters(), GROUP, 
PREFIX, 100, 300);
+  }
+
+  @Test
+  void testSetJobLevelCountersWithZeroCountSetsPercentilesToZero() throws 
IOException, InterruptedException {
+    Configuration conf = NutchConfiguration.create();
+    Map<Text, Text> out = new HashMap<>();
+    SetJobLevelCountersZeroReducer reducer = new 
SetJobLevelCountersZeroReducer(GROUP, PREFIX);
+    ReducerContextWrapper<Text, Text, Text, Text> wrapper =
+        new ReducerContextWrapper<>(reducer, conf, out);
+    reducer.reduce(new Text("k"), Collections.emptyList(), 
wrapper.getContext());
+
+    LatencyTestUtil.assertCountSumAndPercentilesZero(wrapper.getCounters(), 
GROUP, PREFIX);
+  }
+
+  /** Reducer that emits only count and sum via LatencyTracker (real Context, 
no mocks). */
+  private static final class EmitCountAndSumOnlyReducer extends Reducer<Text, 
Text, Text, Text> {
+    private final String group;
+    private final String prefix;
+
+    EmitCountAndSumOnlyReducer(String group, String prefix) {
+      this.group = group;
+      this.prefix = prefix;
+    }
+
+    @Override
+    protected void reduce(Text key, Iterable<Text> values, Context context)
+        throws IOException, InterruptedException {
+      LatencyTracker tracker = new LatencyTracker(group, prefix);
+      tracker.record(10);
+      tracker.record(20);
+      tracker.emitCountAndSumOnly(context);
+    }
+  }
+
+  /** Reducer that emits count, sum, and percentiles via LatencyTracker (real 
Context, no mocks). */
+  private static final class EmitCountersReducer extends Reducer<Text, Text, 
Text, Text> {
+    private final String group;
+    private final String prefix;
+
+    EmitCountersReducer(String group, String prefix) {
+      this.group = group;
+      this.prefix = prefix;
+    }
+
+    @Override
+    protected void reduce(Text key, Iterable<Text> values, Context context)
+        throws IOException, InterruptedException {
+      LatencyTracker tracker = new LatencyTracker(group, prefix);
+      tracker.record(100);
+      tracker.record(200);
+      tracker.record(300);
+      tracker.emitCounters(context);
+    }
+  }
+
+  /** Reducer that emits counters with zero samples (percentiles set to 0). */
+  private static final class EmitCountersZeroReducer extends Reducer<Text, 
Text, Text, Text> {
+    private final String group;
+    private final String prefix;
+
+    EmitCountersZeroReducer(String group, String prefix) {
+      this.group = group;
+      this.prefix = prefix;
+    }
+
+    @Override
+    protected void reduce(Text key, Iterable<Text> values, Context context)
+        throws IOException, InterruptedException {
+      LatencyTracker tracker = new LatencyTracker(group, prefix);
+      tracker.emitCounters(context);
+    }
+  }
+
+  /** Reducer that calls setJobLevelCounters with a merged digest (real 
Context, no mocks). */
+  private static final class SetJobLevelCountersReducer extends Reducer<Text, 
Text, Text, Text> {
+    private final String group;
+    private final String prefix;
+
+    SetJobLevelCountersReducer(String group, String prefix) {
+      this.group = group;
+      this.prefix = prefix;
+    }
+
+    @Override
+    protected void reduce(Text key, Iterable<Text> values, Context context)
+        throws IOException, InterruptedException {
+      MergingDigest digest = (MergingDigest) 
TDigest.createMergingDigest(100.0);
+      digest.add(100);
+      digest.add(200);
+      digest.add(300);
+      LatencyTracker.setJobLevelCounters(context, group, prefix, 3, 600, 
digest);
+    }
+  }
+
+  /** Reducer that calls setJobLevelCounters with zero count (percentiles set 
to 0). */
+  private static final class SetJobLevelCountersZeroReducer extends 
Reducer<Text, Text, Text, Text> {
+    private final String group;
+    private final String prefix;
+
+    SetJobLevelCountersZeroReducer(String group, String prefix) {
+      this.group = group;
+      this.prefix = prefix;
+    }
+
+    @Override
+    protected void reduce(Text key, Iterable<Text> values, Context context)
+        throws IOException, InterruptedException {
+      LatencyTracker.setJobLevelCounters(context, group, prefix, 0, 0, null);
+    }
+  }
+}
diff --git a/src/test/org/apache/nutch/parse/TestParseSegment.java 
b/src/test/org/apache/nutch/parse/TestParseSegment.java
index d989c7a0b..e166bc891 100644
--- a/src/test/org/apache/nutch/parse/TestParseSegment.java
+++ b/src/test/org/apache/nutch/parse/TestParseSegment.java
@@ -16,14 +16,29 @@
  */
 package org.apache.nutch.parse;
 
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
 import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
 
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.io.BytesWritable;
+import org.apache.hadoop.io.Text;
+import org.apache.nutch.crawl.NutchWritable;
 import org.apache.nutch.metadata.Metadata;
+import org.apache.nutch.metrics.LatencyTestUtil;
+import org.apache.nutch.metrics.NutchMetrics;
 import org.apache.nutch.net.protocols.Response;
 import org.apache.nutch.protocol.Content;
+import org.apache.nutch.util.NutchConfiguration;
+import org.apache.nutch.util.ReducerContextWrapper;
 import org.junit.jupiter.api.Test;
-import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertTrue;
 
 public class TestParseSegment {
   private static byte[] BYTES = "the quick brown 
fox".getBytes(StandardCharsets.UTF_8);
@@ -77,4 +92,49 @@ public class TestParseSegment {
     content.setContent(BYTES);
     assertFalse(ParseSegment.isTruncated(content));
   }
+
+  /**
+   * ParseSegmentReducer latency branch: when key is LATENCY_KEY, merges
+   * BytesWritable TDigests and sets job-level parser latency counters.
+   */
+  @Test
+  void testParseSegmentReducerLatencyKeySetsCounters() throws IOException, 
InterruptedException {
+    Configuration conf = NutchConfiguration.create();
+    Map<Text, ParseImpl> out = new HashMap<>();
+    ParseSegment.ParseSegmentReducer reducer = new 
ParseSegment.ParseSegmentReducer();
+    ReducerContextWrapper<Text, NutchWritable, Text, ParseImpl> wrapper = new 
ReducerContextWrapper<>(
+        reducer, conf, out);
+
+    byte[] digestBytes = LatencyTestUtil.createDigestBytes(100, 200);
+    List<NutchWritable> values = new ArrayList<>();
+    values.add(new NutchWritable(new BytesWritable(digestBytes)));
+
+    reducer.reduce(new Text(NutchMetrics.LATENCY_KEY), values,
+        wrapper.getContext());
+
+    LatencyTestUtil.assertPercentilesInRange(wrapper.getCounters(),
+        NutchMetrics.GROUP_PARSER, NutchMetrics.PARSER_LATENCY, 100, 200);
+    assertEquals(0, out.size());
+  }
+
+  /**
+   * ParseSegmentPartitioner sends LATENCY_KEY to partition 0 so one reducer
+   * merges all TDigests.
+   */
+  @Test
+  void testParseSegmentPartitionerSendsLatencyKeyToPartitionZero() {
+    ParseSegment.ParseSegmentPartitioner partitioner = new 
ParseSegment.ParseSegmentPartitioner();
+    int numPartitions = 4;
+    assertEquals(0, partitioner.getPartition(new 
Text(NutchMetrics.LATENCY_KEY),
+        new NutchWritable(new BytesWritable()), numPartitions));
+  }
+
+  @Test
+  void testParseSegmentPartitionerWithSinglePartition() {
+    ParseSegment.ParseSegmentPartitioner partitioner = new 
ParseSegment.ParseSegmentPartitioner();
+    assertEquals(0, partitioner.getPartition(new 
Text(NutchMetrics.LATENCY_KEY),
+        new NutchWritable(new BytesWritable()), 1));
+    assertEquals(0, partitioner.getPartition(new Text("http://example.com/";),
+        new NutchWritable(new BytesWritable()), 1));
+  }
 }
diff --git a/src/test/org/apache/nutch/util/ReducerContextWrapper.java 
b/src/test/org/apache/nutch/util/ReducerContextWrapper.java
index ec683e466..8fddf5857 100644
--- a/src/test/org/apache/nutch/util/ReducerContextWrapper.java
+++ b/src/test/org/apache/nutch/util/ReducerContextWrapper.java
@@ -71,6 +71,16 @@ public class ReducerContextWrapper<KEYIN, VALUEIN, KEYOUT, 
VALUEOUT> {
     return context;
   }
 
+  /**
+   * Return the underlying counters updated by the context, for assertions in 
tests.
+   * Uses the real Hadoop Counters API (no mocks).
+   *
+   * @return the counters instance
+   */
+  public Counters getCounters() {
+    return counters;
+  }
+
   @SuppressWarnings("unchecked")
   private void initContext() {
     context = Mockito.mock(Reducer.Context.class,

Reply via email to