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 f37bbe5980f Map recommender Avro schema by original data type, not 
stored type (#18970)
f37bbe5980f is described below

commit f37bbe5980f82424898bd4a8df1b4c8fc426e2da
Author: Xiang Fu <[email protected]>
AuthorDate: Sat Jul 11 03:58:43 2026 +0200

    Map recommender Avro schema by original data type, not stored type (#18970)
    
    * Map Avro schema by original data type, not stored type
    
    `AvroSchemaUtil.toAvroSchemaJsonObject` switched on `getStoredType()`,
    collapsing logical types to their physical storage: a BOOLEAN field
    emitted Avro `"int"` and TIMESTAMP a plain `"long"`, so the generated
    Avro schema misrepresented the column and could not round-trip back to
    BOOLEAN/TIMESTAMP.
    
    Switch on the original `DataType` instead: BOOLEAN -> Avro `boolean`,
    TIMESTAMP -> `timestamp-millis` long, UUID -> `bytes` (unchanged), all
    others unchanged. `AvroWriter` coerces the generator's stored int `0`/`1`
    for BOOLEAN columns to a `Boolean` so the new schema serializes, with the
    boolean columns resolved once per file rather than per row.
    
    The segment-processing converters `AvroUtils.getAvroSchemaFromPinotSchema`
    and `SegmentProcessorAvroUtils.convertPinotSchemaToAvroSchema`
    intentionally keep the stored type because they serialize physically
    stored values; this divergence is documented on `toAvroSchemaJsonObject`.
    
    * Dedupe nullable-union unwrap: reuse AvroRecordAppender.nonNullBranch in 
test
    
    Address review nit: expose the appender's union-branch helper as
    @VisibleForTesting (returning the branch Schema) and have the test's
    nonNullBranch delegate to it instead of re-implementing the union walk.
---
 .../recommender/data/writer/AvroWriter.java        |  45 ++++++-
 .../recommender/data/writer/AvroWriterTest.java    | 135 +++++++++++++++++++++
 .../plugin/inputformat/avro/AvroSchemaUtil.java    |  31 ++++-
 .../inputformat/avro/AvroSchemaUtilTest.java       |  74 +++++++++++
 4 files changed, 280 insertions(+), 5 deletions(-)

diff --git 
a/pinot-controller/src/main/java/org/apache/pinot/controller/recommender/data/writer/AvroWriter.java
 
b/pinot-controller/src/main/java/org/apache/pinot/controller/recommender/data/writer/AvroWriter.java
index 5b82bf10245..04a858035da 100644
--- 
a/pinot-controller/src/main/java/org/apache/pinot/controller/recommender/data/writer/AvroWriter.java
+++ 
b/pinot-controller/src/main/java/org/apache/pinot/controller/recommender/data/writer/AvroWriter.java
@@ -21,11 +21,16 @@ package org.apache.pinot.controller.recommender.data.writer;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.node.ArrayNode;
 import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.google.common.annotations.VisibleForTesting;
 import java.io.Closeable;
 import java.io.File;
 import java.io.IOException;
+import java.util.HashSet;
 import java.util.Map;
 import java.util.Objects;
+import java.util.Set;
+import org.apache.avro.Schema.Field;
+import org.apache.avro.Schema.Type;
 import org.apache.avro.file.DataFileWriter;
 import org.apache.avro.generic.GenericData;
 import org.apache.avro.generic.GenericDatumWriter;
@@ -93,10 +98,12 @@ public class AvroWriter implements Writer {
 class AvroRecordAppender implements Closeable {
   private final DataFileWriter<GenericData.Record> _recordWriter;
   private final org.apache.avro.Schema _avroSchema;
+  private final Set<String> _booleanColumns;
 
   public AvroRecordAppender(File file, org.apache.avro.Schema avroSchema)
       throws IOException {
     _avroSchema = avroSchema;
+    _booleanColumns = booleanColumns(avroSchema);
     _recordWriter = new DataFileWriter<>(new 
GenericDatumWriter<>(_avroSchema));
     _recordWriter.create(_avroSchema, file);
   }
@@ -104,12 +111,44 @@ class AvroRecordAppender implements Closeable {
   public void append(Map<String, Object> record)
       throws IOException {
     GenericData.Record nextRecord = new GenericData.Record(_avroSchema);
-    record.forEach((column, value) -> {
-      nextRecord.put(column, record.get(column));
-    });
+    record.forEach((column, value) -> nextRecord.put(column, 
coerce(_booleanColumns.contains(column), value)));
     _recordWriter.append(nextRecord);
   }
 
+  /// The data generator emits Pinot's stored representation (an int `0`/`1` 
for BOOLEAN), while the Avro schema now
+  /// declares the logical `boolean` type. Coerce those stored ints to 
`Boolean` so they serialize; other values pass
+  /// through unchanged.
+  @VisibleForTesting
+  static Object coerce(boolean booleanColumn, Object value) {
+    if (booleanColumn && value instanceof Number) {
+      return ((Number) value).intValue() != 0;
+    }
+    return value;
+  }
+
+  private static Set<String> booleanColumns(org.apache.avro.Schema avroSchema) 
{
+    Set<String> booleanColumns = new HashSet<>();
+    for (Field field : avroSchema.getFields()) {
+      if (nonNullBranch(field.schema()).getType() == Type.BOOLEAN) {
+        booleanColumns.add(field.name());
+      }
+    }
+    return booleanColumns;
+  }
+
+  /// Returns the non-null branch of a nullable union `["null", <type>]`, or 
the schema itself if it is not a union.
+  @VisibleForTesting
+  static org.apache.avro.Schema nonNullBranch(org.apache.avro.Schema schema) {
+    if (schema.getType() == Type.UNION) {
+      for (org.apache.avro.Schema member : schema.getTypes()) {
+        if (member.getType() != Type.NULL) {
+          return member;
+        }
+      }
+    }
+    return schema;
+  }
+
   @Override
   public void close()
       throws IOException {
diff --git 
a/pinot-controller/src/test/java/org/apache/pinot/controller/recommender/data/writer/AvroWriterTest.java
 
b/pinot-controller/src/test/java/org/apache/pinot/controller/recommender/data/writer/AvroWriterTest.java
new file mode 100644
index 00000000000..8362c362707
--- /dev/null
+++ 
b/pinot-controller/src/test/java/org/apache/pinot/controller/recommender/data/writer/AvroWriterTest.java
@@ -0,0 +1,135 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pinot.controller.recommender.data.writer;
+
+import java.io.File;
+import java.nio.file.Files;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import org.apache.avro.Schema;
+import org.apache.avro.file.DataFileReader;
+import org.apache.avro.generic.GenericDatumReader;
+import org.apache.avro.generic.GenericRecord;
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.lang3.IntegerRange;
+import org.apache.pinot.controller.recommender.data.generator.DataGenerator;
+import 
org.apache.pinot.controller.recommender.data.generator.DataGeneratorSpec;
+import org.apache.pinot.spi.data.FieldSpec.DataType;
+import org.apache.pinot.spi.data.FieldSpec.FieldType;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertTrue;
+
+
+public class AvroWriterTest {
+  private File _baseDir;
+
+  @BeforeMethod
+  public void setUp()
+      throws Exception {
+    _baseDir = Files.createTempDirectory("avroWriterTest").toFile();
+  }
+
+  @AfterMethod
+  public void tearDown()
+      throws Exception {
+    FileUtils.deleteQuietly(_baseDir);
+  }
+
+  /// Guards that logical types round-trip through the generated Avro file: 
BOOLEAN is written as an Avro boolean
+  /// (not an int) and TIMESTAMP as a timestamp-millis long. Before the fix 
the schema used the stored type, so
+  /// BOOLEAN showed up as "int" and TIMESTAMP as a plain "long".
+  @Test
+  public void testLogicalTypesRoundTrip()
+      throws Exception {
+    List<String> columns = List.of("intCol", "longCol", "boolCol", "tsCol", 
"strCol");
+    Map<String, DataType> dataTypes = new HashMap<>();
+    dataTypes.put("intCol", DataType.INT);
+    dataTypes.put("longCol", DataType.LONG);
+    dataTypes.put("boolCol", DataType.BOOLEAN);
+    dataTypes.put("tsCol", DataType.TIMESTAMP);
+    dataTypes.put("strCol", DataType.STRING);
+    Map<String, FieldType> fieldTypes = new HashMap<>();
+    Map<String, Integer> cardinality = new HashMap<>();
+    Map<String, Integer> length = new HashMap<>();
+    for (String column : columns) {
+      fieldTypes.put(column, FieldType.DIMENSION);
+      cardinality.put(column, 5);
+    }
+    length.put("strCol", 6);
+
+    DataGeneratorSpec spec =
+        new DataGeneratorSpec(columns, cardinality, new HashMap<String, 
IntegerRange>(), new HashMap<>(),
+            new HashMap<>(), length, dataTypes, fieldTypes, new 
HashMap<String, TimeUnit>(), new HashMap<>(),
+            new HashMap<>());
+    DataGenerator generator = new DataGenerator();
+    generator.init(spec);
+
+    int totalDocs = 10;
+    AvroWriterSpec writerSpec = new AvroWriterSpec(generator, _baseDir, 
totalDocs, 1, 0);
+    AvroWriter writer = new AvroWriter();
+    writer.init(writerSpec);
+    writer.write();
+
+    // Schema assertions: original types are preserved, not their stored types.
+    Schema avroSchema = AvroWriter.getAvroSchema(writerSpec.getSchema());
+    assertEquals(nonNullBranch(avroSchema, "intCol").getType(), 
Schema.Type.INT);
+    assertEquals(nonNullBranch(avroSchema, "longCol").getType(), 
Schema.Type.LONG);
+    assertEquals(nonNullBranch(avroSchema, "boolCol").getType(), 
Schema.Type.BOOLEAN);
+    Schema tsBranch = nonNullBranch(avroSchema, "tsCol");
+    assertEquals(tsBranch.getType(), Schema.Type.LONG);
+    assertEquals(tsBranch.getProp("logicalType"), "timestamp-millis");
+    assertEquals(nonNullBranch(avroSchema, "strCol").getType(), 
Schema.Type.STRING);
+
+    // Value assertions: the written records serialize and read back with the 
expected Java types.
+    File avroFile = new File(_baseDir, "part-0.avro");
+    assertTrue(avroFile.exists());
+    int count = 0;
+    try (DataFileReader<GenericRecord> reader = new DataFileReader<>(avroFile, 
new GenericDatumReader<>())) {
+      for (GenericRecord record : reader) {
+        assertTrue(record.get("boolCol") instanceof Boolean, "boolCol should 
be a Boolean");
+        assertTrue(record.get("tsCol") instanceof Long, "tsCol should be a 
Long");
+        assertTrue(record.get("intCol") instanceof Integer, "intCol should be 
an Integer");
+        count++;
+      }
+    }
+    assertEquals(count, totalDocs);
+  }
+
+  /// The generator emits Pinot's stored form for BOOLEAN (int 0/1); coercion 
must map it to the right Boolean and
+  /// leave non-boolean columns untouched.
+  @Test
+  public void testBooleanCoercion() {
+    assertEquals(AvroRecordAppender.coerce(true, 0), Boolean.FALSE);
+    assertEquals(AvroRecordAppender.coerce(true, 1), Boolean.TRUE);
+    assertEquals(AvroRecordAppender.coerce(true, 5), Boolean.TRUE);
+    // Non-boolean columns pass through unchanged.
+    assertEquals(AvroRecordAppender.coerce(false, 1), 1);
+    assertEquals(AvroRecordAppender.coerce(true, null), null);
+  }
+
+  private static Schema nonNullBranch(Schema recordSchema, String fieldName) {
+    return 
AvroRecordAppender.nonNullBranch(recordSchema.getField(fieldName).schema());
+  }
+}
diff --git 
a/pinot-plugins/pinot-input-format/pinot-avro-base/src/main/java/org/apache/pinot/plugin/inputformat/avro/AvroSchemaUtil.java
 
b/pinot-plugins/pinot-input-format/pinot-avro-base/src/main/java/org/apache/pinot/plugin/inputformat/avro/AvroSchemaUtil.java
index 021b46f4cd8..52ea2240bd4 100644
--- 
a/pinot-plugins/pinot-input-format/pinot-avro-base/src/main/java/org/apache/pinot/plugin/inputformat/avro/AvroSchemaUtil.java
+++ 
b/pinot-plugins/pinot-input-format/pinot-avro-base/src/main/java/org/apache/pinot/plugin/inputformat/avro/AvroSchemaUtil.java
@@ -76,10 +76,21 @@ public class AvroSchemaUtil {
     }
   }
 
+  /// Builds the Avro schema JSON for a single Pinot field. Used to generate 
sample Avro data from a Pinot schema
+  /// (see `AvroWriter`). Each field is emitted as a nullable union `["null", 
<type>]`.
+  ///
+  /// The switch is driven by the original (logical) [DataType] rather than 
the stored type, so logical types are
+  /// represented faithfully instead of collapsing to their physical storage 
type: BOOLEAN maps to Avro `boolean`
+  /// and TIMESTAMP to a `timestamp-millis` long, not a plain `int`/`long`.
+  ///
+  /// This intentionally differs from the segment-processing converters 
`AvroUtils.getAvroSchemaFromPinotSchema` and
+  /// `SegmentProcessorAvroUtils.convertPinotSchemaToAvroSchema`, which switch 
on the stored type because they
+  /// serialize Pinot's physically-stored values (e.g. an int for BOOLEAN) 
directly.
   public static ObjectNode toAvroSchemaJsonObject(FieldSpec fieldSpec) {
     ObjectNode jsonSchema = JsonUtils.newObjectNode();
     jsonSchema.put("name", fieldSpec.getName());
-    switch (fieldSpec.getDataType().getStoredType()) {
+    DataType dataType = fieldSpec.getDataType();
+    switch (dataType) {
       case INT:
         jsonSchema.set("type", convertStringsToJsonArray("null", "int"));
         return jsonSchema;
@@ -92,15 +103,31 @@ public class AvroSchemaUtil {
       case DOUBLE:
         jsonSchema.set("type", convertStringsToJsonArray("null", "double"));
         return jsonSchema;
+      case BOOLEAN:
+        jsonSchema.set("type", convertStringsToJsonArray("null", "boolean"));
+        return jsonSchema;
+      case TIMESTAMP:
+        // TIMESTAMP is stored as LONG millis-since-epoch; annotate the long 
branch with the timestamp-millis
+        // logical type so the value stays a long but is self-describing as a 
timestamp.
+        ObjectNode timestampType = JsonUtils.newObjectNode();
+        timestampType.put("type", "long");
+        timestampType.put("logicalType", "timestamp-millis");
+        ArrayNode timestampUnion = JsonUtils.newArrayNode();
+        timestampUnion.add("null");
+        timestampUnion.add(timestampType);
+        jsonSchema.set("type", timestampUnion);
+        return jsonSchema;
       case STRING:
       case JSON:
         jsonSchema.set("type", convertStringsToJsonArray("null", "string"));
         return jsonSchema;
       case BYTES:
+      case UUID:
+        // UUID is a logical type stored as fixed-width 16-byte BYTES.
         jsonSchema.set("type", convertStringsToJsonArray("null", "bytes"));
         return jsonSchema;
       default:
-        throw new UnsupportedOperationException();
+        throw new UnsupportedOperationException("Unsupported data type: " + 
dataType);
     }
   }
 
diff --git 
a/pinot-plugins/pinot-input-format/pinot-avro-base/src/test/java/org/apache/pinot/plugin/inputformat/avro/AvroSchemaUtilTest.java
 
b/pinot-plugins/pinot-input-format/pinot-avro-base/src/test/java/org/apache/pinot/plugin/inputformat/avro/AvroSchemaUtilTest.java
new file mode 100644
index 00000000000..924add4bb78
--- /dev/null
+++ 
b/pinot-plugins/pinot-input-format/pinot-avro-base/src/test/java/org/apache/pinot/plugin/inputformat/avro/AvroSchemaUtilTest.java
@@ -0,0 +1,74 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pinot.plugin.inputformat.avro;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.apache.pinot.spi.data.DimensionFieldSpec;
+import org.apache.pinot.spi.data.FieldSpec.DataType;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertThrows;
+
+
+public class AvroSchemaUtilTest {
+
+  /// The switch must be driven by the original (logical) data type, not the 
stored type. Otherwise BOOLEAN collapses
+  /// to "int" and TIMESTAMP to a plain "long", misrepresenting the column in 
the generated Avro schema.
+  @Test
+  public void testToAvroSchemaJsonObjectUsesOriginalType() {
+    assertPrimitiveType(DataType.INT, "int");
+    assertPrimitiveType(DataType.LONG, "long");
+    assertPrimitiveType(DataType.FLOAT, "float");
+    assertPrimitiveType(DataType.DOUBLE, "double");
+    assertPrimitiveType(DataType.STRING, "string");
+    assertPrimitiveType(DataType.JSON, "string");
+    assertPrimitiveType(DataType.BYTES, "bytes");
+    // UUID is a logical type stored as BYTES; it must keep mapping to Avro 
bytes (not fall through to default).
+    assertPrimitiveType(DataType.UUID, "bytes");
+    // Logical types must not collapse to their stored INT/LONG type.
+    assertPrimitiveType(DataType.BOOLEAN, "boolean");
+
+    JsonNode type = typeOf(DataType.TIMESTAMP);
+    assertEquals(type.get(0).asText(), "null");
+    JsonNode timestampBranch = type.get(1);
+    assertEquals(timestampBranch.get("type").asText(), "long");
+    assertEquals(timestampBranch.get("logicalType").asText(), 
"timestamp-millis");
+  }
+
+  /// Types with no Avro mapping (e.g. BIG_DECIMAL) must be rejected rather 
than silently mishandled.
+  @Test
+  public void testToAvroSchemaJsonObjectRejectsUnsupportedType() {
+    assertThrows(UnsupportedOperationException.class,
+        () -> AvroSchemaUtil.toAvroSchemaJsonObject(new 
DimensionFieldSpec("col", DataType.BIG_DECIMAL, true)));
+  }
+
+  private static void assertPrimitiveType(DataType dataType, String 
expectedAvroType) {
+    JsonNode type = typeOf(dataType);
+    assertEquals(type.get(0).asText(), "null");
+    assertEquals(type.get(1).asText(), expectedAvroType);
+  }
+
+  private static JsonNode typeOf(DataType dataType) {
+    ObjectNode jsonSchema = AvroSchemaUtil.toAvroSchemaJsonObject(new 
DimensionFieldSpec("col", dataType, true));
+    assertEquals(jsonSchema.get("name").asText(), "col");
+    return jsonSchema.get("type");
+  }
+}


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

Reply via email to