This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new f2752953f6 [common] Stop answering value predicates from a truncated
BSI index (#9654)
f2752953f6 is described below
commit f2752953f6a737d8e895ad2f37efccd22e075804
Author: YangJie <[email protected]>
AuthorDate: Fri Sep 11 03:08:30 2026 -0400
[common] Stop answering value predicates from a truncated BSI index (#9654)
---
.../bsi/BitSliceIndexBitmapFileIndex.java | 44 ++++++++++++-
.../bsi/BitSliceIndexBitmapFileIndexTest.java | 64 ++++++++++++++++++
.../paimon/table/DataEvolutionFileIndexTest.java | 76 ++++++++++++++++++++++
3 files changed, 183 insertions(+), 1 deletion(-)
diff --git
a/paimon-common/src/main/java/org/apache/paimon/fileindex/bsi/BitSliceIndexBitmapFileIndex.java
b/paimon-common/src/main/java/org/apache/paimon/fileindex/bsi/BitSliceIndexBitmapFileIndex.java
index f9f0f95cec..e48e0934fc 100644
---
a/paimon-common/src/main/java/org/apache/paimon/fileindex/bsi/BitSliceIndexBitmapFileIndex.java
+++
b/paimon-common/src/main/java/org/apache/paimon/fileindex/bsi/BitSliceIndexBitmapFileIndex.java
@@ -95,12 +95,28 @@ public class BitSliceIndexBitmapFileIndex implements
FileIndexer {
? BitSliceIndexRoaringBitmap.map(input)
: BitSliceIndexRoaringBitmap.EMPTY;
- return new Reader(dataType, rowNumber, positive, negative);
+ Reader reader = new Reader(dataType, rowNumber, positive,
negative);
+ return valuesAreTruncated(dataType) ? new
TruncatedValueReader(reader) : reader;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
+ /**
+ * Whether the value mapper loses information for this type. TIMESTAMP
above microsecond
+ * precision is mapped with {@link Timestamp#toMicros()}, so two values
that differ only below a
+ * microsecond share one indexed value.
+ */
+ private static boolean valuesAreTruncated(DataType dataType) {
+ if (dataType instanceof TimestampType) {
+ return ((TimestampType) dataType).getPrecision() > 6;
+ }
+ if (dataType instanceof LocalZonedTimestampType) {
+ return ((LocalZonedTimestampType) dataType).getPrecision() > 6;
+ }
+ return false;
+ }
+
private static class Writer extends FileIndexWriter {
private final Function<Object, Long> valueMapper;
@@ -358,6 +374,32 @@ public class BitSliceIndexBitmapFileIndex implements
FileIndexer {
}
}
+ /**
+ * Reader for a column whose values the mapper truncated, so comparing a
literal against the
+ * indexed value cannot answer the predicate: {@code ts <> '...000000000'}
would drop a row
+ * whose nanoseconds differ, and {@code ts = '...'} would select it.
Inheriting {@link
+ * FileIndexReader}'s {@code REMAIN} for those leaves the rows to be read
and filtered.
+ * Null-ness survives truncation, so those two questions still come from
the index.
+ */
+ private static class TruncatedValueReader extends FileIndexReader {
+
+ private final Reader reader;
+
+ public TruncatedValueReader(Reader reader) {
+ this.reader = reader;
+ }
+
+ @Override
+ public FileIndexResult visitIsNull(FieldRef fieldRef) {
+ return reader.visitIsNull(fieldRef);
+ }
+
+ @Override
+ public FileIndexResult visitIsNotNull(FieldRef fieldRef) {
+ return reader.visitIsNotNull(fieldRef);
+ }
+ }
+
public static Function<Object, Long> getValueMapper(DataType dataType) {
return dataType.accept(
new DataTypeDefaultVisitor<Function<Object, Long>>() {
diff --git
a/paimon-common/src/test/java/org/apache/paimon/fileindex/bsi/BitSliceIndexBitmapFileIndexTest.java
b/paimon-common/src/test/java/org/apache/paimon/fileindex/bsi/BitSliceIndexBitmapFileIndexTest.java
index ad60831ea2..467a569a15 100644
---
a/paimon-common/src/test/java/org/apache/paimon/fileindex/bsi/BitSliceIndexBitmapFileIndexTest.java
+++
b/paimon-common/src/test/java/org/apache/paimon/fileindex/bsi/BitSliceIndexBitmapFileIndexTest.java
@@ -18,13 +18,16 @@
package org.apache.paimon.fileindex.bsi;
+import org.apache.paimon.data.Timestamp;
import org.apache.paimon.fileindex.FileIndexReader;
+import org.apache.paimon.fileindex.FileIndexResult;
import org.apache.paimon.fileindex.FileIndexWriter;
import org.apache.paimon.fileindex.bitmap.BitmapIndexResult;
import org.apache.paimon.fs.ByteArraySeekableStream;
import org.apache.paimon.predicate.FieldRef;
import org.apache.paimon.types.BigIntType;
import org.apache.paimon.types.IntType;
+import org.apache.paimon.types.TimestampType;
import org.apache.paimon.utils.RoaringBitmap32;
import org.junit.jupiter.api.Test;
@@ -331,4 +334,65 @@ public class BitSliceIndexBitmapFileIndexTest {
.hasCauseInstanceOf(IllegalArgumentException.class)
.hasRootCauseMessage("values should be non-negative");
}
+
+ @Test
+ public void testSubMicrosecondTimestampIndexAnswersNoValuePredicate() {
+ // The value mapper stores micros, so these two rows share one indexed
value.
+ Timestamp second = Timestamp.fromEpochMillis(1000, 0);
+ Timestamp secondAndHalfMicro = Timestamp.fromEpochMillis(1000, 500);
+
+ TimestampType nanos = new TimestampType(9);
+ FieldRef fieldRef = new FieldRef(0, "", nanos);
+ BitSliceIndexBitmapFileIndex bsiFileIndex = new
BitSliceIndexBitmapFileIndex(nanos);
+ FileIndexWriter writer = bsiFileIndex.createWriter();
+ for (Object o : new Object[] {second, secondAndHalfMicro, null}) {
+ writer.write(o);
+ }
+ byte[] bytes = writer.serializedBytes();
+ FileIndexReader reader =
+ bsiFileIndex.createReader(new ByteArraySeekableStream(bytes),
0, bytes.length);
+
+ // Answering these from the index would drop row 1 from the <> result
and select it for
+ // the =, since the bitmap is the row set the scan reads.
+ assertThat(reader.visitEqual(fieldRef,
second)).isSameAs(FileIndexResult.REMAIN);
+ assertThat(reader.visitNotEqual(fieldRef,
second)).isSameAs(FileIndexResult.REMAIN);
+ assertThat(reader.visitIn(fieldRef, Arrays.asList(second,
secondAndHalfMicro)))
+ .isSameAs(FileIndexResult.REMAIN);
+ assertThat(reader.visitNotIn(fieldRef, Arrays.asList(second)))
+ .isSameAs(FileIndexResult.REMAIN);
+ assertThat(reader.visitLessThan(fieldRef, secondAndHalfMicro))
+ .isSameAs(FileIndexResult.REMAIN);
+ assertThat(reader.visitGreaterThan(fieldRef,
second)).isSameAs(FileIndexResult.REMAIN);
+ assertThat(reader.visitBetween(fieldRef, second, secondAndHalfMicro))
+ .isSameAs(FileIndexResult.REMAIN);
+
+ // Null-ness does not depend on the truncated digits, so it still
prunes.
+ assertThat(((BitmapIndexResult) reader.visitIsNull(fieldRef)).get())
+ .isEqualTo(RoaringBitmap32.bitmapOf(2));
+ assertThat(((BitmapIndexResult) reader.visitIsNotNull(fieldRef)).get())
+ .isEqualTo(RoaringBitmap32.bitmapOf(0, 1));
+ }
+
+ @Test
+ public void testMicrosecondTimestampIndexStillAnswersValuePredicates() {
+ // Precision 6 is exactly what the mapper stores, so nothing is given
up there.
+ Timestamp second = Timestamp.fromEpochMillis(1000, 0);
+ Timestamp secondAndMicro = Timestamp.fromEpochMillis(1000, 1000);
+
+ TimestampType micros = new TimestampType(6);
+ FieldRef fieldRef = new FieldRef(0, "", micros);
+ BitSliceIndexBitmapFileIndex bsiFileIndex = new
BitSliceIndexBitmapFileIndex(micros);
+ FileIndexWriter writer = bsiFileIndex.createWriter();
+ for (Object o : new Object[] {second, secondAndMicro}) {
+ writer.write(o);
+ }
+ byte[] bytes = writer.serializedBytes();
+ FileIndexReader reader =
+ bsiFileIndex.createReader(new ByteArraySeekableStream(bytes),
0, bytes.length);
+
+ assertThat(((BitmapIndexResult) reader.visitEqual(fieldRef,
second)).get())
+ .isEqualTo(RoaringBitmap32.bitmapOf(0));
+ assertThat(((BitmapIndexResult) reader.visitNotEqual(fieldRef,
second)).get())
+ .isEqualTo(RoaringBitmap32.bitmapOf(1));
+ }
}
diff --git
a/paimon-core/src/test/java/org/apache/paimon/table/DataEvolutionFileIndexTest.java
b/paimon-core/src/test/java/org/apache/paimon/table/DataEvolutionFileIndexTest.java
index 178e5ed9e2..a45a508057 100644
---
a/paimon-core/src/test/java/org/apache/paimon/table/DataEvolutionFileIndexTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/table/DataEvolutionFileIndexTest.java
@@ -24,6 +24,7 @@ import org.apache.paimon.data.BinaryRow;
import org.apache.paimon.data.BinaryString;
import org.apache.paimon.data.GenericRow;
import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.data.Timestamp;
import org.apache.paimon.data.serializer.InternalRowSerializer;
import org.apache.paimon.deletionvectors.BitmapDeletionVector;
import org.apache.paimon.deletionvectors.DeletionVector;
@@ -31,6 +32,7 @@ import
org.apache.paimon.deletionvectors.append.BaseAppendDeleteFileMaintainer;
import org.apache.paimon.fileindex.FileIndexOptions;
import org.apache.paimon.fileindex.bitmap.BitmapFileIndexFactory;
import org.apache.paimon.fileindex.bloomfilter.BloomFilterFileIndexFactory;
+import org.apache.paimon.fileindex.bsi.BitSliceIndexBitmapFileIndexFactory;
import org.apache.paimon.index.IndexFileMeta;
import org.apache.paimon.io.CompactIncrement;
import org.apache.paimon.io.DataFileMeta;
@@ -557,6 +559,80 @@ public class DataEvolutionFileIndexTest extends
DataEvolutionTestBase {
return options;
}
+ @ParameterizedTest
+ @ValueSource(strings = {"parquet", "orc"})
+ public void testSubMicrosecondTimestampBsiMatchesUnindexed(String format)
throws Exception {
+ // A BSI index maps TIMESTAMP through toMicros(), so on a TIMESTAMP(9)
column two values in
+ // the same microsecond share one indexed value. Answering <>, strict
range or NOT IN from
+ // that bitmap drops rows the residual filter can no longer recover.
The fix makes the
+ // indexed read (executeFilter) return exactly what an unindexed full
scan does.
+ FileStoreTable table = createTimestampBsiTable("bsi_ts9_" + format,
format);
+
+ long base = 1_704_067_200_000L;
+ Timestamp tsA = Timestamp.fromEpochMillis(base, 123_000); // micro
bucket base*1000+123
+ Timestamp tsB = Timestamp.fromEpochMillis(base, 123_400); // same
bucket, but tsB > tsA
+ Timestamp tsC = Timestamp.fromEpochMillis(base, 999_000); // a
different bucket
+ write(
+ table,
+ GenericRow.of(0, tsA),
+ GenericRow.of(1, tsB),
+ GenericRow.of(2, tsC),
+ GenericRow.of(3, null));
+
+ PredicateBuilder b = new PredicateBuilder(table.rowType());
+
+ // Guard: the sub-microsecond nanos must survive the write/read round
trip on this format,
+ // otherwise tsA and tsB collapse and the comparison below would pass
vacuously.
+ List<Timestamp> stored = new ArrayList<>();
+ for (InternalRow row : fullScanFiltered(table, b.isNotNull(1))) {
+ stored.add(row.getTimestamp(1, 9));
+ }
+ assertThat(stored).contains(tsA, tsB);
+
+ // notEqual / strict lessThan / strict greaterThan are the ones that
lose rows to the
+ // micro-bucket collision; isNull/isNotNull still come from the index;
equal/between only
+ // over-select and are already corrected by the residual filter.
+ List<Predicate> predicates =
+ Arrays.asList(
+ b.notEqual(1, tsA),
+ b.lessThan(1, tsB),
+ b.greaterThan(1, tsA),
+ b.isNull(1),
+ b.isNotNull(1),
+ b.equal(1, tsA),
+ b.between(1, tsA, tsC));
+ for (Predicate p : predicates) {
+ assertThat(query(table, p))
+
.containsExactlyInAnyOrderElementsOf(fullScanFiltered(table, p));
+ }
+ }
+
+ private FileStoreTable createTimestampBsiTable(String name, String format)
throws Exception {
+ Schema.Builder builder =
+ Schema.newBuilder()
+ .column("f0", DataTypes.INT())
+ .column("f1", DataTypes.TIMESTAMP(9))
+ .option(CoreOptions.ROW_TRACKING_ENABLED.key(), "true")
+ .option(CoreOptions.DATA_EVOLUTION_ENABLED.key(),
"true")
+ .option(CoreOptions.FILE_FORMAT.key(), format);
+ bsiOptions("f1").forEach(builder::option);
+ Identifier identifier = identifier(name);
+ catalog.createTable(identifier, builder.build(), false);
+ return getTable(identifier);
+ }
+
+ private static Map<String, String> bsiOptions(String column) {
+ Map<String, String> options = new HashMap<>();
+ options.put(
+ FileIndexOptions.FILE_INDEX
+ + "."
+ + BitSliceIndexBitmapFileIndexFactory.BSI_INDEX
+ + "."
+ + CoreOptions.COLUMNS,
+ column);
+ return options;
+ }
+
private void writeAllColumns(FileStoreTable table, int count) throws
Exception {
BatchWriteBuilder builder = table.newBatchWriteBuilder();
try (BatchTableWrite write = builder.newWrite();