github-actions[bot] commented on code in PR #66729:
URL: https://github.com/apache/doris/pull/66729#discussion_r4003487943


##########
fe/be-java-extensions/jdbc-scanner/src/main/java/org/apache/doris/jdbc/JdbcJniScanner.java:
##########
@@ -330,17 +334,25 @@ public void close() throws IOException {
     }
 
     @Override
-    public Map<String, String> getStatistics() {
+    protected Map<String, String> collectStatistics() {
         Map<String, String> stats = new HashMap<>();
         stats.put("counter:ReadRows", String.valueOf(readRows));
         stats.put("timer:ReadTime", String.valueOf(readTime));
         return stats;
     }
 
-    private void initializeClassLoaderAndDataSource() throws Exception {
-        java.net.URL[] urls = {new java.net.URL(jdbcDriverUrl)};
-        ClassLoader parent = getClass().getClassLoader();
-        this.classLoader = java.net.URLClassLoader.newInstance(urls, parent);
+    private void initializeClassLoaderAndDataSource() {
+        // Before the driver classes are loaded, because that is when they 
read these. Both
+        // properties this sets are there to keep a driver's own cleanup 
thread from pinning the
+        // classloader that loaded it - which matters more now than it did, 
not less: every plugin
+        // has a classloader of its own, and a driver thread holding one keeps 
a whole plugin alive.
+        typeHandler.setSystemProperties();
+        // The checksum the catalog was defined with, checked once per driver 
jar - when its
+        // classloader is created, not on every scan. A driver jar replaced in 
place at the same
+        // URL is otherwise served from the cache until BE restarts, and a jar 
that is not the one
+        // the catalog names is a silent wrong answer rather than an error.
+        this.classLoader = JdbcDriverUtils.driverClassLoader(jdbcDriverUrl, 
getClass().getClassLoader(),

Review Comment:
   [P1] Retire the pool when the driver checksum changes
   
   `driverClassLoader` correctly verifies the new checksum and drops the cached 
loader when a jar is replaced at the same URL, but the following datasource 
lookup is keyed by `JdbcDataSource.createCacheKey`, which does not include 
`jdbcDriverChecksum`. An existing Hikari pool therefore survives the ALTER and 
still owns the old Driver instance/pooled connections. The connection tester 
uses a separate temporary pool, so validation may pass against the new jar 
while scans and writes continue through the old one. Include the checksum (or a 
driver-loader generation) in the pool identity and retire the old pool safely.



##########
build.sh:
##########
@@ -1376,57 +1452,132 @@ EOF
         cp -r -p "${DORIS_HOME}/be/output/lib/task_executor_simulator" 
"${DORIS_OUTPUT}/be/lib/"/
     fi
 
-    extensions_modules=("java-udf")
-    extensions_modules+=("jdbc-scanner")
-    extensions_modules+=("hadoop-hudi-scanner")
-    extensions_modules+=("paimon-scanner")
-    extensions_modules+=("trino-connector-scanner")
-    extensions_modules+=("max-compute-connector")
-    # lakesoul-scanner has been deprecated
-    # extensions_modules+=("lakesoul-scanner")
-    extensions_modules+=("preload-extensions")
-    extensions_modules+=("iceberg-metadata-scanner")
-    extensions_modules+=("${HADOOP_DEPS_NAME}")
-    extensions_modules+=("java-writer")
+    # Everything from here to the end of this block deploys what the Java 
extension build
+    # produced, so it only runs when there was one. 
DISABLE_BE_JAVA_EXTENSIONS=ON (and the
+    # Darwin fallback that sets the same flag when JAVA_HOME has no usable 
libjvm) leaves every
+    # target/ below empty, and the plugin loop is a hard failure when a jar is 
missing - which
+    # is how a BE-only build, .github/workflows/be-ut-mac.yml included, died 
here.
+    if [[ "${BUILD_BE_JAVA_EXTENSIONS}" -eq 1 ]]; then
+
+        # Every be-java-extensions module that BE addresses by name is a 
plugin now. The one exception
+        # is the hadoop drop below, which is not a plugin and never was, so 
there is no list of
+        # "extensions modules" left to iterate - only that one flag.
+        deploy_hadoop_deps=1
+        if [[ -n "${BE_EXTENSION_IGNORE}" ]]; then
+            IFS=',' read -r -a ignore_modules <<<"${BE_EXTENSION_IGNORE}"
+            for ignore_module in "${ignore_modules[@]}"; do
+                if [[ "${ignore_module// /}" == "${HADOOP_DEPS_NAME}" ]]; then
+                    deploy_hadoop_deps=0
+                    break
+                fi
+            done
+        fi
 
-    if [[ -n "${BE_EXTENSION_IGNORE}" ]]; then
-        IFS=',' read -r -a ignore_modules <<<"${BE_EXTENSION_IGNORE}"
-        new_modules=()
-        for module in "${extensions_modules[@]}"; do
-            module=${module// /}
-            if [[ -n "${module}" ]]; then
+        # The shared layer: the SPI a plugin compiles against and the loader 
that reads the plugin
+        # directory. These are the only Doris classes that live on both sides 
of the boundary, which
+        # is why they are the only ones deployed where the system classpath 
can see them.
+        BE_JAVA_SPI_DIR="${DORIS_OUTPUT}/be/lib/jni/spi"
+        rm -rf "${DORIS_OUTPUT}/be/lib/jni"
+        mkdir -p "${BE_JAVA_SPI_DIR}"
+        for spi_module in jni-spi jni-bootstrap; do
+            
spi_jar="${DORIS_HOME}/fe/be-java-extensions/${spi_module}/target/doris-${spi_module}.jar"
+            # Louder than the plugin loop below, not quieter: without these 
two jars there is no
+            # loader at all, so every Java feature fails at runtime with a 
FindClass error that
+            # names none of this. They are also not affected by 
BE_EXTENSION_IGNORE - see the
+            # module list far above.
+            if [[ ! -f "${spi_jar}" ]]; then
+                echo "Error: ${spi_module} produced no ${spi_jar}. It carries 
the plugin SPI and the"
+                echo "       loader that reads plugins/jni, so a BE without it 
can load no Java"
+                echo "       plugin at all."
+                exit 1
+            fi
+            echo "Copy Be shared layer ${spi_module} jar to ${BE_JAVA_SPI_DIR}"
+            cp "${spi_jar}" "${BE_JAVA_SPI_DIR}"
+        done
+
+        # Plugins, one directory each: the module jar plus the runtime closure 
copy-dependencies put
+        # beside it. The directory name is what BE addresses the plugin by and 
is deliberately not
+        # required to equal the module name - paimon-scanner will deploy as 
"paimon" - so the mapping
+        # is spelled out rather than derived.
+        #
+        # ATTN: a module named here must also be in the maven module list far 
above; adding it in one
+        # place only means deploying whatever the last build happened to leave 
in target/, which looks
+        # like a successful build of the wrong thing.
+        BE_JAVA_PLUGINS_DIR="${DORIS_OUTPUT}/be/plugins/jni"
+        # ATTN: this rm reaches into plugins/, which is otherwise the 
operator's tree - the drivers,
+        # configs and UDF jars they dropped there. It must name plugins/jni 
and nothing above it;
+        # widening it by one path element wipes a running deployment's drop 
points.
+        rm -rf "${BE_JAVA_PLUGINS_DIR}"
+        mkdir -p "${BE_JAVA_PLUGINS_DIR}"
+        plugin_modules=("java-writer:java-writer")
+        plugin_modules+=("jdbc-scanner:jdbc")
+        plugin_modules+=("iceberg-metadata-scanner:iceberg")
+        plugin_modules+=("max-compute-connector:max-compute")
+        plugin_modules+=("paimon-scanner:paimon")
+        plugin_modules+=("hadoop-hudi-scanner:hudi")
+        plugin_modules+=("trino-connector-scanner:trino-connector")
+        plugin_modules+=("java-udf:java-udf")
+
+        if [[ -n "${BE_EXTENSION_IGNORE}" ]]; then
+            IFS=',' read -r -a ignore_modules <<<"${BE_EXTENSION_IGNORE}"
+            kept_plugins=()
+            for plugin_entry in "${plugin_modules[@]}"; do
                 ignore=0
                 for ignore_module in "${ignore_modules[@]}"; do
-                    if [[ "${module}" == "${ignore_module}" ]]; then
+                    if [[ "${plugin_entry%%:*}" == "${ignore_module// /}" ]]; 
then
                         ignore=1
                         break
                     fi
                 done
                 if [[ "${ignore}" -eq 0 ]]; then
-                    new_modules+=("${module}")
+                    kept_plugins+=("${plugin_entry}")
                 fi
+            done
+            plugin_modules=("${kept_plugins[@]}")
+        fi
+
+        for plugin_entry in "${plugin_modules[@]}"; do
+            plugin_module="${plugin_entry%%:*}"
+            plugin_name="${plugin_entry##*:}"
+            
plugin_target="${DORIS_HOME}/fe/be-java-extensions/${plugin_module}/target"
+            plugin_jar="${plugin_target}/${plugin_module}.jar"
+            if [[ ! -f "${plugin_jar}" ]]; then
+                echo "Error: ${plugin_module} produced no 
${plugin_module}.jar. A plugin jar is named"
+                echo "       after its module; deploying an empty plugin 
directory would surface much"
+                echo "       later as 'Java plugin ${plugin_name} failed to 
load'."
+                exit 1
+            fi
+            echo "Copy Be plugin ${plugin_module} to 
${BE_JAVA_PLUGINS_DIR}/${plugin_name}"
+            mkdir -p "${BE_JAVA_PLUGINS_DIR}/${plugin_name}"
+            cp "${plugin_jar}" "${BE_JAVA_PLUGINS_DIR}/${plugin_name}"
+            # Tested on the jars, not on the directory: target/lib is emptied 
before
+            # copy-dependencies refills it, so an existing but empty directory 
is reachable and the
+            # glob below would then expand to nothing and fail the whole build 
under set -e.
+            if compgen -G "${plugin_target}/lib/*.jar" > /dev/null; then
+                cp "${plugin_target}/lib"/*.jar 
"${BE_JAVA_PLUGINS_DIR}/${plugin_name}"
             fi
         done
-        extensions_modules=("${new_modules[@]}")
-    fi
 
-    BE_JAVA_EXTENSIONS_DIR="${DORIS_OUTPUT}/be/lib/java_extensions/"
-    rm -rf "${BE_JAVA_EXTENSIONS_DIR}"
-    mkdir "${BE_JAVA_EXTENSIONS_DIR}"
-    for extensions_module in "${extensions_modules[@]}"; do
-        
module_jar="${DORIS_HOME}/fe/be-java-extensions/${extensions_module}/target/${extensions_module}-jar-with-dependencies.jar"
-        
module_proj_jar="${DORIS_HOME}/fe/be-java-extensions/${extensions_module}/target/${extensions_module}-project.jar"
-        mkdir "${BE_JAVA_EXTENSIONS_DIR}"/"${extensions_module}"
-        echo "Copy Be Extensions ${extensions_module} jar to 
${BE_JAVA_EXTENSIONS_DIR}/${extensions_module}"
-     if [[ "${extensions_module}" == "${HADOOP_DEPS_NAME}" ]]; then
-          
+        # The hadoop drop C++ libhdfs loads, and the JindoFS/JuiceFS drops the 
same libhdfs resolves
+        # oss-hdfs:// and jfs:// through: none of the three is a plugin, so 
libhdfs finds each by a
+        # fixed directory name on the system classpath rather than through a 
plugin loader. Each is
+        # therefore wiped and deployed whole every build rather than merged 
with whatever a previous
+        # build using the same output directory left behind - unwiped, a 
version bump would leave two
+        # jar versions of the same filesystem side by side, and start_be.sh's 
*.jar glob would put
+        # both of them on the classpath.
+        if [[ "${deploy_hadoop_deps}" -eq 1 ]]; then

Review Comment:
   [P1] Remove Hadoop jars when `hadoop-deps` is explicitly ignored
   
   The old unconditional wipe was moved inside this `deploy_hadoop_deps` 
branch. Consequently, a normal build followed by `--be-extension-ignore 
hadoop-deps` into the same `DORIS_OUTPUT` skips both the copy and the only 
cleanup, so `be/lib/hadoop_hdfs` retains the first build's jars and 
`start_be.sh` still loads them. Fresh and reused outputs now disagree about an 
explicitly excluded module. Preserve this directory only for the 
whole-extension-disabled case if needed, but remove it when `hadoop-deps` 
itself is named in `BE_EXTENSION_IGNORE`.



##########
fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorMetadata.java:
##########
@@ -98,6 +98,46 @@ default Optional<ConnectorMvccPartitionView> 
getMvccPartitionView(
         return Optional.empty();
     }
 
+    /**
+     * Whether {@link #listPartitions} reads the pin carried by a 
snapshot-applied handle, so that a
+     * {@code FOR TIME/VERSION AS OF} query can be told which partitions 
existed AT that pin.
+     *
+     * <p>Point-in-time time travel otherwise pins with EMPTY partition maps, 
because a partition set
+     * listed at LATEST is the wrong universe for a past snapshot in both 
directions: it hides a
+     * partition that has since been dropped (pruning it away loses rows) and 
invents ones created
+     * after the pin. Empty is the safe answer — the generic scan node reads 
it as scan-all and lets
+     * the connector's own predicate pushdown do the pruning — but it costs 
the query its partition
+     * pruning and makes EXPLAIN report {@code partition=0/0} for a scan that 
reads everything.</p>
+     *
+     * <p>A connector that answers true promises that {@code listPartitions} 
on the handle returned by
+     * {@link #applySnapshot} enumerates exactly the partitions with data at 
that pin. The generic
+     * model then pins the real partition set and both pruning and {@code 
partition=N/M} become
+     * truthful. The default is false: a connector whose listing is 
snapshot-blind keeps the empty pin,
+     * which is correct, just coarse.</p>
+     *
+     * <p><b>A SECOND PROMISE COMES WITH IT, and it is easy to miss:</b> the 
AT-SNAPSHOT schema this
+     * connector returns from {@link #getTableSchema(ConnectorSession, 
ConnectorTableHandle,
+     * ConnectorMvccSnapshot)} must declare, through {@code 
ConnectorTableSchema.PARTITION_COLUMNS_KEY},
+     * partition columns that MATCH the values that pinned listing produces - 
same count, in the same
+     * order, each parseable into the column's type. That schema is what types 
the pinned partition
+     * items, because it is the schema this snapshot publishes; the latest 
schema may partition the
+     * table differently.</p>
+     *
+     * <p>Breaking that promise degrades quietly rather than failing: each 
mismatched partition is
+     * skipped inside a per-partition catch, the pinned item map ends up 
shorter than the listed name
+     * set, and the table is reported UNPARTITIONED. Rows are still correct - 
pruning and
+     * {@code partition=N/M} are what is lost. Two signals in the log: one 
WARN per skipped
+     * partition, and - when EVERY partition was skipped, which is what a 
schema that never matched
+     * produces as opposed to iceberg spec evolution - one aggregate WARN 
naming both counts and the
+     * partition columns, from {@code PluginDrivenMvccExternalTable}. Note
+     * that a connector whose at-snapshot schema resolution can degrade to an 
empty column list on
+     * error - hudi's {@code getSchemaFromMetaClient} swallows a failed 
metadata read into one -
+     * produces exactly this shape from a transient fault.</p>
+     */
+    default boolean listsPartitionsAtSnapshot(ConnectorSession session, 
ConnectorTableHandle handle) {

Review Comment:
   [P1] Bump the connector SPI major for this method
   
   This expands the frozen connector SPI, while `fe/fe-connector/pom.xml` 
explicitly requires a major bump for any method addition and still publishes 
7.0. Since `ApiVersionGate` compares only majors, older and newer, mutually 
different surfaces both identify as compatible; a new plugin can be admitted by 
an old FE even though this method is absent there. Bump 
`connector.plugin.api.version` to 8.0 and update the surface/version assertions 
and generated manifests/resources in the same change.



##########
fe/be-java-extensions/jni-bootstrap/src/main/java/org/apache/doris/jni/bootstrap/PluginRuntime.java:
##########
@@ -0,0 +1,657 @@
+// 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.doris.jni.bootstrap;
+
+import org.apache.doris.jni.spi.DorisPlugin;
+import org.apache.doris.jni.spi.JniScannerFactory;
+import org.apache.doris.jni.spi.JniWriterFactory;
+import org.apache.doris.jni.spi.SpiVersion;
+import org.apache.doris.jni.spi.ThreadContextClassLoader;
+import org.apache.doris.jni.spi.UdfExecutorFactory;
+import org.apache.doris.jni.spi.utils.JniUtil;
+
+import java.io.IOException;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.ServiceLoader;
+import java.util.TreeMap;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Loads plugins from a directory and hands BE the objects it asks for.
+ *
+ * <p>Loading is lazy and happens at most once per plugin: the first request 
for a plugin builds its
+ * classloader, runs {@link ServiceLoader} and indexes the factories, and 
every later request reads
+ * the cached {@link PluginHandle} - including a cached failure. Nothing is 
eagerly resolved beyond
+ * the plugin object and its factories, so a class missing from a plugin's 
jars is reported when
+ * something touches it, with the name of the class, instead of taking the 
process down at startup.
+ *
+ * <p>All plugin code runs with the plugin's classloader installed as the 
thread context
+ * classloader. BE's threads have no meaningful one, and ServiceLoader and 
most plugin libraries
+ * consult it.
+ */
+final class PluginRuntime {
+
+    private static final Logger LOG = 
Logger.getLogger(PluginRuntime.class.getName());
+
+    private final Path pluginDir;
+    private final ClassLoader spiClassLoader;
+    private final ClassLoader hadoopConfResources;
+    private final Path fsDir;
+    private final ConcurrentHashMap<String, PluginHandle> plugins = new 
ConcurrentHashMap<>();
+    private final ConcurrentHashMap<String, Object> loadLocks = new 
ConcurrentHashMap<>();
+
+    PluginRuntime(Path pluginDir, ClassLoader spiClassLoader) {
+        this(pluginDir, spiClassLoader, null, null);
+    }
+
+    PluginRuntime(Path pluginDir, ClassLoader spiClassLoader, Path 
hadoopConfDir) {
+        this(pluginDir, spiClassLoader, hadoopConfDir, null);
+    }
+
+    /**
+     * @param hadoopConfDir directory whose files every plugin can read as 
classpath resources, so
+     *                      that a hadoop {@code Configuration} built inside a 
plugin finds
+     *                      {@code core-site.xml} and friends. Null when there 
is none.
+     * @param fsDir         directory of third-party hadoop {@code FileSystem} 
jars every plugin
+     *                      may need; see {@link #sharedFilesystemJars()}. 
Null when there is none.
+     */
+    PluginRuntime(Path pluginDir, ClassLoader spiClassLoader, Path 
hadoopConfDir, Path fsDir) {
+        this.pluginDir = Objects.requireNonNull(pluginDir, "pluginDir");
+        this.spiClassLoader = Objects.requireNonNull(spiClassLoader, 
"spiClassLoader");
+        this.hadoopConfResources = hadoopConfLoader(hadoopConfDir);
+        this.fsDir = fsDir;
+    }
+
+    /**
+     * Jars appended to EVERY plugin's classpath, after that plugin's own.
+     *
+     * <p>What lives here: third-party hadoop {@code FileSystem} 
implementations that no plugin
+     * declares as a dependency because none of them is written against it - 
JindoFS serves
+     * {@code oss://} and {@code oss-hdfs://}, JuiceFS serves {@code jfs://}, 
and hadoop reaches
+     * both by class name out of a {@code Configuration}. Both are opt-in 
build flags
+     * ({@code DISABLE_BUILD_JINDOFS=OFF}, {@code DISABLE_BUILD_JUICEFS=OFF}), 
so on a default
+     * build this directory is absent and this method returns nothing.
+     *
+     * <p>Why shared rather than bundled per plugin. Before the plugins were 
isolated these jars
+     * sat on the system classpath, which every scanner could reach, so any 
table format could read
+     * a table on any of those filesystems. A plugin classloader cannot reach 
that classpath by
+     * design, and the alternative - copying the jars into each plugin 
directory - does not scale:
+     * the JuiceFS Hadoop SDK is a 180 MB fat jar that carries jersey, 
checkerframework and
+     * javax.ws.rs, which collide with about 1500 classes already in a 
lake-format plugin. One
+     * directory read by every plugin costs one copy on disk and produces no 
collisions to
+     * adjudicate.
+     *
+     * <p>APPENDED, never prepended: these fat jars carry stray copies of 
third-party classes,
+     * hadoop's included, and a plugin's own hadoop must win. Counted on the 
jars this build
+     * packages: jindo-sdk carries 19 hadoop classes (11 in {@code 
org.apache.hadoop.fs}, 5 in
+     * {@code fs.impl}, 3 in {@code util}) and juicefs-hadoop carries 4, all in
+     * {@code org.apache.hadoop.security}. That is the same rule {@code 
bin/start_be.sh} applies
+     * when it puts them after {@code lib/hadoop_hdfs} on the system classpath 
for libhdfs.
+     *
+     * <p>ISOLATION IS PRESERVED: each plugin loads its own copy of these 
classes in its own

Review Comment:
   [P1] Avoid loading Jindo's native library in every plugin
   
   These shared jars are resolved independently by every plugin classloader, 
but the comment below and `build.sh` both acknowledge that `jindo-core` can 
bind its native library to only one classloader and that the second plugin (or 
plugin plus libhdfs) fails. In the supplied base the BE Jindo jars were on one 
system classpath; this change makes the failure reachable for ordinary mixed 
Paimon/Iceberg/Hudi access to an OSS warehouse. Provide one JVM owner/bridge 
for the native component, or another layout that shares the binding, and cover 
sequential access from two real plugin classloaders.



##########
be/src/format/transformer/vfile_format_transformer_factory.cpp:
##########
@@ -49,8 +49,23 @@ Status create_tvf_format_transformer(const TTVFTableSink& 
tvf_sink, RuntimeState
         if (tvf_sink.__isset.line_delimiter) {
             writer_params["line_delimiter"] = tvf_sink.line_delimiter;
         }
-        result->reset(new VJniFormatTransformer(state, output_vexpr_ctxs, 
tvf_sink.writer_class,
-                                                std::move(writer_params)));
+        // writer_class names a plugin factory, not a Java class. A class name 
stopped being able
+        // to identify a writer when plugins were isolated: a concrete writer 
lives in its own
+        // plugin's classloader, which BE cannot search by name, so what is 
addressable is the
+        // plugin directory under plugins/jni and the factory inside it.
+        const std::string& writer = tvf_sink.writer_class;
+        const size_t sep = writer.find(':');
+        if (sep == std::string::npos || sep == 0 || sep + 1 == writer.size()) {

Review Comment:
   [P1] Preserve `writer_class` through rolling upgrades
   
   This reinterprets existing thrift field 15 in place. A legacy class name 
works on an old BE but is explicitly rejected here, while 
`java-writer:local-file` works here but an old BE passes it to `FindClass` and 
cannot resolve it. FE forwards one user value and has no scheduled-BE 
capability branch, so no spelling can execute on every BE during a rolling 
upgrade. Keep accepting/mapping the known legacy class name until the 
compatibility window closes, or introduce a versioned/capability-negotiated 
field.



##########
be/CMakeLists.txt:
##########
@@ -686,29 +685,21 @@ set(COMMON_THIRDPARTY
     ${COMMON_THIRDPARTY}
 )
 
-if ((ARCH_AMD64 OR ARCH_AARCH64) AND OS_LINUX)
-    add_library(hadoop_hdfs STATIC IMPORTED)
-    set_target_properties(hadoop_hdfs PROPERTIES IMPORTED_LOCATION 
${THIRDPARTY_DIR}/lib/hadoop_hdfs_3_4/native/libhdfs.a)
+add_library(hadoop_hdfs STATIC IMPORTED)

Review Comment:
   [P1] Keep the macOS no-JVM fallback usable
   
   This removes libhdfs3 and makes Java libhdfs unconditional, but `build.sh` 
still treats a missing or wrong-architecture macOS `libjvm.dylib` as a 
supported fallback and writes `enable_java_support=false`. Every new HDFS 
connection now calls `ensure_jvm()`, which rejects exactly that config, so the 
packaged fallback can no longer access HDFS (the new unit test confirms the 
refusal). Either retain a non-JVM HDFS implementation for this build or make a 
compatible JDK/libjvm a hard macOS requirement and remove the fallback.



##########
fe/be-java-extensions/plugin-toolkit/src/main/java/org/apache/doris/jni/toolkit/jdbc/JdbcDriverUtils.java:
##########
@@ -0,0 +1,373 @@
+// 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.doris.jni.toolkit.jdbc;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.net.URLConnection;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Loads a user-supplied JDBC driver jar into a classloader of its own.
+ *
+ * <h2>Why the driver gets its own classloader</h2>
+ *
+ * <p>The jar is named by the catalog and downloaded at query time, so it 
cannot be part of any
+ * plugin's build. Giving it a child of the plugin's classloader means the 
driver can see the JDBC
+ * API and the plugin's classes, the plugin cannot accidentally compile 
against driver internals,
+ * and two catalogs pointing at different drivers stay independent.
+ *
+ * <h2>Why the classloader is cached</h2>
+ *
+ * <p>One per driver jar, for the life of the process. Building a fresh one 
per scan looks harmless
+ * but is not: connection pools outlive a single scan, so a pooled connection 
keeps the driver
+ * classes from the loader that created it while the next scan loads a second 
copy of the same
+ * classes. Everything then works until something compares two driver types 
and finds them
+ * unrelated. Caching also bounds a leak that is otherwise proportional to 
query count - a
+ * classloader holding a jar's worth of classes is not cheap.
+ *
+ * <p>The cache is keyed by jar <em>and</em> parent, because "the same jar 
under two plugins" is two
+ * different driver worlds. Note that a driver jar replaced in place at the 
same URL is not picked
+ * up on its own; {@link #checksumVerifier}, which the JDBC scanner, writer 
and connection tester
+ * all pass, is what turns that from a silent stale read into an error - and, 
once the operator
+ * states the jar's new checksum, into a reload: an expectation that differs 
from the one the
+ * cached loader was verified under discards it, because otherwise the check 
would report success
+ * against bytes the process is not running.
+ *
+ * <p>Whether a jar has been checked is remembered SEPARATELY from the 
classloader, keyed by jar and
+ * expected checksum rather than by jar and parent. Folding the two together 
looks equivalent and is
+ * not: the connection tester runs first, under the same parent as the 
scanner, so once it had built
+ * the loader every later scan took the cache's early return and never reached 
its own verifier -
+ * one un-checksummed CREATE CATALOG disabled the check for that jar for the 
life of the process.
+ */
+public final class JdbcDriverUtils {
+
+    /** Same 10s the executor this replaced used, for both connect and read. */
+    private static final int CHECKSUM_TIMEOUT_MS = 10000;
+
+    private static final ConcurrentHashMap<DriverKey, ClassLoader> 
DRIVER_CLASS_LOADERS =
+            new ConcurrentHashMap<>();
+
+    /** One lock per driver jar, so that loading two different drivers does 
not serialize. */
+    private static final ConcurrentHashMap<DriverKey, Object> LOAD_LOCKS = new 
ConcurrentHashMap<>();
+
+    /**
+     * Jars already checked, as "<url>\0<expectation>". Not keyed by parent: 
the bytes behind a URL
+     * are the same bytes whichever plugin asked, so one read answers for all 
of them - which is the
+     * whole reason this is remembered at all. Two catalogs naming the same 
URL with DIFFERENT
+     * checksums are two entries and both get checked; exactly one of them can 
pass.
+     */
+    private static final Set<String> VERIFIED = ConcurrentHashMap.newKeySet();
+
+    /**
+     * Per driver jar URL, the expectation the classloaders cached for it were 
built under.
+     *
+     * <p>This is what connects the two caches above, which are otherwise 
deliberately independent.
+     * A new expectation for a URL that already has a loader means the 
operator replaced the jar in
+     * place and told Doris its new checksum; the bytes just verified are then 
NOT the bytes the
+     * cached loader was built from. Without this the check reports success 
against the current jar
+     * while every query keeps using the old driver until BE restarts - a 
verification that passes
+     * for a driver the process is not running.
+     *
+     * <p>An entry is written whenever a loader is created, NOT only when an 
expectation was stated:
+     * a catalog defined without {@code jdbc_driver_checksum} produces no 
expectation at all, and
+     * leaving it out of this map is what made the discard below unreachable 
for exactly the case it
+     * exists for. The operator's sequence is "create the catalog, later 
replace the jar and declare
+     * its checksum" - the first step has nothing to record and the second 
then found no previous
+     * entry, read {@code null}, and skipped the discard. {@link #UNDECLARED} 
is that first step's
+     * entry: it equals no checksum, so any checksum stated later differs from 
it.
+     */
+    private static final ConcurrentHashMap<String, String> LOADED_UNDER = new 
ConcurrentHashMap<>();
+
+    /**
+     * The {@link #LOADED_UNDER} value for a loader built without a stated 
expectation. Not a valid
+     * checksum in any spelling - {@link #checksumVerifier} lower-cases hex - 
so it can never be
+     * mistaken for one, and comparing it against a real checksum always says 
"different".
+     */
+    private static final String UNDECLARED = "<no checksum declared>";
+
+    private JdbcDriverUtils() {
+    }
+
+    /**
+     * Checks a driver jar before it is loaded for the first time. Callers 
that know what the jar
+     * should be - Doris ships an MD5 with the catalog definition - pass one; 
the check then runs
+     * exactly once per jar, when its classloader is created, rather than on 
every query.
+     */
+    public interface DriverJarVerifier {
+        /** Throws to reject the jar; the classloader is then not created or 
cached. */
+        void verify(URL driverJar);
+
+        /**
+         * What this verifier expects of the jar, or null when it cannot say.
+         *
+         * <p>It is what lets "already checked" be remembered without holding 
on to the verifier:
+         * two calls naming the same jar and the same expectation ask the same 
question, so the
+         * second is skipped even when the classloader was built by somebody 
else. A verifier that
+         * returns null keeps the older, weaker rule - it runs only when the 
classloader is created
+         * - because nothing can tell two of them apart.
+         */
+        default String expectation() {
+            return null;
+        }
+    }
+
+    /**
+     * The verifier Doris ships with: the MD5 the catalog definition carries, 
compared against the
+     * jar actually behind the driver URL.
+     *
+     * <p>Returns {@code null} - "do not verify" - when the expected checksum 
is blank, which is
+     * what a catalog defined without one produces. Only what Doris was told 
to expect is checked;
+     * this never invents an expectation of its own.
+     *
+     * <p>The read is what makes it worth caching: {@code driverClassLoader} 
runs the verifier
+     * exactly once per jar and parent, when the classloader for it is 
created, so a remote driver
+     * jar is downloaded for checksumming once per process and not once per 
query.
+     */
+    public static DriverJarVerifier checksumVerifier(String expectedChecksum) {
+        if (expectedChecksum == null || expectedChecksum.trim().isEmpty()) {
+            return null;
+        }
+        String expected = expectedChecksum.trim();
+        return new DriverJarVerifier() {
+            @Override
+            public void verify(URL driverJar) {
+                String actual = md5Of(driverJar);
+                if (!expected.equalsIgnoreCase(actual)) {
+                    throw new IllegalStateException("Checksum mismatch for 
JDBC driver " + driverJar
+                            + ": the catalog expects " + expected + " but the 
jar is " + actual
+                            + ". The driver jar behind this URL is not the one 
the catalog was defined"
+                            + " with; replace the jar or redefine the catalog 
with the new checksum");
+                }
+            }
+
+            @Override
+            public String expectation() {
+                // Lower-cased because the comparison above is 
case-insensitive: the same MD5 in two
+                // spellings is one question, and must not be asked twice.
+                return expected.toLowerCase(Locale.ROOT);
+            }
+        };
+    }
+
+    private static String md5Of(URL driverJar) {
+        try {
+            MessageDigest digest = MessageDigest.getInstance("MD5");
+            URLConnection connection = driverJar.openConnection();
+            connection.setConnectTimeout(CHECKSUM_TIMEOUT_MS);
+            connection.setReadTimeout(CHECKSUM_TIMEOUT_MS);
+            try (InputStream in = connection.getInputStream()) {
+                byte[] buffer = new byte[8192];
+                int read;
+                while ((read = in.read(buffer)) != -1) {
+                    digest.update(buffer, 0, read);
+                }
+            }
+            StringBuilder hex = new StringBuilder(32);
+            for (byte b : digest.digest()) {
+                hex.append(Character.forDigit((b >> 4) & 0xF, 
16)).append(Character.forDigit(b & 0xF, 16));
+            }
+            return hex.toString();
+        } catch (IOException | NoSuchAlgorithmException e) {
+            throw new IllegalStateException("Cannot checksum the JDBC driver 
at " + driverJar
+                    + ": " + e.getMessage(), e);
+        }
+    }
+
+    /** The classloader for one driver jar, creating it on first use. */
+    public static ClassLoader driverClassLoader(String driverUrl, ClassLoader 
parent) {
+        return driverClassLoader(driverUrl, parent, null);
+    }
+
+    /**
+     * @param driverUrl where the driver jar lives, as a URL
+     * @param parent    the classloader of the code that will use the driver
+     * @param verifier  optional; runs once, before the classloader for this 
jar exists
+     */
+    public static ClassLoader driverClassLoader(String driverUrl, ClassLoader 
parent,
+            DriverJarVerifier verifier) {
+        DriverKey key = new DriverKey(toUrl(driverUrl), parent);
+        ClassLoader cached = DRIVER_CLASS_LOADERS.get(key);
+        if (cached != null) {
+            // Before the early return, not after the cache miss: a cached 
loader says nothing about
+            // whether THIS caller's expectation was ever checked against the 
jar.
+            verifyOnce(key.driverUrl, verifier, false);
+            // Re-read rather than return `cached`: verifyOnce discards the 
loaders for this jar when
+            // the expectation it just checked is not the one they were built 
under, and returning
+            // the loader read a moment ago would hand back exactly the stale 
driver that discovery
+            // was for. A miss here falls through and builds a fresh loader 
from the new bytes.
+            ClassLoader stillCached = DRIVER_CLASS_LOADERS.get(key);
+            if (stillCached != null) {
+                return stillCached;
+            }
+        }
+        // Per-key lock plus a second look, rather than computeIfAbsent: 
verifying a driver jar
+        // reads and checksums it and creating the loader opens it, and 
neither may run while a
+        // ConcurrentHashMap bin lock is held - two catalogs whose keys share 
a bin would then
+        // serialize on each other's jar download, and a nested load would be 
a recursive update.
+        // What computeIfAbsent bought is kept: exactly one classloader per 
jar is ever published.
+        synchronized (LOAD_LOCKS.computeIfAbsent(key, entry -> new Object())) {
+            ClassLoader loaded = DRIVER_CLASS_LOADERS.get(key);
+            // Before the loader exists, so that a jar that fails the check is 
neither opened nor
+            // cached. Throwing here leaves nothing behind and the next 
request checks again.
+            verifyOnce(key.driverUrl, verifier, loaded == null);
+            // Re-read, for the same reason the fast path above does and with 
the same consequence
+            // when it is skipped: verifyOnce may have just discarded the 
loaders for this jar, and
+            // `loaded` was read before that. Returning it would hand back the 
stale driver that the
+            // discard was for. Reached only when the fast path missed and 
this one hit, which is a
+            // publication race rather than the ordinary ALTER - that one is 
served above.
+            loaded = DRIVER_CLASS_LOADERS.get(key);
+            if (loaded == null) {
+                loaded = URLClassLoader.newInstance(new URL[] {key.driverUrl}, 
key.parent);
+                DRIVER_CLASS_LOADERS.put(key, loaded);
+                // Recorded here rather than only in verifyOnce, so that a 
loader built with no
+                // stated expectation is still attributable. putIfAbsent 
because verifyOnce has
+                // already written the real expectation when there was one, 
and that is the more
+                // specific answer.
+                LOADED_UNDER.putIfAbsent(key.driverUrl.toString(), 
declaredExpectation(verifier));
+            }
+            return loaded;
+        }
+    }
+
+    /**
+     * Runs the verifier unless this exact question - this jar, this 
expectation - was already
+     * answered in this process.
+     *
+     * @param loaderIsNew whether the classloader is about to be created, 
which is the only thing a
+     *                    verifier that cannot state its expectation can be 
keyed on
+     */
+    /** What {@link #LOADED_UNDER} records for a loader built under {@code 
verifier}. */
+    private static String declaredExpectation(DriverJarVerifier verifier) {
+        String expectation = verifier == null ? null : verifier.expectation();
+        return expectation == null ? UNDECLARED : expectation;
+    }
+
+    private static void verifyOnce(URL driverJar, DriverJarVerifier verifier, 
boolean loaderIsNew) {
+        if (verifier == null) {
+            return;
+        }
+        String expectation = verifier.expectation();
+        if (expectation == null) {
+            if (loaderIsNew) {
+                verifier.verify(driverJar);
+            }
+            return;
+        }
+        String token = driverJar.toString() + '\0' + expectation;
+        if (VERIFIED.contains(token)) {

Review Comment:
   [P1] Invalidate the loader after a checksum rollback
   
   `VERIFIED` lives for the process lifetime. After checksums A and B have both 
been used for this URL, rolling the jar back to A hits this early return before 
`LOADED_UNDER` is changed from B or `dropLoaders` runs, so the cached B loader 
is returned while the caller supplied expectation A. This also affects the 
connection tester independently of the datasource-cache issue. Treat a token as 
current only while `LOADED_UNDER` equals it, update/invalidate the current 
generation atomically, and add an A-to-B-to-A test.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to