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

Jackie-Jiang 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 302783f42ca Fix ProtoBufRecordExtractor cache invalidation for schema 
evolution (#18840)
302783f42ca is described below

commit 302783f42ca608394100e1b0062654a76e265880
Author: Arunkumar Saravanan <[email protected]>
AuthorDate: Tue Jun 23 23:39:52 2026 +0530

    Fix ProtoBufRecordExtractor cache invalidation for schema evolution (#18840)
---
 .../protobuf/ProtoBufRecordExtractor.java          | 14 ++--
 .../ProtoBufRecordExtractorCachingTest.java        | 87 ++++++++++++++++++++++
 2 files changed, 95 insertions(+), 6 deletions(-)

diff --git 
a/pinot-plugins/pinot-input-format/pinot-protobuf/src/main/java/org/apache/pinot/plugin/inputformat/protobuf/ProtoBufRecordExtractor.java
 
b/pinot-plugins/pinot-input-format/pinot-protobuf/src/main/java/org/apache/pinot/plugin/inputformat/protobuf/ProtoBufRecordExtractor.java
index 2a4881ddca1..9c6d3c14322 100644
--- 
a/pinot-plugins/pinot-input-format/pinot-protobuf/src/main/java/org/apache/pinot/plugin/inputformat/protobuf/ProtoBufRecordExtractor.java
+++ 
b/pinot-plugins/pinot-input-format/pinot-protobuf/src/main/java/org/apache/pinot/plugin/inputformat/protobuf/ProtoBufRecordExtractor.java
@@ -50,9 +50,10 @@ import org.apache.pinot.spi.data.readers.GenericRow;
 public class ProtoBufRecordExtractor extends BaseRecordExtractor<Message> {
 
   // Cached field descriptors initialized lazily on first message extraction 
to avoid repeated lookups
-  // via findFieldByName. The cache is invalidated when the descriptor's full 
name changes (handles schema
-  // evolution within a single reader).
-  private String _descriptorFullName;
+  // via findFieldByName. The cache is invalidated by identity comparison on 
the Descriptor object:
+  // schema evolution (e.g. a new column added in the registry) produces a new 
Descriptor instance with
+  // the same full name but a different schema ID, so full-name equality is 
insufficient.
+  private Descriptors.Descriptor _cachedDescriptor;
   private Descriptors.FieldDescriptor[] _fieldDescriptors;
   private String[] _fieldNames;
 
@@ -87,14 +88,15 @@ public class ProtoBufRecordExtractor extends 
BaseRecordExtractor<Message> {
       _fieldDescriptors = i < numFields ? Arrays.copyOf(fieldDescriptors, i) : 
fieldDescriptors;
       _fieldNames = i < numFields ? Arrays.copyOf(fieldNames, i) : fieldNames;
     }
-    _descriptorFullName = descriptor.getFullName();
+    _cachedDescriptor = descriptor;
   }
 
   @Override
   public GenericRow extract(Message from, GenericRow to) {
     Descriptors.Descriptor descriptor = from.getDescriptorForType();
-    // Initialize or reinitialize cache if descriptor changed (handles schema 
evolution).
-    if (_descriptorFullName == null || 
!_descriptorFullName.equals(descriptor.getFullName())) {
+    // Identity check: a schema evolution in the registry produces a new 
Descriptor instance even when the
+    // full name is unchanged. Using != catches that case; full-name equality 
does not.
+    if (_cachedDescriptor != descriptor) {
       initFieldDescriptors(descriptor);
     }
     // The cache only contains live descriptors — fields requested but not in 
the source schema were filtered
diff --git 
a/pinot-plugins/pinot-input-format/pinot-protobuf/src/test/java/org/apache/pinot/plugin/inputformat/protobuf/ProtoBufRecordExtractorCachingTest.java
 
b/pinot-plugins/pinot-input-format/pinot-protobuf/src/test/java/org/apache/pinot/plugin/inputformat/protobuf/ProtoBufRecordExtractorCachingTest.java
index 3c7aa7a3309..102d62cad4a 100644
--- 
a/pinot-plugins/pinot-input-format/pinot-protobuf/src/test/java/org/apache/pinot/plugin/inputformat/protobuf/ProtoBufRecordExtractorCachingTest.java
+++ 
b/pinot-plugins/pinot-input-format/pinot-protobuf/src/test/java/org/apache/pinot/plugin/inputformat/protobuf/ProtoBufRecordExtractorCachingTest.java
@@ -18,6 +18,9 @@
  */
 package org.apache.pinot.plugin.inputformat.protobuf;
 
+import com.google.protobuf.DescriptorProtos;
+import com.google.protobuf.Descriptors;
+import com.google.protobuf.DynamicMessage;
 import com.google.protobuf.Message;
 import java.io.IOException;
 import java.util.Arrays;
@@ -317,6 +320,90 @@ public class ProtoBufRecordExtractorCachingTest {
     assertEquals(row1.getFieldToValueMap().size(), 
row2.getFieldToValueMap().size());
   }
 
+  // ==================== SCHEMA EVOLUTION (SAME FULL NAME, DIFFERENT 
DESCRIPTOR INSTANCE) ====================
+
+  /**
+   * Regression test for the bug introduced in PR #17593: when a Confluent 
Schema Registry schema evolves
+   * (e.g. a new column is added), KafkaProtobufDeserializer returns 
DynamicMessage instances built from a
+   * new Descriptor object even though the message's full name is unchanged. 
The old cache invalidation
+   * keyed on {@code descriptor.getFullName()} never detected this, so stale 
FieldDescriptors from the V1
+   * Descriptor were used against V2 DynamicMessages. Protobuf enforces strict 
identity between a
+   * FieldDescriptor and the Descriptor of the message it is applied to, so 
this threw
+   * {@code IllegalArgumentException: FieldDescriptor does not match message 
type}.
+   *
+   * <p>The fix uses object identity ({@code !=}) to detect descriptor 
changes, which correctly fires
+   * whenever the schema registry serves a new schema version.
+   */
+  @Test
+  public void testSchemaEvolutionSameFullNameDifferentDescriptorInstance()
+      throws Descriptors.DescriptorValidationException {
+    _extractor.init(null, null);
+
+    // Build V1 descriptor: Order { string id = 1; int32 amount = 2; }
+    Descriptors.Descriptor v1Descriptor = buildOrderDescriptor(false);
+    DynamicMessage v1Message = DynamicMessage.newBuilder(v1Descriptor)
+        .setField(v1Descriptor.findFieldByName("id"), "order-1")
+        .setField(v1Descriptor.findFieldByName("amount"), 100)
+        .build();
+
+    GenericRow row1 = _extractor.extract(v1Message, new GenericRow());
+    assertEquals(row1.getValue("id"), "order-1");
+    assertEquals(row1.getValue("amount"), 100);
+
+    // Simulate schema evolution: new column "status" added. Registry returns 
a fresh Descriptor
+    // object with the same full name "Order" but a different instance — same 
as what
+    // KafkaProtobufDeserializer does when it fetches a new schema ID.
+    Descriptors.Descriptor v2Descriptor = buildOrderDescriptor(true);
+    // Sanity: same full name, different object — this is the exact condition 
the old code missed.
+    assertEquals(v1Descriptor.getFullName(), v2Descriptor.getFullName());
+    assertNotSame(v1Descriptor, v2Descriptor);
+
+    DynamicMessage v2Message = DynamicMessage.newBuilder(v2Descriptor)
+        .setField(v2Descriptor.findFieldByName("id"), "order-2")
+        .setField(v2Descriptor.findFieldByName("amount"), 200)
+        .setField(v2Descriptor.findFieldByName("status"), "SHIPPED")
+        .build();
+
+    // Before the fix this threw: IllegalArgumentException: FieldDescriptor 
does not match message type
+    GenericRow row2 = _extractor.extract(v2Message, new GenericRow());
+    assertEquals(row2.getValue("id"), "order-2");
+    assertEquals(row2.getValue("amount"), 200);
+    assertEquals(row2.getValue("status"), "SHIPPED");
+  }
+
+  /**
+   * Builds a simple "Order" DynamicMessage descriptor with fields id (string) 
and amount (int32).
+   * When {@code withStatus} is true, a third field "status" (string) is 
added, simulating a schema
+   * evolution where a new column was appended to the registry schema.
+   *
+   * <p>Each invocation produces a distinct {@link Descriptors.Descriptor} 
instance even though the
+   * full name is the same — exactly what Confluent Schema Registry does 
across schema versions.
+   */
+  private static Descriptors.Descriptor buildOrderDescriptor(boolean 
withStatus)
+      throws Descriptors.DescriptorValidationException {
+    DescriptorProtos.DescriptorProto.Builder msgBuilder = 
DescriptorProtos.DescriptorProto.newBuilder()
+        .setName("Order")
+        .addField(DescriptorProtos.FieldDescriptorProto.newBuilder()
+            
.setName("id").setNumber(1).setLabel(DescriptorProtos.FieldDescriptorProto.Label.LABEL_OPTIONAL)
+            .setType(DescriptorProtos.FieldDescriptorProto.Type.TYPE_STRING))
+        .addField(DescriptorProtos.FieldDescriptorProto.newBuilder()
+            
.setName("amount").setNumber(2).setLabel(DescriptorProtos.FieldDescriptorProto.Label.LABEL_OPTIONAL)
+            .setType(DescriptorProtos.FieldDescriptorProto.Type.TYPE_INT32));
+    if (withStatus) {
+      msgBuilder.addField(DescriptorProtos.FieldDescriptorProto.newBuilder()
+          
.setName("status").setNumber(3).setLabel(DescriptorProtos.FieldDescriptorProto.Label.LABEL_OPTIONAL)
+          .setType(DescriptorProtos.FieldDescriptorProto.Type.TYPE_STRING));
+    }
+    DescriptorProtos.FileDescriptorProto fileProto = 
DescriptorProtos.FileDescriptorProto.newBuilder()
+        .setName(withStatus ? "order_v2.proto" : "order_v1.proto")
+        .setSyntax("proto3")
+        .addMessageType(msgBuilder)
+        .build();
+    Descriptors.FileDescriptor fileDescriptor =
+        Descriptors.FileDescriptor.buildFrom(fileProto, new 
Descriptors.FileDescriptor[0]);
+    return fileDescriptor.findMessageTypeByName("Order");
+  }
+
   // ==================== HELPER METHODS ====================
 
   private Message createTestMessage(String stringValue, int intValue)


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

Reply via email to