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

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


The following commit(s) were added to refs/heads/master by this push:
     new f6b4c708ae9 minor: Add logs and metrics observability to the segment 
upgrade path that fires while using concurrent locking (#19651)
f6b4c708ae9 is described below

commit f6b4c708ae9d9d70ed4270e982640e360678d85c
Author: Lucas Capistrant <[email protected]>
AuthorDate: Sun Jul 12 15:10:39 2026 -0500

    minor: Add logs and metrics observability to the segment upgrade path that 
fires while using concurrent locking (#19651)
    
    * Add logs and metrics observability to the segment upgrade path for 
concurrent append and replace ingestion
    
    mets checkpoint
    
    doc and test
    
    tidy up
    
    self review
    
    * Apply suggestions from code review
    
    applying the pure text changes. will commit responses to review in another 
commit
    
    Co-authored-by: Kashif Faraz <[email protected]>
    
    * upgrade metrics names
    
    * review responses
    
    * fixup statsd emitter json
    
    * dropwizard json def update
    
    * detailed task action logging
    
    * more review comments
    
    * coverage
    
    ---------
    
    Co-authored-by: Kashif Faraz <[email protected]>
---
 docs/operations/metrics.md                         |   6 +
 .../main/resources/defaultMetricDimensions.json    |  54 ++++++
 .../src/main/resources/defaultMetrics.json         |  36 ++++
 .../src/main/resources/defaultMetrics.json         |   6 +
 .../main/resources/defaultMetricDimensions.json    |   7 +
 .../indexing/common/SegmentUpgradeMetrics.java     |  64 ++++++++
 .../actions/SegmentTransactionalReplaceAction.java |  81 ++++++++-
 .../druid/indexing/common/task/IndexTaskUtils.java |  18 ++
 .../overlord/supervisor/SupervisorManager.java     |  13 +-
 .../SeekableStreamIndexTaskRunner.java             |  61 ++++++-
 .../supervisor/SeekableStreamSupervisor.java       |  84 +++++++++-
 .../SegmentTransactionalReplaceActionTest.java     | 181 +++++++++++++++++++++
 .../ConcurrentReplaceAndStreamingAppendTest.java   |   3 +-
 .../overlord/supervisor/SupervisorManagerTest.java |   8 +-
 .../SeekableStreamSupervisorStateTest.java         |  80 +++++++++
 .../java/org/apache/druid/query/DruidMetrics.java  |   1 +
 .../realtime/appenderator/StreamAppenderator.java  |  49 +++++-
 .../appenderator/StreamAppenderatorTest.java       |  54 ++++++
 18 files changed, 781 insertions(+), 25 deletions(-)

diff --git a/docs/operations/metrics.md b/docs/operations/metrics.md
index f4757760b71..81f8f21b917 100644
--- a/docs/operations/metrics.md
+++ b/docs/operations/metrics.md
@@ -317,6 +317,12 @@ batch ingestion emit the following metrics. These metrics 
are deltas for each em
 |`ingest/notices/time`|Milliseconds taken to process a notice by the 
supervisor.|`supervisorId`, `dataSource`, `tags`| < 1s |
 |`ingest/pause/time`|Milliseconds spent by a task in a paused state without 
ingesting.|`dataSource`, `taskId`, `tags`| < 10 seconds|
 |`ingest/handoff/time`|Total number of milliseconds taken to handoff a set of 
segments.|`dataSource`, `taskId`, `taskType`, `groupId`, `tags`|Depends on the 
coordinator cycle time.|
+|`ingest/realtime/segmentUpgrade/persisted`|Number of pending segments that 
were upgraded in the metadata store while publishing segments to an interval. 
Emitted by the Overlord only when a REPLACE task (e.g., compaction task with 
`useConcurrentLocks` set to `true`) commits segments to an interval already 
containing pending segments allocated to an APPEND task (e.g. streaming task 
with `useConcurrentLocks` set to `true`).|`dataSource`, `taskId`, `taskType`, 
`groupId`, `interval`, `versio [...]
+|`ingest/realtime/segmentUpgrade/notified`|Number of notifications the 
supervisor sent to running tasks, emitted once per task so it scales with the 
replica count. Reconcile against the per-task outcomes: `notified` should equal 
`announced` + `skipped` + `sendFailed` over the same period and `dataSource`, 
since every notified task either announces, skips, or fails to receive the 
request. A shortfall indicates a notification was silently 
dropped.|`supervisorId`, `dataSource`, `stream`, `t [...]
+|`ingest/realtime/segmentUpgrade/unmatched`|Number of upgraded pending 
segments the supervisor could not route to any running task. These are not 
announced under the new version until handoff, so the corresponding data may be 
briefly missing from queries.|`supervisorId`, `dataSource`, `stream`, 
`interval`, `version`, `tags`|0. A non-zero value indicates a lost upgrade 
announcement.|
+|`ingest/realtime/segmentUpgrade/sendFailed`|Number of upgrade requests that 
matched a running task but failed to reach it over the wire after 
retries.|`supervisorId`, `dataSource`, `stream`, `taskId`, `interval`, 
`version`, `tags`|0|
+|`ingest/realtime/segmentUpgrade/announced`|Number of upgraded segments a task 
announced under the new version. Emitted once per task, so it scales with the 
replica count. Reconcile against `notified` (also per task), not 
`ingest/realtime/segmentUpgrade/persisted`, which is per segment rather than 
per replica.|`dataSource`, `taskId`, `taskType`, `groupId`, `interval`, 
`version`, `tags`|Greater than 0 while concurrent replace occurs.|
+|`ingest/realtime/segmentUpgrade/skipped`|Number of upgrade requests a task 
received but did not announce. The `reason` dimension is one of `unknown base` 
(the request reached the wrong task), `base sink already dropped` (the base 
sink is no longer present), or `dropping base sink` (the base sink is handing 
off, which is benign and covered by the durable publish path).|`dataSource`, 
`taskId`, `taskType`, `groupId`, `interval`, `version`, `tags`, `reason`|0, 
excluding `reason=dropping bas [...]
 |`task/autoScaler/requiredCount`|Count of required tasks based on the 
calculations of the auto scaler.|`supervisorId`, `dataSource`, `stream`, 
`scalingSkipReason`|Depends on auto scaler config.|
 |`task/autoScaler/scaleActionTime`|Time taken in milliseconds to complete the 
scale action.|`supervisorId`, `dataSource`, `stream`, `tags`|Depends on auto 
scaler config.|
 |`task/autoScaler/costBased/optimalTaskCount`|Optimal task count computed by 
the cost-based auto scaler.|`supervisorId`, `dataSource`, `stream`|Depends on 
auto scaler config.|
diff --git 
a/extensions-contrib/dropwizard-emitter/src/main/resources/defaultMetricDimensions.json
 
b/extensions-contrib/dropwizard-emitter/src/main/resources/defaultMetricDimensions.json
index 3a681d25cd9..0c83023448e 100644
--- 
a/extensions-contrib/dropwizard-emitter/src/main/resources/defaultMetricDimensions.json
+++ 
b/extensions-contrib/dropwizard-emitter/src/main/resources/defaultMetricDimensions.json
@@ -214,6 +214,60 @@
     "type": "timer",
     "timeUnit": "NANOSECONDS"
   },
+  "ingest/realtime/segmentUpgrade/persisted": {
+    "dimensions": [
+      "dataSource",
+      "interval",
+      "version"
+    ],
+    "type": "counter"
+  },
+  "ingest/realtime/segmentUpgrade/notified": {
+    "dimensions": [
+      "dataSource",
+      "stream",
+      "taskId",
+      "interval",
+      "version"
+    ],
+    "type": "counter"
+  },
+  "ingest/realtime/segmentUpgrade/unmatched": {
+    "dimensions": [
+      "dataSource",
+      "stream",
+      "interval",
+      "version"
+    ],
+    "type": "counter"
+  },
+  "ingest/realtime/segmentUpgrade/sendFailed": {
+    "dimensions": [
+      "dataSource",
+      "stream",
+      "taskId",
+      "interval",
+      "version"
+    ],
+    "type": "counter"
+  },
+  "ingest/realtime/segmentUpgrade/announced": {
+    "dimensions": [
+      "dataSource",
+      "interval",
+      "version"
+    ],
+    "type": "counter"
+  },
+  "ingest/realtime/segmentUpgrade/skipped": {
+    "dimensions": [
+      "dataSource",
+      "reason",
+      "interval",
+      "version"
+    ],
+    "type": "counter"
+  },
   "task/run/time": {
     "dimensions": [
       "dataSource",
diff --git 
a/extensions-contrib/opentsdb-emitter/src/main/resources/defaultMetrics.json 
b/extensions-contrib/opentsdb-emitter/src/main/resources/defaultMetrics.json
index f38f115348f..0a1b237b5f0 100644
--- a/extensions-contrib/opentsdb-emitter/src/main/resources/defaultMetrics.json
+++ b/extensions-contrib/opentsdb-emitter/src/main/resources/defaultMetrics.json
@@ -105,6 +105,42 @@
   "ingest/events/messageGap": [
     "dataSource"
   ],
+  "ingest/realtime/segmentUpgrade/persisted": [
+    "dataSource",
+    "interval",
+    "version"
+  ],
+  "ingest/realtime/segmentUpgrade/notified": [
+    "dataSource",
+    "stream",
+    "taskId",
+    "interval",
+    "version"
+  ],
+  "ingest/realtime/segmentUpgrade/unmatched": [
+    "dataSource",
+    "stream",
+    "interval",
+    "version"
+  ],
+  "ingest/realtime/segmentUpgrade/sendFailed": [
+    "dataSource",
+    "stream",
+    "taskId",
+    "interval",
+    "version"
+  ],
+  "ingest/realtime/segmentUpgrade/announced": [
+    "dataSource",
+    "interval",
+    "version"
+  ],
+  "ingest/realtime/segmentUpgrade/skipped": [
+    "dataSource",
+    "reason",
+    "interval",
+    "version"
+  ],
   "ingest/kafka/lag": [
     "dataSource",
     "stream"
diff --git 
a/extensions-contrib/prometheus-emitter/src/main/resources/defaultMetrics.json 
b/extensions-contrib/prometheus-emitter/src/main/resources/defaultMetrics.json
index 52d72cf19bc..cd7b609bb58 100644
--- 
a/extensions-contrib/prometheus-emitter/src/main/resources/defaultMetrics.json
+++ 
b/extensions-contrib/prometheus-emitter/src/main/resources/defaultMetrics.json
@@ -134,6 +134,12 @@
   "ingest/notices/time" : { "dimensions" : ["dataSource"], "type" : "timer", 
"conversionFactor": 1000.0, "help": "Seconds taken to process a notice by the 
supervisor." },
   "ingest/pause/time" : { "dimensions" : ["dataSource"], "type" : "timer", 
"conversionFactor": 1000.0, "help": "Seconds spent by a task in a paused state 
without ingesting." },
   "ingest/handoff/time" : { "dimensions" : ["dataSource"], "type" : "timer", 
"conversionFactor": 1000.0, "help": "Total number of seconds taken to handoff a 
set of segments." },
+  "ingest/realtime/segmentUpgrade/persisted" : { "dimensions" : 
["dataSource"], "type" : "count", "help": "Number of pending segments upgraded 
by a concurrent replace." },
+  "ingest/realtime/segmentUpgrade/notified" : { "dimensions" : ["dataSource", 
"stream"], "type" : "count", "help": "Number of notifications the supervisor 
sent to running tasks, one per task." },
+  "ingest/realtime/segmentUpgrade/unmatched" : { "dimensions" : ["dataSource", 
"stream"], "type" : "count", "help": "Number of upgraded pending segments that 
matched no running task." },
+  "ingest/realtime/segmentUpgrade/sendFailed" : { "dimensions" : 
["dataSource", "stream"], "type" : "count", "help": "Number of upgrade requests 
that failed to reach a task." },
+  "ingest/realtime/segmentUpgrade/announced" : { "dimensions" : 
["dataSource"], "type" : "count", "help": "Number of upgraded segments a task 
announced under the new version." },
+  "ingest/realtime/segmentUpgrade/skipped" : { "dimensions" : ["dataSource", 
"reason"], "type" : "count", "help": "Number of upgrade requests a task 
received but did not announce." },
   "task/autoScaler/requiredCount" : { "dimensions" : ["dataSource"], "type" : 
"count", "help": "Count of required tasks based on the calculations of lagBased 
auto scaler." },
 
   "task/run/time" : { "dimensions" : ["dataSource", "taskType"], "type" : 
"timer", "conversionFactor": 1000.0, "help": "Seconds taken to run a task."},
diff --git 
a/extensions-contrib/statsd-emitter/src/main/resources/defaultMetricDimensions.json
 
b/extensions-contrib/statsd-emitter/src/main/resources/defaultMetricDimensions.json
index 24a0129362e..226ec036ec7 100644
--- 
a/extensions-contrib/statsd-emitter/src/main/resources/defaultMetricDimensions.json
+++ 
b/extensions-contrib/statsd-emitter/src/main/resources/defaultMetricDimensions.json
@@ -57,6 +57,13 @@
   "ingest/segments/count" : { "dimensions" : ["dataSource"], "type" : "count" 
},
   "ingest/rows/published": { "dimensions" : ["dataSource"], "type" : "count" },
 
+  "ingest/realtime/segmentUpgrade/persisted" : { "dimensions" : ["dataSource", 
"interval", "version"], "type" : "count" },
+  "ingest/realtime/segmentUpgrade/notified" : { "dimensions" : ["dataSource", 
"stream", "taskId", "interval", "version"], "type" : "count" },
+  "ingest/realtime/segmentUpgrade/unmatched" : { "dimensions" : ["dataSource", 
"stream", "interval", "version"], "type" : "count" },
+  "ingest/realtime/segmentUpgrade/sendFailed" : { "dimensions" : 
["dataSource", "stream", "taskId", "interval", "version"], "type" : "count" },
+  "ingest/realtime/segmentUpgrade/announced" : { "dimensions" : ["dataSource", 
"interval", "version"], "type" : "count" },
+  "ingest/realtime/segmentUpgrade/skipped" : { "dimensions" : ["dataSource", 
"reason", "interval", "version"], "type" : "count" },
+
   "ingest/kafka/lag" : { "dimensions" : ["dataSource", "stream"], "type" : 
"gauge" },
   "ingest/kafka/maxLag" : { "dimensions" : ["dataSource", "stream"], "type" : 
"gauge" },
   "ingest/kafka/avgLag" : { "dimensions" : ["dataSource", "stream"], "type" : 
"gauge" },
diff --git 
a/indexing-service/src/main/java/org/apache/druid/indexing/common/SegmentUpgradeMetrics.java
 
b/indexing-service/src/main/java/org/apache/druid/indexing/common/SegmentUpgradeMetrics.java
new file mode 100644
index 00000000000..1b64fb74740
--- /dev/null
+++ 
b/indexing-service/src/main/java/org/apache/druid/indexing/common/SegmentUpgradeMetrics.java
@@ -0,0 +1,64 @@
+/*
+ * 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.druid.indexing.common;
+
+/**
+ * Metric names and dimension values for the re-announcement of pending 
segments upgraded by a concurrent REPLACE.
+ * <ul>
+ *   <li>the task action emits {@link #PERSISTED} (how many upgrades a commit 
persisted),</li>
+ *   <li>the supervisor emits {@link #NOTIFIED}, {@link #UNMATCHED} and {@link 
#SEND_FAILED} as it fans requests out,</li>
+ *   <li>the streaming task emits {@link #ANNOUNCED} and {@link #SKIPPED} 
(with a {@code reason}) as it applies them.</li>
+ * </ul>
+ * Two reconciliations bound the visibility gap:
+ * <ul>
+ *   <li>per segment, {@link #UNMATCHED} counts upgrades that reached no 
running task (delayed until handoff);</li>
+ *   <li>per task, {@link #NOTIFIED} should equal {@link #ANNOUNCED} + {@link 
#SKIPPED} + {@link #SEND_FAILED},
+ *       since every notified task either announces, skips, or fails to 
receive the request. A shortfall means a
+ *       notification was silently dropped.</li>
+ * </ul>
+ */
+public class SegmentUpgradeMetrics
+{
+  /** Number of upgraded pending segments a REPLACE commit persisted and 
handed to the supervisor. Task-action dims. */
+  public static final String PERSISTED = 
"ingest/realtime/segmentUpgrade/persisted";
+
+  /** A notification was sent to a running task (once per task). Supervisor 
dims plus {@code taskId}. */
+  public static final String NOTIFIED = 
"ingest/realtime/segmentUpgrade/notified";
+
+  /** A record matched no running task and will not be re-announced until 
handoff. Supervisor dims. */
+  public static final String UNMATCHED = 
"ingest/realtime/segmentUpgrade/unmatched";
+
+  /** An upgrade request failed to reach a task over the wire. Supervisor dims 
plus {@code taskId}. */
+  public static final String SEND_FAILED = 
"ingest/realtime/segmentUpgrade/sendFailed";
+
+  /** A task announced an upgraded segment under the new version. Task dims. */
+  public static final String ANNOUNCED = 
"ingest/realtime/segmentUpgrade/announced";
+
+  /**
+   * A task received an upgrade request but did not announce it. The {@code 
reason} dimension carries
+   * {@link 
org.apache.druid.segment.realtime.appenderator.StreamAppenderator.PendingSegmentUpgradeResult#getReason()}.
+   * Task dims.
+   */
+  public static final String SKIPPED = 
"ingest/realtime/segmentUpgrade/skipped";
+
+  private SegmentUpgradeMetrics()
+  {
+  }
+}
diff --git 
a/indexing-service/src/main/java/org/apache/druid/indexing/common/actions/SegmentTransactionalReplaceAction.java
 
b/indexing-service/src/main/java/org/apache/druid/indexing/common/actions/SegmentTransactionalReplaceAction.java
index 6546ed80d9d..d1dc2909faa 100644
--- 
a/indexing-service/src/main/java/org/apache/druid/indexing/common/actions/SegmentTransactionalReplaceAction.java
+++ 
b/indexing-service/src/main/java/org/apache/druid/indexing/common/actions/SegmentTransactionalReplaceAction.java
@@ -22,14 +22,17 @@ package org.apache.druid.indexing.common.actions;
 import com.fasterxml.jackson.annotation.JsonCreator;
 import com.fasterxml.jackson.annotation.JsonProperty;
 import com.fasterxml.jackson.core.type.TypeReference;
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Optional;
 import com.google.common.collect.ImmutableSet;
+import org.apache.druid.indexing.common.SegmentUpgradeMetrics;
 import org.apache.druid.indexing.common.task.IndexTaskUtils;
 import org.apache.druid.indexing.common.task.Task;
 import org.apache.druid.indexing.overlord.CriticalAction;
 import org.apache.druid.indexing.overlord.SegmentPublishResult;
 import org.apache.druid.indexing.overlord.supervisor.SupervisorManager;
 import org.apache.druid.java.util.common.logger.Logger;
+import org.apache.druid.java.util.emitter.service.ServiceMetricEvent;
 import org.apache.druid.metadata.PendingSegmentRecord;
 import org.apache.druid.metadata.ReplaceTaskLock;
 import org.apache.druid.segment.SegmentSchemaMapping;
@@ -37,7 +40,10 @@ import org.apache.druid.segment.SegmentUtils;
 import org.apache.druid.timeline.DataSegment;
 
 import javax.annotation.Nullable;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Map;
+import java.util.OptionalInt;
 import java.util.Set;
 import java.util.stream.Collectors;
 
@@ -63,6 +69,13 @@ public class SegmentTransactionalReplaceAction implements 
TaskAction<SegmentPubl
 {
   private static final Logger log = new 
Logger(SegmentTransactionalReplaceAction.class);
 
+  /**
+   * Upper bound on the number of per-segment entries included in the INFO 
summary of upgraded pending segments. A broad
+   * REPLACE (e.g. compaction over a wide interval of sparse, 
finely-partitioned append data) can upgrade thousands of
+   * pending segments, so the full mapping is reserved for DEBUG.
+   */
+  private static final int NOTIFIED_LOG_SAMPLE_SIZE = 10;
+
   /**
    * Set of segments to be inserted into metadata storage
    */
@@ -163,26 +176,82 @@ public class SegmentTransactionalReplaceAction implements 
TaskAction<SegmentPubl
   /**
    * Registers upgraded pending segments on the active supervisor, if any
    */
-  private void registerUpgradedPendingSegmentsOnSupervisor(
+  @VisibleForTesting
+  void registerUpgradedPendingSegmentsOnSupervisor(
       Task task,
       TaskActionToolbox toolbox,
       List<PendingSegmentRecord> upgradedPendingSegments
   )
   {
+    // Emit one persisted event per upgraded segment (rather than a single 
aggregate) regardless of whether a supervisor
+    // exists to receive them, so the total can be compared against the count 
actually announced by tasks and so each
+    // event carries the segment's interval and version.
+    for (PendingSegmentRecord upgradedPendingSegment : 
upgradedPendingSegments) {
+      final ServiceMetricEvent.Builder metricBuilder = new 
ServiceMetricEvent.Builder();
+      IndexTaskUtils.setTaskDimensions(metricBuilder, task);
+      IndexTaskUtils.setPendingSegmentDimensions(metricBuilder, 
upgradedPendingSegment);
+      
toolbox.getEmitter().emit(metricBuilder.setMetric(SegmentUpgradeMetrics.PERSISTED,
 1));
+    }
+
     final SupervisorManager supervisorManager = toolbox.getSupervisorManager();
     final Optional<String> activeSupervisorIdWithAppendLock =
         
supervisorManager.getActiveSupervisorIdForDatasourceWithAppendLock(task.getDataSource());
 
     if (!activeSupervisorIdWithAppendLock.isPresent()) {
+      log.info(
+          "Could not find any active concurrent streaming supervisor for 
datasource[%s]."
+          + " Ignoring registry of [%d] pending segment(s) upgraded by 
task[%s]. These will become queryable only"
+          + " after handoff.",
+          task.getDataSource(),
+          upgradedPendingSegments.size(),
+          task.getId()
+      );
       return;
     }
 
-    upgradedPendingSegments.forEach(
-        upgradedPendingSegment -> 
supervisorManager.registerUpgradedPendingSegmentOnSupervisor(
-            activeSupervisorIdWithAppendLock.get(),
-            upgradedPendingSegment
-        )
+    // Register each upgraded pending segment on the supervisor and summarize 
the batch. The per-segment mapping is
+    // unbounded (a broad REPLACE can upgrade thousands of segments), so INFO 
carries aggregate counts plus a bounded
+    // sample and the full mapping is materialized only when DEBUG is enabled.
+    final boolean debugEnabled = log.isDebugEnabled();
+    final Map<String, Integer> notifiedTasksSample = new LinkedHashMap<>();
+    final Map<String, Integer> notifiedTasksBySegment = debugEnabled ? new 
LinkedHashMap<>() : null;
+    int registeredSegments = 0;
+    int totalNotifiedTasks = 0;
+    for (PendingSegmentRecord upgradedPendingSegment : 
upgradedPendingSegments) {
+      final OptionalInt notified = supervisorManager
+          
.registerUpgradedPendingSegmentOnSupervisor(activeSupervisorIdWithAppendLock.get(),
 upgradedPendingSegment);
+      if (notified.isEmpty()) {
+        continue;
+      }
+      final int notifiedCount = notified.getAsInt();
+      registeredSegments++;
+      totalNotifiedTasks += notifiedCount;
+      if (notifiedTasksSample.size() < NOTIFIED_LOG_SAMPLE_SIZE) {
+        notifiedTasksSample.put(upgradedPendingSegment.getId().toString(), 
notifiedCount);
+      }
+      if (debugEnabled) {
+        notifiedTasksBySegment.put(upgradedPendingSegment.getId().toString(), 
notifiedCount);
+      }
+    }
+
+    log.info(
+        "Registered [%d] upgraded pending segment(s) created by task[%s] on 
supervisor[%s], notifying [%d] running"
+        + " task(s) in total. Tasks notified per segment%s: %s",
+        registeredSegments,
+        task.getId(),
+        activeSupervisorIdWithAppendLock.get(),
+        totalNotifiedTasks,
+        registeredSegments > NOTIFIED_LOG_SAMPLE_SIZE ? " (first " + 
NOTIFIED_LOG_SAMPLE_SIZE + ", enable debug for all)" : "",
+        notifiedTasksSample
     );
+    if (debugEnabled && registeredSegments > NOTIFIED_LOG_SAMPLE_SIZE) {
+      log.debug(
+          "Tasks notified per segment created by task[%s] on supervisor[%s]: 
%s",
+          task.getId(),
+          activeSupervisorIdWithAppendLock.get(),
+          notifiedTasksBySegment
+      );
+    }
   }
 
   @Override
diff --git 
a/indexing-service/src/main/java/org/apache/druid/indexing/common/task/IndexTaskUtils.java
 
b/indexing-service/src/main/java/org/apache/druid/indexing/common/task/IndexTaskUtils.java
index 29692e1aa98..8f719c2cc39 100644
--- 
a/indexing-service/src/main/java/org/apache/druid/indexing/common/task/IndexTaskUtils.java
+++ 
b/indexing-service/src/main/java/org/apache/druid/indexing/common/task/IndexTaskUtils.java
@@ -25,7 +25,9 @@ import 
org.apache.druid.indexing.overlord.SegmentPublishResult;
 import org.apache.druid.java.util.common.DateTimes;
 import org.apache.druid.java.util.emitter.service.SegmentMetadataEvent;
 import org.apache.druid.java.util.emitter.service.ServiceMetricEvent;
+import org.apache.druid.metadata.PendingSegmentRecord;
 import org.apache.druid.query.DruidMetrics;
+import org.apache.druid.segment.realtime.appenderator.SegmentIdWithShardSpec;
 import org.apache.druid.timeline.DataSegment;
 
 import java.util.Collection;
@@ -119,4 +121,20 @@ public class IndexTaskUtils
         .mapToLong(Integer::longValue)
         .sum();
   }
+
+  /**
+   * Adds the upgraded pending segment's {@code interval} and {@code version} 
to a metric builder so that every
+   * segment-upgrade metric can be sliced by the specific segment being 
re-announced. Mirrors
+   * {@code IndexTaskUtils.setSegmentDimensions}, which serves the same 
purpose for {@code DataSegment}s.
+   */
+  public static ServiceMetricEvent.Builder setPendingSegmentDimensions(
+      ServiceMetricEvent.Builder metricBuilder,
+      PendingSegmentRecord pendingSegmentRecord
+  )
+  {
+    final SegmentIdWithShardSpec id = pendingSegmentRecord.getId();
+    return metricBuilder
+        .setDimension(DruidMetrics.INTERVAL, id.getInterval().toString())
+        .setDimension(DruidMetrics.VERSION, id.getVersion());
+  }
 }
diff --git 
a/indexing-service/src/main/java/org/apache/druid/indexing/overlord/supervisor/SupervisorManager.java
 
b/indexing-service/src/main/java/org/apache/druid/indexing/overlord/supervisor/SupervisorManager.java
index fa7d96634ae..929f34bf89e 100644
--- 
a/indexing-service/src/main/java/org/apache/druid/indexing/overlord/supervisor/SupervisorManager.java
+++ 
b/indexing-service/src/main/java/org/apache/druid/indexing/overlord/supervisor/SupervisorManager.java
@@ -60,6 +60,7 @@ import java.util.Collection;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.OptionalInt;
 import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.Future;
@@ -511,8 +512,11 @@ public class SupervisorManager implements 
SupervisorStatsProvider
    * Registers a new version of the given pending segment on a supervisor. This
    * allows the supervisor to include the pending segment in queries fired 
against
    * that segment version.
+   *
+   * @return the number of tasks notified if the segment was registered on a 
seekable stream supervisor, or
+   * {@link OptionalInt#empty()} if no such supervisor was found or the 
registration failed
    */
-  public boolean registerUpgradedPendingSegmentOnSupervisor(
+  public OptionalInt registerUpgradedPendingSegmentOnSupervisor(
       String supervisorId,
       PendingSegmentRecord upgradedPendingSegment
   )
@@ -529,12 +533,11 @@ public class SupervisorManager implements 
SupervisorStatsProvider
       Pair<Supervisor, SupervisorSpec> supervisor = 
supervisors.get(supervisorId);
       Preconditions.checkNotNull(supervisor, "supervisor could not be found");
       if (!(supervisor.lhs instanceof SeekableStreamSupervisor)) {
-        return false;
+        return OptionalInt.empty();
       }
 
       SeekableStreamSupervisor<?, ?, ?> seekableStreamSupervisor = 
(SeekableStreamSupervisor<?, ?, ?>) supervisor.lhs;
-      
seekableStreamSupervisor.registerNewVersionOfPendingSegment(upgradedPendingSegment);
-      return true;
+      return 
OptionalInt.of(seekableStreamSupervisor.registerNewVersionOfPendingSegment(upgradedPendingSegment));
     }
     catch (Exception e) {
       log.error(
@@ -545,7 +548,7 @@ public class SupervisorManager implements 
SupervisorStatsProvider
           supervisorId
       );
     }
-    return false;
+    return OptionalInt.empty();
   }
 
   /**
diff --git 
a/indexing-service/src/main/java/org/apache/druid/indexing/seekablestream/SeekableStreamIndexTaskRunner.java
 
b/indexing-service/src/main/java/org/apache/druid/indexing/seekablestream/SeekableStreamIndexTaskRunner.java
index 2390792fd4d..d2054550fd6 100644
--- 
a/indexing-service/src/main/java/org/apache/druid/indexing/seekablestream/SeekableStreamIndexTaskRunner.java
+++ 
b/indexing-service/src/main/java/org/apache/druid/indexing/seekablestream/SeekableStreamIndexTaskRunner.java
@@ -58,6 +58,7 @@ import 
org.apache.druid.indexer.report.IngestionStatsAndErrorsTaskReport;
 import org.apache.druid.indexer.report.TaskContextReport;
 import org.apache.druid.indexer.report.TaskReport;
 import org.apache.druid.indexing.common.LockGranularity;
+import org.apache.druid.indexing.common.SegmentUpgradeMetrics;
 import org.apache.druid.indexing.common.TaskLock;
 import org.apache.druid.indexing.common.TaskLockType;
 import org.apache.druid.indexing.common.TaskToolbox;
@@ -80,6 +81,7 @@ import org.apache.druid.java.util.common.StringUtils;
 import org.apache.druid.java.util.common.concurrent.Execs;
 import org.apache.druid.java.util.emitter.EmittingLogger;
 import org.apache.druid.metadata.PendingSegmentRecord;
+import org.apache.druid.query.DruidMetrics;
 import org.apache.druid.segment.incremental.InputRowFilterResult;
 import org.apache.druid.segment.incremental.ParseExceptionHandler;
 import org.apache.druid.segment.incremental.ParseExceptionReport;
@@ -1846,7 +1848,9 @@ public abstract class 
SeekableStreamIndexTaskRunner<PartitionIdType, SequenceOff
   {
     authorizationCheck(req);
     try {
-      ((StreamAppenderator) 
appenderator).registerUpgradedPendingSegment(upgradedPendingSegment);
+      final StreamAppenderator.PendingSegmentUpgradeResult outcome =
+          ((StreamAppenderator) 
appenderator).registerUpgradedPendingSegment(upgradedPendingSegment);
+      recordUpgradeResult(upgradedPendingSegment, outcome);
       return Response.ok().build();
     }
     catch (DruidException e) {
@@ -1865,6 +1869,61 @@ public abstract class 
SeekableStreamIndexTaskRunner<PartitionIdType, SequenceOff
     }
   }
 
+  /**
+   * Logs and emits a metric for the result of a pending-segment upgrade 
request, keyed off the {@code result}
+   * returned by {@link StreamAppenderator#registerUpgradedPendingSegment}.
+   */
+  private void recordUpgradeResult(
+      PendingSegmentRecord upgradedPendingSegment,
+      StreamAppenderator.PendingSegmentUpgradeResult result
+  )
+  {
+    final SegmentIdWithShardSpec upgradedId = upgradedPendingSegment.getId();
+    final String upgradedFromSegmentId = 
upgradedPendingSegment.getUpgradedFromSegmentId();
+
+    if (result == StreamAppenderator.PendingSegmentUpgradeResult.ANNOUNCED) {
+      toolbox.getEmitter().emit(
+          IndexTaskUtils.setPendingSegmentDimensions(task.getMetricBuilder(), 
upgradedPendingSegment)
+                        .setMetric(SegmentUpgradeMetrics.ANNOUNCED, 1)
+      );
+      return;
+    }
+    // Log at the level appropriate to the result; the reason string itself is 
carried by the enum.
+    switch (result) {
+      case SKIPPED_UNKNOWN_BASE:
+        // The request targeted the wrong task: this task never held a base 
sink matching upgradedFromSegmentId.
+        log.warn(
+            "Not announcing upgraded pending segment[%s] on task[%s] because 
it has no base sink matching"
+            + " upgradedFromSegmentId[%s]; the upgrade request likely targeted 
the wrong task.",
+            upgradedId, task.getId(), upgradedFromSegmentId
+        );
+        break;
+      case SKIPPED_NO_SINK:
+        // Unexpected: the base sink is gone even though this task once held 
it.
+        log.info(
+            "Not announcing upgraded pending segment[%s] (upgradedFrom[%s]) on 
task[%s] because the base sink is no"
+            + " longer present.",
+            upgradedId, upgradedFromSegmentId, task.getId()
+        );
+        break;
+      case SKIPPED_DROPPING:
+        // Expected during handoff: the base sink is being dropped and the 
durable path re-announces at the new version.
+        log.debug(
+            "Not announcing upgraded pending segment[%s] (upgradedFrom[%s]) on 
task[%s] because the base sink is being"
+            + " dropped.",
+            upgradedId, upgradedFromSegmentId, task.getId()
+        );
+        break;
+      default:
+        return;
+    }
+    toolbox.getEmitter().emit(
+        IndexTaskUtils.setPendingSegmentDimensions(task.getMetricBuilder(), 
upgradedPendingSegment)
+                      .setDimension(DruidMetrics.REASON, result.getReason())
+                      .setMetric(SegmentUpgradeMetrics.SKIPPED, 1)
+    );
+  }
+
   public Map<String, Object> doGetRowStats()
   {
     Map<String, Object> returnMap = new HashMap<>();
diff --git 
a/indexing-service/src/main/java/org/apache/druid/indexing/seekablestream/supervisor/SeekableStreamSupervisor.java
 
b/indexing-service/src/main/java/org/apache/druid/indexing/seekablestream/supervisor/SeekableStreamSupervisor.java
index 74329c68e1d..ecc4ef3d00b 100644
--- 
a/indexing-service/src/main/java/org/apache/druid/indexing/seekablestream/supervisor/SeekableStreamSupervisor.java
+++ 
b/indexing-service/src/main/java/org/apache/druid/indexing/seekablestream/supervisor/SeekableStreamSupervisor.java
@@ -32,6 +32,7 @@ import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.common.util.concurrent.AsyncFunction;
+import com.google.common.util.concurrent.FutureCallback;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningExecutorService;
@@ -48,7 +49,9 @@ import org.apache.druid.error.InvalidInput;
 import org.apache.druid.indexer.TaskLocation;
 import org.apache.druid.indexer.TaskState;
 import org.apache.druid.indexer.TaskStatus;
+import org.apache.druid.indexing.common.SegmentUpgradeMetrics;
 import org.apache.druid.indexing.common.TaskInfoProvider;
+import org.apache.druid.indexing.common.task.IndexTaskUtils;
 import org.apache.druid.indexing.common.task.Task;
 import org.apache.druid.indexing.overlord.DataSourceMetadata;
 import org.apache.druid.indexing.overlord.IndexerMetadataStorageCoordinator;
@@ -1415,26 +1418,97 @@ public abstract class 
SeekableStreamSupervisor<PartitionIdType, SequenceOffsetTy
     addNotice(new ResetOffsetsNotice(resetDataSourceMetadata));
   }
 
-  public void registerNewVersionOfPendingSegment(
+  /**
+   * Notifies every running task in the matching task group(s) of an upgraded 
pending segment.
+   *
+   * @return the number of tasks notified
+   */
+  public int registerNewVersionOfPendingSegment(
       PendingSegmentRecord pendingSegmentRecord
   )
   {
+    final String taskAllocatorId = pendingSegmentRecord.getTaskAllocatorId();
+    int notifiedTasks = 0;
+
     for (TaskGroup taskGroup : activelyReadingTaskGroups.values()) {
-      if 
(taskGroup.baseSequenceName.equals(pendingSegmentRecord.getTaskAllocatorId())) {
+      if (taskGroup.baseSequenceName.equals(taskAllocatorId)) {
         for (String taskId : taskGroup.taskIds()) {
-          taskClient.registerNewVersionOfPendingSegmentAsync(taskId, 
pendingSegmentRecord);
+          notifyTaskOfUpgradedPendingSegment(taskId, pendingSegmentRecord);
+          notifiedTasks++;
         }
       }
     }
     for (List<TaskGroup> taskGroupList : pendingCompletionTaskGroups.values()) 
{
       for (TaskGroup taskGroup : taskGroupList) {
-        if 
(taskGroup.baseSequenceName.equals(pendingSegmentRecord.getTaskAllocatorId())) {
+        if (taskGroup.baseSequenceName.equals(taskAllocatorId)) {
           for (String taskId : taskGroup.taskIds()) {
-            taskClient.registerNewVersionOfPendingSegmentAsync(taskId, 
pendingSegmentRecord);
+            notifyTaskOfUpgradedPendingSegment(taskId, pendingSegmentRecord);
+            notifiedTasks++;
           }
         }
       }
     }
+
+    if (notifiedTasks == 0) {
+      // No running task matched: the segment will not be re-announced until 
handoff. This is a potential silent-loss
+      // window where data will not be queryable until handoff.
+      log.warn(
+          "Could not find any task matching taskAllocatorId[%s] in 
supervisor[%s] for upgraded pending segment[%s]"
+          + " (upgradedFrom[%s]); it will not be re-announced until handoff.",
+          taskAllocatorId,
+          supervisorId,
+          pendingSegmentRecord.getId(),
+          pendingSegmentRecord.getUpgradedFromSegmentId()
+      );
+      emitter.emit(
+          IndexTaskUtils.setPendingSegmentDimensions(getMetricBuilder(), 
pendingSegmentRecord)
+                        .setMetric(SegmentUpgradeMetrics.UNMATCHED, 1)
+      );
+    }
+    return notifiedTasks;
+  }
+
+  /**
+   * Sends an upgraded pending segment to a single task, emitting {@link 
SegmentUpgradeMetrics#NOTIFIED} for the
+   * notification attempt and {@link SegmentUpgradeMetrics#SEND_FAILED} if the 
request fails to reach the task. Both
+   * are per-task (keyed by {@code taskId}), so {@code notified} can be 
reconciled against the per-task
+   * {@code announced}, {@code skipped} and {@code sendFailed} outcomes.
+   */
+  private void notifyTaskOfUpgradedPendingSegment(String taskId, 
PendingSegmentRecord pendingSegmentRecord)
+  {
+    emitter.emit(
+        IndexTaskUtils.setPendingSegmentDimensions(getMetricBuilder(), 
pendingSegmentRecord)
+                      .setDimension(DruidMetrics.TASK_ID, taskId)
+                      .setMetric(SegmentUpgradeMetrics.NOTIFIED, 1)
+    );
+    Futures.addCallback(
+        taskClient.registerNewVersionOfPendingSegmentAsync(taskId, 
pendingSegmentRecord),
+        new FutureCallback<>()
+        {
+          @Override
+          public void onSuccess(Boolean result)
+          {
+            // The request reached the task; what the task does with it 
(announce or skip) is reported by the task's
+            // own announced/skipped metrics, so there is nothing to record 
here.
+          }
+
+          @Override
+          public void onFailure(Throwable t)
+          {
+            log.warn(
+                t,
+                "Failed to register upgraded pending segment[%s] on task[%s] 
of supervisor[%s].",
+                pendingSegmentRecord.getId(), taskId, supervisorId
+            );
+            emitter.emit(
+                IndexTaskUtils.setPendingSegmentDimensions(getMetricBuilder(), 
pendingSegmentRecord)
+                              .setDimension(DruidMetrics.TASK_ID, taskId)
+                              .setMetric(SegmentUpgradeMetrics.SEND_FAILED, 1)
+            );
+          }
+        },
+        MoreExecutors.directExecutor()
+    );
   }
 
   public ReentrantLock getRecordSupplierLock()
diff --git 
a/indexing-service/src/test/java/org/apache/druid/indexing/common/actions/SegmentTransactionalReplaceActionTest.java
 
b/indexing-service/src/test/java/org/apache/druid/indexing/common/actions/SegmentTransactionalReplaceActionTest.java
new file mode 100644
index 00000000000..069763d9be2
--- /dev/null
+++ 
b/indexing-service/src/test/java/org/apache/druid/indexing/common/actions/SegmentTransactionalReplaceActionTest.java
@@ -0,0 +1,181 @@
+/*
+ * 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.druid.indexing.common.actions;
+
+import com.google.common.base.Optional;
+import com.google.common.collect.ImmutableSet;
+import org.apache.druid.indexing.common.SegmentUpgradeMetrics;
+import org.apache.druid.indexing.common.task.NoopTask;
+import org.apache.druid.indexing.common.task.Task;
+import org.apache.druid.indexing.overlord.supervisor.SupervisorManager;
+import org.apache.druid.java.util.common.Intervals;
+import org.apache.druid.java.util.metrics.StubServiceEmitter;
+import org.apache.druid.metadata.PendingSegmentRecord;
+import org.apache.druid.segment.realtime.appenderator.SegmentIdWithShardSpec;
+import org.apache.druid.timeline.partition.NumberedShardSpec;
+import org.apache.logging.log4j.Level;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.core.config.Configurator;
+import org.easymock.EasyMock;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.OptionalInt;
+
+public class SegmentTransactionalReplaceActionTest
+{
+  private static final String DATA_SOURCE = "wiki";
+  private static final String SUPERVISOR_ID = "sup1";
+
+  // Larger than SegmentTransactionalReplaceAction.NOTIFIED_LOG_SAMPLE_SIZE 
(10) so the bounded/truncated log path runs.
+  private static final int MORE_THAN_SAMPLE_SIZE = 12;
+
+  private final SegmentTransactionalReplaceAction action =
+      SegmentTransactionalReplaceAction.create(ImmutableSet.of(), null);
+
+  private StubServiceEmitter emitter;
+  private SupervisorManager supervisorManager;
+  private TaskActionToolbox toolbox;
+  private Task task;
+
+  @Before
+  public void setUp()
+  {
+    emitter = new StubServiceEmitter("test", "localhost");
+    supervisorManager = EasyMock.createMock(SupervisorManager.class);
+    toolbox = EasyMock.createMock(TaskActionToolbox.class);
+    task = NoopTask.forDatasource(DATA_SOURCE);
+
+    EasyMock.expect(toolbox.getEmitter()).andReturn(emitter).anyTimes();
+    
EasyMock.expect(toolbox.getSupervisorManager()).andReturn(supervisorManager).anyTimes();
+  }
+
+  @Test
+  public void testNoActiveSupervisorStillEmitsPersistedPerSegment()
+  {
+    
EasyMock.expect(supervisorManager.getActiveSupervisorIdForDatasourceWithAppendLock(DATA_SOURCE))
+            .andReturn(Optional.absent());
+    // registerUpgradedPendingSegmentOnSupervisor must not be called when 
there is no active supervisor.
+    EasyMock.replay(toolbox, supervisorManager);
+
+    action.registerUpgradedPendingSegmentsOnSupervisor(task, toolbox, 
records(2));
+
+    Assert.assertEquals(2, 
emitter.getMetricEventCount(SegmentUpgradeMetrics.PERSISTED));
+    EasyMock.verify(supervisorManager);
+  }
+
+  @Test
+  public void testActiveSupervisorRegistersEachSegment()
+  {
+    
EasyMock.expect(supervisorManager.getActiveSupervisorIdForDatasourceWithAppendLock(DATA_SOURCE))
+            .andReturn(Optional.of(SUPERVISOR_ID));
+    EasyMock.expect(
+        
supervisorManager.registerUpgradedPendingSegmentOnSupervisor(EasyMock.eq(SUPERVISOR_ID),
 EasyMock.anyObject())
+    ).andReturn(OptionalInt.of(2)).times(3);
+    EasyMock.replay(toolbox, supervisorManager);
+
+    action.registerUpgradedPendingSegmentsOnSupervisor(task, toolbox, 
records(3));
+
+    Assert.assertEquals(3, 
emitter.getMetricEventCount(SegmentUpgradeMetrics.PERSISTED));
+    EasyMock.verify(supervisorManager);
+  }
+
+  @Test
+  public void testBatchLargerThanSampleSizeRegistersEverySegment()
+  {
+    
EasyMock.expect(supervisorManager.getActiveSupervisorIdForDatasourceWithAppendLock(DATA_SOURCE))
+            .andReturn(Optional.of(SUPERVISOR_ID));
+    EasyMock.expect(
+        
supervisorManager.registerUpgradedPendingSegmentOnSupervisor(EasyMock.eq(SUPERVISOR_ID),
 EasyMock.anyObject())
+    ).andReturn(OptionalInt.of(1)).times(MORE_THAN_SAMPLE_SIZE);
+    EasyMock.replay(toolbox, supervisorManager);
+
+    action.registerUpgradedPendingSegmentsOnSupervisor(task, toolbox, 
records(MORE_THAN_SAMPLE_SIZE));
+
+    Assert.assertEquals(MORE_THAN_SAMPLE_SIZE, 
emitter.getMetricEventCount(SegmentUpgradeMetrics.PERSISTED));
+    EasyMock.verify(supervisorManager);
+  }
+
+  @Test
+  public void testSegmentsNotRegisteredOnSupervisorAreStillPersisted()
+  {
+    // An empty result models a non-seekable-stream supervisor or a 
registration failure; the segment is still counted
+    // as persisted but contributes no notified-task summary.
+    
EasyMock.expect(supervisorManager.getActiveSupervisorIdForDatasourceWithAppendLock(DATA_SOURCE))
+            .andReturn(Optional.of(SUPERVISOR_ID));
+    EasyMock.expect(
+        
supervisorManager.registerUpgradedPendingSegmentOnSupervisor(EasyMock.eq(SUPERVISOR_ID),
 EasyMock.anyObject())
+    ).andReturn(OptionalInt.empty()).times(2);
+    EasyMock.replay(toolbox, supervisorManager);
+
+    action.registerUpgradedPendingSegmentsOnSupervisor(task, toolbox, 
records(2));
+
+    Assert.assertEquals(2, 
emitter.getMetricEventCount(SegmentUpgradeMetrics.PERSISTED));
+    EasyMock.verify(supervisorManager);
+  }
+
+  @Test
+  public void testFullPerSegmentDetailLoggedAtDebug()
+  {
+    final Level originalLevel = 
LogManager.getLogger(SegmentTransactionalReplaceAction.class).getLevel();
+    Configurator.setLevel(SegmentTransactionalReplaceAction.class.getName(), 
Level.DEBUG);
+    try {
+      
EasyMock.expect(supervisorManager.getActiveSupervisorIdForDatasourceWithAppendLock(DATA_SOURCE))
+              .andReturn(Optional.of(SUPERVISOR_ID));
+      EasyMock.expect(
+          
supervisorManager.registerUpgradedPendingSegmentOnSupervisor(EasyMock.eq(SUPERVISOR_ID),
 EasyMock.anyObject())
+      ).andReturn(OptionalInt.of(1)).times(MORE_THAN_SAMPLE_SIZE);
+      EasyMock.replay(toolbox, supervisorManager);
+
+      action.registerUpgradedPendingSegmentsOnSupervisor(task, toolbox, 
records(MORE_THAN_SAMPLE_SIZE));
+
+      Assert.assertEquals(MORE_THAN_SAMPLE_SIZE, 
emitter.getMetricEventCount(SegmentUpgradeMetrics.PERSISTED));
+      EasyMock.verify(supervisorManager);
+    }
+    finally {
+      Configurator.setLevel(SegmentTransactionalReplaceAction.class.getName(), 
originalLevel);
+    }
+  }
+
+  private static List<PendingSegmentRecord> records(int count)
+  {
+    final List<PendingSegmentRecord> records = new ArrayList<>(count);
+    for (int i = 0; i < count; i++) {
+      records.add(
+          PendingSegmentRecord.create(
+              new SegmentIdWithShardSpec(
+                  DATA_SOURCE,
+                  Intervals.of("2024/2025"),
+                  "v1",
+                  new NumberedShardSpec(i, 0)
+              ),
+              "sequenceName",
+              "prevId" + i,
+              "upgradedFrom" + i,
+              "taskAllocatorId"
+          )
+      );
+    }
+    return records;
+  }
+}
diff --git 
a/indexing-service/src/test/java/org/apache/druid/indexing/common/task/concurrent/ConcurrentReplaceAndStreamingAppendTest.java
 
b/indexing-service/src/test/java/org/apache/druid/indexing/common/task/concurrent/ConcurrentReplaceAndStreamingAppendTest.java
index 868d8c15568..35382746ae1 100644
--- 
a/indexing-service/src/test/java/org/apache/druid/indexing/common/task/concurrent/ConcurrentReplaceAndStreamingAppendTest.java
+++ 
b/indexing-service/src/test/java/org/apache/druid/indexing/common/task/concurrent/ConcurrentReplaceAndStreamingAppendTest.java
@@ -84,6 +84,7 @@ import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.OptionalInt;
 import java.util.Set;
 import java.util.concurrent.atomic.AtomicInteger;
 
@@ -170,7 +171,7 @@ public class ConcurrentReplaceAndStreamingAppendTest 
extends IngestionTestBase
     
EasyMock.expect(supervisorManager.registerUpgradedPendingSegmentOnSupervisor(
         EasyMock.capture(supervisorId),
         EasyMock.capture(pendingSegment)
-    )).andReturn(true).anyTimes();
+    )).andReturn(OptionalInt.of(1)).anyTimes();
     replaceTask = createAndStartTask();
     EasyMock.replay(supervisorManager);
     versionToIntervalToLoadSpecs = new HashMap<>();
diff --git 
a/indexing-service/src/test/java/org/apache/druid/indexing/overlord/supervisor/SupervisorManagerTest.java
 
b/indexing-service/src/test/java/org/apache/druid/indexing/overlord/supervisor/SupervisorManagerTest.java
index 36e87b604d3..7bbedd87fb8 100644
--- 
a/indexing-service/src/test/java/org/apache/druid/indexing/overlord/supervisor/SupervisorManagerTest.java
+++ 
b/indexing-service/src/test/java/org/apache/druid/indexing/overlord/supervisor/SupervisorManagerTest.java
@@ -75,6 +75,7 @@ import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import java.util.OptionalInt;
 import java.util.concurrent.ConcurrentHashMap;
 
 @RunWith(EasyMockRunner.class)
@@ -894,8 +895,7 @@ public class SupervisorManagerTest extends EasyMockSupport
 
     SeekableStreamSupervisorSpec streamingSpec = 
EasyMock.createNiceMock(SeekableStreamSupervisorSpec.class);
     SeekableStreamSupervisor streamSupervisor = 
EasyMock.createNiceMock(SeekableStreamSupervisor.class);
-    streamSupervisor.registerNewVersionOfPendingSegment(EasyMock.anyObject());
-    EasyMock.expectLastCall().once();
+    
EasyMock.expect(streamSupervisor.registerNewVersionOfPendingSegment(EasyMock.anyObject())).andReturn(1).once();
     EasyMock.expect(streamingSpec.getId()).andReturn("sss").anyTimes();
     EasyMock.expect(streamingSpec.isSuspended()).andReturn(false).anyTimes();
     
EasyMock.expect(streamingSpec.getDataSources()).andReturn(ImmutableList.of("DS")).anyTimes();
@@ -923,10 +923,10 @@ public class SupervisorManagerTest extends EasyMockSupport
     manager.start();
 
     manager.createOrUpdateAndStartSupervisor(noopSpec);
-    
Assert.assertFalse(manager.registerUpgradedPendingSegmentOnSupervisor("noop", 
pendingSegment));
+    
Assert.assertFalse(manager.registerUpgradedPendingSegmentOnSupervisor("noop", 
pendingSegment).isPresent());
 
     manager.createOrUpdateAndStartSupervisor(streamingSpec);
-    
Assert.assertTrue(manager.registerUpgradedPendingSegmentOnSupervisor("sss", 
pendingSegment));
+    Assert.assertEquals(OptionalInt.of(1), 
manager.registerUpgradedPendingSegmentOnSupervisor("sss", pendingSegment));
 
     verifyAll();
   }
diff --git 
a/indexing-service/src/test/java/org/apache/druid/indexing/seekablestream/supervisor/SeekableStreamSupervisorStateTest.java
 
b/indexing-service/src/test/java/org/apache/druid/indexing/seekablestream/supervisor/SeekableStreamSupervisorStateTest.java
index 9e45920ad71..6c12c8b3885 100644
--- 
a/indexing-service/src/test/java/org/apache/druid/indexing/seekablestream/supervisor/SeekableStreamSupervisorStateTest.java
+++ 
b/indexing-service/src/test/java/org/apache/druid/indexing/seekablestream/supervisor/SeekableStreamSupervisorStateTest.java
@@ -39,6 +39,7 @@ import org.apache.druid.error.DruidExceptionMatcher;
 import org.apache.druid.indexer.TaskLocation;
 import org.apache.druid.indexer.TaskStatus;
 import org.apache.druid.indexer.granularity.UniformGranularitySpec;
+import org.apache.druid.indexing.common.SegmentUpgradeMetrics;
 import org.apache.druid.indexing.common.TestUtils;
 import org.apache.druid.indexing.common.task.Task;
 import org.apache.druid.indexing.overlord.DataSourceMetadata;
@@ -2116,6 +2117,85 @@ public class SeekableStreamSupervisorStateTest extends 
EasyMockSupport
 
     Assert.assertEquals(pendingSegmentRecord0, captured0.getValue());
     Assert.assertEquals(pendingSegmentRecord1, captured1.getValue());
+
+    // Both records matched a running task, so each emits a notified metric 
and none is unmatched.
+    Assert.assertEquals(2, 
emitter.getMetricEventCount(SegmentUpgradeMetrics.NOTIFIED));
+    Assert.assertEquals(0, 
emitter.getMetricEventCount(SegmentUpgradeMetrics.UNMATCHED));
+    verifyAll();
+  }
+
+  @Test
+  public void testRegisterNewVersionOfPendingSegmentUnmatchedEmitsMetric()
+  {
+    EasyMock.expect(spec.isSuspended()).andReturn(false);
+    replayAll();
+
+    final TestSeekableStreamSupervisor supervisor = new 
TestSeekableStreamSupervisor();
+    supervisor.getIoConfig().setTaskCount(3);
+    supervisor.start();
+
+    supervisor.addTaskGroupToActivelyReadingTaskGroup(
+        supervisor.getTaskGroupIdForPartition("0"),
+        ImmutableMap.of("0", "5"),
+        null,
+        null,
+        Set.of("task0"),
+        Set.of(),
+        null
+    );
+
+    // A taskAllocatorId that matches no running task group
+    final PendingSegmentRecord record = PendingSegmentRecord.create(
+        new SegmentIdWithShardSpec("DS", Intervals.of("2024/2025"), "2024", 
new NumberedShardSpec(1, 0)),
+        "no-such-allocator",
+        "prevId",
+        "someAppendedSegment",
+        "no-such-allocator"
+    );
+
+    supervisor.registerNewVersionOfPendingSegment(record);
+
+    Assert.assertEquals(1, 
emitter.getMetricEventCount(SegmentUpgradeMetrics.UNMATCHED));
+    Assert.assertEquals(0, 
emitter.getMetricEventCount(SegmentUpgradeMetrics.NOTIFIED));
+    verifyAll();
+  }
+
+  @Test
+  public void testRegisterNewVersionOfPendingSegmentSendFailureEmitsMetric()
+  {
+    EasyMock.expect(spec.isSuspended()).andReturn(false);
+    EasyMock.expect(
+        
indexTaskClient.registerNewVersionOfPendingSegmentAsync(EasyMock.eq("task0"), 
EasyMock.anyObject())
+    ).andReturn(Futures.immediateFailedFuture(new RuntimeException("boom")));
+    replayAll();
+
+    final TestSeekableStreamSupervisor supervisor = new 
TestSeekableStreamSupervisor();
+    supervisor.getIoConfig().setTaskCount(3);
+    supervisor.start();
+
+    final SeekableStreamSupervisor.TaskGroup taskGroup0 = 
supervisor.addTaskGroupToActivelyReadingTaskGroup(
+        supervisor.getTaskGroupIdForPartition("0"),
+        ImmutableMap.of("0", "5"),
+        null,
+        null,
+        Set.of("task0"),
+        Set.of(),
+        null
+    );
+
+    final PendingSegmentRecord record = PendingSegmentRecord.create(
+        new SegmentIdWithShardSpec("DS", Intervals.of("2024/2025"), "2024", 
new NumberedShardSpec(1, 0)),
+        taskGroup0.getBaseSequenceName(),
+        "prevId",
+        "someAppendedSegment",
+        taskGroup0.getBaseSequenceName()
+    );
+
+    supervisor.registerNewVersionOfPendingSegment(record);
+
+    // The record matched task0 (so notified fires) but delivery failed over 
the wire (so sendFailed fires).
+    Assert.assertEquals(1, 
emitter.getMetricEventCount(SegmentUpgradeMetrics.NOTIFIED));
+    Assert.assertEquals(1, 
emitter.getMetricEventCount(SegmentUpgradeMetrics.SEND_FAILED));
     verifyAll();
   }
 
diff --git a/processing/src/main/java/org/apache/druid/query/DruidMetrics.java 
b/processing/src/main/java/org/apache/druid/query/DruidMetrics.java
index 532553aef6e..713ee017c00 100644
--- a/processing/src/main/java/org/apache/druid/query/DruidMetrics.java
+++ b/processing/src/main/java/org/apache/druid/query/DruidMetrics.java
@@ -58,6 +58,7 @@ public class DruidMetrics
   public static final String PARTITION = "partition";
   public static final String SUPERVISOR_ID = "supervisorId";
   public static final String REASON = "reason";
+  public static final String VERSION = "version";
 
   public static final String TAGS = "tags";
 
diff --git 
a/server/src/main/java/org/apache/druid/segment/realtime/appenderator/StreamAppenderator.java
 
b/server/src/main/java/org/apache/druid/segment/realtime/appenderator/StreamAppenderator.java
index 94e7a56679b..4b1d2facc40 100644
--- 
a/server/src/main/java/org/apache/druid/segment/realtime/appenderator/StreamAppenderator.java
+++ 
b/server/src/main/java/org/apache/druid/segment/realtime/appenderator/StreamAppenderator.java
@@ -1181,12 +1181,53 @@ public class StreamAppenderator implements Appenderator
     }
   }
 
-  public void registerUpgradedPendingSegment(PendingSegmentRecord 
pendingSegmentRecord) throws IOException
+  /**
+   * Result of a {@link #registerUpgradedPendingSegment} call. Each skipped 
outcome carries the human-readable
+   * {@link #getReason() reason} emitted as the {@code reason} metric 
dimension.
+   */
+  public enum PendingSegmentUpgradeResult
+  {
+    ANNOUNCED(null),
+    /** The task holds no pending segment matching upgradedFromSegmentId 
(request targeted the wrong task). */
+    SKIPPED_UNKNOWN_BASE("unknown base sink"),
+    /** The base sink is gone even though this task once held it. */
+    SKIPPED_NO_SINK("base sink already dropped"),
+    /** The base sink is being dropped (handoff in progress); the durable path 
re-announces at the new version. */
+    SKIPPED_DROPPING("dropping base sink");
+
+    @Nullable
+    private final String reason;
+
+    PendingSegmentUpgradeResult(@Nullable String reason)
+    {
+      this.reason = reason;
+    }
+
+    /**
+     * Reason a request was skipped, for the {@code reason} metric dimension; 
null for {@link #ANNOUNCED}.
+     */
+    @Nullable
+    public String getReason()
+    {
+      return reason;
+    }
+  }
+
+  public PendingSegmentUpgradeResult 
registerUpgradedPendingSegment(PendingSegmentRecord pendingSegmentRecord) 
throws IOException
   {
     SegmentIdWithShardSpec basePendingSegment = 
idToPendingSegment.get(pendingSegmentRecord.getUpgradedFromSegmentId());
     SegmentIdWithShardSpec upgradedPendingSegment = 
pendingSegmentRecord.getId();
-    if (!sinks.containsKey(basePendingSegment) || 
droppingSinks.contains(basePendingSegment)) {
-      return;
+    if (basePendingSegment == null || 
droppingSinks.contains(basePendingSegment) || 
!sinks.containsKey(basePendingSegment)) {
+      if (basePendingSegment == null) {
+        // This task never allocated a segment matching upgradedFromSegmentId, 
i.e. the request targeted the wrong task.
+        return PendingSegmentUpgradeResult.SKIPPED_UNKNOWN_BASE;
+      } else if (droppingSinks.contains(basePendingSegment)) {
+        // Expected during handoff: the base sink is being dropped.
+        return PendingSegmentUpgradeResult.SKIPPED_DROPPING;
+      } else {
+        // Unexpected: the base sink is gone even though this task once held 
it.
+        return PendingSegmentUpgradeResult.SKIPPED_NO_SINK;
+      }
     }
 
     final Sink sink = sinks.get(basePendingSegment);
@@ -1201,6 +1242,8 @@ public class StreamAppenderator implements Appenderator
     segmentAnnouncer.announceSegment(newSegment);
     
baseSegmentToUpgradedSegments.get(basePendingSegment).add(upgradedPendingSegment);
     upgradedSegmentToBaseSegment.put(upgradedPendingSegment, 
basePendingSegment);
+    log.info("Announced upgraded segment[%s] for base segment[%s] on 
task[%s]", upgradedPendingSegment, basePendingSegment, myId);
+    return PendingSegmentUpgradeResult.ANNOUNCED;
   }
 
   private DataSegment getUpgradedSegment(DataSegment baseSegment, 
SegmentIdWithShardSpec upgradedVersion)
diff --git 
a/server/src/test/java/org/apache/druid/segment/realtime/appenderator/StreamAppenderatorTest.java
 
b/server/src/test/java/org/apache/druid/segment/realtime/appenderator/StreamAppenderatorTest.java
index bc5b3c6de36..4f4a0d39046 100644
--- 
a/server/src/test/java/org/apache/druid/segment/realtime/appenderator/StreamAppenderatorTest.java
+++ 
b/server/src/test/java/org/apache/druid/segment/realtime/appenderator/StreamAppenderatorTest.java
@@ -1160,6 +1160,60 @@ public class StreamAppenderatorTest extends 
InitializedNullHandlingTest
     }
   }
 
+  @Test
+  public void 
testRegisterUpgradedPendingSegmentReturnsAnnouncedWhenBaseSinkExists() throws 
Exception
+  {
+    try (
+        final StreamAppenderatorTester tester =
+            new StreamAppenderatorTester.Builder().maxRowsInMemory(2)
+                                                  
.basePersistDirectory(temporaryFolder.newFolder())
+                                                  .build()) {
+      final StreamAppenderator appenderator = (StreamAppenderator) 
tester.getAppenderator();
+      appenderator.startJob();
+      // Create the base sink for IDENTIFIERS.get(0) so the upgrade can be 
announced against it.
+      appenderator.add(IDENTIFIERS.get(0), ir("2000", "foo", 1), 
Suppliers.ofInstance(Committers.nil()));
+
+      final StreamAppenderator.PendingSegmentUpgradeResult outcome = 
appenderator.registerUpgradedPendingSegment(
+          PendingSegmentRecord.create(
+              si("2000/2001", "B", 1),
+              si("2000/2001", "B", 1).asSegmentId().toString(),
+              IDENTIFIERS.get(0).asSegmentId().toString(),
+              IDENTIFIERS.get(0).asSegmentId().toString(),
+              StreamAppenderatorTester.DATASOURCE
+          )
+      );
+
+      
Assert.assertEquals(StreamAppenderator.PendingSegmentUpgradeResult.ANNOUNCED, 
outcome);
+    }
+  }
+
+  @Test
+  public void 
testRegisterUpgradedPendingSegmentReturnsSkippedUnknownBaseWhenBaseNotHeld() 
throws Exception
+  {
+    try (
+        final StreamAppenderatorTester tester =
+            new StreamAppenderatorTester.Builder().maxRowsInMemory(2)
+                                                  
.basePersistDirectory(temporaryFolder.newFolder())
+                                                  .build()) {
+      final StreamAppenderator appenderator = (StreamAppenderator) 
tester.getAppenderator();
+      appenderator.startJob();
+
+      // No sink has ever been created for the upgradedFromSegmentId below, so 
this task cannot announce it.
+      // This is the case where the upgrade request reached the wrong task.
+      final StreamAppenderator.PendingSegmentUpgradeResult outcome = 
appenderator.registerUpgradedPendingSegment(
+          PendingSegmentRecord.create(
+              si("2050/2051", "Z", 1),
+              si("2050/2051", "Z", 1).asSegmentId().toString(),
+              si("2050/2051", "Y", 0).asSegmentId().toString(),
+              si("2050/2051", "Y", 0).asSegmentId().toString(),
+              StreamAppenderatorTester.DATASOURCE
+          )
+      );
+
+      
Assert.assertEquals(StreamAppenderator.PendingSegmentUpgradeResult.SKIPPED_UNKNOWN_BASE,
 outcome);
+    }
+  }
+
   @Test
   public void testQueryBySegments_withSegmentVersionUpgrades() throws Exception
   {


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to