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

KKcorps 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 15b838a5df8 Fall back to last-known-good protobuf descriptor when the 
remote fetch fails (#19434)
15b838a5df8 is described below

commit 15b838a5df8d1cc34ab6302289defed31c7f44f9
Author: Arunkumar Saravanan <[email protected]>
AuthorDate: Thu Sep 17 11:25:32 2026 +0530

    Fall back to last-known-good protobuf descriptor when the remote fetch 
fails (#19434)
    
    A remote (e.g. S3) descriptor file is re-fetched on every decoder
    creation, i.e. on every CONSUMING transition on every replica, making the
    transition a hard dependency on live DNS + object store. A transient DNS
    blip at rollover time permanently marks consuming segments ERROR.
    
    The descriptor is still fetched fresh on every call (streamed via
    PinotFS.open, building on #19378), so in-place updates keep propagating,
    but the last remote content that both fetched and resolved successfully
    is now remembered per URI and served only when the fetch itself fails:
    
    - Content is promoted to the cache only after the configured message type
      resolves (ProtoBufUtils.getDescriptor), so an empty or unusable
      descriptor set can never displace a good copy; this also removes the
      decoder's second parse of the descriptor.
    - Only fetch (I/O) failures fall back; fetched-but-unresolvable content
      fails decoder init with a distinct error and leaves the cache intact.
    - Each fetch is stamped with its start time and published via merge, so a
      slow stale fetch cannot roll the cache back over a newer entry.
    - Cache entries weigh content + key + fixed overhead, bounding both
      memory and entry count.
    - Controllable via cluster config
      pinot.server.protobuf.descriptor.fallback.enabled (dynamically
      updatable, default true), overridden per table by the decoder prop
      descriptorFileFallbackEnabled.
    - getFileCopiedToLocal (codegen jar path) now removes its temp tree on
      copy failure.
---
 .../protobuf/ProtoBufMessageDecoder.java           |  39 ++-
 .../plugin/inputformat/protobuf/ProtoBufUtils.java | 135 ++++++++-
 .../protobuf/ProtoBufMessageDecoderTest.java       |  25 ++
 .../protobuf/ProtoBufUtilsDescriptorCacheTest.java | 312 +++++++++++++++++++++
 .../server/starter/helix/BaseServerStarter.java    |   5 +
 .../apache/pinot/spi/utils/CommonConstants.java    |   8 +
 .../utils/ProtoBufDescriptorFallbackListener.java  |  87 ++++++
 .../ProtoBufDescriptorFallbackListenerTest.java    |  75 +++++
 8 files changed, 660 insertions(+), 26 deletions(-)

diff --git 
a/pinot-plugins/pinot-input-format/pinot-protobuf/src/main/java/org/apache/pinot/plugin/inputformat/protobuf/ProtoBufMessageDecoder.java
 
b/pinot-plugins/pinot-input-format/pinot-protobuf/src/main/java/org/apache/pinot/plugin/inputformat/protobuf/ProtoBufMessageDecoder.java
index 5c79564f2ac..197c46006da 100644
--- 
a/pinot-plugins/pinot-input-format/pinot-protobuf/src/main/java/org/apache/pinot/plugin/inputformat/protobuf/ProtoBufMessageDecoder.java
+++ 
b/pinot-plugins/pinot-input-format/pinot-protobuf/src/main/java/org/apache/pinot/plugin/inputformat/protobuf/ProtoBufMessageDecoder.java
@@ -18,27 +18,30 @@
  */
 package org.apache.pinot.plugin.inputformat.protobuf;
 
-import com.github.os72.protobuf.dynamic.DynamicSchema;
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Preconditions;
 import com.google.protobuf.Descriptors;
 import com.google.protobuf.DynamicMessage;
 import com.google.protobuf.Message;
-import java.io.IOException;
-import java.io.InputStream;
 import java.util.Map;
 import java.util.Set;
-import org.apache.commons.lang3.StringUtils;
 import org.apache.pinot.spi.data.readers.GenericRow;
 import org.apache.pinot.spi.stream.StreamMessageDecoder;
+import org.apache.pinot.spi.utils.ProtoBufDescriptorFallbackListener;
 
 
 //TODO: Add support for Schema Registry
 public class ProtoBufMessageDecoder implements StreamMessageDecoder<byte[]> {
   public static final String DESCRIPTOR_FILE_PATH = "descriptorFile";
   public static final String PROTO_CLASS_NAME = "protoClassName";
+  /// When enabled, a remote descriptor that both fetched and resolved 
successfully before is served when the
+  /// remote fetch fails, so a transient DNS / object-store outage does not 
permanently fail the CONSUMING
+  /// transition. When this decoder prop is not set, the cluster config
+  /// 'pinot.server.protobuf.descriptor.fallback.enabled' applies (enabled by 
default, dynamically updatable);
+  /// setting the prop to 'true' or 'false' overrides the cluster-wide value 
for the table.
+  public static final String DESCRIPTOR_FILE_FALLBACK_ENABLED = 
"descriptorFileFallbackEnabled";
 
   private ProtoBufRecordExtractor _recordExtractor;
-  private String _protoClassName;
   private Message.Builder _builder;
 
   @Override
@@ -47,30 +50,20 @@ public class ProtoBufMessageDecoder implements 
StreamMessageDecoder<byte[]> {
     Preconditions.checkState(props.containsKey(DESCRIPTOR_FILE_PATH),
         "Protocol Buffer schema descriptor file must be provided");
 
-    _protoClassName = props.getOrDefault(PROTO_CLASS_NAME, "");
-    Descriptors.Descriptor descriptor;
-    try (InputStream fin = 
ProtoBufUtils.openDescriptorFile(props.get(DESCRIPTOR_FILE_PATH))) {
-      descriptor = buildProtoBufDescriptor(fin);
-    }
+    Descriptors.Descriptor descriptor = 
ProtoBufUtils.getDescriptor(props.get(DESCRIPTOR_FILE_PATH),
+        props.getOrDefault(PROTO_CLASS_NAME, ""), 
isDescriptorFallbackEnabled(props));
     _recordExtractor = new ProtoBufRecordExtractor();
     _recordExtractor.init(fieldsToRead, null);
     DynamicMessage dynamicMessage = 
DynamicMessage.getDefaultInstance(descriptor);
     _builder = dynamicMessage.newBuilderForType();
   }
 
-  private Descriptors.Descriptor buildProtoBufDescriptor(InputStream fin)
-      throws IOException {
-    try {
-      DynamicSchema dynamicSchema = DynamicSchema.parseFrom(fin);
-
-      if (!StringUtils.isEmpty(_protoClassName)) {
-        return dynamicSchema.getMessageDescriptor(_protoClassName);
-      } else {
-        return 
dynamicSchema.getMessageDescriptor(dynamicSchema.getMessageTypes().toArray(new 
String[]{})[0]);
-      }
-    } catch (Descriptors.DescriptorValidationException e) {
-      throw new IOException("Descriptor file validation failed", e);
-    }
+  /// The table-level decoder prop, when set, overrides the dynamically 
updatable cluster-wide setting.
+  @VisibleForTesting
+  static boolean isDescriptorFallbackEnabled(Map<String, String> props) {
+    String tableOverride = props.get(DESCRIPTOR_FILE_FALLBACK_ENABLED);
+    return tableOverride != null ? Boolean.parseBoolean(tableOverride)
+        : ProtoBufDescriptorFallbackListener.getInstance().isEnabled();
   }
 
   @Override
diff --git 
a/pinot-plugins/pinot-input-format/pinot-protobuf/src/main/java/org/apache/pinot/plugin/inputformat/protobuf/ProtoBufUtils.java
 
b/pinot-plugins/pinot-input-format/pinot-protobuf/src/main/java/org/apache/pinot/plugin/inputformat/protobuf/ProtoBufUtils.java
index 9ec6110054c..a91b397051c 100644
--- 
a/pinot-plugins/pinot-input-format/pinot-protobuf/src/main/java/org/apache/pinot/plugin/inputformat/protobuf/ProtoBufUtils.java
+++ 
b/pinot-plugins/pinot-input-format/pinot-protobuf/src/main/java/org/apache/pinot/plugin/inputformat/protobuf/ProtoBufUtils.java
@@ -18,6 +18,11 @@
  */
 package org.apache.pinot.plugin.inputformat.protobuf;
 
+import com.github.os72.protobuf.dynamic.DynamicSchema;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Preconditions;
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheBuilder;
 import com.google.protobuf.Descriptors;
 import com.google.protobuf.ProtobufInternalUtils;
 import java.io.File;
@@ -25,6 +30,11 @@ import java.io.InputStream;
 import java.net.URI;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.Comparator;
+import java.util.Set;
+import java.util.stream.Stream;
+import javax.annotation.Nullable;
+import org.apache.commons.lang3.StringUtils;
 import org.apache.pinot.spi.filesystem.PinotFS;
 import org.apache.pinot.spi.filesystem.PinotFSFactory;
 import org.slf4j.Logger;
@@ -35,12 +45,41 @@ public class ProtoBufUtils {
   public static final String TMP_DIR_PREFIX = "pinot-protobuf";
   public static final String PB_OUTER_CLASS_SUFFIX = "OuterClass";
 
+  // Last content of each remote (S3, GCS, ...) descriptor file that both 
fetched and resolved successfully, keyed
+  // by URI. The descriptor is still fetched fresh on every decoder creation, 
so in-place updates of the file keep
+  // propagating exactly as before; this copy is served only when the fetch 
itself fails (e.g. a transient DNS or
+  // object-store outage), so a CONSUMING transition cannot go to ERROR on a 
network blip once the descriptor has
+  // been fetched once by this JVM. The weight charges content, key and a 
fixed per-entry overhead so both total
+  // memory and entry count stay bounded.
+  private static final long FALLBACK_CACHE_MAX_WEIGHT_BYTES = 64L << 20;
+  private static final int FALLBACK_CACHE_ENTRY_OVERHEAD_BYTES = 1024;
+  private static final Cache<String, CachedDescriptor> 
LAST_KNOWN_GOOD_DESCRIPTORS = CacheBuilder.newBuilder()
+      .maximumWeight(FALLBACK_CACHE_MAX_WEIGHT_BYTES)
+      .weigher((String key, CachedDescriptor value) ->
+          value._content.length + 2 * key.length() + 
FALLBACK_CACHE_ENTRY_OVERHEAD_BYTES)
+      .build();
+
+  /// Descriptor content stamped with the time its fetch started. Publication 
keeps the entry whose fetch started
+  /// last, so a slow stale fetch that completes after a newer one can never 
roll the cache backward.
+  private static class CachedDescriptor {
+    final byte[] _content;
+    final long _fetchStartNanos;
+
+    CachedDescriptor(byte[] content, long fetchStartNanos) {
+      _content = content;
+      _fetchStartNanos = fetchStartNanos;
+    }
+  }
+
   private ProtoBufUtils() {
   }
 
   /// Copies a remote file to a local temp directory and returns the local 
copy. Used by the codegen
   /// decoder path for JAR files that must remain on disk for the lifetime of 
the classloader.
-  /// The caller is responsible for managing the lifetime of the returned file 
and its parent directory.
+  /// On success the caller is responsible for managing the lifetime of the 
returned file and its parent directory;
+  /// on copy failure the temp directory is removed (the copy may have left 
partial content or filesystem sidecars
+  /// such as Hadoop `.crc` files). This is a plain download with no outage 
fallback (a jar cannot be validated as
+  /// a descriptor set); descriptors should be resolved through 
[#getDescriptor(String, String, boolean)] instead.
   public static File getFileCopiedToLocal(String filePath)
       throws Exception {
     URI fileURI = URI.create(filePath);
@@ -54,8 +93,13 @@ public class ProtoBufUtils {
       File localFile = createLocalFile(fileURI, localTmpDir.toFile());
       LOGGER.info("Copying protocol buffer jar/descriptor file from source: {} 
to dst: {}", filePath,
           localFile.getAbsolutePath());
-      pinotFS.copyToLocalFile(fileURI, localFile);
-      return localFile;
+      try {
+        pinotFS.copyToLocalFile(fileURI, localFile);
+        return localFile;
+      } catch (Exception e) {
+        deleteRecursivelyQuietly(localTmpDir);
+        throw e;
+      }
     } else {
       throw new RuntimeException(String.format("Scheme: %s not supported in 
PinotFSFactory"
           + " for protocol buffer jar/descriptor file: %s.", scheme, 
filePath));
@@ -86,6 +130,91 @@ public class ProtoBufUtils {
     return pinotFS.open(fileURI);
   }
 
+  /// Resolves a message [Descriptors.Descriptor] from the descriptor set at 
the given path: the message type with
+  /// the given name, or the first message type in the set when the name is 
null or empty.
+  ///
+  /// The descriptor set is read fresh on every call via 
[#openDescriptorFile(String)] — no temp files — so
+  /// in-place updates of the file keep propagating. When 
`fallbackToLastKnownGood` is set, the last remote content
+  /// that both fetched and resolved successfully is remembered per URI and 
served ONLY when the fetch itself fails
+  /// (e.g. a transient DNS or object-store outage), so decoder creation 
survives outages instead of permanently
+  /// marking the CONSUMING segment ERROR. Content that fetches successfully 
but does not resolve (corrupt, empty,
+  /// or missing the requested message type) always fails the call and leaves 
the remembered copy untouched: a bad
+  /// descriptor deployment must surface as an error, never silently serve an 
obsolete schema. Local files are
+  /// read fresh and never remembered.
+  ///
+  /// NOTE: Only this method has the fallback. The jar used by 
[ProtoBufCodeGenMessageDecoder] is downloaded via
+  /// [#getFileCopiedToLocal(String)] without one (see the note there).
+  public static Descriptors.Descriptor getDescriptor(String 
descriptorFilePath, @Nullable String messageTypeName,
+      boolean fallbackToLastKnownGood)
+      throws Exception {
+    String scheme = URI.create(descriptorFilePath).getScheme();
+    boolean remote = scheme != null && 
!scheme.equals(PinotFSFactory.LOCAL_PINOT_FS_SCHEME);
+    boolean fallbackEnabled = remote && fallbackToLastKnownGood;
+    // The stamp is taken before the fetch so that publication can reject a 
slow stale fetch that completes after
+    // a newer one (see CachedDescriptor)
+    long fetchStartNanos = System.nanoTime();
+    byte[] content;
+    try (InputStream inputStream = openDescriptorFile(descriptorFilePath)) {
+      content = inputStream.readAllBytes();
+    } catch (Exception fetchException) {
+      CachedDescriptor lastKnownGood =
+          fallbackEnabled ? 
LAST_KNOWN_GOOD_DESCRIPTORS.getIfPresent(descriptorFilePath) : null;
+      if (lastKnownGood == null) {
+        throw fetchException;
+      }
+      LOGGER.warn("Failed to fetch protocol buffer descriptor file: {}, 
falling back to the last known good copy",
+          descriptorFilePath, fetchException);
+      return resolveMessageDescriptor(lastKnownGood._content, messageTypeName, 
descriptorFilePath);
+    }
+    // A fetched-but-unresolvable descriptor fails here, before publication, 
so it can neither be served nor
+    // overwrite the last known good copy
+    Descriptors.Descriptor descriptor = resolveMessageDescriptor(content, 
messageTypeName, descriptorFilePath);
+    if (fallbackEnabled) {
+      LAST_KNOWN_GOOD_DESCRIPTORS.asMap().merge(descriptorFilePath,
+          new CachedDescriptor(content, fetchStartNanos),
+          (existing, candidate) -> candidate._fetchStartNanos - 
existing._fetchStartNanos > 0 ? candidate
+              : existing);
+    }
+    return descriptor;
+  }
+
+  /// Parses the descriptor set and resolves the requested message type (or 
the first one when no name is given).
+  /// Failures here mean the content is unusable — deliberately distinct from 
a fetch failure.
+  private static Descriptors.Descriptor resolveMessageDescriptor(byte[] 
descriptorSetBytes,
+      @Nullable String messageTypeName, String descriptorFilePath)
+      throws Exception {
+    DynamicSchema schema;
+    try {
+      schema = DynamicSchema.parseFrom(descriptorSetBytes);
+    } catch (Exception e) {
+      throw new IllegalStateException("Invalid protocol buffer descriptor set 
at: " + descriptorFilePath, e);
+    }
+    String typeName = messageTypeName;
+    if (StringUtils.isEmpty(typeName)) {
+      Set<String> messageTypes = schema.getMessageTypes();
+      Preconditions.checkState(!messageTypes.isEmpty(), "Descriptor set at: %s 
contains no message types",
+          descriptorFilePath);
+      typeName = messageTypes.iterator().next();
+    }
+    Descriptors.Descriptor descriptor = schema.getMessageDescriptor(typeName);
+    Preconditions.checkState(descriptor != null, "Message type: %s not found 
in descriptor set at: %s", typeName,
+        descriptorFilePath);
+    return descriptor;
+  }
+
+  private static void deleteRecursivelyQuietly(Path dir) {
+    try (Stream<Path> paths = Files.walk(dir)) {
+      paths.sorted(Comparator.reverseOrder()).forEach(path -> 
path.toFile().delete());
+    } catch (Exception e) {
+      LOGGER.warn("Failed to clean up temporary directory: {}", dir, e);
+    }
+  }
+
+  @VisibleForTesting
+  static void clearDescriptorCache() {
+    LAST_KNOWN_GOOD_DESCRIPTORS.invalidateAll();
+  }
+
   public static String getFullJavaName(Descriptors.Descriptor descriptor) {
     String prefix;
     if (null != descriptor.getContainingType()) {
diff --git 
a/pinot-plugins/pinot-input-format/pinot-protobuf/src/test/java/org/apache/pinot/plugin/inputformat/protobuf/ProtoBufMessageDecoderTest.java
 
b/pinot-plugins/pinot-input-format/pinot-protobuf/src/test/java/org/apache/pinot/plugin/inputformat/protobuf/ProtoBufMessageDecoderTest.java
index 20511192689..a8f93b98be9 100644
--- 
a/pinot-plugins/pinot-input-format/pinot-protobuf/src/test/java/org/apache/pinot/plugin/inputformat/protobuf/ProtoBufMessageDecoderTest.java
+++ 
b/pinot-plugins/pinot-input-format/pinot-protobuf/src/test/java/org/apache/pinot/plugin/inputformat/protobuf/ProtoBufMessageDecoderTest.java
@@ -25,15 +25,40 @@ import java.util.HashMap;
 import java.util.Map;
 import java.util.Set;
 import org.apache.pinot.spi.data.readers.GenericRow;
+import org.apache.pinot.spi.utils.ProtoBufDescriptorFallbackListener;
 import org.testng.annotations.Test;
 
 import static 
org.apache.pinot.plugin.inputformat.protobuf.ProtoBufTestDataGenerator.*;
 import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
 import static org.testng.Assert.assertNotNull;
+import static org.testng.Assert.assertTrue;
 
 
 public class ProtoBufMessageDecoderTest {
 
+  @Test
+  public void testDescriptorFallbackEnabledPrecedence() {
+    ProtoBufDescriptorFallbackListener listener = 
ProtoBufDescriptorFallbackListener.getInstance();
+    try {
+      Map<String, String> props = new HashMap<>();
+      // Without a table-level prop, the (dynamically updatable) cluster-wide 
value applies
+      listener.setEnabled(true);
+      assertTrue(ProtoBufMessageDecoder.isDescriptorFallbackEnabled(props));
+      listener.setEnabled(false);
+      assertFalse(ProtoBufMessageDecoder.isDescriptorFallbackEnabled(props));
+
+      // An explicit table-level prop overrides the cluster-wide value in both 
directions
+      props.put(ProtoBufMessageDecoder.DESCRIPTOR_FILE_FALLBACK_ENABLED, 
"true");
+      assertTrue(ProtoBufMessageDecoder.isDescriptorFallbackEnabled(props));
+      listener.setEnabled(true);
+      props.put(ProtoBufMessageDecoder.DESCRIPTOR_FILE_FALLBACK_ENABLED, 
"false");
+      assertFalse(ProtoBufMessageDecoder.isDescriptorFallbackEnabled(props));
+    } finally {
+      listener.reset();
+    }
+  }
+
   @Test
   public void testHappyCase()
       throws Exception {
diff --git 
a/pinot-plugins/pinot-input-format/pinot-protobuf/src/test/java/org/apache/pinot/plugin/inputformat/protobuf/ProtoBufUtilsDescriptorCacheTest.java
 
b/pinot-plugins/pinot-input-format/pinot-protobuf/src/test/java/org/apache/pinot/plugin/inputformat/protobuf/ProtoBufUtilsDescriptorCacheTest.java
new file mode 100644
index 00000000000..4ad5e91fcdb
--- /dev/null
+++ 
b/pinot-plugins/pinot-input-format/pinot-protobuf/src/test/java/org/apache/pinot/plugin/inputformat/protobuf/ProtoBufUtilsDescriptorCacheTest.java
@@ -0,0 +1,312 @@
+/**
+ * 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.protobuf;
+
+import com.github.os72.protobuf.dynamic.DynamicSchema;
+import java.io.File;
+import java.io.FilterInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.nio.file.Files;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import org.apache.pinot.spi.filesystem.LocalPinotFS;
+import org.apache.pinot.spi.filesystem.PinotFSFactory;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertNull;
+import static org.testng.Assert.assertThrows;
+import static org.testng.Assert.assertTrue;
+import static org.testng.Assert.expectThrows;
+
+
+/// Tests the last-known-good fallback in [ProtoBufUtils#getDescriptor(String, 
String, boolean)]: the descriptor is
+/// fetched fresh on every call (so in-place updates propagate) and the last 
content that both fetched and resolved
+/// successfully is served only when the fetch itself fails. 
Fetched-but-unresolvable content (corrupt, empty, or
+/// missing the requested type) must fail the call without touching the 
remembered copy, and a slow stale fetch must
+/// never roll the remembered copy backward.
+public class ProtoBufUtilsDescriptorCacheTest {
+  private static final String COUNTING_SCHEME = "counting";
+
+  private File _descriptorFile;
+  private byte[] _sampleContent;
+  private String _sampleTypeName;
+  private byte[] _complexContent;
+  private String _complexTypeName;
+
+  /// A "remote" filesystem that serves local files, counts reads, and can 
simulate network failures, stalled
+  /// streams, and copies that leave partial content plus checksum sidecars 
behind (like Hadoop filesystems do).
+  public static class CountingPinotFS extends LocalPinotFS {
+    static final AtomicInteger OPEN_CALLS = new AtomicInteger();
+    static volatile boolean _failReads = false;
+    static volatile CountDownLatch _firstOpenStallLatch = null;
+    static final CountDownLatch[] FIRST_OPEN_REACHED_EOF = new 
CountDownLatch[1];
+    static final AtomicReference<File> LAST_COPY_DST = new AtomicReference<>();
+
+    @Override
+    public InputStream open(URI uri)
+        throws IOException {
+      int call = OPEN_CALLS.incrementAndGet();
+      if (_failReads) {
+        throw new IOException("Simulated network failure: Temporary failure in 
name resolution");
+      }
+      InputStream delegate = super.open(uri);
+      CountDownLatch stallLatch = _firstOpenStallLatch;
+      if (stallLatch != null && call == 1) {
+        return stallAtEof(delegate, stallLatch);
+      }
+      return delegate;
+    }
+
+    @Override
+    public void copyToLocalFile(URI srcUri, File dstFile)
+        throws Exception {
+      LAST_COPY_DST.set(dstFile);
+      if (_failReads) {
+        // Simulate a Hadoop-style filesystem failing mid-copy after writing 
partial content and a checksum
+        // sidecar into the destination directory
+        Files.write(dstFile.toPath(), new byte[]{1, 2, 3});
+        Files.write(new File(dstFile.getParentFile(), "." + dstFile.getName() 
+ ".crc").toPath(), new byte[]{4});
+        throw new IOException("Simulated network failure: Temporary failure in 
name resolution");
+      }
+      super.copyToLocalFile(srcUri, dstFile);
+    }
+
+    /// Wraps the stream so that the first reader announces reaching EOF and 
then blocks until released — used to
+    /// hold a stale fetch open while a newer fetch completes.
+    private static InputStream stallAtEof(InputStream delegate, CountDownLatch 
stallLatch) {
+      return new FilterInputStream(delegate) {
+        @Override
+        public int read()
+            throws IOException {
+          int result = super.read();
+          if (result == -1) {
+            awaitRelease();
+          }
+          return result;
+        }
+
+        @Override
+        public int read(byte[] buffer, int offset, int length)
+            throws IOException {
+          int result = super.read(buffer, offset, length);
+          if (result == -1) {
+            awaitRelease();
+          }
+          return result;
+        }
+
+        private void awaitRelease()
+            throws IOException {
+          FIRST_OPEN_REACHED_EOF[0].countDown();
+          try {
+            stallLatch.await();
+          } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new IOException(e);
+          }
+        }
+      };
+    }
+  }
+
+  @BeforeClass
+  public void setUp()
+      throws Exception {
+    PinotFSFactory.register(COUNTING_SCHEME, CountingPinotFS.class.getName(), 
null);
+    _descriptorFile = File.createTempFile("proto-descriptor-cache-test", 
".desc");
+    try (InputStream in = 
getClass().getClassLoader().getResourceAsStream("sample.desc")) {
+      _sampleContent = in.readAllBytes();
+    }
+    try (InputStream in = 
getClass().getClassLoader().getResourceAsStream("complex_types.desc")) {
+      _complexContent = in.readAllBytes();
+    }
+    _sampleTypeName = firstMessageType(_sampleContent);
+    _complexTypeName = firstMessageType(_complexContent);
+  }
+
+  @AfterClass
+  public void tearDown() {
+    _descriptorFile.delete();
+  }
+
+  @BeforeMethod
+  public void reset()
+      throws Exception {
+    ProtoBufUtils.clearDescriptorCache();
+    CountingPinotFS.OPEN_CALLS.set(0);
+    CountingPinotFS._failReads = false;
+    CountingPinotFS._firstOpenStallLatch = null;
+    CountingPinotFS.FIRST_OPEN_REACHED_EOF[0] = new CountDownLatch(1);
+    CountingPinotFS.LAST_COPY_DST.set(null);
+    Files.write(_descriptorFile.toPath(), _sampleContent);
+  }
+
+  private String remotePath() {
+    return COUNTING_SCHEME + "://" + _descriptorFile.getAbsolutePath();
+  }
+
+  private static String firstMessageType(byte[] descriptorSetBytes)
+      throws Exception {
+    return 
DynamicSchema.parseFrom(descriptorSetBytes).getMessageTypes().iterator().next();
+  }
+
+  @Test
+  public void testFetchesFreshOnEveryCallAndNeverCopiesToLocal()
+      throws Exception {
+    assertEquals(ProtoBufUtils.getDescriptor(remotePath(), null, 
true).getFullName(), _sampleTypeName);
+    assertEquals(CountingPinotFS.OPEN_CALLS.get(), 1);
+
+    // An in-place update of the remote file must be picked up by the next 
fetch
+    Files.write(_descriptorFile.toPath(), _complexContent);
+    assertEquals(ProtoBufUtils.getDescriptor(remotePath(), null, 
true).getFullName(), _complexTypeName);
+    assertEquals(CountingPinotFS.OPEN_CALLS.get(), 2);
+
+    // The fetch path streams the content and never goes through the 
local-copy (temp file) lifecycle
+    assertNull(CountingPinotFS.LAST_COPY_DST.get());
+  }
+
+  @Test
+  public void testFallsBackToLastKnownGoodOnFetchFailure()
+      throws Exception {
+    assertEquals(ProtoBufUtils.getDescriptor(remotePath(), null, 
true).getFullName(), _sampleTypeName);
+
+    CountingPinotFS._failReads = true;
+    assertEquals(ProtoBufUtils.getDescriptor(remotePath(), null, 
true).getFullName(), _sampleTypeName);
+    // The fetch was attempted (and failed) before falling back
+    assertEquals(CountingPinotFS.OPEN_CALLS.get(), 2);
+
+    // Recovery: once the network is back, fresh content is fetched and 
remembered again
+    CountingPinotFS._failReads = false;
+    Files.write(_descriptorFile.toPath(), _complexContent);
+    assertEquals(ProtoBufUtils.getDescriptor(remotePath(), null, 
true).getFullName(), _complexTypeName);
+  }
+
+  @Test
+  public void testFetchFailureWithoutFallbackCopyPropagates() {
+    CountingPinotFS._failReads = true;
+    assertThrows(IOException.class, () -> 
ProtoBufUtils.getDescriptor(remotePath(), null, true));
+  }
+
+  @Test
+  public void testFallbackDisabledFailsFastEvenWhenWarm()
+      throws Exception {
+    assertEquals(ProtoBufUtils.getDescriptor(remotePath(), null, 
true).getFullName(), _sampleTypeName);
+    CountingPinotFS._failReads = true;
+    assertThrows(IOException.class, () -> 
ProtoBufUtils.getDescriptor(remotePath(), null, false));
+  }
+
+  @Test
+  public void testUnresolvableFetchedContentFailsAndPreservesFallbackCopy()
+      throws Exception {
+    assertEquals(ProtoBufUtils.getDescriptor(remotePath(), null, 
true).getFullName(), _sampleTypeName);
+
+    // An empty descriptor set parses but resolves no message type: it must 
fail the call, not serve the fallback
+    Files.write(_descriptorFile.toPath(), new byte[0]);
+    IllegalStateException emptyFailure =
+        expectThrows(IllegalStateException.class, () -> 
ProtoBufUtils.getDescriptor(remotePath(), null, true));
+    assertTrue(emptyFailure.getMessage().contains("no message types"), 
emptyFailure.getMessage());
+
+    // Unparseable bytes (0xFF = invalid wire type) must fail the same way
+    Files.write(_descriptorFile.toPath(), new byte[]{(byte) 0xFF, (byte) 0xFF, 
(byte) 0xFF, (byte) 0xFF});
+    IllegalStateException corruptFailure =
+        expectThrows(IllegalStateException.class, () -> 
ProtoBufUtils.getDescriptor(remotePath(), null, true));
+    assertTrue(corruptFailure.getMessage().contains("Invalid protocol buffer 
descriptor set"),
+        corruptFailure.getMessage());
+
+    // A valid set missing the requested message type must also fail without 
promotion
+    Files.write(_descriptorFile.toPath(), _complexContent);
+    assertThrows(IllegalStateException.class,
+        () -> ProtoBufUtils.getDescriptor(remotePath(), "does.not.Exist", 
true));
+
+    // None of the failures above may have replaced the last known good copy
+    CountingPinotFS._failReads = true;
+    assertEquals(ProtoBufUtils.getDescriptor(remotePath(), null, 
true).getFullName(), _sampleTypeName);
+  }
+
+  @Test(timeOut = 60_000)
+  public void testStaleConcurrentFetchCannotRollFallbackCopyBackward()
+      throws Exception {
+    // Thread A fetches the OLD content and stalls at end-of-stream, before 
publication
+    CountDownLatch stallLatch = new CountDownLatch(1);
+    CountingPinotFS._firstOpenStallLatch = stallLatch;
+    AtomicReference<Throwable> staleFetchFailure = new AtomicReference<>();
+    Thread staleFetch = new Thread(() -> {
+      try {
+        ProtoBufUtils.getDescriptor(remotePath(), null, true);
+      } catch (Throwable t) {
+        staleFetchFailure.set(t);
+      }
+    });
+    staleFetch.start();
+    CountingPinotFS.FIRST_OPEN_REACHED_EOF[0].await();
+
+    // While A is stalled, the remote file is replaced and a newer fetch 
completes and publishes
+    Files.write(_descriptorFile.toPath(), _complexContent);
+    assertEquals(ProtoBufUtils.getDescriptor(remotePath(), null, 
true).getFullName(), _complexTypeName);
+
+    // A resumes and attempts to publish the older content; publication must 
reject it
+    stallLatch.countDown();
+    staleFetch.join();
+    assertNull(staleFetchFailure.get());
+
+    CountingPinotFS._failReads = true;
+    assertEquals(ProtoBufUtils.getDescriptor(remotePath(), null, 
true).getFullName(), _complexTypeName);
+  }
+
+  @Test
+  public void testLocalDescriptorReadFreshWithoutFallback()
+      throws Exception {
+    File localFile = File.createTempFile("proto-descriptor-cache-test-local", 
".desc");
+    try {
+      Files.write(localFile.toPath(), _sampleContent);
+      assertEquals(ProtoBufUtils.getDescriptor(localFile.getAbsolutePath(), 
null, true).getFullName(),
+          _sampleTypeName);
+
+      // A local file edited in place must be re-read
+      Files.write(localFile.toPath(), _complexContent);
+      assertEquals(ProtoBufUtils.getDescriptor(localFile.getAbsolutePath(), 
null, true).getFullName(),
+          _complexTypeName);
+
+      // Local files are never remembered: once the file is gone the call 
fails even though it succeeded before
+      assertTrue(localFile.delete());
+      assertThrows(Exception.class, () -> 
ProtoBufUtils.getDescriptor(localFile.getAbsolutePath(), null, true));
+    } finally {
+      localFile.delete();
+    }
+  }
+
+  @Test
+  public void testGetFileCopiedToLocalCleansUpTempDirOnCopyFailure() {
+    CountingPinotFS._failReads = true;
+    assertThrows(IOException.class, () -> 
ProtoBufUtils.getFileCopiedToLocal(remotePath()));
+
+    // The temp directory must be fully removed even though the failed copy 
left partial content and a checksum
+    // sidecar behind
+    File copyDst = CountingPinotFS.LAST_COPY_DST.get();
+    assertFalse(copyDst.getParentFile().exists(), "Leaked temp directory: " + 
copyDst.getParentFile());
+  }
+}
diff --git 
a/pinot-server/src/main/java/org/apache/pinot/server/starter/helix/BaseServerStarter.java
 
b/pinot-server/src/main/java/org/apache/pinot/server/starter/helix/BaseServerStarter.java
index d43a54b0c64..a2d64283a9e 100644
--- 
a/pinot-server/src/main/java/org/apache/pinot/server/starter/helix/BaseServerStarter.java
+++ 
b/pinot-server/src/main/java/org/apache/pinot/server/starter/helix/BaseServerStarter.java
@@ -140,6 +140,7 @@ import 
org.apache.pinot.spi.utils.ConsumingSegmentConsistencyModeListener;
 import org.apache.pinot.spi.utils.InstanceTypeUtils;
 import org.apache.pinot.spi.utils.NetUtils;
 import org.apache.pinot.spi.utils.PinotMd5Mode;
+import org.apache.pinot.spi.utils.ProtoBufDescriptorFallbackListener;
 import org.apache.pinot.spi.utils.builder.TableNameBuilder;
 import org.apache.pinot.sql.parsers.rewriter.QueryRewriterFactory;
 import org.slf4j.Logger;
@@ -284,6 +285,10 @@ public abstract class BaseServerStarter implements 
ServiceStartable {
         ConsumingSegmentConsistencyModeListener.getInstance());
     LOGGER.info(
         "Registered ConsumingSegmentConsistencyModeListener change listener 
for dynamic force commit/reload control");
+    // Register configuration change listener for the protobuf descriptor 
fallback setting
+    
_clusterConfigChangeHandler.registerClusterConfigChangeListener(ProtoBufDescriptorFallbackListener.getInstance());
+    LOGGER.info("Registered ProtoBufDescriptorFallbackListener change listener 
for dynamic descriptor fallback"
+        + " control");
 
     LOGGER.info("Initializing Helix manager with zkAddress: {}, clusterName: 
{}, instanceId: {}", _zkAddress,
         _helixClusterName, _instanceId);
diff --git 
a/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java 
b/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java
index e0de1210a65..a7314eee62e 100644
--- a/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java
+++ b/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java
@@ -3004,5 +3004,13 @@ public class CommonConstants {
     /// - PROTECTED: Force commit is enabled with metadata reversion on 
inconsistencies
     /// - UNSAFE: Force commit is enabled without metadata reversion (Can lead 
to inconsistencies)
     public static final String CONSUMING_SEGMENT_CONSISTENCY_MODE = 
"pinot.server.consuming.segment.consistency.mode";
+
+    /// Cluster config key to control whether the protobuf decoder falls back 
to the last successfully fetched
+    /// (and resolved) descriptor when the remote descriptor fetch fails, so a 
transient DNS / object-store outage
+    /// does not permanently fail the CONSUMING transition. Enabled by 
default; set to 'false' to fail fast
+    /// instead. Dynamically updatable without a server restart; a table-level 
decoder prop
+    /// ('descriptorFileFallbackEnabled') overrides this cluster-wide value.
+    public static final String PROTOBUF_DESCRIPTOR_FALLBACK_ENABLED =
+        "pinot.server.protobuf.descriptor.fallback.enabled";
   }
 }
diff --git 
a/pinot-spi/src/main/java/org/apache/pinot/spi/utils/ProtoBufDescriptorFallbackListener.java
 
b/pinot-spi/src/main/java/org/apache/pinot/spi/utils/ProtoBufDescriptorFallbackListener.java
new file mode 100644
index 00000000000..46b2859328a
--- /dev/null
+++ 
b/pinot-spi/src/main/java/org/apache/pinot/spi/utils/ProtoBufDescriptorFallbackListener.java
@@ -0,0 +1,87 @@
+/**
+ * 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.spi.utils;
+
+import com.google.common.annotations.VisibleForTesting;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.apache.pinot.spi.config.provider.PinotClusterConfigChangeListener;
+import 
org.apache.pinot.spi.utils.CommonConstants.ConfigChangeListenerConstants;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+
+/// Singleton holding the cluster-wide default for the protobuf decoder's 
descriptor fallback: whether the last
+/// successfully fetched (and resolved) descriptor is served when the remote 
descriptor fetch fails, so a transient
+/// DNS / object-store outage does not permanently fail the CONSUMING 
transition. Enabled by default.
+///
+/// Dynamically updatable via the ZK cluster config
+/// [ConfigChangeListenerConstants#PROTOBUF_DESCRIPTOR_FALLBACK_ENABLED] 
without a server restart; the value is
+/// consulted each time a decoder is created. A table-level decoder prop 
('descriptorFileFallbackEnabled')
+/// overrides this cluster-wide value. Thread-safe.
+public class ProtoBufDescriptorFallbackListener implements 
PinotClusterConfigChangeListener {
+  private static final Logger LOGGER = 
LoggerFactory.getLogger(ProtoBufDescriptorFallbackListener.class);
+  private static final boolean DEFAULT_ENABLED = true;
+  private static final ProtoBufDescriptorFallbackListener INSTANCE = new 
ProtoBufDescriptorFallbackListener();
+
+  private final AtomicBoolean _enabled = new AtomicBoolean(DEFAULT_ENABLED);
+
+  private ProtoBufDescriptorFallbackListener() {
+  }
+
+  public static ProtoBufDescriptorFallbackListener getInstance() {
+    return INSTANCE;
+  }
+
+  public boolean isEnabled() {
+    return _enabled.get();
+  }
+
+  @Override
+  public void onChange(Set<String> changedConfigs, Map<String, String> 
clusterConfigs) {
+    if 
(!changedConfigs.contains(ConfigChangeListenerConstants.PROTOBUF_DESCRIPTOR_FALLBACK_ENABLED))
 {
+      return;
+    }
+    boolean newEnabled =
+        
fromString(clusterConfigs.get(ConfigChangeListenerConstants.PROTOBUF_DESCRIPTOR_FALLBACK_ENABLED));
+    boolean previousEnabled = _enabled.getAndSet(newEnabled);
+    if (previousEnabled != newEnabled) {
+      LOGGER.info("Updated cluster config: {} from {} to {}",
+          ConfigChangeListenerConstants.PROTOBUF_DESCRIPTOR_FALLBACK_ENABLED, 
previousEnabled, newEnabled);
+    }
+  }
+
+  private static boolean fromString(String value) {
+    if (value == null || value.trim().isEmpty()) {
+      return DEFAULT_ENABLED;
+    }
+    return Boolean.parseBoolean(value.trim());
+  }
+
+  @VisibleForTesting
+  public void reset() {
+    _enabled.set(DEFAULT_ENABLED);
+  }
+
+  @VisibleForTesting
+  public void setEnabled(boolean enabled) {
+    _enabled.set(enabled);
+  }
+}
diff --git 
a/pinot-spi/src/test/java/org/apache/pinot/spi/utils/ProtoBufDescriptorFallbackListenerTest.java
 
b/pinot-spi/src/test/java/org/apache/pinot/spi/utils/ProtoBufDescriptorFallbackListenerTest.java
new file mode 100644
index 00000000000..92c784bc90d
--- /dev/null
+++ 
b/pinot-spi/src/test/java/org/apache/pinot/spi/utils/ProtoBufDescriptorFallbackListenerTest.java
@@ -0,0 +1,75 @@
+/**
+ * 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.spi.utils;
+
+import java.util.Map;
+import java.util.Set;
+import 
org.apache.pinot.spi.utils.CommonConstants.ConfigChangeListenerConstants;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertTrue;
+
+
+public class ProtoBufDescriptorFallbackListenerTest {
+  private static final String KEY = 
ConfigChangeListenerConstants.PROTOBUF_DESCRIPTOR_FALLBACK_ENABLED;
+
+  private final ProtoBufDescriptorFallbackListener _listener = 
ProtoBufDescriptorFallbackListener.getInstance();
+
+  @AfterMethod
+  public void reset() {
+    _listener.reset();
+  }
+
+  @Test
+  public void testEnabledByDefault() {
+    assertTrue(_listener.isEnabled());
+  }
+
+  @Test
+  public void testDisableAndReEnable() {
+    _listener.onChange(Set.of(KEY), Map.of(KEY, "false"));
+    assertFalse(_listener.isEnabled());
+    _listener.onChange(Set.of(KEY), Map.of(KEY, "true"));
+    assertTrue(_listener.isEnabled());
+  }
+
+  @Test
+  public void testRemovedOrBlankValueRestoresDefault() {
+    _listener.onChange(Set.of(KEY), Map.of(KEY, "false"));
+    assertFalse(_listener.isEnabled());
+    // Key removed from the cluster config
+    _listener.onChange(Set.of(KEY), Map.of());
+    assertTrue(_listener.isEnabled());
+
+    _listener.onChange(Set.of(KEY), Map.of(KEY, "false"));
+    assertFalse(_listener.isEnabled());
+    _listener.onChange(Set.of(KEY), Map.of(KEY, " "));
+    assertTrue(_listener.isEnabled());
+  }
+
+  @Test
+  public void testUnrelatedChangeIgnored() {
+    _listener.onChange(Set.of(KEY), Map.of(KEY, "false"));
+    assertFalse(_listener.isEnabled());
+    _listener.onChange(Set.of("some.other.config"), 
Map.of("some.other.config", "value", KEY, "true"));
+    assertFalse(_listener.isEnabled());
+  }
+}


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

Reply via email to