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, ¶m, &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, ¶m, &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, ¶m, &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]