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

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


The following commit(s) were added to refs/heads/master by this push:
     new 3978f164b Add jitter control loop and `JitterAwareStreamGrouping` 
(#8593)
3978f164b is described below

commit 3978f164bce28e747e7124bef96bcbdeb7216b9b
Author: Gianluca Graziadei <[email protected]>
AuthorDate: Mon Jun 29 20:29:02 2026 +0200

    Add jitter control loop and `JitterAwareStreamGrouping` (#8593)
    
    * init
    
    * allocate suppliers once
    
    * improve jitter definition
    
    * add documentation
    
    * format
    
    * format
    
    * fix checkstyle
    
    * fix RFC 1889 jitter definition, guarantee jitter decay if stable latency
    
    * minor changes
    
    * publish control signal based on ewma metrics
    
    * fix typo on `ZeroOneOpenIntervalValidator`
    
    * consume metrics and update internal status
    
    * implement a cache for child tasks stats
    
    * feedback message serialization and path decoupling
    
    * feedback refactoring + JitterAwareStreamGrouping
    
    * control loop lazy update
    
    * send feedback according to a periodic tick tuple
    
    * minor changes
    
    * add jitter aware grouping topology bench
    
    * remove not necessary UpstreamFeedbackCompareTopo.java
    
    * remove unnecessary import, FT for registering FEEDBACK_TICK_STREAM
    
    * `LoadAwareCustomStreamGrouping` contract backward compatibility
    
    * unit test + fix FeedbackRecord
    
    * refine jitter aware stream grouping algorithm
    
    * review fixes
    
    * security comment + FT scheduleUpstreamFeedbackTick in system components
    
    * remove feedback stream default value
    
    * Removes the lazy update logic to ensure fault tolerance in case a
    parent worker dies. The impact on latency and throughput is negligible.
    
    * two phases validation + `@CustomComboValidator` for 
`UpstreamFeedbackValidator`
    
    * Refactor documentation to frame performance benchmarks neutrally, 
highlighting directional latency improvements under moderate load
---
 conf/defaults.yaml                                 |   2 +
 .../storm/perf/JitterAwareGroupingTopology.java    | 349 +++++++++++++++++++++
 storm-client/src/jvm/org/apache/storm/Config.java  |  43 ++-
 .../src/jvm/org/apache/storm/Constants.java        |   2 +
 .../jvm/org/apache/storm/daemon/StormCommon.java   |  24 ++
 .../src/jvm/org/apache/storm/daemon/Task.java      |  21 ++
 .../org/apache/storm/executor/ChildEwmaStats.java  |  84 +++++
 .../apache/storm/executor/EwmaFeedbackRecord.java  |  82 +++++
 .../jvm/org/apache/storm/executor/Executor.java    | 146 ++++++++-
 .../apache/storm/executor/bolt/BoltExecutor.java   |  15 +
 .../apache/storm/executor/spout/SpoutExecutor.java |   9 +-
 .../storm/grouping/JitterAwareStreamGrouping.java  | 134 ++++++++
 .../grouping/LoadAwareCustomStreamGrouping.java    |   6 +
 .../jvm/org/apache/storm/metrics2/TaskMetrics.java |   6 +-
 .../storm/serialization/SerializationFactory.java  |   1 +
 .../jvm/org/apache/storm/utils/ConfigUtils.java    |  18 ++
 .../apache/storm/validation/ConfigValidation.java  |  77 ++++-
 .../validation/ConfigValidationAnnotations.java    |  11 +
 .../jvm/org/apache/storm/TestConfigValidate.java   |  37 +++
 .../apache/storm/executor/ChildEwmaStatsTest.java  | 123 ++++++++
 .../storm/executor/EwmaFeedbackRecordTest.java     | 228 ++++++++++++++
 .../grouping/JitterAwareStreamGroupingTest.java    | 199 ++++++++++++
 .../org/apache/storm/utils/ConfigUtilsTest.java    |  38 +++
 23 files changed, 1636 insertions(+), 19 deletions(-)

diff --git a/conf/defaults.yaml b/conf/defaults.yaml
index f94125475..d2b1c16eb 100644
--- a/conf/defaults.yaml
+++ b/conf/defaults.yaml
@@ -289,6 +289,8 @@ topology.state.synchronization.timeout.secs: 60
 topology.stats.sample.rate: 0.05
 topology.stats.ewma.enable: false
 topology.stats.ewma.smoothing.factor: 0.0625
+topology.upstream.feedback.freq.secs: 10
+topology.upstream.feedback.enable: false
 topology.builtin.metrics.bucket.size.secs: 60
 topology.fall.back.on.java.serialization: false
 topology.worker.childopts: null
diff --git 
a/examples/storm-perf/src/main/java/org/apache/storm/perf/JitterAwareGroupingTopology.java
 
b/examples/storm-perf/src/main/java/org/apache/storm/perf/JitterAwareGroupingTopology.java
new file mode 100644
index 000000000..7bc53476f
--- /dev/null
+++ 
b/examples/storm-perf/src/main/java/org/apache/storm/perf/JitterAwareGroupingTopology.java
@@ -0,0 +1,349 @@
+/*
+ * 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.storm.perf;
+
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ThreadLocalRandom;
+import java.util.concurrent.locks.LockSupport;
+import org.apache.storm.Config;
+import org.apache.storm.generated.StormTopology;
+import org.apache.storm.grouping.CustomStreamGrouping;
+import org.apache.storm.grouping.JitterAwareStreamGrouping;
+import org.apache.storm.grouping.LoadAwareShuffleGrouping;
+import org.apache.storm.perf.spout.FileReadSpout;
+import org.apache.storm.perf.utils.Helper;
+import org.apache.storm.spout.SpoutOutputCollector;
+import org.apache.storm.task.OutputCollector;
+import org.apache.storm.task.TopologyContext;
+import org.apache.storm.topology.OutputFieldsDeclarer;
+import org.apache.storm.topology.TopologyBuilder;
+import org.apache.storm.topology.base.BaseRichBolt;
+import org.apache.storm.topology.base.BaseRichSpout;
+import org.apache.storm.tuple.Fields;
+import org.apache.storm.tuple.Tuple;
+import org.apache.storm.tuple.Values;
+import org.apache.storm.utils.Utils;
+
+/**
+ * Benchmark for {@link JitterAwareStreamGrouping} in a word-count pipeline 
where worker tasks have
+ * artificially skewed latency <i>dispersion</i> (jitter).
+ *
+ * <p>Pipeline: {@code GenSpout -> SplitterBolt -> JitteryWorkerBolt -> 
SinkBolt}
+ *
+ * <p>{@code JitteryWorkerBolt} tasks have task-index-dependent latency 
<i>jitter</i>: task 0 is
+ * perfectly steady and each higher index is progressively jitterier, because 
the per-tuple noise
+ * <i>width</i> grows with the task index over a constant floor. This 
deliberately exercises the signal
+ * the grouping ranks on — RFC-1889 jitter (the EWMA of {@code |Δlatency|}), 
which measures dispersion,
+ * not level. A per-task <i>mean</i> offset would not work: it cancels in the 
estimator's
+ * consecutive-difference, leaving every task with identical jitter. With 
upstream feedback enabled,
+ * {@link JitterAwareStreamGrouping} steers more tuples toward the steadiest 
(lowest-jitter) tasks; run it
+ * against the {@code loadaware} baseline mode to measure the effect.
+ *
+ * <p>Run the baseline and the jitter-aware run back-to-back to compare. 
Select the grouping with the
+ * {@code grouping.mode} flag:
+ * <pre>
+ *   # Baseline: plain LoadAwareShuffleGrouping (no upstream feedback needed).
+ *   storm jar storm-perf.jar 
org.apache.storm.perf.JitterAwareGroupingTopology 120 \
+ *       -c grouping.mode=loadaware
+ *
+ *   # Jitter-aware: feedback-driven grouping steers tuples to lowest-jitter 
workers (default mode).
+ *   storm jar storm-perf.jar 
org.apache.storm.perf.JitterAwareGroupingTopology 120 \
+ *       -c grouping.mode=jitter
+ * </pre>
+ *
+ * <p>Tuning knobs (pass with {@code -c key=value}):
+ * <ul>
+ *   <li>{@code grouping.mode} — {@code jitter} (default) or {@code loadaware} 
baseline</li>
+ *   <li>{@code spout.count} — number of spout tasks (default 1)</li>
+ *   <li>{@code splitter.count} — number of splitter tasks (default 2)</li>
+ *   <li>{@code worker.count} — number of jittery worker tasks (default 4)</li>
+ *   <li>{@code sink.count} — number of sink tasks (default 1)</li>
+ *   <li>{@code input.file} — path to a text file whose lines are treated as 
sentences
+ *       (e.g. {@code src/main/sampledata/randomwords.txt})</li>
+ *   <li>{@code worker.base.delay.us} — jitter-width step in µs (default 
2000). Task {@code i} parks for
+ *       a constant {@code base} µs floor plus uniform noise in {@code [0, i * 
base]} µs, so the noise
+ *       width (hence jitter) scales with the task index while the floor 
cancels in the jitter estimate.
+ *       Must be &ge;1000 µs so the EWMA jitter gauge (millisecond resolution) 
sees distinct values per
+ *       task.</li>
+ * </ul>
+ */
+public class JitterAwareGroupingTopology {
+
+    public static final String TOPOLOGY_NAME = "JitterAwareGroupingTopology";
+
+    static final String SPOUT_ID = "gen";
+    static final String SPLITTER_ID = "splitter";
+    static final String WORKER_ID = "worker";
+    static final String SINK_ID = "sink";
+
+    static final String SPOUT_NUM = "spout.count";
+    static final String SPLITTER_NUM = "splitter.count";
+    static final String WORKER_NUM = "worker.count";
+    static final String SINK_NUM = "sink.count";
+    static final String INPUT_FILE = "input.file";
+    static final String WORKER_BASE_DELAY_US = "worker.base.delay.us";
+
+    static final String GROUPING_MODE = "grouping.mode";
+    static final String MODE_JITTER = "jitter";
+    static final String MODE_LOADAWARE = "loadaware";
+
+    private static final String FIELD_SENTENCE = "sentence";
+    private static final String FIELD_WORD = "word";
+    private static final String FIELD_COUNT = "count";
+
+    static StormTopology getTopology(Map<String, Object> conf) {
+        int spouts = Helper.getInt(conf, SPOUT_NUM, 1);
+        int splitters = Helper.getInt(conf, SPLITTER_NUM, 2);
+        int workers = Helper.getInt(conf, WORKER_NUM, 4);
+        int sinks = Helper.getInt(conf, SINK_NUM, 1);
+        long baseDelayUs = Helper.getInt(conf, WORKER_BASE_DELAY_US, 2000);
+        String inputFile = Helper.getStr(conf, INPUT_FILE);
+        if (inputFile == null || inputFile.trim().isEmpty()) {
+            throw new IllegalArgumentException(
+                "Required config '" + INPUT_FILE + "' is not set. Pass a 
sentence file, e.g. "
+                + "-c " + INPUT_FILE + "=src/main/sampledata/randomwords.txt");
+        }
+
+        TopologyBuilder builder = new TopologyBuilder();
+        builder.setSpout(SPOUT_ID, new GenSpout(inputFile), spouts);
+        builder.setBolt(SPLITTER_ID, new SplitterBolt(), splitters)
+               .localOrShuffleGrouping(SPOUT_ID);
+        builder.setBolt(WORKER_ID, new JitteryWorkerBolt(baseDelayUs), workers)
+               .customGrouping(SPLITTER_ID, selectGrouping(conf));
+        builder.setBolt(SINK_ID, new SinkBolt(), sinks)
+               .localOrShuffleGrouping(WORKER_ID);
+
+        return builder.createTopology();
+    }
+
+    /**
+     * Picks the {@code splitter -> worker} grouping from {@link 
#GROUPING_MODE}. Defaults to the
+     * feedback-driven {@link JitterAwareStreamGrouping}; {@code 
grouping.mode=loadaware} selects the plain
+     * {@link LoadAwareShuffleGrouping} baseline so the two can be benchmarked 
back-to-back. Both implement
+     * {@link org.apache.storm.grouping.LoadAwareCustomStreamGrouping} and 
require the locality-aware
+     * configuration set in {@link #main}.
+     */
+    static CustomStreamGrouping selectGrouping(Map<String, Object> conf) {
+        String mode = Helper.getStr(conf, GROUPING_MODE);
+        if (MODE_LOADAWARE.equalsIgnoreCase(mode)) {
+            return new LoadAwareShuffleGrouping();
+        }
+        return new JitterAwareStreamGrouping();
+    }
+
+    public static void main(String[] args) throws Exception {
+        int runTime = -1;
+        Config topoConf = new Config();
+        if (args.length > 0) {
+            runTime = Integer.parseInt(args[0]);
+        }
+        if (args.length > 1) {
+            topoConf.putAll(Utils.findAndReadConfigFile(args[1]));
+        }
+        if (args.length > 2) {
+            System.err.println("args: [runDurationSec]  [optionalConfFile]");
+            return;
+        }
+
+        topoConf.put(Config.TOPOLOGY_STATS_EWMA_ENABLE, true);
+        // max.spout.pending counts SPOUT tuples (sentences), but each fans 
out to ~7 word-tuples at the
+        // SplitterBolt, so the in-flight backlog at the slow workers is ~7x 
this number. Keep it low enough
+        // that steady-state complete latency stays well under 
topology.message.timeout.secs with ~0 fails;
+        // 4000 oversubscribed the synthetic workers and pinned latency at the 
timeout. Tune per cluster:
+        // complete_latency ~= (pending * fanout) / aggregate_worker_ack_rate 
(Little's law).
+        topoConf.putIfAbsent(Config.TOPOLOGY_MAX_SPOUT_PENDING, 500);
+        topoConf.putIfAbsent(Config.TOPOLOGY_UPSTREAM_FEEDBACK_FREQ_SECS, 10);
+
+        // Both grouping modes resolve to a LoadAwareShuffleGrouping 
(directly, or as the jitter grouping's
+        // fallback), whose prepare() requires these locality-aware keys. 
Normally supplied by defaults.yaml;
+        // set defensively so the topology is self-contained. Placed before 
the CLI merge so -c can override.
+        topoConf.putIfAbsent(Config.STORM_NETWORK_TOPOGRAPHY_PLUGIN,
+            
"org.apache.storm.networktopography.DefaultRackDNSToSwitchMapping");
+        topoConf.putIfAbsent(Config.TOPOLOGY_LOCALITYAWARE_HIGHER_BOUND, 0.8);
+        topoConf.putIfAbsent(Config.TOPOLOGY_LOCALITYAWARE_LOWER_BOUND, 0.2);
+
+        topoConf.putAll(Utils.readCommandLineOpts());
+
+        Helper.runOnClusterAndPrintMetrics(runTime, TOPOLOGY_NAME, topoConf, 
getTopology(topoConf));
+    }
+
+    /**
+     * Emits anchored sentences loaded from {@code input.file} at maximum 
rate. The file is read
+     * once into memory during {@link #open} and replayed in a round-robin 
loop. Anchoring (with a
+     * msgId) ensures Storm tracks each tuple tree to completion, so spout 
complete-latency is a
+     * reliable end-to-end signal.
+     */
+    private static class GenSpout extends BaseRichSpout {
+        private final String filePath;
+        private SpoutOutputCollector collector;
+        private List<String> lines;
+        private int lineIdx;
+        private long msgId;
+
+        GenSpout(String filePath) {
+            this.filePath = filePath;
+        }
+
+        @Override
+        public void open(Map<String, Object> conf, TopologyContext context, 
SpoutOutputCollector collector) {
+            this.collector = collector;
+            try {
+                this.lines = FileReadSpout.readLines(new 
FileInputStream(filePath));
+            } catch (IOException e) {
+                throw new RuntimeException("Cannot open input file: " + 
filePath, e);
+            }
+            if (lines.isEmpty()) {
+                throw new RuntimeException("Input file is empty: " + filePath);
+            }
+        }
+
+        @Override
+        public void nextTuple() {
+            String sentence = lines.get(lineIdx++ % lines.size());
+            collector.emit(new Values(sentence), ++msgId);
+        }
+
+        @Override
+        public void declareOutputFields(OutputFieldsDeclarer declarer) {
+            declarer.declare(new Fields(FIELD_SENTENCE));
+        }
+    }
+
+    /**
+     * Splits each incoming sentence into words and emits one tuple per word, 
anchored so the ack
+     * tree extends to the downstream worker.
+     */
+    private static class SplitterBolt extends BaseRichBolt {
+        private OutputCollector collector;
+
+        @Override
+        public void prepare(Map<String, Object> conf, TopologyContext context, 
OutputCollector collector) {
+            this.collector = collector;
+        }
+
+        @Override
+        public void execute(Tuple tuple) {
+            String sentence = tuple.getString(0);
+            for (String word : sentence.split("\\s+")) {
+                collector.emit(tuple, new Values(word));
+            }
+            collector.ack(tuple);
+        }
+
+        @Override
+        public void declareOutputFields(OutputFieldsDeclarer declarer) {
+            declarer.declare(new Fields(FIELD_WORD));
+        }
+    }
+
+    /**
+     * Counts words and parks for a constant floor plus 
task-index-proportional random noise, so each
+     * task's RFC-1889 execute-jitter (the EWMA of {@code |Δ 
execute-latency|}) differs by construction.
+     *
+     * <p>Task {@code i} parks for {@code baseDelayUs} µs plus uniform noise 
in {@code [0, i * baseDelayUs]}
+     * µs per tuple. Crucially, the constant floor cancels in the 
consecutive-difference the jitter
+     * estimator takes, so only the noise <i>width</i> drives jitter — not a 
per-task mean offset (which
+     * would cancel and leave every task with identical jitter). Steady-state 
jitter is {@code width / 3}.
+     * For a 4-task setup with the default {@code baseDelayUs = 2000}:
+     * <ul>
+     *   <li>Task 0: constant ~2 ms — jitter ≈ 0 ms (steadiest)</li>
+     *   <li>Task 1: ~2–4 ms — jitter ≈ 0.67 ms</li>
+     *   <li>Task 2: ~2–6 ms — jitter ≈ 1.33 ms</li>
+     *   <li>Task 3: ~2–8 ms — jitter ≈ 2 ms (jitteriest)</li>
+     * </ul>
+     * Widths are millisecond-scale so the EWMA jitter gauge (millisecond 
resolution) records distinct
+     * values per task. {@link JitterAwareStreamGrouping} then steers tuples 
toward the lowest-jitter
+     * task (task 0) when feedback is enabled.
+     *
+     * <p>{@link LockSupport#parkNanos} is used instead of a spin loop so 
jittery tasks yield the CPU
+     * and do not starve the steady task's executor thread.
+     */
+    private static class JitteryWorkerBolt extends BaseRichBolt {
+        private final long baseDelayUs;
+        private OutputCollector collector;
+        private long baseFloorNs;
+        private long jitterWidthNs;
+        private final Map<String, Integer> counts = new HashMap<>();
+
+        JitteryWorkerBolt(long baseDelayUs) {
+            this.baseDelayUs = baseDelayUs;
+        }
+
+        @Override
+        public void prepare(Map<String, Object> conf, TopologyContext context, 
OutputCollector collector) {
+            this.collector = collector;
+            long baseDelayNs = baseDelayUs * 1_000L;
+            // Constant floor parked by every task: keeps each task doing real 
work, but cancels in the
+            // consecutive-difference the RFC-1889 jitter estimator takes, so 
it adds no jitter.
+            this.baseFloorNs = baseDelayNs;
+            // Noise WIDTH grows with task index, so execute-jitter (EWMA of 
|Δlatency|) genuinely differs
+            // per task: task 0 is perfectly steady (zero jitter), higher 
indices are progressively jitterier.
+            this.jitterWidthNs = baseDelayNs * context.getThisTaskIndex();
+        }
+
+        @Override
+        public void execute(Tuple tuple) {
+            String word = tuple.getString(0);
+            counts.merge(word, 1, Integer::sum);
+            int count = counts.get(word);
+
+            long noiseNs = jitterWidthNs == 0L ? 0L : (long) 
(ThreadLocalRandom.current().nextDouble() * jitterWidthNs);
+            long sleepNs = baseFloorNs + noiseNs;
+            if (sleepNs > 0) {
+                LockSupport.parkNanos(sleepNs);
+            }
+
+            // Emit anchored so the ack chain continues to SinkBolt, and so 
execute/process jitter
+            // is measured and reported back to SplitterBolt via upstream 
feedback.
+            collector.emit(tuple, new Values(word, count));
+            collector.ack(tuple);
+        }
+
+        @Override
+        public void declareOutputFields(OutputFieldsDeclarer declarer) {
+            declarer.declare(new Fields(FIELD_WORD, FIELD_COUNT));
+        }
+    }
+
+    /**
+     * Terminal bolt: acks each tuple to complete the tuple tree and drive 
spout complete-latency.
+     */
+    private static class SinkBolt extends BaseRichBolt {
+        private OutputCollector collector;
+
+        @Override
+        public void prepare(Map<String, Object> conf, TopologyContext context, 
OutputCollector collector) {
+            this.collector = collector;
+        }
+
+        @Override
+        public void execute(Tuple tuple) {
+            collector.ack(tuple);
+        }
+
+        @Override
+        public void declareOutputFields(OutputFieldsDeclarer declarer) {
+            // terminal — no output
+        }
+    }
+}
diff --git a/storm-client/src/jvm/org/apache/storm/Config.java 
b/storm-client/src/jvm/org/apache/storm/Config.java
index 6b3ac658d..36b3bb78a 100644
--- a/storm-client/src/jvm/org/apache/storm/Config.java
+++ b/storm-client/src/jvm/org/apache/storm/Config.java
@@ -39,6 +39,7 @@ import 
org.apache.storm.validation.ConfigValidation.MetricRegistryValidator;
 import org.apache.storm.validation.ConfigValidation.MetricReportersValidator;
 import 
org.apache.storm.validation.ConfigValidation.RasConstraintsTypeValidator;
 import org.apache.storm.validation.ConfigValidationAnnotations;
+import 
org.apache.storm.validation.ConfigValidationAnnotations.CustomComboValidator;
 import org.apache.storm.validation.ConfigValidationAnnotations.CustomValidator;
 import org.apache.storm.validation.ConfigValidationAnnotations.IsBoolean;
 import org.apache.storm.validation.ConfigValidationAnnotations.IsExactlyOneOf;
@@ -608,8 +609,47 @@ public class Config extends HashMap<String, Object> {
      *
      * @see <a href="https://www.rfc-editor.org/rfc/rfc1889#appendix-A.8";>RFC 
1889 §A.8</a>
      */
-    @CustomValidator(validatorClass = 
ConfigValidation.EwmaSmoothingFactorValidator.class)
+    @CustomValidator(validatorClass = 
ConfigValidation.ZeroOneOpenIntervalValidator.class)
     public static final String TOPOLOGY_STATS_EWMA_SMOOTHING_FACTOR = 
"topology.stats.ewma.smoothing.factor";
+    /**
+     * Flag to enable or disable the feedback channel for upstream 
communication.
+     * When true, components can send unanchored tuples back to their source 
tasks.
+     *
+     * <p><b>Security:</b> feedback tuples carry a routing control signal 
(e.g. per-task EWMA jitter
+     * used by grouping decisions), so a peer that can inject messages on the 
worker transport could
+     * forge feedback and deterministically steer a topology's traffic to a 
chosen task. This stays
+     * within Storm's existing worker-transport trust model, but because the 
default
+     * {@code storm.messaging.netty.authentication} is {@code false}, enable 
Netty authentication
+     * (and TLS where available) when running this feature in an untrusted 
network.</p>
+     */
+    @IsBoolean
+    @CustomComboValidator(validatorClass = 
ConfigValidation.UpstreamFeedbackValidator.class)
+    public static final String TOPOLOGY_UPSTREAM_FEEDBACK_ENABLE = 
"topology.upstream.feedback.enable";
+    /**
+     * The period, in seconds, between upstream feedback messages within the 
topology.
+     *
+     * <p>A dedicated internal feedback tick fires on this interval; on each 
tick a task emits
+     * a feedback tuple (containing metrics such as EWMA jitter stats) back to 
its parent tasks.
+     * This mechanism allows parent tasks to receive performance signals from 
downstream
+     * components to facilitate adaptive flow control or load balancing. 
Unlike a probabilistic
+     * trigger, the period yields a deterministic, data-volume-independent 
feedback cadence.</p>
+     *
+     * <p><b>Validation:</b> Must be a positive integer (seconds).</p>
+     *
+     * <p><b>Impact:</b>
+     * <ul>
+     *   <li>Lower values provide more precise, real-time performance data but 
increase
+     *       network overhead and CPU usage on the control plane.</li>
+     *   <li>Higher values minimize the "observer effect" on the topology's 
throughput
+     *       while still providing periodic statistical snapshots of 
health.</li>
+     * </ul>
+     * </p>
+     *
+     * Defaults to 10 if not explicitly configured.
+     */
+    @IsInteger
+    @IsPositiveNumber
+    public static final String TOPOLOGY_UPSTREAM_FEEDBACK_FREQ_SECS = 
"topology.upstream.feedback.freq.secs";
     /**
      * The time period that builtin metrics data in bucketed into.
      */
@@ -1908,7 +1948,6 @@ public class Config extends HashMap<String, Object> {
     public static final String STORM_MESSAGING_NETTY_TLS_SSL_PROTOCOLS = 
"storm.messaging.netty.tls.ssl.protocols";
 
     /**
-     * /**
      * Netty based messaging: The number of milliseconds that a Netty client 
will retry flushing messages that are already
      * buffered to be sent.
      */
diff --git a/storm-client/src/jvm/org/apache/storm/Constants.java 
b/storm-client/src/jvm/org/apache/storm/Constants.java
index c3cd08087..683b42402 100644
--- a/storm-client/src/jvm/org/apache/storm/Constants.java
+++ b/storm-client/src/jvm/org/apache/storm/Constants.java
@@ -28,6 +28,8 @@ public class Constants {
     public static final String METRICS_COMPONENT_ID_PREFIX = "__metrics_";
     public static final String METRICS_STREAM_ID = "__metrics";
     public static final String METRICS_TICK_STREAM_ID = "__metrics_tick";
+    public static final String FEEDBACK_STREAM_ID = "__feedback";
+    public static final String FEEDBACK_TICK_STREAM_ID = "__feedback_tick";
 
     public static final Object TOPOLOGY = "topology";
     public static final String SYSTEM_TOPOLOGY = "system-topology";
diff --git a/storm-client/src/jvm/org/apache/storm/daemon/StormCommon.java 
b/storm-client/src/jvm/org/apache/storm/daemon/StormCommon.java
index b3cfd90d4..0776e1eea 100644
--- a/storm-client/src/jvm/org/apache/storm/daemon/StormCommon.java
+++ b/storm-client/src/jvm/org/apache/storm/daemon/StormCommon.java
@@ -360,6 +360,20 @@ public class StormCommon {
         topology.put_to_bolts(EVENTLOGGER_COMPONENT_ID, eventLoggerBolt);
     }
 
+    public static void addUpstreamFeedback(Map<String, Object> conf, 
StormTopology topology) {
+        // Only invoked when hasUpstreamFeedback(conf) is true, so declare the 
feedback stream on every
+        // component unconditionally. The schema must match the tuple emitted 
by
+        // Executor.buildUpstreamFeedbackTuple: [TaskInfo, EwmaFeedbackRecord].
+        for (Object component : allComponents(topology).values()) {
+            ComponentCommon common = getComponentCommon(component);
+            common.put_to_streams(Constants.FEEDBACK_STREAM_ID, 
Thrift.outputFields(upstreamFeedbackFields()));
+        }
+    }
+
+    public static List<String> upstreamFeedbackFields() {
+        return Arrays.asList("task-info", "feedback");
+    }
+
     @SuppressWarnings("unchecked")
     public static Map<String, Bolt> metricsConsumerBoltSpecs(Map<String, 
Object> conf, StormTopology topology) {
         Map<String, Bolt> metricsConsumerBolts = new HashMap<>();
@@ -429,6 +443,9 @@ public class StormCommon {
         outputStreams.put(Constants.SYSTEM_TICK_STREAM_ID, 
Thrift.outputFields(Arrays.asList("rate_secs")));
         outputStreams.put(Constants.SYSTEM_FLUSH_STREAM_ID, 
Thrift.outputFields(Arrays.asList()));
         outputStreams.put(Constants.METRICS_TICK_STREAM_ID, 
Thrift.outputFields(Arrays.asList("interval")));
+        if (ConfigUtils.upstreamFeedbackEnable(conf)) {
+            outputStreams.put(Constants.FEEDBACK_TICK_STREAM_ID, 
Thrift.outputFields(Arrays.asList("interval")));
+        }
 
         Map<String, Object> boltConf = new HashMap<>();
         boltConf.put(Config.TOPOLOGY_TASKS, 0);
@@ -464,6 +481,10 @@ public class StormCommon {
         return eventLoggerNum == null || ObjectReader.getInt(eventLoggerNum) > 
0;
     }
 
+    public static boolean hasUpstreamFeedback(Map<String, Object> topoConf) {
+        return ConfigUtils.upstreamFeedbackEnable(topoConf);
+    }
+
     public static int numStartExecutors(Object component) throws 
InvalidTopologyException {
         ComponentCommon common = getComponentCommon(component);
         return Thrift.getParallelismHint(common);
@@ -538,6 +559,9 @@ public class StormCommon {
         if (hasEventLoggers(topoConf)) {
             addEventLogger(topoConf, ret);
         }
+        if (hasUpstreamFeedback(topoConf)) {
+            addUpstreamFeedback(topoConf, ret);
+        }
         addMetricComponents(topoConf, ret);
         addSystemComponents(topoConf, ret);
         addMetricStreams(ret);
diff --git a/storm-client/src/jvm/org/apache/storm/daemon/Task.java 
b/storm-client/src/jvm/org/apache/storm/daemon/Task.java
index 45a1f2d7e..802044522 100644
--- a/storm-client/src/jvm/org/apache/storm/daemon/Task.java
+++ b/storm-client/src/jvm/org/apache/storm/daemon/Task.java
@@ -216,6 +216,27 @@ public class Task {
         }
     }
 
+    /**
+     * Sends an unanchored feedback tuple directly to a specific task ID 
(typically upstream).
+     * <p>
+     * This method bypasses standard stream grouping logic and routes the tuple
+     * exclusively to the provided {@code targetTaskId}. It is a 
<b>non-blocking</b> call:
+     * if the destination buffer is full, the tuple is added to the {@code 
pendingEmits}
+     * queue for later retry, preventing executor stalls.
+     * </p>
+     *
+     * @param stream       The ID of the stream to emit on (must be declared 
in the topology).
+     * @param values       The data payload to be sent.
+     * @param targetTaskId The unique ID of the destination task (e.g., the 
sourceTaskId of an incoming tuple).
+     * @param transfer     The {@link ExecutorTransfer} instance handling the 
physical data transfer.
+     * @param pendingEmits A queue used to store tuples that cannot be 
transferred immediately due to backpressure.
+     */
+    public void sendUnanchoredFeedback(String stream, List<Object> values, int 
targetTaskId, ExecutorTransfer transfer, Queue<AddressedTuple> pendingEmits) {
+        Tuple tuple = getTuple(stream, values);
+        AddressedTuple addressedTuple = new AddressedTuple(targetTaskId, 
tuple);
+        transfer.tryTransfer(addressedTuple, pendingEmits);
+    }
+
     /**
      * Send sampled data to the eventlogger if the global or component level 
debug flag is set (via nimbus api).
      */
diff --git a/storm-client/src/jvm/org/apache/storm/executor/ChildEwmaStats.java 
b/storm-client/src/jvm/org/apache/storm/executor/ChildEwmaStats.java
new file mode 100644
index 000000000..0eb5fac88
--- /dev/null
+++ b/storm-client/src/jvm/org/apache/storm/executor/ChildEwmaStats.java
@@ -0,0 +1,84 @@
+/*
+ * 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.storm.executor;
+
+import java.util.Collections;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import org.apache.storm.metrics2.TaskMetrics;
+
+/**
+ * Thread-safe store of EWMA jitter statistics reported by downstream (child) 
tasks back to a parent task.
+ * The data is indexed by parent {@code taskId} so a lookup touches only that 
task's children:
+ * {@link #getStats} is an O(1) map lookup and {@link #update} is O(metrics), 
neither scanning the whole
+ * store. This keeps cost bound to a single task's child fan-out, independent 
of how many tasks the
+ * executor hosts.
+ */
+public class ChildEwmaStats {
+
+    private final boolean enabled;
+    private final Map<Integer, ConcurrentHashMap<Integer, Map<String, 
Double>>> byTask;
+
+    private static final String[] JITTER_PRIORITY = {
+        TaskMetrics.METRIC_NAME_EXECUTE_JITTER,
+        TaskMetrics.METRIC_NAME_PROCESS_JITTER,
+        TaskMetrics.METRIC_NAME_COMPLETE_JITTER,
+    };
+
+    public ChildEwmaStats(boolean enabled) {
+        this.enabled = enabled;
+        this.byTask = enabled ? new ConcurrentHashMap<>() : 
Collections.emptyMap();
+    }
+
+    /**
+     * Records the jitter metrics reported by {@code childTaskId} for the 
given parent {@code taskId}.
+     * Runs in O(metrics) by writing straight into the task's bucket; no 
rescanning of existing data.
+     */
+    public void update(int taskId, int childTaskId, EwmaFeedbackRecord 
feedback) {
+        if (!enabled) {
+            return;
+        }
+        ConcurrentHashMap<Integer, Map<String, Double>> children =
+            byTask.computeIfAbsent(taskId, k -> new ConcurrentHashMap<>());
+        Map<String, Double> metrics = children.computeIfAbsent(childTaskId, k 
-> new ConcurrentHashMap<>());
+        feedback.forEachMetric(metrics::put);
+    }
+
+    /**
+     * Returns the latest reported value of each metric, per child task, for 
the given source
+     * {@code taskId} as {@code childTaskId -> (metricName -> value)}.
+     */
+    public Map<Integer, Map<String, Double>> getStats(int taskId) {
+        if (!enabled) {
+            return Collections.emptyMap();
+        }
+        Map<Integer, Map<String, Double>> children = byTask.get(taskId);
+        return children == null ? Collections.emptyMap() : children;
+    }
+
+    /**
+     * Compares two stats maps following {@link #JITTER_PRIORITY}, ascending. 
A missing metric
+     * is treated as {@link Double#MAX_VALUE} so it loses to any measured 
value.
+     */
+    public static int compareByJitter(Map<String, Double> a, Map<String, 
Double> b) {
+        for (String metric : JITTER_PRIORITY) {
+            int cmp = Double.compare(
+                a.getOrDefault(metric, Double.MAX_VALUE),
+                b.getOrDefault(metric, Double.MAX_VALUE));
+            if (cmp != 0) {
+                return cmp;
+            }
+        }
+        return 0;
+    }
+}
diff --git 
a/storm-client/src/jvm/org/apache/storm/executor/EwmaFeedbackRecord.java 
b/storm-client/src/jvm/org/apache/storm/executor/EwmaFeedbackRecord.java
new file mode 100644
index 000000000..b99ce78c2
--- /dev/null
+++ b/storm-client/src/jvm/org/apache/storm/executor/EwmaFeedbackRecord.java
@@ -0,0 +1,82 @@
+/*
+ * 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.storm.executor;
+
+import com.codahale.metrics.Gauge;
+import java.util.Map;
+import java.util.function.ObjDoubleConsumer;
+import org.apache.storm.daemon.worker.WorkerState;
+import org.apache.storm.metrics2.PerReporterGauge;
+import org.apache.storm.metrics2.TaskMetrics;
+
+/**
+ * Immutable snapshot of a task's jitter metrics, used as the payload of an 
upstream feedback tuple.
+ *
+ * @param processJitter  The {@code __process-jitter} gauge value, or {@link 
#VOID} if absent.
+ * @param completeJitter The {@code __complete-jitter} gauge value, or {@link 
#VOID} if absent.
+ * @param executeJitter  The {@code __execute-jitter} gauge value, or {@link 
#VOID} if absent.
+ */
+public record EwmaFeedbackRecord(double processJitter, double completeJitter, 
double executeJitter) {
+
+    // Sentinel for an absent metric. Jitter values are always >= 0, so a 
negative value can never
+    // collide with a real measurement and unambiguously marks "gauge missing 
/ not a Number".
+    private static final double VOID = -1;
+
+    private static double fromGauge(Gauge<?> gauge) {
+        if (gauge != null && !(gauge instanceof PerReporterGauge)) {
+            Object v = gauge.getValue();
+            if (v instanceof Number) {
+                return ((Number) v).doubleValue();
+            }
+        }
+        return VOID;
+    }
+
+    private static double aggregate(Map<String, Gauge> gauges, String 
metricName) {
+        String suffixedPrefix = metricName + "-";
+        double agg = VOID;
+        for (Map.Entry<String, Gauge> entry : gauges.entrySet()) {
+            String name = entry.getKey();
+            if (!name.equals(metricName) && !name.startsWith(suffixedPrefix)) {
+                continue;
+            }
+            double value = fromGauge(entry.getValue());
+            if (value != VOID && (agg == VOID || value > agg)) {
+                agg = value;
+            }
+        }
+        return agg;
+    }
+
+    public static EwmaFeedbackRecord fromWorkerState(WorkerState workerData, 
int taskId) {
+        Map<String, Gauge> allGauges = 
workerData.getMetricRegistry().getTaskGauges(taskId);
+        return new EwmaFeedbackRecord(aggregate(allGauges, 
TaskMetrics.METRIC_NAME_PROCESS_JITTER),
+                aggregate(allGauges, TaskMetrics.METRIC_NAME_COMPLETE_JITTER),
+                aggregate(allGauges, TaskMetrics.METRIC_NAME_EXECUTE_JITTER));
+    }
+
+    /**
+     * Invokes {@code consumer} once for each present jitter metric.
+     */
+    public void forEachMetric(ObjDoubleConsumer<String> consumer) {
+        if (processJitter != VOID) {
+            consumer.accept(TaskMetrics.METRIC_NAME_PROCESS_JITTER, 
processJitter);
+        }
+        if (completeJitter != VOID) {
+            consumer.accept(TaskMetrics.METRIC_NAME_COMPLETE_JITTER, 
completeJitter);
+        }
+        if (executeJitter != VOID) {
+            consumer.accept(TaskMetrics.METRIC_NAME_EXECUTE_JITTER, 
executeJitter);
+        }
+    }
+}
diff --git a/storm-client/src/jvm/org/apache/storm/executor/Executor.java 
b/storm-client/src/jvm/org/apache/storm/executor/Executor.java
index edd77743f..59f0894fb 100644
--- a/storm-client/src/jvm/org/apache/storm/executor/Executor.java
+++ b/storm-client/src/jvm/org/apache/storm/executor/Executor.java
@@ -1,4 +1,4 @@
-/**
+/*
  * 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
@@ -25,11 +25,13 @@ import java.net.UnknownHostException;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Queue;
 import java.util.Random;
+import java.util.Set;
 import java.util.concurrent.Callable;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
@@ -58,6 +60,7 @@ import org.apache.storm.executor.spout.SpoutExecutor;
 import org.apache.storm.generated.Bolt;
 import org.apache.storm.generated.Credentials;
 import org.apache.storm.generated.DebugOptions;
+import org.apache.storm.generated.GlobalStreamId;
 import org.apache.storm.generated.Grouping;
 import org.apache.storm.generated.SpoutSpec;
 import org.apache.storm.generated.StormTopology;
@@ -131,7 +134,11 @@ public abstract class Executor implements Callable, 
JCQueue.Consumer {
     private final RateCounter reportedErrorCount;
     private final boolean enableV2MetricsDataPoints;
     private final Integer v2MetricsTickInterval;
-
+    protected final boolean upstreamFeedbackEnabled;
+    protected final int upstreamFeedbackFreqSecs;
+    // task ids of all upstream (source component) tasks, recipients of the 
periodic feedback tick
+    protected final List<Integer> upstreamTaskIds;
+    protected final ChildEwmaStats childEwmaStats;
     protected Executor(WorkerState workerData, List<Long> executorId, 
Map<String, String> credentials, String type) {
         this.workerData = workerData;
         this.executorId = executorId;
@@ -177,6 +184,7 @@ public abstract class Executor implements Callable, 
JCQueue.Consumer {
         this.credentials = credentials;
         this.hasEventLoggers = StormCommon.hasEventLoggers(topoConf);
         this.ackingEnabled = StormCommon.hasAckers(topoConf);
+        this.upstreamFeedbackEnabled = 
ConfigUtils.upstreamFeedbackEnable(topoConf);
 
         try {
             this.hostname = Utils.hostname();
@@ -189,6 +197,16 @@ public abstract class Executor implements Callable, 
JCQueue.Consumer {
 
         enableV2MetricsDataPoints = 
ObjectReader.getBoolean(topoConf.get(Config.TOPOLOGY_ENABLE_V2_METRICS_TICK), 
false);
         v2MetricsTickInterval = 
ObjectReader.getInt(topoConf.get(Config.TOPOLOGY_V2_METRICS_TICK_INTERVAL_SECONDS),
 60);
+        this.childEwmaStats = new ChildEwmaStats(this.upstreamFeedbackEnabled);
+        if (this.upstreamFeedbackEnabled) {
+            this.upstreamFeedbackFreqSecs = 
ConfigUtils.upstreamFeedbackFreqSecs(topoConf);
+            this.upstreamTaskIds = computeUpstreamTaskIds();
+            // register ewma stats for loadaware streaming grouping
+            groupers.forEach(g -> g.registerEwmaStats(childEwmaStats));
+        } else {
+            this.upstreamFeedbackFreqSecs = 0;
+            this.upstreamTaskIds = Collections.emptyList();
+        }
     }
 
     public static Executor mkExecutor(WorkerState workerState, List<Long> 
executorId, Map<String, String> credentials) {
@@ -364,6 +382,74 @@ public abstract class Executor implements Callable, 
JCQueue.Consumer {
         }
     }
 
+    /**
+     * Constructs a Storm {@link Values} object carrying a snapshot of this 
task's jitter metrics
+     * to be sent as upstream feedback.
+     *
+     * <p>This method generates a {@link IMetricsConsumer.TaskInfo} header 
with a timestamp
+     * and a default interval of -1 (indicating an on-demand, 
non-periodic-metrics tick),
+     * followed by the {@link EwmaFeedbackRecord} snapshot. The snapshot is 
emitted on every tick:
+     * the tick frequency ({@code topology.upstream.feedback.freq.secs}) is 
the rate limit, and an
+     * unconditional resend keeps a restarted/reassigned upstream task from 
being stranded with stale
+     * or empty stats when the metric value happens to be stable.</p>
+     *
+     * @param taskId  The ID of the task for which metrics are being collected.
+     * @return A {@link Values} object containing {@code [TaskInfo, 
EwmaFeedbackRecord]} (matching the
+     *         feedback stream schema declared by {@link 
StormCommon#upstreamFeedbackFields()}).
+     */
+    public Values buildUpstreamFeedbackTuple(int taskId) {
+        EwmaFeedbackRecord statsRecord = 
EwmaFeedbackRecord.fromWorkerState(this.workerData, taskId);
+        IMetricsConsumer.TaskInfo taskInfo = new IMetricsConsumer.TaskInfo(
+            hostname, workerTopologyContext.getThisWorkerPort(),
+            componentId, taskId, Time.currentTimeSecs(), -1);
+        return new Values(taskInfo, statsRecord);
+    }
+
+    /**
+     * Updates child task statistics by unwrapping the Storm Values object.
+     *
+     * <p>Extracts the {@link IMetricsConsumer.TaskInfo} and the {@link 
EwmaFeedbackRecord}
+     * produced by {@link #buildUpstreamFeedbackTuple(int)} and forwards them 
to the thread-safe
+     * {@link ChildEwmaStats} store.</p>
+     *
+     * <p><b>Data Mapping:</b>
+     * <ul>
+     *   <li>Index 0: {@link IMetricsConsumer.TaskInfo}</li>
+     *   <li>Index 1: {@link EwmaFeedbackRecord}</li>
+     * </ul>
+     * </p>
+     *
+     * @param task  The {@link Task} associated with this update.
+     * @param tuple The {@link TupleImpl} emitted by the upstream feedback 
builder.
+     */
+    public void updateChildEwmaStats(Task task, TupleImpl tuple) {
+        if (!this.upstreamFeedbackEnabled || tuple == null) {
+            return;
+        }
+
+        List<Object> values = tuple.getValues();
+        if (values == null || values.size() < 2) {
+            LOG.warn("Feedback tuple for task {} has insufficient elements 
(size={})",
+                task.getTaskId(), values == null ? 0 : values.size());
+            return;
+        }
+
+        // Safe type check replaces unchecked cast and suppression
+        if (!(values.get(0) instanceof IMetricsConsumer.TaskInfo taskInfo)) {
+            LOG.warn("Unexpected type at index 0 in feedbackTuple for task {}: 
{}",
+                task.getTaskId(), values.get(0) == null ? "null" : 
values.get(0).getClass().getName());
+            return;
+        }
+
+        if (!(values.get(1) instanceof EwmaFeedbackRecord feedback)) {
+            LOG.warn("Unexpected type at index 1 in feedbackTuple for task {}: 
{}",
+                task.getTaskId(), values.get(1) == null ? "null" : 
values.get(1).getClass().getName());
+            return;
+        }
+
+        childEwmaStats.update(task.getTaskId(), taskInfo.srcTaskId, feedback);
+    }
+
     // updates v1 metric dataPoints with v2 metric API data
     private void addV2Metrics(int taskId, List<IMetricsConsumer.DataPoint> 
dataPoints, int interval) {
         if (!enableV2MetricsDataPoints) {
@@ -503,6 +589,62 @@ public abstract class Executor implements Callable, 
JCQueue.Consumer {
         );
     }
 
+    /**
+     * Collects the task ids of every upstream (source component) task. These 
are the recipients of
+     * the periodic upstream feedback tick. System components (e.g. ackers, 
metrics) are excluded.
+     */
+    private List<Integer> computeUpstreamTaskIds() {
+        Set<Integer> taskIds = new HashSet<>();
+        for (GlobalStreamId source : 
workerTopologyContext.getSources(componentId).keySet()) {
+            String sourceComponentId = source.get_componentId();
+            if (Utils.isSystemId(sourceComponentId)) {
+                continue;
+            }
+            
taskIds.addAll(workerTopologyContext.getComponentTasks(sourceComponentId));
+        }
+        return new ArrayList<>(taskIds);
+    }
+
+    /**
+     * Schedules a recurring internal tick on {@link 
Constants#FEEDBACK_TICK_STREAM_ID}. Handling the
+     * tick (see BoltExecutor.tupleActionFn) triggers {@link 
#sendUpstreamFeedback(Task)}, replacing
+     * the former probabilistic per-emit trigger with a deterministic periodic 
one.
+     */
+    protected void scheduleUpstreamFeedbackTick(int interval) {
+        StormTimer timerTask = workerData.getUserTimer();
+        timerTask.scheduleRecurring(interval, interval,
+            () -> {
+                TupleImpl tuple =
+                        new TupleImpl(workerTopologyContext, new 
Values(interval), Constants.SYSTEM_COMPONENT_ID,
+                                (int) Constants.SYSTEM_TASK_ID, 
Constants.FEEDBACK_TICK_STREAM_ID);
+                AddressedTuple feedbackTickTuple = new 
AddressedTuple(AddressedTuple.BROADCAST_DEST, tuple);
+                try {
+                    receiveQueue.publish(feedbackTickTuple);
+                    receiveQueue.flush();  // avoid buffering
+                } catch (InterruptedException e) {
+                    LOG.warn("Thread interrupted when publishing upstream 
feedback tick. Setting interrupt flag.");
+                    Thread.currentThread().interrupt();
+                    return;
+                }
+            }
+        );
+    }
+
+    /**
+     * Sends an upstream feedback tuple for the given task to all of its 
upstream tasks. Invoked on
+     * each feedback tick. The snapshot is built by {@link 
#buildUpstreamFeedbackTuple(int)}.
+     */
+    public void sendUpstreamFeedback(Task task) {
+        if (!upstreamFeedbackEnabled) {
+            return;
+        }
+        Values feedbackTuple = buildUpstreamFeedbackTuple(task.getTaskId());
+        for (int parentTask : upstreamTaskIds) {
+            task.sendUnanchoredFeedback(Constants.FEEDBACK_STREAM_ID, 
feedbackTuple, parentTask,
+                    executorTransfer, pendingEmits);
+        }
+    }
+
     protected void setupTicks(boolean isSpout) {
         final Integer tickTimeSecs = 
ObjectReader.getInt(topoConf.get(Config.TOPOLOGY_TICK_TUPLE_FREQ_SECS), null);
         if (tickTimeSecs != null) {
diff --git 
a/storm-client/src/jvm/org/apache/storm/executor/bolt/BoltExecutor.java 
b/storm-client/src/jvm/org/apache/storm/executor/bolt/BoltExecutor.java
index 273bab5e6..47a4940e7 100644
--- a/storm-client/src/jvm/org/apache/storm/executor/bolt/BoltExecutor.java
+++ b/storm-client/src/jvm/org/apache/storm/executor/bolt/BoltExecutor.java
@@ -131,6 +131,9 @@ public class BoltExecutor extends Executor {
         LOG.info("Prepared bolt {}:{}", componentId, taskIds);
         setupTicks(false);
         setupMetrics();
+        if (upstreamFeedbackEnabled && !Utils.isSystemId(componentId)) {
+            scheduleUpstreamFeedbackTick(upstreamFeedbackFreqSecs);
+        }
     }
 
     @Override
@@ -198,6 +201,18 @@ public class BoltExecutor extends Executor {
             outputCollector.flush();
         } else if (Constants.METRICS_TICK_STREAM_ID.equals(streamId)) {
             metricsTick(idToTask.get(taskId - idToTaskBase), tuple);
+        } else if (Constants.FEEDBACK_TICK_STREAM_ID.equals(streamId)) {
+            if (this.upstreamFeedbackEnabled) {
+                // periodic trigger: emit this task's feedback snapshot to its 
upstream tasks
+                sendUpstreamFeedback(idToTask.get(taskId - idToTaskBase));
+            }
+        } else if (Constants.FEEDBACK_STREAM_ID.equals(streamId)) {
+            if (!this.upstreamFeedbackEnabled) {
+                LOG.debug("Upstream feedback skipped.");
+            } else {
+                // update internal metrics
+                this.updateChildEwmaStats(idToTask.get(taskId - idToTaskBase), 
tuple);
+            }
         } else {
             IBolt boltObject = (IBolt) idToTask.get(taskId - 
idToTaskBase).getTaskObject();
             boolean isSampled = sampler.getAsBoolean();
diff --git 
a/storm-client/src/jvm/org/apache/storm/executor/spout/SpoutExecutor.java 
b/storm-client/src/jvm/org/apache/storm/executor/spout/SpoutExecutor.java
index 734fca2a2..2eab9b088 100644
--- a/storm-client/src/jvm/org/apache/storm/executor/spout/SpoutExecutor.java
+++ b/storm-client/src/jvm/org/apache/storm/executor/spout/SpoutExecutor.java
@@ -95,7 +95,7 @@ public class SpoutExecutor extends Executor {
     }
 
     public void init(final ArrayList<Task> idToTask, int idToTaskBase) throws 
InterruptedException {
-        this.threadId = Thread.currentThread().getId();
+        this.threadId = Thread.currentThread().threadId();
         executorTransfer.initLocalRecvQueues();
         workerReady.await();
         while (!stormActive.get()) {
@@ -321,6 +321,13 @@ public class SpoutExecutor extends Executor {
             if (pendingForId != null) {
                 pending.put(id, pendingForId);
             }
+        } else if (Constants.FEEDBACK_STREAM_ID.equals(streamId)) {
+            if (!this.upstreamFeedbackEnabled) {
+                LOG.debug("Upstream feedback skipped.");
+            } else {
+                // update internal metrics
+                this.updateChildEwmaStats(idToTask.get(taskId - idToTaskBase), 
tuple);
+            }
         } else {
             Long id = (Long) tuple.getValue(0);
             Long timeDeltaMs = (Long) tuple.getValue(1);
diff --git 
a/storm-client/src/jvm/org/apache/storm/grouping/JitterAwareStreamGrouping.java 
b/storm-client/src/jvm/org/apache/storm/grouping/JitterAwareStreamGrouping.java
new file mode 100644
index 000000000..b0c723cb2
--- /dev/null
+++ 
b/storm-client/src/jvm/org/apache/storm/grouping/JitterAwareStreamGrouping.java
@@ -0,0 +1,134 @@
+/*
+ * 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.storm.grouping;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.concurrent.ThreadLocalRandom;
+import org.apache.storm.executor.ChildEwmaStats;
+import org.apache.storm.generated.GlobalStreamId;
+import org.apache.storm.shade.com.google.common.annotations.VisibleForTesting;
+import org.apache.storm.task.WorkerTopologyContext;
+
+/**
+ * A {@link LoadAwareCustomStreamGrouping} that steers each tuple toward the 
downstream (child) task with
+ * lower jitter, as reported back to the emitting task through upstream 
feedback and aggregated by
+ * {@link ChildEwmaStats}. Jitter is compared with {@link 
ChildEwmaStats#compareByJitter}, so a lower
+ * {@code __execute-jitter} wins first, then {@code __process-jitter}, then 
{@code __complete-jitter}.
+ *
+ * <p>Steering uses <b>power-of-two-choices</b>: for each tuple two target 
tasks are sampled at random and
+ * the lower-jitter one wins. Random sampling keeps the best task from 
receiving every tuple (the
+ * "thundering herd" a plain arg-min selection would cause) while still 
biasing traffic toward the good
+ * tasks.
+ *
+ * <p>Whenever jitter cannot pick a winner — the sampled pair ties (equal 
jitter, or neither has reported),
+ * no feedback exists yet for the source task, or no {@link ChildEwmaStats} 
was registered — the decision is
+ * delegated to an embedded {@link LoadAwareShuffleGrouping}. When <i>all</i> 
targets carry equal jitter,
+ * every sampled pair ties, so the grouping behaves as a pure load-aware 
shuffle. {@link #refreshLoad} is
+ * forwarded to that delegate, so the fallback path honours real system load 
and locality.
+ *
+ * <p><b>Ordering:</b> like any load-aware shuffle, this grouping does 
<i>not</i> preserve tuple ordering.
+ * It is a routing policy layered on top of {@link LoadAwareShuffleGrouping} 
and inherits the same (lack of)
+ * ordering semantics: successive tuples emitted by the same source task may 
be steered to different
+ * downstream tasks, so consumers must not rely on receiving tuples in 
emission order. Use a fields grouping
+ * if per-key ordering is required.
+ *
+ * <p><b>Requirements:</b> this grouping is purely opt-in and only steers 
while the upstream feedback loop is
+ * active. The feedback records carry EWMA jitter stats, which are produced 
solely when
+ * {@code topology.stats.ewma.enable=true}; enabling {@code 
topology.upstream.feedback.enable} without EWMA
+ * is therefore rejected at config validation (see {@code 
ConfigValidation.UpstreamFeedbackValidator}), since
+ * the grouping would otherwise silently degrade to a plain load-aware shuffle 
forever.
+ *
+ * <p><b>Performance:</b> benchmarks show the feedback signal adds negligible 
overhead and, at moderate load yields
+ * a directionally lower complete latency than a  plain load-aware shuffle at 
comparable throughput.
+ * At higher load the advantage disappears and results are 
neutral-to-slightly-worse.
+ * Treat it as an opt-in latency-smoothing policy rather than a general 
throughput improvement.
+ */
+public class JitterAwareStreamGrouping implements 
LoadAwareCustomStreamGrouping {
+
+    private final LoadAwareShuffleGrouping fallback = new 
LoadAwareShuffleGrouping();
+    private List<Integer> targetTasks;
+    private ChildEwmaStats stats;
+
+    // deterministic test
+    @VisibleForTesting
+    Random random;
+
+    @Override
+    public void refreshLoad(LoadMapping loadMapping) {
+        fallback.refreshLoad(loadMapping);
+    }
+
+    @Override
+    public void registerEwmaStats(ChildEwmaStats childEwmaStats) {
+        this.stats = childEwmaStats;
+    }
+
+    @Override
+    public void prepare(WorkerTopologyContext context, GlobalStreamId stream, 
List<Integer> targetTasks) {
+        this.targetTasks = targetTasks;
+        // The fallback rejects an empty target list; chooseTasks 
short-circuits that case before delegating.
+        if (targetTasks != null && !targetTasks.isEmpty()) {
+            fallback.prepare(context, stream, targetTasks);
+        }
+    }
+
+    @Override
+    public List<Integer> chooseTasks(int taskId, List<Object> values) {
+        if (targetTasks == null || targetTasks.isEmpty()) {
+            return Collections.emptyList();
+        }
+        if (targetTasks.size() == 1) {
+            return targetTasks;
+        }
+
+        if (stats == null) {
+            return fallback.chooseTasks(taskId, values);
+        }
+
+        Map<Integer, Map<String, Double>> childStats = stats.getStats(taskId);
+        if (childStats.isEmpty()) {
+            return fallback.chooseTasks(taskId, values);
+        }
+
+        // Power-of-two-choices: sample two distinct targets and keep the 
lower-jitter one.
+        int n = targetTasks.size();
+        int i = nextInt(n);
+        int j = nextInt(n - 1);
+        if (j >= i) {
+            j++;
+        }
+        Integer a = targetTasks.get(i);
+        Integer b = targetTasks.get(j);
+
+        // An unreported target is an empty map, which compareByJitter treats 
as worst.
+        Map<String, Double> metricsA = childStats.getOrDefault(a, 
Collections.emptyMap());
+        Map<String, Double> metricsB = childStats.getOrDefault(b, 
Collections.emptyMap());
+        int cmp = ChildEwmaStats.compareByJitter(metricsA, metricsB);
+        if (cmp < 0) {
+            return Collections.singletonList(a);
+        }
+        if (cmp > 0) {
+            return Collections.singletonList(b);
+        }
+        // Tie (equal jitter, or both unreported): no jitter winner -> defer 
to the load-aware fallback.
+        return fallback.chooseTasks(taskId, values);
+    }
+
+    private int nextInt(int bound) {
+        return random != null ? random.nextInt(bound) : 
ThreadLocalRandom.current().nextInt(bound);
+    }
+
+}
diff --git 
a/storm-client/src/jvm/org/apache/storm/grouping/LoadAwareCustomStreamGrouping.java
 
b/storm-client/src/jvm/org/apache/storm/grouping/LoadAwareCustomStreamGrouping.java
index 5a4d4a671..7edeec83f 100644
--- 
a/storm-client/src/jvm/org/apache/storm/grouping/LoadAwareCustomStreamGrouping.java
+++ 
b/storm-client/src/jvm/org/apache/storm/grouping/LoadAwareCustomStreamGrouping.java
@@ -12,6 +12,12 @@
 
 package org.apache.storm.grouping;
 
+import org.apache.storm.executor.ChildEwmaStats;
+
 public interface LoadAwareCustomStreamGrouping extends CustomStreamGrouping {
     void refreshLoad(LoadMapping loadMapping);
+
+    default void registerEwmaStats(ChildEwmaStats childEwmaStats) {
+        // no-op: backward compatibility
+    }
 }
diff --git a/storm-client/src/jvm/org/apache/storm/metrics2/TaskMetrics.java 
b/storm-client/src/jvm/org/apache/storm/metrics2/TaskMetrics.java
index 78cd6d3b9..059109b0b 100644
--- a/storm-client/src/jvm/org/apache/storm/metrics2/TaskMetrics.java
+++ b/storm-client/src/jvm/org/apache/storm/metrics2/TaskMetrics.java
@@ -29,11 +29,11 @@ public class TaskMetrics {
     private static final String METRIC_NAME_TRANSFERRED = "__transfer-count";
     private static final String METRIC_NAME_EXECUTED = "__execute-count";
     private static final String METRIC_NAME_PROCESS_LATENCY = 
"__process-latency";
-    private static final String METRIC_NAME_PROCESS_JITTER = 
"__process-jitter";
+    public static final String METRIC_NAME_PROCESS_JITTER = "__process-jitter";
     private static final String METRIC_NAME_COMPLETE_LATENCY = 
"__complete-latency";
-    private static final String METRIC_NAME_COMPLETE_JITTER = 
"__complete-jitter";
+    public static final String METRIC_NAME_COMPLETE_JITTER = 
"__complete-jitter";
     private static final String METRIC_NAME_EXECUTE_LATENCY = 
"__execute-latency";
-    private static final String METRIC_NAME_EXECUTE_JITTER = 
"__execute-jitter";
+    public static final String METRIC_NAME_EXECUTE_JITTER = "__execute-jitter";
     private static final String METRIC_NAME_CAPACITY = "__capacity";
 
     private final ConcurrentMap<String, RateCounter> rateCounters = new 
ConcurrentHashMap<>();
diff --git 
a/storm-client/src/jvm/org/apache/storm/serialization/SerializationFactory.java 
b/storm-client/src/jvm/org/apache/storm/serialization/SerializationFactory.java
index b53f8375f..18b471c99 100644
--- 
a/storm-client/src/jvm/org/apache/storm/serialization/SerializationFactory.java
+++ 
b/storm-client/src/jvm/org/apache/storm/serialization/SerializationFactory.java
@@ -79,6 +79,7 @@ public class SerializationFactory {
         k.register(BackPressureStatus.class);
         k.register(NodeInfo.class);
         k.register(DetachedTuple.class);
+        k.register(org.apache.storm.executor.EwmaFeedbackRecord.class);
 
         synchronized (loader) {
             for (SerializationRegister sr : loader) {
diff --git a/storm-client/src/jvm/org/apache/storm/utils/ConfigUtils.java 
b/storm-client/src/jvm/org/apache/storm/utils/ConfigUtils.java
index d0fc9691d..663a79154 100644
--- a/storm-client/src/jvm/org/apache/storm/utils/ConfigUtils.java
+++ b/storm-client/src/jvm/org/apache/storm/utils/ConfigUtils.java
@@ -198,6 +198,24 @@ public class ConfigUtils {
         return ObjectReader.getBoolean(value, false);
     }
 
+    public static boolean upstreamFeedbackEnable(Map<String, Object> conf) {
+        Object value = conf.get(Config.TOPOLOGY_UPSTREAM_FEEDBACK_ENABLE);
+        if (value == null) {
+            return false;
+        }
+        return ObjectReader.getBoolean(value, false);
+    }
+    
+    public static int upstreamFeedbackFreqSecs(Map<String, Object> conf) {
+        int freqSecs = 
ObjectReader.getInt(conf.get(Config.TOPOLOGY_UPSTREAM_FEEDBACK_FREQ_SECS), 10);
+        if (freqSecs > 0) {
+            return freqSecs;
+        }
+        throw new IllegalArgumentException(
+            "Illegal " + Config.TOPOLOGY_UPSTREAM_FEEDBACK_FREQ_SECS
+                + " in conf: " + freqSecs + " must be > 0");
+    }
+
     public static BooleanSupplier mkStatsSampler(Map<String, Object> conf) {
         return evenSampler(samplingRate(conf));
     }
diff --git 
a/storm-client/src/jvm/org/apache/storm/validation/ConfigValidation.java 
b/storm-client/src/jvm/org/apache/storm/validation/ConfigValidation.java
index 0d59fed77..7d59b4a2e 100644
--- a/storm-client/src/jvm/org/apache/storm/validation/ConfigValidation.java
+++ b/storm-client/src/jvm/org/apache/storm/validation/ConfigValidation.java
@@ -128,6 +128,10 @@ public class ConfigValidation {
      * @param conf  map of confs
      */
     public static void validateField(Field field, Map<String, Object> conf) {
+        validateField(field, conf, false);
+    }
+
+    private static void validateField(Field field, Map<String, Object> conf, 
boolean comboPhase) {
         Annotation[] annotations = field.getAnnotations();
         if (annotations.length == 0) {
             LOG.warn("Field {} does not have validator annotation", field);
@@ -152,20 +156,29 @@ public class ConfigValidation {
                 if (validatorClass != null) {
                     Object v = validatorClass.cast(annotation);
                     String key = (String) field.get(null);
-                    @SuppressWarnings("unchecked")
-                    Class<Validator> clazz = (Class<Validator>) validatorClass
+                    Class<?> clazz = (Class<?>) validatorClass
                         
.getMethod(ConfigValidationAnnotations.ValidatorParams.VALIDATOR_CLASS).invoke(v);
-                    Validator o = null;
+                    //run each validator only in its phase, so cross-field 
rules are deferred to pass 2.
+                    boolean isCombo = 
ComboValidator.class.isAssignableFrom(clazz);
+                    if (isCombo != comboPhase) {
+                        continue;
+                    }
                     Map<String, Object> params = 
getParamsFromAnnotation(validatorClass, v);
                     //two constructor signatures used to initialize validators.
                     //One constructor takes input a Map of arguments, the 
other doesn't take any arguments (default constructor)
                     //If validator has a constructor that takes a Map as an 
argument call that constructor
+                    Object o;
                     if (hasConstructor(clazz, Map.class)) {
                         o = 
clazz.getConstructor(Map.class).newInstance(params);
                     } else { //If not call default constructor
                         o = clazz.newInstance();
                     }
-                    o.validateField(field.getName(), conf.get(key));
+                    if (isCombo) {
+                        //cross-field rule: pass the whole conf, keyed off the 
annotated field's name.
+                        ((ComboValidator) o).validateComboFields(conf);
+                    } else {
+                        ((Validator) o).validateField(field.getName(), 
conf.get(key));
+                    }
                 }
             }
         } catch (NoSuchMethodException | IllegalAccessException | 
InstantiationException | InvocationTargetException e) {
@@ -197,12 +210,13 @@ public class ConfigValidation {
      * @param classes config class
      */
     public static void validateFields(Map<String, Object> conf, List<Class<?>> 
classes) {
+        List<Field> presentFields = new ArrayList<>();
         for (Class<?> clazz : classes) {
             for (Field field : clazz.getDeclaredFields()) {
                 if (!isFieldAllowed(field)) {
                     continue;
                 }
-                Object keyObj = null;
+                Object keyObj;
                 try {
                     keyObj = field.get(null);
                 } catch (IllegalAccessException e) {
@@ -210,14 +224,19 @@ public class ConfigValidation {
                     throw new RuntimeException(e);
                 }
                 //make sure that defined key is string in case wrong stuff got 
put into Config.java
-                if (keyObj instanceof String) {
-                    String confKey = (String) keyObj;
-                    if (conf.containsKey(confKey)) {
-                        validateField(field, conf);
-                    }
+                if (keyObj instanceof String && conf.containsKey((String) 
keyObj)) {
+                    presentFields.add(field);
                 }
             }
         }
+        //Pass 1: validate each present field on its own, so the whole conf is 
individually valid first.
+        for (Field field : presentFields) {
+            validateField(field, conf, false);
+        }
+        //Pass 2: cross-field rules, run only after pass 1 so a ComboValidator 
sees an already-valid conf.
+        for (Field field : presentFields) {
+            validateField(field, conf, true);
+        }
     }
 
     public static boolean isFieldAllowed(Field field) {
@@ -264,6 +283,16 @@ public class ConfigValidation {
         public abstract void validateField(String name, Object o);
     }
 
+    public abstract static class ComboValidator {
+        public ComboValidator(Map<String, Object> params) {
+        }
+
+        public ComboValidator() {
+        }
+
+        public abstract void validateComboFields(Map<String, Object> conf);
+    }
+
     /**
      * Validates if an object is not null.
      */
@@ -870,7 +899,7 @@ public class ConfigValidation {
         }
     }
 
-    public static class EwmaSmoothingFactorValidator extends Validator {
+    public static class ZeroOneOpenIntervalValidator extends Validator {
         @Override
         public void validateField(String name, Object o) {
             if (o == null) {
@@ -1093,4 +1122,30 @@ public class ConfigValidation {
             }
         }
     }
+
+
+    /**
+     * Rejects enabling upstream feedback without EWMA stats. The feedback 
loop only carries EWMA jitter
+     * statistics, which are populated solely when {@link 
Config#TOPOLOGY_STATS_EWMA_ENABLE} is on; with
+     * {@link Config#TOPOLOGY_UPSTREAM_FEEDBACK_ENABLE} enabled but EWMA left 
at its default ({@code false}),
+     * every feedback record is empty and a jitter-aware grouping silently 
degrades to its load-aware
+     * fallback forever.
+     */
+    public static class UpstreamFeedbackValidator extends ComboValidator {
+        @Override
+        public void validateComboFields(Map<String, Object> conf) {
+            boolean feedbackEnabled =
+                    
ObjectReader.getBoolean(conf.get(Config.TOPOLOGY_UPSTREAM_FEEDBACK_ENABLE), 
false);
+            boolean ewmaEnabled =
+                    
ObjectReader.getBoolean(conf.get(Config.TOPOLOGY_STATS_EWMA_ENABLE), false);
+            if (feedbackEnabled && !ewmaEnabled) {
+                throw new IllegalArgumentException(
+                        Config.TOPOLOGY_UPSTREAM_FEEDBACK_ENABLE + " requires 
" + Config.TOPOLOGY_STATS_EWMA_ENABLE
+                                + "=true: the feedback loop only carries EWMA 
jitter stats, which are produced solely "
+                                + "when EWMA is enabled. Enable " + 
Config.TOPOLOGY_STATS_EWMA_ENABLE + ", or disable "
+                                + Config.TOPOLOGY_UPSTREAM_FEEDBACK_ENABLE + " 
(it is otherwise a no-op).");
+            }
+        }
+    }
+
 }
diff --git 
a/storm-client/src/jvm/org/apache/storm/validation/ConfigValidationAnnotations.java
 
b/storm-client/src/jvm/org/apache/storm/validation/ConfigValidationAnnotations.java
index 736746e76..796336065 100644
--- 
a/storm-client/src/jvm/org/apache/storm/validation/ConfigValidationAnnotations.java
+++ 
b/storm-client/src/jvm/org/apache/storm/validation/ConfigValidationAnnotations.java
@@ -204,6 +204,17 @@ public class ConfigValidationAnnotations {
         Class<?> validatorClass();
     }
 
+    /**
+     * For custom cross-field validators. Unlike {@link CustomValidator}, 
whose validator receives a single
+     * field's value, the referenced {@link ConfigValidation.ComboValidator} 
receives the whole configuration
+     * so it can enforce dependencies between keys. Place it on the field 
whose presence triggers the rule.
+     */
+    @Retention(RetentionPolicy.RUNTIME)
+    @Target(ElementType.FIELD)
+    public @interface CustomComboValidator {
+        Class<?> validatorClass();
+    }
+
     /**
      * Custom validator where exactly one of the validations must be 
successful.
      * Used for overloaded configuration, where value must match one (and 
exactly one)
diff --git a/storm-client/test/jvm/org/apache/storm/TestConfigValidate.java 
b/storm-client/test/jvm/org/apache/storm/TestConfigValidate.java
index 31b9c4834..32bb14338 100644
--- a/storm-client/test/jvm/org/apache/storm/TestConfigValidate.java
+++ b/storm-client/test/jvm/org/apache/storm/TestConfigValidate.java
@@ -92,6 +92,43 @@ public class TestConfigValidate {
         assertThrows(IllegalArgumentException.class, () -> 
ConfigValidation.validateFields(conf));
     }
 
+    @Test
+    public void upstreamFeedbackRequiresEwmaTest() {
+        // Cross-field rule: enabling feedback without EWMA stats is a no-op, 
so it is rejected.
+        Map<String, Object> conf = new HashMap<>();
+        conf.put(Config.TOPOLOGY_UPSTREAM_FEEDBACK_ENABLE, true);
+        conf.put(Config.TOPOLOGY_STATS_EWMA_ENABLE, false);
+        IllegalArgumentException ex =
+            assertThrows(IllegalArgumentException.class, () -> 
ConfigValidation.validateFields(conf));
+        assertTrue(ex.getMessage().contains(Config.TOPOLOGY_STATS_EWMA_ENABLE),
+            "message should name the required key: " + ex.getMessage());
+    }
+
+    @Test
+    public void upstreamFeedbackEwmaAbsentTest() {
+        // EWMA defaults to false, so an absent key is still the rejected 
no-op combination.
+        Map<String, Object> conf = new HashMap<>();
+        conf.put(Config.TOPOLOGY_UPSTREAM_FEEDBACK_ENABLE, true);
+        assertThrows(IllegalArgumentException.class, () -> 
ConfigValidation.validateFields(conf));
+    }
+
+    @Test
+    public void validUpstreamFeedbackTest() {
+        Map<String, Object> conf = new HashMap<>();
+        conf.put(Config.TOPOLOGY_UPSTREAM_FEEDBACK_ENABLE, true);
+        conf.put(Config.TOPOLOGY_STATS_EWMA_ENABLE, true);
+        ConfigValidation.validateFields(conf);
+    }
+
+    @Test
+    public void upstreamFeedbackDisabledTest() {
+        // Feedback off => no requirement on EWMA.
+        Map<String, Object> conf = new HashMap<>();
+        conf.put(Config.TOPOLOGY_UPSTREAM_FEEDBACK_ENABLE, false);
+        conf.put(Config.TOPOLOGY_STATS_EWMA_ENABLE, false);
+        ConfigValidation.validateFields(conf);
+    }
+
     @Test
     public void validConfigTest() {
         Map<String, Object> conf = new HashMap<>();
diff --git 
a/storm-client/test/jvm/org/apache/storm/executor/ChildEwmaStatsTest.java 
b/storm-client/test/jvm/org/apache/storm/executor/ChildEwmaStatsTest.java
new file mode 100644
index 000000000..2b4be5de9
--- /dev/null
+++ b/storm-client/test/jvm/org/apache/storm/executor/ChildEwmaStatsTest.java
@@ -0,0 +1,123 @@
+/**
+ * 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.storm.executor;
+
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.storm.metrics2.TaskMetrics;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Unit tests for {@link ChildEwmaStats}: the per-source-task aggregation of 
downstream jitter
+ * reports and the {@link ChildEwmaStats#compareByJitter} ordering used by
+ * {@link org.apache.storm.grouping.JitterAwareStreamGrouping} to pick the 
lowest-jitter child.
+ */
+public class ChildEwmaStatsTest {
+
+    private static final int PARENT = 10;
+    private static final int CHILD_A = 20;
+    private static final int CHILD_B = 21;
+
+    private Map<String, Double> metrics(double execute, double process, double 
complete) {
+        Map<String, Double> m = new HashMap<>();
+        m.put(TaskMetrics.METRIC_NAME_EXECUTE_JITTER, execute);
+        m.put(TaskMetrics.METRIC_NAME_PROCESS_JITTER, process);
+        m.put(TaskMetrics.METRIC_NAME_COMPLETE_JITTER, complete);
+        return m;
+    }
+
+    @Test
+    public void disabled_updateIsNoOpAndStatsEmpty() {
+        ChildEwmaStats stats = new ChildEwmaStats(false);
+        stats.update(PARENT, CHILD_A, new EwmaFeedbackRecord(1, 2, 3));
+        assertTrue(stats.getStats(PARENT).isEmpty());
+    }
+
+    @Test
+    public void update_storesPerChildMetricsForParent() {
+        ChildEwmaStats stats = new ChildEwmaStats(true);
+        stats.update(PARENT, CHILD_A, new EwmaFeedbackRecord(1.0, 2.0, 3.0));
+
+        Map<Integer, Map<String, Double>> byChild = stats.getStats(PARENT);
+        assertEquals(1, byChild.size());
+        Map<String, Double> child = byChild.get(CHILD_A);
+        assertEquals(3.0, child.get(TaskMetrics.METRIC_NAME_EXECUTE_JITTER));
+        assertEquals(1.0, child.get(TaskMetrics.METRIC_NAME_PROCESS_JITTER));
+        assertEquals(2.0, child.get(TaskMetrics.METRIC_NAME_COMPLETE_JITTER));
+    }
+
+    @Test
+    public void update_isIsolatedPerParentTask() {
+        ChildEwmaStats stats = new ChildEwmaStats(true);
+        stats.update(PARENT, CHILD_A, new EwmaFeedbackRecord(1, 2, 3));
+        assertTrue(stats.getStats(999).isEmpty(), "a parent with no reports 
sees no stats");
+    }
+
+    @Test
+    public void update_latestReportOverwritesPrevious() {
+        ChildEwmaStats stats = new ChildEwmaStats(true);
+        stats.update(PARENT, CHILD_A, new EwmaFeedbackRecord(1, 1, 1));
+        stats.update(PARENT, CHILD_A, new EwmaFeedbackRecord(9, 9, 9));
+        assertEquals(9.0, 
stats.getStats(PARENT).get(CHILD_A).get(TaskMetrics.METRIC_NAME_EXECUTE_JITTER));
+    }
+
+    @Test
+    public void update_absentMetricNotStored() {
+        // VOID-valued components are skipped by 
EwmaFeedbackRecord#forEachMetric, so they never reach the map.
+        ChildEwmaStats stats = new ChildEwmaStats(true);
+        stats.update(PARENT, CHILD_A, new EwmaFeedbackRecord(-1, -1, 5.0));
+        Map<String, Double> child = stats.getStats(PARENT).get(CHILD_A);
+        assertEquals(1, child.size());
+        assertEquals(5.0, child.get(TaskMetrics.METRIC_NAME_EXECUTE_JITTER));
+    }
+
+    @Test
+    public void compareByJitter_executeJitterWinsFirst() {
+        // Lower execute-jitter is preferred regardless of the other two 
metrics.
+        Map<String, Double> low = metrics(1.0, 9.0, 9.0);
+        Map<String, Double> high = metrics(2.0, 0.0, 0.0);
+        assertTrue(ChildEwmaStats.compareByJitter(low, high) < 0);
+        assertTrue(ChildEwmaStats.compareByJitter(high, low) > 0);
+    }
+
+    @Test
+    public void compareByJitter_fallsThroughToProcessThenComplete() {
+        Map<String, Double> a = metrics(1.0, 1.0, 5.0);
+        Map<String, Double> b = metrics(1.0, 2.0, 0.0);
+        // tie on execute -> process decides (a wins)
+        assertTrue(ChildEwmaStats.compareByJitter(a, b) < 0);
+
+        Map<String, Double> c = metrics(1.0, 1.0, 3.0);
+        Map<String, Double> d = metrics(1.0, 1.0, 4.0);
+        // tie on execute and process -> complete decides (c wins)
+        assertTrue(ChildEwmaStats.compareByJitter(c, d) < 0);
+    }
+
+    @Test
+    public void compareByJitter_equalMetricsAreEqual() {
+        assertEquals(0, ChildEwmaStats.compareByJitter(metrics(1, 2, 3), 
metrics(1, 2, 3)));
+    }
+
+    @Test
+    public void compareByJitter_missingMetricTreatedAsWorst() {
+        // An empty map (a child that has not reported) must lose to any 
measured value.
+        Map<String, Double> measured = metrics(5.0, 5.0, 5.0);
+        Map<String, Double> empty = new HashMap<>();
+        assertTrue(ChildEwmaStats.compareByJitter(measured, empty) < 0);
+        assertTrue(ChildEwmaStats.compareByJitter(empty, measured) > 0);
+        assertEquals(0, ChildEwmaStats.compareByJitter(empty, empty));
+    }
+}
diff --git 
a/storm-client/test/jvm/org/apache/storm/executor/EwmaFeedbackRecordTest.java 
b/storm-client/test/jvm/org/apache/storm/executor/EwmaFeedbackRecordTest.java
new file mode 100644
index 000000000..38913d900
--- /dev/null
+++ 
b/storm-client/test/jvm/org/apache/storm/executor/EwmaFeedbackRecordTest.java
@@ -0,0 +1,228 @@
+/**
+ * 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.storm.executor;
+
+import com.codahale.metrics.Gauge;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.storm.Config;
+import org.apache.storm.daemon.worker.WorkerState;
+import org.apache.storm.metric.api.IMetricsConsumer;
+import org.apache.storm.metrics2.StormMetricRegistry;
+import org.apache.storm.metrics2.TaskMetrics;
+import org.apache.storm.serialization.KryoValuesDeserializer;
+import org.apache.storm.serialization.KryoValuesSerializer;
+import org.apache.storm.task.WorkerTopologyContext;
+import org.apache.storm.tuple.Values;
+import org.apache.storm.utils.Utils;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Tests for {@link EwmaFeedbackRecord}, covering three concerns:
+ * <ul>
+ *   <li>the {@link EwmaFeedbackRecord#forEachMetric} contract, including the 
VOID sentinel that
+ *       suppresses absent metrics;</li>
+ *   <li>Kryo wire round-trip — feedback tuples are emitted to upstream tasks 
that may live in a
+ *       different worker JVM, so the record is serialized across the network 
exactly as registered in
+ *       {@link org.apache.storm.serialization.SerializationFactory};</li>
+ *   <li>{@link EwmaFeedbackRecord#fromWorkerState} against the real {@link 
TaskMetrics} /
+ *       {@link StormMetricRegistry} registration path. {@code TaskMetrics} 
registers jitter gauges under
+ *       <em>suffixed</em> names (one per {@code 
sourceComponent:sourceStream}), e.g.
+ *       {@code __execute-jitter-splitter:default}, so a bare {@code 
get("__execute-jitter")} never matches.
+ *       These tests assert the signal is actually discovered and aggregated — 
without it
+ *       {@link org.apache.storm.grouping.JitterAwareStreamGrouping} silently 
degrades to round-robin.</li>
+ * </ul>
+ */
+public class EwmaFeedbackRecordTest {
+
+    private static final int TASK_ID = 7;
+    private static final double VOID = -1.0;
+
+    private Map<String, Object> conf() {
+        return Utils.readStormConfig();
+    }
+
+    private Map<String, Object> ewmaConf(boolean enabled) {
+        Map<String, Object> conf = Utils.readStormConfig();
+        conf.put(Config.TOPOLOGY_STATS_EWMA_ENABLE, enabled);
+        return conf;
+    }
+
+    private TaskMetrics newTaskMetrics(StormMetricRegistry registry, 
Map<String, Object> conf) {
+        WorkerTopologyContext context = mock(WorkerTopologyContext.class);
+        when(context.getStormId()).thenReturn("test-topo");
+        when(context.getThisWorkerPort()).thenReturn(6700);
+        return new TaskMetrics(context, "worker", TASK_ID, registry, conf);
+    }
+
+    private WorkerState workerStateFor(StormMetricRegistry registry) {
+        WorkerState workerState = mock(WorkerState.class);
+        when(workerState.getMetricRegistry()).thenReturn(registry);
+        return workerState;
+    }
+
+    // ---- forEachMetric ----
+
+    @Test
+    public void forEachMetric_emitsAllPresentMetricsWithCorrectNames() {
+        EwmaFeedbackRecord record = new EwmaFeedbackRecord(1.0, 2.0, 3.0);
+
+        Map<String, Double> collected = new LinkedHashMap<>();
+        record.forEachMetric(collected::put);
+
+        assertEquals(3, collected.size());
+        assertEquals(1.0, 
collected.get(TaskMetrics.METRIC_NAME_PROCESS_JITTER));
+        assertEquals(2.0, 
collected.get(TaskMetrics.METRIC_NAME_COMPLETE_JITTER));
+        assertEquals(3.0, 
collected.get(TaskMetrics.METRIC_NAME_EXECUTE_JITTER));
+    }
+
+    @Test
+    public void forEachMetric_skipsAbsentMetrics() {
+        // -1 is the VOID sentinel: an absent gauge must not be reported as a 
real (negative) measurement.
+        EwmaFeedbackRecord record = new EwmaFeedbackRecord(-1, 2.0, -1);
+
+        Map<String, Double> collected = new LinkedHashMap<>();
+        record.forEachMetric(collected::put);
+
+        assertEquals(1, collected.size());
+        assertEquals(2.0, 
collected.get(TaskMetrics.METRIC_NAME_COMPLETE_JITTER));
+    }
+
+    @Test
+    public void forEachMetric_emitsZeroJitter() {
+        // Zero is a legitimate value (jitter decays to 0 under stable 
latency) and must be distinct from VOID.
+        EwmaFeedbackRecord record = new EwmaFeedbackRecord(0.0, 0.0, 0.0);
+
+        Map<String, Double> collected = new LinkedHashMap<>();
+        record.forEachMetric(collected::put);
+
+        assertEquals(3, collected.size());
+        assertEquals(0.0, 
collected.get(TaskMetrics.METRIC_NAME_EXECUTE_JITTER));
+    }
+
+    // ---- Kryo round-trip ----
+
+    @Test
+    public void kryoRoundTrip_preservesRecord() {
+        Map<String, Object> conf = conf();
+        KryoValuesSerializer serializer = new KryoValuesSerializer(conf);
+        KryoValuesDeserializer deserializer = new KryoValuesDeserializer(conf);
+
+        EwmaFeedbackRecord original = new EwmaFeedbackRecord(1.5, 2.5, 3.5);
+        Object restored = 
deserializer.deserializeObject(serializer.serializeObject(original));
+
+        assertInstanceOf(EwmaFeedbackRecord.class, restored);
+        assertEquals(original, restored);
+    }
+
+    @Test
+    public void kryoRoundTrip_insideFeedbackTuple() {
+        // Mirrors Executor#buildUpstreamFeedbackTuple: [TaskInfo, 
EwmaFeedbackRecord]. Both elements
+        // must survive the wire so the receiving task can rebuild its child 
stats.
+        Map<String, Object> conf = conf();
+        KryoValuesSerializer serializer = new KryoValuesSerializer(conf);
+        KryoValuesDeserializer deserializer = new KryoValuesDeserializer(conf);
+
+        IMetricsConsumer.TaskInfo taskInfo =
+            new IMetricsConsumer.TaskInfo("host", 6700, "comp", 7, 123456, -1);
+        EwmaFeedbackRecord feedback = new EwmaFeedbackRecord(-1, 9.0, 4.0);
+        Values original = new Values(taskInfo, feedback);
+
+        List<Object> restored = 
deserializer.deserialize(serializer.serialize(original));
+
+        assertEquals(2, restored.size());
+        assertInstanceOf(IMetricsConsumer.TaskInfo.class, restored.get(0));
+        assertInstanceOf(EwmaFeedbackRecord.class, restored.get(1));
+        assertEquals(7, ((IMetricsConsumer.TaskInfo) 
restored.get(0)).srcTaskId);
+        assertEquals(feedback, restored.get(1));
+        assertTrue(((EwmaFeedbackRecord) restored.get(1)).processJitter() < 0, 
"VOID sentinel preserved");
+    }
+
+    @Test
+    public void fromWorkerState_discoversSuffixedExecuteJitterGauge() {
+        StormMetricRegistry registry = new StormMetricRegistry();
+        TaskMetrics taskMetrics = newTaskMetrics(registry, ewmaConf(true));
+
+        // Real path: registers "__execute-jitter-splitter:default" and feeds 
the RFC-1889 EWMA estimator.
+        // Varying latencies make the jitter estimate strictly positive.
+        for (long latency : new long[]{10, 60, 15, 90, 20}) {
+            taskMetrics.boltExecuteTuple("splitter", "default", latency);
+        }
+
+        EwmaFeedbackRecord record = 
EwmaFeedbackRecord.fromWorkerState(workerStateFor(registry), TASK_ID);
+
+        // Regression guard: a bare get("__execute-jitter") misses the 
suffixed key and leaves this VOID (-1).
+        assertTrue(record.executeJitter() > 0,
+            "execute-jitter must be discovered from the suffixed gauge, got " 
+ record.executeJitter());
+
+        // Metrics that were never driven must stay VOID — proving the prefix 
filter does not cross-match
+        // (e.g. __execute-jitter must not be picked up when asking for 
__process-jitter).
+        assertEquals(VOID, record.processJitter());
+        assertEquals(VOID, record.completeJitter());
+
+        // forEachMetric must surface the value under the canonical bare name 
consumed by ChildEwmaStats.
+        Map<String, Double> emitted = new HashMap<>();
+        record.forEachMetric(emitted::put);
+        assertEquals(1, emitted.size());
+        assertEquals(record.executeJitter(), 
emitted.get(TaskMetrics.METRIC_NAME_EXECUTE_JITTER));
+    }
+
+    @Test
+    public void fromWorkerState_aggregatesMaxAcrossMultipleSources() {
+        StormMetricRegistry registry = new StormMetricRegistry();
+        TaskMetrics taskMetrics = newTaskMetrics(registry, ewmaConf(true));
+
+        // Same task consuming from two upstream sources => two distinct 
__execute-jitter-<src> gauges.
+        for (long latency : new long[]{10, 20, 12, 18}) {
+            taskMetrics.boltExecuteTuple("srcA", "default", latency);
+        }
+        for (long latency : new long[]{10, 200, 5, 300}) {   // far jumpier => 
higher jitter
+            taskMetrics.boltExecuteTuple("srcB", "default", latency);
+        }
+
+        // Compute the expected max directly from the registry, independent of 
EWMA arithmetic.
+        Map<String, Gauge> gauges = registry.getTaskGauges(TASK_ID);
+        double expectedMax = gauges.entrySet().stream()
+            .filter(e -> 
e.getKey().startsWith(TaskMetrics.METRIC_NAME_EXECUTE_JITTER + "-"))
+            .mapToDouble(e -> ((Number) e.getValue().getValue()).doubleValue())
+            .max()
+            .orElseThrow(() -> new AssertionError("no execute-jitter gauges 
registered"));
+        assertTrue(expectedMax > 0, "precondition: at least one source 
produced positive jitter");
+
+        EwmaFeedbackRecord record = 
EwmaFeedbackRecord.fromWorkerState(workerStateFor(registry), TASK_ID);
+        assertEquals(expectedMax, record.executeJitter(),
+            "fromWorkerState must report the max jitter across all of the 
task's source gauges");
+    }
+
+    @Test
+    public void fromWorkerState_allVoidWhenNoJitterGaugesRegistered() {
+        // EWMA disabled => boltExecuteTuple registers latency gauges but no 
jitter gauges at all.
+        StormMetricRegistry registry = new StormMetricRegistry();
+        TaskMetrics taskMetrics = newTaskMetrics(registry, ewmaConf(false));
+
+        taskMetrics.boltExecuteTuple("splitter", "default", 42);
+
+        EwmaFeedbackRecord record = 
EwmaFeedbackRecord.fromWorkerState(workerStateFor(registry), TASK_ID);
+        assertEquals(VOID, record.executeJitter());
+        assertEquals(VOID, record.processJitter());
+        assertEquals(VOID, record.completeJitter());
+    }
+}
diff --git 
a/storm-client/test/jvm/org/apache/storm/grouping/JitterAwareStreamGroupingTest.java
 
b/storm-client/test/jvm/org/apache/storm/grouping/JitterAwareStreamGroupingTest.java
new file mode 100644
index 000000000..558ef0546
--- /dev/null
+++ 
b/storm-client/test/jvm/org/apache/storm/grouping/JitterAwareStreamGroupingTest.java
@@ -0,0 +1,199 @@
+/**
+ * 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.storm.grouping;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+import org.apache.storm.Config;
+import org.apache.storm.executor.ChildEwmaStats;
+import org.apache.storm.executor.EwmaFeedbackRecord;
+import org.apache.storm.generated.NodeInfo;
+import org.apache.storm.shade.com.google.common.collect.Sets;
+import org.apache.storm.task.WorkerTopologyContext;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for {@link JitterAwareStreamGrouping}: power-of-two-choices 
steering toward the lower-jitter
+ * child once {@link ChildEwmaStats} is populated, and delegation to the 
embedded
+ * {@link LoadAwareShuffleGrouping} whenever jitter cannot pick a winner (no 
feedback, or a tied pair).
+ *
+ * <p>With exactly two targets, P2C deterministically samples both, so the 
lower-jitter target always wins
+ * those tests without needing to control the random source.
+ */
+public class JitterAwareStreamGroupingTest {
+
+    private static final int SOURCE_TASK = 1;
+    private static final List<Object> VALUES = Collections.singletonList("v");
+
+    private Map<String, Object> createConf() {
+        Map<String, Object> conf = new HashMap<>();
+        conf.put(Config.STORM_NETWORK_TOPOGRAPHY_PLUGIN, 
"org.apache.storm.networktopography.DefaultRackDNSToSwitchMapping");
+        conf.put(Config.TOPOLOGY_LOCALITYAWARE_HIGHER_BOUND, 0.8);
+        conf.put(Config.TOPOLOGY_LOCALITYAWARE_LOWER_BOUND, 0.2);
+        return conf;
+    }
+
+    /** Minimal context that satisfies the embedded {@link 
LoadAwareShuffleGrouping#prepare}. */
+    private WorkerTopologyContext mockContext(List<Integer> availableTaskIds) {
+        WorkerTopologyContext context = mock(WorkerTopologyContext.class);
+        when(context.getConf()).thenReturn(createConf());
+        Map<Integer, NodeInfo> taskNodeToPort = new HashMap<>();
+        NodeInfo nodeInfo = new NodeInfo("node-id", Sets.newHashSet(6700L));
+        availableTaskIds.forEach(e -> taskNodeToPort.put(e, nodeInfo));
+        when(context.getTaskToNodePort()).thenReturn(new 
AtomicReference<>(taskNodeToPort));
+        when(context.getAssignmentId()).thenReturn("node-id");
+        when(context.getThisWorkerPort()).thenReturn(6700);
+        when(context.getNodeToHost()).thenReturn(new 
AtomicReference<>(Collections.singletonMap("node-id", "hostname1")));
+        return context;
+    }
+
+    private JitterAwareStreamGrouping prepared(List<Integer> targets, 
ChildEwmaStats stats) {
+        JitterAwareStreamGrouping grouping = new JitterAwareStreamGrouping();
+        if (stats != null) {
+            grouping.registerEwmaStats(stats);
+        }
+        grouping.prepare(mockContext(targets), null, targets);
+        return grouping;
+    }
+
+    @Test
+    public void chooseTasks_emptyTargetsReturnsEmpty() {
+        JitterAwareStreamGrouping grouping = prepared(Collections.emptyList(), 
null);
+        assertTrue(grouping.chooseTasks(SOURCE_TASK, VALUES).isEmpty());
+    }
+
+    @Test
+    public void chooseTasks_singleTargetAlwaysReturnsIt() {
+        JitterAwareStreamGrouping grouping = 
prepared(Collections.singletonList(42), new ChildEwmaStats(true));
+        for (int i = 0; i < 5; i++) {
+            assertEquals(Collections.singletonList(42), 
grouping.chooseTasks(SOURCE_TASK, VALUES));
+        }
+    }
+
+    @Test
+    public void chooseTasks_noStatsRegistered_delegatesToFallback() {
+        List<Integer> targets = Arrays.asList(10, 11, 12);
+        JitterAwareStreamGrouping grouping = prepared(targets, null);
+        assertDelegatedSpread(grouping, targets);
+    }
+
+    @Test
+    public void chooseTasks_statsEmptyForSource_delegatesToFallback() {
+        List<Integer> targets = Arrays.asList(10, 11, 12);
+        JitterAwareStreamGrouping grouping = prepared(targets, new 
ChildEwmaStats(true));
+        assertDelegatedSpread(grouping, targets);
+    }
+
+    @Test
+    public void chooseTasks_steersToLowerJitterChild() {
+        // Two targets => P2C samples both; the lower execute-jitter (11) 
always wins.
+        List<Integer> targets = Arrays.asList(10, 11);
+        ChildEwmaStats stats = new ChildEwmaStats(true);
+        stats.update(SOURCE_TASK, 10, new EwmaFeedbackRecord(5, 5, 8.0));
+        stats.update(SOURCE_TASK, 11, new EwmaFeedbackRecord(5, 5, 2.0));
+        JitterAwareStreamGrouping grouping = prepared(targets, stats);
+
+        for (int i = 0; i < 5; i++) {
+            assertEquals(Collections.singletonList(11), 
grouping.chooseTasks(SOURCE_TASK, VALUES));
+        }
+    }
+
+    @Test
+    public void chooseTasks_prefersReportedOverUnreported() {
+        // Only 11 has reported; with two targets it is always in the pair and 
beats the unreported 10.
+        List<Integer> targets = Arrays.asList(10, 11);
+        ChildEwmaStats stats = new ChildEwmaStats(true);
+        stats.update(SOURCE_TASK, 11, new EwmaFeedbackRecord(7.0, 7.0, 7.0));
+        JitterAwareStreamGrouping grouping = prepared(targets, stats);
+
+        for (int i = 0; i < 5; i++) {
+            assertEquals(Collections.singletonList(11), 
grouping.chooseTasks(SOURCE_TASK, VALUES));
+        }
+    }
+
+    @Test
+    public void chooseTasks_tiedJitter_delegatesToFallback() {
+        // Equal jitter on both targets => every pair ties => the load-aware 
fallback decides and spreads.
+        List<Integer> targets = Arrays.asList(10, 11);
+        ChildEwmaStats stats = new ChildEwmaStats(true);
+        stats.update(SOURCE_TASK, 10, new EwmaFeedbackRecord(5.0, 5.0, 5.0));
+        stats.update(SOURCE_TASK, 11, new EwmaFeedbackRecord(5.0, 5.0, 5.0));
+        JitterAwareStreamGrouping grouping = prepared(targets, stats);
+        assertDelegatedSpread(grouping, targets);
+    }
+
+    @Test
+    public void chooseTasks_isPerSourceTask() {
+        // Two targets, source 1 only: child 10 is best; the grouping must not 
consult another source's stats.
+        List<Integer> targets = Arrays.asList(10, 11);
+        ChildEwmaStats stats = new ChildEwmaStats(true);
+        stats.update(1, 10, new EwmaFeedbackRecord(1, 1, 1.0));
+        stats.update(1, 11, new EwmaFeedbackRecord(1, 1, 9.0));
+        JitterAwareStreamGrouping grouping = prepared(targets, stats);
+
+        for (int i = 0; i < 5; i++) {
+            assertEquals(Collections.singletonList(10), 
grouping.chooseTasks(1, VALUES));
+        }
+    }
+
+    @Test
+    public void chooseTasks_p2cAvoidsHerd() {
+        // Three strictly-ordered targets, real randomness: the best (10) 
takes the plurality but NOT 100%
+        // (herd avoided), the worst (12) never wins a pair, and 11 takes the 
remainder.
+        List<Integer> targets = Arrays.asList(10, 11, 12);
+        ChildEwmaStats stats = new ChildEwmaStats(true);
+        stats.update(SOURCE_TASK, 10, new EwmaFeedbackRecord(5, 5, 2.0)); // 
best
+        stats.update(SOURCE_TASK, 11, new EwmaFeedbackRecord(5, 5, 5.0));
+        stats.update(SOURCE_TASK, 12, new EwmaFeedbackRecord(5, 5, 8.0)); // 
worst
+        JitterAwareStreamGrouping grouping = prepared(targets, stats);
+
+        Map<Integer, Integer> hits = new HashMap<>();
+        int total = 3000;
+        for (int i = 0; i < total; i++) {
+            List<Integer> chosen = grouping.chooseTasks(SOURCE_TASK, VALUES);
+            assertEquals(1, chosen.size());
+            hits.merge(chosen.get(0), 1, Integer::sum);
+        }
+        int best = hits.getOrDefault(10, 0);
+        int mid = hits.getOrDefault(11, 0);
+        int worst = hits.getOrDefault(12, 0);
+        assertTrue(best > 0 && best < total, "best target should take a share 
but not the whole herd: " + best);
+        assertTrue(best > mid, "best target should outweigh the middle one: " 
+ best + " vs " + mid);
+        assertTrue(mid > 0, "middle target should still receive traffic: " + 
mid);
+        // The worst target is never the lower-jitter of any sampled pair, and 
there are no ties to delegate.
+        assertEquals(0, worst, "worst target should never win a P2C 
comparison");
+    }
+
+    /** Over a full sweep, every result is a single valid target and every 
target is used (fallback spread). */
+    private void assertDelegatedSpread(JitterAwareStreamGrouping grouping, 
List<Integer> targets) {
+        Map<Integer, Integer> hits = new HashMap<>();
+        for (int i = 0; i < targets.size() * 1000; i++) {
+            List<Integer> chosen = grouping.chooseTasks(SOURCE_TASK, VALUES);
+            assertEquals(1, chosen.size());
+            assertTrue(targets.contains(chosen.get(0)));
+            hits.merge(chosen.get(0), 1, Integer::sum);
+        }
+        for (Integer target : targets) {
+            assertTrue(hits.getOrDefault(target, 0) > 0, "target " + target + 
" should receive some traffic");
+        }
+    }
+}
diff --git a/storm-client/test/jvm/org/apache/storm/utils/ConfigUtilsTest.java 
b/storm-client/test/jvm/org/apache/storm/utils/ConfigUtilsTest.java
index 7bbf3941c..7a5bd14f5 100644
--- a/storm-client/test/jvm/org/apache/storm/utils/ConfigUtilsTest.java
+++ b/storm-client/test/jvm/org/apache/storm/utils/ConfigUtilsTest.java
@@ -23,8 +23,10 @@ import org.apache.storm.Config;
 import org.junit.jupiter.api.Test;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 
 public class ConfigUtilsTest {
 
@@ -157,4 +159,40 @@ public class ConfigUtilsTest {
         conf.put(Config.STORM_HDFS_LOGIN_PRINCIPAL, principal);
         assertEquals(Config.getHdfsPrincipal(conf), principal);
     }
+
+    @Test
+    public void upstreamFeedbackEnable_defaultsFalseWhenAbsent() {
+        assertFalse(ConfigUtils.upstreamFeedbackEnable(new HashMap<>()));
+    }
+
+    @Test
+    public void upstreamFeedbackEnable_readsConfiguredValue() {
+        assertTrue(ConfigUtils.upstreamFeedbackEnable(
+            mockMap(Config.TOPOLOGY_UPSTREAM_FEEDBACK_ENABLE, true)));
+        assertFalse(ConfigUtils.upstreamFeedbackEnable(
+            mockMap(Config.TOPOLOGY_UPSTREAM_FEEDBACK_ENABLE, false)));
+    }
+    
+    @Test
+    public void upstreamFeedbackFreqSecs_defaultsToTenWhenAbsent() {
+        assertEquals(10, ConfigUtils.upstreamFeedbackFreqSecs(new 
HashMap<>()));
+    }
+
+    @Test
+    public void upstreamFeedbackFreqSecs_returnsConfiguredPositiveValue() {
+        assertEquals(5, ConfigUtils.upstreamFeedbackFreqSecs(
+            mockMap(Config.TOPOLOGY_UPSTREAM_FEEDBACK_FREQ_SECS, 5)));
+    }
+
+    @Test
+    public void upstreamFeedbackFreqSecs_rejectsZero() {
+        assertThrows(IllegalArgumentException.class, () -> 
ConfigUtils.upstreamFeedbackFreqSecs(
+            mockMap(Config.TOPOLOGY_UPSTREAM_FEEDBACK_FREQ_SECS, 0)));
+    }
+
+    @Test
+    public void upstreamFeedbackFreqSecs_rejectsNegative() {
+        assertThrows(IllegalArgumentException.class, () -> 
ConfigUtils.upstreamFeedbackFreqSecs(
+            mockMap(Config.TOPOLOGY_UPSTREAM_FEEDBACK_FREQ_SECS, -1)));
+    }
 }

Reply via email to