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 88c3262d10a Support chained, forward-index-disabled, and 
disabled-source derived columns (#19581)
88c3262d10a is described below

commit 88c3262d10ac94bb70aceded8771595d288b77ca
Author: RAGHVENDRA KUMAR YADAV <[email protected]>
AuthorDate: Tue Oct 20 15:33:51 2026 -0700

    Support chained, forward-index-disabled, and disabled-source derived 
columns (#19581)
    
    * Support chained, forward-index-disabled, and disabled-source derived 
columns
    
    BaseDefaultColumnHandler carried three TODOs that limited derived columns 
built
    at segment load. All three are lifted here.
    
    Chained derived columns. updateDefaultColumns iterated an unordered map and
    resolved arguments against the segment metadata loaded before the run, so a
    column created during the run was invisible. Columns are now visited in
    dependency order (topological over the ADD actions), and the metadata 
written
    for each created column is recorded and consulted before the segment 
metadata.
    This also fixes a derived column reading a plain default column added in the
    same reload, which failed the same way. A dependency cycle cannot be 
satisfied
    in any order: those columns are emitted last and take the default-value 
path,
    with the cycle logged rather than failing the segment load.
    
    Source column with the forward index disabled. Previously an unconditional
    UnsupportedOperationException. The forward index is now regenerated from the
    dictionary and inverted index for the duration of the run and dropped
    afterwards, mirroring the temporary-forward-index pattern BaseIndexHandler
    already uses. When regeneration is not possible the column falls back to its
    default value, or still fails when errorOnColumnBuildFailure is set.
    
    Forward-index-disabled derived column. Previously skipped with a warning. 
It is
    now built with a forward index and a dictionary; ForwardIndexHandler runs 
next,
    queues DISABLE_FORWARD_INDEX and deletes the forward index in its 
post-update
    cleanup, once the inverted index has been built from it.
    
    V3DefaultColumnHandler decided whether to transfer a forward or an inverted
    index into the v3 file from the config. A forward-index-disabled multi-value
    default column writes an inverted index but a derived one writes a forward
    index, so the decision now follows the file that exists on disk.
    
    DefaultColumnHandlerFactory gains a SegmentDirectory overload, needed by
    InvertedIndexAndDictionaryBasedForwardIndexCreator; the existing signature 
is
    kept and delegates with null.
    
    The transform configs are indexed by column and the parsed expressions 
cached,
    so each is parsed once per segment load rather than once for ordering and 
again
    for deriving. Reading back a created column's metadata is restricted to 
columns
    a transform function actually reads, so a schema evolution adding many plain
    default columns does not re-parse metadata it will never consult. A table 
with
    no derived columns takes the same path as before: no expression is parsed 
and
    the action map is returned unchanged.
    
    * Address review: chained value fidelity, metadata refresh, update 
ordering, cycle fallback
    
    Five correctness findings from the review of the previous commit.
    
    Chained derived columns now evaluate on the expression results rather than 
on
    what the intermediate column stored. Ingestion evaluates a whole chain 
before
    converting anything to its stored type, so with a = 1, INT b = divide(a, 2) 
and
    DOUBLE c = plus(b, 1) it stores b = 0 and c = 1.5. Reading b back from its 
index
    gave c = 1.0 instead. The values a derived column produces are kept, before
    conversion, for the columns another transform function reads, and dropped 
at the
    end of the run.
    
    An argument's metadata is re-read after its forward index is regenerated. 
The
    rebuild rewrites the properties the reader works from -- the encoding flips 
when
    a raw index is restored, and TOTAL_NUMBER_OF_ENTRIES shrinks when a disabled
    multi-value source held duplicates within a row -- so the copy taken 
beforehand
    computed the wrong offsets, the same reason BaseIndexHandler returns 
refreshed
    metadata from createForwardIndexIfNeeded.
    
    Dependency ordering covers UPDATE actions, not just ADD. A source whose 
default
    value is changing is only correct once its own action has run; reading it 
earlier
    wrote the pre-update value into the chain permanently, and no later reload 
put it
    right.
    
    Regenerating the forward index of a source created in the same run no longer
    dereferences null. Such a column exists only in the metadata properties just
    flushed, not in the segment directory's metadata, so the creator takes it 
as an
    argument; InvertedIndexAndDictionaryBasedForwardIndexCreator gains the 
overload.
    
    Every member of a dependency cycle now takes its default value. Returning on
    re-entry left the enclosing calls free to emit the other members, so 
whichever
    one ran second read the first and derived a value from a default instead of
    taking the documented fallback.
    
    Five regression tests, each failing without the matching fix: the chain 
compared
    against a segment built through ingestion, the encoding flip during 
regeneration,
    the updated source ahead of a two-link chain, a derived column reading a
    forward-index-disabled multi-value column added in the same reload, and the 
cycle
    asserted on actual default values rather than cardinality.
    
    * Address review: null intermediates, refreshed source metadata, full cycle 
detection
    
    - Hand the raw expression results to the rest of the chain, captured before
      nulls are replaced with the column's default, so a chain over a null
      intermediate stores what ingestion stores.
    - Cache the metadata refreshed after a forward index is regenerated for 
every
      source, not just ones created in the same run, so a second read of the 
same
      source works from it.
    - Identify dependency cycles as strongly connected components, so every 
member
      of overlapping cycles falls back to its own default value.
    
    * Fall back explicitly for a derived column reading a cycle member
    
    A column reading a member of a transform dependency cycle relied on the
    member's metadata being absent to take the default-value path. When the
    cycle members already exist and are only being updated, their metadata is
    present, so the dependent -- ordered before the postponed cycle -- read
    their pre-update values and kept them for good.
    
    Treat a cyclic argument as an explicit fallback when resolving a derived
    column's arguments.
---
 ...IndexAndDictionaryBasedForwardIndexCreator.java |  17 +-
 .../segment/index/loader/SegmentPreProcessor.java  |   2 +-
 .../defaultcolumn/BaseDefaultColumnHandler.java    | 586 ++++++++++++++++++---
 .../defaultcolumn/DefaultColumnHandlerFactory.java |  15 +-
 .../defaultcolumn/V1DefaultColumnHandler.java      |   7 +
 .../defaultcolumn/V3DefaultColumnHandler.java      |  26 +-
 .../index/loader/SegmentPreProcessorTest.java      | 509 +++++++++++++++++-
 7 files changed, 1080 insertions(+), 82 deletions(-)

diff --git 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/loader/InvertedIndexAndDictionaryBasedForwardIndexCreator.java
 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/loader/InvertedIndexAndDictionaryBasedForwardIndexCreator.java
index fbbf894a9d7..cb6a735a7c8 100644
--- 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/loader/InvertedIndexAndDictionaryBasedForwardIndexCreator.java
+++ 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/loader/InvertedIndexAndDictionaryBasedForwardIndexCreator.java
@@ -24,6 +24,7 @@ import java.io.IOException;
 import java.math.BigDecimal;
 import java.util.HashMap;
 import java.util.Map;
+import javax.annotation.Nullable;
 import org.apache.commons.io.FileUtils;
 import 
org.apache.pinot.segment.local.segment.index.dictionary.DictionaryIndexType;
 import 
org.apache.pinot.segment.local.segment.index.forward.CompressionStatsMetadata;
@@ -119,6 +120,17 @@ public class 
InvertedIndexAndDictionaryBasedForwardIndexCreator implements AutoC
       SegmentDirectory.Writer segmentWriter, TableConfig tableConfig, String 
columnName,
       FieldIndexConfigs fieldIndexConfigs, boolean isTemporaryForwardIndex)
       throws IOException {
+    this(segmentDirectory, segmentWriter, tableConfig, columnName, 
fieldIndexConfigs, isTemporaryForwardIndex, null);
+  }
+
+  /// Same, for a column the segment directory's metadata does not list yet. A 
column created during the current
+  /// segment load is only visible in `metadata.properties`, so its metadata 
has to be handed in; passing `null`
+  /// keeps the usual lookup.
+  public InvertedIndexAndDictionaryBasedForwardIndexCreator(SegmentDirectory 
segmentDirectory,
+      SegmentDirectory.Writer segmentWriter, TableConfig tableConfig, String 
columnName,
+      FieldIndexConfigs fieldIndexConfigs, boolean isTemporaryForwardIndex,
+      @Nullable ColumnMetadata columnMetadata)
+      throws IOException {
     _segmentDirectory = segmentDirectory;
     _segmentWriter = segmentWriter;
     _tableConfig = tableConfig;
@@ -128,7 +140,10 @@ public class 
InvertedIndexAndDictionaryBasedForwardIndexCreator implements AutoC
     _isTemporaryForwardIndex = isTemporaryForwardIndex;
 
     _segmentMetadata = segmentDirectory.getSegmentMetadata();
-    _columnMetadata = _segmentMetadata.getColumnMetadataFor(columnName);
+    _columnMetadata =
+        columnMetadata != null ? columnMetadata : 
_segmentMetadata.getColumnMetadataFor(columnName);
+    Preconditions.checkState(_columnMetadata != null, "Found no metadata for 
column: %s of segment: %s", columnName,
+        _segmentMetadata.getName());
     _singleValue = _columnMetadata.isSingleValue();
     _numDocs = _columnMetadata.getTotalDocs();
     _cardinality = _columnMetadata.getCardinality();
diff --git 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/loader/SegmentPreProcessor.java
 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/loader/SegmentPreProcessor.java
index 6912e8f5833..a9da03a539d 100644
--- 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/loader/SegmentPreProcessor.java
+++ 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/loader/SegmentPreProcessor.java
@@ -167,7 +167,7 @@ public class SegmentPreProcessor implements AutoCloseable {
       // Update default columns according to the schema.
       DefaultColumnHandler defaultColumnHandler =
           DefaultColumnHandlerFactory.getDefaultColumnHandler(indexDir, 
segmentMetadata, _indexLoadingConfig,
-              segmentWriter);
+              segmentWriter, _segmentDirectory);
       defaultColumnHandler.updateDefaultColumns();
       _segmentDirectory.reloadMetadata();
 
diff --git 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/loader/defaultcolumn/BaseDefaultColumnHandler.java
 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/loader/defaultcolumn/BaseDefaultColumnHandler.java
index cd29c2045b0..1727d8c2ba6 100644
--- 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/loader/defaultcolumn/BaseDefaultColumnHandler.java
+++ 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/loader/defaultcolumn/BaseDefaultColumnHandler.java
@@ -24,12 +24,20 @@ import java.io.Closeable;
 import java.io.File;
 import java.io.IOException;
 import java.math.BigDecimal;
+import java.util.ArrayDeque;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Deque;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
+import java.util.function.IntFunction;
+import javax.annotation.Nullable;
 import org.apache.commons.configuration2.PropertiesConfiguration;
 import org.apache.pinot.common.evaluator.FunctionEvaluatorFactory;
 import org.apache.pinot.common.function.FunctionUtils;
@@ -52,6 +60,7 @@ import 
org.apache.pinot.segment.local.segment.creator.impl.stats.StringColumnPre
 import 
org.apache.pinot.segment.local.segment.index.dictionary.DictionaryIndexType;
 import 
org.apache.pinot.segment.local.segment.index.forward.CompressionStatsMetadata;
 import org.apache.pinot.segment.local.segment.index.loader.IndexLoadingConfig;
+import 
org.apache.pinot.segment.local.segment.index.loader.InvertedIndexAndDictionaryBasedForwardIndexCreator;
 import org.apache.pinot.segment.local.segment.index.loader.LoaderUtils;
 import org.apache.pinot.segment.local.segment.readers.PinotSegmentColumnReader;
 import org.apache.pinot.segment.local.utils.ClusterConfigForTable;
@@ -71,6 +80,7 @@ import org.apache.pinot.segment.spi.index.IndexType;
 import org.apache.pinot.segment.spi.index.StandardIndexes;
 import 
org.apache.pinot.segment.spi.index.creator.DictionaryBasedInvertedIndexCreator;
 import org.apache.pinot.segment.spi.index.creator.ForwardIndexCreator;
+import org.apache.pinot.segment.spi.index.metadata.ColumnMetadataImpl;
 import org.apache.pinot.segment.spi.index.reader.Dictionary;
 import org.apache.pinot.segment.spi.index.reader.ForwardIndexReader;
 import org.apache.pinot.segment.spi.store.SegmentDirectory;
@@ -138,13 +148,52 @@ public abstract class BaseDefaultColumnHandler implements 
DefaultColumnHandler {
   protected final TableConfig _tableConfig;
   protected final Schema _schema;
   protected final SegmentDirectory.Writer _segmentWriter;
+  /// Needed only to regenerate the forward index of a forward-index-disabled 
source column of a derived column.
+  /// Null when the handler runs without one (the metadata-only 
`needUpdateDefaultColumns` path), in which case such
+  /// a source cannot be read and the derived column falls back to its default 
value.
+  @Nullable
+  protected final SegmentDirectory _segmentDirectory;
 
   // NOTE: _segmentProperties shouldn't be used when checking whether default 
column need to be created because at that
   //       time _segmentMetadata might not be loaded from a local file
   private PropertiesConfiguration _segmentProperties;
 
+  /// Metadata of the columns created during this run. `_segmentMetadata` was 
loaded before the run and never sees
+  /// them, so without this a derived column could not read a column added in 
the same reload -- including another
+  /// derived column (chained derived columns).
+  private final Map<String, ColumnMetadata> _createdColumnMetadata = new 
HashMap<>();
+
+  /// Values a derived column produced in this run, as the transform function 
returned them -- before they were
+  /// converted to the column's stored type. Another column in the same chain 
reads these rather than the index that
+  /// was just written, because ingestion evaluates the whole chain on the raw 
expression results and converts only
+  /// at the end: with `a = 1`, `INT b = divide(a, 2)` stores 0, yet `DOUBLE c 
= plus(b, 1)` must still see 0.5 and
+  /// store 1.5. Only kept for the columns a transform function actually 
reads, and dropped at the end of the run.
+  private final Map<String, Object[]> _createdColumnValues = new HashMap<>();
+
+  /// Columns that sit in a transform-function dependency cycle. No order can 
satisfy them, so each one is created
+  /// from its default value instead of its transform function -- every member 
of the cycle, not only the first one
+  /// the traversal happens to reach.
+  private final Set<String> _cyclicColumns = new HashSet<>();
+
+  /// Source columns whose forward index was regenerated purely so a derived 
column could read them. Dropped once
+  /// every default column has been created, restoring the 
forward-index-disabled shape the config asks for.
+  private final Set<String> _tmpForwardIndexColumns = new LinkedHashSet<>();
+
+  /// Transform config per derived column, and the columns their expressions 
read. Built once: the config list would
+  /// otherwise be scanned once per column being created, and the expressions 
parsed twice - once to order the
+  /// columns, once to derive them. Null until the first lookup; empty when 
the table declares no transform.
+  private Map<String, TransformConfig> _transformConfigsByColumn;
+  private Set<String> _derivedColumnArgumentNames;
+  private final Map<String, FunctionEvaluator> _functionEvaluators = new 
HashMap<>();
+
   protected BaseDefaultColumnHandler(File indexDir, SegmentMetadata 
segmentMetadata,
       IndexLoadingConfig indexLoadingConfig, SegmentDirectory.Writer 
segmentWriter) {
+    this(indexDir, segmentMetadata, indexLoadingConfig, segmentWriter, null);
+  }
+
+  protected BaseDefaultColumnHandler(File indexDir, SegmentMetadata 
segmentMetadata,
+      IndexLoadingConfig indexLoadingConfig, SegmentDirectory.Writer 
segmentWriter,
+      @Nullable SegmentDirectory segmentDirectory) {
     _indexDir = indexDir;
     _segmentMetadata = segmentMetadata;
     _indexLoadingConfig = indexLoadingConfig;
@@ -153,6 +202,7 @@ public abstract class BaseDefaultColumnHandler implements 
DefaultColumnHandler {
     _schema = _indexLoadingConfig.getSchema();
     Preconditions.checkArgument(_schema != null, "Schema must be provided");
     _segmentWriter = segmentWriter;
+    _segmentDirectory = segmentDirectory;
   }
 
   @Override
@@ -177,15 +227,22 @@ public abstract class BaseDefaultColumnHandler implements 
DefaultColumnHandler {
       return;
     }
 
-    // Update each default column based on the default column action.
+    // Update each default column based on the default column action. Columns 
are visited in dependency order so a
+    // derived column is created after every column it reads that is being 
created in this same run.
     _segmentProperties = 
SegmentMetadataUtils.getPropertiesConfiguration(_segmentMetadata);
-    Iterator<Map.Entry<String, DefaultColumnAction>> entryIterator = 
defaultColumnActionMap.entrySet().iterator();
-    while (entryIterator.hasNext()) {
-      Map.Entry<String, DefaultColumnAction> entry = entryIterator.next();
-      // This method updates the metadata properties, need to save it later. 
Remove the entry if the update failed.
-      if (!updateDefaultColumn(entry.getKey(), entry.getValue())) {
-        entryIterator.remove();
+    defaultColumnActionMap = orderByDependencies(defaultColumnActionMap);
+    try {
+      Iterator<Map.Entry<String, DefaultColumnAction>> entryIterator = 
defaultColumnActionMap.entrySet().iterator();
+      while (entryIterator.hasNext()) {
+        Map.Entry<String, DefaultColumnAction> entry = entryIterator.next();
+        // This method updates the metadata properties, need to save it later. 
Remove the entry if the update failed.
+        if (!updateDefaultColumn(entry.getKey(), entry.getValue())) {
+          entryIterator.remove();
+        }
       }
+    } finally {
+      removeTemporaryForwardIndexes();
+      _createdColumnValues.clear();
     }
 
     // Update the segment metadata.
@@ -378,65 +435,99 @@ public abstract class BaseDefaultColumnHandler implements 
DefaultColumnHandler {
 
   /// Helper method to create the V1 indices (dictionary and forward index) 
for a column, returns `true` if the
   /// creation succeeds, `false` otherwise.
+  ///
+  /// A derived column whose argument is itself created in this same run is 
supported: [#updateDefaultColumns] visits
+  /// the columns in dependency order and [#getColumnMetadata] sees the 
metadata written a moment ago. A source
+  /// column with its forward index disabled is supported too, by regenerating 
that index for the duration of the
+  /// run. An argument that is genuinely absent from the segment still falls 
back to the default value.
   protected boolean createColumnV1Indices(String column)
       throws Exception {
     boolean errorOnFailure = _indexLoadingConfig.isErrorOnColumnBuildFailure();
-    IngestionConfig ingestionConfig = _tableConfig.getIngestionConfig();
-    if (ingestionConfig != null && ingestionConfig.getTransformConfigs() != 
null) {
-      List<TransformConfig> transformConfigs = 
ingestionConfig.getTransformConfigs();
-      for (TransformConfig transformConfig : transformConfigs) {
-        if (transformConfig.getColumnName().equals(column)) {
-          String transformFunction = transformConfig.getTransformFunction();
-          FunctionEvaluator functionEvaluator = 
FunctionEvaluatorFactory.getExpressionEvaluator(transformFunction);
-
-          // Check if all arguments exist in the segment
-          // TODO: Support chained derived column
-          List<String> arguments = functionEvaluator.getArguments();
-          List<ColumnMetadata> argumentsMetadata = new 
ArrayList<>(arguments.size());
-          for (String argument : arguments) {
-            ColumnMetadata columnMetadata = 
_segmentMetadata.getColumnMetadataFor(argument);
-            if (columnMetadata == null) {
-              LOGGER.warn("Assigning default value to derived column: {} 
because argument: {} does not exist in the "
-                  + "segment", column, argument);
-              createDefaultValueColumnV1Indices(column);
-              return true;
-            }
-            // TODO: Support creation of derived columns from forward index 
disabled columns
-            if (!_segmentWriter.hasIndexFor(argument, 
StandardIndexes.forward())) {
-              throw new UnsupportedOperationException(String.format("Operation 
not supported! Cannot create a derived "
-                      + "column %s because argument: %s does not have a 
forward index. Enable forward index and "
-                      + "refresh/backfill the segments to create a derived 
column from source column", column,
-                  argument));
-            }
-            argumentsMetadata.add(columnMetadata);
-          }
-
-          // TODO: Support forward index disabled derived column
-          if (isForwardIndexDisabled(column)) {
-            LOGGER.warn("Skip creating forward index disabled derived column: 
{}", column);
-            if (errorOnFailure) {
-              throw new UnsupportedOperationException(
-                  String.format("Failed to create forward index disabled 
derived column: %s", column));
-            }
-            return false;
+    TransformConfig transformConfig = getTransformConfig(column);
+    if (transformConfig != null && _cyclicColumns.contains(column)) {
+      // No order satisfies a cycle, so every column in it takes the default 
value rather than deriving from a
+      // sibling that was itself defaulted.
+      LOGGER.warn("Assigning default value to derived column: {} because it is 
part of a transform function "
+          + "dependency cycle", column);
+      createDefaultValueColumnV1Indices(column);
+      recordCreatedColumnMetadata(column);
+      return true;
+    }
+    if (transformConfig != null) {
+      String transformFunction = transformConfig.getTransformFunction();
+      FunctionEvaluator functionEvaluator = getFunctionEvaluator(column, 
transformFunction);
+
+      // Check if all arguments can be read from the segment
+      List<String> arguments = functionEvaluator.getArguments();
+      List<DerivedColumnArgument> argumentSources = new 
ArrayList<>(arguments.size());
+      for (String argument : arguments) {
+        if (_cyclicColumns.contains(argument)) {
+          // A cycle member is created after everything that reads it and 
takes its own default value. Reading it
+          // here would see either nothing or, when it already exists and is 
only being updated, its pre-update
+          // values, baked into this column for good. Fall back explicitly 
rather than rely on absent metadata.
+          LOGGER.warn("Assigning default value to derived column: {} because 
argument: {} is part of a transform "
+              + "function dependency cycle", column, argument);
+          createDefaultValueColumnV1Indices(column);
+          recordCreatedColumnMetadata(column);
+          return true;
+        }
+        Object[] createdValues = _createdColumnValues.get(argument);
+        if (createdValues != null) {
+          // Derived earlier in this same run: use the expression results it 
produced, not the converted values its
+          // index now holds, so the chain matches what ingestion would have 
computed.
+          argumentSources.add(DerivedColumnArgument.ofValues(createdValues));
+          continue;
+        }
+        ColumnMetadata columnMetadata = getColumnMetadata(argument);
+        if (columnMetadata == null) {
+          LOGGER.warn("Assigning default value to derived column: {} because 
argument: {} does not exist in the "
+              + "segment", column, argument);
+          createDefaultValueColumnV1Indices(column);
+          recordCreatedColumnMetadata(column);
+          return true;
+        }
+        // The values are read through the argument's forward index. When the 
argument has it disabled, regenerate
+        // it from the dictionary and inverted index for the duration of this 
run. Regeneration can change the
+        // metadata the reader needs, so take the refreshed copy it returns.
+        columnMetadata = materializeSourceForwardIndex(argument, 
columnMetadata);
+        if (columnMetadata == null) {
+          // Regeneration needs the argument's dictionary and inverted index; 
without them the only way to get the
+          // values back is a refresh or back-fill. This used to be an 
unconditional failure, so keep failing when
+          // the table asks for it, and otherwise degrade the way a missing 
argument already does.
+          if (errorOnFailure) {
+            throw new UnsupportedOperationException(String.format("Operation 
not supported! Cannot create a derived "
+                    + "column %s because argument: %s does not have a forward 
index and it could not be regenerated "
+                    + "from its dictionary and inverted index. Enable forward 
index and refresh/backfill the segments "
+                    + "to create a derived column from source column", column, 
argument));
           }
+          LOGGER.warn("Assigning default value to derived column: {} because 
argument: {} has no forward index and "
+              + "it could not be regenerated", column, argument);
+          createDefaultValueColumnV1Indices(column);
+          recordCreatedColumnMetadata(column);
+          return true;
+        }
+        argumentSources.add(DerivedColumnArgument.ofColumn(columnMetadata));
+      }
 
-          try {
-            createDerivedColumnV1Indices(column, functionEvaluator, 
argumentsMetadata, errorOnFailure);
-            return true;
-          } catch (Exception e) {
-            LOGGER.error("Caught exception while creating derived column: {} 
with transform function: {}", column,
-                transformFunction, e);
-            if (errorOnFailure) {
-              throw e;
-            }
-            return false;
-          }
+      try {
+        // A forward-index-disabled derived column is built with its forward 
index like any other; ForwardIndexHandler
+        // queues DISABLE_FORWARD_INDEX for it right after this handler and 
drops the index in its post-update
+        // cleanup, once every handler that needs it (the inverted index, 
above all) has read it.
+        createDerivedColumnV1Indices(column, functionEvaluator, 
argumentSources, errorOnFailure);
+        recordCreatedColumnMetadata(column);
+        return true;
+      } catch (Exception e) {
+        LOGGER.error("Caught exception while creating derived column: {} with 
transform function: {}", column,
+            transformFunction, e);
+        if (errorOnFailure) {
+          throw e;
         }
+        return false;
       }
     }
 
     createDefaultValueColumnV1Indices(column);
+    recordCreatedColumnMetadata(column);
     return true;
   }
 
@@ -532,17 +623,23 @@ public abstract class BaseDefaultColumnHandler implements 
DefaultColumnHandler {
   }
 
   /// Helper method to create the V1 indices (dictionary and forward index) 
for a column with derived values.
-  /// TODO:
-  ///   - Support chained derived column
-  ///   - Support forward index disabled derived column
   private void createDerivedColumnV1Indices(String column, FunctionEvaluator 
functionEvaluator,
-      List<ColumnMetadata> argumentsMetadata, boolean errorOnFailure)
+      List<DerivedColumnArgument> argumentSources, boolean errorOnFailure)
       throws Exception {
-    // Initialize value readers for all arguments
-    int numArguments = argumentsMetadata.size();
+    // Initialize value readers for the arguments that are read from the 
segment. An argument derived earlier in this
+    // same run is served from the values it produced instead (see 
_createdColumnValues).
+    int numArguments = argumentSources.size();
     List<ValueReader> valueReaders = new ArrayList<>(numArguments);
-    for (ColumnMetadata argumentMetadata : argumentsMetadata) {
-      valueReaders.add(new ValueReader(argumentMetadata));
+    List<IntFunction<Object>> argumentValues = new ArrayList<>(numArguments);
+    for (DerivedColumnArgument argumentSource : argumentSources) {
+      Object[] values = argumentSource.getValues();
+      if (values != null) {
+        argumentValues.add(docId -> values[docId]);
+      } else {
+        ValueReader valueReader = new 
ValueReader(argumentSource.getColumnMetadata());
+        valueReaders.add(valueReader);
+        argumentValues.add(valueReader::getValue);
+      }
     }
 
     FieldSpec fieldSpec = _schema.getFieldSpecFor(column);
@@ -561,10 +658,14 @@ public abstract class BaseDefaultColumnHandler implements 
DefaultColumnHandler {
       Object[] inputValues = new Object[numArguments];
       int numDocs = _segmentMetadata.getTotalDocs();
       Object[] outputValues = new Object[numDocs];
+      // The expression results as the rest of the chain has to see them: 
nulls still null, values not yet converted
+      // to the stored type. Ingestion evaluates every expression before 
either of those happens, so a column reading
+      // this one has to be handed this array rather than what the index ends 
up holding. Null when nothing reads it.
+      Object[] expressionValues = newChainValues(column, numDocs);
       PinotDataType outputValueType = null;
       for (int i = 0; i < numDocs; i++) {
         for (int j = 0; j < numArguments; j++) {
-          inputValues[j] = valueReaders.get(j).getValue(i);
+          inputValues[j] = argumentValues.get(j).apply(i);
         }
 
         Object outputValue = null;
@@ -584,6 +685,10 @@ public abstract class BaseDefaultColumnHandler implements 
DefaultColumnHandler {
           }
         }
 
+        if (expressionValues != null) {
+          expressionValues[i] = outputValue;
+        }
+
         if (outputValue == null) {
           outputValue = fieldSpec.getDefaultNullValue();
           if (nullValueVectorCreator != null) {
@@ -611,9 +716,12 @@ public abstract class BaseDefaultColumnHandler implements 
DefaultColumnHandler {
       DictionaryIndexConfig dictionaryIndexConfig =
           fieldIndexConfigs != null ? 
fieldIndexConfigs.getConfig(StandardIndexes.dictionary())
               : DictionaryIndexConfig.DEFAULT;
-      boolean createDictionary = dictionaryIndexConfig.isEnabled();
+      // A forward-index-disabled column is served by its dictionary plus 
inverted index, so it must be dictionary
+      // encoded whatever the dictionary config says -- the same requirement 
Pinot enforces for non-derived columns.
+      boolean forwardIndexDisabled = isForwardIndexDisabled(column);
+      boolean createDictionary = dictionaryIndexConfig.isEnabled() || 
forwardIndexDisabled;
       boolean useNoDictColumnStatsCollector = false;
-      if (!dictionaryIndexConfig.isEnabled()) {
+      if (!createDictionary) {
         useNoDictColumnStatsCollector = 
ClusterConfigForTable.useOptimizedNoDictCollector(_tableConfig);
       }
       StatsCollectorConfig statsCollectorConfig = new 
StatsCollectorConfig(_tableConfig, _schema, null);
@@ -761,6 +869,10 @@ public abstract class BaseDefaultColumnHandler implements 
DefaultColumnHandler {
       } else {
         createDerivedColumnForwardIndexWithoutDictionary(column, fieldSpec, 
outputValues, statsCollector);
       }
+      // Only once the column exists: a column that failed to be created must 
not feed the rest of the chain.
+      if (expressionValues != null) {
+        _createdColumnValues.put(column, expressionValues);
+      }
     } finally {
       for (ValueReader valueReader : valueReaders) {
         valueReader.close();
@@ -1219,6 +1331,346 @@ public abstract class BaseDefaultColumnHandler 
implements DefaultColumnHandler {
     compressionMetadata.applyTo(_segmentProperties, column);
   }
 
+  /// Reorders the actions so that a column is created after every column it 
reads that is also being created in
+  /// this run -- the dependency that makes chained derived columns work, and 
that also lets a derived column read a
+  /// plain default column added in the same reload. Non-derived columns keep 
their original relative order.
+  ///
+  /// A dependency cycle cannot be satisfied in any order, so the columns in 
it are emitted last, unordered: each
+  /// still gets created, falling back to its default value because its 
argument is not in the segment yet. The
+  /// cycle is logged rather than failing the segment load.
+  private Map<String, DefaultColumnAction> orderByDependencies(Map<String, 
DefaultColumnAction> actionMap) {
+    Map<String, List<String>> dependencies = new HashMap<>();
+    for (Map.Entry<String, DefaultColumnAction> entry : actionMap.entrySet()) {
+      if (!entry.getValue().isRemoveAction()) {
+        List<String> arguments = derivedColumnArguments(entry.getKey());
+        if (arguments != null) {
+          List<String> pending = new ArrayList<>(arguments.size());
+          for (String argument : arguments) {
+            // Only columns written in this run constrain the order; anything 
already in its final shape is readable.
+            // An updated argument counts as much as an added one: its values 
are only correct once its own action has
+            // run, so reading it earlier would bake the pre-update values 
into the derived column for good.
+            DefaultColumnAction argumentAction = actionMap.get(argument);
+            if (argumentAction != null && !argumentAction.isRemoveAction() && 
!argument.equals(entry.getKey())) {
+              pending.add(argument);
+            }
+          }
+          if (!pending.isEmpty()) {
+            dependencies.put(entry.getKey(), pending);
+          }
+        }
+      }
+    }
+    if (dependencies.isEmpty()) {
+      return actionMap;
+    }
+
+    markCyclicColumns(dependencies);
+
+    Map<String, DefaultColumnAction> ordered = new 
LinkedHashMap<>(actionMap.size());
+    for (String column : actionMap.keySet()) {
+      visitForOrdering(column, actionMap, dependencies, ordered);
+    }
+    // Columns dropped by a cycle still have to be created; they land last and 
take the default-value path.
+    for (Map.Entry<String, DefaultColumnAction> entry : actionMap.entrySet()) {
+      ordered.putIfAbsent(entry.getKey(), entry.getValue());
+    }
+    return ordered;
+  }
+
+  private void visitForOrdering(String column, Map<String, 
DefaultColumnAction> actionMap,
+      Map<String, List<String>> dependencies, Map<String, DefaultColumnAction> 
ordered) {
+    // A column on a cycle is left out here and appended at the end, so that 
anything reading it is created first and
+    // takes the fallback for a cyclic argument (see [#createColumnV1Indices]) 
instead of deriving a value from a
+    // sibling's default. Skipping those columns is also what bounds this 
traversal: every edge that closes a cycle
+    // leads into one of them.
+    if (ordered.containsKey(column) || _cyclicColumns.contains(column) || 
!actionMap.containsKey(column)) {
+      return;
+    }
+    for (String dependency : dependencies.getOrDefault(column, List.of())) {
+      visitForOrdering(dependency, actionMap, dependencies, ordered);
+    }
+    // A dependency stuck in a cycle is absent from `ordered`; emitting this 
column anyway is safe because the cyclic
+    // argument makes it take the default-value path.
+    ordered.put(column, actionMap.get(column));
+  }
+
+  /// Records every column that sits on a dependency cycle, so that each of 
them takes its own default value instead
+  /// of deriving from a sibling that was itself defaulted.
+  ///
+  /// Cycle membership is a strongly connected component of the dependency 
graph, not just the path on which a
+  /// depth-first search happens to find a back edge. With `a = plus(b, c)`, 
`b = plus(a, 1)` and `c = plus(b, 1)`
+  /// the first back edge only closes over `a` and `b`, yet `c` reaches itself 
through `b` and `a` just the same, so
+  /// all three are cyclic. Tarjan's algorithm names the whole component in 
one pass.
+  private void markCyclicColumns(Map<String, List<String>> dependencies) {
+    SccState state = new SccState();
+    for (String column : dependencies.keySet()) {
+      if (!state._index.containsKey(column)) {
+        strongConnect(column, dependencies, state);
+      }
+    }
+  }
+
+  private void strongConnect(String column, Map<String, List<String>> 
dependencies, SccState state) {
+    int index = state._nextIndex++;
+    state._index.put(column, index);
+    state._lowLink.put(column, index);
+    state._stack.push(column);
+    state._onStack.add(column);
+
+    for (String dependency : dependencies.getOrDefault(column, List.of())) {
+      Integer dependencyIndex = state._index.get(dependency);
+      if (dependencyIndex == null) {
+        strongConnect(dependency, dependencies, state);
+        state._lowLink.put(column, Math.min(state._lowLink.get(column), 
state._lowLink.get(dependency)));
+      } else if (state._onStack.contains(dependency)) {
+        state._lowLink.put(column, Math.min(state._lowLink.get(column), 
dependencyIndex));
+      }
+    }
+
+    if (state._lowLink.get(column) == index) {
+      List<String> component = new ArrayList<>();
+      String member;
+      do {
+        member = state._stack.pop();
+        state._onStack.remove(member);
+        component.add(member);
+      } while (!member.equals(column));
+      // A component of one is a plain column: a self-reference never gets an 
edge in the first place, since a
+      // transform reading the column it produces is reading the pre-reload 
values and is left alone.
+      if (component.size() > 1) {
+        LOGGER.warn("Derived columns form a dependency cycle: {}. They will be 
assigned default values instead of "
+            + "being derived; break the cycle in the transform configs.", 
component);
+        _cyclicColumns.addAll(component);
+      }
+    }
+  }
+
+  /// The bookkeeping of one run of Tarjan's algorithm over the dependency 
graph (see [#markCyclicColumns]).
+  private static class SccState {
+    final Map<String, Integer> _index = new HashMap<>();
+    final Map<String, Integer> _lowLink = new HashMap<>();
+    final Deque<String> _stack = new ArrayDeque<>();
+    final Set<String> _onStack = new HashSet<>();
+    int _nextIndex;
+  }
+
+  /// The arguments of the transform function that derives this column, or 
null when the column is not derived.
+  @Nullable
+  private List<String> derivedColumnArguments(String column) {
+    TransformConfig transformConfig = getTransformConfig(column);
+    if (transformConfig == null) {
+      return null;
+    }
+    try {
+      return getFunctionEvaluator(column, 
transformConfig.getTransformFunction()).getArguments();
+    } catch (Exception e) {
+      // An unparseable transform function fails later in 
createColumnV1Indices with the full context; ordering just
+      // treats the column as dependency-free.
+      return null;
+    }
+  }
+
+  @Nullable
+  private TransformConfig getTransformConfig(String column) {
+    if (_transformConfigsByColumn == null) {
+      buildTransformConfigIndex();
+    }
+    return _transformConfigsByColumn.get(column);
+  }
+
+  /// Indexes the transform configs by the column they produce, and collects 
every column their expressions read.
+  /// An unparseable expression is left out of the argument set; it fails 
later in [#createColumnV1Indices] with the
+  /// transform function in the message.
+  private void buildTransformConfigIndex() {
+    _transformConfigsByColumn = new HashMap<>();
+    _derivedColumnArgumentNames = new HashSet<>();
+    IngestionConfig ingestionConfig = _tableConfig.getIngestionConfig();
+    List<TransformConfig> transformConfigs =
+        ingestionConfig != null ? ingestionConfig.getTransformConfigs() : null;
+    if (transformConfigs == null) {
+      return;
+    }
+    for (TransformConfig transformConfig : transformConfigs) {
+      _transformConfigsByColumn.putIfAbsent(transformConfig.getColumnName(), 
transformConfig);
+      try {
+        _derivedColumnArgumentNames.addAll(
+            getFunctionEvaluator(transformConfig.getColumnName(), 
transformConfig.getTransformFunction())
+                .getArguments());
+      } catch (Exception e) {
+        LOGGER.debug("Could not parse the transform function of column: {} 
while indexing the transform configs",
+            transformConfig.getColumnName(), e);
+      }
+    }
+  }
+
+  /// The parsed expression for a derived column. Cached because ordering the 
columns and deriving them both need it.
+  /// A parse failure propagates and is not cached, so the caller still sees 
it.
+  private FunctionEvaluator getFunctionEvaluator(String column, String 
transformFunction) {
+    return _functionEvaluators.computeIfAbsent(column,
+        k -> 
FunctionEvaluatorFactory.getExpressionEvaluator(transformFunction));
+  }
+
+  /// Metadata for a column, including one created earlier in this same run 
(see [#_createdColumnMetadata]).
+  @Nullable
+  private ColumnMetadata getColumnMetadata(String column) {
+    ColumnMetadata created = _createdColumnMetadata.get(column);
+    return created != null ? created : 
_segmentMetadata.getColumnMetadataFor(column);
+  }
+
+  /// Records the metadata just written for a created column so later columns 
in this run can read it. Only columns
+  /// that a transform function actually reads are read back: a schema 
evolution adding hundreds of plain default
+  /// columns would otherwise re-parse the metadata of every one of them for 
nothing.
+  private void recordCreatedColumnMetadata(String column) {
+    if (_transformConfigsByColumn == null) {
+      buildTransformConfigIndex();
+    }
+    if (!_derivedColumnArgumentNames.contains(column)) {
+      return;
+    }
+    try {
+      _createdColumnMetadata.put(column,
+          ColumnMetadataImpl.fromPropertiesConfiguration(_segmentProperties, 
_segmentMetadata.getTotalDocs(), column));
+    } catch (Exception e) {
+      // Only chained reads need this; a failure here must not fail the column 
that was created successfully.
+      LOGGER.warn("Could not read back the metadata of newly created column: 
{}; a derived column reading it in this "
+          + "same reload will fall back to its default value", column, e);
+    }
+  }
+
+  /// An array to collect the raw expression results of a derived column in, 
to hand to a column later in the chain
+  /// (see [#_createdColumnValues]), or null when no transform function reads 
this column and nothing would ever look
+  /// at them.
+  @Nullable
+  private Object[] newChainValues(String column, int numDocs) {
+    if (_transformConfigsByColumn == null) {
+      buildTransformConfigIndex();
+    }
+    return _derivedColumnArgumentNames.contains(column) ? new Object[numDocs] 
: null;
+  }
+
+  /// Makes a forward index available for a source column that has it 
disabled, by regenerating it from the
+  /// dictionary and inverted index. The regenerated index is temporary: 
[#removeTemporaryForwardIndexes] drops it
+  /// once every default column is created, so the column keeps the shape its 
config asks for.
+  ///
+  /// Returns the metadata to read the column with -- `columnMetadata` when 
the forward index was already there, and
+  /// a refreshed copy when it was rebuilt, since the rebuild can change the 
encoding and the entry count the reader
+  /// derives its offsets from.
+  ///
+  /// Returns null when regeneration is not possible -- no segment directory 
(the metadata-only path), or the
+  /// dictionary / inverted index the rebuild needs is missing. The caller 
then falls back to the default value
+  /// rather than failing the load.
+  @Nullable
+  private ColumnMetadata materializeSourceForwardIndex(String column, 
ColumnMetadata columnMetadata) {
+    if (_segmentWriter.hasIndexFor(column, StandardIndexes.forward())) {
+      return columnMetadata;
+    }
+    if (_segmentDirectory == null) {
+      return null;
+    }
+    if (!_segmentWriter.hasIndexFor(column, StandardIndexes.dictionary())
+        || !_segmentWriter.hasIndexFor(column, StandardIndexes.inverted())) {
+      LOGGER.warn("Cannot regenerate the forward index of column: {} to derive 
from it: it needs both a dictionary "
+          + "({}) and an inverted index ({})", column,
+          _segmentWriter.hasIndexFor(column, StandardIndexes.dictionary()) ? 
"present" : "missing",
+          _segmentWriter.hasIndexFor(column, StandardIndexes.inverted()) ? 
"present" : "missing");
+      return null;
+    }
+    FieldIndexConfigs fieldIndexConfigs = 
_indexLoadingConfig.getFieldIndexConfig(column);
+    if (fieldIndexConfigs == null) {
+      return null;
+    }
+    try {
+      LOGGER.info("Temporarily regenerating the forward index of 
forward-index-disabled column: {} so a derived "
+          + "column can read it", column);
+      // The creator writes metadata.properties itself 
(SegmentMetadataUtils#updateMetadataProperties). Our own
+      // in-memory copy was loaded before this point and is saved at the end 
of the run, so flush the columns
+      // created so far to disk and re-read afterwards -- otherwise the save 
would discard the creator's writes.
+      if (_segmentProperties != null) {
+        SegmentMetadataUtils.savePropertiesConfiguration(_segmentProperties, 
_segmentMetadata.getIndexDir());
+      }
+      // A column created earlier in this run is not in the segment 
directory's metadata -- it only exists in the
+      // properties just flushed -- so hand the creator the metadata we 
already hold instead of letting it look up
+      // one that is not there.
+      new 
InvertedIndexAndDictionaryBasedForwardIndexCreator(_segmentDirectory, 
_segmentWriter, _tableConfig, column,
+          fieldIndexConfigs, true, columnMetadata).regenerateForwardIndex();
+      if (_segmentProperties != null) {
+        _segmentProperties = 
SegmentMetadataUtils.getPropertiesConfiguration(_segmentMetadata);
+      }
+    } catch (Exception e) {
+      LOGGER.warn("Failed to regenerate the forward index of column: {}", 
column, e);
+      return null;
+    }
+    if (!_segmentWriter.hasIndexFor(column, StandardIndexes.forward())) {
+      return null;
+    }
+    _tmpForwardIndexColumns.add(column);
+    return refreshColumnMetadata(column, columnMetadata);
+  }
+
+  /// Re-reads a column's metadata after its forward index was regenerated. 
The rebuild rewrites the properties the
+  /// readers work from -- the encoding flips when a raw index is restored, 
and `TOTAL_NUMBER_OF_ENTRIES` shrinks
+  /// when a disabled multi-value source held duplicates within a row -- so 
the copy taken beforehand would compute
+  /// the wrong offsets. Falls back to that copy when the re-read fails, which 
leaves the previous behaviour.
+  private ColumnMetadata refreshColumnMetadata(String column, ColumnMetadata 
previous) {
+    if (_segmentProperties == null) {
+      return previous;
+    }
+    try {
+      ColumnMetadata refreshed =
+          ColumnMetadataImpl.fromPropertiesConfiguration(_segmentProperties, 
_segmentMetadata.getTotalDocs(), column);
+      // Every later read of this column has to get the refreshed copy, not 
just the caller holding it now: a
+      // transform as plain as `plus(source, source)` reads the column twice, 
and the second read finds the
+      // regenerated forward index already in place and so never comes back 
through here.
+      _createdColumnMetadata.put(column, refreshed);
+      return refreshed;
+    } catch (Exception e) {
+      LOGGER.warn("Could not re-read the metadata of column: {} after 
regenerating its forward index", column, e);
+      return previous;
+    }
+  }
+
+  /// Drops the forward indexes regenerated by 
[#materializeSourceForwardIndex]. Mirrors
+  /// `BaseIndexHandler#postUpdateIndicesCleanup`, which does the same for the 
index handlers.
+  private void removeTemporaryForwardIndexes() {
+    for (String column : _tmpForwardIndexColumns) {
+      try {
+        _segmentWriter.removeIndex(column, StandardIndexes.forward());
+      } catch (Exception e) {
+        LOGGER.warn("Could not remove the temporary forward index of column: 
{}", column, e);
+      }
+    }
+    _tmpForwardIndexColumns.clear();
+  }
+
+  /// Where one argument of a transform function gets its values from: a 
column in the segment, read through its
+  /// forward index, or the results a derived column produced earlier in this 
same run.
+  private static class DerivedColumnArgument {
+    private final ColumnMetadata _columnMetadata;
+    private final Object[] _values;
+
+    private DerivedColumnArgument(@Nullable ColumnMetadata columnMetadata, 
@Nullable Object[] values) {
+      _columnMetadata = columnMetadata;
+      _values = values;
+    }
+
+    static DerivedColumnArgument ofColumn(ColumnMetadata columnMetadata) {
+      return new DerivedColumnArgument(columnMetadata, null);
+    }
+
+    static DerivedColumnArgument ofValues(Object[] values) {
+      return new DerivedColumnArgument(null, values);
+    }
+
+    ColumnMetadata getColumnMetadata() {
+      return _columnMetadata;
+    }
+
+    @Nullable
+    Object[] getValues() {
+      return _values;
+    }
+  }
+
   @SuppressWarnings("rawtypes")
   private class ValueReader implements Closeable {
     final ForwardIndexReader _forwardIndexReader;
diff --git 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/loader/defaultcolumn/DefaultColumnHandlerFactory.java
 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/loader/defaultcolumn/DefaultColumnHandlerFactory.java
index 5058ac91e80..778048dd7e6 100644
--- 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/loader/defaultcolumn/DefaultColumnHandlerFactory.java
+++ 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/loader/defaultcolumn/DefaultColumnHandlerFactory.java
@@ -19,6 +19,7 @@
 package org.apache.pinot.segment.local.segment.index.loader.defaultcolumn;
 
 import java.io.File;
+import javax.annotation.Nullable;
 import org.apache.pinot.segment.local.segment.index.loader.IndexLoadingConfig;
 import org.apache.pinot.segment.spi.creator.SegmentVersion;
 import org.apache.pinot.segment.spi.index.metadata.SegmentMetadataImpl;
@@ -31,10 +32,20 @@ public class DefaultColumnHandlerFactory {
 
   public static DefaultColumnHandler getDefaultColumnHandler(File indexDir, 
SegmentMetadataImpl segmentMetadata,
       IndexLoadingConfig indexLoadingConfig, SegmentDirectory.Writer 
segmentWriter) {
+    return getDefaultColumnHandler(indexDir, segmentMetadata, 
indexLoadingConfig, segmentWriter, null);
+  }
+
+  /// `segmentDirectory` lets the handler regenerate the forward index of a 
forward-index-disabled source column so a
+  /// derived column can read it; pass null when there is none to work with.
+  public static DefaultColumnHandler getDefaultColumnHandler(File indexDir, 
SegmentMetadataImpl segmentMetadata,
+      IndexLoadingConfig indexLoadingConfig, SegmentDirectory.Writer 
segmentWriter,
+      @Nullable SegmentDirectory segmentDirectory) {
     if (segmentMetadata.getVersion() == SegmentVersion.v3) {
-      return new V3DefaultColumnHandler(indexDir, segmentMetadata, 
indexLoadingConfig, segmentWriter);
+      return new V3DefaultColumnHandler(indexDir, segmentMetadata, 
indexLoadingConfig, segmentWriter,
+          segmentDirectory);
     } else {
-      return new V1DefaultColumnHandler(indexDir, segmentMetadata, 
indexLoadingConfig, segmentWriter);
+      return new V1DefaultColumnHandler(indexDir, segmentMetadata, 
indexLoadingConfig, segmentWriter,
+          segmentDirectory);
     }
   }
 }
diff --git 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/loader/defaultcolumn/V1DefaultColumnHandler.java
 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/loader/defaultcolumn/V1DefaultColumnHandler.java
index 1a2935c4c6b..56cac3a6436 100644
--- 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/loader/defaultcolumn/V1DefaultColumnHandler.java
+++ 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/loader/defaultcolumn/V1DefaultColumnHandler.java
@@ -19,6 +19,7 @@
 package org.apache.pinot.segment.local.segment.index.loader.defaultcolumn;
 
 import java.io.File;
+import javax.annotation.Nullable;
 import org.apache.pinot.segment.local.segment.index.loader.IndexLoadingConfig;
 import org.apache.pinot.segment.spi.index.metadata.SegmentMetadataImpl;
 import org.apache.pinot.segment.spi.store.SegmentDirectory;
@@ -34,6 +35,12 @@ public class V1DefaultColumnHandler extends 
BaseDefaultColumnHandler {
     super(indexDir, segmentMetadata, indexLoadingConfig, segmentWriter);
   }
 
+  public V1DefaultColumnHandler(File indexDir, SegmentMetadataImpl 
segmentMetadata,
+      IndexLoadingConfig indexLoadingConfig, SegmentDirectory.Writer 
segmentWriter,
+      @Nullable SegmentDirectory segmentDirectory) {
+    super(indexDir, segmentMetadata, indexLoadingConfig, segmentWriter, 
segmentDirectory);
+  }
+
   @Override
   protected boolean updateDefaultColumn(String column, DefaultColumnAction 
action)
       throws Exception {
diff --git 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/loader/defaultcolumn/V3DefaultColumnHandler.java
 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/loader/defaultcolumn/V3DefaultColumnHandler.java
index c4ac70bad5d..1d3a1cca772 100644
--- 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/loader/defaultcolumn/V3DefaultColumnHandler.java
+++ 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/loader/defaultcolumn/V3DefaultColumnHandler.java
@@ -20,6 +20,7 @@ package 
org.apache.pinot.segment.local.segment.index.loader.defaultcolumn;
 
 import com.google.common.base.Preconditions;
 import java.io.File;
+import javax.annotation.Nullable;
 import org.apache.pinot.segment.local.segment.index.loader.IndexLoadingConfig;
 import org.apache.pinot.segment.local.segment.index.loader.LoaderUtils;
 import org.apache.pinot.segment.spi.V1Constants;
@@ -39,6 +40,12 @@ public class V3DefaultColumnHandler extends 
BaseDefaultColumnHandler {
     super(indexDir, segmentMetadata, indexLoadingConfig, segmentWriter);
   }
 
+  public V3DefaultColumnHandler(File indexDir, SegmentMetadataImpl 
segmentMetadata,
+      IndexLoadingConfig indexLoadingConfig, SegmentDirectory.Writer 
segmentWriter,
+      @Nullable SegmentDirectory segmentDirectory) {
+    super(indexDir, segmentMetadata, indexLoadingConfig, segmentWriter, 
segmentDirectory);
+  }
+
   @Override
   protected boolean updateDefaultColumn(String column, DefaultColumnAction 
action)
       throws Exception {
@@ -61,7 +68,6 @@ public class V3DefaultColumnHandler extends 
BaseDefaultColumnHandler {
     FieldSpec fieldSpec = _schema.getFieldSpecFor(column);
     Preconditions.checkNotNull(fieldSpec);
     boolean isSingleValue = fieldSpec.isSingleValueField();
-    boolean forwardIndexDisabled = !isSingleValue && 
isForwardIndexDisabled(column);
     File forwardIndexFile = null;
     File invertedIndexFile = null;
 
@@ -74,15 +80,17 @@ public class V3DefaultColumnHandler extends 
BaseDefaultColumnHandler {
         forwardIndexFile = new File(_indexDir, column + 
V1Constants.Indexes.RAW_SV_FORWARD_INDEX_FILE_EXTENSION);
       }
     } else {
-      if (forwardIndexDisabled) {
-        // An inverted index is created instead of forward index for 
multi-value columns with forward index disabled
-        // Note that we don't currently support creation of forward index 
disabled derived columns
+      forwardIndexFile = new File(_indexDir, column + 
V1Constants.Indexes.UNSORTED_MV_FORWARD_INDEX_FILE_EXTENSION);
+      if (!forwardIndexFile.exists()) {
+        forwardIndexFile = new File(_indexDir, column + 
V1Constants.Indexes.RAW_MV_FORWARD_INDEX_FILE_EXTENSION);
+      }
+      if (!forwardIndexFile.exists() && isForwardIndexDisabled(column)) {
+        // A forward-index-disabled multi-value DEFAULT column is written as 
an inverted index instead of a forward
+        // index (see 
BaseDefaultColumnHandler#createDefaultValueColumnV1Indices). A DERIVED column 
always writes a
+        // forward index -- ForwardIndexHandler drops it in its post-update 
cleanup once the inverted index has been
+        // built from it -- so the on-disk file, not the config, decides which 
one to transfer here.
+        forwardIndexFile = null;
         invertedIndexFile = new File(_indexDir, column + 
V1Constants.Indexes.BITMAP_INVERTED_INDEX_FILE_EXTENSION);
-      } else {
-        forwardIndexFile = new File(_indexDir, column + 
V1Constants.Indexes.UNSORTED_MV_FORWARD_INDEX_FILE_EXTENSION);
-        if (!forwardIndexFile.exists()) {
-          forwardIndexFile = new File(_indexDir, column + 
V1Constants.Indexes.RAW_MV_FORWARD_INDEX_FILE_EXTENSION);
-        }
       }
     }
 
diff --git 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/loader/SegmentPreProcessorTest.java
 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/loader/SegmentPreProcessorTest.java
index 12105b176e6..ccc323e742c 100644
--- 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/loader/SegmentPreProcessorTest.java
+++ 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/loader/SegmentPreProcessorTest.java
@@ -46,6 +46,7 @@ import 
org.apache.pinot.segment.local.segment.creator.impl.SegmentIndexCreationD
 import 
org.apache.pinot.segment.local.segment.index.converter.SegmentV1V2ToV3FormatConverter;
 import 
org.apache.pinot.segment.local.segment.index.loader.columnminmaxvalue.ColumnMinMaxValueGeneratorMode;
 import org.apache.pinot.segment.local.segment.readers.GenericRowRecordReader;
+import org.apache.pinot.segment.local.segment.readers.PinotSegmentRecordReader;
 import org.apache.pinot.segment.local.segment.store.SegmentLocalFSDirectory;
 import org.apache.pinot.segment.local.startree.StarTreeBuilderUtils;
 import org.apache.pinot.segment.local.utils.SegmentOperationsThrottler;
@@ -80,8 +81,10 @@ import org.apache.pinot.spi.config.table.TableConfig;
 import org.apache.pinot.spi.config.table.TableType;
 import org.apache.pinot.spi.config.table.ingestion.IngestionConfig;
 import org.apache.pinot.spi.config.table.ingestion.TransformConfig;
+import org.apache.pinot.spi.data.DimensionFieldSpec;
 import org.apache.pinot.spi.data.FieldSpec;
 import org.apache.pinot.spi.data.FieldSpec.DataType;
+import org.apache.pinot.spi.data.MetricFieldSpec;
 import org.apache.pinot.spi.data.Schema;
 import org.apache.pinot.spi.data.readers.GenericRow;
 import org.apache.pinot.spi.utils.ByteArray;
@@ -185,6 +188,7 @@ public class SegmentPreProcessorTest implements 
PinotBuffersAfterClassCheckRule
   private List<StarTreeIndexConfig> _starTreeIndexConfigs;
   private boolean _enableDefaultStarTree;
   private boolean _compressionStatsEnabled;
+  private boolean _nullHandlingEnabled;
 
   public SegmentPreProcessorTest()
       throws IOException {
@@ -258,6 +262,7 @@ public class SegmentPreProcessorTest implements 
PinotBuffersAfterClassCheckRule
     _starTreeIndexConfigs = null;
     _enableDefaultStarTree = false;
     _compressionStatsEnabled = false;
+    _nullHandlingEnabled = true;
   }
 
   @AfterMethod
@@ -274,13 +279,28 @@ public class SegmentPreProcessorTest implements 
PinotBuffersAfterClassCheckRule
     }
   }
 
+  /// Builds a segment whose own generation applies the schema given -- 
including any transform config in
+  /// `_ingestionConfig`, which is how a derived column is produced on the 
ingestion path rather than at load.
+  private void buildSegment(SegmentVersion segmentVersion, Schema schema)
+      throws Exception {
+    buildV1Segment(schema);
+    if (segmentVersion == SegmentVersion.v3) {
+      convertV1SegmentToV3();
+    }
+  }
+
   private void buildV1Segment()
       throws Exception {
+    buildV1Segment(_schema);
+  }
+
+  private void buildV1Segment(Schema schema)
+      throws Exception {
     FileUtils.deleteQuietly(TEMP_DIR);
 
     SegmentGeneratorConfig config =
         SegmentTestUtils.getSegmentGeneratorConfigWithSchema(_avroFile, 
TEMP_DIR, RAW_TABLE_NAME, createTableConfig(),
-            _schema);
+            schema);
     config.setInstanceType(InstanceType.SERVER);
     config.setOutDir(TEMP_DIR.getPath());
     config.setSegmentName(SEGMENT_NAME);
@@ -307,7 +327,7 @@ public class SegmentPreProcessorTest implements 
PinotBuffersAfterClassCheckRule
         .setInvertedIndexColumns(new ArrayList<>(_invertedIndexColumns))
         .setRangeIndexColumns(new ArrayList<>(_rangeIndexColumns))
         .setFieldConfigList(new ArrayList<>(_fieldConfigMap.values()))
-        .setNullHandlingEnabled(true)
+        .setNullHandlingEnabled(_nullHandlingEnabled)
         .setOptimizeNoDictStatsCollection(true)
         .setIngestionConfig(_ingestionConfig)
         .build();
@@ -351,6 +371,19 @@ public class SegmentPreProcessorTest implements 
PinotBuffersAfterClassCheckRule
   /// Cartesian product of segment version (v1/v3) and geo-column encoding 
(DICTIONARY/RAW), so the H3 reload test
   /// exercises both 
[org.apache.pinot.segment.local.segment.index.loader.invertedindex.H3IndexHandler]
   /// paths: the dictionary path and the raw 
`forwardIndexReader.getBytes(...)` path.
+  /// Cartesian product of segment version (v1/v3) and the table's null 
handling flag, for the derived-column tests
+  /// that have to behave the same either way: a transform expression yields 
null regardless of the flag, which only
+  /// decides whether a null vector is written alongside the column.
+  @DataProvider(name = "versionAndNullHandling")
+  public Object[][] versionAndNullHandling() {
+    return new Object[][]{
+        {SegmentVersion.v1, true},
+        {SegmentVersion.v3, true},
+        {SegmentVersion.v1, false},
+        {SegmentVersion.v3, false}
+    };
+  }
+
   @DataProvider(name = "h3VersionAndEncoding")
   public Object[][] h3VersionAndEncoding() {
     return new Object[][]{
@@ -2639,4 +2672,476 @@ public class SegmentPreProcessorTest implements 
PinotBuffersAfterClassCheckRule
     validateIndexDoesNotExist(StandardIndexes.inverted(), 
NEWLY_ADDED_FORWARD_INDEX_DISABLED_COL_MV);
     validateIndexExists(StandardIndexes.dictionary(), 
NEWLY_ADDED_FORWARD_INDEX_DISABLED_COL_MV);
   }
+
+  /// A derived column whose argument is another derived column added in the 
same reload. The transform configs are
+  /// listed dependent-first on purpose: the handler has to order them by 
dependency, not by config order.
+  @Test(dataProvider = "bothV1AndV3")
+  public void testChainedDerivedColumns(SegmentVersion segmentVersion)
+      throws Exception {
+    buildSegment(segmentVersion);
+
+    _ingestionConfig.setTransformConfigs(
+        List.of(new TransformConfig(NEW_INT_METRIC_COLUMN_NAME, 
"plus(newIntSVDimension, 1)"),
+            new TransformConfig(NEW_INT_SV_DIMENSION_COLUMN_NAME, 
"plus(column1, 1)")));
+    runPreProcessor(_newColumnsSchema1);
+
+    SegmentMetadataImpl segmentMetadata = new SegmentMetadataImpl(INDEX_DIR);
+    ColumnMetadata sourceMetadata = 
segmentMetadata.getColumnMetadataFor(COLUMN1_NAME);
+    int sourceMin = (Integer) sourceMetadata.getMinValue();
+    int sourceMax = (Integer) sourceMetadata.getMaxValue();
+
+    ColumnMetadata firstLink = 
segmentMetadata.getColumnMetadataFor(NEW_INT_SV_DIMENSION_COLUMN_NAME);
+    assertTrue(firstLink.isAutoGenerated());
+    assertEquals(firstLink.getMinValue(), sourceMin + 1);
+    assertEquals(firstLink.getMaxValue(), sourceMax + 1);
+
+    // The chained column is derived from the column above, not filled with 
its default value.
+    ColumnMetadata secondLink = 
segmentMetadata.getColumnMetadataFor(NEW_INT_METRIC_COLUMN_NAME);
+    assertTrue(secondLink.isAutoGenerated());
+    assertEquals(secondLink.getMinValue(), sourceMin + 2);
+    assertEquals(secondLink.getMaxValue(), sourceMax + 2);
+    assertEquals(secondLink.getCardinality(), sourceMetadata.getCardinality());
+  }
+
+  /// A derived column whose argument is a plain default column added in the 
same reload. Before dependency
+  /// ordering this hit the same bug as chaining: the argument was not yet in 
the segment metadata, so the derived
+  /// column silently took its own default value instead of being derived.
+  @Test(dataProvider = "bothV1AndV3")
+  public void 
testDerivedColumnReadingDefaultColumnAddedInSameReload(SegmentVersion 
segmentVersion)
+      throws Exception {
+    buildSegment(segmentVersion);
+
+    // newIntMetric is NOT derived -- it is a plain new column filled with its 
default value (0).
+    _ingestionConfig.setTransformConfigs(
+        List.of(new TransformConfig(NEW_INT_SV_DIMENSION_COLUMN_NAME, 
"plus(newIntMetric, 1)")));
+    runPreProcessor(_newColumnsSchema1);
+
+    SegmentMetadataImpl segmentMetadata = new SegmentMetadataImpl(INDEX_DIR);
+    // newIntMetric declares defaultNullValue 1 in the schema, so every row of 
it is 1.
+    ColumnMetadata source = 
segmentMetadata.getColumnMetadataFor(NEW_INT_METRIC_COLUMN_NAME);
+    assertEquals(source.getMinValue(), 1);
+
+    // Derived from that value (1 + 1 = 2). Had the argument not been visible, 
the column would instead hold its own
+    // default null value, which for an INT dimension is Integer.MIN_VALUE.
+    ColumnMetadata derived = 
segmentMetadata.getColumnMetadataFor(NEW_INT_SV_DIMENSION_COLUMN_NAME);
+    assertEquals(derived.getMinValue(), 2);
+    assertEquals(derived.getMaxValue(), 2);
+    assertTrue(derived.isAutoGenerated());
+  }
+
+  /// A column reading a cycle member must take the fallback even when that 
member already exists in the segment and is
+  /// only being updated. Its metadata is then present, so nothing about a 
missing argument would trigger the fallback,
+  /// and the new column -- ordered before the postponed cycle -- would read 
the member's pre-update value for good.
+  @Test(dataProvider = "bothV1AndV3")
+  public void 
testDerivedColumnReadingUpdatedCycleMemberFallsBackToDefaultValue(SegmentVersion
 segmentVersion)
+      throws Exception {
+    buildSegment(segmentVersion);
+
+    // First reload: a two-column cycle, so both columns are created with 
their schema default values (1 and 2).
+    _ingestionConfig.setTransformConfigs(
+        List.of(new TransformConfig(NEW_INT_METRIC_COLUMN_NAME, 
"plus(newLongMetric, 1)"),
+            new TransformConfig(NEW_LONG_METRIC_COLUMN_NAME, 
"plus(newIntMetric, 1)")));
+    Schema firstSchema = new Schema();
+    firstSchema.setSchemaName(_schema.getSchemaName());
+    firstSchema.addField(new MetricFieldSpec(NEW_INT_METRIC_COLUMN_NAME, 
DataType.INT, 1));
+    firstSchema.addField(new MetricFieldSpec(NEW_LONG_METRIC_COLUMN_NAME, 
DataType.LONG, 2L));
+    runPreProcessor(firstSchema);
+    SegmentMetadataImpl firstMetadata = new SegmentMetadataImpl(INDEX_DIR);
+    
assertEquals(firstMetadata.getColumnMetadataFor(NEW_INT_METRIC_COLUMN_NAME).getMinValue(),
 1);
+    
assertEquals(firstMetadata.getColumnMetadataFor(NEW_LONG_METRIC_COLUMN_NAME).getMinValue(),
 2L);
+
+    // Second reload: the cycle members' defaults change (an update, not an 
add) and a column reading one is added.
+    _ingestionConfig.setTransformConfigs(
+        List.of(new TransformConfig(NEW_INT_METRIC_COLUMN_NAME, 
"plus(newLongMetric, 1)"),
+            new TransformConfig(NEW_LONG_METRIC_COLUMN_NAME, 
"plus(newIntMetric, 1)"),
+            new TransformConfig(NEW_DOUBLE_METRIC_COLUMN_NAME, 
"plus(newIntMetric, 1)")));
+    Schema secondSchema = new Schema();
+    secondSchema.setSchemaName(_schema.getSchemaName());
+    secondSchema.addField(new MetricFieldSpec(NEW_INT_METRIC_COLUMN_NAME, 
DataType.INT, 10));
+    secondSchema.addField(new MetricFieldSpec(NEW_LONG_METRIC_COLUMN_NAME, 
DataType.LONG, 20L));
+    secondSchema.addField(new MetricFieldSpec(NEW_DOUBLE_METRIC_COLUMN_NAME, 
DataType.DOUBLE, 0.0));
+    runPreProcessor(secondSchema);
+
+    SegmentMetadataImpl segmentMetadata = new SegmentMetadataImpl(INDEX_DIR);
+    
assertEquals(segmentMetadata.getColumnMetadataFor(NEW_INT_METRIC_COLUMN_NAME).getMinValue(),
 10);
+    
assertEquals(segmentMetadata.getColumnMetadataFor(NEW_LONG_METRIC_COLUMN_NAME).getMinValue(),
 20L);
+    // Its own default, not 1 + the cycle member's pre-update value of 1.
+    ColumnMetadata dependent = 
segmentMetadata.getColumnMetadataFor(NEW_DOUBLE_METRIC_COLUMN_NAME);
+    assertEquals(dependent.getCardinality(), 1);
+    assertEquals(dependent.getMinValue(), 0.0);
+  }
+
+  /// A dependency cycle cannot be satisfied in any order. Both columns must 
still be created, each falling back to
+  /// its default value, rather than failing the segment load.
+  @Test(dataProvider = "bothV1AndV3")
+  public void testDerivedColumnCycleFallsBackToDefaultValue(SegmentVersion 
segmentVersion)
+      throws Exception {
+    buildSegment(segmentVersion);
+
+    _ingestionConfig.setTransformConfigs(
+        List.of(new TransformConfig(NEW_INT_METRIC_COLUMN_NAME, 
"plus(newIntSVDimension, 1)"),
+            new TransformConfig(NEW_INT_SV_DIMENSION_COLUMN_NAME, 
"plus(newIntMetric, 1)")));
+    runPreProcessor(_newColumnsSchema1);
+
+    SegmentMetadataImpl segmentMetadata = new SegmentMetadataImpl(INDEX_DIR);
+    for (String column : List.of(NEW_INT_METRIC_COLUMN_NAME, 
NEW_INT_SV_DIMENSION_COLUMN_NAME)) {
+      ColumnMetadata columnMetadata = 
segmentMetadata.getColumnMetadataFor(column);
+      assertNotNull(columnMetadata, column + " must still be created");
+      assertEquals(columnMetadata.getCardinality(), 1, column + " must hold a 
single default value");
+      // Its own default value, not one derived from a sibling that was itself 
defaulted: whichever member of the
+      // cycle is created second can see the first through the metadata 
written a moment ago.
+      assertEquals(columnMetadata.getMinValue(), 
_newColumnsSchema1.getFieldSpecFor(column).getDefaultNullValue(),
+          column + " must hold its own default value");
+    }
+  }
+
+  /// A derived column reading a source whose forward index is disabled. The 
handler regenerates that forward index
+  /// from the dictionary and inverted index for the duration of the reload, 
then drops it again.
+  ///
+  /// v3 only: a v1 reload cannot remove a forward index, so the source never 
reaches the shape under test.
+  @Test
+  public void testDerivedColumnFromForwardIndexDisabledSource()
+      throws Exception {
+    buildSegment(SegmentVersion.v3);
+
+    // First reload: take the forward index away from the source column, 
keeping its dictionary and inverted index.
+    _invertedIndexColumns.add(COLUMN1_NAME);
+    _fieldConfigMap.put(COLUMN1_NAME,
+        new FieldConfig(COLUMN1_NAME, FieldConfig.EncodingType.DICTIONARY, 
List.of(), null,
+            Map.of(FieldConfig.FORWARD_INDEX_DISABLED, "true")));
+    runPreProcessor(_schema);
+    validateIndexDoesNotExist(StandardIndexes.forward(), COLUMN1_NAME);
+    validateIndexExists(StandardIndexes.dictionary(), COLUMN1_NAME);
+    validateIndexExists(StandardIndexes.inverted(), COLUMN1_NAME);
+
+    SegmentMetadataImpl beforeMetadata = new SegmentMetadataImpl(INDEX_DIR);
+    int sourceMin = (Integer) 
beforeMetadata.getColumnMetadataFor(COLUMN1_NAME).getMinValue();
+    int sourceMax = (Integer) 
beforeMetadata.getColumnMetadataFor(COLUMN1_NAME).getMaxValue();
+
+    // Second reload: derive from it.
+    _ingestionConfig.setTransformConfigs(
+        List.of(new TransformConfig(NEW_INT_SV_DIMENSION_COLUMN_NAME, 
"plus(column1, 1)")));
+    runPreProcessor(_newColumnsSchema1);
+
+    SegmentMetadataImpl segmentMetadata = new SegmentMetadataImpl(INDEX_DIR);
+    ColumnMetadata derived = 
segmentMetadata.getColumnMetadataFor(NEW_INT_SV_DIMENSION_COLUMN_NAME);
+    assertEquals(derived.getMinValue(), sourceMin + 1);
+    assertEquals(derived.getMaxValue(), sourceMax + 1);
+
+    // The regenerated forward index was temporary: the source keeps the shape 
its config asks for.
+    validateIndexDoesNotExist(StandardIndexes.forward(), COLUMN1_NAME);
+  }
+
+  /// A derived column that itself has the forward index disabled: it is built 
with one, and ForwardIndexHandler
+  /// drops it in its post-update cleanup once the inverted index has been 
built from it.
+  ///
+  /// v3 only: a v1 reload cannot remove a forward index, so it stays on disk 
there (same as every other
+  /// forward-index-disabled column on v1).
+  @Test
+  public void testForwardIndexDisabledDerivedColumn()
+      throws Exception {
+    buildSegment(SegmentVersion.v3);
+
+    _invertedIndexColumns.add(NEW_RAW_STRING_SV_DIMENSION_COLUMN_NAME);
+    _fieldConfigMap.put(NEW_RAW_STRING_SV_DIMENSION_COLUMN_NAME,
+        new FieldConfig(NEW_RAW_STRING_SV_DIMENSION_COLUMN_NAME, 
FieldConfig.EncodingType.DICTIONARY, List.of(), null,
+            Map.of(FieldConfig.FORWARD_INDEX_DISABLED, "true")));
+    _ingestionConfig.setTransformConfigs(
+        List.of(new TransformConfig(NEW_RAW_STRING_SV_DIMENSION_COLUMN_NAME, 
"reverse(column3)")));
+    runPreProcessor(_newColumnsSchema1);
+
+    SegmentMetadataImpl segmentMetadata = new SegmentMetadataImpl(INDEX_DIR);
+    ColumnMetadata derived = 
segmentMetadata.getColumnMetadataFor(NEW_RAW_STRING_SV_DIMENSION_COLUMN_NAME);
+    assertTrue(derived.isAutoGenerated());
+    assertTrue(derived.hasDictionary(), "A forward-index-disabled column must 
be dictionary encoded");
+    // Derived from column3, not filled with the default value: column3 has 5 
distinct values.
+    assertEquals(derived.getCardinality(), 5);
+
+    validateIndexExists(StandardIndexes.dictionary(), 
NEW_RAW_STRING_SV_DIMENSION_COLUMN_NAME);
+    validateIndexExists(StandardIndexes.inverted(), 
NEW_RAW_STRING_SV_DIMENSION_COLUMN_NAME);
+    validateIndexDoesNotExist(StandardIndexes.forward(), 
NEW_RAW_STRING_SV_DIMENSION_COLUMN_NAME);
+  }
+
+  /// A chain must give the same answer whether it is built at ingestion or at 
load. Ingestion evaluates every
+  /// expression before anything is converted to its stored type, so an 
intermediate link that loses precision on
+  /// the way to disk -- `INT newIntSVDimension = divide(column1, 2)` -- must 
still hand the exact quotient to
+  /// `DOUBLE newDoubleMetric = plus(newIntSVDimension, 1)`. Reading the 
intermediate back from its index would
+  /// quietly give the truncated value instead.
+  @Test(dataProvider = "bothV1AndV3")
+  public void testChainedDerivedColumnsMatchIngestion(SegmentVersion 
segmentVersion)
+      throws Exception {
+    Schema schema = copyOfSchema(_schema);
+    
schema.addField(_newColumnsSchema1.getFieldSpecFor(NEW_INT_SV_DIMENSION_COLUMN_NAME));
+    
schema.addField(_newColumnsSchema1.getFieldSpecFor(NEW_DOUBLE_METRIC_COLUMN_NAME));
+    List<TransformConfig> transformConfigs =
+        List.of(new TransformConfig(NEW_DOUBLE_METRIC_COLUMN_NAME, 
"plus(newIntSVDimension, 1)"),
+            new TransformConfig(NEW_INT_SV_DIMENSION_COLUMN_NAME, 
"divide(column1, 2)"));
+
+    // The ingestion path: both columns are produced while the segment is 
generated.
+    _ingestionConfig.setTransformConfigs(transformConfigs);
+    buildSegment(segmentVersion, schema);
+    List<Object> ingestedIntermediate = 
readColumnValues(NEW_INT_SV_DIMENSION_COLUMN_NAME);
+    List<Object> ingestedDerived = 
readColumnValues(NEW_DOUBLE_METRIC_COLUMN_NAME);
+
+    // The load path: the same two columns, built by the default column 
handler on a segment that has neither.
+    _ingestionConfig.setTransformConfigs(null);
+    buildSegment(segmentVersion);
+    _ingestionConfig.setTransformConfigs(transformConfigs);
+    runPreProcessor(schema);
+
+    assertEquals(readColumnValues(NEW_INT_SV_DIMENSION_COLUMN_NAME), 
ingestedIntermediate);
+    assertEquals(readColumnValues(NEW_DOUBLE_METRIC_COLUMN_NAME), 
ingestedDerived);
+
+    // Guard against the comparison passing for the wrong reason: the two 
paths can only differ on a row whose
+    // quotient has a fractional part, so the data has to contain at least one.
+    boolean anyTruncated = false;
+    for (int i = 0; i < ingestedDerived.size(); i++) {
+      if ((Double) ingestedDerived.get(i) != (Integer) 
ingestedIntermediate.get(i) + 1.0) {
+        anyTruncated = true;
+        break;
+      }
+    }
+    assertTrue(anyTruncated, "Expected at least one row whose intermediate 
value is truncated on the way to disk");
+  }
+
+  /// Regenerating a source's forward index can change the metadata its reader 
has to work from. Here the encoding
+  /// flips from dictionary to raw in the very reload that derives from the 
column, so the metadata captured before
+  /// the rebuild would open raw bytes with a dictionary reader.
+  ///
+  /// v3 only: a v1 reload cannot remove a forward index, so the source never 
reaches the shape under test.
+  @Test
+  public void testDerivedColumnFromSourceRegeneratedWithNewEncoding()
+      throws Exception {
+    buildSegment(SegmentVersion.v3);
+
+    // First reload: take the forward index away from the source, keeping its 
dictionary and inverted index.
+    _invertedIndexColumns.add(COLUMN1_NAME);
+    _fieldConfigMap.put(COLUMN1_NAME,
+        new FieldConfig(COLUMN1_NAME, FieldConfig.EncodingType.DICTIONARY, 
List.of(), null,
+            Map.of(FieldConfig.FORWARD_INDEX_DISABLED, "true")));
+    runPreProcessor(_schema);
+    validateIndexDoesNotExist(StandardIndexes.forward(), COLUMN1_NAME);
+
+    SegmentMetadataImpl beforeMetadata = new SegmentMetadataImpl(INDEX_DIR);
+    ColumnMetadata sourceMetadata = 
beforeMetadata.getColumnMetadataFor(COLUMN1_NAME);
+    assertTrue(sourceMetadata.hasDictionary());
+    int sourceMin = (Integer) sourceMetadata.getMinValue();
+    int sourceMax = (Integer) sourceMetadata.getMaxValue();
+
+    // Second reload: bring the forward index back as a raw one and derive 
from the column at the same time.
+    _fieldConfigMap.remove(COLUMN1_NAME);
+    _invertedIndexColumns.remove(COLUMN1_NAME);
+    _noDictionaryColumns.add(COLUMN1_NAME);
+    _ingestionConfig.setTransformConfigs(
+        List.of(new TransformConfig(NEW_INT_SV_DIMENSION_COLUMN_NAME, 
"plus(column1, 1)")));
+    runPreProcessor(_newColumnsSchema1);
+
+    SegmentMetadataImpl segmentMetadata = new SegmentMetadataImpl(INDEX_DIR);
+    ColumnMetadata derived = 
segmentMetadata.getColumnMetadataFor(NEW_INT_SV_DIMENSION_COLUMN_NAME);
+    assertEquals(derived.getMinValue(), sourceMin + 1);
+    assertEquals(derived.getMaxValue(), sourceMax + 1);
+    assertEquals(derived.getCardinality(), sourceMetadata.getCardinality());
+  }
+
+  /// A source whose own action is an update, not an add. Its values are only 
final once that update has run, so the
+  /// chain reading it has to be ordered after it -- otherwise the pre-update 
value is written into the chain for
+  /// good, and no later reload puts it right.
+  @Test(dataProvider = "bothV1AndV3")
+  public void testDerivedColumnOrderedAfterUpdatedSource(SegmentVersion 
segmentVersion)
+      throws Exception {
+    buildSegment(segmentVersion);
+
+    // First reload: the source is added, holding the default value its schema 
declares.
+    Schema firstSchema = new Schema();
+    firstSchema.setSchemaName(_schema.getSchemaName());
+    firstSchema.addField(new MetricFieldSpec(NEW_INT_METRIC_COLUMN_NAME, 
DataType.INT, 1));
+    runPreProcessor(firstSchema);
+    assertEquals(new 
SegmentMetadataImpl(INDEX_DIR).getColumnMetadataFor(NEW_INT_METRIC_COLUMN_NAME).getMinValue(),
 1);
+
+    // Second reload: the source's default changes and a two-link chain 
reading it is added in the same run.
+    Schema secondSchema = new Schema();
+    secondSchema.setSchemaName(_schema.getSchemaName());
+    secondSchema.addField(new MetricFieldSpec(NEW_INT_METRIC_COLUMN_NAME, 
DataType.INT, 2));
+    secondSchema.addField(new 
DimensionFieldSpec(NEW_INT_SV_DIMENSION_COLUMN_NAME, DataType.INT, true));
+    secondSchema.addField(new MetricFieldSpec(NEW_LONG_METRIC_COLUMN_NAME, 
DataType.LONG, 0L));
+    _ingestionConfig.setTransformConfigs(
+        List.of(new TransformConfig(NEW_LONG_METRIC_COLUMN_NAME, 
"plus(newIntSVDimension, 1)"),
+            new TransformConfig(NEW_INT_SV_DIMENSION_COLUMN_NAME, 
"plus(newIntMetric, 1)")));
+    runPreProcessor(secondSchema);
+
+    SegmentMetadataImpl segmentMetadata = new SegmentMetadataImpl(INDEX_DIR);
+    
assertEquals(segmentMetadata.getColumnMetadataFor(NEW_INT_METRIC_COLUMN_NAME).getMinValue(),
 2);
+    
assertEquals(segmentMetadata.getColumnMetadataFor(NEW_INT_SV_DIMENSION_COLUMN_NAME).getMinValue(),
 3);
+    
assertEquals(segmentMetadata.getColumnMetadataFor(NEW_LONG_METRIC_COLUMN_NAME).getMinValue(),
 4L);
+  }
+
+  /// A derived column reading a plain multi-value default column that is 
added in the same reload with its forward
+  /// index disabled. That source has a dictionary and an inverted index but 
no forward index, and it exists only in
+  /// the metadata properties written a moment ago -- the segment directory's 
own metadata does not list it yet.
+  ///
+  /// v3 only: regenerating a forward index writes the index file and then 
registers it under the same path on a v1
+  /// segment, so the rebuild cannot run there at all and the derived column 
falls back to its default value.
+  @Test
+  public void 
testDerivedColumnFromForwardIndexDisabledColumnAddedInSameReload()
+      throws Exception {
+    buildSegment(SegmentVersion.v3);
+
+    // A default value of its own, so a derived column that fell back to ITS 
default would be visibly different.
+    Schema schema = copyOfSchema(_newColumnsSchema1);
+    
schema.getFieldSpecFor(NEW_STRING_MV_DIMENSION_COLUMN_NAME).setDefaultNullValue("mvDefault");
+
+    _invertedIndexColumns.add(NEW_STRING_MV_DIMENSION_COLUMN_NAME);
+    _fieldConfigMap.put(NEW_STRING_MV_DIMENSION_COLUMN_NAME,
+        new FieldConfig(NEW_STRING_MV_DIMENSION_COLUMN_NAME, 
FieldConfig.EncodingType.DICTIONARY, List.of(), null,
+            Map.of(FieldConfig.FORWARD_INDEX_DISABLED, "true")));
+    _ingestionConfig.setTransformConfigs(List.of(new 
TransformConfig(NEW_RAW_STRING_SV_DIMENSION_COLUMN_NAME,
+        "arrayElementAtString(newStringMVDimension, 1)")));
+    runPreProcessor(schema);
+
+    SegmentMetadataImpl segmentMetadata = new SegmentMetadataImpl(INDEX_DIR);
+    ColumnMetadata derived = 
segmentMetadata.getColumnMetadataFor(NEW_RAW_STRING_SV_DIMENSION_COLUMN_NAME);
+    assertTrue(derived.isAutoGenerated());
+    assertEquals(derived.getMinValue(), "mvDefault");
+  }
+
+  /// A chain whose intermediate link evaluates to null. Ingestion runs every 
expression before any null is turned
+  /// into a column's default, so `newIntMetric = coalesce(newIntSVDimension, 
7)` sees the null that
+  /// `newIntSVDimension = nullIf(column1, column1)` produced and stores 7, 
while the intermediate itself lands on
+  /// disk as its own default (0). Handing the stored default to the rest of 
the chain instead would store 0 in
+  /// `newIntMetric` for good -- a wrong value that no later reload corrects.
+  ///
+  /// Run with null handling on and off: the substitution happens either way, 
the flag only decides whether a null
+  /// vector is written next to the intermediate.
+  @Test(dataProvider = "versionAndNullHandling")
+  public void 
testChainedDerivedColumnsWithNullIntermediateMatchIngestion(SegmentVersion 
segmentVersion,
+      boolean nullHandlingEnabled)
+      throws Exception {
+    _nullHandlingEnabled = nullHandlingEnabled;
+
+    Schema schema = copyOfSchema(_schema);
+    schema.addField(new DimensionFieldSpec(NEW_INT_SV_DIMENSION_COLUMN_NAME, 
DataType.INT, true, 0));
+    schema.addField(new MetricFieldSpec(NEW_INT_METRIC_COLUMN_NAME, 
DataType.INT, 0));
+    List<TransformConfig> transformConfigs =
+        List.of(new TransformConfig(NEW_INT_METRIC_COLUMN_NAME, 
"coalesce(newIntSVDimension, 7)"),
+            new TransformConfig(NEW_INT_SV_DIMENSION_COLUMN_NAME, 
"nullIf(column1, column1)"));
+
+    // The ingestion path: both columns are produced while the segment is 
generated.
+    _ingestionConfig.setTransformConfigs(transformConfigs);
+    buildSegment(segmentVersion, schema);
+    List<Object> ingestedIntermediate = 
readColumnValues(NEW_INT_SV_DIMENSION_COLUMN_NAME);
+    List<Object> ingestedDerived = 
readColumnValues(NEW_INT_METRIC_COLUMN_NAME);
+    // Guard against the comparison below passing for the wrong reason: the 
chain has to have run on a null, which
+    // shows up as the intermediate holding its default while the column 
reading it does not.
+    assertEquals(ingestedIntermediate.get(0), 0);
+    assertEquals(ingestedDerived.get(0), 7);
+
+    // The load path: the same two columns, built by the default column 
handler on a segment that has neither.
+    _ingestionConfig.setTransformConfigs(null);
+    buildSegment(segmentVersion);
+    _ingestionConfig.setTransformConfigs(transformConfigs);
+    runPreProcessor(schema);
+
+    assertEquals(readColumnValues(NEW_INT_SV_DIMENSION_COLUMN_NAME), 
ingestedIntermediate);
+    assertEquals(readColumnValues(NEW_INT_METRIC_COLUMN_NAME), 
ingestedDerived);
+  }
+
+  /// A transform that reads the same forward-index-disabled source twice. 
Regenerating that source's forward index
+  /// happens on the first read and brings it back as a raw one, so every 
later read has to work from the refreshed
+  /// metadata -- the copy taken before the rebuild describes a 
dictionary-encoded column and would make the second
+  /// read open raw bytes with a dictionary reader.
+  ///
+  /// v3 only: a v1 reload cannot remove a forward index, so the source never 
reaches the shape under test.
+  @Test
+  public void testDerivedColumnReadingRegeneratedSourceTwice()
+      throws Exception {
+    buildSegment(SegmentVersion.v3);
+
+    // First reload: take the forward index away from the source, keeping its 
dictionary and inverted index.
+    _invertedIndexColumns.add(COLUMN1_NAME);
+    _fieldConfigMap.put(COLUMN1_NAME,
+        new FieldConfig(COLUMN1_NAME, FieldConfig.EncodingType.DICTIONARY, 
List.of(), null,
+            Map.of(FieldConfig.FORWARD_INDEX_DISABLED, "true")));
+    runPreProcessor(_schema);
+    validateIndexDoesNotExist(StandardIndexes.forward(), COLUMN1_NAME);
+
+    SegmentMetadataImpl beforeMetadata = new SegmentMetadataImpl(INDEX_DIR);
+    ColumnMetadata sourceMetadata = 
beforeMetadata.getColumnMetadataFor(COLUMN1_NAME);
+    assertTrue(sourceMetadata.hasDictionary());
+    double sourceMin = (Integer) sourceMetadata.getMinValue();
+    double sourceMax = (Integer) sourceMetadata.getMaxValue();
+
+    // Second reload: bring the forward index back as a raw one and read the 
column twice in the same expression.
+    _fieldConfigMap.remove(COLUMN1_NAME);
+    _invertedIndexColumns.remove(COLUMN1_NAME);
+    _noDictionaryColumns.add(COLUMN1_NAME);
+    // A DOUBLE target, so that doubling the largest value of column1 stays 
representable and a wrong read shows up
+    // as a wrong value rather than as both reads saturating at 
Integer.MAX_VALUE.
+    _ingestionConfig.setTransformConfigs(
+        List.of(new TransformConfig(NEW_DOUBLE_METRIC_COLUMN_NAME, 
"plus(column1, column1)")));
+    runPreProcessor(_newColumnsSchema1);
+
+    SegmentMetadataImpl segmentMetadata = new SegmentMetadataImpl(INDEX_DIR);
+    ColumnMetadata derived = 
segmentMetadata.getColumnMetadataFor(NEW_DOUBLE_METRIC_COLUMN_NAME);
+    assertNotNull(derived, "The derived column must be created");
+    assertTrue(derived.isAutoGenerated());
+    assertEquals(derived.getMinValue(), sourceMin * 2.0);
+    assertEquals(derived.getMaxValue(), sourceMax * 2.0);
+    assertEquals(derived.getCardinality(), sourceMetadata.getCardinality());
+  }
+
+  /// Overlapping cycles: four columns that all reach each other, wired so 
that a depth-first search closes its
+  /// first back edge over only part of them whichever column it starts from. 
Reaching the rest of a cycle through
+  /// an edge into an already finished column is what a search like that 
cannot see, and every one of these four
+  /// columns is reached that way from some starting point.
+  ///
+  /// Each has to hold its own default value. Deriving one of them from a 
sibling that was itself defaulted would
+  /// store a value that looks real -- 12 rather than 22, say -- instead of 
the documented fallback.
+  @Test(dataProvider = "bothV1AndV3")
+  public void 
testOverlappingDerivedColumnCyclesFallBackToDefaultValue(SegmentVersion 
segmentVersion)
+      throws Exception {
+    buildSegment(segmentVersion);
+
+    Schema schema = new Schema();
+    schema.setSchemaName(_schema.getSchemaName());
+    schema.addField(new DimensionFieldSpec(NEW_INT_SV_DIMENSION_COLUMN_NAME, 
DataType.INT, true, 11));
+    schema.addField(new MetricFieldSpec(NEW_INT_METRIC_COLUMN_NAME, 
DataType.INT, 22));
+    schema.addField(new MetricFieldSpec(NEW_LONG_METRIC_COLUMN_NAME, 
DataType.LONG, 33L));
+    schema.addField(new MetricFieldSpec(NEW_DOUBLE_METRIC_COLUMN_NAME, 
DataType.DOUBLE, 44.0));
+    _ingestionConfig.setTransformConfigs(List.of(
+        new TransformConfig(NEW_INT_SV_DIMENSION_COLUMN_NAME, 
"plus(newIntMetric, newLongMetric)"),
+        new TransformConfig(NEW_INT_METRIC_COLUMN_NAME, 
"plus(newIntSVDimension, newDoubleMetric)"),
+        new TransformConfig(NEW_LONG_METRIC_COLUMN_NAME, "plus(newIntMetric, 
1)"),
+        new TransformConfig(NEW_DOUBLE_METRIC_COLUMN_NAME, 
"plus(newIntSVDimension, 1)")));
+    runPreProcessor(schema);
+
+    SegmentMetadataImpl segmentMetadata = new SegmentMetadataImpl(INDEX_DIR);
+    for (String column : List.of(NEW_INT_SV_DIMENSION_COLUMN_NAME, 
NEW_INT_METRIC_COLUMN_NAME,
+        NEW_LONG_METRIC_COLUMN_NAME, NEW_DOUBLE_METRIC_COLUMN_NAME)) {
+      ColumnMetadata columnMetadata = 
segmentMetadata.getColumnMetadataFor(column);
+      assertNotNull(columnMetadata, column + " must still be created");
+      assertEquals(columnMetadata.getCardinality(), 1, column + " must hold a 
single default value");
+      assertEquals(columnMetadata.getMinValue(), 
schema.getFieldSpecFor(column).getDefaultNullValue(),
+          column + " must hold its own default value");
+    }
+  }
+
+  /// A deep copy, so a test can change a default value without the schema 
fields -- read once in the constructor --
+  /// carrying that change into every test that runs after it.
+  private static Schema copyOfSchema(Schema schema)
+      throws IOException {
+    return Schema.fromString(schema.toSingleLineJsonString());
+  }
+
+  private static List<Object> readColumnValues(String column)
+      throws Exception {
+    List<Object> values = new ArrayList<>();
+    try (PinotSegmentRecordReader recordReader = new 
PinotSegmentRecordReader(INDEX_DIR)) {
+      GenericRow reuse = new GenericRow();
+      while (recordReader.hasNext()) {
+        reuse.clear();
+        values.add(recordReader.next(reuse).getValue(column));
+      }
+    }
+    return values;
+  }
 }


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

Reply via email to