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

xiangfu0 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 eeebb520118 Preserve wide Parquet INT96 timestamps (#20060)
eeebb520118 is described below

commit eeebb5201181f6c534331d35599cf2d23da18f1f
Author: Xiang Fu <[email protected]>
AuthorDate: Tue Oct 27 20:33:34 2026 -0700

    Preserve wide Parquet INT96 timestamps (#20060)
    
    * Fix Parquet INT96 timestamp overflow
    
    Reproduce: read an INT96 timestamp in year 1600 or 2300; it wraps through 
signed epoch nanoseconds. Decode logical timestamps directly and reject raw 
values outside the Long range.
    
    * Reject Parquet UINT64 values outside the signed Long range
    
    Reproduce: read UINT64 9223372036854775808 or 18446744073709551615 through 
the native reader; both become negative Long values. Preserve representable 
Long values and reject overflow at every nesting level.
    
    * Clarify the Parquet INT96 overflow carry calculation
    
    * Name the time units used for Parquet INT96 decoding
    
    * Advance Parquet row position before extracting values
    
    Reproduce: skip rejected UINT64 records, read the valid row, then check 
hasNext at the end of the file. Count each successfully fetched physical row so 
extraction errors cannot leave iteration stuck.
    
    * Advance the Avro Parquet reader after rejected records
    
    Reproduce: read an INT96 year-1600 row in raw mode and catch the conversion 
error; the next call retries that row. Advance in finally and cover 
rejected/valid/EOF plus rewind with a real Parquet file.
    
    * Preserve legacy UINT64 signed-bit values
    
    Reproduce: read a UINT64 hashed ID above Long.MAX_VALUE; rejection fails 
previously valid ingestion. Preserve its signed Long bits and cover both 
readers plus nested values, while retaining the INT96 fixes.
    
    * Consolidate INT96 fixtures and cover rejected records at EOF
    
    Share canonical Instant encoding across extractor tests and check trailing 
rejection through EOF and rewind.
    Correct the Avro and raw INT96 documentation; unchecked fetch 
classification is tracked separately in #20065.
---
 .../parquet/ParquetAvroRecordExtractor.java        | 12 +--
 .../parquet/ParquetAvroRecordReader.java           | 20 ++---
 .../parquet/ParquetNativeRecordExtractor.java      |  9 ++-
 .../parquet/ParquetNativeRecordReader.java         |  3 +-
 .../parquet/ParquetRecordReaderConfig.java         |  1 +
 .../plugin/inputformat/parquet/ParquetUtils.java   | 42 +++++++++--
 .../parquet/ParquetAvroRecordExtractorTest.java    | 27 ++++---
 .../parquet/ParquetNativeRecordExtractorTest.java  | 85 ++++++++++++++++++----
 .../parquet/ParquetReaderLogicalTypesTest.java     | 73 +++++++++++++++++++
 .../inputformat/parquet/ParquetTestUtils.java      | 14 ++++
 10 files changed, 233 insertions(+), 53 deletions(-)

diff --git 
a/pinot-plugins/pinot-input-format/pinot-parquet/src/main/java/org/apache/pinot/plugin/inputformat/parquet/ParquetAvroRecordExtractor.java
 
b/pinot-plugins/pinot-input-format/pinot-parquet/src/main/java/org/apache/pinot/plugin/inputformat/parquet/ParquetAvroRecordExtractor.java
index be98b860388..7c2fe2a186d 100644
--- 
a/pinot-plugins/pinot-input-format/pinot-parquet/src/main/java/org/apache/pinot/plugin/inputformat/parquet/ParquetAvroRecordExtractor.java
+++ 
b/pinot-plugins/pinot-input-format/pinot-parquet/src/main/java/org/apache/pinot/plugin/inputformat/parquet/ParquetAvroRecordExtractor.java
@@ -24,18 +24,18 @@ import org.apache.avro.generic.GenericFixed;
 import org.apache.avro.generic.GenericRecord;
 import org.apache.pinot.plugin.inputformat.avro.AvroRecordExtractor;
 import org.apache.pinot.spi.data.readers.GenericRow;
-import org.apache.pinot.spi.utils.TimestampUtils;
 
 
 /// The type matrix is inherited from [AvroRecordExtractor], with two 
overrides:
 /// - INT96 timestamp (which parquet-avro surfaces as `fixed(12)` with `doc = 
"INT96 represented as byte[12]"`) →
-///   `Timestamp` (or `Long` epoch nanos when `extractRawTimeValues` is 
`true`) via
-///   [ParquetUtils#convertInt96ToEpochNanos].
+///   `Timestamp` (or `Long` epoch nanos when `extractRawTimeValues` is 
`true`) via [ParquetUtils].
 /// - Top-level unsigned 32-bit integer fields (`INTEGER(32, false)` / legacy 
`UINT_32`), which parquet-avro surfaces
 ///   as a signed Avro `int` → `Long` via [Integer#toUnsignedLong]. The field 
names come from the Parquet file schema
 ///   (see [ParquetUtils#getUnsignedInt32FieldNames]) since the Avro schema 
carries no signedness. Unsigned values
 ///   nested in a struct, list or map are still read as signed ints by this 
reader; [ParquetNativeRecordReader]
 ///   widens them at every level.
+///
+/// Unsigned 64-bit fields preserve their signed `Long` bit pattern, matching 
[ParquetNativeRecordReader].
 public class ParquetAvroRecordExtractor extends AvroRecordExtractor {
   private static final int INT96_BYTE_SIZE = 12;
   private static final String INT96_DOC = "INT96 represented as byte[12]";
@@ -66,8 +66,10 @@ public class ParquetAvroRecordExtractor extends 
AvroRecordExtractor {
   protected Object convertSingleValue(Schema schema, Object value) {
     if (schema.getType() == Schema.Type.FIXED && schema.getFixedSize() == 
INT96_BYTE_SIZE
         && INT96_DOC.equals(schema.getDoc())) {
-      long nanos = ParquetUtils.convertInt96ToEpochNanos(((GenericFixed) 
value).bytes());
-      return _extractRawTimeValues ? nanos : 
TimestampUtils.fromNanosSinceEpoch(nanos);
+      byte[] bytes = ((GenericFixed) value).bytes();
+      return _extractRawTimeValues
+          ? ParquetUtils.convertInt96ToEpochNanos(bytes)
+          : ParquetUtils.convertInt96ToTimestamp(bytes);
     }
     return super.convertSingleValue(schema, value);
   }
diff --git 
a/pinot-plugins/pinot-input-format/pinot-parquet/src/main/java/org/apache/pinot/plugin/inputformat/parquet/ParquetAvroRecordReader.java
 
b/pinot-plugins/pinot-input-format/pinot-parquet/src/main/java/org/apache/pinot/plugin/inputformat/parquet/ParquetAvroRecordReader.java
index 18e8796de9c..de5cfa6761c 100644
--- 
a/pinot-plugins/pinot-input-format/pinot-parquet/src/main/java/org/apache/pinot/plugin/inputformat/parquet/ParquetAvroRecordReader.java
+++ 
b/pinot-plugins/pinot-input-format/pinot-parquet/src/main/java/org/apache/pinot/plugin/inputformat/parquet/ParquetAvroRecordReader.java
@@ -32,9 +32,8 @@ import org.apache.pinot.spi.data.readers.RecordReader;
 import org.apache.pinot.spi.data.readers.RecordReaderConfig;
 import org.apache.pinot.spi.data.readers.RecordReaderUtils;
 
-/// Avro Record reader for Parquet file. This reader doesn't read parquet file 
with incompatible Avro schemas,
-/// e.g. INT96, DECIMAL. Please use 
[org.apache.pinot.plugin.inputformat.parquet.ParquetNativeRecordReader]
-/// instead.
+/// Reads Parquet records through Avro with logical-type conversion, including 
INT96 timestamps and DECIMAL values.
+/// Parquet schemas that cannot be represented by Avro require 
[ParquetNativeRecordReader].
 ///
 /// For More info on Avro to Parquet schema conversion:
 /// 
[https://javadoc.io/doc/org.apache.parquet/parquet-avro/latest/index.html][ref1]
@@ -72,13 +71,16 @@ public class ParquetAvroRecordReader implements 
RecordReader {
   @Override
   public GenericRow next(GenericRow reuse)
       throws IOException {
-    // Data parsing: extract current record into GenericRow.
-    _recordExtractor.extract(_nextRecord, reuse);
-    // Record fetch: read next Parquet Avro record.
     try {
-      _nextRecord = _parquetReader.read();
-    } catch (IOException e) {
-      throw new RecordFetchException("Failed to read next Parquet Avro 
record", e);
+      // Data parsing: extract current record into GenericRow.
+      _recordExtractor.extract(_nextRecord, reuse);
+    } finally {
+      // Advance even if extraction fails and the caller skips the rejected 
record.
+      try {
+        _nextRecord = _parquetReader.read();
+      } catch (IOException e) {
+        throw new RecordFetchException("Failed to read next Parquet Avro 
record", e);
+      }
     }
     return reuse;
   }
diff --git 
a/pinot-plugins/pinot-input-format/pinot-parquet/src/main/java/org/apache/pinot/plugin/inputformat/parquet/ParquetNativeRecordExtractor.java
 
b/pinot-plugins/pinot-input-format/pinot-parquet/src/main/java/org/apache/pinot/plugin/inputformat/parquet/ParquetNativeRecordExtractor.java
index a96f3412b27..5e894355a14 100644
--- 
a/pinot-plugins/pinot-input-format/pinot-parquet/src/main/java/org/apache/pinot/plugin/inputformat/parquet/ParquetNativeRecordExtractor.java
+++ 
b/pinot-plugins/pinot-input-format/pinot-parquet/src/main/java/org/apache/pinot/plugin/inputformat/parquet/ParquetNativeRecordExtractor.java
@@ -34,7 +34,6 @@ import org.apache.parquet.schema.Type;
 import org.apache.pinot.spi.data.readers.BaseRecordExtractor;
 import org.apache.pinot.spi.data.readers.GenericRow;
 import org.apache.pinot.spi.data.readers.RecordExtractorConfig;
-import org.apache.pinot.spi.utils.TimestampUtils;
 import org.apache.pinot.spi.utils.UuidUtils;
 
 
@@ -45,7 +44,7 @@ import org.apache.pinot.spi.utils.UuidUtils;
 /// - `BOOLEAN` → `Boolean`
 /// - `INT32` → `Integer`, or `Long` when annotated as unsigned 32-bit 
(`INTEGER(32, false)` / legacy `UINT_32`),
 ///   since values above `Integer.MAX_VALUE` do not fit in a signed `int`
-/// - `INT64` → `Long`
+/// - `INT64` → `Long`, preserving the signed bit pattern for unsigned 64-bit 
values
 /// - `FLOAT` → `Float`
 /// - `DOUBLE` → `Double`
 /// - `BINARY` with `STRING` / `ENUM` annotation → `String`
@@ -181,8 +180,10 @@ public class ParquetNativeRecordExtractor extends 
BaseRecordExtractor<Group> {
         case INT96: {
           // INT96 has no Parquet logical-type spec, but its physical encoding 
(nanos-of-day + Julian day) is
           // nanosecond-precision, so nanos is the natural raw unit.
-          long nanos = 
ParquetUtils.convertInt96ToEpochNanos(from.getInt96(fieldIndex, 
index).getBytes());
-          return _extractRawTimeValues ? nanos : 
TimestampUtils.fromNanosSinceEpoch(nanos);
+          byte[] bytes = from.getInt96(fieldIndex, index).getBytes();
+          return _extractRawTimeValues
+              ? ParquetUtils.convertInt96ToEpochNanos(bytes)
+              : ParquetUtils.convertInt96ToTimestamp(bytes);
         }
         case FLOAT:
           return from.getFloat(fieldIndex, index);
diff --git 
a/pinot-plugins/pinot-input-format/pinot-parquet/src/main/java/org/apache/pinot/plugin/inputformat/parquet/ParquetNativeRecordReader.java
 
b/pinot-plugins/pinot-input-format/pinot-parquet/src/main/java/org/apache/pinot/plugin/inputformat/parquet/ParquetNativeRecordReader.java
index 744f6b0484f..24dfc2a019f 100644
--- 
a/pinot-plugins/pinot-input-format/pinot-parquet/src/main/java/org/apache/pinot/plugin/inputformat/parquet/ParquetNativeRecordReader.java
+++ 
b/pinot-plugins/pinot-input-format/pinot-parquet/src/main/java/org/apache/pinot/plugin/inputformat/parquet/ParquetNativeRecordReader.java
@@ -122,9 +122,10 @@ public class ParquetNativeRecordReader implements 
RecordReader {
     } catch (Exception e) {
       throw new RecordFetchException("Failed to read next Parquet native 
record", e);
     }
+    // The physical row is consumed even if extraction fails and the caller 
skips it.
+    _currentPageIdx++;
     // Data parsing: extract into GenericRow.
     _recordExtractor.extract(_nextRecord, reuse);
-    _currentPageIdx++;
     return reuse;
   }
 
diff --git 
a/pinot-plugins/pinot-input-format/pinot-parquet/src/main/java/org/apache/pinot/plugin/inputformat/parquet/ParquetRecordReaderConfig.java
 
b/pinot-plugins/pinot-input-format/pinot-parquet/src/main/java/org/apache/pinot/plugin/inputformat/parquet/ParquetRecordReaderConfig.java
index 70f8d152046..fd61510c14b 100644
--- 
a/pinot-plugins/pinot-input-format/pinot-parquet/src/main/java/org/apache/pinot/plugin/inputformat/parquet/ParquetRecordReaderConfig.java
+++ 
b/pinot-plugins/pinot-input-format/pinot-parquet/src/main/java/org/apache/pinot/plugin/inputformat/parquet/ParquetRecordReaderConfig.java
@@ -28,6 +28,7 @@ import org.apache.pinot.spi.data.readers.RecordReaderConfig;
 ///   auto-detects via the file's `avro.schema` metadata (Avro reader if 
present, native otherwise).
 /// - `extractRawTimeValues` — opt out of TIMESTAMP / DATE / TIME conversion 
at the extractor boundary,
 ///   surfacing the raw underlying integer in the column's declared unit 
instead of the contract Java type.
+///   Raw INT96 values are signed `long` epoch nanoseconds; timestamps outside 
that range are rejected.
 ///   DECIMAL and UUID always convert. See [ParquetAvroRecordExtractor] / 
[ParquetNativeRecordExtractor] for
 ///   the per-type matrix.
 public class ParquetRecordReaderConfig implements RecordReaderConfig {
diff --git 
a/pinot-plugins/pinot-input-format/pinot-parquet/src/main/java/org/apache/pinot/plugin/inputformat/parquet/ParquetUtils.java
 
b/pinot-plugins/pinot-input-format/pinot-parquet/src/main/java/org/apache/pinot/plugin/inputformat/parquet/ParquetUtils.java
index 58cc84eca7a..bd28d39004c 100644
--- 
a/pinot-plugins/pinot-input-format/pinot-parquet/src/main/java/org/apache/pinot/plugin/inputformat/parquet/ParquetUtils.java
+++ 
b/pinot-plugins/pinot-input-format/pinot-parquet/src/main/java/org/apache/pinot/plugin/inputformat/parquet/ParquetUtils.java
@@ -56,6 +56,8 @@ public class ParquetUtils {
   /// Number of days from the Julian day epoch (4713-01-01 BC) to the Unix day 
epoch (1970-01-01).
   public static final long JULIAN_DAY_NUMBER_FOR_UNIX_EPOCH = 2440588;
 
+  private static final long SECONDS_PER_DAY = TimeUnit.DAYS.toSeconds(1);
+  private static final long NANOS_PER_SECOND = TimeUnit.SECONDS.toNanos(1);
   private static final long NANOS_PER_DAY = TimeUnit.DAYS.toNanos(1);
 
   /// Returns a ParquetReader with the given path.
@@ -159,14 +161,38 @@ public class ParquetUtils {
     }
   }
 
-  /// Converts a 12-byte INT96 timestamp to `Long` epoch nanos. INT96 stores 
the timestamp split as bytes
-  /// 0..7 (nanos within the day, long, little-endian) and bytes 8..11 (Julian 
day number, int, little-endian).
-  /// Nanos is INT96's natural unit since this physical encoding carries 
nanosecond precision; pair with
-  /// [TimestampUtils#fromNanosSinceEpoch] for a [Timestamp].
+  /// Converts a 12-byte INT96 timestamp to [Timestamp] without narrowing it 
to signed epoch nanoseconds.
+  /// INT96 stores bytes 0..7 as nanoseconds within the day and bytes 8..11 as 
the Julian day, little-endian.
+  /// This also preserves instants outside the approximately 1677–2262 range 
of signed epoch nanoseconds.
+  public static Timestamp convertInt96ToTimestamp(byte[] int96Bytes) {
+    ByteBuffer buffer = 
ByteBuffer.wrap(int96Bytes).order(ByteOrder.LITTLE_ENDIAN);
+    long days = buffer.getInt(8) - JULIAN_DAY_NUMBER_FOR_UNIX_EPOCH;
+    long nanosOfDay = buffer.getLong(0);
+    long epochSeconds = days * SECONDS_PER_DAY + Math.floorDiv(nanosOfDay, 
NANOS_PER_SECOND);
+    Timestamp timestamp = new Timestamp(epochSeconds * 1_000L);
+    timestamp.setNanos((int) Math.floorMod(nanosOfDay, NANOS_PER_SECOND));
+    return timestamp;
+  }
+
+  /// Converts a 12-byte INT96 timestamp to signed `Long` epoch nanoseconds.
+  ///
+  /// @throws IllegalArgumentException if the exact value does not fit in a 
signed `long`; logical extraction
+  ///         should use [#convertInt96ToTimestamp(byte\[\])] instead
   public static long convertInt96ToEpochNanos(byte[] int96Bytes) {
-    ByteBuffer buf = 
ByteBuffer.wrap(int96Bytes).order(ByteOrder.LITTLE_ENDIAN);
-    long days = buf.getInt(8) - JULIAN_DAY_NUMBER_FOR_UNIX_EPOCH;
-    long nanosOfDay = buf.getLong(0);
-    return days * NANOS_PER_DAY + nanosOfDay;
+    ByteBuffer buffer = 
ByteBuffer.wrap(int96Bytes).order(ByteOrder.LITTLE_ENDIAN);
+    long days = buffer.getInt(8) - JULIAN_DAY_NUMBER_FOR_UNIX_EPOCH;
+    long nanosOfDay = buffer.getLong(0);
+    // Evaluate the complete sum in 128 bits. At Long.MIN_VALUE, the day 
product alone underflows, but adding
+    // the positive day fraction brings the sum back into range, so 
multiplyExact followed by addExact is wrong.
+    long product = days * NANOS_PER_DAY;
+    long nanos = product + nanosOfDay;
+    // Unsigned low-word overflow produces a carry; nanosOfDay >> 63 
sign-extends the addend into the high word.
+    long carry = Long.compareUnsigned(nanos, product) < 0 ? 1 : 0;
+    long high = Math.multiplyHigh(days, NANOS_PER_DAY) + (nanosOfDay >> 63) + 
carry;
+    if (high != (nanos >> 63)) {
+      throw new IllegalArgumentException("INT96 timestamp " + 
convertInt96ToTimestamp(int96Bytes).toInstant()
+          + " is outside the signed epoch-nanosecond interval and cannot be 
returned as raw epoch nanoseconds");
+    }
+    return nanos;
   }
 }
diff --git 
a/pinot-plugins/pinot-input-format/pinot-parquet/src/test/java/org/apache/pinot/plugin/inputformat/parquet/ParquetAvroRecordExtractorTest.java
 
b/pinot-plugins/pinot-input-format/pinot-parquet/src/test/java/org/apache/pinot/plugin/inputformat/parquet/ParquetAvroRecordExtractorTest.java
index e4adca70a21..39018954223 100644
--- 
a/pinot-plugins/pinot-input-format/pinot-parquet/src/test/java/org/apache/pinot/plugin/inputformat/parquet/ParquetAvroRecordExtractorTest.java
+++ 
b/pinot-plugins/pinot-input-format/pinot-parquet/src/test/java/org/apache/pinot/plugin/inputformat/parquet/ParquetAvroRecordExtractorTest.java
@@ -18,9 +18,8 @@
  */
 package org.apache.pinot.plugin.inputformat.parquet;
 
-import java.nio.ByteBuffer;
-import java.nio.ByteOrder;
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.List;
 import org.apache.avro.Schema;
 import org.apache.avro.Schema.Field;
@@ -30,7 +29,9 @@ import 
org.apache.pinot.plugin.inputformat.avro.AvroRecordExtractorConfig;
 import org.apache.pinot.spi.data.readers.GenericRow;
 import org.testng.annotations.Test;
 
+import static 
org.apache.pinot.plugin.inputformat.parquet.ParquetTestUtils.int96Bytes;
 import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.expectThrows;
 
 
 /// Tests [ParquetAvroRecordExtractor]'s INT96 override — see its class 
Javadoc. All other Avro behavior is
@@ -78,6 +79,18 @@ public class ParquetAvroRecordExtractorTest {
     assertEquals(extract(record, null), new Timestamp(epochMillis));
   }
 
+  @Test
+  public void testInt96BeyondEpochNanosRange() {
+    for (String text : new String[]{"1600-01-01T00:00:00.123456789Z", 
"2300-01-01T00:00:00.123456789Z"}) {
+      Instant instant = Instant.parse(text);
+      byte[] bytes = int96Bytes(instant);
+      Schema fixed = Schema.createFixed("Int96", "INT96 represented as 
byte[12]", null, 12);
+      GenericRecord record = singleField(fixed, new GenericData.Fixed(fixed, 
bytes));
+      assertEquals(extract(record), Timestamp.from(instant), text);
+      expectThrows(IllegalArgumentException.class, () -> extract(record, 
rawConfig()));
+    }
+  }
+
   // === Helpers ===
 
   private static Object extract(GenericRecord record, 
AvroRecordExtractorConfig config) {
@@ -112,16 +125,8 @@ public class ParquetAvroRecordExtractorTest {
     return Schema.createUnion(List.of(Schema.create(Schema.Type.NULL), 
fixedSchema));
   }
 
-  /// Encode `epochMillis` as an INT96 byte[12]: bytes 0..7 = nanos-within-day 
(long), bytes 8..11 = Julian
-  /// day number (int).
   private static GenericData.Fixed int96Fixed(long epochMillis) {
-    long millisPerDay = 86_400_000L;
-    long dayNumber = epochMillis / millisPerDay;
-    long nanosWithinDay = (epochMillis - dayNumber * millisPerDay) * 
1_000_000L;
-    int julianDay = (int) (dayNumber + 
ParquetUtils.JULIAN_DAY_NUMBER_FOR_UNIX_EPOCH);
-    byte[] int96 = ByteBuffer.allocate(12).order(ByteOrder.LITTLE_ENDIAN)
-        .putLong(nanosWithinDay).putInt(julianDay).array();
     Schema fixedSchema = Schema.createFixed("Int96", "INT96 represented as 
byte[12]", null, 12);
-    return new GenericData.Fixed(fixedSchema, int96);
+    return new GenericData.Fixed(fixedSchema, 
int96Bytes(Instant.ofEpochMilli(epochMillis)));
   }
 }
diff --git 
a/pinot-plugins/pinot-input-format/pinot-parquet/src/test/java/org/apache/pinot/plugin/inputformat/parquet/ParquetNativeRecordExtractorTest.java
 
b/pinot-plugins/pinot-input-format/pinot-parquet/src/test/java/org/apache/pinot/plugin/inputformat/parquet/ParquetNativeRecordExtractorTest.java
index 0c5bcf7ae7c..0cb294e2c16 100644
--- 
a/pinot-plugins/pinot-input-format/pinot-parquet/src/test/java/org/apache/pinot/plugin/inputformat/parquet/ParquetNativeRecordExtractorTest.java
+++ 
b/pinot-plugins/pinot-input-format/pinot-parquet/src/test/java/org/apache/pinot/plugin/inputformat/parquet/ParquetNativeRecordExtractorTest.java
@@ -19,9 +19,8 @@
 package org.apache.pinot.plugin.inputformat.parquet;
 
 import java.math.BigDecimal;
-import java.nio.ByteBuffer;
-import java.nio.ByteOrder;
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.time.LocalDate;
 import java.time.LocalTime;
 import java.util.Map;
@@ -40,8 +39,10 @@ import org.apache.pinot.spi.data.readers.GenericRow;
 import org.apache.pinot.spi.utils.UuidUtils;
 import org.testng.annotations.Test;
 
+import static 
org.apache.pinot.plugin.inputformat.parquet.ParquetTestUtils.int96Bytes;
 import static org.testng.Assert.assertEquals;
 import static org.testng.Assert.assertNull;
+import static org.testng.Assert.expectThrows;
 
 
 /// Tests [ParquetNativeRecordExtractor] — see its class Javadoc for the 
Parquet primitive + logical-type
@@ -70,11 +71,51 @@ public class ParquetNativeRecordExtractorTest {
     assertEquals(extract(schema, g -> g.add(0, 1_588_469_340_000L)), 
1_588_469_340_000L);
   }
 
+  @Test
+  public void testUnsignedInt64PreservesSignedLongBits() {
+    for (String annotation : new String[]{"UINT_64", "INTEGER(64,false)"}) {
+      MessageType schema = MessageTypeParser.parseMessageType(
+          "message R { required int64 col (" + annotation + "); }");
+      for (long value : new long[]{0L, 1L, Long.MAX_VALUE, Long.MIN_VALUE, 
-1L}) {
+        assertEquals(extract(schema, g -> g.add(0, value)), value, annotation);
+        assertEquals(extract(schema, g -> g.add(0, value), rawConfig()), 
value, annotation);
+      }
+    }
+    MessageType signed = MessageTypeParser.parseMessageType("message R { 
required int64 col; }");
+    assertEquals(extract(signed, g -> g.add(0, -1L)), -1L);
+    assertEquals(extract(signed, g -> g.add(0, Long.MIN_VALUE)), 
Long.MIN_VALUE);
+  }
+
+  @Test
+  public void testNestedUnsignedInt64PreservesSignedLongBits() {
+    MessageType list = MessageTypeParser.parseMessageType("message R { 
required group col (LIST) {"
+        + " repeated group list { required int64 element (UINT_64); } } }");
+    MessageType struct = MessageTypeParser.parseMessageType(
+        "message R { required group col { required int64 value (UINT_64); } 
}");
+    MessageType map = MessageTypeParser.parseMessageType("message R { required 
group col (MAP) {"
+        + " repeated group key_value { required int64 key (UINT_64); optional 
int64 value (UINT_64); } } }");
+    for (long value : new long[]{Long.MIN_VALUE, -1L}) {
+      assertEquals(extract(list, g -> g.addGroup(0).addGroup(0).add(0, 
value)), new Object[]{value});
+      assertEquals(extract(struct, g -> g.addGroup(0).add(0, value)), 
Map.of("value", value));
+      assertEquals(extract(map, g -> {
+        Group entry = g.addGroup(0).addGroup(0);
+        entry.add(0, value);
+        entry.add(1, 0L);
+      }), Map.of(Long.toString(value), 0L));
+      assertEquals(extract(map, g -> {
+        Group entry = g.addGroup(0).addGroup(0);
+        entry.add(0, 0L);
+        entry.add(1, value);
+      }), Map.of("0", value));
+    }
+  }
+
   @Test
   public void testInt96ExtractedAsTimestamp() {
     long epochMillis = 1_649_924_302_123L;
     MessageType schema = MessageTypeParser.parseMessageType("message R { 
required int96 col; }");
-    assertEquals(extract(schema, g -> g.add(0, 
Binary.fromConstantByteArray(int96Bytes(epochMillis)))),
+    assertEquals(extract(schema,
+        g -> g.add(0, 
Binary.fromConstantByteArray(int96Bytes(Instant.ofEpochMilli(epochMillis))))),
         new Timestamp(epochMillis));
   }
 
@@ -270,10 +311,35 @@ public class ParquetNativeRecordExtractorTest {
     // nanosecond precision, so nanos is the natural raw unit.
     long epochMillis = 1_649_924_302_123L;
     MessageType schema = MessageTypeParser.parseMessageType("message R { 
required int96 col; }");
-    assertEquals(extract(schema, g -> g.add(0, 
Binary.fromConstantByteArray(int96Bytes(epochMillis))), rawConfig()),
+    assertEquals(extract(schema,
+        g -> g.add(0, 
Binary.fromConstantByteArray(int96Bytes(Instant.ofEpochMilli(epochMillis)))), 
rawConfig()),
         epochMillis * 1_000_000L);
   }
 
+  @Test
+  public void testInt96RangeAndNanosecondBoundaries() {
+    MessageType schema = MessageTypeParser.parseMessageType("message R { 
required int96 col; }");
+    for (String text : new String[]{"1970-01-01T00:00:00Z", 
"1969-12-31T23:59:59.999999999Z",
+        "1677-09-21T00:12:43.145224192Z", "2262-04-11T23:47:16.854775807Z",
+        "1677-09-21T00:12:43.145224191Z", "2262-04-11T23:47:16.854775808Z",
+        "1600-01-01T00:00:00.123456789Z", "2300-01-01T00:00:00.123456789Z"}) {
+      Instant instant = Instant.parse(text);
+      byte[] bytes = int96Bytes(instant);
+      assertEquals(extract(schema, g -> g.add(0, 
Binary.fromConstantByteArray(bytes))), Timestamp.from(instant), text);
+      BigDecimal nanos = 
BigDecimal.valueOf(instant.getEpochSecond()).movePointRight(9)
+          .add(BigDecimal.valueOf(instant.getNano()));
+      if (nanos.compareTo(BigDecimal.valueOf(Long.MIN_VALUE)) >= 0
+          && nanos.compareTo(BigDecimal.valueOf(Long.MAX_VALUE)) <= 0) {
+        assertEquals(extract(schema, g -> g.add(0, 
Binary.fromConstantByteArray(bytes)), rawConfig()),
+            nanos.longValueExact(), text);
+      } else {
+        IllegalArgumentException exception = 
expectThrows(IllegalArgumentException.class,
+            () -> extract(schema, g -> g.add(0, 
Binary.fromConstantByteArray(bytes)), rawConfig()));
+        assertEquals(exception.getMessage().contains(instant.toString()), 
true, exception.getMessage());
+      }
+    }
+  }
+
   // === LIST — standard 3-level wrapper and legacy forms ===
 
   @Test
@@ -449,15 +515,4 @@ public class ParquetNativeRecordExtractorTest {
     config.setExtractRawTimeValues(true);
     return config;
   }
-
-  /// Encodes `epochMillis` as an INT96 byte[12]: bytes 0..7 = 
nanos-within-day (long, little-endian),
-  /// bytes 8..11 = Julian day number (int, little-endian).
-  private static byte[] int96Bytes(long epochMillis) {
-    long millisPerDay = 86_400_000L;
-    long dayNumber = epochMillis / millisPerDay;
-    long nanosWithinDay = (epochMillis - dayNumber * millisPerDay) * 
1_000_000L;
-    int julianDay = (int) (dayNumber + 
ParquetUtils.JULIAN_DAY_NUMBER_FOR_UNIX_EPOCH);
-    return ByteBuffer.allocate(12).order(ByteOrder.LITTLE_ENDIAN)
-        .putLong(nanosWithinDay).putInt(julianDay).array();
-  }
 }
diff --git 
a/pinot-plugins/pinot-input-format/pinot-parquet/src/test/java/org/apache/pinot/plugin/inputformat/parquet/ParquetReaderLogicalTypesTest.java
 
b/pinot-plugins/pinot-input-format/pinot-parquet/src/test/java/org/apache/pinot/plugin/inputformat/parquet/ParquetReaderLogicalTypesTest.java
index 89b12bf5d4a..c7666a16f95 100644
--- 
a/pinot-plugins/pinot-input-format/pinot-parquet/src/test/java/org/apache/pinot/plugin/inputformat/parquet/ParquetReaderLogicalTypesTest.java
+++ 
b/pinot-plugins/pinot-input-format/pinot-parquet/src/test/java/org/apache/pinot/plugin/inputformat/parquet/ParquetReaderLogicalTypesTest.java
@@ -41,6 +41,9 @@ import org.apache.pinot.spi.data.readers.RecordReader;
 import org.testng.annotations.Test;
 
 import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertTrue;
+import static org.testng.Assert.expectThrows;
 
 
 /// TODO: add test for other logical types
@@ -177,6 +180,76 @@ public class ParquetReaderLogicalTypesTest {
     }
   }
 
+  @Test
+  public void testReadersPreserveUnsignedInt64Bits()
+      throws IOException {
+    MessageType schema = MessageTypeParser.parseMessageType(
+        "message m { required int64 value (INTEGER(64, false)); }");
+    File dataFile = new File(FileUtils.getTempDirectory(), "unsigned-int64-" + 
System.nanoTime() + ".parquet");
+    try {
+      try (ParquetWriter<Group> writer = ExampleParquetWriter.builder(new 
Path(dataFile.getAbsolutePath()))
+          .withType(schema).build()) {
+        SimpleGroupFactory groups = new SimpleGroupFactory(schema);
+        for (long value : new long[]{-1L, 7L, Long.MIN_VALUE}) {
+          writer.write(groups.newGroup().append("value", value));
+        }
+      }
+      for (RecordReader reader : List.of(new ParquetNativeRecordReader(), new 
ParquetAvroRecordReader())) {
+        try (reader) {
+          reader.init(dataFile, null, null);
+          for (long value : new long[]{-1L, 7L, Long.MIN_VALUE}) {
+            assertTrue(reader.hasNext());
+            assertEquals(reader.next(new GenericRow()).getValue("value"), 
value);
+          }
+          assertFalse(reader.hasNext());
+        }
+      }
+    } finally {
+      FileUtils.deleteQuietly(dataFile);
+    }
+  }
+
+  @Test
+  public void testReadersAdvancePastRejectedInt96()
+      throws IOException {
+    MessageType schema = MessageTypeParser.parseMessageType("message m { 
required int96 value; }");
+    File dataFile = new File(FileUtils.getTempDirectory(), "rejected-int96-" + 
System.nanoTime() + ".parquet");
+    try {
+      try (ParquetWriter<Group> writer = ExampleParquetWriter.builder(new 
Path(dataFile.getAbsolutePath()))
+          .withType(schema).build()) {
+        SimpleGroupFactory groups = new SimpleGroupFactory(schema);
+        // Year 1600 cannot fit signed Long epoch nanos; the valid middle row 
is followed by another rejection.
+        writer.write(groups.newGroup().append("value", new NanoTime(2305448, 
0L)));
+        writer.write(groups.newGroup().append("value", new NanoTime(2440588, 
7L)));
+        writer.write(groups.newGroup().append("value", new NanoTime(2305448, 
0L)));
+      }
+      for (boolean useAvro : new boolean[]{false, true}) {
+        ParquetRecordReaderConfig config = new ParquetRecordReaderConfig();
+        config.setUseParquetAvroRecordReader(useAvro);
+        config.setUseParquetNativeRecordReader(!useAvro);
+        config.setExtractRawTimeValues(true);
+        try (ParquetRecordReader reader = new ParquetRecordReader()) {
+          reader.init(dataFile, null, config);
+          assertEquals(reader.useAvroParquetRecordReader(), useAvro);
+          for (int iteration = 0; iteration < 2; iteration++) {
+            if (iteration > 0) {
+              reader.rewind();
+            }
+            assertTrue(reader.hasNext());
+            expectThrows(IllegalArgumentException.class, () -> reader.next(new 
GenericRow()));
+            assertTrue(reader.hasNext());
+            assertEquals(reader.next(new GenericRow()).getValue("value"), 7L);
+            assertTrue(reader.hasNext());
+            expectThrows(IllegalArgumentException.class, () -> reader.next(new 
GenericRow()));
+            assertFalse(reader.hasNext(), "Extraction failures must not retry 
the rejected record");
+          }
+        }
+      }
+    } finally {
+      FileUtils.deleteQuietly(dataFile);
+    }
+  }
+
   /// Write the given row object to a random parquet file and return the path 
of the file
   private String writeToFile(String schemaStr, Map<String, Object> rowObject)
       throws IOException {
diff --git 
a/pinot-plugins/pinot-input-format/pinot-parquet/src/test/java/org/apache/pinot/plugin/inputformat/parquet/ParquetTestUtils.java
 
b/pinot-plugins/pinot-input-format/pinot-parquet/src/test/java/org/apache/pinot/plugin/inputformat/parquet/ParquetTestUtils.java
index 97bb404c33f..36ac415edda 100644
--- 
a/pinot-plugins/pinot-input-format/pinot-parquet/src/test/java/org/apache/pinot/plugin/inputformat/parquet/ParquetTestUtils.java
+++ 
b/pinot-plugins/pinot-input-format/pinot-parquet/src/test/java/org/apache/pinot/plugin/inputformat/parquet/ParquetTestUtils.java
@@ -19,6 +19,10 @@
 package org.apache.pinot.plugin.inputformat.parquet;
 
 import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.time.Instant;
+import java.util.concurrent.TimeUnit;
 import org.apache.avro.Schema;
 import org.apache.avro.generic.GenericRecord;
 import org.apache.hadoop.fs.Path;
@@ -33,6 +37,16 @@ public class ParquetTestUtils {
   private ParquetTestUtils() {
   }
 
+  /// Encodes an instant as little-endian INT96 with a nonnegative 
nanosecond-of-day and Julian day number.
+  static byte[] int96Bytes(Instant instant) {
+    long secondsPerDay = TimeUnit.DAYS.toSeconds(1);
+    long day = Math.floorDiv(instant.getEpochSecond(), secondsPerDay);
+    long nanosOfDay = Math.floorMod(instant.getEpochSecond(), secondsPerDay) * 
TimeUnit.SECONDS.toNanos(1)
+        + instant.getNano();
+    return 
ByteBuffer.allocate(12).order(ByteOrder.LITTLE_ENDIAN).putLong(nanosOfDay)
+        .putInt(Math.toIntExact(day + 
ParquetUtils.JULIAN_DAY_NUMBER_FOR_UNIX_EPOCH)).array();
+  }
+
   /// Returns a ParquetWriter with the given path and schema.
   public static ParquetWriter<GenericRecord> getParquetAvroWriter(Path path, 
Schema schema)
       throws IOException {


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

Reply via email to