How OpenSearch Plugins Really Work: Architecture & Extension Points
OpenSearch is powerful out of the box, but its true flexibility comes from plugins. Yet most developers treat plugins as black boxes: you install them, they work, and you move on. But what if you need to build one? Or understand why a plugin broke after an upgrade? Or design a system that integrates with OpenSearch's plugin ecosystem?
In this post, I'll walk you through how plugins actually work: compilation, packaging, installation, and the extension points that make customization possible. By the end, you'll understand the mechanics well enough to build your own.
The Plugin Lifecycle: From Source to Running Code
Step 1: Writing and Compiling a Plugin
A plugin is a Java project with dependencies on OpenSearch core. At minimum, you need:
dependencies {
compileOnly "org.opensearch:opensearch:${opensearch_version}"
}
That compileOnly is critical: your plugin compiles against OpenSearch, but doesn't bundle it. The plugin will run inside the OpenSearch JVM, using the host's core libraries.
Your plugin entry point is a class that extends Plugin. For example:
public class MyCustomPlugin extends Plugin implements SearchPlugin {
@Override
public List<QuerySpec<?>> getQueries() {
return Collections.singletonList(
new QuerySpec<>(MyCustomQuery.NAME, MyCustomQuery::new, p -> MyCustomQuery.fromXContent(p))
);
}
}
This simple declaration tells OpenSearch: "I provide a custom query type called my_custom_query."
Step 2: Building the Plugin Artifact
When you run gradle build, you produce a .zip file containing:
my-plugin-1.0.0.zip
├── opensearch-plugin-descriptor.properties
├── lib/
│ ├── my-plugin-1.0.0.jar
│ └── my-dependencies.jar (if any third-party libs needed)
├── bin/ (optional: scripts)
└── config/ (optional: default settings)
The opensearch-plugin-descriptor.properties file is the plugin manifest:
name=my-custom-plugin
description=My custom query plugin
version=1.0.0
opensearch.version=2.13.0
java.version=11
classname=com.example.MyCustomPlugin
This manifest declares: which OpenSearch version the plugin targets, what Java version it needs, and crucially, the entry point class name.
Step 3: Installation via the opensearch-plugin Tool
You install via CLI:
./bin/opensearch-plugin install file:///path/to/my-plugin-1.0.0.zip
The tool does several things:
-
Verifies the manifest - reads
opensearch-plugin-descriptor.properties - Version checks - ensures plugin targets the installed OpenSearch version
-
Extracts - unpacks to
plugins/my-custom-plugin/ - Loads classes - prepares the plugin for JVM loading
- Restarts the node - required to load the plugin code
After restart, your plugin code is live.
Class Loader Isolation and Bootstrap
Here's where it gets interesting. Your plugin code runs in the same JVM as OpenSearch core. How does OpenSearch prevent your plugin from accidentally (or maliciously) breaking core?
Class Loader Isolation:
OpenSearch uses a custom PluginClassLoader for each plugin. This loader is a child of the core class loader, but has its own namespace:
- Core classes (org.opensearch.*) resolve from the main class loader
- Plugin classes resolve from the plugin's class loader first
- If a class isn't found in the plugin loader, it falls back to core
This prevents version conflicts. If your plugin wants to use a specific version of a library, it can bundle it, and its class loader will find that version first without conflicting with core.
Bootstrap Contract:
When OpenSearch starts, it:
- Discovers all plugins in
plugins/directory - Reads each plugin's descriptor
- Creates a
PluginClassLoaderfor each - Instantiates each plugin's entry point class via reflection
- Calls lifecycle methods:
onIndexModule(),onNodeStarted(), etc.
If a plugin fails to load, OpenSearch will refuse to start. This is intentional: it's safer to fail loudly than to silently omit a plugin that applications might depend on.
Extension Points: How Plugins Hook Into OpenSearch
A plugin doesn't have direct access to internal OpenSearch code. Instead, it implements well-defined extension point interfaces. OpenSearch discovers these implementations and calls them at the right moments.
SearchPlugin: Custom Query Types and Aggregations
The most common extension point for search-focused plugins:
public class MySearchPlugin extends Plugin implements SearchPlugin {
@Override
public List<QuerySpec<?>> getQueries() {
// Register custom query types
return Collections.singletonList(
new QuerySpec<>(MyQuery.NAME, MyQuery::new, p -> MyQuery.fromXContent(p))
);
}
@Override
public List<AggregationSpec> getAggregations() {
// Register custom aggregations
return Collections.singletonList(
new AggregationSpec(MyAggregation.NAME, MyAggregation::new, p -> MyAggregation.parse(p))
);
}
@Override
public List<ScoreFunctionSpec<?>> getScoreFunctions() {
// Register custom scoring functions
return Collections.singletonList(
new ScoreFunctionSpec<>(MyScoreFunction.NAME, MyScoreFunction::new, p -> MyScoreFunction.parse(p))
);
}
}
Once registered, your custom query is available via the REST API:
GET /my-index/_search
{
"query": {
"my_custom_query": {
"field": "title",
"boost": 2.0
}
}
}
ActionPlugin: Custom REST and Transport Actions
For plugins that need custom REST endpoints or transport operations:
public class MyActionPlugin extends Plugin implements ActionPlugin {
@Override
public List<ActionHandler<?, ?>> getActions() {
return Collections.singletonList(
new ActionHandler<>(MyAction.INSTANCE, TransportMyAction.class)
);
}
@Override
public List<RestHandler> getRestHandlers(Settings settings, RestController restController,
ClusterSettings clusterSettings, IndexScopedSettings indexScopedSettings,
SettingsFilter settingsFilter, List<NamedWriteableRegistry> namedWriteableRegistries,
List<NamedXContentRegistry> namedXContentRegistries, Supplier<DiscoveryNodes> nodesInCluster,
Supplier<ClusterState> clusterStateSupplier) {
return Collections.singletonList(
new RestMyHandler()
);
}
}
Now you can hit a custom endpoint:
POST /_plugin/my-action
{
"param1": "value"
}
MapperPlugin: Custom Field Types
If you need a new field type (beyond standard text, keyword, numeric, etc.):
public class MyMapperPlugin extends Plugin implements MapperPlugin {
@Override
public Map<String, Mapper.TypeParser> getMappers() {
return Collections.singletonMap(
"my_custom_field",
(name, node, parserContext) -> new MyCustomFieldMapper(name, parserContext)
);
}
}
Now you can use it in mappings:
PUT /my-index
{
"mappings": {
"properties": {
"custom_field": {
"type": "my_custom_field",
"analyzer": "standard"
}
}
}
}
EnginePlugin: Custom Lucene Behavior
For advanced use cases, you can hook into the Lucene engine itself:
public class MyEnginePlugin extends Plugin implements EnginePlugin {
@Override
public Optional<EngineFactory> getEngineFactory(IndexSettings indexSettings) {
return Optional.of(config -> new MyCustomEngine(config));
}
}
IngestPlugin: Custom Processors
For plugins that process documents during ingestion:
public class MyIngestPlugin extends Plugin implements IngestPlugin {
@Override
public Map<String, Processor.Factory> getProcessors(Processor.Parameters parameters) {
return Collections.singletonMap(
"my_processor",
(factories, tag, config) -> new MyIngestProcessor(tag, config)
);
}
}
Use in pipeline:
PUT /_ingest/pipeline/my_pipeline
{
"processors": [
{
"my_processor": {
"field": "content"
}
}
]
}
Real-World Example: The Search Relevance Plugin
OpenSearch's own search-relevance plugin demonstrates these concepts in action. It provides:
- Custom query types for A/B testing search relevance
- Custom aggregations for metrics collection
- REST endpoints to manage experiments
- System indexes (prefixed with
.plugins-search-rel-) to store experiment state - Concurrent search request deciders (OpenSearch 2.17+) for custom query execution strategies
The plugin is battle-tested in production, used by teams optimizing ranking and relevance across massive datasets.
System Indexes: How Plugins Store Their Own State
Most non-trivial plugins need to persist data. Rather than requiring external storage, they use system indexes within OpenSearch itself.
System indexes are prefixed with .plugins- or .opendistro-:
.plugins-search-rel-<version>-experiments
.plugins-search-rel-<version>-notes
.plugins-ml-config
.opendistro-job-scheduler-lock
The challenge: how do you evolve the schema without breaking existing deployments?
OpenSearch plugins use a schema versioning pattern:
public static final String SCHEMA_VERSION = "1";
private void ensureIndexInitialized() {
if (!indexExists()) {
createIndex();
return;
}
Map<String, Object> indexMeta = getIndexMeta();
String currentVersion = (String) indexMeta.getOrDefault("schema_version", "0");
if (!currentVersion.equals(SCHEMA_VERSION)) {
migrateSchema(currentVersion, SCHEMA_VERSION);
}
}
private void migrateSchema(String fromVersion, String toVersion) {
// Use Put Mapping API to add new fields (additive only)
// Never remove or change existing field types
putMapping(newFields);
}
This ensures:
- Old documents coexist with new schema
- Upgrades are backwards compatible
- No downtime required for schema evolution
Performance and Reliability Considerations
Startup Time
Each plugin adds to startup time. Large plugins or plugins that do heavy initialization can slow cluster startup. Monitor this in production.
Class Loader Memory
Each plugin gets its own class loader, holding copies of loaded classes in memory. Many plugins = higher memory footprint. Keep plugin count reasonable.
API Stability
OpenSearch's plugin APIs are versioned with OpenSearch itself. When OpenSearch releases a major version, plugins must recompile and test. This is by design: it ensures plugins stay compatible with core.
Security
Plugins run in the same JVM as OpenSearch core. A malicious or buggy plugin can crash the entire node. Only install plugins from trusted sources. In multi-tenant environments, consider network isolation or separate clusters.
Building Your Own Plugin: Where to Start
-
Clone the plugin template: OpenSearch provides
plugin-templaterepository - Implement your extension point (SearchPlugin, ActionPlugin, etc.)
- Write tests - use OpenSearch's testing framework
-
Build the .zip -
gradle buildproduces the artifact -
Install locally -
./bin/opensearch-plugin install file://... - Test end-to-end - verify your REST endpoint/query/aggregation works
- Publish - host on artifact repository or GitHub Releases
Conclusion
OpenSearch plugins are not magic. They're well-structured Java code that hooks into OpenSearch via extension points. Understanding this architecture demystifies plugin behavior, helps you troubleshoot issues, and opens the door to building custom extensions.
Whether you're optimizing search relevance, integrating with custom systems, or building observability tooling, the plugin architecture gives you the hooks you need without compromising core stability.
The next time a plugin breaks after an upgrade, you'll know exactly where to look. And when you need to build one, you'll have a mental model of how the pieces fit together.
Want to explore further?
- OpenSearch Plugin Developer Guide: https://opensearch.org/docs/latest/plugins/intro/
- Plugin Template Repository: https://github.com/opensearch-project/plugin-template
I'm Prithvi S, Staff Software Engineer at Cloudera and Opensource Enthusiast. Follow my work on GitHub: https://github.com/iprithv
Top comments (0)