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

raghavyadav01 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 779210b9831 Add onSegmentAdded hook to IndexSegment and 
SegmentDirectory (#19053)
779210b9831 is described below

commit 779210b98313d845f4edf4f1182c3a53d66f1981
Author: Jhow <[email protected]>
AuthorDate: Wed Jul 29 17:50:59 2026 -0700

    Add onSegmentAdded hook to IndexSegment and SegmentDirectory (#19053)
    
    * add onSegmentAdded hook
    
    * address PR comments
    
    * add unit test
---
 .../core/data/manager/BaseTableDataManager.java    |  33 +++++
 .../data/manager/BaseTableDataManagerTest.java     | 138 +++++++++++++++++++++
 .../indexsegment/immutable/EmptyIndexSegment.java  |  46 +++++++
 .../immutable/ImmutableSegmentImpl.java            |  19 +++
 .../immutable/ImmutableSegmentLoader.java          |   4 +-
 .../immutable/EmptyIndexSegmentTest.java           | 103 +++++++++++++++
 .../immutable/ImmutableSegmentImplTest.java        |  97 +++++++++++++++
 .../org/apache/pinot/segment/spi/IndexSegment.java |  19 +++
 .../pinot/segment/spi/store/SegmentDirectory.java  |  17 +++
 9 files changed, 475 insertions(+), 1 deletion(-)

diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/data/manager/BaseTableDataManager.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/data/manager/BaseTableDataManager.java
index 8bb9393bb4e..a81b6f42652 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/data/manager/BaseTableDataManager.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/data/manager/BaseTableDataManager.java
@@ -1248,9 +1248,42 @@ public abstract class BaseTableDataManager implements 
TableDataManager {
       oldSegmentDataManager = _segmentDataManagerMap.put(segmentName, 
segmentDataManager);
     }
     _recentlyDeletedSegments.invalidate(segmentName);
+    // Fire the post-registration lifecycle hook now that the segment is 
swapped into the serving set
+    fireOnSegmentAdded(segmentName, segmentDataManager);
     return oldSegmentDataManager;
   }
 
+  /**
+   * Fires the post-registration lifecycle hook on the segments exposed by a 
newly registered segment data manager.
+   * <p>
+   * The hook runs after the segment is swapped into the serving set, so a 
reference is held while it runs: without it
+   * a concurrent {@link #replaceSegment} / {@link #unregisterSegment} could 
drop the reference count to 0 and destroy
+   * the segment (closing its {@link 
org.apache.pinot.segment.spi.store.SegmentDirectory}) while the hook is still
+   * using it. A manager that is already destroyed cannot take a reference and 
has nothing left to notify, so it is
+   * skipped.
+   * <p>
+   * Failures are contained here: the segment is already serving, so a failing 
hook must not fail the registration or
+   * the enclosing Helix state transition. This also keeps a manager that 
exposes a null segment (possible for a
+   * custom implementation, as the default {@link 
SegmentDataManager#getReportableSegments()} wraps
+   * {@link SegmentDataManager#getSegment()}) from aborting registration.
+   */
+  private void fireOnSegmentAdded(String segmentName, SegmentDataManager 
segmentDataManager) {
+    if (!segmentDataManager.increaseReferenceCount()) {
+      return;
+    }
+    try {
+      for (IndexSegment segment : segmentDataManager.getReportableSegments()) {
+        if (segment != null) {
+          segment.onSegmentAdded();
+        }
+      }
+    } catch (Exception e) {
+      _logger.warn("Caught exception while firing onSegmentAdded for segment: 
{}", segmentName, e);
+    } finally {
+      releaseSegment(segmentDataManager);
+    }
+  }
+
   /**
    * De-registering a segment ensures that no query uses the given segment 
until a segment with that name is
    * re-registered. There may be scenarios where the broker thinks that a 
segment is available even though it has
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/core/data/manager/BaseTableDataManagerTest.java
 
b/pinot-core/src/test/java/org/apache/pinot/core/data/manager/BaseTableDataManagerTest.java
index 248ad28ca35..229ffdcab60 100644
--- 
a/pinot-core/src/test/java/org/apache/pinot/core/data/manager/BaseTableDataManagerTest.java
+++ 
b/pinot-core/src/test/java/org/apache/pinot/core/data/manager/BaseTableDataManagerTest.java
@@ -24,6 +24,7 @@ import java.io.FileInputStream;
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -41,6 +42,7 @@ import 
org.apache.pinot.common.utils.fetcher.SegmentFetcherFactory;
 import org.apache.pinot.core.data.manager.offline.ImmutableSegmentDataManager;
 import org.apache.pinot.core.data.manager.offline.OfflineTableDataManager;
 import org.apache.pinot.segment.local.data.manager.SegmentDataManager;
+import org.apache.pinot.segment.local.indexsegment.immutable.EmptyIndexSegment;
 import 
org.apache.pinot.segment.local.segment.creator.impl.SegmentIndexCreationDriverImpl;
 import org.apache.pinot.segment.local.segment.index.loader.IndexLoadingConfig;
 import org.apache.pinot.segment.local.segment.readers.GenericRowRecordReader;
@@ -55,6 +57,7 @@ import org.apache.pinot.segment.spi.V1Constants;
 import org.apache.pinot.segment.spi.creator.SegmentGeneratorConfig;
 import org.apache.pinot.segment.spi.creator.SegmentVersion;
 import org.apache.pinot.segment.spi.index.metadata.SegmentMetadataImpl;
+import org.apache.pinot.segment.spi.store.SegmentDirectory;
 import org.apache.pinot.segment.spi.store.SegmentDirectoryPaths;
 import org.apache.pinot.spi.config.instance.InstanceDataManagerConfig;
 import org.apache.pinot.spi.config.table.TableConfig;
@@ -78,8 +81,11 @@ import org.testng.annotations.Test;
 
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 import static org.testng.Assert.*;
@@ -943,6 +949,138 @@ public class BaseTableDataManagerTest {
     }
   }
 
+  /**
+   * registerSegment must fire the post-registration lifecycle hook on the 
backing segment of a standalone manager.
+   * A standalone ImmutableSegmentDataManager exposes its segment via 
getReportableSegments() (default), while
+   * getSegments() defaults to empty — so iterating getSegments() here would 
silently skip the hook for the common
+   * single-segment case. This is a regression guard for that: it fails if 
registerSegment iterates getSegments().
+   */
+  @Test
+  public void testRegisterSegmentFiresOnSegmentAddedForStandaloneSegment() {
+    BaseTableDataManager tableDataManager = createTableManager();
+    ImmutableSegment immutableSegment = mock(ImmutableSegment.class);
+    when(immutableSegment.getSegmentName()).thenReturn(SEGMENT_NAME);
+    tableDataManager.registerSegment(SEGMENT_NAME, new 
ImmutableSegmentDataManager(immutableSegment));
+    verify(immutableSegment).onSegmentAdded();
+  }
+
+  /**
+   * registerSegment must fire the hook once per reportable segment for a 
multi-segment manager
+   */
+  @Test
+  public void testRegisterSegmentFiresOnSegmentAddedForEachReportableSegment() 
{
+    BaseTableDataManager tableDataManager = createTableManager();
+    ImmutableSegment segment1 = mock(ImmutableSegment.class);
+    ImmutableSegment segment2 = mock(ImmutableSegment.class);
+    SegmentDataManager multiSegmentManager = mock(SegmentDataManager.class);
+    when(multiSegmentManager.getSegmentName()).thenReturn(SEGMENT_NAME);
+    
when(multiSegmentManager.getReportableSegments()).thenReturn(List.of(segment1, 
segment2));
+    // registerSegment holds a reference while the hook runs; a mock returns 
false unless stubbed.
+    when(multiSegmentManager.increaseReferenceCount()).thenReturn(true);
+    tableDataManager.registerSegment(SEGMENT_NAME, multiSegmentManager);
+    verify(segment1).onSegmentAdded();
+    verify(segment2).onSegmentAdded();
+  }
+
+  /**
+   * The hook fires after the segment is swapped into the serving set, so 
registerSegment must hold a reference while
+   * it runs. A manager that has already been destroyed (reference count 0) 
cannot be referenced and has nothing left
+   * to notify: firing the hook there would run it against a closed 
SegmentDirectory.
+   */
+  @Test
+  public void testRegisterSegmentSkipsOnSegmentAddedForDestroyedSegment() {
+    BaseTableDataManager tableDataManager = createTableManager();
+    ImmutableSegment immutableSegment = mock(ImmutableSegment.class);
+    when(immutableSegment.getSegmentName()).thenReturn(SEGMENT_NAME);
+    ImmutableSegmentDataManager segmentDataManager = new 
ImmutableSegmentDataManager(immutableSegment);
+    // Drop the initial reference, mirroring a concurrent 
replaceSegment()/unregisterSegment() that destroyed it.
+    assertTrue(segmentDataManager.decreaseReferenceCount());
+
+    tableDataManager.registerSegment(SEGMENT_NAME, segmentDataManager);
+
+    verify(immutableSegment, never()).onSegmentAdded();
+  }
+
+  /**
+   * The hook must not leave a net reference behind: the segment stays 
acquirable after registration, and is not
+   * destroyed by the reference registerSegment took while firing the hook.
+   */
+  @Test
+  public void testRegisterSegmentRestoresReferenceCountAfterFiringHook() {
+    BaseTableDataManager tableDataManager = createTableManager();
+    ImmutableSegment immutableSegment = mock(ImmutableSegment.class);
+    when(immutableSegment.getSegmentName()).thenReturn(SEGMENT_NAME);
+    ImmutableSegmentDataManager segmentDataManager = new 
ImmutableSegmentDataManager(immutableSegment);
+
+    tableDataManager.registerSegment(SEGMENT_NAME, segmentDataManager);
+
+    verify(immutableSegment).onSegmentAdded();
+    assertEquals(segmentDataManager.getReferenceCount(), 1);
+    verify(immutableSegment, never()).destroy();
+  }
+
+  /**
+   * A failing hook must not fail the registration: the segment is already 
serving by then, and propagating the
+   * failure would abort the enclosing Helix state transition for a segment 
that is in fact up.
+   */
+  @Test
+  public void testRegisterSegmentSucceedsWhenOnSegmentAddedThrows() {
+    BaseTableDataManager tableDataManager = createTableManager();
+    ImmutableSegment immutableSegment = mock(ImmutableSegment.class);
+    when(immutableSegment.getSegmentName()).thenReturn(SEGMENT_NAME);
+    doThrow(new 
RuntimeException("boom")).when(immutableSegment).onSegmentAdded();
+    ImmutableSegmentDataManager segmentDataManager = new 
ImmutableSegmentDataManager(immutableSegment);
+
+    tableDataManager.registerSegment(SEGMENT_NAME, segmentDataManager);
+
+    assertSame(tableDataManager.getSegmentDataManager(SEGMENT_NAME), 
segmentDataManager);
+    assertEquals(segmentDataManager.getReferenceCount(), 1);
+  }
+
+  /**
+   * The default getReportableSegments() wraps getSegment(), so a custom 
manager exposing a null segment must not
+   * abort registration.
+   */
+  @Test
+  public void testRegisterSegmentToleratesNullReportableSegment() {
+    BaseTableDataManager tableDataManager = createTableManager();
+    SegmentDataManager segmentDataManager = mock(SegmentDataManager.class);
+    when(segmentDataManager.getSegmentName()).thenReturn(SEGMENT_NAME);
+    when(segmentDataManager.increaseReferenceCount()).thenReturn(true);
+    
when(segmentDataManager.getReportableSegments()).thenReturn(Arrays.asList(null, 
null));
+
+    tableDataManager.registerSegment(SEGMENT_NAME, segmentDataManager);
+
+    assertSame(tableDataManager.getSegmentDataManager(SEGMENT_NAME), 
segmentDataManager);
+  }
+
+  /**
+   * An upsert replacement with a consistency mode other than NONE registers 
the same new segment twice: first through
+   * a DuoSegmentDataManager (whose default getReportableSegments() returns 
its primary, i.e. the new segment) and then
+   * directly. The hook must reach the underlying SegmentDirectory only once, 
since implementations are not required to
+   * be idempotent — a second call would e.g. upload a duplicate marker file.
+   */
+  @Test
+  public void testConsistencyModeReplacementFiresOnSegmentAddedOnce()
+      throws Exception {
+    BaseTableDataManager tableDataManager = createTableManager();
+    SegmentMetadataImpl segmentMetadata = mock(SegmentMetadataImpl.class);
+    when(segmentMetadata.getName()).thenReturn(SEGMENT_NAME);
+    SegmentDirectory segmentDirectory = mock(SegmentDirectory.class);
+    // A real ImmutableSegment (not a mock) so the at-most-once guard in the 
implementation is exercised.
+    EmptyIndexSegment newSegment = new EmptyIndexSegment(segmentMetadata, 
segmentDirectory);
+    ImmutableSegmentDataManager newSegmentManager = new 
ImmutableSegmentDataManager(newSegment);
+    ImmutableSegment oldSegment = mock(ImmutableSegment.class);
+    when(oldSegment.getSegmentName()).thenReturn(SEGMENT_NAME);
+    ImmutableSegmentDataManager oldSegmentManager = new 
ImmutableSegmentDataManager(oldSegment);
+
+    // Mirrors BaseTableDataManager.replaceUpsertSegment() for a non-NONE 
consistency mode.
+    tableDataManager.registerSegment(SEGMENT_NAME, new 
DuoSegmentDataManager(newSegmentManager, oldSegmentManager));
+    tableDataManager.registerSegment(SEGMENT_NAME, newSegmentManager);
+
+    verify(segmentDirectory, times(1)).onSegmentAdded();
+  }
+
   protected BaseTableDataManager createTableManager() {
     return createTableManager(createDefaultInstanceDataManagerConfig());
   }
diff --git 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/indexsegment/immutable/EmptyIndexSegment.java
 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/indexsegment/immutable/EmptyIndexSegment.java
index 5a19761cc53..cd85e1741d2 100644
--- 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/indexsegment/immutable/EmptyIndexSegment.java
+++ 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/indexsegment/immutable/EmptyIndexSegment.java
@@ -21,6 +21,7 @@ package org.apache.pinot.segment.local.indexsegment.immutable;
 import com.google.common.base.Preconditions;
 import java.util.List;
 import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
 import javax.annotation.Nullable;
 import org.apache.pinot.segment.local.segment.index.datasource.EmptyDataSource;
 import org.apache.pinot.segment.spi.ColumnMetadata;
@@ -35,9 +36,12 @@ import 
org.apache.pinot.segment.spi.index.reader.ForwardIndexReader;
 import org.apache.pinot.segment.spi.index.reader.InvertedIndexReader;
 import org.apache.pinot.segment.spi.index.reader.TextIndexReader;
 import org.apache.pinot.segment.spi.index.startree.StarTreeV2;
+import org.apache.pinot.segment.spi.store.SegmentDirectory;
 import org.apache.pinot.spi.data.FieldSpec;
 import org.apache.pinot.spi.data.Schema;
 import org.apache.pinot.spi.data.readers.GenericRow;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 
 /**
@@ -45,10 +49,26 @@ import org.apache.pinot.spi.data.readers.GenericRow;
  * Such an IndexSegment contains only the metadata, and no indexes
  */
 public class EmptyIndexSegment implements ImmutableSegment {
+  private static final Logger LOGGER = 
LoggerFactory.getLogger(EmptyIndexSegment.class);
+
   private final SegmentMetadataImpl _segmentMetadata;
+  // The directory this empty segment was loaded from, if any. An empty 
(0-doc) segment holds no index buffers, but the
+  // directory may still own resources and post-registration work that a 
non-empty segment gets via
+  // ImmutableSegmentImpl. Null when loaded without a directory.
+  @Nullable
+  private final SegmentDirectory _segmentDirectory;
+  // Guards the post-registration hook so it reaches the directory at most 
once per segment instance, even when the
+  // same segment is registered more than once (e.g. an upsert replacement 
with a consistency mode other than NONE
+  // registers the new segment through a DuoSegmentDataManager and then 
directly).
+  private final AtomicBoolean _segmentAdded = new AtomicBoolean();
 
   public EmptyIndexSegment(SegmentMetadataImpl segmentMetadata) {
+    this(segmentMetadata, null);
+  }
+
+  public EmptyIndexSegment(SegmentMetadataImpl segmentMetadata, @Nullable 
SegmentDirectory segmentDirectory) {
     _segmentMetadata = segmentMetadata;
+    _segmentDirectory = segmentDirectory;
   }
 
   @Override
@@ -71,12 +91,38 @@ public class EmptyIndexSegment implements ImmutableSegment {
     return _segmentMetadata.getSchema().getPhysicalColumnNames();
   }
 
+  @Override
+  public void onSegmentAdded() {
+    // Best-effort: this fires after the segment is already serving, so a 
failure cannot roll back the registration.
+    if (_segmentDirectory == null) {
+      return;
+    }
+    if (!_segmentAdded.compareAndSet(false, true)) {
+      // Already notified for this segment instance; a repeated registration 
must not notify the directory again.
+      return;
+    }
+    try {
+      _segmentDirectory.onSegmentAdded();
+    } catch (Exception e) {
+      LOGGER.warn("Caught exception in onSegmentAdded for empty segment: {}. 
Continuing with error.", getSegmentName(),
+          e);
+    }
+  }
+
   @Override
   public void offload() {
   }
 
   @Override
   public void destroy() {
+    if (_segmentDirectory != null) {
+      try {
+        _segmentDirectory.close();
+      } catch (Exception e) {
+        LOGGER.warn("Failed to close segment directory for empty segment: {}. 
Continuing with error.",
+            getSegmentName(), e);
+      }
+    }
   }
 
   @Nullable
diff --git 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/indexsegment/immutable/ImmutableSegmentImpl.java
 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/indexsegment/immutable/ImmutableSegmentImpl.java
index 99706255793..0791af6dd66 100644
--- 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/indexsegment/immutable/ImmutableSegmentImpl.java
+++ 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/indexsegment/immutable/ImmutableSegmentImpl.java
@@ -29,6 +29,7 @@ import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
 import javax.annotation.Nullable;
 import org.apache.commons.io.FileUtils;
 import org.apache.pinot.segment.local.dedup.PartitionDedupMetadataManager;
@@ -83,6 +84,10 @@ public class ImmutableSegmentImpl implements 
ImmutableSegment {
   private final StarTreeIndexContainer _starTreeIndexContainer;
   private final TextIndexReader _multiColumnTextIndex;
   private final Map<String, DataSource> _dataSources;
+  // Guards the post-registration hook so it reaches the directory at most 
once per segment instance, even when the
+  // same segment is registered more than once (e.g. an upsert replacement 
with a consistency mode other than NONE
+  // registers the new segment through a DuoSegmentDataManager and then 
directly).
+  private final AtomicBoolean _segmentAdded = new AtomicBoolean();
 
   // Dedupe
   private PartitionDedupMetadataManager _partitionDedupMetadataManager;
@@ -323,6 +328,20 @@ public class ImmutableSegmentImpl implements 
ImmutableSegment {
     _segmentDirectory.release(fetchContext);
   }
 
+  @Override
+  public void onSegmentAdded() {
+    if (!_segmentAdded.compareAndSet(false, true)) {
+      // Already notified for this segment instance; a repeated registration 
must not notify the directory again.
+      return;
+    }
+    // Best-effort: this fires after the segment is already serving, so a 
failure cannot roll back the registration.
+    try {
+      _segmentDirectory.onSegmentAdded();
+    } catch (Exception e) {
+      LOGGER.warn("Caught exception in onSegmentAdded for segment: {}. 
Continuing with error.", getSegmentName(), e);
+    }
+  }
+
   @Override
   public void offload() {
     if (_partitionUpsertMetadataManager != null) {
diff --git 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/indexsegment/immutable/ImmutableSegmentLoader.java
 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/indexsegment/immutable/ImmutableSegmentLoader.java
index 0fb0353fca5..ae0391a76f6 100644
--- 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/indexsegment/immutable/ImmutableSegmentLoader.java
+++ 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/indexsegment/immutable/ImmutableSegmentLoader.java
@@ -206,7 +206,9 @@ public class ImmutableSegmentLoader {
       throws Exception {
     SegmentMetadataImpl segmentMetadata = 
segmentDirectory.getSegmentMetadata();
     if (segmentMetadata.getTotalDocs() == 0) {
-      return new EmptyIndexSegment(segmentMetadata);
+      // Hand the directory to the empty segment so it can run 
post-registration work and own closing the directory,
+      // mirroring the non-empty ImmutableSegmentImpl path.
+      return new EmptyIndexSegment(segmentMetadata, segmentDirectory);
     }
 
     // Remove columns not in schema from the metadata
diff --git 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/indexsegment/immutable/EmptyIndexSegmentTest.java
 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/indexsegment/immutable/EmptyIndexSegmentTest.java
new file mode 100644
index 00000000000..7621e071715
--- /dev/null
+++ 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/indexsegment/immutable/EmptyIndexSegmentTest.java
@@ -0,0 +1,103 @@
+/**
+ * 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.indexsegment.immutable;
+
+import org.apache.pinot.segment.spi.index.metadata.SegmentMetadataImpl;
+import org.apache.pinot.segment.spi.store.SegmentDirectory;
+import org.testng.annotations.Test;
+
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+
+public class EmptyIndexSegmentTest {
+
+  /**
+   * An empty (0-doc) segment loaded from a directory must still run the 
post-registration lifecycle hook and own
+   * closing the directory, mirroring the non-empty ImmutableSegmentImpl path.
+   */
+  @Test
+  public void testOnSegmentAddedDelegatesToDirectoryAndDestroyCloses()
+      throws Exception {
+    SegmentMetadataImpl metadata = mock(SegmentMetadataImpl.class);
+    when(metadata.getName()).thenReturn("seg");
+    SegmentDirectory segmentDirectory = mock(SegmentDirectory.class);
+    EmptyIndexSegment segment = new EmptyIndexSegment(metadata, 
segmentDirectory);
+
+    segment.onSegmentAdded();
+    verify(segmentDirectory).onSegmentAdded();
+
+    segment.destroy();
+    verify(segmentDirectory).close();
+  }
+
+  /**
+   * The hook fires after the segment is already serving, so a directory 
failure must not propagate out of it.
+   */
+  @Test
+  public void testOnSegmentAddedIsBestEffort()
+      throws Exception {
+    SegmentMetadataImpl metadata = mock(SegmentMetadataImpl.class);
+    when(metadata.getName()).thenReturn("seg");
+    SegmentDirectory segmentDirectory = mock(SegmentDirectory.class);
+    doThrow(new 
RuntimeException("boom")).when(segmentDirectory).onSegmentAdded();
+    EmptyIndexSegment segment = new EmptyIndexSegment(metadata, 
segmentDirectory);
+
+    // Must not throw.
+    segment.onSegmentAdded();
+  }
+
+  /**
+   * The hook must reach the directory at most once per segment instance: the 
same segment can be registered more than
+   * once (e.g. an upsert replacement with a consistency mode other than NONE 
registers it through a
+   * DuoSegmentDataManager and then directly), and implementations are not 
required to be idempotent.
+   */
+  @Test
+  public void testOnSegmentAddedNotifiesDirectoryAtMostOnce()
+      throws Exception {
+    SegmentMetadataImpl metadata = mock(SegmentMetadataImpl.class);
+    when(metadata.getName()).thenReturn("seg");
+    SegmentDirectory segmentDirectory = mock(SegmentDirectory.class);
+    EmptyIndexSegment segment = new EmptyIndexSegment(metadata, 
segmentDirectory);
+
+    segment.onSegmentAdded();
+    segment.onSegmentAdded();
+    segment.onSegmentAdded();
+
+    verify(segmentDirectory, times(1)).onSegmentAdded();
+  }
+
+  /**
+   * An empty segment loaded without a directory (e.g. the local File path) 
must treat the lifecycle callbacks as safe
+   * no-ops.
+   */
+  @Test
+  public void testMetadataOnlySegmentHasNoDirectorySideEffects() {
+    SegmentMetadataImpl metadata = mock(SegmentMetadataImpl.class);
+    when(metadata.getName()).thenReturn("seg");
+    EmptyIndexSegment segment = new EmptyIndexSegment(metadata);
+
+    // No directory: both must be safe no-ops.
+    segment.onSegmentAdded();
+    segment.destroy();
+  }
+}
diff --git 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/indexsegment/immutable/ImmutableSegmentImplTest.java
 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/indexsegment/immutable/ImmutableSegmentImplTest.java
new file mode 100644
index 00000000000..77f7d6cad31
--- /dev/null
+++ 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/indexsegment/immutable/ImmutableSegmentImplTest.java
@@ -0,0 +1,97 @@
+/**
+ * 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.indexsegment.immutable;
+
+import java.util.Map;
+import java.util.TreeMap;
+import org.apache.pinot.segment.spi.index.metadata.SegmentMetadataImpl;
+import org.apache.pinot.segment.spi.store.SegmentDirectory;
+import org.testng.annotations.Test;
+
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+
+/**
+ * Tests for the post-registration lifecycle hook of {@link 
ImmutableSegmentImpl}.
+ */
+public class ImmutableSegmentImplTest {
+
+  /**
+   * The hook must reach the directory at most once per segment instance: the 
same segment can be registered more than
+   * once (e.g. an upsert replacement with a consistency mode other than NONE 
registers it through a
+   * DuoSegmentDataManager and then directly), and implementations are not 
required to be idempotent.
+   */
+  @Test
+  public void testOnSegmentAddedNotifiesDirectoryAtMostOnce()
+      throws Exception {
+    SegmentDirectory segmentDirectory = mock(SegmentDirectory.class);
+    ImmutableSegmentImpl segment = createSegment(segmentDirectory);
+
+    segment.onSegmentAdded();
+    segment.onSegmentAdded();
+    segment.onSegmentAdded();
+
+    verify(segmentDirectory, times(1)).onSegmentAdded();
+  }
+
+  /**
+   * The hook fires after the segment is already serving, so a directory 
failure must not propagate out of it.
+   */
+  @Test
+  public void testOnSegmentAddedIsBestEffort()
+      throws Exception {
+    SegmentDirectory segmentDirectory = mock(SegmentDirectory.class);
+    doThrow(new 
RuntimeException("boom")).when(segmentDirectory).onSegmentAdded();
+    ImmutableSegmentImpl segment = createSegment(segmentDirectory);
+
+    // Must not throw.
+    segment.onSegmentAdded();
+
+    verify(segmentDirectory).onSegmentAdded();
+  }
+
+  /**
+   * A failed attempt consumes the single notification: the directory was 
already told, and retry semantics are the
+   * implementation's business, not the caller's.
+   */
+  @Test
+  public void testFailedOnSegmentAddedIsNotRetried()
+      throws Exception {
+    SegmentDirectory segmentDirectory = mock(SegmentDirectory.class);
+    doThrow(new 
RuntimeException("boom")).when(segmentDirectory).onSegmentAdded();
+    ImmutableSegmentImpl segment = createSegment(segmentDirectory);
+
+    segment.onSegmentAdded();
+    segment.onSegmentAdded();
+
+    verify(segmentDirectory, times(1)).onSegmentAdded();
+  }
+
+  private static ImmutableSegmentImpl createSegment(SegmentDirectory 
segmentDirectory) {
+    SegmentMetadataImpl segmentMetadata = mock(SegmentMetadataImpl.class);
+    when(segmentMetadata.getName()).thenReturn("seg");
+    // getColumnMetadataMap() is declared as a TreeMap, so an immutable 
Map.of() will not do here.
+    when(segmentMetadata.getColumnMetadataMap()).thenReturn(new TreeMap<>());
+    return new ImmutableSegmentImpl(segmentDirectory, segmentMetadata, 
Map.of(), null);
+  }
+}
diff --git 
a/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/IndexSegment.java
 
b/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/IndexSegment.java
index b1d6b4592d4..d69d96701d3 100644
--- 
a/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/IndexSegment.java
+++ 
b/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/IndexSegment.java
@@ -165,6 +165,25 @@ public interface IndexSegment {
   default void release(FetchContext fetchContext) {
   }
 
+  /**
+   * Lifecycle callback invoked after this segment has been successfully added 
to the serving set (i.e. registered and
+   * swapped in so queries can reach it). Default is a no-op; implementations 
may override to run post-registration
+   * work. It is invoked only on the success path, and at most once per 
segment instance even if the same segment is
+   * registered more than once.
+   * <p>
+   * Implementations must be best-effort: this fires after the segment is 
already serving, so a failure here cannot
+   * roll back the registration and should be handled internally rather than 
propagated.
+   * <p>
+   * Implementations must also return promptly. This runs inline on the 
segment registration thread, which is the
+   * Helix state-transition thread for the ONLINE transition, so a blocking or 
unbounded call here stalls the state
+   * transition itself; the caller applies no timeout. Any unbounded work (in 
particular remote I/O) must be bounded
+   * by the implementation or performed asynchronously by it. An 
implementation that goes asynchronous must not assume
+   * the segment is still alive once this method has returned, since the 
caller only keeps the segment referenced for
+   * the duration of the call.
+   */
+  default void onSegmentAdded() {
+  }
+
   /**
    * Offloads the segment from the metadata management (e.g. upsert metadata), 
but not releases the resources yet
    * because there might be queries still accessing the segment.
diff --git 
a/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/store/SegmentDirectory.java
 
b/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/store/SegmentDirectory.java
index 3e0541c5292..18ffb6d748d 100644
--- 
a/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/store/SegmentDirectory.java
+++ 
b/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/store/SegmentDirectory.java
@@ -151,6 +151,23 @@ public abstract class SegmentDirectory implements 
Closeable {
       throws Exception {
   }
 
+  /**
+   * Lifecycle callback invoked after the segment backed by this directory has 
been successfully added to the serving
+   * set (registered and swapped in). Default is a no-op, invoked at most once 
per segment instance backed by this
+   * directory.
+   * <p>
+   * Must be best-effort: it fires after the segment is already serving, so 
failures cannot roll back the registration
+   * and should be handled internally.
+   * <p>
+   * Must also return promptly: this runs inline on the segment registration 
thread, which is the Helix
+   * state-transition thread for the ONLINE transition, so blocking or 
unbounded work here stalls the state transition.
+   * Bound any remote I/O (e.g. with a request timeout) or perform it 
asynchronously, keeping in mind that the segment
+   * and this directory are only guaranteed to be alive for the duration of 
this call.
+   */
+  public void onSegmentAdded()
+      throws Exception {
+  }
+
   /**
    * Get the storage tier where the segment directory is placed by server.
    *


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

Reply via email to