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

CalvinKirs pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/master by this push:
     new 0c35a6e6d8b [feature](plugin) Add information_schema.plugins for 
kernel plugin metadata (#65644)
0c35a6e6d8b is described below

commit 0c35a6e6d8bb1a14bc572a3f9ee1915b7453d3e1
Author: Calvin Kirs <[email protected]>
AuthorDate: Mon Jul 27 09:55:22 2026 +0800

    [feature](plugin) Add information_schema.plugins for kernel plugin metadata 
(#65644)
    
    ### What problem does this PR solve?
    
    Issue #65479
    
    Kernel plugin families (filesystem, connector, authentication, lineage)
    currently have no unified, queryable inventory: to know which plugins
    are loaded on an FE, one has to read logs. This PR adds a unified
    `information_schema.plugins` system table listing all successfully
    loaded kernel plugins of the currently connected FE.
    
    Columns: `PLUGIN_NAME`, `PLUGIN_TYPE`, `PLUGIN_VERSION`, `SOURCE`,
    `DESCRIPTION`.
    
    Design points:
    - `(plugin_type, plugin_name)` is the primary key, enforced at load
    time: the first registration wins and is never silently overridden, so a
    directory jar can never displace a built-in plugin.
    - `name()` / `description()` are snapshotted once at load time; querying
    the table never executes plugin code. A broken self-report fails the
    load.
    - Plugin names are validated at load (trimmed, non-blank, <=64 chars,
    `[A-Za-z0-9._-]`); an invalid name fails the load for external plugins.
    - `PLUGIN_VERSION` comes from the jar MANIFEST `Implementation-Version`
    (NULL when unavailable), never from plugin interface methods.
    - Only successfully loaded plugins appear; load failures go to logs. No
    status/fail_reason columns.
    - Querying requires global ADMIN privilege; non-admin users see no rows.
    - View semantics are per-FE: the BE scanner RPCs the currently connected
    FE (same pattern as `catalog_meta_cache_statistics`), so per-FE jar
    deployment can be audited connection by connection.
    
    Implementation:
    - `fe-extension-spi`: add `default String description()` to
    `PluginFactory` (empty by default, zero migration cost).
    - `fe-extension-loader`: new process-wide `PluginRegistry` (load-time
    snapshots only) and `PluginNames` validation;
    `DirectoryPluginRuntimeManager` snapshots name/description and reads
    `Implementation-Version` from the jar containing the factory class.
    - The four family managers register BUILTIN/EXTERNAL rows; filesystem
    and connector managers now also reject external providers whose name
    conflicts with an already-registered one.
    - FE: `SchemaTable` definition + `MetadataGenerator` data source; thrift
    `TSchemaTableName.PLUGINS`. Reuses the existing MySQL-compat
    `TSchemaTableType.SCH_PLUGINS`.
    - BE: new `schema_plugins_scanner` wired into `schema_scanner`.
    
    Example:
    
    ```
    mysql> SELECT * FROM information_schema.plugins;
    
+-------------+----------------+----------------+---------+---------------------------+
    | PLUGIN_NAME | PLUGIN_TYPE    | PLUGIN_VERSION | SOURCE  | DESCRIPTION     
          |
    
+-------------+----------------+----------------+---------+---------------------------+
    | hdfs        | FILESYSTEM     | NULL           | BUILTIN |                 
          |
    | jdbc        | CONNECTOR      | NULL           | BUILTIN |                 
          |
    | oidc        | AUTHENTICATION | 4.0.0          | EXTERNAL| OIDC 
authentication       |
    
+-------------+----------------+----------------+---------+---------------------------+
    ```
    
    ### Release note
    
    Add `information_schema.plugins` system table listing loaded kernel
    plugins (filesystem/connector/authentication/lineage) of the currently
    connected FE; requires ADMIN privilege.
    
    ### Check List (For Author)
    
    - Test
    - [x] Unit test: `PluginRegistryTest`,
    `DirectoryPluginRuntimeManagerMetadataTest`
    - [x] Regression test:
    `regression-test/suites/query_p0/schema_table/test_plugins_schema.groovy`
    
    - Behavior changed:
        - [x] Yes, new `information_schema.plugins` table (additive only).
    
    - Does this need documentation?
    - [x] Yes, `information_schema.plugins` should be documented as a new
    system table.
---
 .../schema_extensions_scanner.cpp                  | 156 +++++++++++++++
 .../information_schema/schema_extensions_scanner.h |  56 ++++++
 be/src/information_schema/schema_scanner.cpp       |   3 +
 .../schema_extensions_scanner_test.cpp             | 126 ++++++++++++
 .../handler/AuthenticationPluginManager.java       |  40 ++--
 .../handler/AuthenticationPluginManagerTest.java   |  16 ++
 .../org/apache/doris/analysis/SchemaTableType.java |   3 +-
 .../java/org/apache/doris/catalog/SchemaTable.java |   8 +
 .../doris/connector/ConnectorPluginManager.java    |  35 +++-
 .../apache/doris/fs/FileSystemPluginManager.java   |  54 ++++-
 .../nereids/lineage/LineageEventProcessor.java     |  18 ++
 .../doris/tablefunction/MetadataGenerator.java     |  39 ++++
 fe/fe-extension-loader/pom.xml                     |  10 +
 .../loader/DirectoryPluginRuntimeManager.java      |  78 +++++++-
 .../doris/extension/loader/ManifestVersions.java   |  92 +++++++++
 .../doris/extension/loader/PluginHandle.java       |  20 ++
 .../apache/doris/extension/loader/PluginNames.java |  62 ++++++
 .../doris/extension/loader/PluginRegistry.java     | 219 +++++++++++++++++++++
 .../DirectoryPluginRuntimeManagerMetadataTest.java | 143 ++++++++++++++
 .../doris/extension/loader/PluginRegistryTest.java | 145 ++++++++++++++
 .../testplugins/BadNameTestPluginFactory.java}     |  41 ++--
 .../testplugins/MetadataTestPluginFactory.java}    |  45 ++---
 .../apache/doris/extension/spi/PluginFactory.java  |  11 ++
 gensrc/thrift/Descriptors.thrift                   |   1 +
 gensrc/thrift/FrontendService.thrift               |   1 +
 .../schema_table/test_extensions_schema.groovy     |  81 ++++++++
 26 files changed, 1424 insertions(+), 79 deletions(-)

diff --git a/be/src/information_schema/schema_extensions_scanner.cpp 
b/be/src/information_schema/schema_extensions_scanner.cpp
new file mode 100644
index 00000000000..a31d35ed7a7
--- /dev/null
+++ b/be/src/information_schema/schema_extensions_scanner.cpp
@@ -0,0 +1,156 @@
+// 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.
+
+#include "information_schema/schema_extensions_scanner.h"
+
+#include <utility>
+
+#include "core/assert_cast.h"
+#include "core/block/block.h"
+#include "core/column/column_nullable.h"
+#include "core/data_type/data_type_factory.hpp"
+#include "core/string_ref.h"
+#include "runtime/exec_env.h"
+#include "runtime/query_context.h"
+#include "runtime/runtime_state.h"
+#include "util/client_cache.h"
+#include "util/thrift_rpc_helper.h"
+
+namespace doris {
+
+std::vector<SchemaScanner::ColumnDesc> 
SchemaExtensionsScanner::_s_tbls_columns = {
+        {"EXTENSION_NAME", TYPE_STRING, sizeof(StringRef), true},
+        {"EXTENSION_TYPE", TYPE_STRING, sizeof(StringRef), true},
+        {"EXTENSION_VERSION", TYPE_STRING, sizeof(StringRef), true},
+        {"SOURCE", TYPE_STRING, sizeof(StringRef), true},
+        {"DESCRIPTION", TYPE_STRING, sizeof(StringRef), true},
+};
+
+SchemaExtensionsScanner::SchemaExtensionsScanner()
+        : SchemaScanner(_s_tbls_columns, TSchemaTableType::SCH_EXTENSIONS) {}
+
+Status SchemaExtensionsScanner::start(RuntimeState* state) {
+    if (!_is_init) {
+        return Status::InternalError("used before initialized.");
+    }
+    _block_rows_limit = state->batch_size();
+    _rpc_timeout_ms = state->execution_timeout() * 1000;
+    // Extensions are per-FE local state: ask the FE this session is connected 
to.
+    auto* query_ctx = state->get_query_ctx();
+    if (query_ctx == nullptr) {
+        return Status::InternalError("query context is null");
+    }
+    _fe_addr = query_ctx->current_connect_fe;
+    return Status::OK();
+}
+
+Status SchemaExtensionsScanner::_get_extensions_block_from_fe() {
+    TSchemaTableRequestParams schema_table_request_params;
+    for (int i = 0; i < _s_tbls_columns.size(); i++) {
+        schema_table_request_params.__isset.columns_name = true;
+        
schema_table_request_params.columns_name.emplace_back(_s_tbls_columns[i].name);
+    }
+    
schema_table_request_params.__set_current_user_ident(*_param->common_param->current_user_ident);
+
+    TFetchSchemaTableDataRequest request;
+    request.__set_schema_table_name(TSchemaTableName::EXTENSIONS);
+    request.__set_schema_table_params(schema_table_request_params);
+
+    TFetchSchemaTableDataResult result;
+    RETURN_IF_ERROR(ThriftRpcHelper::rpc<FrontendServiceClient>(
+            _fe_addr.hostname, _fe_addr.port,
+            [&request, &result](FrontendServiceConnection& client) {
+                client->fetchSchemaTableData(result, request);
+            },
+            _rpc_timeout_ms));
+
+    Status status(Status::create(result.status));
+    if (!status.ok()) {
+        LOG(WARNING) << "fetch extensions from FE(" << _fe_addr.hostname
+                     << ") failed, errmsg=" << status;
+        return status;
+    }
+
+    std::vector<TRow> result_data = std::move(result.data_batch);
+    if (!result_data.empty()) {
+        auto col_size = result_data[0].column_value.size();
+        if (col_size != _s_tbls_columns.size()) {
+            return Status::InternalError<false>("extensions schema is not 
match for FE and BE");
+        }
+    }
+
+    _extensions_block = Block::create_unique();
+    for (int i = 0; i < _s_tbls_columns.size(); ++i) {
+        auto data_type =
+                
DataTypeFactory::instance().create_data_type(_s_tbls_columns[i].type, true);
+        
_extensions_block->insert(ColumnWithTypeAndName(data_type->create_column(), 
data_type,
+                                                        
_s_tbls_columns[i].name));
+    }
+    _extensions_block->reserve(_block_rows_limit);
+
+    for (int i = 0; i < result_data.size(); i++) {
+        const TRow& row = result_data[i];
+        for (int j = 0; j < _s_tbls_columns.size(); j++) {
+            const TCell& cell = row.column_value[j];
+            // An unset string cell means SQL NULL (e.g. unknown 
EXTENSION_VERSION);
+            // insert_block_column would materialize it as an empty string.
+            if (!cell.__isset.stringVal) {
+                MutableColumnPtr mutable_col_ptr =
+                        
IColumn::mutate(_extensions_block->get_by_position(j).column);
+                // All _s_tbls_columns are declared nullable, so the column is
+                // always a ColumnNullable wrapping the string column.
+                
assert_cast<ColumnNullable*>(mutable_col_ptr.get())->insert_data(nullptr, 0);
+                _extensions_block->replace_by_position(j, 
std::move(mutable_col_ptr));
+                continue;
+            }
+            RETURN_IF_ERROR(
+                    insert_block_column(cell, j, _extensions_block.get(), 
_s_tbls_columns[j].type));
+        }
+    }
+    return Status::OK();
+}
+
+Status SchemaExtensionsScanner::get_next_block_internal(Block* block, bool* 
eos) {
+    if (!_is_init) {
+        return Status::InternalError("Used before initialized.");
+    }
+
+    if (nullptr == block || nullptr == eos) {
+        return Status::InternalError("input pointer is nullptr.");
+    }
+
+    if (_extensions_block == nullptr) {
+        RETURN_IF_ERROR(_get_extensions_block_from_fe());
+        _total_rows = static_cast<int>(_extensions_block->rows());
+    }
+
+    if (_row_idx == _total_rows) {
+        *eos = true;
+        return Status::OK();
+    }
+
+    int current_batch_rows = std::min(_block_rows_limit, _total_rows - 
_row_idx);
+    ScopedMutableBlock scoped_mblock(block);
+    auto& mblock = scoped_mblock.mutable_block();
+    RETURN_IF_ERROR(mblock.add_rows(_extensions_block.get(), _row_idx, 
current_batch_rows));
+    _row_idx += current_batch_rows;
+
+    *eos = _row_idx == _total_rows;
+    return Status::OK();
+}
+
+} // namespace doris
diff --git a/be/src/information_schema/schema_extensions_scanner.h 
b/be/src/information_schema/schema_extensions_scanner.h
new file mode 100644
index 00000000000..3a9f40e505c
--- /dev/null
+++ b/be/src/information_schema/schema_extensions_scanner.h
@@ -0,0 +1,56 @@
+// 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.
+
+#pragma once
+
+#include <vector>
+
+#include "common/status.h"
+#include "information_schema/schema_scanner.h"
+
+namespace doris {
+class RuntimeState;
+class Block;
+
+// information_schema.extensions: kernel extension inventory of the currently
+// connected FE (extensions are per-FE local state, so the RPC targets the
+// connected FE instead of the master).
+class SchemaExtensionsScanner : public SchemaScanner {
+    ENABLE_FACTORY_CREATOR(SchemaExtensionsScanner);
+
+public:
+    SchemaExtensionsScanner();
+    ~SchemaExtensionsScanner() override = default;
+
+    Status start(RuntimeState* state) override;
+    Status get_next_block_internal(Block* block, bool* eos) override;
+
+    static std::vector<SchemaScanner::ColumnDesc> _s_tbls_columns;
+
+private:
+    Status _get_extensions_block_from_fe();
+
+    TNetworkAddress _fe_addr;
+
+    int _block_rows_limit = 4096;
+    int _row_idx = 0;
+    int _total_rows = 0;
+    int _rpc_timeout_ms = 3000;
+    std::unique_ptr<Block> _extensions_block = nullptr;
+};
+
+} // namespace doris
diff --git a/be/src/information_schema/schema_scanner.cpp 
b/be/src/information_schema/schema_scanner.cpp
index b2b38c83d66..635ba04469a 100644
--- a/be/src/information_schema/schema_scanner.cpp
+++ b/be/src/information_schema/schema_scanner.cpp
@@ -60,6 +60,7 @@
 #include "information_schema/schema_database_properties_scanner.h"
 #include "information_schema/schema_dummy_scanner.h"
 #include "information_schema/schema_encryption_keys_scanner.h"
+#include "information_schema/schema_extensions_scanner.h"
 #include "information_schema/schema_file_cache_info_scanner.h"
 #include "information_schema/schema_file_cache_statistics.h"
 #include "information_schema/schema_files_scanner.h"
@@ -286,6 +287,8 @@ std::unique_ptr<SchemaScanner> 
SchemaScanner::create(TSchemaTableType::type type
         return SchemaFileCacheInfoScanner::create_unique();
     case TSchemaTableType::SCH_AUTHENTICATION_INTEGRATIONS:
         return SchemaAuthenticationIntegrationsScanner::create_unique();
+    case TSchemaTableType::SCH_EXTENSIONS:
+        return SchemaExtensionsScanner::create_unique();
     case TSchemaTableType::SCH_ROLE_MAPPINGS:
         return SchemaRoleMappingsScanner::create_unique();
     case TSchemaTableType::SCH_TABLE_STREAMS:
diff --git a/be/test/exec/schema_scanner/schema_extensions_scanner_test.cpp 
b/be/test/exec/schema_scanner/schema_extensions_scanner_test.cpp
new file mode 100644
index 00000000000..c87c2c24878
--- /dev/null
+++ b/be/test/exec/schema_scanner/schema_extensions_scanner_test.cpp
@@ -0,0 +1,126 @@
+// 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.
+
+#include "information_schema/schema_extensions_scanner.h"
+
+#include <gen_cpp/Descriptors_types.h>
+#include <gtest/gtest.h>
+
+#include "common/object_pool.h"
+#include "common/status.h"
+#include "core/block/block.h"
+#include "core/data_type/define_primitive_type.h"
+#include "information_schema/schema_scanner.h"
+#include "runtime/runtime_state.h"
+#include "testutil/mock/mock_runtime_state.h"
+
+namespace doris {
+
+// The RPC data path (_get_extensions_block_from_fe) requires a live FE and is
+// covered by the FE-side regression test; ThriftRpcHelper has no 
result-injection
+// hook, so these unit tests exercise the FE-independent surface only: the 
factory
+// registration, the column schema, and the pre-/post-init guard branches of
+// start() and get_next_block_internal().
+class SchemaExtensionsScannerTest : public testing::Test {
+    void SetUp() override {}
+    void TearDown() override {}
+};
+
+TEST_F(SchemaExtensionsScannerTest, test_create_extensions_scanner) {
+    auto scanner = SchemaScanner::create(TSchemaTableType::SCH_EXTENSIONS);
+    ASSERT_NE(nullptr, scanner);
+    EXPECT_EQ(TSchemaTableType::SCH_EXTENSIONS, scanner->type());
+
+    const auto& columns = scanner->get_column_desc();
+    ASSERT_EQ(5, columns.size());
+    EXPECT_STREQ("EXTENSION_NAME", columns[0].name);
+    EXPECT_STREQ("EXTENSION_TYPE", columns[1].name);
+    EXPECT_STREQ("EXTENSION_VERSION", columns[2].name);
+    EXPECT_STREQ("SOURCE", columns[3].name);
+    EXPECT_STREQ("DESCRIPTION", columns[4].name);
+
+    // All extension columns are nullable strings (e.g. unknown 
EXTENSION_VERSION
+    // is surfaced as SQL NULL rather than an empty string).
+    for (const auto& column : columns) {
+        EXPECT_EQ(TYPE_STRING, column.type);
+        EXPECT_TRUE(column.is_null);
+    }
+}
+
+TEST_F(SchemaExtensionsScannerTest, test_start_before_init) {
+    SchemaExtensionsScanner scanner;
+    // start() must reject use before init() regardless of the RuntimeState.
+    auto st = scanner.start(nullptr);
+    EXPECT_FALSE(st.ok());
+}
+
+TEST_F(SchemaExtensionsScannerTest, test_get_next_block_before_init) {
+    SchemaExtensionsScanner scanner;
+    auto block = Block::create_unique();
+    bool eos = false;
+    auto st = scanner.get_next_block_internal(block.get(), &eos);
+    EXPECT_FALSE(st.ok());
+    EXPECT_FALSE(eos);
+}
+
+// After init(), start() still fails when the session has no query context, 
since
+// extensions are fetched from the FE recorded on the query context.
+TEST_F(SchemaExtensionsScannerTest, test_start_without_query_context) {
+    // A default RuntimeState has a null query context.
+    RuntimeState state;
+    SchemaScannerParam param;
+    ObjectPool pool;
+
+    SchemaExtensionsScanner scanner;
+    ASSERT_TRUE(scanner.init(&state, &param, &pool).ok());
+
+    auto st = scanner.start(&state);
+    EXPECT_FALSE(st.ok());
+}
+
+// With a query context present, start() succeeds and stashes the per-session
+// state (batch size, rpc timeout, connected FE) used by the later RPC.
+TEST_F(SchemaExtensionsScannerTest, test_start_with_query_context) {
+    MockRuntimeState state;
+    SchemaScannerParam param;
+    ObjectPool pool;
+
+    SchemaExtensionsScanner scanner;
+    ASSERT_TRUE(scanner.init(&state, &param, &pool).ok());
+
+    auto st = scanner.start(&state);
+    EXPECT_TRUE(st.ok());
+}
+
+// After init(), get_next_block_internal() rejects null output pointers before
+// attempting any FE RPC.
+TEST_F(SchemaExtensionsScannerTest, test_get_next_block_null_output) {
+    RuntimeState state;
+    SchemaScannerParam param;
+    ObjectPool pool;
+
+    SchemaExtensionsScanner scanner;
+    ASSERT_TRUE(scanner.init(&state, &param, &pool).ok());
+
+    bool eos = false;
+    EXPECT_FALSE(scanner.get_next_block_internal(nullptr, &eos).ok());
+
+    auto block = Block::create_unique();
+    EXPECT_FALSE(scanner.get_next_block_internal(block.get(), nullptr).ok());
+}
+
+} // namespace doris
diff --git 
a/fe/fe-authentication/fe-authentication-handler/src/main/java/org/apache/doris/authentication/handler/AuthenticationPluginManager.java
 
b/fe/fe-authentication/fe-authentication-handler/src/main/java/org/apache/doris/authentication/handler/AuthenticationPluginManager.java
index 3bbfe6459ea..69f7b019ce3 100644
--- 
a/fe/fe-authentication/fe-authentication-handler/src/main/java/org/apache/doris/authentication/handler/AuthenticationPluginManager.java
+++ 
b/fe/fe-authentication/fe-authentication-handler/src/main/java/org/apache/doris/authentication/handler/AuthenticationPluginManager.java
@@ -26,12 +26,11 @@ import 
org.apache.doris.extension.loader.DirectoryPluginRuntimeManager;
 import org.apache.doris.extension.loader.LoadFailure;
 import org.apache.doris.extension.loader.LoadReport;
 import org.apache.doris.extension.loader.PluginHandle;
+import org.apache.doris.extension.loader.PluginRegistry;
 
 import org.apache.logging.log4j.LogManager;
 import org.apache.logging.log4j.Logger;
 
-import java.io.Closeable;
-import java.io.IOException;
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.Collections;
@@ -63,6 +62,9 @@ public class AuthenticationPluginManager {
     private static final List<String> AUTH_PARENT_FIRST_PREFIXES =
             Collections.singletonList("org.apache.doris.authentication.");
 
+    /** Family label in the process-wide {@link PluginRegistry}. */
+    private static final String PLUGIN_FAMILY = "AUTHENTICATION";
+
     /** Factories by plugin name (e.g., "ldap", "oidc", "password") */
     private final Map<String, AuthenticationPluginFactory> factories = new 
ConcurrentHashMap<>();
 
@@ -88,7 +90,23 @@ public class AuthenticationPluginManager {
                 : ClassLoadingPolicy.defaultPolicy();
 
         ServiceLoader.load(AuthenticationPluginFactory.class)
-                .forEach(factory -> factories.put(factory.name(), factory));
+                .forEach(factory -> {
+                    try {
+                        // Snapshot self-reported metadata before publishing 
the factory so
+                        // one throwing implementation is rejected cleanly. 
The registry is
+                        // first-wins on (family, name), so re-registration 
from another
+                        // manager instance is a quiet no-op. First 
registration also wins
+                        // here, keeping the executable factory and the 
inventory row the
+                        // same plugin.
+                        
PluginRegistry.getInstance().registerBuiltin(PLUGIN_FAMILY, factory);
+                        if (factories.putIfAbsent(factory.name(), factory) != 
null) {
+                            LOG.warn("Skip duplicated built-in authentication 
plugin name: {}", factory.name());
+                        }
+                    } catch (RuntimeException e) {
+                        LOG.warn("Skip built-in authentication plugin factory 
{}: self-reported metadata failed",
+                                factory.getClass().getName(), e);
+                    }
+                });
     }
 
     /**
@@ -136,11 +154,14 @@ public class AuthenticationPluginManager {
             String pluginName = handle.getPluginName();
             AuthenticationPluginFactory existing = 
factories.putIfAbsent(pluginName, handle.getFactory());
             if (existing != null) {
-                closeClassLoaderQuietly(handle.getClassLoader());
+                // Remove the rejected handle from the runtime manager too, so 
its
+                // classloader is not retained for the FE lifetime.
+                runtimeManager.discard(pluginName);
                 LOG.warn("Skip duplicated plugin name: {} from directory {}", 
pluginName, handle.getPluginDir());
                 continue;
             }
             loadedPlugins++;
+            PluginRegistry.getInstance().registerExternal(PLUGIN_FAMILY, 
handle);
             LOG.info("Loaded external authentication plugin: name={}, 
pluginDir={}, jarCount={}",
                     pluginName, handle.getPluginDir(), 
handle.getResolvedJars().size());
         }
@@ -165,17 +186,6 @@ public class AuthenticationPluginManager {
         return null;
     }
 
-    private static void closeClassLoaderQuietly(ClassLoader classLoader) {
-        if (!(classLoader instanceof Closeable)) {
-            return;
-        }
-        try {
-            ((Closeable) classLoader).close();
-        } catch (IOException ignored) {
-            // Best effort close.
-        }
-    }
-
     /**
      * Get a factory by plugin name.
      *
diff --git 
a/fe/fe-authentication/fe-authentication-handler/src/test/java/org/apache/doris/authentication/handler/AuthenticationPluginManagerTest.java
 
b/fe/fe-authentication/fe-authentication-handler/src/test/java/org/apache/doris/authentication/handler/AuthenticationPluginManagerTest.java
index 260afb30a96..92b2e207e0a 100644
--- 
a/fe/fe-authentication/fe-authentication-handler/src/test/java/org/apache/doris/authentication/handler/AuthenticationPluginManagerTest.java
+++ 
b/fe/fe-authentication/fe-authentication-handler/src/test/java/org/apache/doris/authentication/handler/AuthenticationPluginManagerTest.java
@@ -516,6 +516,22 @@ public class AuthenticationPluginManagerTest {
             return Optional.empty();
         }
 
+        @Override
+        public void discard(String pluginName) {
+            // This stub bypasses the real loadAll bookkeeping, so honor the
+            // contract that discarding a reported success closes its 
classloader.
+            for (PluginHandle<AuthenticationPluginFactory> handle : 
report.getSuccesses()) {
+                if (handle.getPluginName().equals(pluginName)
+                        && handle.getClassLoader() instanceof Closeable) {
+                    try {
+                        ((Closeable) handle.getClassLoader()).close();
+                    } catch (IOException ignored) {
+                        // test stub: never expected
+                    }
+                }
+            }
+        }
+
         @Override
         public List<PluginHandle<AuthenticationPluginFactory>> list() {
             return Collections.emptyList();
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/analysis/SchemaTableType.java 
b/fe/fe-core/src/main/java/org/apache/doris/analysis/SchemaTableType.java
index 605f53b1cb9..6cdcb2f5918 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/analysis/SchemaTableType.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/analysis/SchemaTableType.java
@@ -124,7 +124,8 @@ public enum SchemaTableType {
             TSchemaTableType.SCH_BE_COMPACTION_TASKS),
     SCH_ROLE_MAPPINGS("ROLE_MAPPINGS", "ROLE_MAPPINGS", 
TSchemaTableType.SCH_ROLE_MAPPINGS),
     SCH_BACKEND_MS_RPC_TABLE_THROTTLERS("BACKEND_MS_RPC_TABLE_THROTTLERS", 
"BACKEND_MS_RPC_TABLE_THROTTLERS",
-            TSchemaTableType.SCH_BACKEND_MS_RPC_TABLE_THROTTLERS);
+            TSchemaTableType.SCH_BACKEND_MS_RPC_TABLE_THROTTLERS),
+    SCH_EXTENSIONS("EXTENSIONS", "EXTENSIONS", 
TSchemaTableType.SCH_EXTENSIONS);
 
     private static final String dbName = "INFORMATION_SCHEMA";
 
diff --git a/fe/fe-core/src/main/java/org/apache/doris/catalog/SchemaTable.java 
b/fe/fe-core/src/main/java/org/apache/doris/catalog/SchemaTable.java
index bc20f860d16..5bf68fcc5c8 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/catalog/SchemaTable.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/catalog/SchemaTable.java
@@ -863,6 +863,14 @@ public class SchemaTable extends Table {
                             .column("ALTER_USER", 
ScalarType.createStringType())
                             .column("MODIFY_TIME", 
ScalarType.createStringType())
                             .build()))
+            .put("extensions",
+                    new SchemaTable(SystemIdGenerator.getNextId(), 
"extensions", TableType.SCHEMA,
+                        builder().column("EXTENSION_NAME", 
ScalarType.createStringType())
+                            .column("EXTENSION_TYPE", 
ScalarType.createStringType())
+                            .column("EXTENSION_VERSION", 
ScalarType.createStringType())
+                            .column("SOURCE", ScalarType.createStringType())
+                            .column("DESCRIPTION", 
ScalarType.createStringType())
+                            .build()))
             .put("table_streams",
                     new SchemaTable(SystemIdGenerator.getNextId(), 
"table_streams", TableType.SCHEMA,
                             builder().column("DB_NAME", 
ScalarType.createVarchar(NAME_CHAR_LEN))
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorPluginManager.java
 
b/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorPluginManager.java
index 57353415270..a45e0c90051 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorPluginManager.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorPluginManager.java
@@ -25,6 +25,7 @@ import 
org.apache.doris.extension.loader.DirectoryPluginRuntimeManager;
 import org.apache.doris.extension.loader.LoadFailure;
 import org.apache.doris.extension.loader.LoadReport;
 import org.apache.doris.extension.loader.PluginHandle;
+import org.apache.doris.extension.loader.PluginRegistry;
 
 import org.apache.logging.log4j.LogManager;
 import org.apache.logging.log4j.Logger;
@@ -64,6 +65,9 @@ public class ConnectorPluginManager {
     private static final List<String> CONNECTOR_PARENT_FIRST_PREFIXES =
             Arrays.asList("org.apache.doris.connector.", 
"org.apache.doris.filesystem.");
 
+    /** Family label in the process-wide {@link PluginRegistry}. */
+    private static final String PLUGIN_FAMILY = "CONNECTOR";
+
     private final List<ConnectorProvider> providers = new 
CopyOnWriteArrayList<>();
     private final DirectoryPluginRuntimeManager<ConnectorProvider> 
runtimeManager =
             new DirectoryPluginRuntimeManager<>();
@@ -74,8 +78,17 @@ public class ConnectorPluginManager {
     public void loadBuiltins() {
         ServiceLoader.load(ConnectorProvider.class)
                 .forEach(p -> {
-                    providers.add(p);
-                    LOG.info("Registered built-in connector provider: {}", 
p.getType());
+                    try {
+                        // Snapshot self-reported metadata before publishing 
the provider
+                        // so one throwing implementation is rejected cleanly 
instead of
+                        // aborting startup or being active without an 
inventory row.
+                        
PluginRegistry.getInstance().registerBuiltin(PLUGIN_FAMILY, p);
+                        providers.add(p);
+                        LOG.info("Registered built-in connector provider: {}", 
p.getType());
+                    } catch (RuntimeException e) {
+                        LOG.warn("Skip built-in connector provider {}: 
self-reported metadata failed",
+                                p.getClass().getName(), e);
+                    }
                 });
     }
 
@@ -104,13 +117,31 @@ public class ConnectorPluginManager {
         }
 
         for (PluginHandle<ConnectorProvider> handle : report.getSuccesses()) {
+            // Built-ins (and earlier-loaded plugins) must never be displaced 
by a
+            // same-name directory jar.
+            if (hasProviderNamed(handle.getPluginName())) {
+                LOG.warn("Skip connector plugin '{}' from {}: name conflicts 
with an already "
+                        + "registered provider", handle.getPluginName(), 
handle.getPluginDir());
+                runtimeManager.discard(handle.getPluginName());
+                continue;
+            }
             providers.add(handle.getFactory());
+            PluginRegistry.getInstance().registerExternal(PLUGIN_FAMILY, 
handle);
             LOG.info("Loaded connector plugin: name={}, pluginDir={}, 
jarCount={}",
                     handle.getPluginName(), handle.getPluginDir(),
                     handle.getResolvedJars().size());
         }
     }
 
+    private boolean hasProviderNamed(String name) {
+        for (ConnectorProvider p : providers) {
+            if (name.equals(p.name())) {
+                return true;
+            }
+        }
+        return false;
+    }
+
     /**
      * Creates a Connector for the given catalog type by selecting the first 
supporting provider.
      *
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/fs/FileSystemPluginManager.java 
b/fe/fe-core/src/main/java/org/apache/doris/fs/FileSystemPluginManager.java
index 51bad3a55a3..db3cbca1883 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/fs/FileSystemPluginManager.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/fs/FileSystemPluginManager.java
@@ -23,6 +23,7 @@ import 
org.apache.doris.extension.loader.DirectoryPluginRuntimeManager;
 import org.apache.doris.extension.loader.LoadFailure;
 import org.apache.doris.extension.loader.LoadReport;
 import org.apache.doris.extension.loader.PluginHandle;
+import org.apache.doris.extension.loader.PluginRegistry;
 import org.apache.doris.filesystem.FileSystem;
 import org.apache.doris.filesystem.spi.FileSystemProvider;
 
@@ -36,6 +37,7 @@ import java.util.Arrays;
 import java.util.List;
 import java.util.Map;
 import java.util.ServiceLoader;
+import java.util.Set;
 import java.util.concurrent.CopyOnWriteArrayList;
 
 /**
@@ -70,6 +72,9 @@ public class FileSystemPluginManager {
     private static final List<String> FS_PARENT_FIRST_PREFIXES =
             Arrays.asList("org.apache.doris.filesystem.", 
"software.amazon.awssdk.", "org.apache.hadoop.");
 
+    /** Family label in the process-wide {@link PluginRegistry}. */
+    private static final String PLUGIN_FAMILY = "FILESYSTEM";
+
     private final List<FileSystemProvider> providers = new 
CopyOnWriteArrayList<>();
     private final DirectoryPluginRuntimeManager<FileSystemProvider> 
runtimeManager =
             new DirectoryPluginRuntimeManager<>();
@@ -80,9 +85,20 @@ public class FileSystemPluginManager {
     public void loadBuiltins() {
         ServiceLoader.load(FileSystemProvider.class)
                 .forEach(p -> {
-                    providers.add(p);
-                    
DatasourcePrintableMap.registerSensitiveKeys(p.sensitivePropertyKeys());
-                    LOG.info("Registered built-in filesystem provider: {}", 
p.name());
+                    try {
+                        // Snapshot all self-reported metadata (sensitive keys 
included)
+                        // before mutating any store, so one throwing 
implementation is
+                        // rejected cleanly instead of aborting startup or 
leaving an
+                        // inventory row for a provider that never became 
active.
+                        Set<String> sensitiveKeys = p.sensitivePropertyKeys();
+                        
PluginRegistry.getInstance().registerBuiltin(PLUGIN_FAMILY, p);
+                        
DatasourcePrintableMap.registerSensitiveKeys(sensitiveKeys);
+                        providers.add(p);
+                        LOG.info("Registered built-in filesystem provider: 
{}", p.name());
+                    } catch (RuntimeException e) {
+                        LOG.warn("Skip built-in filesystem provider {}: 
self-reported metadata failed",
+                                p.getClass().getName(), e);
+                    }
                 });
     }
 
@@ -111,15 +127,45 @@ public class FileSystemPluginManager {
         }
 
         for (PluginHandle<FileSystemProvider> handle : report.getSuccesses()) {
+            // Built-ins (and earlier-loaded plugins) must never be displaced 
by a
+            // same-name directory jar.
+            if (hasProviderNamed(handle.getPluginName())) {
+                LOG.warn("Skip filesystem plugin '{}' from {}: name conflicts 
with an already "
+                        + "registered provider", handle.getPluginName(), 
handle.getPluginDir());
+                runtimeManager.discard(handle.getPluginName());
+                continue;
+            }
             FileSystemProvider provider = handle.getFactory();
+            // Snapshot the self-reported sensitive keys before publishing 
anything:
+            // this is plugin code and may throw, and a provider must never be 
active
+            // without its inventory row (or vice versa).
+            Set<String> sensitiveKeys;
+            try {
+                sensitiveKeys = provider.sensitivePropertyKeys();
+            } catch (RuntimeException | LinkageError e) {
+                runtimeManager.discard(handle.getPluginName());
+                LOG.warn("Skip filesystem plugin '{}' from {}: 
sensitivePropertyKeys() failed",
+                        handle.getPluginName(), handle.getPluginDir(), e);
+                continue;
+            }
             providers.add(provider);
-            
DatasourcePrintableMap.registerSensitiveKeys(provider.sensitivePropertyKeys());
+            DatasourcePrintableMap.registerSensitiveKeys(sensitiveKeys);
+            PluginRegistry.getInstance().registerExternal(PLUGIN_FAMILY, 
handle);
             LOG.info("Loaded filesystem plugin: name={}, pluginDir={}, 
jarCount={}",
                     handle.getPluginName(), handle.getPluginDir(),
                     handle.getResolvedJars().size());
         }
     }
 
+    private boolean hasProviderNamed(String name) {
+        for (FileSystemProvider p : providers) {
+            if (name.equals(p.name())) {
+                return true;
+            }
+        }
+        return false;
+    }
+
     /**
      * Creates a FileSystem for the given properties by selecting the first 
supporting provider.
      *
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/lineage/LineageEventProcessor.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/lineage/LineageEventProcessor.java
index 1f7585634f5..8a55086d16a 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/lineage/LineageEventProcessor.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/lineage/LineageEventProcessor.java
@@ -23,6 +23,7 @@ import 
org.apache.doris.extension.loader.DirectoryPluginRuntimeManager;
 import org.apache.doris.extension.loader.LoadFailure;
 import org.apache.doris.extension.loader.LoadReport;
 import org.apache.doris.extension.loader.PluginHandle;
+import org.apache.doris.extension.loader.PluginRegistry;
 import org.apache.doris.extension.spi.PluginContext;
 
 import org.apache.logging.log4j.LogManager;
@@ -65,6 +66,9 @@ public class LineageEventProcessor {
     private static final Logger LOG = 
LogManager.getLogger(LineageEventProcessor.class);
     private static final long EVENT_POLL_TIMEOUT_SECONDS = 5L;
 
+    /** Family label in the process-wide {@link PluginRegistry}. */
+    private static final String PLUGIN_FAMILY = "LINEAGE";
+
     /** Parent-first prefixes for child-first classloading isolation. */
     private static final List<String> LINEAGE_PARENT_FIRST_PREFIXES =
             Collections.singletonList("org.apache.doris.nereids.lineage.");
@@ -129,6 +133,16 @@ public class LineageEventProcessor {
                             factory == null ? "null" : 
factory.getClass().getName());
                     continue;
                 }
+                try {
+                    // Snapshot self-reported metadata before publishing the 
factory so one
+                    // throwing implementation is rejected cleanly instead of 
aborting the
+                    // whole built-in discovery or being active without an 
inventory row.
+                    
PluginRegistry.getInstance().registerBuiltin(PLUGIN_FAMILY, factory);
+                } catch (RuntimeException e) {
+                    LOG.warn("Skip built-in lineage plugin factory {}: 
self-reported metadata failed",
+                            factory.getClass().getName(), e);
+                    continue;
+                }
                 LineagePluginFactory existing = 
factories.putIfAbsent(pluginName, factory);
                 if (existing != null) {
                     LOG.warn("Skip duplicated built-in lineage plugin name: 
{}", pluginName);
@@ -158,9 +172,13 @@ public class LineageEventProcessor {
                 String pluginName = handle.getPluginName();
                 LineagePluginFactory existing = 
factories.putIfAbsent(pluginName, handle.getFactory());
                 if (existing != null) {
+                    // Remove the rejected handle from the runtime manager 
too, so its
+                    // classloader is not retained for the FE lifetime.
+                    runtimeManager.discard(pluginName);
                     LOG.warn("Skip duplicated lineage plugin name: {} from 
directory {}", pluginName,
                             handle.getPluginDir());
                 } else {
+                    
PluginRegistry.getInstance().registerExternal(PLUGIN_FAMILY, handle);
                     LOG.info("Loaded external lineage plugin factory: name={}, 
pluginDir={}, jarCount={}",
                             pluginName, handle.getPluginDir(), 
handle.getResolvedJars().size());
                 }
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/tablefunction/MetadataGenerator.java
 
b/fe/fe-core/src/main/java/org/apache/doris/tablefunction/MetadataGenerator.java
index 1838085cb1f..52ab9868d16 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/tablefunction/MetadataGenerator.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/tablefunction/MetadataGenerator.java
@@ -70,6 +70,7 @@ import org.apache.doris.datasource.hive.HiveExternalMetaCache;
 import org.apache.doris.datasource.maxcompute.MaxComputeExternalCatalog;
 import org.apache.doris.datasource.metacache.MetaCacheEntryStats;
 import org.apache.doris.datasource.mvcc.MvccUtil;
+import org.apache.doris.extension.loader.PluginRegistry;
 import org.apache.doris.job.common.JobType;
 import org.apache.doris.job.extensions.insert.streaming.AbstractStreamingTask;
 import org.apache.doris.job.extensions.insert.streaming.StreamingInsertJob;
@@ -166,6 +167,8 @@ public class MetadataGenerator {
 
     private static final ImmutableMap<String, Integer> 
AUTHENTICATION_INTEGRATIONS_COLUMN_TO_INDEX;
 
+    private static final ImmutableMap<String, Integer> 
EXTENSIONS_COLUMN_TO_INDEX;
+
     private static final ImmutableMap<String, Integer> 
ROLE_MAPPINGS_COLUMN_TO_INDEX;
 
     private static final ImmutableMap<String, Integer> 
TABLE_STREAMS_COLUMN_TO_INDEX;
@@ -259,6 +262,13 @@ public class MetadataGenerator {
         }
         AUTHENTICATION_INTEGRATIONS_COLUMN_TO_INDEX = 
authenticationIntegrationsBuilder.build();
 
+        ImmutableMap.Builder<String, Integer> extensionsBuilder = new 
ImmutableMap.Builder();
+        List<Column> extensionsColList = 
SchemaTable.TABLE_MAP.get("extensions").getFullSchema();
+        for (int i = 0; i < extensionsColList.size(); i++) {
+            
extensionsBuilder.put(extensionsColList.get(i).getName().toLowerCase(), i);
+        }
+        EXTENSIONS_COLUMN_TO_INDEX = extensionsBuilder.build();
+
         ImmutableMap.Builder<String, Integer> roleMappingsBuilder = new 
ImmutableMap.Builder();
         List<Column> roleMappingsColList = 
SchemaTable.TABLE_MAP.get("role_mappings").getFullSchema();
         for (int i = 0; i < roleMappingsColList.size(); i++) {
@@ -399,6 +409,10 @@ public class MetadataGenerator {
                 result = 
authenticationIntegrationsMetadataResult(schemaTableParams);
                 columnIndex = AUTHENTICATION_INTEGRATIONS_COLUMN_TO_INDEX;
                 break;
+            case EXTENSIONS:
+                result = extensionsMetadataResult(schemaTableParams);
+                columnIndex = EXTENSIONS_COLUMN_TO_INDEX;
+                break;
             case ROLE_MAPPINGS:
                 result = roleMappingsMetadataResult(schemaTableParams);
                 columnIndex = ROLE_MAPPINGS_COLUMN_TO_INDEX;
@@ -795,6 +809,31 @@ public class MetadataGenerator {
         return result;
     }
 
+    private static TFetchSchemaTableDataResult 
extensionsMetadataResult(TSchemaTableRequestParams params) {
+        TFetchSchemaTableDataResult result = new TFetchSchemaTableDataResult();
+        List<TRow> dataBatch = Lists.newArrayList();
+        result.setDataBatch(dataBatch);
+        result.setStatus(new TStatus(TStatusCode.OK));
+
+        // Readable by any authenticated user: a registry record only carries 
load-time
+        // component identity, never extension configuration or credentials.
+        // Registry rows are load-time snapshots of the current FE; no 
extension code runs here.
+        for (PluginRegistry.PluginRecord record : 
PluginRegistry.getInstance().list()) {
+            TRow row = new TRow();
+            row.addToColumnValue(new TCell().setStringVal(record.getName()));
+            row.addToColumnValue(new TCell().setStringVal(record.getType()));
+            if (record.getVersion() == null) {
+                row.addToColumnValue(new TCell());
+            } else {
+                row.addToColumnValue(new 
TCell().setStringVal(record.getVersion()));
+            }
+            row.addToColumnValue(new 
TCell().setStringVal(record.getSource().name()));
+            row.addToColumnValue(new 
TCell().setStringVal(record.getDescription()));
+            dataBatch.add(row);
+        }
+        return result;
+    }
+
     private static TFetchSchemaTableDataResult 
roleMappingsMetadataResult(TSchemaTableRequestParams params) {
         if (!params.isSetCurrentUserIdent()) {
             return errorResult("current user ident is not set.");
diff --git a/fe/fe-extension-loader/pom.xml b/fe/fe-extension-loader/pom.xml
index 71ca2563ef4..22ecc4329ea 100644
--- a/fe/fe-extension-loader/pom.xml
+++ b/fe/fe-extension-loader/pom.xml
@@ -35,5 +35,15 @@ under the License.
             <artifactId>fe-extension-spi</artifactId>
             <version>${revision}</version>
         </dependency>
+        <!-- Load/registration observability (PluginRegistry); version managed 
by the fe parent. -->
+        <dependency>
+            <groupId>org.apache.logging.log4j</groupId>
+            <artifactId>log4j-api</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.junit.jupiter</groupId>
+            <artifactId>junit-jupiter</artifactId>
+            <scope>test</scope>
+        </dependency>
     </dependencies>
 </project>
diff --git 
a/fe/fe-extension-loader/src/main/java/org/apache/doris/extension/loader/DirectoryPluginRuntimeManager.java
 
b/fe/fe-extension-loader/src/main/java/org/apache/doris/extension/loader/DirectoryPluginRuntimeManager.java
index dabcb47cb06..7e01d885c37 100644
--- 
a/fe/fe-extension-loader/src/main/java/org/apache/doris/extension/loader/DirectoryPluginRuntimeManager.java
+++ 
b/fe/fe-extension-loader/src/main/java/org/apache/doris/extension/loader/DirectoryPluginRuntimeManager.java
@@ -36,6 +36,7 @@ import java.util.Objects;
 import java.util.Optional;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ConcurrentMap;
+import java.util.jar.JarFile;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
@@ -151,6 +152,21 @@ public class DirectoryPluginRuntimeManager<F extends 
PluginFactory> {
         return Optional.ofNullable(handlesByName.get(pluginName));
     }
 
+    /**
+     * Removes a loaded handle and closes its classloader. For callers that 
reject
+     * a successfully loaded plugin after the fact (e.g. a family-level name
+     * conflict with an already registered provider); without this the factory 
and
+     * its classloader would stay strongly retained for the FE lifetime.
+     */
+    public void discard(String pluginName) {
+        synchronized (lifecycleLock) {
+            PluginHandle<F> handle = handlesByName.remove(pluginName);
+            if (handle != null) {
+                closeClassLoader(handle.getClassLoader());
+            }
+        }
+    }
+
     public List<PluginHandle<F>> list() {
         Collection<PluginHandle<F>> handles = handlesByName.values();
         List<PluginHandle<F>> results = new ArrayList<>(handles);
@@ -254,10 +270,12 @@ public class DirectoryPluginRuntimeManager<F extends 
PluginFactory> {
             throw e;
         }
 
+        // Snapshot self-reported metadata once at load time. A throwing or 
invalid
+        // self-report is a load failure; query paths never re-invoke plugin 
code.
         String pluginName;
         try {
             pluginName = factory.name();
-        } catch (RuntimeException e) {
+        } catch (RuntimeException | LinkageError e) {
             closeClassLoader(classLoader);
             throw new PluginLoadException(
                     normalizedDir,
@@ -265,22 +283,74 @@ public class DirectoryPluginRuntimeManager<F extends 
PluginFactory> {
                     "Failed to get plugin name from discovered factory in " + 
normalizedDir,
                     e);
         }
-        if (pluginName == null || pluginName.trim().isEmpty()) {
+        String nameValidationError = PluginNames.validate(pluginName);
+        if (nameValidationError != null) {
             closeClassLoader(classLoader);
             throw new PluginLoadException(
                     normalizedDir,
                     LoadFailure.STAGE_INSTANTIATE,
-                    "Plugin name is empty for directory: " + normalizedDir,
+                    "Invalid plugin name for directory " + normalizedDir + ": 
" + nameValidationError,
                     null);
         }
 
+        // LinkageError included: a factory jar with a broken classpath (or one
+        // compiled against a conflicting interface) must fail this plugin 
only,
+        // never escape per-plugin isolation.
+        String description;
+        try {
+            description = factory.description();
+        } catch (RuntimeException | LinkageError e) {
+            closeClassLoader(classLoader);
+            throw new PluginLoadException(
+                    normalizedDir,
+                    LoadFailure.STAGE_INSTANTIATE,
+                    "Failed to get plugin description from discovered factory 
in " + normalizedDir,
+                    e);
+        }
+
+        String version = readImplementationVersion(factory.getClass(), 
allJars);
+
         return new PluginHandle<>(
                 pluginName.trim(),
                 normalizedDir,
                 allJars,
                 classLoader,
                 factory,
-                Instant.now());
+                Instant.now(),
+                description,
+                version);
+    }
+
+    /**
+     * Reads Implementation-Version from the MANIFEST of the jar that defined 
the
+     * factory class: the class's code source when available (covers layouts 
where
+     * the service descriptor sits in a root jar but the implementation lives 
in
+     * lib/), otherwise the first candidate jar containing the class entry.
+     * Version is display-only metadata: failures degrade to null instead of
+     * failing the load.
+     */
+    private String readImplementationVersion(Class<?> factoryClass, List<Path> 
candidateJars) {
+        String packagePath = ManifestVersions.packagePathOf(factoryClass);
+        Path definingJar = ManifestVersions.jarOf(factoryClass);
+        if (definingJar != null) {
+            try (JarFile jarFile = new JarFile(definingJar.toFile())) {
+                return ManifestVersions.fromManifest(jarFile, packagePath);
+            } catch (IOException ignored) {
+                // Fall through to scanning the candidate jars.
+            }
+        }
+        String classEntry = factoryClass.getName().replace('.', '/') + 
".class";
+        for (Path jar : candidateJars) {
+            try (JarFile jarFile = new JarFile(jar.toFile())) {
+                if (jarFile.getEntry(classEntry) == null) {
+                    continue;
+                }
+                return ManifestVersions.fromManifest(jarFile, packagePath);
+            } catch (IOException ignored) {
+                // Display-only metadata; fall through to the next candidate 
jar.
+            }
+        }
+        return null;
     }
 
     /**
diff --git 
a/fe/fe-extension-loader/src/main/java/org/apache/doris/extension/loader/ManifestVersions.java
 
b/fe/fe-extension-loader/src/main/java/org/apache/doris/extension/loader/ManifestVersions.java
new file mode 100644
index 00000000000..ff0a6cdd4ea
--- /dev/null
+++ 
b/fe/fe-extension-loader/src/main/java/org/apache/doris/extension/loader/ManifestVersions.java
@@ -0,0 +1,92 @@
+// 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.extension.loader;
+
+import java.io.IOException;
+import java.net.URISyntaxException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.security.CodeSource;
+import java.util.jar.Attributes;
+import java.util.jar.JarFile;
+import java.util.jar.Manifest;
+
+/**
+ * Reads plugin release versions from jar MANIFEST Implementation-Version.
+ *
+ * <p>Always resolves the jar that actually defined the class (its code
+ * source) instead of {@link Package#getImplementationVersion()}: Package
+ * metadata is fixed per package name and classloader by whichever jar defines
+ * the package first, so with two same-package factories from different jars
+ * the later one would report the earlier jar's version.
+ */
+final class ManifestVersions {
+
+    private ManifestVersions() {
+    }
+
+    /**
+     * Implementation-Version from the manifest of one jar, honoring the 
class's
+     * package section first.
+     *
+     * <p>Per the jar spec a package section ("Name: com/acme/plugin/") 
overrides
+     * the main attributes for classes in that package, mirroring
+     * {@code Package.getImplementationVersion()}.
+     *
+     * @param packagePath manifest section name from {@link #packagePathOf}, 
may be null
+     * @return the version, or null when the manifest or the attribute is 
absent
+     */
+    static String fromManifest(JarFile jarFile, String packagePath) throws 
IOException {
+        Manifest manifest = jarFile.getManifest();
+        if (manifest == null) {
+            return null;
+        }
+        if (packagePath != null) {
+            Attributes packageAttributes = manifest.getAttributes(packagePath);
+            if (packageAttributes != null) {
+                String version = 
packageAttributes.getValue(Attributes.Name.IMPLEMENTATION_VERSION);
+                if (version != null) {
+                    return version;
+                }
+            }
+        }
+        return 
manifest.getMainAttributes().getValue(Attributes.Name.IMPLEMENTATION_VERSION);
+    }
+
+    /** Manifest section name for the class's package ("com/acme/plugin/"), or 
null. */
+    static String packagePathOf(Class<?> clazz) {
+        String className = clazz.getName();
+        int lastDot = className.lastIndexOf('.');
+        return lastDot < 0 ? null : className.substring(0, 
lastDot).replace('.', '/') + "/";
+    }
+
+    /** Jar file that defined the class per its protection domain, or null. */
+    static Path jarOf(Class<?> clazz) {
+        try {
+            CodeSource codeSource = 
clazz.getProtectionDomain().getCodeSource();
+            if (codeSource == null || codeSource.getLocation() == null) {
+                return null;
+            }
+            Path path = Paths.get(codeSource.getLocation().toURI());
+            return Files.isRegularFile(path) ? path : null;
+        } catch (URISyntaxException | RuntimeException e) {
+            return null;
+        }
+    }
+}
diff --git 
a/fe/fe-extension-loader/src/main/java/org/apache/doris/extension/loader/PluginHandle.java
 
b/fe/fe-extension-loader/src/main/java/org/apache/doris/extension/loader/PluginHandle.java
index 64d5056439a..052ae29c6f3 100644
--- 
a/fe/fe-extension-loader/src/main/java/org/apache/doris/extension/loader/PluginHandle.java
+++ 
b/fe/fe-extension-loader/src/main/java/org/apache/doris/extension/loader/PluginHandle.java
@@ -37,9 +37,17 @@ public final class PluginHandle<F extends PluginFactory> {
     private final ClassLoader classLoader;
     private final F factory;
     private final Instant loadedAt;
+    private final String description;
+    private final String version;
 
     public PluginHandle(String pluginName, Path pluginDir, List<Path> 
resolvedJars,
             ClassLoader classLoader, F factory, Instant loadedAt) {
+        this(pluginName, pluginDir, resolvedJars, classLoader, factory, 
loadedAt, "", null);
+    }
+
+    public PluginHandle(String pluginName, Path pluginDir, List<Path> 
resolvedJars,
+            ClassLoader classLoader, F factory, Instant loadedAt,
+            String description, String version) {
         this.pluginName = requireNonBlank(pluginName, "pluginName");
         this.pluginDir = Objects.requireNonNull(pluginDir, "pluginDir");
         this.resolvedJars = Collections.unmodifiableList(new ArrayList<>(
@@ -47,6 +55,8 @@ public final class PluginHandle<F extends PluginFactory> {
         this.classLoader = Objects.requireNonNull(classLoader, "classLoader");
         this.factory = Objects.requireNonNull(factory, "factory");
         this.loadedAt = Objects.requireNonNull(loadedAt, "loadedAt");
+        this.description = description == null ? "" : description;
+        this.version = version;
     }
 
     public String getPluginName() {
@@ -73,6 +83,16 @@ public final class PluginHandle<F extends PluginFactory> {
         return loadedAt;
     }
 
+    /** Load-time snapshot of {@code factory.description()}; never re-invokes 
plugin code. */
+    public String getDescription() {
+        return description;
+    }
+
+    /** Plugin version from the Implementation-Version of the jar containing 
the factory, may be null. */
+    public String getVersion() {
+        return version;
+    }
+
     private static String requireNonBlank(String value, String fieldName) {
         Objects.requireNonNull(value, fieldName);
         String trimmed = value.trim();
diff --git 
a/fe/fe-extension-loader/src/main/java/org/apache/doris/extension/loader/PluginNames.java
 
b/fe/fe-extension-loader/src/main/java/org/apache/doris/extension/loader/PluginNames.java
new file mode 100644
index 00000000000..08ddf3134dd
--- /dev/null
+++ 
b/fe/fe-extension-loader/src/main/java/org/apache/doris/extension/loader/PluginNames.java
@@ -0,0 +1,62 @@
+// 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.extension.loader;
+
+/**
+ * Validation rules for self-reported plugin names.
+ *
+ * <p>A plugin name is a configuration-facing identity key, so it is validated
+ * at load time: non-blank after trimming, bounded length, restricted charset.
+ * An invalid name is treated as a load failure by the loading side.
+ */
+final class PluginNames {
+
+    static final int MAX_LENGTH = 64;
+
+    private PluginNames() {
+    }
+
+    /**
+     * Validates a self-reported plugin name.
+     *
+     * @param name raw value returned by {@code PluginFactory.name()}
+     * @return null if valid, otherwise a human-readable rejection reason
+     */
+    static String validate(String name) {
+        if (name == null) {
+            return "plugin name is null";
+        }
+        String trimmed = name.trim();
+        if (trimmed.isEmpty()) {
+            return "plugin name is blank";
+        }
+        if (trimmed.length() > MAX_LENGTH) {
+            return "plugin name exceeds " + MAX_LENGTH + " characters";
+        }
+        for (int i = 0; i < trimmed.length(); i++) {
+            char c = trimmed.charAt(i);
+            boolean allowed = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') 
|| (c >= '0' && c <= '9')
+                    || c == '.' || c == '_' || c == '-';
+            if (!allowed) {
+                return "plugin name contains illegal character '" + c
+                        + "', allowed: letters, digits, '.', '_', '-'";
+            }
+        }
+        return null;
+    }
+}
diff --git 
a/fe/fe-extension-loader/src/main/java/org/apache/doris/extension/loader/PluginRegistry.java
 
b/fe/fe-extension-loader/src/main/java/org/apache/doris/extension/loader/PluginRegistry.java
new file mode 100644
index 00000000000..03c0c4c1e62
--- /dev/null
+++ 
b/fe/fe-extension-loader/src/main/java/org/apache/doris/extension/loader/PluginRegistry.java
@@ -0,0 +1,219 @@
+// 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.extension.loader;
+
+import org.apache.doris.extension.spi.PluginFactory;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.jar.JarFile;
+
+/**
+ * Process-wide registry of loaded plugins across all plugin families
+ * (filesystem, connector, authentication, lineage, ...).
+ *
+ * <p>This is the single fact source behind {@code 
information_schema.extensions}.
+ * It only stores load-time snapshots (plain strings); listing the registry
+ * never executes plugin code.
+ *
+ * <p>Rules:
+ * <ul>
+ *   <li>Primary key is {@code (type, name)}. The first registration wins;
+ *       later registrations with the same key are rejected, never silently
+ *       overridden. Family managers register built-ins before external 
plugins,
+ *       so a directory jar can never displace a built-in row.</li>
+ *   <li>Rows only exist for successfully loaded plugins. Load failures are
+ *       reported via logs by the loading side and never enter the 
registry.</li>
+ * </ul>
+ */
+public final class PluginRegistry {
+
+    /** Where a plugin was loaded from. */
+    public enum PluginSource {
+        /** Bundled on the FE classpath, discovered via ServiceLoader. */
+        BUILTIN,
+        /** Loaded from a plugin directory jar. */
+        EXTERNAL
+    }
+
+    /** Immutable load-time snapshot of one plugin. */
+    public static final class PluginRecord {
+        private final String type;
+        private final String name;
+        private final String version;
+        private final String description;
+        private final PluginSource source;
+        private final Instant loadTime;
+
+        public PluginRecord(String type, String name, String version, String 
description,
+                PluginSource source, Instant loadTime) {
+            this.type = Objects.requireNonNull(type, "plugin record requires a 
non-null family type");
+            this.name = Objects.requireNonNull(name, "plugin record requires a 
non-null plugin name");
+            this.version = version;
+            this.description = description == null ? "" : description;
+            this.source = Objects.requireNonNull(source, "plugin record 
requires a non-null source");
+            this.loadTime = Objects.requireNonNull(loadTime, "plugin record 
requires a non-null load time");
+        }
+
+        public String getType() {
+            return type;
+        }
+
+        public String getName() {
+            return name;
+        }
+
+        /** Plugin release version from jar MANIFEST Implementation-Version, 
may be null. */
+        public String getVersion() {
+            return version;
+        }
+
+        public String getDescription() {
+            return description;
+        }
+
+        public PluginSource getSource() {
+            return source;
+        }
+
+        public Instant getLoadTime() {
+            return loadTime;
+        }
+    }
+
+    private static final Logger LOG = 
LogManager.getLogger(PluginRegistry.class);
+
+    private static final PluginRegistry INSTANCE = new PluginRegistry();
+
+    private final ConcurrentMap<String, PluginRecord> recordsByKey = new 
ConcurrentHashMap<>();
+
+    private PluginRegistry() {
+    }
+
+    public static PluginRegistry getInstance() {
+        return INSTANCE;
+    }
+
+    /**
+     * Registers a load-time snapshot for one plugin.
+     *
+     * <p>Rejects (returns false) when the name is invalid or the
+     * {@code (type, name)} key is already taken. Rejection never throws so a
+     * bad self-reported name cannot break FE startup paths that register
+     * built-ins; callers decide how to react.
+     *
+     * @param type plugin family label, e.g. "FILESYSTEM", "AUTHENTICATION"
+     * @param name plugin identity within the family
+     * @param version plugin release version, may be null when unknown
+     * @param description one-line description, may be null
+     * @param source BUILTIN or EXTERNAL
+     * @return true if the record was inserted, false if rejected
+     */
+    public boolean register(String type, String name, String version, String 
description, PluginSource source) {
+        if (type == null || type.trim().isEmpty()) {
+            LOG.warn("Reject plugin registration with null or blank family 
type: name={}", name);
+            return false;
+        }
+        String validationError = PluginNames.validate(name);
+        if (validationError != null) {
+            LOG.warn("Reject plugin registration with invalid name: type={}, 
name={}, reason={}",
+                    type, name, validationError);
+            return false;
+        }
+        PluginRecord record = new PluginRecord(type, name.trim(), version, 
description, source, Instant.now());
+        PluginRecord existing = recordsByKey.putIfAbsent(key(record.getType(), 
record.getName()), record);
+        if (existing != null) {
+            // The same plugin may be legitimately loaded by more than one 
manager
+            // instance within a family (e.g. authentication); keep the first 
row.
+            LOG.warn("Skip duplicated plugin registration: type={}, name={}, 
existingSource={}, newSource={}",
+                    record.getType(), record.getName(), existing.getSource(), 
source);
+            return false;
+        }
+        LOG.info("Registered plugin: type={}, name={}, version={}, source={}",
+                record.getType(), record.getName(), version, source);
+        return true;
+    }
+
+    /**
+     * Registers a classpath built-in factory. Snapshots {@code name()} and
+     * {@code description()} now; the version comes from the 
Implementation-Version
+     * of the jar that bundles the factory class, when available.
+     */
+    public boolean registerBuiltin(String type, PluginFactory factory) {
+        return register(type, factory.name(), 
implementationVersionOf(factory.getClass()),
+                factory.description(), PluginSource.BUILTIN);
+    }
+
+    /** Registers a directory-loaded plugin from its load-time handle 
snapshot. */
+    public boolean registerExternal(String type, PluginHandle<?> handle) {
+        return register(type, handle.getPluginName(), handle.getVersion(),
+                handle.getDescription(), PluginSource.EXTERNAL);
+    }
+
+    /** Returns a snapshot of all registered plugins sorted by (type, name). */
+    public List<PluginRecord> list() {
+        List<PluginRecord> records = new ArrayList<>(recordsByKey.values());
+        
records.sort(Comparator.comparing(PluginRecord::getType).thenComparing(PluginRecord::getName));
+        return records;
+    }
+
+    /** Removes all records. Only for tests. */
+    public void clearForTest() {
+        recordsByKey.clear();
+    }
+
+    /**
+     * Best-effort release version of a classpath (built-in) plugin, from the
+     * Implementation-Version of the jar that defined the given class.
+     *
+     * <p>Reads the defining jar's manifest directly instead of
+     * {@code Package.getImplementationVersion()}: Package metadata is fixed by
+     * whichever jar defines the package first on a classloader, so with two
+     * same-package factories from different jars the later plugin would report
+     * the earlier jar's version. Falls back to Package metadata only when the
+     * defining jar cannot be determined (e.g. classes directory in tests).
+     *
+     * @return the version, or null when unavailable
+     */
+    public static String implementationVersionOf(Class<?> clazz) {
+        Path definingJar = ManifestVersions.jarOf(clazz);
+        if (definingJar != null) {
+            try (JarFile jarFile = new JarFile(definingJar.toFile())) {
+                return ManifestVersions.fromManifest(jarFile, 
ManifestVersions.packagePathOf(clazz));
+            } catch (IOException e) {
+                return null;
+            }
+        }
+        Package pkg = clazz.getPackage();
+        return pkg == null ? null : pkg.getImplementationVersion();
+    }
+
+    private static String key(String type, String name) {
+        return type + " " + name;
+    }
+}
diff --git 
a/fe/fe-extension-loader/src/test/java/org/apache/doris/extension/loader/DirectoryPluginRuntimeManagerMetadataTest.java
 
b/fe/fe-extension-loader/src/test/java/org/apache/doris/extension/loader/DirectoryPluginRuntimeManagerMetadataTest.java
new file mode 100644
index 00000000000..19db17d11ef
--- /dev/null
+++ 
b/fe/fe-extension-loader/src/test/java/org/apache/doris/extension/loader/DirectoryPluginRuntimeManagerMetadataTest.java
@@ -0,0 +1,143 @@
+// 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.extension.loader;
+
+import org.apache.doris.extension.loader.testplugins.BadNameTestPluginFactory;
+import org.apache.doris.extension.loader.testplugins.MetadataTestPluginFactory;
+import org.apache.doris.extension.spi.PluginFactory;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Collections;
+import java.util.List;
+import java.util.jar.Attributes;
+import java.util.jar.JarEntry;
+import java.util.jar.JarOutputStream;
+import java.util.jar.Manifest;
+
+/**
+ * Verifies load-time metadata snapshotting: MANIFEST Implementation-Version,
+ * description snapshot, and self-reported name validation.
+ */
+class DirectoryPluginRuntimeManagerMetadataTest {
+
+    @TempDir
+    Path tempDir;
+
+    @Test
+    void testManifestVersionAndDescriptionSnapshot() throws IOException {
+        Path root = tempDir.resolve("root");
+        
createPluginJar(root.resolve("metadata-test").resolve("metadata-test.jar"),
+                MetadataTestPluginFactory.class, "3.1.4");
+
+        DirectoryPluginRuntimeManager<PluginFactory> manager = new 
DirectoryPluginRuntimeManager<>();
+        LoadReport<PluginFactory> report = manager.loadAll(
+                Collections.singletonList(root),
+                Thread.currentThread().getContextClassLoader(),
+                PluginFactory.class,
+                null);
+
+        Assertions.assertEquals(1, report.getSuccesses().size(), () -> 
failures(report));
+        PluginHandle<PluginFactory> handle = report.getSuccesses().get(0);
+        Assertions.assertEquals("metadata-test", handle.getPluginName());
+        Assertions.assertEquals("Metadata snapshot test plugin", 
handle.getDescription());
+        Assertions.assertEquals("3.1.4", handle.getVersion());
+    }
+
+    @Test
+    void testMissingManifestVersionDegradesToNull() throws IOException {
+        Path root = tempDir.resolve("root-no-version");
+        
createPluginJar(root.resolve("metadata-test").resolve("metadata-test.jar"),
+                MetadataTestPluginFactory.class, null);
+
+        DirectoryPluginRuntimeManager<PluginFactory> manager = new 
DirectoryPluginRuntimeManager<>();
+        LoadReport<PluginFactory> report = manager.loadAll(
+                Collections.singletonList(root),
+                Thread.currentThread().getContextClassLoader(),
+                PluginFactory.class,
+                null);
+
+        Assertions.assertEquals(1, report.getSuccesses().size(), () -> 
failures(report));
+        Assertions.assertNull(report.getSuccesses().get(0).getVersion());
+    }
+
+    @Test
+    void testInvalidSelfReportedNameIsLoadFailure() throws IOException {
+        Path root = tempDir.resolve("root-bad-name");
+        createPluginJar(root.resolve("bad-name").resolve("bad-name.jar"),
+                BadNameTestPluginFactory.class, "1.0");
+
+        DirectoryPluginRuntimeManager<PluginFactory> manager = new 
DirectoryPluginRuntimeManager<>();
+        LoadReport<PluginFactory> report = manager.loadAll(
+                Collections.singletonList(root),
+                Thread.currentThread().getContextClassLoader(),
+                PluginFactory.class,
+                null);
+
+        Assertions.assertTrue(report.getSuccesses().isEmpty());
+        Assertions.assertEquals(1, report.getFailures().size());
+        
Assertions.assertTrue(report.getFailures().get(0).getMessage().contains("Invalid
 plugin name"),
+                () -> report.getFailures().get(0).getMessage());
+    }
+
+    /**
+     * Builds a plugin jar containing the factory's class bytes, a 
ServiceLoader
+     * registration, and (optionally) a MANIFEST Implementation-Version.
+     */
+    private static void createPluginJar(Path jarPath, Class<? extends 
PluginFactory> factoryClass,
+            String implementationVersion) throws IOException {
+        Files.createDirectories(jarPath.getParent());
+        Manifest manifest = new Manifest();
+        manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, 
"1.0");
+        if (implementationVersion != null) {
+            
manifest.getMainAttributes().put(Attributes.Name.IMPLEMENTATION_VERSION, 
implementationVersion);
+        }
+        String classEntry = factoryClass.getName().replace('.', '/') + 
".class";
+        try (JarOutputStream jar = new 
JarOutputStream(Files.newOutputStream(jarPath), manifest)) {
+            jar.putNextEntry(new JarEntry(classEntry));
+            try (InputStream classBytes = 
factoryClass.getClassLoader().getResourceAsStream(classEntry)) {
+                Assertions.assertNotNull(classBytes, "class bytes not found: " 
+ classEntry);
+                byte[] buffer = new byte[8192];
+                int read;
+                while ((read = classBytes.read(buffer)) != -1) {
+                    jar.write(buffer, 0, read);
+                }
+            }
+            jar.closeEntry();
+            jar.putNextEntry(new JarEntry("META-INF/services/" + 
PluginFactory.class.getName()));
+            jar.write((factoryClass.getName() + 
"\n").getBytes(StandardCharsets.UTF_8));
+            jar.closeEntry();
+        }
+    }
+
+    private static String failures(LoadReport<PluginFactory> report) {
+        StringBuilder sb = new StringBuilder("load failures:");
+        List<LoadFailure> failures = report.getFailures();
+        for (LoadFailure failure : failures) {
+            sb.append(" [").append(failure.getStage()).append("] 
").append(failure.getMessage());
+        }
+        return sb.toString();
+    }
+}
diff --git 
a/fe/fe-extension-loader/src/test/java/org/apache/doris/extension/loader/PluginRegistryTest.java
 
b/fe/fe-extension-loader/src/test/java/org/apache/doris/extension/loader/PluginRegistryTest.java
new file mode 100644
index 00000000000..716a8551eb6
--- /dev/null
+++ 
b/fe/fe-extension-loader/src/test/java/org/apache/doris/extension/loader/PluginRegistryTest.java
@@ -0,0 +1,145 @@
+// 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.extension.loader;
+
+import org.apache.doris.extension.loader.PluginRegistry.PluginRecord;
+import org.apache.doris.extension.loader.PluginRegistry.PluginSource;
+import org.apache.doris.extension.spi.Plugin;
+import org.apache.doris.extension.spi.PluginFactory;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+class PluginRegistryTest {
+
+    @BeforeEach
+    void setUp() {
+        PluginRegistry.getInstance().clearForTest();
+    }
+
+    @AfterEach
+    void tearDown() {
+        PluginRegistry.getInstance().clearForTest();
+    }
+
+    @Test
+    void testRegisterAndList() {
+        Assertions.assertTrue(PluginRegistry.getInstance()
+                .register("AUTHENTICATION", "ldap", "1.2.0", "LDAP 
authentication", PluginSource.EXTERNAL));
+
+        List<PluginRecord> records = PluginRegistry.getInstance().list();
+        Assertions.assertEquals(1, records.size());
+        PluginRecord record = records.get(0);
+        Assertions.assertEquals("AUTHENTICATION", record.getType());
+        Assertions.assertEquals("ldap", record.getName());
+        Assertions.assertEquals("1.2.0", record.getVersion());
+        Assertions.assertEquals("LDAP authentication", 
record.getDescription());
+        Assertions.assertEquals(PluginSource.EXTERNAL, record.getSource());
+        Assertions.assertNotNull(record.getLoadTime());
+    }
+
+    @Test
+    void testDuplicateKeyRejectedFirstWins() {
+        Assertions.assertTrue(PluginRegistry.getInstance()
+                .register("AUTHORIZATION", "ranger", "1.0", "builtin", 
PluginSource.BUILTIN));
+        Assertions.assertFalse(PluginRegistry.getInstance()
+                .register("AUTHORIZATION", "ranger", "9.9", "impostor", 
PluginSource.EXTERNAL));
+
+        List<PluginRecord> records = PluginRegistry.getInstance().list();
+        Assertions.assertEquals(1, records.size());
+        Assertions.assertEquals(PluginSource.BUILTIN, 
records.get(0).getSource());
+        Assertions.assertEquals("1.0", records.get(0).getVersion());
+    }
+
+    @Test
+    void testSameNameAcrossFamiliesIsLegal() {
+        Assertions.assertTrue(PluginRegistry.getInstance()
+                .register("AUTHENTICATION", "ranger", null, "", 
PluginSource.EXTERNAL));
+        Assertions.assertTrue(PluginRegistry.getInstance()
+                .register("AUTHORIZATION", "ranger", null, "", 
PluginSource.EXTERNAL));
+
+        Assertions.assertEquals(2, PluginRegistry.getInstance().list().size());
+    }
+
+    @Test
+    void testInvalidNameRejected() {
+        PluginRegistry registry = PluginRegistry.getInstance();
+        Assertions.assertFalse(registry.register("FILESYSTEM", null, null, "", 
PluginSource.BUILTIN));
+        Assertions.assertFalse(registry.register("FILESYSTEM", "   ", null, 
"", PluginSource.BUILTIN));
+        Assertions.assertFalse(registry.register("FILESYSTEM", "bad name", 
null, "", PluginSource.BUILTIN));
+        StringBuilder tooLong = new StringBuilder();
+        for (int i = 0; i < 65; i++) {
+            tooLong.append('a');
+        }
+        Assertions.assertFalse(registry.register("FILESYSTEM", 
tooLong.toString(), null, "", PluginSource.BUILTIN));
+        Assertions.assertTrue(registry.list().isEmpty());
+    }
+
+    @Test
+    void testNameTrimmedBeforeKeying() {
+        Assertions.assertTrue(PluginRegistry.getInstance()
+                .register("CONNECTOR", " hive ", null, "", 
PluginSource.BUILTIN));
+        Assertions.assertFalse(PluginRegistry.getInstance()
+                .register("CONNECTOR", "hive", null, "", 
PluginSource.EXTERNAL));
+        Assertions.assertEquals("hive", 
PluginRegistry.getInstance().list().get(0).getName());
+    }
+
+    @Test
+    void testListSortedByTypeThenName() {
+        PluginRegistry registry = PluginRegistry.getInstance();
+        registry.register("FILESYSTEM", "s3", null, "", PluginSource.BUILTIN);
+        registry.register("AUTHENTICATION", "oidc", null, "", 
PluginSource.BUILTIN);
+        registry.register("AUTHENTICATION", "ldap", null, "", 
PluginSource.BUILTIN);
+
+        List<PluginRecord> records = registry.list();
+        Assertions.assertEquals(3, records.size());
+        Assertions.assertEquals("ldap", records.get(0).getName());
+        Assertions.assertEquals("oidc", records.get(1).getName());
+        Assertions.assertEquals("s3", records.get(2).getName());
+    }
+
+    @Test
+    void testRegisterBuiltinSnapshotsFactoryMetadata() {
+        
Assertions.assertTrue(PluginRegistry.getInstance().registerBuiltin("LINEAGE", 
new PluginFactory() {
+            @Override
+            public String name() {
+                return "console";
+            }
+
+            @Override
+            public String description() {
+                return "Console lineage sink";
+            }
+
+            @Override
+            public Plugin create() {
+                return null;
+            }
+        }));
+
+        PluginRecord record = PluginRegistry.getInstance().list().get(0);
+        Assertions.assertEquals("LINEAGE", record.getType());
+        Assertions.assertEquals("console", record.getName());
+        Assertions.assertEquals("Console lineage sink", 
record.getDescription());
+        Assertions.assertEquals(PluginSource.BUILTIN, record.getSource());
+    }
+}
diff --git 
a/fe/fe-extension-spi/src/main/java/org/apache/doris/extension/spi/PluginFactory.java
 
b/fe/fe-extension-loader/src/test/java/org/apache/doris/extension/loader/testplugins/BadNameTestPluginFactory.java
similarity index 53%
copy from 
fe/fe-extension-spi/src/main/java/org/apache/doris/extension/spi/PluginFactory.java
copy to 
fe/fe-extension-loader/src/test/java/org/apache/doris/extension/loader/testplugins/BadNameTestPluginFactory.java
index 5e915b6f83f..4676338002d 100644
--- 
a/fe/fe-extension-spi/src/main/java/org/apache/doris/extension/spi/PluginFactory.java
+++ 
b/fe/fe-extension-loader/src/test/java/org/apache/doris/extension/loader/testplugins/BadNameTestPluginFactory.java
@@ -15,37 +15,24 @@
 // specific language governing permissions and limitations
 // under the License.
 
-package org.apache.doris.extension.spi;
+package org.apache.doris.extension.loader.testplugins;
+
+import org.apache.doris.extension.spi.Plugin;
+import org.apache.doris.extension.spi.PluginFactory;
 
 /**
- * Factory for creating plugin instances.
- *
- * <p>Factories are discovered by {@code ServiceLoader} in the loader 
module.</p>
+ * Test factory whose self-reported name violates the load-time charset rule.
  */
-public interface PluginFactory {
-
-    /**
-     * Returns the plugin identifier for this factory.
-     *
-     * @return plugin name
-     */
-    String name();
+public class BadNameTestPluginFactory implements PluginFactory {
 
-    /**
-     * Create a new plugin instance.
-     *
-     * @return plugin instance
-     */
-    Plugin create();
+    @Override
+    public String name() {
+        return "bad name!";
+    }
 
-    /**
-     * Create a new plugin instance with runtime context.
-     * Default implementation delegates to {@link #create()}.
-     *
-     * @param context plugin context
-     * @return plugin instance
-     */
-    default Plugin create(PluginContext context) {
-        return create();
+    @Override
+    public Plugin create() {
+        return new Plugin() {
+        };
     }
 }
diff --git 
a/fe/fe-extension-spi/src/main/java/org/apache/doris/extension/spi/PluginFactory.java
 
b/fe/fe-extension-loader/src/test/java/org/apache/doris/extension/loader/testplugins/MetadataTestPluginFactory.java
similarity index 53%
copy from 
fe/fe-extension-spi/src/main/java/org/apache/doris/extension/spi/PluginFactory.java
copy to 
fe/fe-extension-loader/src/test/java/org/apache/doris/extension/loader/testplugins/MetadataTestPluginFactory.java
index 5e915b6f83f..0abf8ad9534 100644
--- 
a/fe/fe-extension-spi/src/main/java/org/apache/doris/extension/spi/PluginFactory.java
+++ 
b/fe/fe-extension-loader/src/test/java/org/apache/doris/extension/loader/testplugins/MetadataTestPluginFactory.java
@@ -15,37 +15,30 @@
 // specific language governing permissions and limitations
 // under the License.
 
-package org.apache.doris.extension.spi;
+package org.apache.doris.extension.loader.testplugins;
+
+import org.apache.doris.extension.spi.Plugin;
+import org.apache.doris.extension.spi.PluginFactory;
 
 /**
- * Factory for creating plugin instances.
- *
- * <p>Factories are discovered by {@code ServiceLoader} in the loader 
module.</p>
+ * Well-behaved test factory; its class bytes are copied into fabricated plugin
+ * jars by loader tests.
  */
-public interface PluginFactory {
+public class MetadataTestPluginFactory implements PluginFactory {
 
-    /**
-     * Returns the plugin identifier for this factory.
-     *
-     * @return plugin name
-     */
-    String name();
+    @Override
+    public String name() {
+        return "metadata-test";
+    }
 
-    /**
-     * Create a new plugin instance.
-     *
-     * @return plugin instance
-     */
-    Plugin create();
+    @Override
+    public String description() {
+        return "Metadata snapshot test plugin";
+    }
 
-    /**
-     * Create a new plugin instance with runtime context.
-     * Default implementation delegates to {@link #create()}.
-     *
-     * @param context plugin context
-     * @return plugin instance
-     */
-    default Plugin create(PluginContext context) {
-        return create();
+    @Override
+    public Plugin create() {
+        return new Plugin() {
+        };
     }
 }
diff --git 
a/fe/fe-extension-spi/src/main/java/org/apache/doris/extension/spi/PluginFactory.java
 
b/fe/fe-extension-spi/src/main/java/org/apache/doris/extension/spi/PluginFactory.java
index 5e915b6f83f..b80d0dcfd19 100644
--- 
a/fe/fe-extension-spi/src/main/java/org/apache/doris/extension/spi/PluginFactory.java
+++ 
b/fe/fe-extension-spi/src/main/java/org/apache/doris/extension/spi/PluginFactory.java
@@ -31,6 +31,17 @@ public interface PluginFactory {
      */
     String name();
 
+    /**
+     * Returns a one-line human-readable description of the plugin.
+     *
+     * <p>Snapshotted once at load time by the loader; never invoked at query 
time.</p>
+     *
+     * @return plugin description, empty by default
+     */
+    default String description() {
+        return "";
+    }
+
     /**
      * Create a new plugin instance.
      *
diff --git a/gensrc/thrift/Descriptors.thrift b/gensrc/thrift/Descriptors.thrift
index ca20687d9a9..46c84d28266 100644
--- a/gensrc/thrift/Descriptors.thrift
+++ b/gensrc/thrift/Descriptors.thrift
@@ -220,6 +220,7 @@ enum TSchemaTableType {
     SCH_BE_COMPACTION_TASKS = 70;
     SCH_ROLE_MAPPINGS = 71;
     SCH_BACKEND_MS_RPC_TABLE_THROTTLERS = 72;
+    SCH_EXTENSIONS = 73;
 }
 
 enum THdfsCompression {
diff --git a/gensrc/thrift/FrontendService.thrift 
b/gensrc/thrift/FrontendService.thrift
index 33c0a117821..39bdbf022fa 100644
--- a/gensrc/thrift/FrontendService.thrift
+++ b/gensrc/thrift/FrontendService.thrift
@@ -916,6 +916,7 @@ enum TSchemaTableName {
   TABLE_STREAMS = 15,
   TABLE_STREAM_CONSUMPTION = 16,
   ROLE_MAPPINGS = 17,
+  EXTENSIONS = 18,
 }
 
 struct TMetadataTableRequestParams {
diff --git 
a/regression-test/suites/query_p0/schema_table/test_extensions_schema.groovy 
b/regression-test/suites/query_p0/schema_table/test_extensions_schema.groovy
new file mode 100644
index 00000000000..ba007bf8313
--- /dev/null
+++ b/regression-test/suites/query_p0/schema_table/test_extensions_schema.groovy
@@ -0,0 +1,81 @@
+// 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.
+
+import org.junit.Assert
+
+suite("test_extensions_schema", "p0") {
+    // Schema check: fixed five columns.
+    def schema = sql "DESC information_schema.extensions"
+    def columnNames = schema.collect { it[0] }
+    Assert.assertEquals(
+            ["EXTENSION_NAME", "EXTENSION_TYPE", "EXTENSION_VERSION", 
"SOURCE", "DESCRIPTION"], columnNames)
+
+    // Loaded extensions must be listed. Do not require BUILTIN
+    // rows: the packaged cluster deploys filesystem/connector providers as
+    // directory extensions (source = EXTERNAL), so a correct inventory may 
contain
+    // no BUILTIN row at all.
+    def rows = sql """
+        SELECT EXTENSION_NAME, EXTENSION_TYPE, SOURCE, EXTENSION_VERSION
+        FROM information_schema.extensions
+        ORDER BY EXTENSION_TYPE, EXTENSION_NAME
+    """
+    Assert.assertTrue("expect at least one extension row", rows.size() > 0)
+    rows.each { row ->
+        Assert.assertTrue(row[2] == "BUILTIN" || row[2] == "EXTERNAL")
+        // Unknown versions must surface as SQL NULL, never as an empty string.
+        Assert.assertTrue("EXTENSION_VERSION must be NULL or non-empty, got 
''",
+                row[3] == null || row[3].toString().length() > 0)
+    }
+
+    // (type, name) is the primary key: no duplicates may appear.
+    def keys = rows.collect { "${it[1]}|${it[0]}".toString() }
+    Assert.assertEquals(keys.size(), keys.unique(false).size())
+
+    // Filter by family works.
+    def fsRows = sql """
+        SELECT EXTENSION_NAME FROM information_schema.extensions WHERE 
EXTENSION_TYPE = 'FILESYSTEM'
+    """
+    Assert.assertTrue("expect built-in filesystem providers", fsRows.size() > 
0)
+
+    // The inventory is not ADMIN-gated: a plain user sees the same rows.
+    String user = "test_extensions_schema_user"
+    String pwd = "C123_567p"
+    try_sql("DROP USER IF EXISTS ${user}")
+    sql "CREATE USER ${user} IDENTIFIED BY '${pwd}'"
+    // The JDBC URL carries a default database; without a grant on it the
+    // connection itself is refused before the query runs.
+    sql "GRANT SELECT_PRIV ON regression_test TO ${user}"
+    if (isCloudMode()) {
+        def clusters = sql " SHOW CLUSTERS; "
+        Assert.assertTrue(!clusters.isEmpty())
+        def validCluster = clusters[0][0]
+        sql """GRANT USAGE_PRIV ON CLUSTER `${validCluster}` TO ${user}"""
+    }
+    try {
+        connect(user, pwd, context.config.jdbcUrl) {
+            def result = sql """
+                SELECT EXTENSION_NAME, EXTENSION_TYPE, SOURCE, 
EXTENSION_VERSION
+                FROM information_schema.extensions
+                ORDER BY EXTENSION_TYPE, EXTENSION_NAME
+            """
+            Assert.assertEquals("a non-admin user must see the full inventory",
+                    rows.size(), result.size())
+        }
+    } finally {
+        try_sql("DROP USER IF EXISTS ${user}")
+    }
+}


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


Reply via email to