gortiz opened a new issue, #19781:
URL: https://github.com/apache/pinot/issues/19781
## Summary
Column `minValue` and `maxValue` in segment metadata include the placeholder
that Pinot stores for null rows. With
null handling, this makes the values useless as bounds for a column that has
nulls. Today each optimization that
reads min/max adds its own guard for nulls, and most guards simply disable
the optimization.
This issue proposes to fix the cause. We add three optional, sparse keys per
column to `metadata.properties`:
`numNulls`, `minNonNullValue` and `maxNonNullValue`. The existing keys keep
their meaning. Segments created before the
change report "unknown", and the optimizations keep their current
conservative behavior for those segments. A
server-level flag lets operators backfill old segments on reload.
We want feedback on the format, the compatibility rules and the PR plan
before we start.
## Background
The forward index has no null representation. When a value is null,
ingestion writes the `defaultNullValue` of the
column into the forward index and marks the doc in the null value vector.
The stats collectors receive the
placeholder value, so it goes into the dictionary, `minValue`, `maxValue`
and the cardinality.
For numeric dimensions the default is the minimum of the type
(`Integer.MIN_VALUE` for INT). One null row is enough
to set `minValue` to that value. For metrics the default is `0`, which can
also become the min. A custom
`defaultNullValue` does not solve the problem: any value can be an extreme
in some segment.
The existing keys must keep this meaning, for three reasons:
- When null handling is disabled in a query, a null row *is* the default
value. `MIN(col)` from metadata or from the
dictionary must return it.
- The bit-sliced range index uses the stored min as an offset. Its on-disk
layout depends on it.
- The dictionary contains the default value anyway.
## Work that this change would have helped
| PR / issue | Effect of the polluted min/max |
|---|---|
| #19725, fixed by #19724 |
`MinMaxValueBasedSelectionOrderByCombineOperator` returned wrong rows (and an
NPE) with null handling. The fix makes every segment with nulls unskippable
when nulls sort first. |
| #18692 (fix for #18685) | `SelectionQuerySegmentPruner` now prunes
filtered `ORDER BY col LIMIT n` queries. It is disabled when null handling is
active on the order-by or filter column, because nulls pollute min/max. |
| #19120 (open) | The streaming selection order-by combine prunes by min/max
only when `isNonNull` flags the leading column. A segment with any null, or
built before #19072, is always activated. |
| #19072 | Added the `isNonNull` column flag. It covers only the zero-null
case. |
| #14342 | The non-scan `MIN`/`MAX` path of `AggregationPlanNode` is
disabled when null handling is enabled and the segment has nulls. |
With non-null bounds, #19724, #18692 and #19120 can prune segments that have
nulls, instead of always processing
them.
## Proposal
### New keys
All keys are optional and live under `column.<name>.` in
`metadata.properties`:
| Key | When the writer writes it |
|---|---|
| `numNulls` | The column has one or more nulls, and the values below are
representable. |
| `minNonNullValue` | Only when the min of the non-null values is different
from `minValue`. |
| `maxNonNullValue` | Only when the max of the non-null values is different
from `maxValue`. |
The keys are sparse because tables can have hundreds of thousands of
segments. A column without nulls gets no new
key. A column with nulls gets only `numNulls` when the default value is not
an extreme, for example when
`defaultNullValue` is between the real values.
The writer writes all the keys for a column, or none of them. It writes none
when a value is not representable (for
example, a STRING longer than the 512-character metadata limit), or when the
stored min/max is absent. "None" means
"unknown", which is always safe.
### How a reader resolves the values
`NullValueVectorCreator` writes no bitmap file when a column has zero nulls.
As a result, the presence of the bitmap
already means "one or more nulls", in old and new segments. No new marker is
necessary:
| Null bitmap | `numNulls` key | Result |
|---|---|---|
| absent | - | Known. Zero nulls, so the non-null min/max are
`minValue`/`maxValue`. |
| present | present | Known. An absent `minNonNullValue`/`maxNonNullValue`
means "equal to `minValue`/`maxValue`". |
| present | absent | Unknown. The segment is from an older version and has
not been backfilled. |
### Java API
```java
/// Statistics computed over the non-null values of a column in one segment.
/// `minValue` and `maxValue` are null only when every value is null.
public record NonNullStats(int numNulls, int numDocs,
@Nullable Comparable<?> minValue, @Nullable Comparable<?> maxValue) {
public boolean hasNulls() { return numNulls > 0; }
public boolean isAllNull() { return numNulls == numDocs; }
}
// DataSourceMetadata
/// Returns null when the segment cannot supply the values.
@Nullable
default NonNullStats getNonNullStats() { return null; }
```
The immutable data source applies the resolution rules. The mutable data
source tracks the values during
consumption, so it always returns them. Query code selects the variant from
the query: with null handling it uses
`getNonNullStats()`, and without null handling it uses
`getMinValue()`/`getMaxValue()` as today.
### Old segments
A reload does not change old segments by default. `SegmentPreProcessor`
checks every segment when a server loads it,
so an always-on backfill would rewrite every old segment with nulls at
server startup. A server-level
flag, disabled by default, enables the backfill.
The backfill is cheap in most cases. The forward index value at the first
null doc is the default that was used when
the segment was created. If that value is not equal to `minValue` or
`maxValue`, the stored values are already the
non-null bounds, and only `numNulls` (the bitmap cardinality) is written. A
scan is necessary only for the extreme that
the default occupies. For a dictionary column with an inverted index, a
bitmap operation replaces the scan.
The feature is only an optimization. Segments that stay "unknown" keep the
current behavior, and retention removes
them over time.
## Compatibility
- There is no segment format version change. `ColumnMetadataImpl` ignores
unknown keys, so an older server can load
a new segment, and a rollback is safe.
- An older server that adds a null bitmap on reload (#19072) does not write
`numNulls`. The column then resolves to
"unknown".
- The new interface methods are `default` methods and return "unknown".
External implementations of
`ColumnMetadata`, `ColumnStatistics` and `DataSourceMetadata` still
compile.
- The new getters appear in the JSON of the server segment metadata REST
API. This change only adds fields.
## Planned PRs
Each PR keeps "unknown" as the fallback. Thus the PRs can merge in this
order without a risk of wrong results.
1. SPI, reader, writer and offline creation. Add `NonNullStats` and the
keys. Pass the null state of each row to the
stats collectors (row and columnar paths), which keep a running non-null
min/max. Write the keys in
`BaseSegmentCreator`, and apply the resolution rules in
`ImmutableDataSource`.
2. Realtime and default columns. Track non-null min/max in
`MutableSegmentImpl`, and pass them through the
realtime-to-offline converter statistics. Compacted segments compute them
from valid docs only. Handle new default
columns and derived columns in `BaseDefaultColumnHandler`. Make sure that
every path that rewrites column metadata
also rewrites or removes the new keys.
3. Backfill on reload, behind a server-level flag that is disabled by
default.
4. `MinMaxValueBasedSelectionOrderByCombineOperator`. With null handling,
use the non-null bounds, and treat a segment
as unskippable for nulls-first only when it has nulls. Keep the #19724
behavior for "unknown" segments.
5. `SelectionQuerySegmentPruner`. Enable it with null handling when every
segment involved supplies non-null stats.
6. Optional: `ColumnValueSegmentPruner`. With null handling, EQ, IN and
range predicates do not match null docs, so the
pruner can use the non-null bounds.
## Out of scope
- `startTime` and `endTime` in ZooKeeper. A null time value also changes
them, but broker pruning on non-null bounds
would break `WHERE ts IS NULL`.
- Non-scan `MIN`/`MAX` with null handling. A future aggregation function for
non-null min/max can use the new API.
- Storing `defaultNullValue` in the metadata. The forward index value at the
first null doc gives it.
## Questions for the community
1. Are the sparse keys and the "bitmap present but no `numNulls` means
unknown" rule acceptable, or do you prefer
explicit keys on every column?
2. Must the backfill flag be at server level or at table level?
3. Do you know other consumers of min/max that need a null guard today?
--
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]