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

9aman pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pinot.git


The following commit(s) were added to refs/heads/master by this push:
     new ebbc9f46c05 Make pauseless re-ingestion consumption timeout 
configurable (#19737)
ebbc9f46c05 is described below

commit ebbc9f46c056e82fd7be74c798e401a17dda5240
Author: Suvodeep Pyne <[email protected]>
AuthorDate: Thu Oct 15 00:39:24 2026 -0700

    Make pauseless re-ingestion consumption timeout configurable (#19737)
    
    * Make pauseless re-ingestion consumption timeout configurable
    
    The server's ReingestionResource waited a hard-coded 30 minutes for
    re-ingestion consumption to reach the segment end offset. A segment that
    cannot be consumed within that window (e.g. ~140M rows at ~49k rows/s)
    can never be repaired, because every retry restarts from the start offset.
    
    Add pinot.server.reingestion.consumption.timeoutMs (default 30 minutes,
    unchanged). It is resolved when each re-ingestion request is accepted:
    the Helix cluster config (picked up live, no restart needed) takes
    precedence over the server config, which takes precedence over the
    default. Non-numeric or non-positive values are ignored with a warning.
    
    Bind the server's cluster config change handler into the admin API as a
    PinotClusterConfigProvider so the resource can read live cluster configs,
    and saturate the wait deadline so a large timeout cannot overflow.
    
    * Check re-ingestion completion at the deadline before timing out
    
    waitForCondition slept a fixed check interval (5s) and only re-checked
    the condition while the deadline had not passed. With a timeout shorter
    than the interval, or completion within the last interval, consumption
    that had finished was still reported as a timeout and the segment was
    never built or uploaded.
    
    Cap each sleep at the remaining time and check the condition once more
    at the deadline before timing out.
    
    * Apply re-ingestion timeout through a cluster config listener and make 
ReingestionResource a singleton
    
    Addresses review feedback on the configurable re-ingestion consumption 
timeout:
    - Resolve the timeout in ReingestionConsumptionTimeout, a 
PinotClusterConfigChangeListener registered in
      BaseServerStarter and bound into the admin API, instead of reading the 
raw cluster config map and the server config
      on every request. Values are validated, and invalid ones warned about, 
once per config change. The timeout is read
      when a job starts consuming.
    - Make ReingestionResource a singleton. It was created per request, so 
duplicate requests were never rejected, the jobs
      listing was always empty, MAX_PARALLEL_REINGESTIONS never applied and 
every request leaked a thread pool.
    - Report an interrupted wait as an interrupt. The interrupt flag is not 
restored, so that closing the segment writer
      afterward still waits for its consumer thread before destroying the 
segment.
    - Drop the always-zero grace period from waitForCondition and take a 
BooleanSupplier.
    - Document that only the exact key is applied without a restart, the 
fallback for a removed or invalid cluster config,
      and why the cluster config takes precedence.
    - Split the tests into plain unit tests for the timeout and the wait loop, 
and endpoint tests for the dependency
      injection and the singleton behavior.
    
    * Stop re-ingestion jobs on shutdown, list queued jobs and clarify wait 
messages
    
    Addresses the second round of review feedback:
    - Shut down the re-ingestion executor when the admin API stops 
(@PreDestroy), interrupting running jobs and logging
      dropped queued ones. Its threads are now daemon threads, and a submit 
rejected during shutdown returns 503 and
      clears the in-progress tracking.
    - Make StatelessRealtimeSegmentWriter.stopConsumption() wait for the 
consumer thread even when the caller is
      interrupted, so that close() never destroys the mutable segment while the 
consumer thread is still indexing.
    - Track a re-ingestion job before submitting it, so that GET 
/reingestSegment/jobs also lists jobs waiting for a
      re-ingestion thread.
    - Name the segment being consumed in the wait messages, and include the 
timeout in the timeout message.
    - Log the previous and new timeout on a cluster config change, and only 
when it changes.
    - Widen the timeout of the wait test that checks completion at the deadline 
to 4s, so that a pause on a loaded host
      cannot make it fail spuriously.
    - Move the endpoint tests to the resources package, give each test its own 
segment names, and add tests for the
      queued job listing and the shutdown.
    
    * Stop re-ingestion consumption at the next batch even if the stream 
ignores interrupts
    
    stopConsumption() now waits for the consumer thread uninterruptibly, but 
the consumer loop only ended on an
    interrupt if the stream plugin threw on it. With a plugin that swallows the 
interrupt or retries internally, the
    consumer kept consuming to the end offset, so close() and the re-ingestion 
shutdown could block until then.
    
    stopConsumption() now also sets a stop flag that the consumer loop checks 
before each fetch, so consumption ends at
    the next batch (bounded by the fetch timeout) regardless of how the plugin 
handles interrupts. A stopped consumption
    is not successful and records a cause naming the stop; 
getConsumptionException() is marked @Nullable.
    
    Adds the first StatelessRealtimeSegmentWriter unit test: it stops 
consumption from a stream that never reaches the end
    offset and ignores interrupts, which hung before this change.
---
 .../writer/StatelessRealtimeSegmentWriter.java     |  20 ++-
 .../writer/StatelessRealtimeSegmentWriterTest.java | 168 +++++++++++++++++++++
 .../pinot/server/api/AdminApiApplication.java      |   4 +-
 .../resources/ReingestionConsumptionTimeout.java   |  87 +++++++++++
 .../server/api/resources/ReingestionResource.java  | 112 +++++++++-----
 .../server/starter/helix/BaseServerStarter.java    |   9 +-
 .../apache/pinot/server/api/AccessControlTest.java |   3 +-
 .../apache/pinot/server/api/BaseResourceTest.java  |   3 +-
 .../ReingestionConsumptionTimeoutTest.java         | 128 ++++++++++++++++
 .../api/resources/ReingestionResourceApiTest.java  | 155 +++++++++++++++++++
 .../api/resources/ReingestionResourceTest.java     |  68 +++++++++
 .../apache/pinot/spi/utils/CommonConstants.java    |  12 ++
 12 files changed, 720 insertions(+), 49 deletions(-)

diff --git 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/writer/StatelessRealtimeSegmentWriter.java
 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/writer/StatelessRealtimeSegmentWriter.java
index 21961573264..d43975dddac 100644
--- 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/writer/StatelessRealtimeSegmentWriter.java
+++ 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/writer/StatelessRealtimeSegmentWriter.java
@@ -19,6 +19,7 @@
 package org.apache.pinot.segment.local.realtime.writer;
 
 import com.google.common.base.Preconditions;
+import com.google.common.util.concurrent.Uninterruptibles;
 import java.io.Closeable;
 import java.io.File;
 import java.time.Duration;
@@ -114,6 +115,8 @@ public class StatelessRealtimeSegmentWriter implements 
Closeable {
   private final TransformPipeline _transformPipeline;
   private volatile boolean _isSuccess = false;
   private volatile Throwable _consumptionException;
+  // Set by stopConsumption() to end the consumption at the next batch, even 
if the stream ignores the interrupt
+  private volatile boolean _shouldStop;
 
   public StatelessRealtimeSegmentWriter(SegmentZKMetadata segmentZKMetadata, 
IndexLoadingConfig indexLoadingConfig,
       @Nullable Semaphore segBuildSemaphore)
@@ -259,6 +262,14 @@ public class StatelessRealtimeSegmentWriter implements 
Closeable {
         _logger.info("Created new consumer thread {} for {}", _consumerThread, 
this);
         _currentOffset = _startOffset;
         while (_currentOffset.compareTo(_endOffset) < 0) {
+          if (_shouldStop) {
+            _consumptionException = new IllegalStateException(
+                "Consumption stopped at offset: " + _currentOffset + " before 
reaching end offset: " + _endOffset);
+            _logger.info("Stopped consumption at offset: {} before reaching 
end offset: {}", _currentOffset,
+                _endOffset);
+            return;
+          }
+
           // Fetch messages
           MessageBatch messageBatch = _consumer.fetchMessages(_currentOffset, 
_fetchTimeoutMs);
 
@@ -306,13 +317,11 @@ public class StatelessRealtimeSegmentWriter implements 
Closeable {
   }
 
   public void stopConsumption() {
+    _shouldStop = true;
     if (_consumerThread.isAlive()) {
       _consumerThread.interrupt();
-      try {
-        _consumerThread.join();
-      } catch (InterruptedException e) {
-        _logger.warn("Interrupted while waiting for consumer thread to 
finish");
-      }
+      // Wait even if interrupted, so that the segment is not destroyed while 
the consumer thread is still indexing
+      Uninterruptibles.joinUninterruptibly(_consumerThread);
     }
   }
 
@@ -324,6 +333,7 @@ public class StatelessRealtimeSegmentWriter implements 
Closeable {
     return _isSuccess;
   }
 
+  @Nullable
   public Throwable getConsumptionException() {
     return _consumptionException;
   }
diff --git 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/realtime/writer/StatelessRealtimeSegmentWriterTest.java
 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/realtime/writer/StatelessRealtimeSegmentWriterTest.java
new file mode 100644
index 00000000000..9b3101bd860
--- /dev/null
+++ 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/realtime/writer/StatelessRealtimeSegmentWriterTest.java
@@ -0,0 +1,168 @@
+/**
+ * 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.pinot.segment.local.realtime.writer;
+
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import javax.annotation.Nullable;
+import org.apache.pinot.common.metadata.segment.SegmentZKMetadata;
+import org.apache.pinot.common.utils.LLCSegmentName;
+import org.apache.pinot.segment.local.segment.index.loader.IndexLoadingConfig;
+import org.apache.pinot.spi.config.table.TableConfig;
+import org.apache.pinot.spi.config.table.TableType;
+import org.apache.pinot.spi.data.FieldSpec.DataType;
+import org.apache.pinot.spi.data.Schema;
+import org.apache.pinot.spi.data.readers.GenericRow;
+import org.apache.pinot.spi.stream.MessageBatch;
+import org.apache.pinot.spi.stream.PartitionGroupConsumer;
+import org.apache.pinot.spi.stream.PartitionGroupConsumptionStatus;
+import org.apache.pinot.spi.stream.StreamConfigProperties;
+import org.apache.pinot.spi.stream.StreamConsumerFactory;
+import org.apache.pinot.spi.stream.StreamMessage;
+import org.apache.pinot.spi.stream.StreamMessageDecoder;
+import org.apache.pinot.spi.stream.StreamMetadataProvider;
+import org.apache.pinot.spi.stream.StreamPartitionMsgOffset;
+import org.apache.pinot.spi.utils.builder.TableConfigBuilder;
+import org.testng.annotations.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+
+/// Tests how [StatelessRealtimeSegmentWriter] stops consuming.
+public class StatelessRealtimeSegmentWriterTest {
+  private static final String RAW_TABLE_NAME = "testTable";
+  private static final String STREAM_TYPE = "test";
+  // Counted down when the stream is fetched, so that consumption can be 
stopped while it is in progress
+  private static final CountDownLatch FETCHED = new CountDownLatch(1);
+
+  @Test(timeOut = 30_000)
+  public void testStopConsumptionWithStreamIgnoringInterrupts()
+      throws Exception {
+    Schema schema = new Schema.SchemaBuilder().setSchemaName(RAW_TABLE_NAME)
+        .addSingleValueDimension("col", DataType.STRING)
+        .build();
+    TableConfig tableConfig = new 
TableConfigBuilder(TableType.REALTIME).setTableName(RAW_TABLE_NAME)
+        .setStreamConfigs(Map.of(StreamConfigProperties.STREAM_TYPE, 
STREAM_TYPE,
+            StreamConfigProperties.constructStreamProperty(STREAM_TYPE, 
StreamConfigProperties.STREAM_TOPIC_NAME),
+            "testTopic",
+            StreamConfigProperties.constructStreamProperty(STREAM_TYPE,
+                StreamConfigProperties.STREAM_CONSUMER_FACTORY_CLASS), 
InterruptIgnoringConsumerFactory.class.getName(),
+            StreamConfigProperties.constructStreamProperty(STREAM_TYPE, 
StreamConfigProperties.STREAM_DECODER_CLASS),
+            NoOpDecoder.class.getName()))
+        .build();
+    SegmentZKMetadata segmentZKMetadata =
+        new SegmentZKMetadata(new LLCSegmentName(RAW_TABLE_NAME, 0, 0, 
System.currentTimeMillis()).getSegmentName());
+    // The stream never reaches the end offset
+    segmentZKMetadata.setStartOffset("0");
+    segmentZKMetadata.setEndOffset("100");
+
+    try (StatelessRealtimeSegmentWriter writer = new 
StatelessRealtimeSegmentWriter(segmentZKMetadata,
+        new IndexLoadingConfig(tableConfig, schema), null)) {
+      writer.startConsumption();
+      assertThat(FETCHED.await(10, TimeUnit.SECONDS)).isTrue();
+      writer.stopConsumption();
+      assertThat(writer.isSuccess()).isFalse();
+      
assertThat(writer.getConsumptionException()).hasMessageStartingWith("Consumption
 stopped at offset");
+    }
+  }
+
+  /// Stream that never returns a message and keeps polling when interrupted.
+  public static class InterruptIgnoringConsumerFactory extends 
StreamConsumerFactory {
+    @Nullable
+    @Override
+    public StreamMetadataProvider createPartitionMetadataProvider(String 
clientId, int partition) {
+      return null;
+    }
+
+    @Nullable
+    @Override
+    public StreamMetadataProvider createStreamMetadataProvider(String 
clientId) {
+      return null;
+    }
+
+    @Override
+    public PartitionGroupConsumer createPartitionGroupConsumer(String clientId,
+        PartitionGroupConsumptionStatus partitionGroupConsumptionStatus) {
+      return new PartitionGroupConsumer() {
+        @Override
+        public MessageBatch<byte[]> fetchMessages(StreamPartitionMsgOffset 
startOffset, int timeoutMs) {
+          FETCHED.countDown();
+          try {
+            Thread.sleep(10);
+          } catch (InterruptedException e) {
+            // Ignore the interrupt like a stream plugin that retries 
internally
+          }
+          return new EmptyMessageBatch(startOffset);
+        }
+
+        @Override
+        public void close() {
+        }
+      };
+    }
+  }
+
+  private static class EmptyMessageBatch implements MessageBatch<byte[]> {
+    private final StreamPartitionMsgOffset _nextOffset;
+
+    EmptyMessageBatch(StreamPartitionMsgOffset nextOffset) {
+      _nextOffset = nextOffset;
+    }
+
+    @Override
+    public int getMessageCount() {
+      return 0;
+    }
+
+    @Override
+    public StreamMessage<byte[]> getStreamMessage(int index) {
+      throw new IndexOutOfBoundsException(index);
+    }
+
+    @Override
+    public StreamPartitionMsgOffset getOffsetOfNextBatch() {
+      return _nextOffset;
+    }
+
+    @Override
+    public long getSizeInBytes() {
+      return 0;
+    }
+  }
+
+  public static class NoOpDecoder implements StreamMessageDecoder<byte[]> {
+    @Override
+    public void init(Map<String, String> props, Set<String> fieldsToRead, 
String topicName) {
+    }
+
+    @Nullable
+    @Override
+    public GenericRow decode(byte[] payload, GenericRow destination) {
+      return null;
+    }
+
+    @Nullable
+    @Override
+    public GenericRow decode(byte[] payload, int offset, int length, 
GenericRow destination) {
+      return null;
+    }
+  }
+}
diff --git 
a/pinot-server/src/main/java/org/apache/pinot/server/api/AdminApiApplication.java
 
b/pinot-server/src/main/java/org/apache/pinot/server/api/AdminApiApplication.java
index ba33d84aa47..21188b50606 100644
--- 
a/pinot-server/src/main/java/org/apache/pinot/server/api/AdminApiApplication.java
+++ 
b/pinot-server/src/main/java/org/apache/pinot/server/api/AdminApiApplication.java
@@ -37,6 +37,7 @@ import org.apache.pinot.core.transport.ListenerConfig;
 import org.apache.pinot.core.util.ListenerConfigUtil;
 import org.apache.pinot.segment.local.utils.ServerReloadJobStatusCache;
 import org.apache.pinot.server.access.AccessControlFactory;
+import org.apache.pinot.server.api.resources.ReingestionConsumptionTimeout;
 import org.apache.pinot.server.starter.ServerInstance;
 import org.apache.pinot.spi.env.PinotConfiguration;
 import org.apache.pinot.spi.utils.CommonConstants;
@@ -64,7 +65,7 @@ public class AdminApiApplication extends ResourceConfig {
 
 
   public AdminApiApplication(ServerInstance instance, AccessControlFactory 
accessControlFactory,
-      ServerReloadJobStatusCache reloadJobStatusCache,
+      ServerReloadJobStatusCache reloadJobStatusCache, 
ReingestionConsumptionTimeout reingestionConsumptionTimeout,
       PinotConfiguration serverConf) {
     _serverInstance = instance;
     _accessControlFactory = accessControlFactory;
@@ -84,6 +85,7 @@ public class AdminApiApplication extends ResourceConfig {
         bind(_serverInstance.getServerMetrics()).to(ServerMetrics.class);
         bind(_accessControlFactory).to(AccessControlFactory.class);
         bind(reloadJobStatusCache).to(ServerReloadJobStatusCache.class);
+        
bind(reingestionConsumptionTimeout).to(ReingestionConsumptionTimeout.class);
 
         
bind(serverConf.getProperty(CommonConstants.Server.CONFIG_OF_INSTANCE_ID)).named(SERVER_INSTANCE_ID);
         String loggerRootDir = 
serverConf.getProperty(CommonConstants.Server.CONFIG_OF_LOGGER_ROOT_DIR);
diff --git 
a/pinot-server/src/main/java/org/apache/pinot/server/api/resources/ReingestionConsumptionTimeout.java
 
b/pinot-server/src/main/java/org/apache/pinot/server/api/resources/ReingestionConsumptionTimeout.java
new file mode 100644
index 00000000000..aacfbb36ae4
--- /dev/null
+++ 
b/pinot-server/src/main/java/org/apache/pinot/server/api/resources/ReingestionConsumptionTimeout.java
@@ -0,0 +1,87 @@
+/**
+ * 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.pinot.server.api.resources;
+
+import com.google.common.primitives.Longs;
+import java.util.Map;
+import java.util.Set;
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.ThreadSafe;
+import org.apache.pinot.spi.config.provider.PinotClusterConfigChangeListener;
+import org.apache.pinot.spi.env.PinotConfiguration;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import static 
org.apache.pinot.spi.utils.CommonConstants.Server.CONFIG_OF_REINGESTION_CONSUMPTION_TIMEOUT_MS;
+import static 
org.apache.pinot.spi.utils.CommonConstants.Server.DEFAULT_REINGESTION_CONSUMPTION_TIMEOUT_MS;
+
+
+/// Holds the consumption timeout of the pauseless segment re-ingestion jobs 
run by [ReingestionResource]. The cluster
+/// config takes precedence over the server config, which takes precedence 
over the default. Cluster config changes are
+/// applied as they arrive. A non-numeric or non-positive value is ignored 
with a warning when the config is loaded or
+/// changed.
+///
+/// Thread-safe: cluster config changes are delivered one at a time, and the 
latest value is visible to all readers.
+@ThreadSafe
+public class ReingestionConsumptionTimeout implements 
PinotClusterConfigChangeListener {
+  private static final Logger LOGGER = 
LoggerFactory.getLogger(ReingestionConsumptionTimeout.class);
+
+  // Timeout from the server config or the default, used when the cluster 
config is absent or invalid
+  private final long _serverTimeoutMs;
+  private volatile long _timeoutMs;
+
+  public ReingestionConsumptionTimeout(PinotConfiguration serverConf) {
+    Long serverTimeoutMs =
+        
parseTimeoutMs(serverConf.getProperty(CONFIG_OF_REINGESTION_CONSUMPTION_TIMEOUT_MS),
 "server");
+    _serverTimeoutMs = serverTimeoutMs != null ? serverTimeoutMs : 
DEFAULT_REINGESTION_CONSUMPTION_TIMEOUT_MS;
+    _timeoutMs = _serverTimeoutMs;
+    LOGGER.info("Initialized re-ingestion consumption timeout to: {}ms", 
_timeoutMs);
+  }
+
+  public long getTimeoutMs() {
+    return _timeoutMs;
+  }
+
+  @Override
+  public void onChange(Set<String> changedConfigs, Map<String, String> 
clusterConfigs) {
+    if 
(!changedConfigs.contains(CONFIG_OF_REINGESTION_CONSUMPTION_TIMEOUT_MS)) {
+      return;
+    }
+    Long clusterTimeoutMs = 
parseTimeoutMs(clusterConfigs.get(CONFIG_OF_REINGESTION_CONSUMPTION_TIMEOUT_MS),
 "cluster");
+    long oldTimeoutMs = _timeoutMs;
+    _timeoutMs = clusterTimeoutMs != null ? clusterTimeoutMs : 
_serverTimeoutMs;
+    if (_timeoutMs != oldTimeoutMs) {
+      LOGGER.info("Updated re-ingestion consumption timeout from: {}ms to: 
{}ms", oldTimeoutMs, _timeoutMs);
+    }
+  }
+
+  @Nullable
+  private static Long parseTimeoutMs(@Nullable String value, String 
configSource) {
+    if (value == null) {
+      return null;
+    }
+    Long timeoutMs = Longs.tryParse(value.trim());
+    if (timeoutMs == null || timeoutMs <= 0) {
+      LOGGER.warn("Ignoring invalid {} config: {}={}, expecting a positive 
number of milliseconds", configSource,
+          CONFIG_OF_REINGESTION_CONSUMPTION_TIMEOUT_MS, value);
+      return null;
+    }
+    return timeoutMs;
+  }
+}
diff --git 
a/pinot-server/src/main/java/org/apache/pinot/server/api/resources/ReingestionResource.java
 
b/pinot-server/src/main/java/org/apache/pinot/server/api/resources/ReingestionResource.java
index f4f08c67054..6264f4c4156 100644
--- 
a/pinot-server/src/main/java/org/apache/pinot/server/api/resources/ReingestionResource.java
+++ 
b/pinot-server/src/main/java/org/apache/pinot/server/api/resources/ReingestionResource.java
@@ -20,7 +20,8 @@ package org.apache.pinot.server.api.resources;
 
 import com.fasterxml.jackson.annotation.JsonCreator;
 import com.fasterxml.jackson.annotation.JsonProperty;
-import com.google.common.base.Function;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.math.LongMath;
 import com.google.common.util.concurrent.ThreadFactoryBuilder;
 import io.swagger.annotations.Api;
 import io.swagger.annotations.ApiKeyAuthDefinition;
@@ -38,10 +39,14 @@ import java.util.UUID;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
+import java.util.concurrent.RejectedExecutionException;
 import java.util.concurrent.Semaphore;
 import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.BooleanSupplier;
 import javax.annotation.Nullable;
+import javax.annotation.PreDestroy;
 import javax.inject.Inject;
+import javax.inject.Singleton;
 import javax.ws.rs.Encoded;
 import javax.ws.rs.GET;
 import javax.ws.rs.POST;
@@ -79,12 +84,13 @@ import static 
org.apache.pinot.spi.utils.CommonConstants.SWAGGER_AUTHORIZATION_K
         description = "Database context passed through http header. If no 
context is provided 'default' database "
             + "context will be considered.")}))
 @Path("/")
+@Singleton
 public class ReingestionResource {
   private static final Logger LOGGER = 
LoggerFactory.getLogger(ReingestionResource.class);
 
   // TODO: Make them configurable
-  private static final int MAX_PARALLEL_REINGESTIONS = 
Math.max(Runtime.getRuntime().availableProcessors() / 2, 1);
-  public static final long CONSUMPTION_END_TIMEOUT_MS = 
Duration.ofMinutes(30).toMillis();
+  @VisibleForTesting
+  static final int MAX_PARALLEL_REINGESTIONS = 
Math.max(Runtime.getRuntime().availableProcessors() / 2, 1);
   public static final long CHECK_INTERVAL_MS = 
Duration.ofSeconds(5).toMillis();
 
   // Tracks if a particular segment is currently being re-ingested
@@ -92,7 +98,7 @@ public class ReingestionResource {
 
   // Executor for asynchronous re-ingestion
   private final ExecutorService _reingestionExecutor = 
Executors.newFixedThreadPool(MAX_PARALLEL_REINGESTIONS,
-      new 
ThreadFactoryBuilder().setNameFormat("reingestion-worker-%d").build());
+      new 
ThreadFactoryBuilder().setNameFormat("reingestion-worker-%d").setDaemon(true).build());
 
   // Keep track of jobs by jobId => job info
   private final ConcurrentHashMap<String, ReingestionJob> _runningJobs = new 
ConcurrentHashMap<>();
@@ -100,6 +106,9 @@ public class ReingestionResource {
   @Inject
   private ServerInstance _serverInstance;
 
+  @Inject
+  private ReingestionConsumptionTimeout _consumptionTimeout;
+
   /// Simple data class to hold job details.
   public static class ReingestionJob {
     private final String _jobId;
@@ -130,9 +139,10 @@ public class ReingestionResource {
   @GET
   @Path("/reingestSegment/jobs")
   @Produces(MediaType.APPLICATION_JSON)
-  @ApiOperation("Get all running re-ingestion jobs along with job IDs")
+  @ApiOperation("Get all running re-ingestion jobs, including the ones waiting 
for a re-ingestion thread, along with "
+      + "job IDs")
   public Response getAllRunningReingestionJobs() {
-    // Filter only the jobs still marked as running
+    // Filter only the jobs still marked as running, including the ones 
waiting for a re-ingestion thread
     List<ReingestionJob> runningJobs = new ArrayList<>(_runningJobs.values());
     return Response.ok(runningJobs).build();
   }
@@ -202,25 +212,43 @@ public class ReingestionResource {
     String jobId = UUID.randomUUID().toString();
     ReingestionJob job = new ReingestionJob(jobId, segmentName);
 
+    // Track the job before submitting it, so that it is listed while waiting 
for a re-ingestion thread
+    _runningJobs.put(jobId, job);
+
     // Kick off the actual work asynchronously
-    _reingestionExecutor.submit(() -> {
-      try {
-        _runningJobs.put(jobId, job);
-        doReingestSegment(realtimeTableName, segmentZKMetadata, 
indexLoadingConfig,
-            tableDataManager.getSegmentBuildSemaphore());
-      } catch (Exception e) {
-        LOGGER.error("Error during async re-ingestion for job {} 
(segment={})", jobId, segmentName, e);
-        _serverInstance.getServerMetrics()
-            .addMeteredTableValue(realtimeTableName, 
ServerMeter.SEGMENT_REINGESTION_FAILURE, 1);
-      } finally {
-        _runningJobs.remove(jobId);
-        _reingestingSegments.remove(segmentName);
-      }
-    });
+    try {
+      _reingestionExecutor.submit(() -> {
+        try {
+          doReingestSegment(realtimeTableName, segmentZKMetadata, 
indexLoadingConfig,
+              tableDataManager.getSegmentBuildSemaphore());
+        } catch (Exception e) {
+          LOGGER.error("Error during async re-ingestion for job {} 
(segment={})", jobId, segmentName, e);
+          _serverInstance.getServerMetrics()
+              .addMeteredTableValue(realtimeTableName, 
ServerMeter.SEGMENT_REINGESTION_FAILURE, 1);
+        } finally {
+          _runningJobs.remove(jobId);
+          _reingestingSegments.remove(segmentName);
+        }
+      });
+    } catch (RejectedExecutionException e) {
+      // The executor is shut down when the admin API stops
+      _runningJobs.remove(jobId);
+      _reingestingSegments.remove(segmentName);
+      throw new WebApplicationException("Server is shutting down, cannot 
re-ingest segment: " + segmentName,
+          Response.Status.SERVICE_UNAVAILABLE);
+    }
 
     return Response.ok(job).build();
   }
 
+  /// Stops the re-ingestion jobs when the admin API stops. Running jobs are 
interrupted, and fail and clean up their
+  /// segment. Jobs waiting for a re-ingestion thread are dropped.
+  @PreDestroy
+  public void shutDown() {
+    int numDroppedJobs = _reingestionExecutor.shutdownNow().size();
+    LOGGER.info("Stopped re-ingestion executor, dropped {} jobs waiting for a 
re-ingestion thread", numDroppedJobs);
+  }
+
   /// The actual re-ingestion logic, moved into a separate method for clarity.
   /// This is essentially the old synchronous logic you had in reingestSegment.
   private void doReingestSegment(String realtimeTableName, SegmentZKMetadata 
segmentZKMetadata,
@@ -229,8 +257,13 @@ public class ReingestionResource {
     String segmentName = segmentZKMetadata.getSegmentName();
     try (StatelessRealtimeSegmentWriter writer = new 
StatelessRealtimeSegmentWriter(segmentZKMetadata,
         indexLoadingConfig, segmentBuildSemaphore)) {
+      // Read when the consumption starts, so that a job waiting for a 
re-ingestion thread uses the latest timeout
+      long consumptionTimeoutMs = _consumptionTimeout.getTimeoutMs();
+      LOGGER.info("Starting consumption to re-ingest segment: {} with timeout: 
{}ms", segmentName,
+          consumptionTimeoutMs);
       writer.startConsumption();
-      waitForCondition((Void) -> writer.isDoneConsuming(), CHECK_INTERVAL_MS, 
CONSUMPTION_END_TIMEOUT_MS, 0);
+      waitForCondition(writer::isDoneConsuming, "consumption of segment: " + 
segmentName, CHECK_INTERVAL_MS,
+          consumptionTimeoutMs);
       writer.stopConsumption();
 
       if (!writer.isSuccess()) {
@@ -247,32 +280,31 @@ public class ReingestionResource {
     }
   }
 
-  private void waitForCondition(
-      Function<Void, Boolean> condition, long checkIntervalMs, long timeoutMs, 
long gracePeriodMs) {
-    long endTime = System.currentTimeMillis() + timeoutMs;
+  @VisibleForTesting
+  static void waitForCondition(BooleanSupplier condition, String description, 
long checkIntervalMs, long timeoutMs) {
+    // Saturate to avoid overflow, since the timeout is configurable
+    long endTime = LongMath.saturatedAdd(System.currentTimeMillis(), 
timeoutMs);
 
-    // Adding grace period before starting the condition checks
-    if (gracePeriodMs > 0) {
-      LOGGER.info("Waiting for a grace period of {} ms before starting 
condition checks", gracePeriodMs);
+    while (true) {
       try {
-        Thread.sleep(gracePeriodMs);
-      } catch (InterruptedException e) {
-        throw new RuntimeException("Interrupted during grace period wait", e);
-      }
-    }
-
-    while (System.currentTimeMillis() < endTime) {
-      try {
-        if (Boolean.TRUE.equals(condition.apply(null))) {
-          LOGGER.info("Condition satisfied: {}", condition);
+        if (condition.getAsBoolean()) {
+          LOGGER.info("Finished waiting for {}", description);
           return;
         }
-        Thread.sleep(checkIntervalMs);
+        long remainingMs = endTime - System.currentTimeMillis();
+        if (remainingMs <= 0) {
+          break;
+        }
+        // Do not sleep past the deadline, so that the condition is checked 
once more at the deadline before timing out
+        Thread.sleep(Math.min(checkIntervalMs, remainingMs));
+      } catch (InterruptedException e) {
+        // Do not restore the interrupt flag, since the interrupt is preserved 
as the cause of the converted exception
+        throw new RuntimeException("Interrupted while waiting for " + 
description, e);
       } catch (Exception e) {
-        throw new RuntimeException("Caught exception while checking the 
condition", e);
+        throw new RuntimeException("Caught exception while waiting for " + 
description, e);
       }
     }
 
-    throw new RuntimeException("Timeout waiting for condition: " + condition);
+    throw new RuntimeException("Timed out after " + timeoutMs + "ms waiting 
for " + description);
   }
 }
diff --git 
a/pinot-server/src/main/java/org/apache/pinot/server/starter/helix/BaseServerStarter.java
 
b/pinot-server/src/main/java/org/apache/pinot/server/starter/helix/BaseServerStarter.java
index d1abf4f96b2..7d90df3ac06 100644
--- 
a/pinot-server/src/main/java/org/apache/pinot/server/starter/helix/BaseServerStarter.java
+++ 
b/pinot-server/src/main/java/org/apache/pinot/server/starter/helix/BaseServerStarter.java
@@ -111,6 +111,7 @@ import org.apache.pinot.segment.spi.memory.PinotDataBuffer;
 import org.apache.pinot.segment.spi.memory.unsafe.MmapMemoryConfig;
 import org.apache.pinot.server.access.AccessControlFactory;
 import org.apache.pinot.server.api.AdminApiApplication;
+import org.apache.pinot.server.api.resources.ReingestionConsumptionTimeout;
 import org.apache.pinot.server.conf.ServerConf;
 import org.apache.pinot.server.realtime.ControllerLeaderLocator;
 import org.apache.pinot.server.realtime.ServerSegmentCompletionProtocolHandler;
@@ -186,6 +187,7 @@ public abstract class BaseServerStarter implements 
ServiceStartable {
   protected volatile boolean _isServerReadyToServeQueries = false;
   protected ScheduledExecutorService _helixMessageCountScheduler;
   protected ServerReloadJobStatusCache _reloadJobStatusCache;
+  protected ReingestionConsumptionTimeout _reingestionConsumptionTimeout;
   // Override this to provide custom thread pool for Helix state transitions. 
Null means using Helix's default
   @Nullable
   protected StateTransitionThreadPoolManager _transitionThreadPoolManager;
@@ -678,6 +680,10 @@ public abstract class BaseServerStarter implements 
ServiceStartable {
     
_clusterConfigChangeHandler.registerClusterConfigChangeListener(_reloadJobStatusCache);
     LOGGER.info("Registered ServerReloadJobStatusCache as cluster config 
listener");
 
+    // Register re-ingestion consumption timeout as cluster config listener 
for dynamic config updates
+    _reingestionConsumptionTimeout = new 
ReingestionConsumptionTimeout(_serverConf);
+    
_clusterConfigChangeHandler.registerClusterConfigChangeListener(_reingestionConsumptionTimeout);
+
     // install default SSL context if necessary (even if not force-enabled 
everywhere)
     TlsConfig tlsDefaults = TlsUtils.extractTlsConfig(_serverConf, 
Server.SERVER_TLS_PREFIX);
     if (StringUtils.isNotBlank(tlsDefaults.getKeyStorePath()) || 
StringUtils.isNotBlank(
@@ -1280,7 +1286,8 @@ public abstract class BaseServerStarter implements 
ServiceStartable {
   }
 
   protected AdminApiApplication createServerAdminApp() {
-    return new AdminApiApplication(_serverInstance, _accessControlFactory, 
_reloadJobStatusCache, _serverConf);
+    return new AdminApiApplication(_serverInstance, _accessControlFactory, 
_reloadJobStatusCache,
+        _reingestionConsumptionTimeout, _serverConf);
   }
 
   /// Creates the [SegmentMessageHandlerFactory] used to handle user-defined 
Helix messages for segments.
diff --git 
a/pinot-server/src/test/java/org/apache/pinot/server/api/AccessControlTest.java 
b/pinot-server/src/test/java/org/apache/pinot/server/api/AccessControlTest.java
index c87da3866ef..953d21b6c01 100644
--- 
a/pinot-server/src/test/java/org/apache/pinot/server/api/AccessControlTest.java
+++ 
b/pinot-server/src/test/java/org/apache/pinot/server/api/AccessControlTest.java
@@ -45,6 +45,7 @@ import org.apache.pinot.server.access.AccessControl;
 import org.apache.pinot.server.access.BasicAuthAccessFactory;
 import org.apache.pinot.server.access.GrpcRequesterIdentity;
 import org.apache.pinot.server.access.HttpRequesterIdentity;
+import org.apache.pinot.server.api.resources.ReingestionConsumptionTimeout;
 import org.apache.pinot.server.starter.ServerInstance;
 import org.apache.pinot.spi.auth.server.RequesterIdentity;
 import org.apache.pinot.spi.env.PinotConfiguration;
@@ -118,7 +119,7 @@ public class AccessControlTest {
     ServiceStatus.setServiceStatusCallback(_instanceId, serviceStatusCallback);
 
     _adminApiApplication = new AdminApiApplication(serverInstance, 
basicAuthAccessFactory,
-        mock(ServerReloadJobStatusCache.class),
+        mock(ServerReloadJobStatusCache.class), new 
ReingestionConsumptionTimeout(serverConf),
         serverConf);
 
     int adminApiApplicationPort = getAvailablePort();
diff --git 
a/pinot-server/src/test/java/org/apache/pinot/server/api/BaseResourceTest.java 
b/pinot-server/src/test/java/org/apache/pinot/server/api/BaseResourceTest.java
index 200fcce5cb0..e1387b8fc8a 100644
--- 
a/pinot-server/src/test/java/org/apache/pinot/server/api/BaseResourceTest.java
+++ 
b/pinot-server/src/test/java/org/apache/pinot/server/api/BaseResourceTest.java
@@ -51,6 +51,7 @@ import org.apache.pinot.segment.spi.ImmutableSegment;
 import org.apache.pinot.segment.spi.creator.SegmentGeneratorConfig;
 import org.apache.pinot.segment.spi.creator.SegmentIndexCreationDriver;
 import org.apache.pinot.server.access.AllowAllAccessFactory;
+import org.apache.pinot.server.api.resources.ReingestionConsumptionTimeout;
 import org.apache.pinot.server.starter.ServerInstance;
 import org.apache.pinot.spi.config.instance.InstanceDataManagerConfig;
 import org.apache.pinot.spi.config.table.TableConfig;
@@ -161,7 +162,7 @@ public abstract class BaseResourceTest {
     serverConf.setProperty(CommonConstants.Server.CONFIG_OF_INSTANCE_ID, 
_instanceId);
     configureServerConf(serverConf);
     _adminApiApplication = new AdminApiApplication(_serverInstance, new 
AllowAllAccessFactory(),
-        mock(ServerReloadJobStatusCache.class),
+        mock(ServerReloadJobStatusCache.class), new 
ReingestionConsumptionTimeout(serverConf),
         serverConf);
     _adminApiApplication.start(List.of(
         new ListenerConfig(CommonConstants.HTTP_PROTOCOL, "0.0.0.0", 0,
diff --git 
a/pinot-server/src/test/java/org/apache/pinot/server/api/resources/ReingestionConsumptionTimeoutTest.java
 
b/pinot-server/src/test/java/org/apache/pinot/server/api/resources/ReingestionConsumptionTimeoutTest.java
new file mode 100644
index 00000000000..2c04697c02f
--- /dev/null
+++ 
b/pinot-server/src/test/java/org/apache/pinot/server/api/resources/ReingestionConsumptionTimeoutTest.java
@@ -0,0 +1,128 @@
+/**
+ * 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.pinot.server.api.resources;
+
+import java.time.Duration;
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.helix.model.ClusterConfig;
+import org.apache.pinot.common.config.DefaultClusterConfigChangeHandler;
+import org.apache.pinot.spi.env.PinotConfiguration;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+import static 
org.apache.pinot.spi.utils.CommonConstants.Server.CONFIG_OF_REINGESTION_CONSUMPTION_TIMEOUT_MS;
+import static org.assertj.core.api.Assertions.assertThat;
+
+
+/// Tests that [ReingestionConsumptionTimeout] resolves the timeout from the 
cluster config, the server config and the
+/// default, and follows cluster config changes.
+public class ReingestionConsumptionTimeoutTest {
+  private static final PinotConfiguration EMPTY_SERVER_CONF = new 
PinotConfiguration();
+  private static final PinotConfiguration SERVER_CONF =
+      new 
PinotConfiguration(Map.of(CONFIG_OF_REINGESTION_CONSUMPTION_TIMEOUT_MS, 
"3600000"));
+  private static final long DEFAULT_TIMEOUT_MS = 
Duration.ofMinutes(30).toMillis();
+
+  @Test
+  public void testDefaultsTo30Minutes() {
+    assertThat(createTimeout(EMPTY_SERVER_CONF, 
Map.of()).getTimeoutMs()).isEqualTo(DEFAULT_TIMEOUT_MS);
+  }
+
+  @Test
+  public void testServerConfig() {
+    assertThat(createTimeout(SERVER_CONF, 
Map.of()).getTimeoutMs()).isEqualTo(3_600_000L);
+  }
+
+  @Test
+  public void testClusterConfigOverridesServerConfig() {
+    Map<String, String> clusterConfigs = 
Map.of(CONFIG_OF_REINGESTION_CONSUMPTION_TIMEOUT_MS, "7200000");
+    assertThat(createTimeout(SERVER_CONF, 
clusterConfigs).getTimeoutMs()).isEqualTo(7_200_000L);
+    assertThat(createTimeout(EMPTY_SERVER_CONF, 
clusterConfigs).getTimeoutMs()).isEqualTo(7_200_000L);
+  }
+
+  @DataProvider
+  public Object[][] validTimeouts() {
+    return new Object[][]{{"1", 1L}, {" 7200000 ", 7_200_000L}, 
{String.valueOf(Long.MAX_VALUE), Long.MAX_VALUE}};
+  }
+
+  @Test(dataProvider = "validTimeouts")
+  public void testValidTimeouts(String timeout, long expectedTimeoutMs) {
+    Map<String, String> configs = 
Map.of(CONFIG_OF_REINGESTION_CONSUMPTION_TIMEOUT_MS, timeout);
+    assertThat(createTimeout(new PinotConfiguration(configs), 
Map.of()).getTimeoutMs()).isEqualTo(expectedTimeoutMs);
+    assertThat(createTimeout(SERVER_CONF, 
configs).getTimeoutMs()).isEqualTo(expectedTimeoutMs);
+  }
+
+  @DataProvider
+  public Object[][] invalidTimeouts() {
+    return new Object[][]{{"abc"}, {""}, {"0"}, {"-1"}, {"1.5"}, {"30m"}, 
{"9223372036854775808"}};
+  }
+
+  @Test(dataProvider = "invalidTimeouts")
+  public void testInvalidClusterConfigFallsBackToServerConfig(String 
invalidTimeout) {
+    Map<String, String> clusterConfigs = 
Map.of(CONFIG_OF_REINGESTION_CONSUMPTION_TIMEOUT_MS, invalidTimeout);
+    assertThat(createTimeout(SERVER_CONF, 
clusterConfigs).getTimeoutMs()).isEqualTo(3_600_000L);
+  }
+
+  @Test(dataProvider = "invalidTimeouts")
+  public void testInvalidServerConfigFallsBackToDefault(String invalidTimeout) 
{
+    PinotConfiguration serverConf =
+        new 
PinotConfiguration(Map.of(CONFIG_OF_REINGESTION_CONSUMPTION_TIMEOUT_MS, 
invalidTimeout));
+    assertThat(createTimeout(serverConf, 
Map.of()).getTimeoutMs()).isEqualTo(DEFAULT_TIMEOUT_MS);
+  }
+
+  @Test
+  public void testClusterConfigChangeIsPickedUpWithoutRestart() {
+    // Registered before the cluster configs are loaded, as on server startup
+    ReingestionConsumptionTimeout timeout = new 
ReingestionConsumptionTimeout(SERVER_CONF);
+    DefaultClusterConfigChangeHandler clusterConfigProvider = new 
DefaultClusterConfigChangeHandler();
+    clusterConfigProvider.registerClusterConfigChangeListener(timeout);
+    assertThat(timeout.getTimeoutMs()).isEqualTo(3_600_000L);
+
+    setClusterConfigs(clusterConfigProvider, 
Map.of(CONFIG_OF_REINGESTION_CONSUMPTION_TIMEOUT_MS, "10800000"));
+    assertThat(timeout.getTimeoutMs()).isEqualTo(10_800_000L);
+
+    // An invalid cluster config falls back to the server config
+    setClusterConfigs(clusterConfigProvider, 
Map.of(CONFIG_OF_REINGESTION_CONSUMPTION_TIMEOUT_MS, "30m"));
+    assertThat(timeout.getTimeoutMs()).isEqualTo(3_600_000L);
+
+    setClusterConfigs(clusterConfigProvider, 
Map.of(CONFIG_OF_REINGESTION_CONSUMPTION_TIMEOUT_MS, "14400000"));
+    assertThat(timeout.getTimeoutMs()).isEqualTo(14_400_000L);
+
+    // Removing the cluster config falls back to the server config
+    setClusterConfigs(clusterConfigProvider, Map.of());
+    assertThat(timeout.getTimeoutMs()).isEqualTo(3_600_000L);
+  }
+
+  /// Creates the timeout and registers it to a cluster config provider 
holding the given cluster configs.
+  private static ReingestionConsumptionTimeout 
createTimeout(PinotConfiguration serverConf,
+      Map<String, String> clusterConfigs) {
+    DefaultClusterConfigChangeHandler clusterConfigProvider = new 
DefaultClusterConfigChangeHandler();
+    setClusterConfigs(clusterConfigProvider, clusterConfigs);
+    ReingestionConsumptionTimeout timeout = new 
ReingestionConsumptionTimeout(serverConf);
+    clusterConfigProvider.registerClusterConfigChangeListener(timeout);
+    return timeout;
+  }
+
+  private static void setClusterConfigs(DefaultClusterConfigChangeHandler 
clusterConfigProvider,
+      Map<String, String> clusterConfigs) {
+    ClusterConfig clusterConfig = new ClusterConfig("testCluster");
+    clusterConfig.getRecord().setSimpleFields(new HashMap<>(clusterConfigs));
+    clusterConfigProvider.onClusterConfigChange(clusterConfig, null);
+  }
+}
diff --git 
a/pinot-server/src/test/java/org/apache/pinot/server/api/resources/ReingestionResourceApiTest.java
 
b/pinot-server/src/test/java/org/apache/pinot/server/api/resources/ReingestionResourceApiTest.java
new file mode 100644
index 00000000000..f4083caf56a
--- /dev/null
+++ 
b/pinot-server/src/test/java/org/apache/pinot/server/api/resources/ReingestionResourceApiTest.java
@@ -0,0 +1,155 @@
+/**
+ * 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.pinot.server.api.resources;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import javax.ws.rs.client.Entity;
+import javax.ws.rs.core.Response;
+import org.apache.pinot.common.metadata.segment.SegmentZKMetadata;
+import org.apache.pinot.common.utils.LLCSegmentName;
+import org.apache.pinot.core.data.manager.realtime.RealtimeTableDataManager;
+import org.apache.pinot.server.api.BaseResourceTest;
+import org.mockito.stubbing.Answer;
+import org.testng.annotations.Test;
+
+import static 
org.apache.pinot.server.api.resources.ReingestionResource.MAX_PARALLEL_REINGESTIONS;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+
+/// Tests that the [ReingestionResource] is served by the admin API with its 
injected dependencies, keeps track of the
+/// re-ingestion jobs across requests, and stops them when the admin API stops.
+public class ReingestionResourceApiTest extends BaseResourceTest {
+  private static final String REINGESTION_RAW_TABLE_NAME = "reingestionTable";
+  private static final String REINGESTION_REALTIME_TABLE_NAME = 
REINGESTION_RAW_TABLE_NAME + "_REALTIME";
+  // Makes the segment names unique across tests, since a segment is held 
until its previous job finishes
+  private static final AtomicInteger SEQUENCE_NUMBER = new AtomicInteger();
+
+  @Test
+  public void testResourceDependenciesAreInjected() {
+    // Any request to the resource fails if one of its injected dependencies 
is not bound
+    Response response = 
_webTarget.path("/reingestSegment/jobs").request().get(Response.class);
+    
assertThat(response.getStatus()).isEqualTo(Response.Status.OK.getStatusCode());
+  }
+
+  @Test
+  public void testRunningReingestionIsTrackedAcrossRequests()
+      throws Exception {
+    // Hold the job on the re-ingestion thread, where the segment build 
semaphore is fetched
+    CountDownLatch jobStarted = new CountDownLatch(1);
+    CountDownLatch releaseJob = new CountDownLatch(1);
+    addTableDataManager(invocation -> {
+      jobStarted.countDown();
+      releaseJob.await();
+      return null;
+    });
+
+    try {
+      String segmentName = segmentName(0);
+      
assertThat(postReingestion(segmentName)).isEqualTo(Response.Status.OK.getStatusCode());
+      assertThat(jobStarted.await(10, TimeUnit.SECONDS)).isTrue();
+
+      // Only visible to the next requests when they are served by the same 
resource instance
+      
assertThat(postReingestion(segmentName)).isEqualTo(Response.Status.CONFLICT.getStatusCode());
+      assertThat(getJobs()).contains(segmentName);
+    } finally {
+      releaseJob.countDown();
+      _tableDataManagerMap.remove(REINGESTION_REALTIME_TABLE_NAME);
+    }
+  }
+
+  @Test
+  public void testQueuedReingestionIsListed() {
+    // Hold every re-ingestion thread so that the last job waits in the queue
+    CountDownLatch releaseJobs = new CountDownLatch(1);
+    addTableDataManager(invocation -> {
+      releaseJobs.await();
+      return null;
+    });
+
+    try {
+      String queuedSegmentName = null;
+      for (int partitionId = 0; partitionId <= MAX_PARALLEL_REINGESTIONS; 
partitionId++) {
+        queuedSegmentName = segmentName(partitionId);
+        
assertThat(postReingestion(queuedSegmentName)).isEqualTo(Response.Status.OK.getStatusCode());
+      }
+      assertThat(getJobs()).contains(queuedSegmentName);
+    } finally {
+      releaseJobs.countDown();
+      _tableDataManagerMap.remove(REINGESTION_REALTIME_TABLE_NAME);
+    }
+  }
+
+  // Runs after the other tests since it stops the admin API
+  @Test(priority = 1)
+  public void testRunningReingestionIsStoppedOnShutdown()
+      throws Exception {
+    // Hold the job on the re-ingestion thread until it is interrupted
+    AtomicReference<Thread> reingestionThread = new AtomicReference<>();
+    CountDownLatch jobStarted = new CountDownLatch(1);
+    addTableDataManager(invocation -> {
+      reingestionThread.set(Thread.currentThread());
+      jobStarted.countDown();
+      new CountDownLatch(1).await();
+      return null;
+    });
+    
assertThat(postReingestion(segmentName(0))).isEqualTo(Response.Status.OK.getStatusCode());
+    assertThat(jobStarted.await(10, TimeUnit.SECONDS)).isTrue();
+
+    _adminApiApplication.stop();
+
+    reingestionThread.get().join(10_000);
+    assertThat(reingestionThread.get().isAlive()).isFalse();
+  }
+
+  /// Adds a table data manager whose segments are pending re-ingestion, and 
which answers the segment build semaphore
+  /// fetched on the re-ingestion thread with the given answer.
+  private void addTableDataManager(Answer<Semaphore> 
segmentBuildSemaphoreAnswer) {
+    RealtimeTableDataManager tableDataManager = 
mock(RealtimeTableDataManager.class);
+    when(tableDataManager.fetchZKMetadata(anyString())).thenAnswer(invocation 
-> {
+      SegmentZKMetadata segmentZKMetadata = new SegmentZKMetadata((String) 
invocation.getArgument(0));
+      segmentZKMetadata.setStartOffset("0");
+      segmentZKMetadata.setEndOffset("100");
+      return segmentZKMetadata;
+    });
+    
when(tableDataManager.getSegmentBuildSemaphore()).thenAnswer(segmentBuildSemaphoreAnswer);
+    _tableDataManagerMap.put(REINGESTION_REALTIME_TABLE_NAME, 
tableDataManager);
+  }
+
+  private static String segmentName(int partitionId) {
+    return new LLCSegmentName(REINGESTION_RAW_TABLE_NAME, partitionId, 
SEQUENCE_NUMBER.getAndIncrement(),
+        System.currentTimeMillis()).getSegmentName();
+  }
+
+  private int postReingestion(String segmentName) {
+    try (Response response = _webTarget.path("/reingestSegment/" + 
segmentName).request().post(Entity.json(""))) {
+      return response.getStatus();
+    }
+  }
+
+  private String getJobs() {
+    return 
_webTarget.path("/reingestSegment/jobs").request().get(String.class);
+  }
+}
diff --git 
a/pinot-server/src/test/java/org/apache/pinot/server/api/resources/ReingestionResourceTest.java
 
b/pinot-server/src/test/java/org/apache/pinot/server/api/resources/ReingestionResourceTest.java
new file mode 100644
index 00000000000..40d50b692a8
--- /dev/null
+++ 
b/pinot-server/src/test/java/org/apache/pinot/server/api/resources/ReingestionResourceTest.java
@@ -0,0 +1,68 @@
+/**
+ * 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.pinot.server.api.resources;
+
+import org.testng.annotations.Test;
+
+import static 
org.apache.pinot.server.api.resources.ReingestionResource.CHECK_INTERVAL_MS;
+import static 
org.apache.pinot.server.api.resources.ReingestionResource.waitForCondition;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+
+/// Tests how [ReingestionResource] waits for the re-ingestion consumption to 
complete.
+public class ReingestionResourceTest {
+  private static final String DESCRIPTION = "test condition";
+
+  @Test
+  public void testWaitForConditionWithMaxTimeoutDoesNotTimeOutEarly() {
+    // The condition is false on the first check, so the wait must keep 
checking instead of timing out
+    long completionTimeMs = System.currentTimeMillis() + 50;
+    waitForCondition(() -> System.currentTimeMillis() >= completionTimeMs, 
DESCRIPTION, 10, Long.MAX_VALUE);
+  }
+
+  @Test
+  public void 
testWaitForConditionDetectsCompletionWithTimeoutShorterThanCheckInterval() {
+    // The condition becomes true after the first check. It must be checked 
again at the deadline instead of timing out
+    // after sleeping a full check interval.
+    long completionTimeMs = System.currentTimeMillis() + 100;
+    waitForCondition(() -> System.currentTimeMillis() >= completionTimeMs, 
DESCRIPTION, CHECK_INTERVAL_MS, 4_000);
+  }
+
+  @Test
+  public void testWaitForConditionDoesNotSleepPastTimeout() {
+    long startTimeMs = System.currentTimeMillis();
+    assertThatThrownBy(() -> waitForCondition(() -> false, DESCRIPTION, 
CHECK_INTERVAL_MS, 100)).hasMessage(
+        "Timed out after 100ms waiting for test condition");
+    assertThat(System.currentTimeMillis() - 
startTimeMs).isLessThan(CHECK_INTERVAL_MS);
+  }
+
+  @Test
+  public void testWaitForConditionReportsInterrupt() {
+    Thread.currentThread().interrupt();
+    try {
+      assertThatThrownBy(() -> waitForCondition(() -> false, DESCRIPTION, 
CHECK_INTERVAL_MS, Long.MAX_VALUE))
+          .hasMessage("Interrupted while waiting for test condition")
+          .hasCauseInstanceOf(InterruptedException.class);
+    } finally {
+      // Clear the interrupt so that it does not leak into other tests
+      Thread.interrupted();
+    }
+  }
+}
diff --git 
a/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java 
b/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java
index ac0edbd60b3..77a089e0908 100644
--- a/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java
+++ b/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java
@@ -1822,6 +1822,18 @@ public class CommonConstants {
         "ingestion.oom.protection.gcIntervalMs";
     public static final long 
DEFAULT_SERVER_INGESTION_OOM_PROTECTION_GC_INTERVAL_MS = 30_000L;
 
+    /// Max time a pauseless segment re-ingestion job (server API `POST 
/reingestSegment/{segmentName}`) waits for
+    /// consumption to reach the segment end offset before failing. Read when 
each job starts consuming.
+    ///
+    /// Can also be set via cluster config, which is applied without a restart 
and takes precedence over the server
+    /// config, so that operators can change the timeout of all servers at 
runtime. Notes on the cluster config:
+    /// - Only this exact key is applied without a restart, e.g. not a 
`pinot.all.` prefixed or differently cased key.
+    /// - When it is removed or invalid, the server config is used, which 
includes the cluster configs copied into it at
+    ///   server startup. So to change the timeout, set a new valid value 
instead of removing it.
+    public static final String CONFIG_OF_REINGESTION_CONSUMPTION_TIMEOUT_MS =
+        "pinot.server.reingestion.consumption.timeoutMs";
+    public static final long DEFAULT_REINGESTION_CONSUMPTION_TIMEOUT_MS = 
1_800_000L; // 30 minutes
+
     public static final String CONFIG_OF_MMAP_DEFAULT_ADVICE = 
"pinot.server.mmap.advice.default";
     public static final String PREFIX_OF_CONFIG_OF_SEGMENT_FETCHER_FACTORY = 
"pinot.server.segment.fetcher";
 


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

Reply via email to