فهرست منبع

Register stable plugins in ActionModule (#90067)

Stable plugins are using @Extensible and @NamedComponents annotations
to mark components to be loaded.
This commit is loading extensible classNames from extensibles.json and
named components from named_components.json

relates #88980
Przemyslaw Gomulka 3 سال پیش
والد
کامیت
e1d897f00b
26فایلهای تغییر یافته به همراه835 افزوده شده و 56 حذف شده
  1. 5 0
      docs/changelog/90067.yaml
  2. 2 1
      modules/analysis-common/src/test/java/org/elasticsearch/analysis/common/CompoundAnalysisTests.java
  3. 6 3
      modules/analysis-common/src/test/java/org/elasticsearch/analysis/common/EdgeNGramTokenizerTests.java
  4. 5 2
      modules/analysis-common/src/test/java/org/elasticsearch/analysis/common/MultiplexerTokenFilterTests.java
  5. 6 1
      modules/analysis-common/src/test/java/org/elasticsearch/analysis/common/PredicateTokenScriptFilterTests.java
  6. 6 1
      modules/analysis-common/src/test/java/org/elasticsearch/analysis/common/ScriptedConditionTokenFilterTests.java
  7. 5 2
      modules/analysis-common/src/test/java/org/elasticsearch/analysis/common/WordDelimiterGraphTokenFilterFactoryTests.java
  8. 4 0
      server/src/main/java/org/elasticsearch/common/NamedRegistry.java
  9. 32 9
      server/src/main/java/org/elasticsearch/indices/analysis/AnalysisModule.java
  10. 213 0
      server/src/main/java/org/elasticsearch/indices/analysis/wrappers/StableApiWrappers.java
  11. 5 1
      server/src/main/java/org/elasticsearch/node/Node.java
  12. 9 0
      server/src/main/java/org/elasticsearch/plugins/PluginsService.java
  13. 1 1
      server/src/main/java/org/elasticsearch/plugins/scanners/PluginInfo.java
  14. 12 7
      server/src/main/java/org/elasticsearch/plugins/scanners/StablePluginsRegistry.java
  15. 2 1
      server/src/test/java/org/elasticsearch/action/admin/indices/TransportAnalyzeActionTests.java
  16. 11 4
      server/src/test/java/org/elasticsearch/index/analysis/AnalysisRegistryTests.java
  17. 201 5
      server/src/test/java/org/elasticsearch/indices/analysis/AnalysisModuleTests.java
  18. 276 0
      server/src/test/java/org/elasticsearch/indices/analysis/wrappers/StableApiWrappersTests.java
  19. 11 10
      server/src/test/java/org/elasticsearch/plugins/scanners/StablePluginsRegistryTests.java
  20. 2 1
      server/src/test/java/org/elasticsearch/snapshots/SnapshotResiliencyTests.java
  21. 6 2
      test/framework/src/main/java/org/elasticsearch/index/analysis/AnalysisTestsHelper.java
  22. 6 1
      test/framework/src/main/java/org/elasticsearch/test/AbstractBuilderTestCase.java
  23. 2 1
      test/framework/src/main/java/org/elasticsearch/test/ESTestCase.java
  24. 3 1
      x-pack/plugin/ml/src/test/java/org/elasticsearch/xpack/ml/aggs/categorization/CategorizeTextAggregatorTests.java
  25. 2 1
      x-pack/plugin/ml/src/test/java/org/elasticsearch/xpack/ml/aggs/categorization/TokenListCategorizerTests.java
  26. 2 1
      x-pack/plugin/ml/src/test/java/org/elasticsearch/xpack/ml/job/categorization/CategorizationAnalyzerTests.java

+ 5 - 0
docs/changelog/90067.yaml

@@ -0,0 +1,5 @@
+pr: 90067
+summary: Register stable plugins in `ActionModule`
+area: Infra/Plugins
+type: enhancement
+issues: []

+ 2 - 1
modules/analysis-common/src/test/java/org/elasticsearch/analysis/common/CompoundAnalysisTests.java

@@ -23,6 +23,7 @@ import org.elasticsearch.index.analysis.TokenFilterFactory;
 import org.elasticsearch.indices.analysis.AnalysisModule;
 import org.elasticsearch.indices.analysis.AnalysisModule.AnalysisProvider;
 import org.elasticsearch.plugins.AnalysisPlugin;
+import org.elasticsearch.plugins.scanners.StablePluginsRegistry;
 import org.elasticsearch.test.ESTestCase;
 import org.elasticsearch.test.IndexSettingsModule;
 import org.hamcrest.MatcherAssert;
@@ -85,7 +86,7 @@ public class CompoundAnalysisTests extends ESTestCase {
             public Map<String, AnalysisProvider<TokenFilterFactory>> getTokenFilters() {
                 return singletonMap("myfilter", MyFilterTokenFilterFactory::new);
             }
-        }));
+        }), new StablePluginsRegistry());
     }
 
     private Settings getJsonSettings() throws IOException {

+ 6 - 3
modules/analysis-common/src/test/java/org/elasticsearch/analysis/common/EdgeNGramTokenizerTests.java

@@ -19,6 +19,7 @@ import org.elasticsearch.index.IndexSettings;
 import org.elasticsearch.index.analysis.IndexAnalyzers;
 import org.elasticsearch.index.analysis.NamedAnalyzer;
 import org.elasticsearch.indices.analysis.AnalysisModule;
+import org.elasticsearch.plugins.scanners.StablePluginsRegistry;
 import org.elasticsearch.test.ESTokenStreamTestCase;
 import org.elasticsearch.test.IndexSettingsModule;
 import org.elasticsearch.test.VersionUtils;
@@ -36,9 +37,11 @@ public class EdgeNGramTokenizerTests extends ESTokenStreamTestCase {
             .put("index.analysis.analyzer.my_analyzer.tokenizer", tokenizer)
             .build();
         IndexSettings idxSettings = IndexSettingsModule.newIndexSettings("index", indexSettings);
-        return new AnalysisModule(TestEnvironment.newEnvironment(settings), Collections.singletonList(new CommonAnalysisPlugin()))
-            .getAnalysisRegistry()
-            .build(idxSettings);
+        return new AnalysisModule(
+            TestEnvironment.newEnvironment(settings),
+            Collections.singletonList(new CommonAnalysisPlugin()),
+            new StablePluginsRegistry()
+        ).getAnalysisRegistry().build(idxSettings);
     }
 
     public void testPreConfiguredTokenizer() throws IOException {

+ 5 - 2
modules/analysis-common/src/test/java/org/elasticsearch/analysis/common/MultiplexerTokenFilterTests.java

@@ -17,6 +17,7 @@ import org.elasticsearch.index.IndexSettings;
 import org.elasticsearch.index.analysis.IndexAnalyzers;
 import org.elasticsearch.index.analysis.NamedAnalyzer;
 import org.elasticsearch.indices.analysis.AnalysisModule;
+import org.elasticsearch.plugins.scanners.StablePluginsRegistry;
 import org.elasticsearch.test.ESTokenStreamTestCase;
 import org.elasticsearch.test.IndexSettingsModule;
 
@@ -41,7 +42,8 @@ public class MultiplexerTokenFilterTests extends ESTokenStreamTestCase {
 
         IndexAnalyzers indexAnalyzers = new AnalysisModule(
             TestEnvironment.newEnvironment(settings),
-            Collections.singletonList(new CommonAnalysisPlugin())
+            Collections.singletonList(new CommonAnalysisPlugin()),
+            new StablePluginsRegistry()
         ).getAnalysisRegistry().build(idxSettings);
 
         try (NamedAnalyzer analyzer = indexAnalyzers.get("myAnalyzer")) {
@@ -75,7 +77,8 @@ public class MultiplexerTokenFilterTests extends ESTokenStreamTestCase {
 
         IndexAnalyzers indexAnalyzers = new AnalysisModule(
             TestEnvironment.newEnvironment(settings),
-            Collections.singletonList(new CommonAnalysisPlugin())
+            Collections.singletonList(new CommonAnalysisPlugin()),
+            new StablePluginsRegistry()
         ).getAnalysisRegistry().build(idxSettings);
 
         try (NamedAnalyzer analyzer = indexAnalyzers.get("myAnalyzer")) {

+ 6 - 1
modules/analysis-common/src/test/java/org/elasticsearch/analysis/common/PredicateTokenScriptFilterTests.java

@@ -17,6 +17,7 @@ import org.elasticsearch.index.IndexSettings;
 import org.elasticsearch.index.analysis.IndexAnalyzers;
 import org.elasticsearch.index.analysis.NamedAnalyzer;
 import org.elasticsearch.indices.analysis.AnalysisModule;
+import org.elasticsearch.plugins.scanners.StablePluginsRegistry;
 import org.elasticsearch.script.Script;
 import org.elasticsearch.script.ScriptContext;
 import org.elasticsearch.script.ScriptService;
@@ -60,7 +61,11 @@ public class PredicateTokenScriptFilterTests extends ESTokenStreamTestCase {
 
         CommonAnalysisPlugin plugin = new CommonAnalysisPlugin();
         plugin.createComponents(null, null, null, null, scriptService, null, null, null, null, null, null, Tracer.NOOP, null);
-        AnalysisModule module = new AnalysisModule(TestEnvironment.newEnvironment(settings), Collections.singletonList(plugin));
+        AnalysisModule module = new AnalysisModule(
+            TestEnvironment.newEnvironment(settings),
+            Collections.singletonList(plugin),
+            new StablePluginsRegistry()
+        );
 
         IndexAnalyzers analyzers = module.getAnalysisRegistry().build(idxSettings);
 

+ 6 - 1
modules/analysis-common/src/test/java/org/elasticsearch/analysis/common/ScriptedConditionTokenFilterTests.java

@@ -17,6 +17,7 @@ import org.elasticsearch.index.IndexSettings;
 import org.elasticsearch.index.analysis.IndexAnalyzers;
 import org.elasticsearch.index.analysis.NamedAnalyzer;
 import org.elasticsearch.indices.analysis.AnalysisModule;
+import org.elasticsearch.plugins.scanners.StablePluginsRegistry;
 import org.elasticsearch.script.Script;
 import org.elasticsearch.script.ScriptContext;
 import org.elasticsearch.script.ScriptService;
@@ -60,7 +61,11 @@ public class ScriptedConditionTokenFilterTests extends ESTokenStreamTestCase {
 
         CommonAnalysisPlugin plugin = new CommonAnalysisPlugin();
         plugin.createComponents(null, null, null, null, scriptService, null, null, null, null, null, null, Tracer.NOOP, null);
-        AnalysisModule module = new AnalysisModule(TestEnvironment.newEnvironment(settings), Collections.singletonList(plugin));
+        AnalysisModule module = new AnalysisModule(
+            TestEnvironment.newEnvironment(settings),
+            Collections.singletonList(plugin),
+            new StablePluginsRegistry()
+        );
 
         IndexAnalyzers analyzers = module.getAnalysisRegistry().build(idxSettings);
 

+ 5 - 2
modules/analysis-common/src/test/java/org/elasticsearch/analysis/common/WordDelimiterGraphTokenFilterFactoryTests.java

@@ -20,6 +20,7 @@ import org.elasticsearch.index.analysis.IndexAnalyzers;
 import org.elasticsearch.index.analysis.NamedAnalyzer;
 import org.elasticsearch.index.analysis.TokenFilterFactory;
 import org.elasticsearch.indices.analysis.AnalysisModule;
+import org.elasticsearch.plugins.scanners.StablePluginsRegistry;
 import org.elasticsearch.test.ESTestCase;
 import org.elasticsearch.test.IndexSettingsModule;
 import org.elasticsearch.test.VersionUtils;
@@ -193,7 +194,8 @@ public class WordDelimiterGraphTokenFilterFactoryTests extends BaseWordDelimiter
             try (
                 IndexAnalyzers indexAnalyzers = new AnalysisModule(
                     TestEnvironment.newEnvironment(settings),
-                    Collections.singletonList(new CommonAnalysisPlugin())
+                    Collections.singletonList(new CommonAnalysisPlugin()),
+                    new StablePluginsRegistry()
                 ).getAnalysisRegistry().build(idxSettings)
             ) {
 
@@ -217,7 +219,8 @@ public class WordDelimiterGraphTokenFilterFactoryTests extends BaseWordDelimiter
             try (
                 IndexAnalyzers indexAnalyzers = new AnalysisModule(
                     TestEnvironment.newEnvironment(settings),
-                    Collections.singletonList(new CommonAnalysisPlugin())
+                    Collections.singletonList(new CommonAnalysisPlugin()),
+                    new StablePluginsRegistry()
                 ).getAnalysisRegistry().build(idxSettings)
             ) {
 

+ 4 - 0
server/src/main/java/org/elasticsearch/common/NamedRegistry.java

@@ -45,4 +45,8 @@ public class NamedRegistry<T> {
             }
         }
     }
+
+    public void register(Map<String, T> collect) {
+        collect.forEach(this::register);
+    }
 }

+ 32 - 9
server/src/main/java/org/elasticsearch/indices/analysis/AnalysisModule.java

@@ -38,7 +38,9 @@ import org.elasticsearch.index.analysis.StopTokenFilterFactory;
 import org.elasticsearch.index.analysis.TokenFilterFactory;
 import org.elasticsearch.index.analysis.TokenizerFactory;
 import org.elasticsearch.index.analysis.WhitespaceAnalyzerProvider;
+import org.elasticsearch.indices.analysis.wrappers.StableApiWrappers;
 import org.elasticsearch.plugins.AnalysisPlugin;
+import org.elasticsearch.plugins.scanners.StablePluginsRegistry;
 
 import java.io.IOException;
 import java.util.List;
@@ -68,13 +70,18 @@ public final class AnalysisModule {
     private final HunspellService hunspellService;
     private final AnalysisRegistry analysisRegistry;
 
-    public AnalysisModule(Environment environment, List<AnalysisPlugin> plugins) throws IOException {
-        NamedRegistry<AnalysisProvider<CharFilterFactory>> charFilters = setupCharFilters(plugins);
+    public AnalysisModule(Environment environment, List<AnalysisPlugin> plugins, StablePluginsRegistry stablePluginRegistry)
+        throws IOException {
+        NamedRegistry<AnalysisProvider<CharFilterFactory>> charFilters = setupCharFilters(plugins, stablePluginRegistry);
         NamedRegistry<org.apache.lucene.analysis.hunspell.Dictionary> hunspellDictionaries = setupHunspellDictionaries(plugins);
         hunspellService = new HunspellService(environment.settings(), environment, hunspellDictionaries.getRegistry());
-        NamedRegistry<AnalysisProvider<TokenFilterFactory>> tokenFilters = setupTokenFilters(plugins, hunspellService);
-        NamedRegistry<AnalysisProvider<TokenizerFactory>> tokenizers = setupTokenizers(plugins);
-        NamedRegistry<AnalysisProvider<AnalyzerProvider<?>>> analyzers = setupAnalyzers(plugins);
+        NamedRegistry<AnalysisProvider<TokenFilterFactory>> tokenFilters = setupTokenFilters(
+            plugins,
+            hunspellService,
+            stablePluginRegistry
+        );
+        NamedRegistry<AnalysisProvider<TokenizerFactory>> tokenizers = setupTokenizers(plugins, stablePluginRegistry);
+        NamedRegistry<AnalysisProvider<AnalyzerProvider<?>>> analyzers = setupAnalyzers(plugins, stablePluginRegistry);
         NamedRegistry<AnalysisProvider<AnalyzerProvider<?>>> normalizers = setupNormalizers(plugins);
 
         Map<String, PreConfiguredCharFilter> preConfiguredCharFilters = setupPreConfiguredCharFilters(plugins);
@@ -104,9 +111,14 @@ public final class AnalysisModule {
         return analysisRegistry;
     }
 
-    private static NamedRegistry<AnalysisProvider<CharFilterFactory>> setupCharFilters(List<AnalysisPlugin> plugins) {
+    private static NamedRegistry<AnalysisProvider<CharFilterFactory>> setupCharFilters(
+        List<AnalysisPlugin> plugins,
+        StablePluginsRegistry stablePluginRegistry
+    ) {
         NamedRegistry<AnalysisProvider<CharFilterFactory>> charFilters = new NamedRegistry<>("char_filter");
         charFilters.extractAndRegister(plugins, AnalysisPlugin::getCharFilters);
+
+        charFilters.register(StableApiWrappers.oldApiForStableCharFilterFactory(stablePluginRegistry));
         return charFilters;
     }
 
@@ -118,7 +130,8 @@ public final class AnalysisModule {
 
     private static NamedRegistry<AnalysisProvider<TokenFilterFactory>> setupTokenFilters(
         List<AnalysisPlugin> plugins,
-        HunspellService hunspellService
+        HunspellService hunspellService,
+        StablePluginsRegistry stablePluginRegistry
     ) {
         NamedRegistry<AnalysisProvider<TokenFilterFactory>> tokenFilters = new NamedRegistry<>("token_filter");
         tokenFilters.register("stop", StopTokenFilterFactory::new);
@@ -157,6 +170,8 @@ public final class AnalysisModule {
         );
 
         tokenFilters.extractAndRegister(plugins, AnalysisPlugin::getTokenFilters);
+        tokenFilters.register(StableApiWrappers.oldApiForTokenFilterFactory(stablePluginRegistry));
+
         return tokenFilters;
     }
 
@@ -241,14 +256,21 @@ public final class AnalysisModule {
         return unmodifiableMap(preConfiguredTokenizers.getRegistry());
     }
 
-    private static NamedRegistry<AnalysisProvider<TokenizerFactory>> setupTokenizers(List<AnalysisPlugin> plugins) {
+    private static NamedRegistry<AnalysisProvider<TokenizerFactory>> setupTokenizers(
+        List<AnalysisPlugin> plugins,
+        StablePluginsRegistry stablePluginRegistry
+    ) {
         NamedRegistry<AnalysisProvider<TokenizerFactory>> tokenizers = new NamedRegistry<>("tokenizer");
         tokenizers.register("standard", StandardTokenizerFactory::new);
         tokenizers.extractAndRegister(plugins, AnalysisPlugin::getTokenizers);
+        tokenizers.register(StableApiWrappers.oldApiForTokenizerFactory(stablePluginRegistry));
         return tokenizers;
     }
 
-    private static NamedRegistry<AnalysisProvider<AnalyzerProvider<?>>> setupAnalyzers(List<AnalysisPlugin> plugins) {
+    private static NamedRegistry<AnalysisProvider<AnalyzerProvider<?>>> setupAnalyzers(
+        List<AnalysisPlugin> plugins,
+        StablePluginsRegistry stablePluginRegistry
+    ) {
         NamedRegistry<AnalysisProvider<AnalyzerProvider<?>>> analyzers = new NamedRegistry<>("analyzer");
         analyzers.register("default", StandardAnalyzerProvider::new);
         analyzers.register("standard", StandardAnalyzerProvider::new);
@@ -257,6 +279,7 @@ public final class AnalysisModule {
         analyzers.register("whitespace", WhitespaceAnalyzerProvider::new);
         analyzers.register("keyword", KeywordAnalyzerProvider::new);
         analyzers.extractAndRegister(plugins, AnalysisPlugin::getAnalyzers);
+        analyzers.register(StableApiWrappers.oldApiForAnalyzerFactory(stablePluginRegistry));
         return analyzers;
     }
 

+ 213 - 0
server/src/main/java/org/elasticsearch/indices/analysis/wrappers/StableApiWrappers.java

@@ -0,0 +1,213 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.indices.analysis.wrappers;
+
+import org.apache.lucene.analysis.Analyzer;
+import org.apache.lucene.analysis.TokenStream;
+import org.apache.lucene.analysis.Tokenizer;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.env.Environment;
+import org.elasticsearch.index.IndexSettings;
+import org.elasticsearch.indices.analysis.AnalysisModule;
+import org.elasticsearch.plugins.scanners.PluginInfo;
+import org.elasticsearch.plugins.scanners.StablePluginsRegistry;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.InvocationTargetException;
+import java.util.Collection;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+/**
+ * A utility class containing methods that wraps the Stable plugin API with the old plugin api.
+ * Note that most old and stable api classes have the same names but differ in package name.
+ * Hence this class is avoiding imports and is using qualifying names
+ */
+public class StableApiWrappers {
+    public static
+        Map<String, AnalysisModule.AnalysisProvider<org.elasticsearch.index.analysis.CharFilterFactory>>
+        oldApiForStableCharFilterFactory(StablePluginsRegistry stablePluginRegistry) {
+        return mapStablePluginApiToOld(
+            stablePluginRegistry,
+            org.elasticsearch.plugin.analysis.api.CharFilterFactory.class,
+            StableApiWrappers::wrapCharFilterFactory
+        );
+    }
+
+    public static
+        Map<String, AnalysisModule.AnalysisProvider<org.elasticsearch.index.analysis.TokenFilterFactory>>
+        oldApiForTokenFilterFactory(StablePluginsRegistry stablePluginRegistry) {
+        return mapStablePluginApiToOld(
+            stablePluginRegistry,
+            org.elasticsearch.plugin.analysis.api.TokenFilterFactory.class,
+            StableApiWrappers::wrapTokenFilterFactory
+        );
+    }
+
+    public static Map<String, AnalysisModule.AnalysisProvider<org.elasticsearch.index.analysis.TokenizerFactory>> oldApiForTokenizerFactory(
+        StablePluginsRegistry stablePluginRegistry
+    ) {
+        return mapStablePluginApiToOld(
+            stablePluginRegistry,
+            org.elasticsearch.plugin.analysis.api.TokenizerFactory.class,
+            StableApiWrappers::wrapTokenizerFactory
+        );
+    }
+
+    public static
+        Map<String, AnalysisModule.AnalysisProvider<org.elasticsearch.index.analysis.AnalyzerProvider<?>>>
+        oldApiForAnalyzerFactory(StablePluginsRegistry stablePluginRegistry) {
+        return mapStablePluginApiToOld(
+            stablePluginRegistry,
+            org.elasticsearch.plugin.analysis.api.AnalyzerFactory.class,
+            StableApiWrappers::wrapAnalyzerFactory
+        );
+    }
+
+    private static <T, F> Map<String, AnalysisModule.AnalysisProvider<T>> mapStablePluginApiToOld(
+        StablePluginsRegistry stablePluginRegistry,
+        Class<F> charFilterFactoryClass,
+        Function<F, T> wrapper
+    ) {
+        Collection<PluginInfo> pluginInfosForExtensible = stablePluginRegistry.getPluginInfosForExtensible(
+            charFilterFactoryClass.getCanonicalName()
+        );
+
+        Map<String, AnalysisModule.AnalysisProvider<T>> oldCharFilters = pluginInfosForExtensible.stream()
+            .collect(Collectors.toMap(PluginInfo::name, p -> analysisProviderWrapper(p, wrapper)));
+        return oldCharFilters;
+    }
+
+    @SuppressWarnings("unchecked")
+    private static <F, T> AnalysisModule.AnalysisProvider<T> analysisProviderWrapper(PluginInfo pluginInfo, Function<F, T> wrapper) {
+        return new AnalysisModule.AnalysisProvider<T>() {
+
+            @Override
+            public T get(IndexSettings indexSettings, Environment environment, String name, Settings settings) throws IOException {
+                try {
+                    Class<? extends F> clazz = (Class<? extends F>) pluginInfo.loader().loadClass(pluginInfo.className());
+                    F instance = createInstance(clazz, indexSettings, environment.settings(), settings, environment);
+                    return wrapper.apply(instance);
+                } catch (ClassNotFoundException e) {
+                    throw new IllegalStateException("Plugin classloader cannot find class " + pluginInfo.className(), e);
+                }
+            }
+        };
+    }
+
+    private static org.elasticsearch.index.analysis.CharFilterFactory wrapCharFilterFactory(
+        org.elasticsearch.plugin.analysis.api.CharFilterFactory charFilterFactory
+    ) {
+        return new org.elasticsearch.index.analysis.CharFilterFactory() {
+            @Override
+            public String name() {
+                return charFilterFactory.name();
+            }
+
+            @Override
+            public Reader create(Reader reader) {
+                return charFilterFactory.create(reader);
+            }
+
+            @Override
+            public Reader normalize(Reader reader) {
+                return charFilterFactory.normalize(reader);
+            }
+        };
+    }
+
+    private static org.elasticsearch.index.analysis.TokenFilterFactory wrapTokenFilterFactory(
+        org.elasticsearch.plugin.analysis.api.TokenFilterFactory f
+    ) {
+        return new org.elasticsearch.index.analysis.TokenFilterFactory() {
+            @Override
+            public String name() {
+                return f.name();
+            }
+
+            @Override
+            public TokenStream create(TokenStream tokenStream) {
+                return f.create(tokenStream);
+            }
+
+            @Override
+            public TokenStream normalize(TokenStream tokenStream) {
+                return f.normalize(tokenStream);
+            }
+
+            @Override
+            public org.elasticsearch.index.analysis.AnalysisMode getAnalysisMode() {
+                return mapAnalysisMode(f.getAnalysisMode());
+            }
+
+            private org.elasticsearch.index.analysis.AnalysisMode mapAnalysisMode(
+                org.elasticsearch.plugin.analysis.api.AnalysisMode analysisMode
+            ) {
+                return org.elasticsearch.index.analysis.AnalysisMode.valueOf(analysisMode.name());
+            }
+        };
+    }
+
+    private static org.elasticsearch.index.analysis.TokenizerFactory wrapTokenizerFactory(
+        org.elasticsearch.plugin.analysis.api.TokenizerFactory f
+    ) {
+        return new org.elasticsearch.index.analysis.TokenizerFactory() {
+
+            @Override
+            public String name() {
+                return f.name();
+            }
+
+            @Override
+            public Tokenizer create() {
+                return f.create();
+            }
+        };
+    }
+
+    private static org.elasticsearch.index.analysis.AnalyzerProvider<?> wrapAnalyzerFactory(
+        org.elasticsearch.plugin.analysis.api.AnalyzerFactory f
+    ) {
+        return new org.elasticsearch.index.analysis.AnalyzerProvider<>() {
+            @Override
+            public String name() {
+                return f.name();
+            }
+
+            @Override
+            public org.elasticsearch.index.analysis.AnalyzerScope scope() {
+                return org.elasticsearch.index.analysis.AnalyzerScope.GLOBAL;// TODO is this right?
+            }
+
+            @Override
+            public Analyzer get() {
+                return f.create();
+            }
+        };
+    }
+
+    @SuppressWarnings({ "unchecked", "rawtypes" })
+    private static <T> T createInstance(
+        Class<T> clazz,
+        IndexSettings indexSettings,
+        Settings nodeSettings,
+        Settings analysisSettings,
+        Environment environment
+    ) {
+        try {
+            Constructor<T> constructor = clazz.getConstructor();
+            return constructor.newInstance();
+        } catch (NoSuchMethodException | InstantiationException | IllegalAccessException | InvocationTargetException e) {
+            throw new IllegalStateException("cannot create instance of " + clazz, e);
+        }
+    }
+}

+ 5 - 1
server/src/main/java/org/elasticsearch/node/Node.java

@@ -446,7 +446,11 @@ public class Node implements Closeable {
                 scriptModule.contexts,
                 threadPool::absoluteTimeInMillis
             );
-            AnalysisModule analysisModule = new AnalysisModule(this.environment, pluginsService.filterPlugins(AnalysisPlugin.class));
+            AnalysisModule analysisModule = new AnalysisModule(
+                this.environment,
+                pluginsService.filterPlugins(AnalysisPlugin.class),
+                pluginsService.getStablePluginRegistry()
+            );
             // this is as early as we can validate settings at this point. we already pass them to ScriptModule as well as ThreadPool
             // so we might be late here already
 

+ 9 - 0
server/src/main/java/org/elasticsearch/plugins/PluginsService.java

@@ -28,6 +28,7 @@ import org.elasticsearch.core.Tuple;
 import org.elasticsearch.env.Environment;
 import org.elasticsearch.jdk.JarHell;
 import org.elasticsearch.node.ReportingService;
+import org.elasticsearch.plugins.scanners.StablePluginsRegistry;
 import org.elasticsearch.plugins.spi.SPIClassIterator;
 
 import java.io.IOException;
@@ -65,6 +66,10 @@ import static org.elasticsearch.common.io.FileSystemUtils.isAccessibleDirectory;
 
 public class PluginsService implements ReportingService<PluginsAndModules> {
 
+    public StablePluginsRegistry getStablePluginRegistry() {
+        return stablePluginsRegistry;
+    }
+
     /**
      * A loaded plugin is one for which Elasticsearch has successfully constructed an instance of the plugin's class
      * @param descriptor Metadata about the plugin, usually loaded from plugin properties
@@ -101,6 +106,7 @@ public class PluginsService implements ReportingService<PluginsAndModules> {
      */
     private final List<LoadedPlugin> plugins;
     private final PluginsAndModules info;
+    private final StablePluginsRegistry stablePluginsRegistry = new StablePluginsRegistry();
 
     public static final Setting<List<String>> MANDATORY_SETTING = Setting.listSetting(
         "plugin.mandatory",
@@ -457,6 +463,9 @@ public class PluginsService implements ReportingService<PluginsAndModules> {
             // that have dependencies with their own SPI endpoints have a chance to load
             // and initialize them appropriately.
             privilegedSetContextClassLoader(pluginClassLoader);
+            if (bundle.pluginDescriptor().isStable()) {
+                stablePluginsRegistry.scanBundleForStablePlugins(bundle, pluginClassLoader);
+            }
 
             Class<? extends Plugin> pluginClass = loadPluginClass(bundle.plugin.getClassname(), pluginClassLoader);
             if (pluginClassLoader != pluginClass.getClassLoader()) {

+ 1 - 1
server/src/main/java/org/elasticsearch/plugins/scanners/PluginInfo.java

@@ -8,6 +8,6 @@
 
 package org.elasticsearch.plugins.scanners;
 
-record PluginInfo(String name, String className, ClassLoader loader) {
+public record PluginInfo(String name, String className, ClassLoader loader) {
 
 }

+ 12 - 7
server/src/main/java/org/elasticsearch/plugins/scanners/StablePluginsRegistry.java

@@ -10,6 +10,8 @@ package org.elasticsearch.plugins.scanners;
 
 import org.elasticsearch.plugins.PluginBundle;
 
+import java.util.Collection;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
 
@@ -29,28 +31,31 @@ public class StablePluginsRegistry {
         {"nori" -> {nori, org.elasticserach.plugin.analysis.new_nori.NoriReadingFormFilterFactory, classloaderInstance}
      */
     private final Map<String /*Extensible */, NameToPluginInfo> namedComponents;
-    private final NamedComponentReader namedComponentsScanner;
+    private final NamedComponentReader namedComponentReader;
 
     public StablePluginsRegistry() {
         this(new NamedComponentReader(), new HashMap<>());
     }
 
     // for testing
-    StablePluginsRegistry(NamedComponentReader namedComponentReader, HashMap<String /*Extensible */, NameToPluginInfo> namedComponents) {
-        this.namedComponentsScanner = namedComponentReader;
+    public StablePluginsRegistry(NamedComponentReader namedComponentReader, Map<String /*Extensible */, NameToPluginInfo> namedComponents) {
+        this.namedComponentReader = namedComponentReader;
         this.namedComponents = namedComponents;
     }
 
     public void scanBundleForStablePlugins(PluginBundle bundle, ClassLoader pluginClassLoader) {
-        Map<String, NameToPluginInfo> namedComponentsFromPlugin = namedComponentsScanner.findNamedComponents(bundle, pluginClassLoader);
+        Map<String, NameToPluginInfo> namedComponentsFromPlugin = namedComponentReader.findNamedComponents(bundle, pluginClassLoader);
         for (Map.Entry<String, NameToPluginInfo> entry : namedComponentsFromPlugin.entrySet()) {
             namedComponents.compute(entry.getKey(), (k, v) -> v != null ? v.put(entry.getValue()) : entry.getValue());
         }
     }
 
-    // TODO this will be removed. getPluginForName or similar will be created
-    public Map<String, NameToPluginInfo> getNamedComponents() {
-        return namedComponents;
+    public Collection<PluginInfo> getPluginInfosForExtensible(String extensibleClassName) {
+        NameToPluginInfo nameToPluginInfo = namedComponents.get(extensibleClassName);
+        if (nameToPluginInfo != null) {
+            return nameToPluginInfo.nameToPluginInfoMap().values();
+        }
+        return Collections.emptyList();
     }
 
 }

+ 2 - 1
server/src/test/java/org/elasticsearch/action/admin/indices/TransportAnalyzeActionTests.java

@@ -34,6 +34,7 @@ import org.elasticsearch.indices.analysis.AnalysisModule;
 import org.elasticsearch.indices.analysis.AnalysisModule.AnalysisProvider;
 import org.elasticsearch.indices.analysis.AnalysisModuleTests.AppendCharFilter;
 import org.elasticsearch.plugins.AnalysisPlugin;
+import org.elasticsearch.plugins.scanners.StablePluginsRegistry;
 import org.elasticsearch.test.ESTestCase;
 import org.elasticsearch.test.IndexSettingsModule;
 
@@ -138,7 +139,7 @@ public class TransportAnalyzeActionTests extends ESTestCase {
                 return singletonList(PreConfiguredCharFilter.singleton("append", false, reader -> new AppendCharFilter(reader, "foo")));
             }
         };
-        registry = new AnalysisModule(environment, singletonList(plugin)).getAnalysisRegistry();
+        registry = new AnalysisModule(environment, singletonList(plugin), new StablePluginsRegistry()).getAnalysisRegistry();
         indexAnalyzers = registry.build(this.indexSettings);
         maxTokenCount = IndexSettings.MAX_TOKEN_COUNT_SETTING.getDefault(settings);
         idxMaxTokenCount = this.indexSettings.getMaxTokenCount();

+ 11 - 4
server/src/test/java/org/elasticsearch/index/analysis/AnalysisRegistryTests.java

@@ -31,6 +31,7 @@ import org.elasticsearch.indices.analysis.AnalysisModule.AnalysisProvider;
 import org.elasticsearch.indices.analysis.PreBuiltAnalyzers;
 import org.elasticsearch.plugins.AnalysisPlugin;
 import org.elasticsearch.plugins.Plugin;
+import org.elasticsearch.plugins.scanners.StablePluginsRegistry;
 import org.elasticsearch.test.ESTestCase;
 import org.elasticsearch.test.IndexSettingsModule;
 import org.elasticsearch.test.VersionUtils;
@@ -91,7 +92,11 @@ public class AnalysisRegistryTests extends ESTestCase {
         Settings settings = Settings.builder().put(Environment.PATH_HOME_SETTING.getKey(), createTempDir().toString()).build();
         emptyRegistry = emptyAnalysisRegistry(settings);
         // Module loaded to register in-built normalizers for testing
-        AnalysisModule module = new AnalysisModule(TestEnvironment.newEnvironment(settings), singletonList(new MockAnalysisPlugin()));
+        AnalysisModule module = new AnalysisModule(
+            TestEnvironment.newEnvironment(settings),
+            singletonList(new MockAnalysisPlugin()),
+            new StablePluginsRegistry()
+        );
         nonEmptyRegistry = module.getAnalysisRegistry();
     }
 
@@ -253,9 +258,11 @@ public class AnalysisRegistryTests extends ESTestCase {
                 return singletonMap("mock", MockFactory::new);
             }
         };
-        IndexAnalyzers indexAnalyzers = new AnalysisModule(TestEnvironment.newEnvironment(settings), singletonList(plugin))
-            .getAnalysisRegistry()
-            .build(idxSettings);
+        IndexAnalyzers indexAnalyzers = new AnalysisModule(
+            TestEnvironment.newEnvironment(settings),
+            singletonList(plugin),
+            new StablePluginsRegistry()
+        ).getAnalysisRegistry().build(idxSettings);
 
         // This shouldn't contain English stopwords
         try (NamedAnalyzer custom_analyser = indexAnalyzers.get("custom_analyzer_with_camel_case")) {

+ 201 - 5
server/src/test/java/org/elasticsearch/indices/analysis/AnalysisModuleTests.java

@@ -10,17 +10,22 @@ package org.elasticsearch.indices.analysis;
 
 import org.apache.lucene.analysis.Analyzer;
 import org.apache.lucene.analysis.CharFilter;
+import org.apache.lucene.analysis.FilteringTokenFilter;
 import org.apache.lucene.analysis.TokenFilter;
 import org.apache.lucene.analysis.TokenStream;
 import org.apache.lucene.analysis.Tokenizer;
+import org.apache.lucene.analysis.charfilter.MappingCharFilter;
+import org.apache.lucene.analysis.charfilter.NormalizeCharMap;
 import org.apache.lucene.analysis.hunspell.Dictionary;
 import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
+import org.apache.lucene.analysis.util.CharTokenizer;
 import org.apache.lucene.store.Directory;
 import org.apache.lucene.tests.analysis.MockTokenizer;
 import org.elasticsearch.Version;
 import org.elasticsearch.cluster.metadata.IndexMetadata;
 import org.elasticsearch.common.io.Streams;
 import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.core.SuppressForbidden;
 import org.elasticsearch.env.Environment;
 import org.elasticsearch.env.TestEnvironment;
 import org.elasticsearch.index.IndexSettings;
@@ -38,7 +43,13 @@ import org.elasticsearch.index.analysis.StopTokenFilterFactory;
 import org.elasticsearch.index.analysis.TokenFilterFactory;
 import org.elasticsearch.index.analysis.TokenizerFactory;
 import org.elasticsearch.indices.analysis.AnalysisModule.AnalysisProvider;
+import org.elasticsearch.plugin.analysis.api.AnalysisMode;
+import org.elasticsearch.plugin.api.NamedComponent;
 import org.elasticsearch.plugins.AnalysisPlugin;
+import org.elasticsearch.plugins.scanners.NameToPluginInfo;
+import org.elasticsearch.plugins.scanners.NamedComponentReader;
+import org.elasticsearch.plugins.scanners.PluginInfo;
+import org.elasticsearch.plugins.scanners.StablePluginsRegistry;
 import org.elasticsearch.test.ESTestCase;
 import org.elasticsearch.test.IndexSettingsModule;
 import org.elasticsearch.test.VersionUtils;
@@ -59,6 +70,7 @@ import java.util.List;
 import java.util.Map;
 import java.util.Set;
 
+import static java.util.Collections.emptyList;
 import static java.util.Collections.singletonList;
 import static java.util.Collections.singletonMap;
 import static org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents;
@@ -92,7 +104,7 @@ public class AnalysisModuleTests extends ESTestCase {
                 public Map<String, AnalysisProvider<CharFilterFactory>> getCharFilters() {
                     return AnalysisPlugin.super.getCharFilters();
                 }
-            })).getAnalysisRegistry();
+            }), new StablePluginsRegistry()).getAnalysisRegistry();
         } catch (IOException e) {
             throw new RuntimeException(e);
         }
@@ -260,7 +272,8 @@ public class AnalysisModuleTests extends ESTestCase {
                         )
                     );
                 }
-            })
+            }),
+            new StablePluginsRegistry()
         ).getAnalysisRegistry();
 
         Version version = VersionUtils.randomVersion(random());
@@ -325,7 +338,8 @@ public class AnalysisModuleTests extends ESTestCase {
                         )
                     );
                 }
-            })
+            }),
+            new StablePluginsRegistry()
         ).getAnalysisRegistry();
 
         Version version = VersionUtils.randomVersion(random());
@@ -411,7 +425,8 @@ public class AnalysisModuleTests extends ESTestCase {
                         )
                     );
                 }
-            })
+            }),
+            new StablePluginsRegistry()
         ).getAnalysisRegistry();
 
         Version version = VersionUtils.randomVersion(random());
@@ -457,10 +472,191 @@ public class AnalysisModuleTests extends ESTestCase {
             public Map<String, Dictionary> getHunspellDictionaries() {
                 return singletonMap("foo", dictionary);
             }
-        }));
+        }), new StablePluginsRegistry());
         assertSame(dictionary, module.getHunspellService().getDictionary("foo"));
     }
 
+    @NamedComponent(name = "stableCharFilterFactory")
+    public static class TestCharFilterFactory implements org.elasticsearch.plugin.analysis.api.CharFilterFactory {
+        @SuppressForbidden(reason = "need a public constructor")
+        public TestCharFilterFactory() {}
+
+        @Override
+        public Reader create(Reader reader) {
+            return new ReplaceHash(reader);
+        }
+
+        @Override
+        public Reader normalize(Reader reader) {
+            return new ReplaceHash(reader);
+        }
+
+    }
+
+    static class ReplaceHash extends MappingCharFilter {
+
+        ReplaceHash(Reader in) {
+            super(charMap(), in);
+        }
+
+        private static NormalizeCharMap charMap() {
+            NormalizeCharMap.Builder builder = new NormalizeCharMap.Builder();
+            builder.add("#", "3");
+            return builder.build();
+        }
+    }
+
+    @NamedComponent(name = "stableTokenFilterFactory")
+    public static class TestTokenFilterFactory implements org.elasticsearch.plugin.analysis.api.TokenFilterFactory {
+
+        @SuppressForbidden(reason = "need a public constructor")
+        public TestTokenFilterFactory() {}
+
+        @Override
+        public TokenStream create(TokenStream tokenStream) {
+
+            return new Skip1TokenFilter(tokenStream);
+        }
+
+        @Override
+        public TokenStream normalize(TokenStream tokenStream) {
+            return new AppendTokenFilter(tokenStream, "1");
+        }
+
+        @Override
+        public AnalysisMode getAnalysisMode() {
+            return org.elasticsearch.plugin.analysis.api.TokenFilterFactory.super.getAnalysisMode();
+        }
+
+    }
+
+    static class Skip1TokenFilter extends FilteringTokenFilter {
+
+        private final CharTermAttribute termAtt = addAttribute(CharTermAttribute.class);
+
+        Skip1TokenFilter(TokenStream in) {
+            super(in);
+        }
+
+        @Override
+        protected boolean accept() throws IOException {
+            return termAtt.buffer()[0] != '1';
+        }
+    }
+
+    @NamedComponent(name = "stableTokenizerFactory")
+    public static class TestTokenizerFactory implements org.elasticsearch.plugin.analysis.api.TokenizerFactory {
+        @SuppressForbidden(reason = "need a public constructor")
+        public TestTokenizerFactory() {}
+
+        @Override
+        public Tokenizer create() {
+            return new UnderscoreTokenizer();
+        }
+
+    }
+
+    static class UnderscoreTokenizer extends CharTokenizer {
+
+        @Override
+        protected boolean isTokenChar(int c) {
+            return c != '_';
+        }
+    }
+
+    @NamedComponent(name = "stableAnalyzerFactory")
+    public static class TestAnalyzerFactory implements org.elasticsearch.plugin.analysis.api.AnalyzerFactory {
+
+        @Override
+        public Analyzer create() {
+            return new CustomAnalyzer();
+        }
+
+        static class CustomAnalyzer extends Analyzer {
+
+            @Override
+            protected TokenStreamComponents createComponents(String fieldName) {
+                var tokenizer = new UnderscoreTokenizer();
+                var tokenFilter = new Skip1TokenFilter(tokenizer);
+                return new TokenStreamComponents(r -> tokenizer.setReader(new ReplaceHash(r)), tokenFilter);
+            }
+        }
+    }
+
+    public void testStablePlugins() throws IOException {
+        ClassLoader classLoader = getClass().getClassLoader();
+        AnalysisRegistry registry = new AnalysisModule(
+            TestEnvironment.newEnvironment(emptyNodeSettings),
+            emptyList(),
+            new StablePluginsRegistry(
+                new NamedComponentReader(),
+                Map.of(
+                    org.elasticsearch.plugin.analysis.api.CharFilterFactory.class.getCanonicalName(),
+                    new NameToPluginInfo(
+                        Map.of(
+                            "stableCharFilterFactory",
+                            new PluginInfo("stableCharFilterFactory", TestCharFilterFactory.class.getName(), classLoader)
+                        )
+                    ),
+                    org.elasticsearch.plugin.analysis.api.TokenFilterFactory.class.getCanonicalName(),
+                    new NameToPluginInfo(
+                        Map.of(
+                            "stableTokenFilterFactory",
+                            new PluginInfo("stableTokenFilterFactory", TestTokenFilterFactory.class.getName(), classLoader)
+                        )
+                    ),
+                    org.elasticsearch.plugin.analysis.api.TokenizerFactory.class.getCanonicalName(),
+                    new NameToPluginInfo(
+                        Map.of(
+                            "stableTokenizerFactory",
+                            new PluginInfo("stableTokenizerFactory", TestTokenizerFactory.class.getName(), classLoader)
+                        )
+                    ),
+                    org.elasticsearch.plugin.analysis.api.AnalyzerFactory.class.getCanonicalName(),
+                    new NameToPluginInfo(
+                        Map.of(
+                            "stableAnalyzerFactory",
+                            new PluginInfo("stableAnalyzerFactory", TestAnalyzerFactory.class.getName(), classLoader)
+                        )
+                    )
+                )
+            )
+        ).getAnalysisRegistry();
+
+        Version version = VersionUtils.randomVersion(random());
+        IndexAnalyzers analyzers = getIndexAnalyzers(
+            registry,
+            Settings.builder()
+                .put("index.analysis.analyzer.char_filter_test.tokenizer", "standard")
+                .put("index.analysis.analyzer.char_filter_test.char_filter", "stableCharFilterFactory")
+
+                .put("index.analysis.analyzer.token_filter_test.tokenizer", "standard")
+                .put("index.analysis.analyzer.token_filter_test.filter", "stableTokenFilterFactory")
+
+                .put("index.analysis.analyzer.tokenizer_test.tokenizer", "stableTokenizerFactory")
+
+                .put("index.analysis.analyzer.analyzer_provider_test.type", "stableAnalyzerFactory")
+
+                .put(IndexMetadata.SETTING_VERSION_CREATED, version)
+                .build()
+        );
+        assertTokenStreamContents(analyzers.get("char_filter_test").tokenStream("", "t#st"), new String[] { "t3st" });
+        assertTokenStreamContents(
+            analyzers.get("token_filter_test").tokenStream("", "1test 2test 1test 3test "),
+            new String[] { "2test", "3test" }
+        );
+        assertTokenStreamContents(analyzers.get("tokenizer_test").tokenStream("", "x_y_z"), new String[] { "x", "y", "z" });
+        assertTokenStreamContents(analyzers.get("analyzer_provider_test").tokenStream("", "1x_y_#z"), new String[] { "y", "3z" });
+
+        assertThat(analyzers.get("char_filter_test").normalize("", "t#st").utf8ToString(), equalTo("t3st"));
+        assertThat(
+            analyzers.get("token_filter_test").normalize("", "1test 2test 1test 3test ").utf8ToString(),
+            equalTo("1test 2test 1test 3test 1")
+        );
+
+        // TODO does it makes sense to test normalize on tokenizer and analyzer?
+    }
+
     // Simple char filter that appends text to the term
     public static class AppendCharFilter extends CharFilter {
 

+ 276 - 0
server/src/test/java/org/elasticsearch/indices/analysis/wrappers/StableApiWrappersTests.java

@@ -0,0 +1,276 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.indices.analysis.wrappers;
+
+import org.apache.lucene.analysis.Analyzer;
+import org.apache.lucene.analysis.TokenStream;
+import org.apache.lucene.analysis.Tokenizer;
+import org.elasticsearch.env.Environment;
+import org.elasticsearch.index.analysis.AnalyzerScope;
+import org.elasticsearch.indices.analysis.AnalysisModule;
+import org.elasticsearch.plugin.analysis.api.AnalysisMode;
+import org.elasticsearch.plugin.api.NamedComponent;
+import org.elasticsearch.plugins.scanners.PluginInfo;
+import org.elasticsearch.plugins.scanners.StablePluginsRegistry;
+import org.elasticsearch.test.ESTestCase;
+import org.mockito.Mockito;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.util.List;
+import java.util.Map;
+
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.instanceOf;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+public class StableApiWrappersTests extends ESTestCase {
+
+    public void testUnknownClass() throws IOException {
+        StablePluginsRegistry registry = Mockito.mock(StablePluginsRegistry.class);
+        Mockito.when(
+            registry.getPluginInfosForExtensible(eq(org.elasticsearch.plugin.analysis.api.AnalyzerFactory.class.getCanonicalName()))
+        ).thenReturn(List.of(new PluginInfo("namedComponentName1", "someRandomName", getClass().getClassLoader())));
+
+        Map<String, AnalysisModule.AnalysisProvider<org.elasticsearch.index.analysis.AnalyzerProvider<?>>> analysisProviderMap =
+            StableApiWrappers.oldApiForAnalyzerFactory(registry);
+
+        AnalysisModule.AnalysisProvider<org.elasticsearch.index.analysis.AnalyzerProvider<?>> oldTokenFilter = analysisProviderMap.get(
+            "namedComponentName1"
+        );
+
+        IllegalStateException illegalStateException = expectThrows(
+            IllegalStateException.class,
+            () -> oldTokenFilter.get(null, mock(Environment.class), null, null)
+        );
+        assertThat(illegalStateException.getCause(), instanceOf(ClassNotFoundException.class));
+    }
+
+    public void testStablePluginHasNoArgConstructor() throws IOException {
+        StablePluginsRegistry registry = Mockito.mock(StablePluginsRegistry.class);
+        Mockito.when(
+            registry.getPluginInfosForExtensible(eq(org.elasticsearch.plugin.analysis.api.AnalyzerFactory.class.getCanonicalName()))
+        )
+            .thenReturn(
+                List.of(new PluginInfo("namedComponentName1", DefaultConstrAnalyzerFactory.class.getName(), getClass().getClassLoader()))
+            );
+
+        Map<String, AnalysisModule.AnalysisProvider<org.elasticsearch.index.analysis.AnalyzerProvider<?>>> analysisProviderMap =
+            StableApiWrappers.oldApiForAnalyzerFactory(registry);
+
+        AnalysisModule.AnalysisProvider<org.elasticsearch.index.analysis.AnalyzerProvider<?>> oldTokenFilter = analysisProviderMap.get(
+            "namedComponentName1"
+        );
+
+        IllegalStateException illegalStateException = expectThrows(
+            IllegalStateException.class,
+            () -> oldTokenFilter.get(null, mock(Environment.class), null, null)
+        );
+        assertThat(illegalStateException.getCause(), instanceOf(NoSuchMethodException.class));
+    }
+
+    public void testAnalyzerFactoryDelegation() throws IOException {
+        StablePluginsRegistry registry = Mockito.mock(StablePluginsRegistry.class);
+        Mockito.when(
+            registry.getPluginInfosForExtensible(eq(org.elasticsearch.plugin.analysis.api.AnalyzerFactory.class.getCanonicalName()))
+        ).thenReturn(List.of(new PluginInfo("namedComponentName1", TestAnalyzerFactory.class.getName(), getClass().getClassLoader())));
+
+        Map<String, AnalysisModule.AnalysisProvider<org.elasticsearch.index.analysis.AnalyzerProvider<?>>> analysisProviderMap =
+            StableApiWrappers.oldApiForAnalyzerFactory(registry);
+
+        AnalysisModule.AnalysisProvider<org.elasticsearch.index.analysis.AnalyzerProvider<?>> oldTokenFilter = analysisProviderMap.get(
+            "namedComponentName1"
+        );
+
+        org.elasticsearch.index.analysis.AnalyzerProvider<?> analyzerProvider = oldTokenFilter.get(
+            null,
+            mock(Environment.class),
+            null,
+            null
+        );
+
+        // test delegation
+        Analyzer analyzer = analyzerProvider.get();
+        assertTrue(Mockito.mockingDetails(analyzer).isMock());
+
+        assertThat(analyzerProvider.name(), equalTo("TestAnalyzerFactory"));
+        assertThat(analyzerProvider.scope(), equalTo(AnalyzerScope.GLOBAL));
+    }
+
+    public void testTokenizerFactoryDelegation() throws IOException {
+        StablePluginsRegistry registry = Mockito.mock(StablePluginsRegistry.class);
+        Mockito.when(
+            registry.getPluginInfosForExtensible(eq(org.elasticsearch.plugin.analysis.api.TokenizerFactory.class.getCanonicalName()))
+        ).thenReturn(List.of(new PluginInfo("namedComponentName1", TestTokenizerFactory.class.getName(), getClass().getClassLoader())));
+
+        Map<String, AnalysisModule.AnalysisProvider<org.elasticsearch.index.analysis.TokenizerFactory>> analysisProviderMap =
+            StableApiWrappers.oldApiForTokenizerFactory(registry);
+
+        AnalysisModule.AnalysisProvider<org.elasticsearch.index.analysis.TokenizerFactory> oldTokenFilter = analysisProviderMap.get(
+            "namedComponentName1"
+        );
+
+        org.elasticsearch.index.analysis.TokenizerFactory tokenizerFactory = oldTokenFilter.get(null, mock(Environment.class), null, null);
+
+        // test delegation
+        Tokenizer tokenizer = tokenizerFactory.create();
+
+        assertTrue(Mockito.mockingDetails(tokenizer).isMock());
+
+        assertThat(tokenizerFactory.name(), equalTo("TestTokenizerFactory"));
+    }
+
+    public void testTokenFilterFactoryDelegation() throws IOException {
+        StablePluginsRegistry registry = Mockito.mock(StablePluginsRegistry.class);
+        Mockito.when(
+            registry.getPluginInfosForExtensible(eq(org.elasticsearch.plugin.analysis.api.TokenFilterFactory.class.getCanonicalName()))
+        ).thenReturn(List.of(new PluginInfo("namedComponentName1", TestTokenFilterFactory.class.getName(), getClass().getClassLoader())));
+
+        Map<String, AnalysisModule.AnalysisProvider<org.elasticsearch.index.analysis.TokenFilterFactory>> analysisProviderMap =
+            StableApiWrappers.oldApiForTokenFilterFactory(registry);
+
+        AnalysisModule.AnalysisProvider<org.elasticsearch.index.analysis.TokenFilterFactory> oldTokenFilter = analysisProviderMap.get(
+            "namedComponentName1"
+        );
+
+        org.elasticsearch.index.analysis.TokenFilterFactory tokenFilterFactory = oldTokenFilter.get(
+            null,
+            mock(Environment.class),
+            null,
+            null
+        );
+
+        // test delegation
+        TokenStream createTokenStreamMock = mock(TokenStream.class);
+        TokenStream tokenStream = tokenFilterFactory.create(createTokenStreamMock);
+
+        assertSame(tokenStream, createTokenStreamMock);
+        verify(createTokenStreamMock).incrementToken();
+
+        TokenStream normalizeTokenStreamMock = mock(TokenStream.class);
+        tokenStream = tokenFilterFactory.normalize(normalizeTokenStreamMock);
+
+        assertSame(tokenStream, normalizeTokenStreamMock);
+        verify(normalizeTokenStreamMock).incrementToken();
+
+        assertThat(tokenFilterFactory.getAnalysisMode(), equalTo(org.elasticsearch.index.analysis.AnalysisMode.INDEX_TIME));
+
+        assertThat(tokenFilterFactory.name(), equalTo("TestTokenFilterFactory"));
+    }
+
+    public void testCharFilterFactoryDelegation() throws IOException {
+        StablePluginsRegistry registry = Mockito.mock(StablePluginsRegistry.class);
+        Mockito.when(
+            registry.getPluginInfosForExtensible(eq(org.elasticsearch.plugin.analysis.api.CharFilterFactory.class.getCanonicalName()))
+        ).thenReturn(List.of(new PluginInfo("namedComponentName1", TestCharFilterFactory.class.getName(), getClass().getClassLoader())));
+
+        Map<String, AnalysisModule.AnalysisProvider<org.elasticsearch.index.analysis.CharFilterFactory>> analysisProviderMap =
+            StableApiWrappers.oldApiForStableCharFilterFactory(registry);
+
+        AnalysisModule.AnalysisProvider<org.elasticsearch.index.analysis.CharFilterFactory> oldCharFilter = analysisProviderMap.get(
+            "namedComponentName1"
+        );
+
+        org.elasticsearch.index.analysis.CharFilterFactory charFilterFactory = oldCharFilter.get(null, mock(Environment.class), null, null);
+
+        // test delegation
+        Reader createReaderMock = mock(Reader.class);
+        Reader reader = charFilterFactory.create(createReaderMock);
+
+        assertSame(reader, createReaderMock);
+        verify(createReaderMock).read();
+
+        Reader normalizeReaderMock = mock(Reader.class);
+        reader = charFilterFactory.normalize(normalizeReaderMock);
+
+        assertSame(reader, normalizeReaderMock);
+        verify(normalizeReaderMock).read();
+
+        assertThat(charFilterFactory.name(), equalTo("TestCharFilterFactory"));
+    }
+
+    @NamedComponent(name = "DefaultConstrAnalyzerFactory")
+    public static class DefaultConstrAnalyzerFactory implements org.elasticsearch.plugin.analysis.api.AnalyzerFactory {
+
+        public DefaultConstrAnalyzerFactory(int x) {}
+
+        @Override
+        public Analyzer create() {
+            return null;
+        }
+
+    }
+
+    @NamedComponent(name = "TestAnalyzerFactory")
+    public static class TestAnalyzerFactory implements org.elasticsearch.plugin.analysis.api.AnalyzerFactory {
+
+        @Override
+        public Analyzer create() {
+            return Mockito.mock(Analyzer.class);
+        }
+
+    }
+
+    @NamedComponent(name = "TestTokenizerFactory")
+    public static class TestTokenizerFactory implements org.elasticsearch.plugin.analysis.api.TokenizerFactory {
+
+        @Override
+        public Tokenizer create() {
+            return Mockito.mock(Tokenizer.class);
+        }
+    }
+
+    @NamedComponent(name = "TestTokenFilterFactory")
+    public static class TestTokenFilterFactory implements org.elasticsearch.plugin.analysis.api.TokenFilterFactory {
+
+        @Override
+        public TokenStream create(TokenStream tokenStream) {
+            try {
+                tokenStream.incrementToken();
+            } catch (IOException e) {}
+            return tokenStream;
+        }
+
+        @Override
+        public TokenStream normalize(TokenStream tokenStream) {
+            try {
+                tokenStream.incrementToken();
+            } catch (IOException e) {}
+            return tokenStream;
+        }
+
+        @Override
+        public AnalysisMode getAnalysisMode() {
+            return AnalysisMode.INDEX_TIME;
+        }
+    }
+
+    @NamedComponent(name = "TestCharFilterFactory")
+    public static class TestCharFilterFactory implements org.elasticsearch.plugin.analysis.api.CharFilterFactory {
+
+        @Override
+        public Reader create(Reader reader) {
+            try {
+                reader.read();
+            } catch (IOException e) {}
+            return reader;
+        }
+
+        @Override
+        public Reader normalize(Reader reader) {
+            try {
+                reader.read();
+            } catch (IOException e) {}
+            return reader;
+        }
+
+    }
+}

+ 11 - 10
server/src/test/java/org/elasticsearch/plugins/scanners/StablePluginsRegistryTests.java

@@ -10,12 +10,12 @@ package org.elasticsearch.plugins.scanners;
 
 import org.elasticsearch.plugins.PluginBundle;
 import org.elasticsearch.test.ESTestCase;
-import org.hamcrest.Matchers;
 import org.mockito.Mockito;
 
 import java.util.HashMap;
 import java.util.Map;
 
+import static org.hamcrest.Matchers.containsInAnyOrder;
 import static org.mockito.ArgumentMatchers.any;
 
 public class StablePluginsRegistryTests extends ESTestCase {
@@ -49,16 +49,17 @@ public class StablePluginsRegistryTests extends ESTestCase {
         registry.scanBundleForStablePlugins(Mockito.mock(PluginBundle.class), loader2); // bundle 3
 
         assertThat(
-            registry.getNamedComponents(),
-            Matchers.equalTo(
-                Map.of(
-                    "extensibleInterfaceName",
-                    new NameToPluginInfo().put("namedComponentName1", new PluginInfo("namedComponentName1", "XXClassName", loader))
-                        .put("namedComponentName2", new PluginInfo("namedComponentName2", "YYClassName", loader)),
-                    "extensibleInterfaceName2",
-                    new NameToPluginInfo().put("namedComponentName3", new PluginInfo("namedComponentName3", "ZZClassName", loader2))
-                )
+            registry.getPluginInfosForExtensible("extensibleInterfaceName"),
+            containsInAnyOrder(
+                new PluginInfo("namedComponentName1", "XXClassName", loader),
+                new PluginInfo("namedComponentName2", "YYClassName", loader)
             )
         );
+
+        assertThat(
+            registry.getPluginInfosForExtensible("extensibleInterfaceName2"),
+            containsInAnyOrder(new PluginInfo("namedComponentName3", "ZZClassName", loader2))
+        );
+
     }
 }

+ 2 - 1
server/src/test/java/org/elasticsearch/snapshots/SnapshotResiliencyTests.java

@@ -160,6 +160,7 @@ import org.elasticsearch.ingest.IngestService;
 import org.elasticsearch.monitor.StatusInfo;
 import org.elasticsearch.node.ResponseCollectorService;
 import org.elasticsearch.plugins.PluginsService;
+import org.elasticsearch.plugins.scanners.StablePluginsRegistry;
 import org.elasticsearch.repositories.RepositoriesService;
 import org.elasticsearch.repositories.Repository;
 import org.elasticsearch.repositories.RepositoryData;
@@ -1899,7 +1900,7 @@ public class SnapshotResiliencyTests extends ESTestCase {
                             threadPool,
                             environment,
                             scriptService,
-                            new AnalysisModule(environment, Collections.emptyList()).getAnalysisRegistry(),
+                            new AnalysisModule(environment, Collections.emptyList(), new StablePluginsRegistry()).getAnalysisRegistry(),
                             Collections.emptyList(),
                             client
                         ),

+ 6 - 2
test/framework/src/main/java/org/elasticsearch/index/analysis/AnalysisTestsHelper.java

@@ -15,6 +15,7 @@ import org.elasticsearch.env.Environment;
 import org.elasticsearch.index.IndexSettings;
 import org.elasticsearch.indices.analysis.AnalysisModule;
 import org.elasticsearch.plugins.AnalysisPlugin;
+import org.elasticsearch.plugins.scanners.StablePluginsRegistry;
 import org.elasticsearch.test.ESTestCase;
 import org.elasticsearch.test.IndexSettingsModule;
 
@@ -54,8 +55,11 @@ public class AnalysisTestsHelper {
             actualSettings = settings;
         }
         final IndexSettings indexSettings = IndexSettingsModule.newIndexSettings("test", actualSettings);
-        final AnalysisRegistry analysisRegistry = new AnalysisModule(new Environment(actualSettings, configPath), Arrays.asList(plugins))
-            .getAnalysisRegistry();
+        final AnalysisRegistry analysisRegistry = new AnalysisModule(
+            new Environment(actualSettings, configPath),
+            Arrays.asList(plugins),
+            new StablePluginsRegistry()
+        ).getAnalysisRegistry();
         return new ESTestCase.TestAnalysis(
             analysisRegistry.build(indexSettings),
             analysisRegistry.buildTokenFilterFactories(indexSettings),

+ 6 - 1
test/framework/src/main/java/org/elasticsearch/test/AbstractBuilderTestCase.java

@@ -56,6 +56,7 @@ import org.elasticsearch.plugins.Plugin;
 import org.elasticsearch.plugins.PluginsService;
 import org.elasticsearch.plugins.ScriptPlugin;
 import org.elasticsearch.plugins.SearchPlugin;
+import org.elasticsearch.plugins.scanners.StablePluginsRegistry;
 import org.elasticsearch.script.MockScriptEngine;
 import org.elasticsearch.script.MockScriptService;
 import org.elasticsearch.script.ScriptCompiler;
@@ -386,7 +387,11 @@ public abstract class AbstractBuilderTestCase extends ESTestCase {
             ).withDeprecationHandler(LoggingDeprecationHandler.INSTANCE);
             IndexScopedSettings indexScopedSettings = settingsModule.getIndexScopedSettings();
             idxSettings = IndexSettingsModule.newIndexSettings(index, indexSettings, indexScopedSettings);
-            AnalysisModule analysisModule = new AnalysisModule(TestEnvironment.newEnvironment(nodeSettings), emptyList());
+            AnalysisModule analysisModule = new AnalysisModule(
+                TestEnvironment.newEnvironment(nodeSettings),
+                emptyList(),
+                new StablePluginsRegistry()
+            );
             IndexAnalyzers indexAnalyzers = analysisModule.getAnalysisRegistry().build(idxSettings);
             scriptService = new MockScriptService(Settings.EMPTY, scriptModule.engines, scriptModule.contexts);
             similarityService = new SimilarityService(idxSettings, null, Collections.emptyMap());

+ 2 - 1
test/framework/src/main/java/org/elasticsearch/test/ESTestCase.java

@@ -89,6 +89,7 @@ import org.elasticsearch.indices.IndicesModule;
 import org.elasticsearch.indices.analysis.AnalysisModule;
 import org.elasticsearch.plugins.AnalysisPlugin;
 import org.elasticsearch.plugins.Plugin;
+import org.elasticsearch.plugins.scanners.StablePluginsRegistry;
 import org.elasticsearch.script.MockScriptEngine;
 import org.elasticsearch.script.Script;
 import org.elasticsearch.script.ScriptType;
@@ -1651,7 +1652,7 @@ public abstract class ESTestCase extends LuceneTestCase {
     public static TestAnalysis createTestAnalysis(IndexSettings indexSettings, Settings nodeSettings, AnalysisPlugin... analysisPlugins)
         throws IOException {
         Environment env = TestEnvironment.newEnvironment(nodeSettings);
-        AnalysisModule analysisModule = new AnalysisModule(env, Arrays.asList(analysisPlugins));
+        AnalysisModule analysisModule = new AnalysisModule(env, Arrays.asList(analysisPlugins), new StablePluginsRegistry());
         AnalysisRegistry analysisRegistry = analysisModule.getAnalysisRegistry();
         return new TestAnalysis(
             analysisRegistry.build(indexSettings),

+ 3 - 1
x-pack/plugin/ml/src/test/java/org/elasticsearch/xpack/ml/aggs/categorization/CategorizeTextAggregatorTests.java

@@ -19,6 +19,7 @@ import org.elasticsearch.env.TestEnvironment;
 import org.elasticsearch.index.mapper.TextFieldMapper;
 import org.elasticsearch.indices.analysis.AnalysisModule;
 import org.elasticsearch.plugins.SearchPlugin;
+import org.elasticsearch.plugins.scanners.StablePluginsRegistry;
 import org.elasticsearch.search.aggregations.AggregatorTestCase;
 import org.elasticsearch.search.aggregations.bucket.histogram.Histogram;
 import org.elasticsearch.search.aggregations.bucket.histogram.HistogramAggregationBuilder;
@@ -46,7 +47,8 @@ public class CategorizeTextAggregatorTests extends AggregatorTestCase {
             TestEnvironment.newEnvironment(
                 Settings.builder().put(Environment.PATH_HOME_SETTING.getKey(), createTempDir().toString()).build()
             ),
-            List.of(new MachineLearning(Settings.EMPTY), new CommonAnalysisPlugin())
+            List.of(new MachineLearning(Settings.EMPTY), new CommonAnalysisPlugin()),
+            new StablePluginsRegistry()
         );
     }
 

+ 2 - 1
x-pack/plugin/ml/src/test/java/org/elasticsearch/xpack/ml/aggs/categorization/TokenListCategorizerTests.java

@@ -14,6 +14,7 @@ import org.elasticsearch.env.Environment;
 import org.elasticsearch.env.TestEnvironment;
 import org.elasticsearch.index.analysis.AnalysisRegistry;
 import org.elasticsearch.indices.analysis.AnalysisModule;
+import org.elasticsearch.plugins.scanners.StablePluginsRegistry;
 import org.elasticsearch.xpack.core.ml.job.config.CategorizationAnalyzerConfig;
 import org.elasticsearch.xpack.ml.MachineLearning;
 import org.elasticsearch.xpack.ml.job.categorization.CategorizationAnalyzer;
@@ -31,7 +32,7 @@ public class TokenListCategorizerTests extends CategorizationTestCase {
     public static AnalysisRegistry buildTestAnalysisRegistry(Environment environment) throws Exception {
         CommonAnalysisPlugin commonAnalysisPlugin = new CommonAnalysisPlugin();
         MachineLearning ml = new MachineLearning(environment.settings());
-        return new AnalysisModule(environment, List.of(commonAnalysisPlugin, ml)).getAnalysisRegistry();
+        return new AnalysisModule(environment, List.of(commonAnalysisPlugin, ml), new StablePluginsRegistry()).getAnalysisRegistry();
     }
 
     @Before

+ 2 - 1
x-pack/plugin/ml/src/test/java/org/elasticsearch/xpack/ml/job/categorization/CategorizationAnalyzerTests.java

@@ -12,6 +12,7 @@ import org.elasticsearch.env.Environment;
 import org.elasticsearch.env.TestEnvironment;
 import org.elasticsearch.index.analysis.AnalysisRegistry;
 import org.elasticsearch.indices.analysis.AnalysisModule;
+import org.elasticsearch.plugins.scanners.StablePluginsRegistry;
 import org.elasticsearch.test.ESTestCase;
 import org.elasticsearch.xpack.core.ml.job.config.CategorizationAnalyzerConfig;
 import org.elasticsearch.xpack.ml.MachineLearning;
@@ -42,7 +43,7 @@ public class CategorizationAnalyzerTests extends ESTestCase {
     public static AnalysisRegistry buildTestAnalysisRegistry(Environment environment) throws Exception {
         CommonAnalysisPlugin commonAnalysisPlugin = new CommonAnalysisPlugin();
         MachineLearning ml = new MachineLearning(environment.settings());
-        return new AnalysisModule(environment, Arrays.asList(commonAnalysisPlugin, ml)).getAnalysisRegistry();
+        return new AnalysisModule(environment, Arrays.asList(commonAnalysisPlugin, ml), new StablePluginsRegistry()).getAnalysisRegistry();
     }
 
     @Before