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

FrankChen021 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 3469a0fdaa4 feat: support ingesting Kafka messages from selected 
partitions (#20474)
3469a0fdaa4 is described below

commit 3469a0fdaa4cb3857e00f51fbc7d490c3305d99f
Author: Frank Chen <[email protected]>
AuthorDate: Sun Oct 11 15:03:16 2026 +0800

    feat: support ingesting Kafka messages from selected partitions (#20474)
    
    * feat: support ingesting Kafka messages from selected partitions
    
    * fix(web-console): validate Kafka partition IDs against the server's 
accepted range and string forms
    
    * address review: keep partition ID field visible, stop coercing IDs, drop 
unused constructors
    
    * address review: filter stored offsets in getOffsetsFromMetadataStorage 
and drop the extra hook
    
    * address review: report lag only for partitions a bounded supervisor tracks
---
 docs/ingestion/kafka-ingestion.md                  |  41 +++
 .../druid/indexing/kafka/KafkaIndexTask.java       |   3 +-
 .../druid/indexing/kafka/KafkaRecordSupplier.java  |  27 +-
 .../druid/indexing/kafka/KafkaSamplerSpec.java     |   3 +-
 .../kafka/supervisor/KafkaIOConfigBuilder.java     |  12 +-
 .../indexing/kafka/supervisor/KafkaSupervisor.java | 127 +++++++--
 .../kafka/supervisor/KafkaSupervisorIOConfig.java  |  34 ++-
 .../kafka/supervisor/KafkaSupervisorSpec.java      |   3 +-
 .../kafka/KafkaRecordSupplierHeaderFilterTest.java |  18 +-
 .../indexing/kafka/KafkaRecordSupplierTest.java    |  47 +++
 .../druid/indexing/kafka/KafkaSamplerSpecTest.java |  25 ++
 .../simulate/EmbeddedKafkaSupervisorTest.java      |  52 ++++
 .../supervisor/KafkaSupervisorIOConfigTest.java    | 168 ++++++++++-
 .../kafka/supervisor/KafkaSupervisorSpecTest.java  |  30 ++
 .../kafka/supervisor/KafkaSupervisorTest.java      | 314 ++++++++++++++++++++-
 .../supervisor/SeekableStreamSupervisor.java       |  28 ++
 .../druid/jackson/StrictIntegerDeserializer.java   |  59 ++++
 .../jackson/StrictIntegerDeserializerTest.java     |  89 ++++++
 .../ingestion-spec/ingestion-spec.spec.ts          |  62 ++++
 .../druid-models/ingestion-spec/ingestion-spec.tsx |  38 +++
 20 files changed, 1138 insertions(+), 42 deletions(-)

diff --git a/docs/ingestion/kafka-ingestion.md 
b/docs/ingestion/kafka-ingestion.md
index 91f1706f134..3b07df3495b 100644
--- a/docs/ingestion/kafka-ingestion.md
+++ b/docs/ingestion/kafka-ingestion.md
@@ -121,6 +121,7 @@ For configuration properties shared across all streaming 
ingestion methods, refe
 |Property|Type|Description|Required|Default|
 |--------|----|-----------|--------|-------|
 |`topic`|String|The Kafka topic to read from. Note that once this value is 
established for a supervisor, updating it is not supported. To ingest data from 
multiple topic, use `topicPattern`. |Yes if `topicPattern` isn't set.||
+|`partitionIds`|Array of integers|Optional nonempty set of nonnegative 
partition IDs for a single `topic`. Omit or set to null to read all partitions. 
Cannot be combined with `topicPattern` or `boundedStreamConfig`. See [Ingest 
selected partitions](#ingest-selected-partitions).|No|null|
 |`topicPattern`|String|Multiple Kafka topics to read from, passed as a regex 
pattern. See [Ingest from multiple topics](#ingest-from-multiple-topics) for 
more information.|Yes if `topic` isn't set.||
 |`consumerProperties`|String, Object|A map of properties to pass to the Kafka 
consumer. See [Consumer properties](#consumer-properties) for details.|Yes. At 
the minimum, you must set the `bootstrap.servers` property to establish the 
initial connection to the Kafka cluster.||
 |`pollTimeout`|Long|The length of time to wait for the Kafka consumer to poll 
records, in milliseconds.|No|100|
@@ -600,6 +601,46 @@ For configuration properties shared across all streaming 
ingestion methods, refe
 |`numPersistThreads`|Integer|The number of threads to use to create and 
persist incremental segments on the disk. Higher ingestion data throughput 
results in a larger number of incremental segments, causing significant CPU 
time to be spent on the creation of the incremental segments on the disk. For 
datasources with number of columns running into hundreds or thousands, creation 
of the incremental segments may take up significant time, in the order of 
multiple seconds. In both of these sc [...]
 |`streamingPartitionsSpec`|Object|Configures query-time segment pruning for 
streaming-ingested segments. A typed object with an optional `type` (defaults 
to `dim_value_set`), `partitionDimensions` (List of String), the dimensions 
whose observed values each segment records so the broker can skip segments that 
can't match a query filter, and an optional `maxValuesPerDimension` (Integer) 
cap on the distinct values recorded per dimension per segment. See [Streaming 
partitions spec](#streamin [...]
 
+## Ingest selected partitions
+
+Set `partitionIds` in the supervisor's `ioConfig` to consume only specific 
partitions:
+
+```json
+{
+  "type": "kafka",
+  "topic": "events",
+  "partitionIds": [0, 2, 5],
+  "consumerProperties": {"bootstrap.servers": "localhost:9092"},
+  "useEarliestOffset": true
+}
+```
+
+This fragment can be used to investigate production data in a separate 
diagnostic supervisor and datasource without ingesting the entire topic again.
+The diagnostic supervisor maintains its own offsets.
+Selected partitions are not necessarily a representative sample, and selecting 
partitions does not limit their throughput.
+Additional ingestion still consumes Kafka and Druid resources.
+
+Druid validates IDs against Kafka `partitionsFor(topic)` metadata during 
discovery, including discovery for sampling and monitoring.
+Spec parsing does not contact Kafka.
+Missing IDs produce a supervisor error and are retried; a new supervisor does 
not start with only part of its selection.
+Empty lists, negative IDs, and null elements are invalid.
+Duplicate IDs are normalized and list order does not matter.
+
+New topic partitions outside the selection are ignored.
+Omit `partitionIds` or set it to null to restore discovery of all partitions.
+Selected partitions are assigned to task groups by their position in the 
sorted selection: `[0, 3, 6]` with three tasks uses three groups.
+
+Submitting a supervisor spec that changes or removes `partitionIds` requests 
managed readers to finish and publish, then restarts the supervisor with the 
updated selection.
+Supervisors with an explicit selection also replace incompatible adopted 
readers on restart.
+Narrowing `partitionIds` does not delete previously ingested rows from 
partitions removed from the selection.
+Already publishing tasks may finish; the update does not provide an 
instantaneous cutoff.
+Retained and re-added partitions resume from saved offsets when available.
+Otherwise, the existing `useEarliestOffset` behavior applies.
+
+Scoped reset requests reject IDs outside the selection.
+If an excluded partition's saved offset has expired and automatic offset reset 
is disabled, first re-add the partition, then issue a scoped reset after the 
unavailable-offset error, or set an explicitly chosen available offset using 
`resetOffsets`.
+A full supervisor reset still clears all saved offsets, including those of 
excluded partitions.
+
 ## Deployment notes on Kafka partitions and Druid segments
 
 Druid assigns Kafka partitions to each Kafka indexing task. A task writes the 
events it consumes from Kafka into a single segment for the segment granularity 
interval until it reaches one of the following limits: `maxRowsPerSegment`, 
`maxTotalRows`, or `intermediateHandoffPeriod`. At this point, the task creates 
a new partition for this segment granularity to contain subsequent events.
diff --git 
a/extensions-core/kafka-indexing-service/src/main/java/org/apache/druid/indexing/kafka/KafkaIndexTask.java
 
b/extensions-core/kafka-indexing-service/src/main/java/org/apache/druid/indexing/kafka/KafkaIndexTask.java
index be50b9554a1..e15753216d0 100644
--- 
a/extensions-core/kafka-indexing-service/src/main/java/org/apache/druid/indexing/kafka/KafkaIndexTask.java
+++ 
b/extensions-core/kafka-indexing-service/src/main/java/org/apache/druid/indexing/kafka/KafkaIndexTask.java
@@ -122,7 +122,8 @@ public class KafkaIndexTask extends 
SeekableStreamIndexTask<KafkaTopicPartition,
               kafkaIndexTaskIOConfig.getConfigOverrides(),
               kafkaIndexTaskIOConfig.isMultiTopic(),
               this::getMetricBuilder,
-              kafkaIndexTaskIOConfig.getHeaderBasedFilterConfig()
+              kafkaIndexTaskIOConfig.getHeaderBasedFilterConfig(),
+              null
           );
 
       if (toolbox.getMonitorScheduler() != null) {
diff --git 
a/extensions-core/kafka-indexing-service/src/main/java/org/apache/druid/indexing/kafka/KafkaRecordSupplier.java
 
b/extensions-core/kafka-indexing-service/src/main/java/org/apache/druid/indexing/kafka/KafkaRecordSupplier.java
index 2c1ea896af6..c20aa81f2c7 100644
--- 
a/extensions-core/kafka-indexing-service/src/main/java/org/apache/druid/indexing/kafka/KafkaRecordSupplier.java
+++ 
b/extensions-core/kafka-indexing-service/src/main/java/org/apache/druid/indexing/kafka/KafkaRecordSupplier.java
@@ -60,6 +60,7 @@ import java.util.List;
 import java.util.Map;
 import java.util.Properties;
 import java.util.Set;
+import java.util.TreeSet;
 import java.util.concurrent.Callable;
 import java.util.function.Supplier;
 import java.util.regex.Pattern;
@@ -83,6 +84,8 @@ public class KafkaRecordSupplier implements 
RecordSupplier<KafkaTopicPartition,
   private boolean closed;
 
   private final boolean multiTopic;
+  @Nullable
+  private final Set<Integer> partitionIds;
 
   @Nullable
   private final KafkaHeaderBasedFilterEvaluator headerFilterEvaluator;
@@ -101,7 +104,7 @@ public class KafkaRecordSupplier implements 
RecordSupplier<KafkaTopicPartition,
       @Nullable Supplier<ServiceMetricEvent.Builder> metricBuilderSupplier
   )
   {
-    this(consumerProperties, sortingMapper, configOverrides, multiTopic, 
metricBuilderSupplier, null);
+    this(consumerProperties, sortingMapper, configOverrides, multiTopic, 
metricBuilderSupplier, null, null);
   }
 
   public KafkaRecordSupplier(
@@ -110,14 +113,16 @@ public class KafkaRecordSupplier implements 
RecordSupplier<KafkaTopicPartition,
       KafkaConfigOverrides configOverrides,
       boolean multiTopic,
       @Nullable Supplier<ServiceMetricEvent.Builder> metricBuilderSupplier,
-      @Nullable KafkaHeaderBasedFilterConfig headerBasedFilterConfig
+      @Nullable KafkaHeaderBasedFilterConfig headerBasedFilterConfig,
+      @Nullable Set<Integer> partitionIds
   )
   {
     this(
         getKafkaConsumer(sortingMapper, consumerProperties, configOverrides),
         multiTopic,
         metricBuilderSupplier,
-        headerBasedFilterConfig
+        headerBasedFilterConfig,
+        partitionIds
     );
   }
 
@@ -128,7 +133,7 @@ public class KafkaRecordSupplier implements 
RecordSupplier<KafkaTopicPartition,
       @Nullable Supplier<ServiceMetricEvent.Builder> metricBuilderSupplier
   )
   {
-    this(consumer, multiTopic, metricBuilderSupplier, null);
+    this(consumer, multiTopic, metricBuilderSupplier, null, null);
   }
 
   @VisibleForTesting
@@ -136,11 +141,13 @@ public class KafkaRecordSupplier implements 
RecordSupplier<KafkaTopicPartition,
       KafkaConsumer<byte[], byte[]> consumer,
       boolean multiTopic,
       @Nullable Supplier<ServiceMetricEvent.Builder> metricBuilderSupplier,
-      @Nullable KafkaHeaderBasedFilterConfig headerBasedFilterConfig
+      @Nullable KafkaHeaderBasedFilterConfig headerBasedFilterConfig,
+      @Nullable Set<Integer> partitionIds
   )
   {
     this.consumer = consumer;
     this.multiTopic = multiTopic;
+    this.partitionIds = partitionIds == null ? null : Set.copyOf(partitionIds);
     this.monitor = new KafkaConsumerMonitor(consumer, metricBuilderSupplier);
     this.headerFilterEvaluator = headerBasedFilterConfig != null
         ? new KafkaHeaderBasedFilterEvaluator(headerBasedFilterConfig) : null;
@@ -327,7 +334,17 @@ public class KafkaRecordSupplier implements 
RecordSupplier<KafkaTopicPartition,
                                      + " Check that the topic exists in Kafka 
cluster", stream);
         }
       }
+
+      if (partitionIds != null) {
+        final Set<Integer> missing = new TreeSet<>(partitionIds);
+        allPartitions.forEach(partition -> 
missing.remove(partition.partition()));
+        if (!missing.isEmpty()) {
+          throw InvalidInput.exception("Requested partition IDs [%s] do not 
exist in topic [%s]", missing, stream);
+        }
+      }
       return allPartitions.stream()
+                          // Keep all discovered partitions unless a fixed 
selection is configured.
+                          .filter(p -> partitionIds == null || 
partitionIds.contains(p.partition()))
                           .map(p -> new KafkaTopicPartition(multiTopic, 
p.topic(), p.partition()))
                           .collect(Collectors.toSet());
     });
diff --git 
a/extensions-core/kafka-indexing-service/src/main/java/org/apache/druid/indexing/kafka/KafkaSamplerSpec.java
 
b/extensions-core/kafka-indexing-service/src/main/java/org/apache/druid/indexing/kafka/KafkaSamplerSpec.java
index 26164a1df7c..751a2144a07 100644
--- 
a/extensions-core/kafka-indexing-service/src/main/java/org/apache/druid/indexing/kafka/KafkaSamplerSpec.java
+++ 
b/extensions-core/kafka-indexing-service/src/main/java/org/apache/druid/indexing/kafka/KafkaSamplerSpec.java
@@ -76,7 +76,8 @@ public class KafkaSamplerSpec extends 
SeekableStreamSamplerSpec
           kafkaSupervisorIOConfig.isMultiTopic(),
           null,
           // Apply the same header-based filter during sampling so sampled 
records match what ingestion tasks keep.
-          kafkaSupervisorIOConfig.getHeaderBasedFilterConfig()
+          kafkaSupervisorIOConfig.getHeaderBasedFilterConfig(),
+          kafkaSupervisorIOConfig.getPartitionIds()
       );
     }
     finally {
diff --git 
a/extensions-core/kafka-indexing-service/src/main/java/org/apache/druid/indexing/kafka/supervisor/KafkaIOConfigBuilder.java
 
b/extensions-core/kafka-indexing-service/src/main/java/org/apache/druid/indexing/kafka/supervisor/KafkaIOConfigBuilder.java
index 60990e3a9fd..852fa358a31 100644
--- 
a/extensions-core/kafka-indexing-service/src/main/java/org/apache/druid/indexing/kafka/supervisor/KafkaIOConfigBuilder.java
+++ 
b/extensions-core/kafka-indexing-service/src/main/java/org/apache/druid/indexing/kafka/supervisor/KafkaIOConfigBuilder.java
@@ -25,6 +25,7 @@ import 
org.apache.druid.indexing.seekablestream.extension.KafkaConfigOverrides;
 import 
org.apache.druid.indexing.seekablestream.supervisor.SupervisorIOConfigBuilder;
 
 import java.util.Map;
+import java.util.Set;
 
 /**
  * Builder for {@link KafkaSupervisorIOConfig}.
@@ -33,6 +34,7 @@ public class KafkaIOConfigBuilder extends 
SupervisorIOConfigBuilder<KafkaIOConfi
 {
   private String topic;
   private String topicPattern;
+  private Set<Integer> partitionIds;
   private Map<String, Object> consumerProperties;
   private Long pollTimeout;
   private KafkaConfigOverrides configOverrides;
@@ -45,6 +47,12 @@ public class KafkaIOConfigBuilder extends 
SupervisorIOConfigBuilder<KafkaIOConfi
     return this;
   }
 
+  public KafkaIOConfigBuilder withPartitionIds(Set<Integer> partitionIds)
+  {
+    this.partitionIds = partitionIds;
+    return this;
+  }
+
   public KafkaIOConfigBuilder withTopicPattern(String topicPattern)
   {
     this.topicPattern = topicPattern;
@@ -105,6 +113,7 @@ public class KafkaIOConfigBuilder extends 
SupervisorIOConfigBuilder<KafkaIOConfi
     copyFromBase(io);
     this.topic = io.getTopic();
     this.topicPattern = io.getTopicPattern();
+    this.partitionIds = io.getPartitionIds();
     this.consumerProperties = io.getConsumerProperties();
     this.pollTimeout = io.getPollTimeout();
     this.configOverrides = io.getConfigOverrides();
@@ -140,7 +149,8 @@ public class KafkaIOConfigBuilder extends 
SupervisorIOConfigBuilder<KafkaIOConfi
         stopTaskCount,
         emitTimeLagMetrics,
         serverPriorityToReplicas,
-        boundedStreamConfig
+        boundedStreamConfig,
+        partitionIds
     );
   }
 }
diff --git 
a/extensions-core/kafka-indexing-service/src/main/java/org/apache/druid/indexing/kafka/supervisor/KafkaSupervisor.java
 
b/extensions-core/kafka-indexing-service/src/main/java/org/apache/druid/indexing/kafka/supervisor/KafkaSupervisor.java
index 7e019266e34..ef9b87573a5 100644
--- 
a/extensions-core/kafka-indexing-service/src/main/java/org/apache/druid/indexing/kafka/supervisor/KafkaSupervisor.java
+++ 
b/extensions-core/kafka-indexing-service/src/main/java/org/apache/druid/indexing/kafka/supervisor/KafkaSupervisor.java
@@ -29,6 +29,7 @@ import org.apache.druid.common.utils.IdUtils;
 import org.apache.druid.data.input.kafka.KafkaRecordEntity;
 import org.apache.druid.data.input.kafka.KafkaTopicPartition;
 import org.apache.druid.error.DruidException;
+import org.apache.druid.error.InvalidInput;
 import org.apache.druid.indexing.common.task.Task;
 import org.apache.druid.indexing.common.task.TaskResource;
 import org.apache.druid.indexing.kafka.KafkaDataSourceMetadata;
@@ -97,6 +98,12 @@ public class KafkaSupervisor extends 
SeekableStreamSupervisor<KafkaTopicPartitio
   private static final Long END_OF_PARTITION = Long.MAX_VALUE;
 
   private final Pattern pattern;
+
+  /// Position of each selected partition ID in the sorted `partitionIds`, 
e.g. `[0, 3, 6]` gives `{0=0, 3=1, 6=2}`.
+  /// Used to spread selected partitions evenly across task groups (`rank % 
taskCount`), where
+  /// `partition % taskCount` could put them all in one group. Empty when no 
selection is configured.
+  private final Map<Integer, Integer> selectedPartitionRanks;
+
   private volatile Map<KafkaTopicPartition, Long> partitionToTimeLag;
 
   private final KafkaSupervisorSpec spec;
@@ -128,6 +135,18 @@ public class KafkaSupervisor extends 
SeekableStreamSupervisor<KafkaTopicPartitio
 
     this.spec = spec;
     this.pattern = getIoConfig().isMultiTopic() ? 
Pattern.compile(getIoConfig().getStream()) : null;
+    final Set<Integer> selected = getIoConfig().getPartitionIds();
+    if (selected == null) {
+      this.selectedPartitionRanks = Map.of();
+    } else {
+      final List<Integer> sorted = new ArrayList<>(selected);
+      Collections.sort(sorted);
+      final Map<Integer, Integer> ranks = new HashMap<>();
+      for (int i = 0; i < sorted.size(); i++) {
+        ranks.put(sorted.get(i), i);
+      }
+      this.selectedPartitionRanks = Map.copyOf(ranks);
+    }
   }
 
   @Override
@@ -140,7 +159,8 @@ public class KafkaSupervisor extends 
SeekableStreamSupervisor<KafkaTopicPartitio
         ioConfig.getConfigOverrides(),
         ioConfig.isMultiTopic(),
         null,
-        ioConfig.getHeaderBasedFilterConfig()
+        ioConfig.getHeaderBasedFilterConfig(),
+        ioConfig.getPartitionIds()
     );
   }
 
@@ -151,10 +171,55 @@ public class KafkaSupervisor extends 
SeekableStreamSupervisor<KafkaTopicPartitio
     if (partitionId.isMultiTopicPartition()) {
       return Math.abs(31 * partitionId.topic().hashCode() + 
partitionId.partition()) % taskCount;
     } else {
+      final Integer rank = selectedPartitionRanks.get(partitionId.partition());
+      if (rank != null) {
+        return rank % taskCount;
+      }
+      // An adopted task can still refer to a partition excluded by this 
supervisor.
       return partitionId.partition() % taskCount;
     }
   }
 
+  @Override
+  protected boolean isTaskPartitionSetCurrent(int taskGroupId, 
Set<KafkaTopicPartition> taskPartitions)
+  {
+    // Removing a selection is handled by the spec-update restart, which 
requests existing readers to publish.
+    // All-partition supervisors must still adopt readers that predate 
ordinary topic partition growth.
+    return selectedPartitionRanks.isEmpty() || 
taskPartitions.equals(partitionGroups.get(taskGroupId));
+  }
+
+  /**
+   * With {@code partitionIds} set, rejects resets naming a different topic or 
any partition outside the selection.
+   * A full reset (null metadata) is always allowed.
+   */
+  @Override
+  protected void validatePartitionReset(@Nullable DataSourceMetadata metadata)
+  {
+    final Set<Integer> selected = getIoConfig().getPartitionIds();
+    if (selected == null || metadata == null) {
+      return;
+    }
+
+    if (!(metadata instanceof KafkaDataSourceMetadata kafkaMetadata)) {
+      throw InvalidInput.exception("Partition reset requires Kafka metadata 
for supervisor [%s]", spec.getId());
+    }
+
+    final SeekableStreamSequenceNumbers<KafkaTopicPartition, Long> sequences = 
kafkaMetadata.getSeekableStreamSequenceNumbers();
+    if (sequences == null || 
!getIoConfig().getStream().equals(sequences.getStream())) {
+      throw InvalidInput.exception("Partition reset must specify offsets for 
topic [%s]", getIoConfig().getStream());
+    }
+
+    final Set<KafkaTopicPartition> excluded = 
sequences.getPartitionSequenceNumberMap().keySet().stream()
+                                                     .filter(p -> 
p.isMultiTopicPartition() || !selected.contains(p.partition()))
+                                                     
.collect(Collectors.toSet());
+    if (!excluded.isEmpty()) {
+      throw InvalidInput.exception(
+          "Cannot reset excluded or topic-qualified partitions [%s] for 
supervisor [%s] with partition IDs [%s]",
+          excluded, spec.getId(), selected
+      );
+    }
+  }
+
   @Override
   protected boolean checkSourceMetadataMatch(DataSourceMetadata metadata)
   {
@@ -270,6 +335,19 @@ public class KafkaSupervisor extends 
SeekableStreamSupervisor<KafkaTopicPartitio
     return taskList;
   }
 
+  /** Keeps offsets for the configured partitions only. */
+  @Override
+  protected Map<KafkaTopicPartition, Long> getHighestCurrentOffsets()
+  {
+    final Set<Integer> selected = getIoConfig().getPartitionIds();
+    if (selected == null) {
+      return super.getHighestCurrentOffsets();
+    }
+    final Map<KafkaTopicPartition, Long> offsets = new 
HashMap<>(super.getHighestCurrentOffsets());
+    offsets.keySet().removeIf(partition -> 
!selected.contains(partition.partition()));
+    return offsets;
+  }
+
   @Override
   protected Map<KafkaTopicPartition, Long> getPartitionRecordLag()
   {
@@ -443,6 +521,25 @@ public class KafkaSupervisor extends 
SeekableStreamSupervisor<KafkaTopicPartitio
     return aggregatePartitionLags(partitionRecordLag);
   }
 
+  /**
+   * Partitions to report lag for. A bounded supervisor, such as a backfill, 
only assigns the partitions in its bounded
+   * config, which can be a subset of the topic. Reading positions of any 
other partition would fail.
+   * Must be called while holding the record supplier lock.
+   */
+  private Set<KafkaTopicPartition> getPartitionsForLag()
+  {
+    if (getIoConfig().isBounded()) {
+      return new HashSet<>(partitionIds);
+    }
+    try {
+      return recordSupplier.getPartitionIds(getIoConfig().getStream());
+    }
+    catch (Exception e) {
+      log.warn("Could not fetch partitions for topic/stream [%s]", 
getIoConfig().getStream());
+      throw new StreamException(e);
+    }
+  }
+
   /**
    * This method is similar to updatePartitionLagFromStream
    * but also determines time lag. Once this method has been
@@ -454,14 +551,7 @@ public class KafkaSupervisor extends 
SeekableStreamSupervisor<KafkaTopicPartitio
 
     getRecordSupplierLock().lock();
     try {
-      Set<KafkaTopicPartition> partitionIds;
-      try {
-        partitionIds = 
recordSupplier.getPartitionIds(getIoConfig().getStream());
-      }
-      catch (Exception e) {
-        log.warn("Could not fetch partitions for topic/stream [%s]", 
getIoConfig().getStream());
-        throw new StreamException(e);
-      }
+      final Set<KafkaTopicPartition> partitionIds = getPartitionsForLag();
 
       final Set<StreamPartition<KafkaTopicPartition>> partitions = partitionIds
           .stream()
@@ -563,14 +653,7 @@ public class KafkaSupervisor extends 
SeekableStreamSupervisor<KafkaTopicPartitio
 
     getRecordSupplierLock().lock();
     try {
-      Set<KafkaTopicPartition> partitionIds;
-      try {
-        partitionIds = 
recordSupplier.getPartitionIds(getIoConfig().getStream());
-      }
-      catch (Exception e) {
-        log.warn("Could not fetch partitions for topic/stream [%s]", 
getIoConfig().getStream());
-        throw new StreamException(e);
-      }
+      final Set<KafkaTopicPartition> partitionIds = getPartitionsForLag();
 
       Set<StreamPartition<KafkaTopicPartition>> partitions = partitionIds
           .stream()
@@ -632,7 +715,8 @@ public class KafkaSupervisor extends 
SeekableStreamSupervisor<KafkaTopicPartitio
 
   /**
    * Gets the offsets as stored in the metadata store. The map returned will 
only contain
-   * offsets from topic partitions that match the current supervisor config 
stream. This
+   * offsets from topic partitions that match the current supervisor config 
stream, and from the partitions
+   * selected by {@code partitionIds} if it is set. This
    * override is needed because in the case of multi-topic, a user could have 
updated the supervisor
    * config from single topic to multi-topic, where the new multi-topic 
pattern regex matches the
    * old config single topic. Without this override, the previously stored 
metadata for the single
@@ -678,6 +762,13 @@ public class KafkaSupervisor extends 
SeekableStreamSupervisor<KafkaTopicPartitio
             partitionOffsets.put(matchingTopicPartition, value);
           }
         });
+
+        // Keep offsets for the configured partitions only. Offsets of 
excluded partitions stay in metadata storage.
+        final Set<Integer> selected = getIoConfig().getPartitionIds();
+        if (selected != null) {
+          partitionOffsets.keySet().removeIf(partition -> 
!selected.contains(partition.partition()));
+        }
+
         return partitionOffsets;
       }
     }
diff --git 
a/extensions-core/kafka-indexing-service/src/main/java/org/apache/druid/indexing/kafka/supervisor/KafkaSupervisorIOConfig.java
 
b/extensions-core/kafka-indexing-service/src/main/java/org/apache/druid/indexing/kafka/supervisor/KafkaSupervisorIOConfig.java
index bb818fb5d4b..3ad41d0a575 100644
--- 
a/extensions-core/kafka-indexing-service/src/main/java/org/apache/druid/indexing/kafka/supervisor/KafkaSupervisorIOConfig.java
+++ 
b/extensions-core/kafka-indexing-service/src/main/java/org/apache/druid/indexing/kafka/supervisor/KafkaSupervisorIOConfig.java
@@ -20,8 +20,11 @@
 package org.apache.druid.indexing.kafka.supervisor;
 
 import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonInclude;
 import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
 import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableSortedSet;
 import org.apache.druid.common.config.Configs;
 import org.apache.druid.data.input.InputFormat;
 import org.apache.druid.error.InvalidInput;
@@ -31,6 +34,7 @@ import 
org.apache.druid.indexing.seekablestream.supervisor.IdleConfig;
 import org.apache.druid.indexing.seekablestream.supervisor.LagAggregator;
 import 
org.apache.druid.indexing.seekablestream.supervisor.SeekableStreamSupervisorIOConfig;
 import 
org.apache.druid.indexing.seekablestream.supervisor.autoscaler.AutoScalerConfig;
+import org.apache.druid.jackson.StrictIntegerDeserializer;
 import org.apache.druid.java.util.common.StringUtils;
 import org.joda.time.DateTime;
 import org.joda.time.Period;
@@ -38,6 +42,7 @@ import org.joda.time.Period;
 import javax.annotation.Nullable;
 import java.util.Map;
 import java.util.Objects;
+import java.util.Set;
 
 public class KafkaSupervisorIOConfig extends SeekableStreamSupervisorIOConfig
 {
@@ -55,6 +60,8 @@ public class KafkaSupervisorIOConfig extends 
SeekableStreamSupervisorIOConfig
   private final KafkaConfigOverrides configOverrides;
   private final String topic;
   private final String topicPattern;
+  @Nullable
+  private final ImmutableSortedSet<Integer> partitionIds;
   private final boolean emitTimeLagMetrics;
   private final KafkaHeaderBasedFilterConfig headerBasedFilterConfig;
 
@@ -83,7 +90,9 @@ public class KafkaSupervisorIOConfig extends 
SeekableStreamSupervisorIOConfig
       @JsonProperty("stopTaskCount") Integer stopTaskCount,
       @Nullable @JsonProperty("emitTimeLagMetrics") Boolean emitTimeLagMetrics,
       @Nullable @JsonProperty("serverPriorityToReplicas") Map<Integer, 
Integer> serverPriorityToReplicas,
-      @Nullable @JsonProperty("boundedStreamConfig") BoundedStreamConfig 
boundedStreamConfig
+      @Nullable @JsonProperty("boundedStreamConfig") BoundedStreamConfig 
boundedStreamConfig,
+      @Nullable @JsonProperty("partitionIds")
+      @JsonDeserialize(contentUsing = StrictIntegerDeserializer.class) 
Set<Integer> partitionIds
   )
   {
     super(
@@ -117,6 +126,15 @@ public class KafkaSupervisorIOConfig extends 
SeekableStreamSupervisorIOConfig
     this.configOverrides = configOverrides;
     this.topic = topic;
     this.topicPattern = topicPattern;
+    if (partitionIds != null) {
+      if (partitionIds.isEmpty() || partitionIds.stream().anyMatch(id -> id == 
null || id < 0)) {
+        throw InvalidInput.exception("partitionIds must contain nonnegative 
partition IDs and must not be empty");
+      }
+      if (topicPattern != null || boundedStreamConfig != null) {
+        throw InvalidInput.exception("partitionIds requires a single topic and 
cannot be combined with boundedStreamConfig");
+      }
+    }
+    this.partitionIds = partitionIds == null ? null : 
ImmutableSortedSet.copyOf(partitionIds);
     this.emitTimeLagMetrics = Configs.valueOrDefault(emitTimeLagMetrics, 
false);
   }
 
@@ -174,7 +192,8 @@ public class KafkaSupervisorIOConfig extends 
SeekableStreamSupervisorIOConfig
         stopTaskCount,
         emitTimeLagMetrics,
         serverPriorityToReplicas,
-        boundedStreamConfig
+        boundedStreamConfig,
+        null
     );
   }
 
@@ -196,6 +215,14 @@ public class KafkaSupervisorIOConfig extends 
SeekableStreamSupervisorIOConfig
     return topicPattern;
   }
 
+  @Nullable
+  @JsonProperty
+  @JsonInclude(JsonInclude.Include.NON_NULL)
+  public Set<Integer> getPartitionIds()
+  {
+    return partitionIds;
+  }
+
   @JsonProperty
   public Map<String, Object> getConsumerProperties()
   {
@@ -248,6 +275,7 @@ public class KafkaSupervisorIOConfig extends 
SeekableStreamSupervisorIOConfig
     return "KafkaSupervisorIOConfig{" +
            "topic='" + getTopic() + '\'' +
            "topicPattern='" + getTopicPattern() + '\'' +
+           ", partitionIds=" + partitionIds +
            ", replicas=" + getReplicas() +
            ", taskCount=" + getTaskCount() +
            ", taskDuration=" + getTaskDuration() +
@@ -299,6 +327,7 @@ public class KafkaSupervisorIOConfig extends 
SeekableStreamSupervisorIOConfig
            && Objects.equals(configOverrides, that.configOverrides)
            && Objects.equals(topic, that.topic)
            && Objects.equals(topicPattern, that.topicPattern)
+           && Objects.equals(partitionIds, that.partitionIds)
            && Objects.equals(headerBasedFilterConfig, 
that.headerBasedFilterConfig);
   }
 
@@ -312,6 +341,7 @@ public class KafkaSupervisorIOConfig extends 
SeekableStreamSupervisorIOConfig
         configOverrides,
         topic,
         topicPattern,
+        partitionIds,
         emitTimeLagMetrics,
         headerBasedFilterConfig
     );
diff --git 
a/extensions-core/kafka-indexing-service/src/main/java/org/apache/druid/indexing/kafka/supervisor/KafkaSupervisorSpec.java
 
b/extensions-core/kafka-indexing-service/src/main/java/org/apache/druid/indexing/kafka/supervisor/KafkaSupervisorSpec.java
index b1d3721555b..e755a8f57c2 100644
--- 
a/extensions-core/kafka-indexing-service/src/main/java/org/apache/druid/indexing/kafka/supervisor/KafkaSupervisorSpec.java
+++ 
b/extensions-core/kafka-indexing-service/src/main/java/org/apache/druid/indexing/kafka/supervisor/KafkaSupervisorSpec.java
@@ -206,7 +206,8 @@ public class KafkaSupervisorSpec extends 
SeekableStreamSupervisorSpec
         ioConfig.getStopTaskCount(),
         ioConfig.isEmitTimeLagMetrics(),
         ioConfig.getServerPriorityToReplicas(),
-        boundedStreamConfig
+        boundedStreamConfig,
+        null
     );
     return new KafkaSupervisorSpec(
         backfillId,
diff --git 
a/extensions-core/kafka-indexing-service/src/test/java/org/apache/druid/indexing/kafka/KafkaRecordSupplierHeaderFilterTest.java
 
b/extensions-core/kafka-indexing-service/src/test/java/org/apache/druid/indexing/kafka/KafkaRecordSupplierHeaderFilterTest.java
index 9bd8fdbb7b5..674e64a3400 100644
--- 
a/extensions-core/kafka-indexing-service/src/test/java/org/apache/druid/indexing/kafka/KafkaRecordSupplierHeaderFilterTest.java
+++ 
b/extensions-core/kafka-indexing-service/src/test/java/org/apache/druid/indexing/kafka/KafkaRecordSupplierHeaderFilterTest.java
@@ -99,7 +99,7 @@ public class KafkaRecordSupplierHeaderFilterTest
     InDimFilter filter = new InDimFilter("environment", 
Collections.singletonList("production"), null);
     KafkaHeaderBasedFilterConfig headerFilter = new 
KafkaHeaderBasedFilterConfig(filter, null, null);
 
-    recordSupplier = new KafkaRecordSupplier(mockConsumer, false, null, 
headerFilter);
+    recordSupplier = new KafkaRecordSupplier(mockConsumer, false, null, 
headerFilter, null);
 
     ConsumerRecord<byte[], byte[]> prodRecord = createRecord("topic", 0, 100L,
         headers("environment", "production"));
@@ -141,7 +141,7 @@ public class KafkaRecordSupplierHeaderFilterTest
     InDimFilter filter = new InDimFilter("environment", 
Collections.singletonList("production"), null);
     KafkaHeaderBasedFilterConfig headerFilter = new 
KafkaHeaderBasedFilterConfig(filter, null, null);
 
-    recordSupplier = new KafkaRecordSupplier(mockConsumer, false, null, 
headerFilter);
+    recordSupplier = new KafkaRecordSupplier(mockConsumer, false, null, 
headerFilter, null);
 
     ConsumerRecord<byte[], byte[]> prodRecord = createRecord("topic", 0, 100L,
         headers("environment", "production"));
@@ -176,7 +176,7 @@ public class KafkaRecordSupplierHeaderFilterTest
     InDimFilter filter = new InDimFilter("service", 
Arrays.asList("user-service", "payment-service"), null);
     KafkaHeaderBasedFilterConfig headerFilter = new 
KafkaHeaderBasedFilterConfig(filter, null, null);
 
-    recordSupplier = new KafkaRecordSupplier(mockConsumer, false, null, 
headerFilter);
+    recordSupplier = new KafkaRecordSupplier(mockConsumer, false, null, 
headerFilter, null);
 
     ConsumerRecord<byte[], byte[]> userServiceRecord = createRecord("topic", 
0, 100L,
         headers("service", "user-service"));
@@ -218,7 +218,7 @@ public class KafkaRecordSupplierHeaderFilterTest
     InDimFilter serviceFilter = new InDimFilter("service", 
Arrays.asList("user-service", "payment-service"), null);
     KafkaHeaderBasedFilterConfig headerFilter = new 
KafkaHeaderBasedFilterConfig(serviceFilter, null, null);
 
-    recordSupplier = new KafkaRecordSupplier(mockConsumer, false, null, 
headerFilter);
+    recordSupplier = new KafkaRecordSupplier(mockConsumer, false, null, 
headerFilter, null);
 
     ConsumerRecord<byte[], byte[]> userServiceRecord = createRecord("topic", 
0, 100L,
         headers("service", "user-service"));
@@ -260,7 +260,7 @@ public class KafkaRecordSupplierHeaderFilterTest
     InDimFilter filter = new InDimFilter("environment", 
Collections.singletonList("production"), null);
     KafkaHeaderBasedFilterConfig headerFilter = new 
KafkaHeaderBasedFilterConfig(filter, null, null);
 
-    recordSupplier = new KafkaRecordSupplier(mockConsumer, false, null, 
headerFilter);
+    recordSupplier = new KafkaRecordSupplier(mockConsumer, false, null, 
headerFilter, null);
 
     // First poll
     ConsumerRecord<byte[], byte[]> prodRecord1 = createRecord("topic", 0, 100L,
@@ -305,7 +305,7 @@ public class KafkaRecordSupplierHeaderFilterTest
     InDimFilter filter = new InDimFilter("environment", 
Collections.singletonList("production"), null);
     KafkaHeaderBasedFilterConfig headerFilter = new 
KafkaHeaderBasedFilterConfig(filter, null, null);
 
-    recordSupplier = new KafkaRecordSupplier(mockConsumer, false, null, 
headerFilter);
+    recordSupplier = new KafkaRecordSupplier(mockConsumer, false, null, 
headerFilter, null);
 
     EasyMock.expect(mockConsumer.poll(EasyMock.anyObject(Duration.class)))
         .andReturn(createConsumerRecords(Collections.emptyList()));
@@ -326,7 +326,7 @@ public class KafkaRecordSupplierHeaderFilterTest
     InDimFilter filter = new InDimFilter("environment", 
Collections.singletonList("production"), null);
     KafkaHeaderBasedFilterConfig headerFilter = new 
KafkaHeaderBasedFilterConfig(filter, null, null);
 
-    recordSupplier = new KafkaRecordSupplier(mockConsumer, false, null, 
headerFilter);
+    recordSupplier = new KafkaRecordSupplier(mockConsumer, false, null, 
headerFilter, null);
 
     // All records have "staging" environment - none should pass the 
"production" filter
     ConsumerRecord<byte[], byte[]> stagingRecord1 = createRecord("topic", 0, 
100L,
@@ -367,7 +367,7 @@ public class KafkaRecordSupplierHeaderFilterTest
     InDimFilter filter = new InDimFilter("environment", 
Collections.singletonList("production"), null);
     KafkaHeaderBasedFilterConfig headerFilter = new 
KafkaHeaderBasedFilterConfig(filter, null, null);
 
-    recordSupplier = new KafkaRecordSupplier(mockConsumer, false, null, 
headerFilter);
+    recordSupplier = new KafkaRecordSupplier(mockConsumer, false, null, 
headerFilter, null);
 
     ConsumerRecord<byte[], byte[]> prodRecord = createRecord("topic", 0, 100L,
         headers("environment", "production"));
@@ -407,7 +407,7 @@ public class KafkaRecordSupplierHeaderFilterTest
     InDimFilter filter = new InDimFilter("environment", 
Collections.singletonList("production"), null);
     KafkaHeaderBasedFilterConfig headerFilter = new 
KafkaHeaderBasedFilterConfig(filter, null, null);
 
-    recordSupplier = new KafkaRecordSupplier(mockConsumer, true, null, 
headerFilter); // multiTopic = true
+    recordSupplier = new KafkaRecordSupplier(mockConsumer, true, null, 
headerFilter, null); // multiTopic = true
 
     ConsumerRecord<byte[], byte[]> topic1Record = createRecord("topic1", 0, 
100L,
         headers("environment", "production"));
diff --git 
a/extensions-core/kafka-indexing-service/src/test/java/org/apache/druid/indexing/kafka/KafkaRecordSupplierTest.java
 
b/extensions-core/kafka-indexing-service/src/test/java/org/apache/druid/indexing/kafka/KafkaRecordSupplierTest.java
index c09ee5f6c71..bd5df4a1780 100644
--- 
a/extensions-core/kafka-indexing-service/src/test/java/org/apache/druid/indexing/kafka/KafkaRecordSupplierTest.java
+++ 
b/extensions-core/kafka-indexing-service/src/test/java/org/apache/druid/indexing/kafka/KafkaRecordSupplierTest.java
@@ -39,6 +39,8 @@ import org.apache.druid.metadata.DynamicConfigProvider;
 import org.apache.druid.metadata.MapStringDynamicConfigProvider;
 import org.apache.druid.query.DruidMetrics;
 import org.apache.druid.segment.TestHelper;
+import org.apache.kafka.clients.admin.Admin;
+import org.apache.kafka.clients.admin.NewPartitions;
 import org.apache.kafka.clients.consumer.ConsumerRecord;
 import org.apache.kafka.clients.consumer.KafkaConsumer;
 import org.apache.kafka.clients.producer.KafkaProducer;
@@ -311,6 +313,44 @@ public class KafkaRecordSupplierTest
     Assertions.assertEquals(0, diff.size(), diff.toString());
   }
 
+  @Test
+  public void testGetPartitionIdsWithSelectionIgnoresUnselectedPartitions() 
throws Exception
+  {
+    // The topic has partitions 0 and 1.
+    insertData();
+    final KafkaRecordSupplier recordSupplier = new KafkaRecordSupplier(
+        KAFKA_SERVER.consumerProperties(), OBJECT_MAPPER, null, false, null, 
null, Set.of(PARTITION_1.partition()));
+
+    Assertions.assertEquals(Set.of(PARTITION_1), 
recordSupplier.getPartitionIds(TOPIC));
+
+    // A partition added later that is outside the selection is still ignored.
+    increasePartitions(TOPIC, 4);
+
+    Assertions.assertEquals(Set.of(PARTITION_1), 
recordSupplier.getPartitionIds(TOPIC));
+    recordSupplier.close();
+  }
+
+  @Test
+  public void testGetPartitionIdsFailsWhenSelectedPartitionIsMissing() throws 
Exception
+  {
+    // The topic has partitions 0 and 1 only.
+    insertData();
+    final KafkaRecordSupplier recordSupplier = new KafkaRecordSupplier(
+        KAFKA_SERVER.consumerProperties(), OBJECT_MAPPER, null, false, null, 
null, Set.of(0, 3));
+
+    final StreamException error = 
Assertions.assertThrows(StreamException.class, () -> 
recordSupplier.getPartitionIds(TOPIC));
+    Assertions.assertTrue(error.getMessage().contains("[3]"), 
error.getMessage());
+
+    // Discovery recovers once the missing partition exists.
+    increasePartitions(TOPIC, 4);
+
+    Assertions.assertEquals(
+        Set.of(PARTITION_0, new KafkaTopicPartition(false, null, 3)),
+        recordSupplier.getPartitionIds(TOPIC)
+    );
+    recordSupplier.close();
+  }
+
   @Test
   public void testSupplierSetupCustomDeserializer() throws ExecutionException, 
InterruptedException
   {
@@ -817,6 +857,13 @@ public class KafkaRecordSupplierTest
     }
   }
 
+  private static void increasePartitions(String topic, int totalPartitions) 
throws Exception
+  {
+    try (Admin admin = KAFKA_SERVER.newAdminClient()) {
+      admin.createPartitions(Map.of(topic, 
NewPartitions.increaseTo(totalPartitions))).all().get();
+    }
+  }
+
   private void insertData() throws ExecutionException, InterruptedException
   {
     try (final KafkaProducer<byte[], byte[]> kafkaProducer = 
KAFKA_SERVER.newProducer()) {
diff --git 
a/extensions-core/kafka-indexing-service/src/test/java/org/apache/druid/indexing/kafka/KafkaSamplerSpecTest.java
 
b/extensions-core/kafka-indexing-service/src/test/java/org/apache/druid/indexing/kafka/KafkaSamplerSpecTest.java
index d91fc68bda8..478f742c931 100644
--- 
a/extensions-core/kafka-indexing-service/src/test/java/org/apache/druid/indexing/kafka/KafkaSamplerSpecTest.java
+++ 
b/extensions-core/kafka-indexing-service/src/test/java/org/apache/druid/indexing/kafka/KafkaSamplerSpecTest.java
@@ -59,6 +59,7 @@ import java.util.Collections;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.function.Consumer;
 import java.util.regex.Pattern;
 
@@ -123,6 +124,30 @@ public class KafkaSamplerSpecTest extends 
InitializedNullHandlingTest
     kafkaServer.close();
   }
 
+  @Test
+  public void testSampleOnlySelectedPartition()
+  {
+    final String topic = TOPIC + "_selected";
+    insertData(List.of(
+        new ProducerRecord<>(topic, 0, null, jb("2008", "excluded", "y", "10", 
"20.0", "1.0")),
+        new ProducerRecord<>(topic, 1, null, jb("2008", "selected", "y", "10", 
"20.0", "1.0"))
+    ));
+    final KafkaSupervisorSpec spec = new KafkaSupervisorSpecBuilder()
+        .withDataSchema(DATA_SCHEMA)
+        .withIoConfig(io -> 
io.withJsonInputFormat().withConsumerProperties(kafkaServer.consumerProperties())
+                             
.withUseEarliestSequenceNumber(true).withPartitionIds(Set.of(1)))
+        .build(DATASOURCE, topic);
+    final SamplerResponse response = new KafkaSamplerSpec(
+        spec,
+        new SamplerConfig(1, 5_000, null, null),
+        new InputSourceSampler(OBJECT_MAPPER),
+        OBJECT_MAPPER
+    ).sample();
+
+    Assertions.assertEquals(1, response.getNumRowsIndexed());
+    Assertions.assertEquals("selected", 
response.getData().get(0).getParsed().get("dim1"));
+  }
+
   @Test
   public void testSample()
   {
diff --git 
a/extensions-core/kafka-indexing-service/src/test/java/org/apache/druid/indexing/kafka/simulate/EmbeddedKafkaSupervisorTest.java
 
b/extensions-core/kafka-indexing-service/src/test/java/org/apache/druid/indexing/kafka/simulate/EmbeddedKafkaSupervisorTest.java
index ea0a3ca49ff..f407b13fa98 100644
--- 
a/extensions-core/kafka-indexing-service/src/test/java/org/apache/druid/indexing/kafka/simulate/EmbeddedKafkaSupervisorTest.java
+++ 
b/extensions-core/kafka-indexing-service/src/test/java/org/apache/druid/indexing/kafka/simulate/EmbeddedKafkaSupervisorTest.java
@@ -58,6 +58,7 @@ import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.concurrent.ThreadLocalRandom;
 
 public class EmbeddedKafkaSupervisorTest extends EmbeddedClusterTestBase
@@ -112,6 +113,57 @@ public class EmbeddedKafkaSupervisorTest extends 
EmbeddedClusterTestBase
     }
   }
 
+  @Test
+  public void testSupervisorIngestsOnlySelectedPartitions()
+  {
+    final String topic = IdUtils.getRandomId();
+    kafkaServer.createTopicWithPartitions(topic, 4);
+    final KafkaSupervisorSpec spec = newKafkaSupervisor()
+        .withIoConfig(io -> 
io.withPartitionIds(Set.of(1)).withTaskCount(2).withStopTaskCount(1))
+        .build(dataSource, topic);
+    cluster.callApi().postSupervisor(spec);
+
+    // One record per partition, but only partition 1 is selected.
+    kafkaServer.produceRecordsToTopic(partitionRecords(topic, 4));
+    waitForSelectedRows(dataSource, 1);
+
+    Assertions.assertEquals("1", cluster.runSql("SELECT COUNT(*) FROM %s", 
dataSource));
+    Assertions.assertEquals("0", cluster.runSql("SELECT COUNT(*) FROM %s WHERE 
item != 'p1'", dataSource));
+
+    // Replacing the supervisor with a wider selection picks up partition 3 
but still skips partition 2.
+    final KafkaSupervisorSpec expanded = newKafkaSupervisor()
+        .withIoConfig(io -> io.withPartitionIds(Set.of(1, 
3)).withTaskCount(2).withStopTaskCount(1))
+        .build(dataSource, topic);
+    cluster.callApi().postSupervisor(expanded);
+    waitForSelectedRows(dataSource, 2);
+
+    Assertions.assertEquals("2", cluster.runSql("SELECT COUNT(*) FROM %s", 
dataSource));
+    Assertions.assertEquals("0", cluster.runSql("SELECT COUNT(*) FROM %s WHERE 
item = 'p2'", dataSource));
+    cluster.callApi().postSupervisor(expanded.createSuspendedSpec());
+  }
+
+  private static List<ProducerRecord<byte[], byte[]>> partitionRecords(String 
topic, int partitions)
+  {
+    final List<ProducerRecord<byte[], byte[]>> records = new ArrayList<>();
+    for (int partition = 0; partition < partitions; partition++) {
+      records.add(new ProducerRecord<>(
+          topic,
+          partition,
+          null,
+          StringUtils.toUtf8("2025-06-01T00:00:00Z,p" + partition)
+      ));
+    }
+    return records;
+  }
+
+  private void waitForSelectedRows(String source, int count)
+  {
+    indexer.latchableEmitter().waitForEventAggregate(
+        event -> 
event.hasMetricName("ingest/events/processed").hasDimension(DruidMetrics.DATASOURCE,
 source),
+        agg -> agg.hasSumAtLeast(count)
+    );
+  }
+
   @Test
   public void test_runKafkaSupervisor()
   {
diff --git 
a/extensions-core/kafka-indexing-service/src/test/java/org/apache/druid/indexing/kafka/supervisor/KafkaSupervisorIOConfigTest.java
 
b/extensions-core/kafka-indexing-service/src/test/java/org/apache/druid/indexing/kafka/supervisor/KafkaSupervisorIOConfigTest.java
index 50486ed0743..6c758928400 100644
--- 
a/extensions-core/kafka-indexing-service/src/test/java/org/apache/druid/indexing/kafka/supervisor/KafkaSupervisorIOConfigTest.java
+++ 
b/extensions-core/kafka-indexing-service/src/test/java/org/apache/druid/indexing/kafka/supervisor/KafkaSupervisorIOConfigTest.java
@@ -24,13 +24,16 @@ import com.fasterxml.jackson.databind.JsonMappingException;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.google.common.base.Optional;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSortedSet;
 import nl.jqno.equalsverifier.EqualsVerifier;
 import nl.jqno.equalsverifier.Warning;
 import org.apache.druid.data.input.InputFormat;
+import org.apache.druid.data.input.impl.TimestampSpec;
 import org.apache.druid.error.DruidException;
 import org.apache.druid.indexing.kafka.KafkaConsumerConfigs;
 import org.apache.druid.indexing.kafka.KafkaIndexTaskModule;
 import org.apache.druid.indexing.kafka.KafkaRecordSupplier;
+import 
org.apache.druid.indexing.overlord.supervisor.SupervisorSpecUpdateAction;
 import org.apache.druid.indexing.seekablestream.extension.KafkaConfigOverrides;
 import org.apache.druid.indexing.seekablestream.supervisor.BoundedStreamConfig;
 import org.apache.druid.indexing.seekablestream.supervisor.IdleConfig;
@@ -39,6 +42,7 @@ import 
org.apache.druid.indexing.seekablestream.supervisor.autoscaler.AutoScaler
 import 
org.apache.druid.indexing.seekablestream.supervisor.autoscaler.LagBasedAutoScalerConfig;
 import org.apache.druid.jackson.DefaultObjectMapper;
 import org.apache.druid.java.util.common.DateTimes;
+import org.apache.druid.java.util.common.StringUtils;
 import org.joda.time.Duration;
 import org.joda.time.Period;
 import org.junit.jupiter.api.Assertions;
@@ -48,6 +52,7 @@ import org.junit.jupiter.api.function.Executable;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Properties;
+import java.util.Set;
 
 import static org.easymock.EasyMock.createMock;
 
@@ -61,6 +66,160 @@ public class KafkaSupervisorIOConfigTest
     mapper.registerModules(new KafkaIndexTaskModule().getJacksonModules());
   }
 
+  @Test
+  public void testPartitionSelectionSerdeAndUpdate() throws Exception
+  {
+    // Duplicates and ordering are normalized into a sorted set.
+    final KafkaSupervisorIOConfig config = mapper.readValue(
+        
"{\"topic\":\"events\",\"consumerProperties\":{\"bootstrap.servers\":\"localhost:9092\"},"
+        + "\"partitionIds\":[5,0,2,0]}",
+        KafkaSupervisorIOConfig.class
+    );
+    Assertions.assertEquals(Set.of(0, 2, 5), config.getPartitionIds());
+
+    // Serialized in sorted order; survives builder copy and JSON round trip; 
the selection is immutable.
+    Assertions.assertEquals("[0,2,5]", 
mapper.writeValueAsString(config.getPartitionIds()));
+    Assertions.assertEquals(config, config.toBuilder().build());
+    Assertions.assertEquals(config, 
mapper.readValue(mapper.writeValueAsString(config), 
KafkaSupervisorIOConfig.class));
+    Assertions.assertThrows(UnsupportedOperationException.class, () -> 
config.getPartitionIds().add(7));
+
+    // A different selection makes the config unequal.
+    final KafkaSupervisorIOConfig changed = 
config.toBuilder().withPartitionIds(Set.of(0, 2)).build();
+    Assertions.assertNotEquals(config, changed);
+
+    // Changing the selection restarts the supervisor and its tasks.
+    final KafkaSupervisorSpec spec = new KafkaSupervisorSpecBuilder()
+        .withDataSchema(schema -> schema.withTimestamp(new 
TimestampSpec("timestamp", "auto", null)))
+        .withIoConfig(io -> io.copyFrom(config))
+        .build("diagnostic", "events");
+    Assertions.assertEquals(
+        SupervisorSpecUpdateAction.RESTART_SUPERVISOR_AND_TASKS,
+        spec.getActionOnUpdateTo(spec.toBuilder().ioConfig(changed).build())
+    );
+
+    // Removing the selection (back to all partitions) also restarts the 
supervisor and its tasks.
+    final KafkaSupervisorSpec allPartitions = spec.toBuilder()
+        .ioConfig(config.toBuilder().withPartitionIds(null).build()).build();
+    
Assertions.assertNull(allPartitions.getSpec().getIOConfig().getPartitionIds());
+    Assertions.assertEquals(
+        SupervisorSpecUpdateAction.RESTART_SUPERVISOR_AND_TASKS,
+        spec.getActionOnUpdateTo(allPartitions)
+    );
+  }
+
+  @Test
+  public void testLegacyConstructorDefaultsToAllPartitions()
+  {
+    final KafkaSupervisorIOConfig config = new KafkaSupervisorIOConfig(
+        "events",
+        null,
+        null,
+        null,
+        null,
+        null,
+        Map.of("bootstrap.servers", "localhost:9092"),
+        null,
+        null,
+        null,
+        null,
+        null,
+        null,
+        null,
+        null,
+        null,
+        null,
+        null,
+        null,
+        null,
+        null,
+        null,
+        null
+    );
+
+    Assertions.assertNull(config.getPartitionIds());
+    Assertions.assertFalse(mapper.valueToTree(config).has("partitionIds"));
+    Assertions.assertEquals(config, config.toBuilder().build());
+  }
+
+  @Test
+  public void testPartitionSelectionSurvivesAutoscalerUpdates() throws 
Exception
+  {
+    final LagBasedAutoScalerConfig autoscaler = mapper.readValue(
+        
"{\"enableTaskAutoScaler\":true,\"taskCountMin\":1,\"taskCountMax\":4}",
+        LagBasedAutoScalerConfig.class
+    );
+    final KafkaSupervisorSpec spec = new KafkaSupervisorSpecBuilder()
+        .withDataSchema(schema -> schema.withTimestamp(TimestampSpec.DEFAULT))
+        .withIoConfig(io -> 
io.withConsumerProperties(Map.of("bootstrap.servers", "localhost:9092"))
+                             .withPartitionIds(Set.of(0, 
2)).withAutoScalerConfig(autoscaler))
+        .build("diagnostic", "events");
+    final KafkaSupervisorSpec scaled = spec.toBuilder().taskCount(2).build();
+
+    Assertions.assertEquals(Set.of(0, 2), 
scaled.getSpec().getIOConfig().getPartitionIds());
+    Assertions.assertEquals(SupervisorSpecUpdateAction.NONE, 
spec.getActionOnUpdateTo(scaled));
+    final KafkaSupervisorSpec narrowed = scaled.toBuilder()
+        
.ioConfig(scaled.getSpec().getIOConfig().toBuilder().withPartitionIds(Set.of(0)).build()).build();
+
+    Assertions.assertEquals(
+        SupervisorSpecUpdateAction.RESTART_SUPERVISOR_AND_TASKS,
+        spec.getActionOnUpdateTo(narrowed)
+    );
+    Assertions.assertEquals(Set.of(0), 
narrowed.getSpec().getIOConfig().getPartitionIds());
+  }
+
+  @Test
+  public void testInvalidPartitionIds()
+  {
+    for (final String ids : new String[]{"[]", "[-1]", "[null]", "[0.5]", 
"[1.0]", "[\"abc\"]", "[true]", "[2147483648]"}) {
+      Assertions.assertThrows(
+          JsonMappingException.class,
+          () -> mapper.readValue(
+              StringUtils.format(
+                  """
+                  {
+                    "topic": "events",
+                    "consumerProperties": {"bootstrap.servers": 
"localhost:9092"},
+                    "partitionIds": %s
+                  }
+                  """,
+                  ids
+              ),
+              KafkaSupervisorIOConfig.class
+          )
+      );
+    }
+  }
+
+  @Test
+  public void testPartitionIdsRequireSingleTopicWithoutBoundedStream()
+  {
+    final String expectedMessage =
+        "partitionIds requires a single topic and cannot be combined with 
boundedStreamConfig";
+
+    // Rejected with topicPattern, which reads multiple topics.
+    final DruidException withTopicPattern = Assertions.assertThrows(
+        DruidException.class,
+        () -> new KafkaIOConfigBuilder()
+            .withTopicPattern("events.*")
+            .withConsumerProperties(Map.of("bootstrap.servers", 
"localhost:9092"))
+            .withPartitionIds(Set.of(0))
+            .build()
+    );
+    Assertions.assertEquals(expectedMessage, withTopicPattern.getMessage());
+
+    // Rejected with boundedStreamConfig.
+    final DruidException withBoundedStream = Assertions.assertThrows(
+        DruidException.class,
+        () -> new KafkaIOConfigBuilder()
+            .withTopic("events")
+            .withConsumerProperties(Map.of("bootstrap.servers", 
"localhost:9092"))
+            .withPartitionIds(Set.of(0))
+            .withBoundedStreamConfig(new BoundedStreamConfig(Map.of(0, 0L), 
Map.of(0, 10L)))
+            .build()
+    );
+    Assertions.assertEquals(expectedMessage, withBoundedStream.getMessage());
+  }
+
   @Test
   public void testSerdeWithDefaults() throws Exception
   {
@@ -81,6 +240,7 @@ public class KafkaSupervisorIOConfigTest
 
     Assertions.assertEquals("my-topic", config.getTopic());
     Assertions.assertNull(config.getTopicPattern());
+    Assertions.assertNull(config.getPartitionIds());
     Assertions.assertEquals(1, (int) config.getReplicas());
     Assertions.assertEquals(1, (int) config.getTaskCount());
     Assertions.assertNull(config.getStopTaskCount());
@@ -353,6 +513,7 @@ public class KafkaSupervisorIOConfigTest
         null,
         false,
         null,
+        null,
         null
     );
     String ioConfig = mapper.writeValueAsString(kafkaSupervisorIOConfig);
@@ -391,6 +552,7 @@ public class KafkaSupervisorIOConfigTest
         null,
         false,
         null,
+        null,
         null
     );
     Assertions.assertEquals(1, kafkaSupervisorIOConfig.getTaskCount());
@@ -457,6 +619,7 @@ public class KafkaSupervisorIOConfigTest
         null,
         false,
         null,
+        null,
         null
     );
   }
@@ -495,6 +658,7 @@ public class KafkaSupervisorIOConfigTest
         null,
         false,
         null,
+        null,
         null
     );
     String ioConfig = mapper.writeValueAsString(kafkaSupervisorIOConfig);
@@ -621,7 +785,8 @@ public class KafkaSupervisorIOConfigTest
         null,
         false,
         null,
-        boundedConfig
+        boundedConfig,
+        null
     );
 
     String json = mapper.writeValueAsString(original);
@@ -687,6 +852,7 @@ public class KafkaSupervisorIOConfigTest
                   .withIgnoredFields("taskCountExplicit", "autoScalerEnabled")
                   .suppress(Warning.NONFINAL_FIELDS)
                   .withPrefabValues(Optional.class, Optional.of("a"), 
Optional.of("b"))
+                  .withPrefabValues(ImmutableSortedSet.class, 
ImmutableSortedSet.of(0), ImmutableSortedSet.of(1))
                   .withPrefabValues(InputFormat.class, 
createMock(InputFormat.class), createMock(InputFormat.class))
                   .withPrefabValues(AutoScalerConfig.class, 
createMock(AutoScalerConfig.class), createMock(AutoScalerConfig.class))
                   .withPrefabValues(LagAggregator.class, 
createMock(LagAggregator.class), createMock(LagAggregator.class))
diff --git 
a/extensions-core/kafka-indexing-service/src/test/java/org/apache/druid/indexing/kafka/supervisor/KafkaSupervisorSpecTest.java
 
b/extensions-core/kafka-indexing-service/src/test/java/org/apache/druid/indexing/kafka/supervisor/KafkaSupervisorSpecTest.java
index e0012c8c53f..37fb268b54f 100644
--- 
a/extensions-core/kafka-indexing-service/src/test/java/org/apache/druid/indexing/kafka/supervisor/KafkaSupervisorSpecTest.java
+++ 
b/extensions-core/kafka-indexing-service/src/test/java/org/apache/druid/indexing/kafka/supervisor/KafkaSupervisorSpecTest.java
@@ -50,6 +50,7 @@ import org.junit.jupiter.api.Test;
 
 import java.io.IOException;
 import java.util.Map;
+import java.util.Set;
 
 import static org.junit.jupiter.api.Assertions.assertThrows;
 
@@ -577,6 +578,35 @@ public class KafkaSupervisorSpecTest
     Assertions.assertEquals(boundedStreamConfig, 
backfill.getSpec().getIOConfig().getBoundedStreamConfig());
   }
 
+  @Test
+  public void testCreateBackfillSpecDropsPartitionSelection()
+  {
+    final KafkaSupervisorSpec spec = new KafkaSupervisorSpecBuilder()
+        .withDataSchema(
+            schema -> schema
+                .withTimestamp(TimestampSpec.DEFAULT)
+                .withAggregators(new CountAggregatorFactory("rows"))
+                .withGranularity(new 
UniformGranularitySpec(Granularities.HOUR, Granularities.NONE, null))
+        )
+        .withIoConfig(
+            ioConfig -> ioConfig
+                .withJsonInputFormat()
+                .withConsumerProperties(Map.of("bootstrap.servers", 
"localhost:9092"))
+                .withPartitionIds(Set.of(0))
+        )
+        .build("testDs", "metrics");
+    final BoundedStreamConfig boundedStreamConfig = new 
BoundedStreamConfig(Map.of("0", 100L), Map.of("0", 500L));
+
+    final KafkaSupervisorSpec backfill = 
spec.createBackfillSpec("backfill-id", boundedStreamConfig, 1);
+
+    // The bounded backfill reads its own range, so it must not inherit the 
selection.
+    Assertions.assertNull(backfill.getSpec().getIOConfig().getPartitionIds());
+    Assertions.assertEquals(boundedStreamConfig, 
backfill.getSpec().getIOConfig().getBoundedStreamConfig());
+
+    // The source spec keeps its selection.
+    Assertions.assertEquals(Set.of(0), 
spec.getSpec().getIOConfig().getPartitionIds());
+  }
+
   private KafkaSupervisorSpec getSpec(String topic, String topicPattern)
   {
     KafkaSupervisorSpecBuilder builder = new KafkaSupervisorSpecBuilder()
diff --git 
a/extensions-core/kafka-indexing-service/src/test/java/org/apache/druid/indexing/kafka/supervisor/KafkaSupervisorTest.java
 
b/extensions-core/kafka-indexing-service/src/test/java/org/apache/druid/indexing/kafka/supervisor/KafkaSupervisorTest.java
index b64bd19e4f4..fc4a181c5cd 100644
--- 
a/extensions-core/kafka-indexing-service/src/test/java/org/apache/druid/indexing/kafka/supervisor/KafkaSupervisorTest.java
+++ 
b/extensions-core/kafka-indexing-service/src/test/java/org/apache/druid/indexing/kafka/supervisor/KafkaSupervisorTest.java
@@ -21,6 +21,7 @@ package org.apache.druid.indexing.kafka.supervisor;
 
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.jsontype.NamedType;
 import com.google.common.base.Optional;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -34,6 +35,7 @@ import org.apache.druid.data.input.impl.StringDimensionSchema;
 import org.apache.druid.data.input.impl.TimestampSpec;
 import org.apache.druid.data.input.kafka.KafkaRecordEntity;
 import org.apache.druid.data.input.kafka.KafkaTopicPartition;
+import org.apache.druid.error.DruidException;
 import org.apache.druid.indexer.TaskLocation;
 import org.apache.druid.indexer.TaskStatus;
 import org.apache.druid.indexer.granularity.UniformGranularitySpec;
@@ -69,6 +71,7 @@ import 
org.apache.druid.indexing.seekablestream.SeekableStreamIndexTaskRunner.St
 import 
org.apache.druid.indexing.seekablestream.SeekableStreamIndexTaskTuningConfig;
 import 
org.apache.druid.indexing.seekablestream.SeekableStreamStartSequenceNumbers;
 import org.apache.druid.indexing.seekablestream.common.RecordSupplier;
+import org.apache.druid.indexing.seekablestream.common.StreamException;
 import org.apache.druid.indexing.seekablestream.supervisor.BoundedStreamConfig;
 import org.apache.druid.indexing.seekablestream.supervisor.IdleConfig;
 import 
org.apache.druid.indexing.seekablestream.supervisor.SeekableStreamSupervisor;
@@ -181,6 +184,7 @@ public class KafkaSupervisorTest extends EasyMockSupport
   private String topic;
   private String topicPattern;
   private boolean multiTopic;
+  private Set<Integer> selectedPartitionIds;
   private RowIngestionMetersFactory rowIngestionMetersFactory;
   private StubServiceEmitter serviceEmitter;
   private SupervisorStateManagerConfig supervisorConfig;
@@ -242,6 +246,7 @@ public class KafkaSupervisorTest extends EasyMockSupport
     topic = getTopic();
     topicPattern = getTopicPattern();
     topicPostfix++;
+    selectedPartitionIds = null;
     multiTopic = false; // assign to true in test if you wish to test 
multi-topic
     rowIngestionMetersFactory = new TestUtils().getRowIngestionMetersFactory();
     serviceEmitter = new StubServiceEmitter("KafkaSupervisorTest", 
"localhost");
@@ -523,6 +528,99 @@ public class KafkaSupervisorTest extends EasyMockSupport
     Assertions.assertEquals(Integer.valueOf(20), 
taskList.get(2).getServerPriority());
   }
 
+  @Test
+  public void testSelectedPartitionAssignmentAndLag() throws Exception
+  {
+    selectedPartitionIds = Set.of(0, 2);
+    supervisor = getTestableSupervisor(1, 2, true, "PT1H", null, null);
+    addSomeEvents(1);
+    final Capture<KafkaIndexTask> captured = 
Capture.newInstance(CaptureType.ALL);
+    
EasyMock.expect(taskMaster.getTaskQueue()).andReturn(Optional.of(taskQueue)).anyTimes();
+    
EasyMock.expect(taskMaster.getTaskRunner()).andReturn(Optional.absent()).anyTimes();
+    
EasyMock.expect(taskQueue.getActiveTasksForDatasource(DATASOURCE)).andReturn(Map.of()).anyTimes();
+    
EasyMock.expect(indexerMetadataStorageCoordinator.retrieveDataSourceMetadata(DATASOURCE)).andReturn(null).anyTimes();
+    
EasyMock.expect(taskQueue.add(EasyMock.capture(captured))).andReturn(true).times(2);
+    replayAll();
+    supervisor.start();
+    // A previous generation had only partition 0. Rebuild the group, not just 
its tasks.
+    supervisor.addTaskGroupToActivelyReadingTaskGroup(
+        0,
+        singlePartitionMap(topic, 0, 0L),
+        null,
+        null,
+        Set.of(),
+        Set.of(),
+        null
+    );
+    supervisor.runInternal();
+    final Set<KafkaTopicPartition> selected = singlePartitionMap(topic, 0, 0L, 
2, 0L).keySet();
+
+    Assertions.assertEquals(
+        Set.of(Set.of(new KafkaTopicPartition(false, topic, 0)), Set.of(new 
KafkaTopicPartition(false, topic, 2))),
+        captured.getValues().stream()
+                .map(task -> 
task.getIOConfig().getStartSequenceNumbers().getPartitionSequenceNumberMap().keySet())
+                .collect(Collectors.toSet())
+    );
+    captured.getValues().forEach(task -> Assertions.assertEquals(
+        
task.getIOConfig().getStartSequenceNumbers().getPartitionSequenceNumberMap().keySet(),
+        
task.getIOConfig().getEndSequenceNumbers().getPartitionSequenceNumberMap().keySet()
+    ));
+
+    Assertions.assertEquals(2, supervisor.getPartitionCount());
+    supervisor.updatePartitionLagFromStream();
+
+    Assertions.assertEquals(selected, 
supervisor.getPartitionRecordLag().keySet());
+    verifyAll();
+  }
+
+  @Test
+  public void testSparseSelectedPartitionGrouping()
+  {
+    selectedPartitionIds = Set.of(0, 3, 6);
+    supervisor = getTestableSupervisor(1, 3, true, "PT1H", null, null);
+
+    Assertions.assertEquals(0, supervisor.getTaskGroupIdForPartition(new 
KafkaTopicPartition(false, topic, 0)));
+    Assertions.assertEquals(1, supervisor.getTaskGroupIdForPartition(new 
KafkaTopicPartition(false, topic, 3)));
+    Assertions.assertEquals(2, supervisor.getTaskGroupIdForPartition(new 
KafkaTopicPartition(false, topic, 6)));
+    // Older tasks may still contain excluded partitions while being replaced.
+    Assertions.assertEquals(1, supervisor.getTaskGroupIdForPartition(new 
KafkaTopicPartition(false, topic, 7)));
+  }
+
+  @Test
+  public void testOffsetsFromMetadataStorageIgnoreUnselectedPartitions()
+  {
+    selectedPartitionIds = Set.of(0);
+    supervisor = getTestableSupervisor(1, 1, true, "PT1H", null, null);
+    // Partition 1 is outside the selection. Its saved offset stays in 
metadata storage but is not reported.
+    final Map<KafkaTopicPartition, Long> storedOffsets = 
singlePartitionMap(topic, 0, 10L, 1, 20L);
+    
EasyMock.expect(indexerMetadataStorageCoordinator.retrieveDataSourceMetadata(DATASOURCE)).andReturn(
+        new KafkaDataSourceMetadata(new 
SeekableStreamEndSequenceNumbers<>(topic, storedOffsets))
+    );
+    replayAll();
+
+    Assertions.assertEquals(singlePartitionMap(topic, 0, 10L), 
supervisor.getOffsetsFromMetadataStorage());
+    verifyAll();
+  }
+
+  @Test
+  public void testMissingSelectedPartitionBlocksStartup() throws Exception
+  {
+    selectedPartitionIds = Set.of(0, 99);
+    supervisor = getTestableSupervisor(1, 2, true, "PT1H", null, null);
+    addSomeEvents(1);
+    
EasyMock.expect(taskMaster.getTaskRunner()).andReturn(Optional.absent()).anyTimes();
+    replayAll();
+    supervisor.start();
+    supervisor.runInternal();
+    supervisor.runInternal();
+
+    Assertions.assertTrue(supervisor.getPartitionGroups().isEmpty());
+    
Assertions.assertFalse(supervisor.getStateManager().isAtLeastOneSuccessfulRun());
+    
Assertions.assertTrue(supervisor.getStateManager().getExceptionEvents().toString().contains("99"));
+    Assertions.assertThrows(StreamException.class, () -> 
supervisor.getPartitionCount());
+    verifyAll();
+  }
+
   @Test
   public void testNoInitialState() throws Exception
   {
@@ -2879,8 +2977,6 @@ public class KafkaSupervisorTest extends EasyMockSupport
     supervisor.start();
     supervisor.updateCurrentAndLatestOffsets();
     supervisor.runInternal();
-    verifyAll();
-
     Thread.sleep(100);
     supervisor.updateCurrentAndLatestOffsets();
     supervisor.runInternal();
@@ -2896,6 +2992,42 @@ public class KafkaSupervisorTest extends EasyMockSupport
     Assertions.assertEquals(SupervisorStateManager.BasicState.IDLE, 
supervisor.getState());
   }
 
+  @Test
+  public void testSelectedSupervisorIsIdleWithExcludedStoredOffsets() throws 
Exception
+  {
+    selectedPartitionIds = Set.of(0);
+    supervisor = getTestableSupervisorForIdleBehaviour(
+        1,
+        2,
+        true,
+        "PT10S",
+        null,
+        null,
+        false,
+        new IdleConfig(true, 200L)
+    );
+    addSomeEvents(1);
+    
EasyMock.expect(taskMaster.getTaskQueue()).andReturn(Optional.of(taskQueue)).anyTimes();
+    
EasyMock.expect(taskMaster.getTaskRunner()).andReturn(Optional.of(taskRunner)).anyTimes();
+    
EasyMock.expect(taskQueue.getActiveTasksForDatasource(DATASOURCE)).andReturn(Map.of()).anyTimes();
+    taskRunner.registerListener(EasyMock.anyObject(TaskRunnerListener.class), 
EasyMock.anyObject(Executor.class));
+    // Saved offsets include partitions 1 and 2, which are outside the 
selection.
+    
EasyMock.expect(indexerMetadataStorageCoordinator.retrieveDataSourceMetadata(DATASOURCE)).andReturn(
+        new KafkaDataSourceMetadata(
+            new SeekableStreamEndSequenceNumbers<>(topic, 
singlePartitionMap(topic, 0, 2L, 1, 2L, 2, 2L))
+        )
+    ).anyTimes();
+    
EasyMock.expect(taskQueue.add(EasyMock.anyObject())).andReturn(true).anyTimes();
+    replayAll();
+    supervisor.start();
+    supervisor.updateCurrentAndLatestOffsets();
+
+    supervisor.runInternal();
+
+    Assertions.assertEquals(SupervisorStateManager.BasicState.IDLE, 
supervisor.getState());
+    verifyAll();
+  }
+
   @Test
   public void testSupervisorNotIdleIfStreamInactiveWhenSuspended() throws 
Exception
   {
@@ -4809,6 +4941,153 @@ public class KafkaSupervisorTest extends EasyMockSupport
     verifyAll();
   }
 
+  @Test
+  public void testRestartAdoptsReaderAfterTopicGainsPartitions() throws 
Exception
+  {
+    supervisor = getTestableSupervisor(1, 1, true, "PT1H", null, null);
+    addSomeEvents(1);
+    // The task predates partition 1; restart must adopt it without discarding 
its uncommitted work.
+    final Map<KafkaTopicPartition, Long> originalOffsets = 
singlePartitionMap(topic, 0, 0L, 2, 0L);
+    final KafkaIndexTask existingTask = createKafkaIndexTask(
+        "existing",
+        DATASOURCE,
+        0,
+        new SeekableStreamStartSequenceNumbers<>(topic, originalOffsets, 
Set.of()),
+        new SeekableStreamEndSequenceNumbers<>(topic, 
singlePartitionMap(topic, 0, Long.MAX_VALUE, 2, Long.MAX_VALUE)),
+        null,
+        null,
+        supervisor.getTuningConfig()
+    );
+    final TreeMap<Integer, Map<KafkaTopicPartition, Long>> checkpoints = new 
TreeMap<>();
+    checkpoints.put(0, originalOffsets);
+    
EasyMock.expect(taskMaster.getTaskQueue()).andReturn(Optional.of(taskQueue)).anyTimes();
+    
EasyMock.expect(taskMaster.getTaskRunner()).andReturn(Optional.absent()).anyTimes();
+    
EasyMock.expect(taskQueue.getActiveTasksForDatasource(DATASOURCE)).andReturn(Map.of("existing",
 existingTask)).anyTimes();
+    
EasyMock.expect(taskStorage.getStatus("existing")).andReturn(Optional.of(TaskStatus.running("existing"))).anyTimes();
+    
EasyMock.expect(taskStorage.getTask("existing")).andReturn(Optional.of(existingTask)).anyTimes();
+    
EasyMock.expect(taskClient.getStatusAsync("existing")).andReturn(Futures.immediateFuture(Status.READING));
+    
EasyMock.expect(taskClient.getStartTimeAsync("existing")).andReturn(Futures.immediateFuture(DateTimes.nowUtc()));
+    EasyMock.expect(taskClient.getCheckpointsAsync(EasyMock.eq("existing"), 
EasyMock.anyBoolean()))
+            .andReturn(Futures.immediateFuture(checkpoints));
+    
EasyMock.expect(indexerMetadataStorageCoordinator.retrieveDataSourceMetadata(DATASOURCE))
+            .andReturn(new KafkaDataSourceMetadata(null)).anyTimes();
+    replayAll();
+    supervisor.start();
+    supervisor.runInternal();
+    verifyAll();
+
+    
Assertions.assertTrue(supervisor.getStateManager().isAtLeastOneSuccessfulRun());
+    Assertions.assertEquals(3, supervisor.getPartitionGroups().get(0).size());
+    Assertions.assertEquals(1, supervisor.getActiveTaskGroupsCount());
+    // Strict mocks reject stop/pause/publish calls and replacement 
submissions in the initial restart cycle.
+  }
+
+  @Test
+  public void testSelectedPartitionTaskCompatibility()
+  {
+    selectedPartitionIds = Set.of(0, 2);
+    final KafkaSupervisor selected = createSupervisor(
+        1,
+        2,
+        true,
+        "PT1H",
+        null,
+        null,
+        false,
+        kafkaHost,
+        dataSchema,
+        tuningConfigBuilder().build()
+    );
+    final Set<KafkaTopicPartition> expected = singlePartitionMap(topic, 0, 0L, 
2, 0L).keySet();
+    selected.getPartitionGroups().put(0, expected);
+
+    Assertions.assertTrue(selected.isTaskPartitionSetCurrent(0, expected));
+    // A task reading fewer, more, or a different group's partitions is not 
current.
+    Assertions.assertFalse(selected.isTaskPartitionSetCurrent(0, 
singlePartitionMap(topic, 0, 0L).keySet()));
+    Assertions.assertFalse(selected.isTaskPartitionSetCurrent(0, 
singlePartitionMap(topic, 0, 0L, 2, 0L, 4, 0L).keySet()));
+    Assertions.assertFalse(selected.isTaskPartitionSetCurrent(1, expected));
+  }
+
+  @Test
+  public void testSelectedPartitionResetValidation()
+  {
+    selectedPartitionIds = Set.of(0, 2);
+    final KafkaSupervisor selected = createSupervisor(
+        1,
+        2,
+        true,
+        "PT1H",
+        null,
+        null,
+        false,
+        kafkaHost,
+        dataSchema,
+        tuningConfigBuilder().build()
+    );
+    final Set<KafkaTopicPartition> expected = singlePartitionMap(topic, 0, 0L, 
2, 0L).keySet();
+    selected.getPartitionGroups().put(0, expected);
+    final KafkaDataSourceMetadata excluded = new KafkaDataSourceMetadata(
+        new SeekableStreamEndSequenceNumbers<>(topic, 
singlePartitionMap(topic, 0, 0L, 4, 0L))
+    );
+
+    assertResetRejected(selected, excluded);
+    Assertions.assertEquals(expected, selected.getPartitionGroups().get(0));
+
+    // Offsets for selected partitions only, and a full reset (null), are 
accepted.
+    selected.validatePartitionReset(new KafkaDataSourceMetadata(
+        new SeekableStreamEndSequenceNumbers<>(topic, 
singlePartitionMap(topic, 0, 0L))
+    ));
+    selected.validatePartitionReset(null);
+  }
+
+  @Test
+  public void testSelectedPartitionResetRejectsTopicQualifiedJsonKeys() throws 
Exception
+  {
+    selectedPartitionIds = Set.of(0);
+    final KafkaSupervisor selected = createSupervisor(
+        1,
+        1,
+        true,
+        "PT1H",
+        null,
+        null,
+        false,
+        kafkaHost,
+        dataSchema,
+        tuningConfigBuilder().build()
+    );
+    final Set<KafkaTopicPartition> expected = singlePartitionMap(topic, 0, 
0L).keySet();
+    selected.getPartitionGroups().put(0, expected);
+    final ObjectMapper resetMapper = OBJECT_MAPPER.copy();
+    resetMapper.registerSubtypes(new NamedType(KafkaDataSourceMetadata.class, 
"kafka"));
+    EasyMock.replay(indexerMetadataStorageCoordinator, taskMaster, taskQueue);
+    for (final String key : List.of("other-topic:0", topic + ":0", ":0")) {
+      final KafkaDataSourceMetadata metadata = resetMapper.readValue(
+          StringUtils.format(
+              
"{\"type\":\"kafka\",\"partitions\":{\"type\":\"end\",\"stream\":\"%s\","
+              + "\"partitionSequenceNumberMap\":{\"0\":5,\"%s\":10}}}",
+              topic,
+              key
+          ),
+          KafkaDataSourceMetadata.class
+      );
+
+      assertResetRejected(selected, metadata);
+      Assertions.assertEquals(expected, selected.getPartitionGroups().get(0));
+    }
+    // No interactions with storage or tasks are permitted, including for 
mixed-key requests.
+    EasyMock.verify(indexerMetadataStorageCoordinator, taskMaster, taskQueue);
+  }
+
+  /** Every reset entry point must reject the request before touching metadata 
storage or the task queue. */
+  private static void assertResetRejected(KafkaSupervisor selected, 
KafkaDataSourceMetadata metadata)
+  {
+    Assertions.assertThrows(DruidException.class, () -> 
selected.reset(metadata));
+    Assertions.assertThrows(DruidException.class, () -> 
selected.resetOffsets(metadata));
+    Assertions.assertThrows(DruidException.class, () -> 
selected.resetInternal(metadata));
+    Assertions.assertThrows(DruidException.class, () -> 
selected.resetOffsetsInternal(metadata));
+  }
+
   @Test
   public void testIsTaskCurrent()
   {
@@ -5826,6 +6105,7 @@ public class KafkaSupervisorTest extends EasyMockSupport
     consumerProperties.put("bootstrap.servers", kafkaHost);
     KafkaSupervisorIOConfig kafkaSupervisorIOConfig = new 
KafkaIOConfigBuilder()
         .withTopic(multiTopic ? null : topic)
+        .withPartitionIds(selectedPartitionIds)
         .withTopicPattern(multiTopic ? topicPattern : null)
         .withInputFormat(INPUT_FORMAT)
         .withReplicas(replicas)
@@ -6000,6 +6280,7 @@ public class KafkaSupervisorTest extends EasyMockSupport
     consumerProperties.put("isolation.level", "read_committed");
     KafkaSupervisorIOConfig kafkaSupervisorIOConfig = new 
KafkaIOConfigBuilder()
         .withTopic(topic)
+        .withPartitionIds(selectedPartitionIds)
         .withInputFormat(INPUT_FORMAT)
         .withReplicas(replicas)
         .withTaskCount(taskCount)
@@ -6305,7 +6586,9 @@ public class KafkaSupervisorTest extends EasyMockSupport
       return new KafkaRecordSupplier(
           new KafkaConsumer<>(props, keyDeserializerObject, 
valueDeserializerObject),
           getIoConfig().isMultiTopic(),
-          null
+          null,
+          null,
+          getIoConfig().getPartitionIds()
       );
     }
 
@@ -6421,6 +6704,31 @@ public class KafkaSupervisorTest extends EasyMockSupport
                                                 .get(new 
KafkaTopicPartition(false, topic, 0)));
   }
 
+  @Test
+  public void testBoundedSupervisorReportsLagOnlyForBoundedPartitions() throws 
Exception
+  {
+    // The topic has more partitions than the bounded config covers, as in a 
backfill of a partition selection.
+    final BoundedStreamConfig boundedConfig = new 
BoundedStreamConfig(Map.of("0", 0L), Map.of("0", 100L));
+    supervisor = getTestableSupervisorWithBoundedConfig(1, 1, "PT1H", 
boundedConfig);
+    addSomeEvents(100);
+    
EasyMock.expect(taskMaster.getTaskQueue()).andReturn(Optional.of(taskQueue)).anyTimes();
+    
EasyMock.expect(taskMaster.getTaskRunner()).andReturn(Optional.of(taskRunner)).anyTimes();
+    
EasyMock.expect(taskQueue.getActiveTasksForDatasource(DATASOURCE)).andReturn(Map.of()).anyTimes();
+    
EasyMock.expect(indexerMetadataStorageCoordinator.retrieveDataSourceMetadata(DATASOURCE)).andReturn(
+        new KafkaDataSourceMetadata(null)
+    ).anyTimes();
+    
EasyMock.expect(taskQueue.add(EasyMock.anyObject())).andReturn(true).anyTimes();
+    taskRunner.registerListener(EasyMock.anyObject(TaskRunnerListener.class), 
EasyMock.anyObject(Executor.class));
+    replayAll();
+    supervisor.start();
+    supervisor.runInternal();
+
+    supervisor.updatePartitionLagFromStream();
+
+    // Partitions the bounded run never assigned must not be queried, as 
reading their positions throws.
+    Assertions.assertEquals(Set.of(new KafkaTopicPartition(false, topic, 0)), 
supervisor.getPartitionRecordLag().keySet());
+  }
+
   @Test
   public void testBoundedStreamConfig_withCheckpoint_resumesFromCheckpoint() 
throws Exception
   {
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 3377090a29c..727a9e3a1bd 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
@@ -1364,9 +1364,15 @@ public abstract class 
SeekableStreamSupervisor<PartitionIdType, SequenceOffsetTy
     });
   }
 
+  protected void validatePartitionReset(@Nullable DataSourceMetadata metadata)
+  {
+    // Stream implementations may restrict which partitions can be reset.
+  }
+
   @Override
   public void reset(@Nullable final DataSourceMetadata dataSourceMetadata)
   {
+    validatePartitionReset(dataSourceMetadata);
     log.info("Posting ResetNotice with datasource metadata [%s]", 
dataSourceMetadata);
     addNotice(new ResetNotice(dataSourceMetadata));
   }
@@ -1381,6 +1387,7 @@ public abstract class 
SeekableStreamSupervisor<PartitionIdType, SequenceOffsetTy
   @Override
   public void resetOffsets(@Nonnull DataSourceMetadata resetDataSourceMetadata)
   {
+    validatePartitionReset(resetDataSourceMetadata);
     if (resetDataSourceMetadata == null) {
       throw InvalidInput.exception("Reset dataSourceMetadata is required for 
resetOffsets.");
     }
@@ -2135,6 +2142,7 @@ public abstract class 
SeekableStreamSupervisor<PartitionIdType, SequenceOffsetTy
   @VisibleForTesting
   public void resetInternal(DataSourceMetadata dataSourceMetadata)
   {
+    validatePartitionReset(dataSourceMetadata);
     if (dataSourceMetadata == null) {
       // Reset everything
       boolean result = 
indexerMetadataStorageCoordinator.deleteDataSourceMetadata(supervisorId);
@@ -2260,6 +2268,7 @@ public abstract class 
SeekableStreamSupervisor<PartitionIdType, SequenceOffsetTy
    */
   public void resetOffsetsInternal(@Nonnull final DataSourceMetadata 
dataSourceMetadata)
   {
+    validatePartitionReset(dataSourceMetadata);
     log.info("Reset offsets for supervisor[%s] for dataSource[%s] with 
metadata[%s]", supervisorId, dataSource, dataSourceMetadata);
 
     @SuppressWarnings("unchecked")
@@ -3087,6 +3096,12 @@ public abstract class 
SeekableStreamSupervisor<PartitionIdType, SequenceOffsetTy
     );
   }
 
+  /** Whether a task's partitions match what its group now owns; if not, the 
task is replaced. Default: always true. */
+  protected boolean isTaskPartitionSetCurrent(int taskGroupId, 
Set<PartitionIdType> taskPartitions)
+  {
+    return true;
+  }
+
   /**
    * Determines whether a given task was created by the current version of the 
supervisor.
    * Uses the Task object mapped to this taskId in the {@code activeTaskMap}.
@@ -3116,6 +3131,13 @@ public abstract class 
SeekableStreamSupervisor<PartitionIdType, SequenceOffsetTy
     SeekableStreamIndexTask<PartitionIdType, SequenceOffsetType, RecordType> 
task =
         (SeekableStreamIndexTask<PartitionIdType, SequenceOffsetType, 
RecordType>) genericTask;
 
+    if (!isTaskPartitionSetCurrent(
+        taskGroupId,
+        
task.getIOConfig().getStartSequenceNumbers().getPartitionSequenceNumberMap().keySet()
+    )) {
+      return false;
+    }
+
     // We recompute the sequence name hash for the supervisor's own 
configuration and compare this to the hash created
     // by rehashing the task's sequence name using the most up-to-date class 
definitions of tuning config and
     // data schema. Recomputing both ensures that forwards-compatible tasks 
won't be killed (which would occur
@@ -4223,6 +4245,12 @@ public abstract class 
SeekableStreamSupervisor<PartitionIdType, SequenceOffsetTy
       Integer groupId = taskGroupEntry.getKey();
       TaskGroup taskGroup = taskGroupEntry.getValue();
 
+      if (!isTaskPartitionSetCurrent(groupId, 
taskGroup.startingSequences.keySet())) {
+        futures.add(stopTasksInGroup(taskGroup, "Task group partition 
selection has changed"));
+        iTaskGroups.remove();
+        continue;
+      }
+
       // Iterate the list of known tasks in this group and:
       //   1) Kill any tasks which are not "current" (have the partitions, 
starting sequences, and minimumMessageTime
       //      & maximumMessageTime (if applicable) in 
[activelyReadingTaskGroups])
diff --git 
a/processing/src/main/java/org/apache/druid/jackson/StrictIntegerDeserializer.java
 
b/processing/src/main/java/org/apache/druid/jackson/StrictIntegerDeserializer.java
new file mode 100644
index 00000000000..f469ee94041
--- /dev/null
+++ 
b/processing/src/main/java/org/apache/druid/jackson/StrictIntegerDeserializer.java
@@ -0,0 +1,59 @@
+/*
+ * 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.jackson;
+
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.JsonToken;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonDeserializer;
+
+import java.io.IOException;
+
+/**
+ * Deserializes an {@link Integer} from a JSON integer or a string holding an 
integer, such as {@code 1} or
+ * {@code "1"}. Unlike Jackson's default, it rejects values that would be 
silently truncated, such as {@code 0.5} and
+ * {@code 1.0}.
+ * <p>
+ * Use it with {@code @JsonDeserialize(using = ...)} on a field, or {@code 
contentUsing = ...} on a collection.
+ */
+public class StrictIntegerDeserializer extends JsonDeserializer<Integer>
+{
+  @Override
+  public Integer deserialize(final JsonParser parser, final 
DeserializationContext context) throws IOException
+  {
+    if (parser.hasToken(JsonToken.VALUE_NUMBER_INT)) {
+      return parser.getIntValue();
+    }
+    if (parser.hasToken(JsonToken.VALUE_STRING)) {
+      try {
+        return Integer.parseInt(parser.getText());
+      }
+      catch (NumberFormatException ignored) {
+        // Fall through to report the offending value.
+      }
+    }
+    return context.reportInputMismatch(
+        Integer.class,
+        "Expected a JSON integer but got %s [%s]",
+        parser.currentToken(),
+        parser.getText()
+    );
+  }
+}
diff --git 
a/processing/src/test/java/org/apache/druid/jackson/StrictIntegerDeserializerTest.java
 
b/processing/src/test/java/org/apache/druid/jackson/StrictIntegerDeserializerTest.java
new file mode 100644
index 00000000000..9af5adbbebe
--- /dev/null
+++ 
b/processing/src/test/java/org/apache/druid/jackson/StrictIntegerDeserializerTest.java
@@ -0,0 +1,89 @@
+/*
+ * 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.jackson;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.JsonMappingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+public class StrictIntegerDeserializerTest
+{
+  private final ObjectMapper mapper = new DefaultObjectMapper();
+
+  @Test
+  public void testAcceptsIntegers() throws Exception
+  {
+    final Holder holder = mapper.readValue("{\"ids\":[0, 7, 2147483647]}", 
Holder.class);
+
+    Assertions.assertEquals(List.of(0, 7, Integer.MAX_VALUE), holder.ids);
+  }
+
+  @Test
+  public void testAcceptsStringsHoldingIntegers() throws Exception
+  {
+    final Holder holder = mapper.readValue("{\"ids\":[\"1\", \"-2\"]}", 
Holder.class);
+
+    Assertions.assertEquals(List.of(1, -2), holder.ids);
+  }
+
+  @Test
+  public void testRejectsNonIntegersAndReportsTheValue()
+  {
+    assertRejected("0.5", "VALUE_NUMBER_FLOAT [0.5]");
+    assertRejected("1.0", "VALUE_NUMBER_FLOAT [1.0]");
+    assertRejected("true", "VALUE_TRUE [true]");
+    assertRejected("\"abc\"", "VALUE_STRING [abc]");
+    assertRejected("\"0.5\"", "VALUE_STRING [0.5]");
+    assertRejected("\"\"", "VALUE_STRING []");
+  }
+
+  @Test
+  public void testRejectsOutOfRangeIntegers()
+  {
+    Assertions.assertThrows(JsonMappingException.class, () -> 
mapper.readValue("{\"ids\":[2147483648]}", Holder.class));
+    assertRejected("\"2147483648\"", "VALUE_STRING [2147483648]");
+  }
+
+  private void assertRejected(String json, String expectedValueDescription)
+  {
+    final JsonMappingException e = Assertions.assertThrows(
+        JsonMappingException.class,
+        () -> mapper.readValue("{\"ids\":[" + json + "]}", Holder.class)
+    );
+    Assertions.assertTrue(e.getMessage().contains("Expected a JSON integer but 
got " + expectedValueDescription), e.getMessage());
+  }
+
+  private static class Holder
+  {
+    private final List<Integer> ids;
+
+    @JsonCreator
+    Holder(@JsonProperty("ids") @JsonDeserialize(contentUsing = 
StrictIntegerDeserializer.class) List<Integer> ids)
+    {
+      this.ids = ids;
+    }
+  }
+}
diff --git a/web-console/src/druid-models/ingestion-spec/ingestion-spec.spec.ts 
b/web-console/src/druid-models/ingestion-spec/ingestion-spec.spec.ts
index cac347cb31f..b8653045147 100644
--- a/web-console/src/druid-models/ingestion-spec/ingestion-spec.spec.ts
+++ b/web-console/src/druid-models/ingestion-spec/ingestion-spec.spec.ts
@@ -23,15 +23,77 @@ import {
   adjustId,
   cleanSpec,
   DEFAULT_FORCE_SEGMENT_SORT_BY_TIME,
+  getIoConfigFormFields,
   guessColumnTypeFromInput,
   guessColumnTypeFromSampleResponse,
   guessKafkaInputFormat,
   guessSimpleInputFormat,
+  issueWithIoConfig,
   updateSchemaWithSample,
   upgradeSpec,
 } from './ingestion-spec';
 
 describe('ingestion-spec', () => {
+  it('validates Kafka partition selection without contacting Kafka', () => {
+    const ioConfig = { type: 'kafka', topic: 'events' };
+    expect(issueWithIoConfig(ioConfig, true)).toBeUndefined();
+    for (const partitionIds of [[0, 2], ['1', '2'], [2147483647], 
['2147483647']]) {
+      expect(issueWithIoConfig({ ...ioConfig, partitionIds }, 
true)).toBeUndefined();
+    }
+    for (const partitionIds of [
+      [],
+      [-1],
+      [0.5],
+      [NaN],
+      [2147483648],
+      ['2147483648'],
+      ['abc'],
+      ['-1'],
+      ['1.5'],
+    ]) {
+      expect(issueWithIoConfig({ ...ioConfig, partitionIds }, 
true)).toBeDefined();
+    }
+    expect(
+      issueWithIoConfig({ ...ioConfig, topicPattern: 'events.*', partitionIds: 
[0] }, true),
+    ).toBeDefined();
+  });
+
+  it('accepts comma-separated Kafka partition IDs in the form', () => {
+    const partitionIdsField = getIoConfigFormFields('kafka').find(
+      field => field.name === 'partitionIds',
+    );
+
+    expect(partitionIdsField?.type).toBe('string-array');
+    expect(partitionIdsField?.placeholder).toBe('Optional; comma-separated, 
e.g. 0, 2');
+    expect(partitionIdsField?.valueAdjustment?.(['1', '2', '3'])).toEqual([1, 
2, 3]);
+    // Values the server rejects are kept as strings, not coerced to numbers.
+    expect(partitionIdsField?.valueAdjustment?.(['1e3', '0x10', '-1', 
'abc'])).toEqual([
+      '1e3',
+      '0x10',
+      '-1',
+      'abc',
+    ]);
+    expect(partitionIdsField?.valueAdjustment?.([])).toBeUndefined();
+    expect(partitionIdsField?.valueAdjustment?.(undefined)).toBeUndefined();
+
+    // The field is shown for a topic, and stays shown while it holds a value 
so it can be cleared.
+    const defined = partitionIdsField?.defined;
+    expect(typeof defined).toBe('function');
+    if (typeof defined === 'function') {
+      expect(defined({ type: 'kafka', topic: 'events' })).toBe(true);
+      expect(defined({ type: 'kafka', topicPattern: 'events.*' })).toBe(false);
+      expect(defined({ type: 'kafka', topicPattern: 'events.*', partitionIds: 
[0] })).toBe(true);
+      expect(defined({ type: 'kinesis', partitionIds: [0] })).toBe(false);
+    }
+
+    const hideInMore = partitionIdsField?.hideInMore;
+    expect(typeof hideInMore).toBe('function');
+    if (typeof hideInMore === 'function') {
+      expect(hideInMore({})).toBe(true);
+      expect(hideInMore({ partitionIds: [1] })).toBe(false);
+    }
+  });
+
   it('upgrades / downgrades task spec 1', () => {
     const oldTaskSpec = {
       type: 'index_parallel',
diff --git a/web-console/src/druid-models/ingestion-spec/ingestion-spec.tsx 
b/web-console/src/druid-models/ingestion-spec/ingestion-spec.tsx
index 778818f4680..2ada7a29a77 100644
--- a/web-console/src/druid-models/ingestion-spec/ingestion-spec.tsx
+++ b/web-console/src/druid-models/ingestion-spec/ingestion-spec.tsx
@@ -590,6 +590,7 @@ export interface IoConfig {
   appendToExisting?: boolean;
   topic?: string;
   topicPattern?: string;
+  partitionIds?: (number | string)[];
   consumerProperties?: any;
   replicas?: number;
   taskCount?: number;
@@ -1156,6 +1157,23 @@ export function 
getIoConfigFormFields(ingestionComboType: IngestionComboType): F
           placeholder: 'your_kafka_topic',
           info: 'The name of the Kafka topic to ingest from.',
         },
+        {
+          name: 'partitionIds',
+          label: 'Partition IDs',
+          type: 'string-array',
+          // Keep the field visible while it holds a value, so it can still be 
cleared after switching to topicPattern.
+          defined: ioConfig =>
+            ioConfig.type === 'kafka' && (Boolean(ioConfig.topic) || 
ioConfig.partitionIds != null),
+          placeholder: 'Optional; comma-separated, e.g. 0, 2',
+          hideInMore: ioConfig => ioConfig.partitionIds == null,
+          // Only plain decimal digits become numbers. Anything else stays a 
string so that validation rejects it,
+          // as the server does, instead of being coerced (e.g. "1e3" or 
"0x10").
+          valueAdjustment: partitionIds =>
+            partitionIds?.length
+              ? partitionIds.map((id: string) => (/^\d+$/.test(id) ? 
Number(id) : id))
+              : undefined,
+          info: 'Optional comma-separated partition IDs, such as 0,2. Omit to 
read all partitions. IDs must exist when Kafka metadata is discovered.',
+        },
         {
           name: 'topicPattern',
           type: 'string',
@@ -1319,6 +1337,16 @@ export function 
getIoConfigFormFields(ingestionComboType: IngestionComboType): F
   throw new Error(`unknown input type ${ingestionComboType}`);
 }
 
+// The server reads partition IDs as Java ints, accepting JSON integers and 
integer strings such as "1".
+const MAX_KAFKA_PARTITION_ID = 2147483647;
+
+function isValidKafkaPartitionId(id: unknown): boolean {
+  if (typeof id === 'number')
+    return Number.isInteger(id) && id >= 0 && id <= MAX_KAFKA_PARTITION_ID;
+  if (typeof id === 'string') return /^\+?\d+$/.test(id) && Number(id) <= 
MAX_KAFKA_PARTITION_ID;
+  return false;
+}
+
 export function issueWithIoConfig(
   ioConfig: IoConfig | undefined,
   ignoreInputFormat = false,
@@ -1335,6 +1363,16 @@ export function issueWithIoConfig(
 
     case 'kafka':
       if (!ioConfig.topic && !ioConfig.topicPattern) return 'must have a topic 
or topicPattern';
+      if (ioConfig.partitionIds != null) {
+        if (ioConfig.topicPattern) return 'partitionIds requires a single 
topic';
+        if (
+          !Array.isArray(ioConfig.partitionIds) ||
+          !ioConfig.partitionIds.length ||
+          !ioConfig.partitionIds.every(isValidKafkaPartitionId)
+        ) {
+          return `partitionIds must be a nonempty array of integers between 0 
and ${MAX_KAFKA_PARTITION_ID}`;
+        }
+      }
       break;
 
     case 'kinesis':


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

Reply via email to