Jackie-Jiang commented on code in PR #19581:
URL: https://github.com/apache/pinot/pull/19581#discussion_r4289143406
##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/loader/defaultcolumn/BaseDefaultColumnHandler.java:
##########
@@ -607,13 +692,20 @@ private void createDerivedColumnV1Indices(String column,
FunctionEvaluator funct
nullValueVectorCreator.seal();
}
+ // Take the untouched expression results aside before the switch below
converts them in place to the stored
+ // type, so the rest of the chain sees what ingestion would have given
it.
+ Object[] expressionValues = copyValuesForChain(column, outputValues);
Review Comment:
**[P1] Preserve null intermediate results before default substitution**
This copy is taken after `outputValue == null` has been replaced with the
column's default at lines 672–673, so it still does not preserve the expression
results ingestion passes to the rest of the chain. For example, with INT `b =
nullIf(a, a)` and default `0`, followed by INT `c = coalesce(b, 7)`, ingestion
evaluates `c` while `b` is null and stores `c=7`; this reload path caches `b=0`
and permanently stores `c=0`. Capture the chain values immediately after
expression evaluation and before null substitution. Please extend the
ingestion/reload comparison to null intermediates, with null handling both
enabled and disabled.
##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/loader/defaultcolumn/BaseDefaultColumnHandler.java:
##########
@@ -1219,6 +1315,299 @@ private void putDictionaryCompressionStats(String
column, long uncompressedValue
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;
+ }
+
+ Map<String, DefaultColumnAction> ordered = new
LinkedHashMap<>(actionMap.size());
+ Set<String> visiting = new LinkedHashSet<>();
+ for (String column : actionMap.keySet()) {
+ visitForOrdering(column, actionMap, dependencies, visiting, 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, Set<String> visiting,
Map<String, DefaultColumnAction> ordered) {
+ if (ordered.containsKey(column)) {
Review Comment:
**[P2] Identify all members of overlapping dependency cycles**
Returning for an already ordered node misses cycle members outside the first
DFS back-edge path. For `a = plus(b, c)`, `b = plus(a, 1)`, and `c = plus(b,
1)`, with all three defaults set to `0`, traversal from `a` marks `a,b` cyclic
and finishes `b`. When it visits `c`, the edge to `b` returns here, leaving `c`
unmarked even though `c -> b -> a -> c` is a cycle. The resulting order builds
`b=0`, then incorrectly derives `c=1`, then defaults `a`. Identify complete
strongly connected components before applying the fallback, and add an
overlapping-cycle regression asserting each member's own default value.
##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/loader/defaultcolumn/BaseDefaultColumnHandler.java:
##########
@@ -1219,6 +1315,299 @@ private void putDictionaryCompressionStats(String
column, long uncompressedValue
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;
+ }
+
+ Map<String, DefaultColumnAction> ordered = new
LinkedHashMap<>(actionMap.size());
+ Set<String> visiting = new LinkedHashSet<>();
+ for (String column : actionMap.keySet()) {
+ visitForOrdering(column, actionMap, dependencies, visiting, 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, Set<String> visiting,
Map<String, DefaultColumnAction> ordered) {
+ if (ordered.containsKey(column)) {
+ return;
+ }
+ if (!visiting.add(column)) {
+ // The cycle is the tail of the traversal from this column onwards.
Every one of its members has to be recorded:
+ // emitting them in any order leaves one reading a sibling that was
itself defaulted, which would silently
+ // derive a value from a default instead of taking the documented
fallback.
+ List<String> cycle = new ArrayList<>();
+ boolean inCycle = false;
+ for (String visited : visiting) {
+ inCycle |= visited.equals(column);
+ if (inCycle) {
+ cycle.add(visited);
+ }
+ }
+ 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.", cycle);
+ _cyclicColumns.addAll(cycle);
+ return;
+ }
+ try {
+ for (String dependency : dependencies.getOrDefault(column, List.of())) {
+ visitForOrdering(dependency, actionMap, dependencies, visiting,
ordered);
+ }
+ // A dependency stuck in a cycle is absent from `ordered`; emitting this
column anyway is safe because the
+ // missing argument makes it take the default-value path.
+ ordered.put(column, actionMap.get(column));
+ } finally {
+ visiting.remove(column);
+ }
+ }
+
+ /// 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);
+ }
+ }
+
+ /// A copy of the values a derived column produced, 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
+ /// the copy. The caller converts the original array in place, which is why
this is taken before that happens.
+ @Nullable
+ private Object[] copyValuesForChain(String column, Object[] outputValues) {
+ if (_transformConfigsByColumn == null) {
+ buildTransformConfigIndex();
+ }
+ return _derivedColumnArgumentNames.contains(column) ?
Arrays.copyOf(outputValues, outputValues.length) : 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);
+ _createdColumnMetadata.computeIfPresent(column, (k, v) -> refreshed);
Review Comment:
**[P1] Cache refreshed metadata for existing source columns too**
`computeIfPresent` does nothing for a source that already existed before
this reload, because it is absent from `_createdColumnMetadata`. Its first read
receives refreshed metadata, but the next `getColumnMetadata()` returns the
original `_segmentMetadata`, and `materializeSourceForwardIndex()` then returns
early because the temporary forward index already exists. Even one transform
such as `plus(source, source)` triggers this: the evaluator records both
identifier occurrences. If regeneration changes DICTIONARY encoding to RAW, the
second occurrence reads raw bytes with dictionary metadata; regenerated MV
sources with duplicates likewise retain incorrect entry counts on subsequent
reads. Cache every refreshed source with `put` and cover repeated references or
multiple consumers.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]