diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 2b524e1385629..84609f9f5383f 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -20,6 +20,7 @@ supercsv = "2.4.0"
log4j = "2.25.4"
error_prone_annotations = "2.45.0"
slf4j = "2.0.17"
+netflix_concurrency_limits = "0.4.0"
asm = "9.10.1"
jettison = "1.5.4"
woodstox = "6.4.0"
diff --git a/modules/concurrency-limit/build.gradle b/modules/concurrency-limit/build.gradle
new file mode 100644
index 0000000000000..b3416de9e6861
--- /dev/null
+++ b/modules/concurrency-limit/build.gradle
@@ -0,0 +1,21 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+apply plugin: 'opensearch.internal-cluster-test'
+
+opensearchplugin {
+ description = 'Adaptive per-action concurrency limiting using the Netflix concurrency-limits library'
+ classname = 'org.opensearch.concurrency.ActionConcurrencyLimitPlugin'
+}
+
+dependencies {
+ implementation "com.netflix.concurrency-limits:concurrency-limits-core:${versions.netflix_concurrency_limits}"
+ compileOnly "org.apache.logging.log4j:log4j-api:${versions.log4j}"
+ testImplementation(project(":test:framework"))
+ runtimeOnly "org.slf4j:slf4j-api:${versions.slf4j}"
+}
diff --git a/modules/concurrency-limit/licenses/concurrency-limits-core-0.4.0.jar.sha1 b/modules/concurrency-limit/licenses/concurrency-limits-core-0.4.0.jar.sha1
new file mode 100644
index 0000000000000..20683912d7d5d
--- /dev/null
+++ b/modules/concurrency-limit/licenses/concurrency-limits-core-0.4.0.jar.sha1
@@ -0,0 +1 @@
+9bf32c330cad19a6e564b460a65132aa765b6440
\ No newline at end of file
diff --git a/modules/concurrency-limit/licenses/concurrency-limits-core-LICENSE.txt b/modules/concurrency-limit/licenses/concurrency-limits-core-LICENSE.txt
new file mode 100644
index 0000000000000..cf95ba481fefc
--- /dev/null
+++ b/modules/concurrency-limit/licenses/concurrency-limits-core-LICENSE.txt
@@ -0,0 +1,201 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to the Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by the Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding any notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. Please also get an
+ OpenSearch-compatible source control URI for your project and
+ replace the "[project-name]" with it.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed 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.
diff --git a/modules/concurrency-limit/licenses/concurrency-limits-core-NOTICE.txt b/modules/concurrency-limit/licenses/concurrency-limits-core-NOTICE.txt
new file mode 100644
index 0000000000000..1c45d5385954a
--- /dev/null
+++ b/modules/concurrency-limit/licenses/concurrency-limits-core-NOTICE.txt
@@ -0,0 +1,2 @@
+concurrency-limits-core
+Copyright 2018 Netflix, Inc.
diff --git a/modules/concurrency-limit/licenses/slf4j-api-2.0.17.jar.sha1 b/modules/concurrency-limit/licenses/slf4j-api-2.0.17.jar.sha1
new file mode 100644
index 0000000000000..435f6c13a28b6
--- /dev/null
+++ b/modules/concurrency-limit/licenses/slf4j-api-2.0.17.jar.sha1
@@ -0,0 +1 @@
+d9e58ac9c7779ba3bf8142aff6c830617a7fe60f
\ No newline at end of file
diff --git a/modules/concurrency-limit/licenses/slf4j-api-LICENSE.txt b/modules/concurrency-limit/licenses/slf4j-api-LICENSE.txt
new file mode 100644
index 0000000000000..1a3d053237bec
--- /dev/null
+++ b/modules/concurrency-limit/licenses/slf4j-api-LICENSE.txt
@@ -0,0 +1,24 @@
+Copyright (c) 2004-2022 QOS.ch Sarl (Switzerland)
+All rights reserved.
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+
+
diff --git a/modules/concurrency-limit/licenses/slf4j-api-NOTICE.txt b/modules/concurrency-limit/licenses/slf4j-api-NOTICE.txt
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/modules/concurrency-limit/src/main/java/org/opensearch/concurrency/ActionConcurrencyLimitFilter.java b/modules/concurrency-limit/src/main/java/org/opensearch/concurrency/ActionConcurrencyLimitFilter.java
new file mode 100644
index 0000000000000..e35f389c714c1
--- /dev/null
+++ b/modules/concurrency-limit/src/main/java/org/opensearch/concurrency/ActionConcurrencyLimitFilter.java
@@ -0,0 +1,98 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.concurrency;
+
+import org.opensearch.action.ActionRequest;
+import org.opensearch.action.support.ActionFilter;
+import org.opensearch.action.support.ActionFilterChain;
+import org.opensearch.action.support.ActionRequestMetadata;
+import org.opensearch.core.action.ActionListener;
+import org.opensearch.core.action.ActionResponse;
+import org.opensearch.core.concurrency.OpenSearchRejectedExecutionException;
+import org.opensearch.tasks.Task;
+
+import java.util.Optional;
+
+import com.netflix.concurrency.limits.Limiter;
+
+/**
+ * {@link ActionFilter} that enforces adaptive concurrency limits on any configured action.
+ *
+ * Delegates to {@link ActionConcurrencyLimiterRegistry} for dispatch. If no limiter is
+ * configured for the action, the request passes through unchanged. When the limit is
+ * reached (after the warm-up period), the request is rejected with HTTP 429.
+ * During warm-up, a no-op listener is returned so the limiter can calibrate without
+ * rejecting real traffic — the filter still wraps the response listener, but the no-op
+ * callbacks have no side effects.
+ *
+ * The task and request are passed to {@link ActionConcurrencyLimiterRegistry#tryAcquire}
+ * so that partition resolvers can inspect the request (e.g. read a header). A
+ * {@link SearchRequestContext} is allocated only when the action's limiter is partitioned,
+ * avoiding any per-request allocation on the non-partitioned hot path.
+ */
+public class ActionConcurrencyLimitFilter implements ActionFilter {
+
+ private final ActionConcurrencyLimiterRegistry registry;
+
+ /**
+ * Creates a new filter backed by the given registry.
+ *
+ * @param registry the limiter registry
+ */
+ public ActionConcurrencyLimitFilter(ActionConcurrencyLimiterRegistry registry) {
+ this.registry = registry;
+ }
+
+ @Override
+ public int order() {
+ return 1;
+ }
+
+ @Override
+ public void apply(
+ Task task,
+ String action,
+ Request request,
+ ActionRequestMetadata actionRequestMetadata,
+ ActionListener listener,
+ ActionFilterChain chain
+ ) {
+ if (!registry.hasLimiterFor(action)) {
+ chain.proceed(task, action, request, listener);
+ return;
+ }
+
+ Optional token = registry.tryAcquire(action, task, request);
+ if (token.isEmpty()) {
+ listener.onFailure(
+ new OpenSearchRejectedExecutionException("request rejected: concurrency limit reached for action [" + action + "]", true)
+ );
+ return;
+ }
+
+ Limiter.Listener limitToken = token.get();
+ chain.proceed(task, action, request, new ActionListener() {
+ @Override
+ public void onResponse(Response response) {
+ limitToken.onSuccess();
+ listener.onResponse(response);
+ }
+
+ @Override
+ public void onFailure(Exception e) {
+ if (e instanceof OpenSearchRejectedExecutionException) {
+ limitToken.onDropped();
+ } else {
+ limitToken.onIgnore();
+ }
+ listener.onFailure(e);
+ }
+ });
+ }
+}
diff --git a/modules/concurrency-limit/src/main/java/org/opensearch/concurrency/ActionConcurrencyLimitPlugin.java b/modules/concurrency-limit/src/main/java/org/opensearch/concurrency/ActionConcurrencyLimitPlugin.java
new file mode 100644
index 0000000000000..0b37c9e27f1da
--- /dev/null
+++ b/modules/concurrency-limit/src/main/java/org/opensearch/concurrency/ActionConcurrencyLimitPlugin.java
@@ -0,0 +1,154 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.concurrency;
+
+import org.opensearch.action.ActionConcurrencyLimiterStats;
+import org.opensearch.action.support.ActionFilter;
+import org.opensearch.cluster.metadata.IndexNameExpressionResolver;
+import org.opensearch.cluster.service.ClusterService;
+import org.opensearch.common.settings.Setting;
+import org.opensearch.core.common.io.stream.NamedWriteableRegistry;
+import org.opensearch.core.xcontent.NamedXContentRegistry;
+import org.opensearch.env.Environment;
+import org.opensearch.env.NodeEnvironment;
+import org.opensearch.plugins.ActionPlugin;
+import org.opensearch.plugins.ConcurrencyLimiterStatsPlugin;
+import org.opensearch.plugins.Plugin;
+import org.opensearch.plugins.TelemetryAwarePlugin;
+import org.opensearch.repositories.RepositoriesService;
+import org.opensearch.rest.RestHeaderDefinition;
+import org.opensearch.script.ScriptService;
+import org.opensearch.telemetry.metrics.MetricsRegistry;
+import org.opensearch.telemetry.tracing.Tracer;
+import org.opensearch.threadpool.ThreadPool;
+import org.opensearch.transport.client.Client;
+import org.opensearch.watcher.ResourceWatcherService;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.function.Supplier;
+
+/**
+ * Module plugin that enables adaptive per-action concurrency limiting.
+ *
+ * Operators configure limiters via cluster settings using an alias as the namespace key:
+ *
+ * PUT /_cluster/settings
+ * { "persistent": {
+ * "concurrency_limit.action.search.action_name": "indices:data/read/search",
+ * "concurrency_limit.action.search.mode": "enforced",
+ * "concurrency_limit.action.search.algorithm": "vegas"
+ * }}
+ *
+ * Any OpenSearch action can be limited — no code change is required.
+ */
+public class ActionConcurrencyLimitPlugin extends Plugin implements ActionPlugin, ConcurrencyLimiterStatsPlugin, TelemetryAwarePlugin {
+
+ /** Creates a new {@link ActionConcurrencyLimitPlugin}. */
+ public ActionConcurrencyLimitPlugin() {}
+
+ /**
+ * HTTP request header that the {@code byHeader} partition resolver reads to determine a
+ * request's partition. Registered via {@link #getRestHeaders()} so it is copied from the
+ * REST request into the transport {@link org.opensearch.common.util.concurrent.ThreadContext},
+ * where the action filter can read it. This is the only header the {@code byHeader} resolver
+ * reads; it is not configurable.
+ */
+ public static final String TIER_HEADER = "X-Request-Tier";
+
+ private volatile ActionConcurrencyLimiterRegistry registry;
+
+ @Override
+ public Collection getRestHeaders() {
+ // Propagate the tier header from REST into the transport ThreadContext so the
+ // byHeader partition resolver can read it during the action filter.
+ return List.of(new RestHeaderDefinition(TIER_HEADER, false));
+ }
+
+ @Override
+ public Collection createComponents(
+ Client client,
+ ClusterService clusterService,
+ ThreadPool threadPool,
+ ResourceWatcherService resourceWatcherService,
+ ScriptService scriptService,
+ NamedXContentRegistry xContentRegistry,
+ Environment environment,
+ NodeEnvironment nodeEnvironment,
+ NamedWriteableRegistry namedWriteableRegistry,
+ IndexNameExpressionResolver indexNameExpressionResolver,
+ Supplier repositoriesServiceSupplier
+ ) {
+ registry = new ActionConcurrencyLimiterRegistry(
+ clusterService.getSettings(),
+ clusterService.getClusterSettings(),
+ threadPool.getThreadContext()
+ );
+ return List.of(registry);
+ }
+
+ /**
+ * Telemetry-aware component creation. Invoked after the standard {@link #createComponents}
+ * (see {@code Node.java}, which runs {@code Plugin.createComponents} before
+ * {@code TelemetryAwarePlugin.createComponents}), so {@link #registry} is already initialized.
+ * Wires the push-telemetry bridge that mirrors the pull-path ({@code NodeStats}) metrics via the
+ * {@link MetricsRegistry}. When telemetry is disabled the node supplies a no-op registry, so the
+ * publisher is inert.
+ *
+ * @param client the node client
+ * @param clusterService the cluster service
+ * @param threadPool the thread pool
+ * @param resourceWatcherService the resource watcher service
+ * @param scriptService the script service
+ * @param xContentRegistry the XContent registry
+ * @param environment the node environment settings
+ * @param nodeEnvironment the node environment
+ * @param namedWriteableRegistry the named writeable registry
+ * @param indexNameExpressionResolver the index name expression resolver
+ * @param repositoriesServiceSupplier the repositories service supplier
+ * @param tracer the tracer
+ * @param metricsRegistry the metrics registry
+ */
+ @Override
+ public Collection createComponents(
+ Client client,
+ ClusterService clusterService,
+ ThreadPool threadPool,
+ ResourceWatcherService resourceWatcherService,
+ ScriptService scriptService,
+ NamedXContentRegistry xContentRegistry,
+ Environment environment,
+ NodeEnvironment nodeEnvironment,
+ NamedWriteableRegistry namedWriteableRegistry,
+ IndexNameExpressionResolver indexNameExpressionResolver,
+ Supplier repositoriesServiceSupplier,
+ Tracer tracer,
+ MetricsRegistry metricsRegistry
+ ) {
+ ConcurrencyLimiterMetricsPublisher publisher = new ConcurrencyLimiterMetricsPublisher(metricsRegistry);
+ registry.setMetricsListener(publisher);
+ return List.of(publisher);
+ }
+
+ @Override
+ public List getActionFilters() {
+ return List.of(new ActionConcurrencyLimitFilter(registry));
+ }
+
+ @Override
+ public List> getSettings() {
+ return ActionConcurrencyLimiterRegistry.ALL_SETTINGS;
+ }
+
+ @Override
+ public ActionConcurrencyLimiterStats getConcurrencyLimiterStats() {
+ ActionConcurrencyLimiterRegistry r = this.registry;
+ return r != null ? r.getStats() : null;
+ }
+}
diff --git a/modules/concurrency-limit/src/main/java/org/opensearch/concurrency/ActionConcurrencyLimiterRegistry.java b/modules/concurrency-limit/src/main/java/org/opensearch/concurrency/ActionConcurrencyLimiterRegistry.java
new file mode 100644
index 0000000000000..38e6bad7d45eb
--- /dev/null
+++ b/modules/concurrency-limit/src/main/java/org/opensearch/concurrency/ActionConcurrencyLimiterRegistry.java
@@ -0,0 +1,913 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.concurrency;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.opensearch.action.ActionConcurrencyLimiterStats;
+import org.opensearch.action.ActionConcurrencyLimiterStats.ActionLimiterSnapshot;
+import org.opensearch.action.ActionRequest;
+import org.opensearch.common.settings.ClusterSettings;
+import org.opensearch.common.settings.Setting;
+import org.opensearch.common.settings.Setting.Property;
+import org.opensearch.common.settings.Settings;
+import org.opensearch.common.unit.TimeValue;
+import org.opensearch.common.util.concurrent.ThreadContext;
+import org.opensearch.concurrency.limit.BurstAwareLimit;
+import org.opensearch.concurrency.limit.OpenSearchVegasLimit;
+import org.opensearch.tasks.Task;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+import com.netflix.concurrency.limits.Limit;
+import com.netflix.concurrency.limits.Limiter;
+import com.netflix.concurrency.limits.limit.AIMDLimit;
+import com.netflix.concurrency.limits.limit.AbstractLimit;
+import com.netflix.concurrency.limits.limit.Gradient2Limit;
+import com.netflix.concurrency.limits.limit.GradientLimit;
+import com.netflix.concurrency.limits.limiter.AbstractPartitionedLimiter;
+import com.netflix.concurrency.limits.limiter.SimpleLimiter;
+
+/**
+ * Registry that manages one adaptive concurrency limiter per configured action alias.
+ *
+ * Operators configure limiters at runtime via cluster settings using an alias as the
+ * namespace key:
+ *
+ * concurrency_limit.action.search.action_name = indices:data/read/search
+ * concurrency_limit.action.search.mode = enforced
+ * concurrency_limit.action.search.algorithm = vegas
+ *
+ * Any action can be limited — no code change is required to add a new action.
+ *
+ * Supported modes: {@code disabled} (default, no-op), {@code monitor_only} (tracks
+ * metrics but never rejects), {@code enforced} (actively rejects when limit reached).
+ *
+ * Partitioning: when {@code partitions} is set, the total limit is divided across
+ * named sub-pools. A {@link PartitionResolver} maps each request to a partition.
+ */
+public class ActionConcurrencyLimiterRegistry {
+
+ private static final Logger LOG = LogManager.getLogger(ActionConcurrencyLimiterRegistry.class);
+
+ static final String SETTING_PREFIX = "concurrency_limit.action.";
+
+ // -------------------------------------------------------------------------
+ // AffixSettings — one per tunable parameter
+ // -------------------------------------------------------------------------
+
+ /** Maps an alias to the transport action name it limits. */
+ public static final Setting.AffixSetting ACTION_NAME_SETTING = Setting.affixKeySetting(
+ SETTING_PREFIX,
+ "action_name",
+ k -> Setting.simpleString(k, "", Property.Dynamic, Property.NodeScope)
+ );
+
+ static final Set VALID_MODES = Set.of("disabled", "enforced", "monitor_only");
+
+ /** Limiter mode: {@code disabled}, {@code enforced}, or {@code monitor_only}. */
+ public static final Setting.AffixSetting MODE_SETTING = Setting.affixKeySetting(
+ SETTING_PREFIX,
+ "mode",
+ k -> new Setting<>(k, "disabled", v -> {
+ if (!VALID_MODES.contains(v)) {
+ throw new IllegalArgumentException("Unknown mode '" + v + "'. Must be one of: " + VALID_MODES);
+ }
+ return v;
+ }, Property.Dynamic, Property.NodeScope)
+ );
+
+ /** Limit algorithm: {@code vegas}, {@code gradient2}, or {@code aimd}. */
+ public static final Setting.AffixSetting ALGORITHM_SETTING = Setting.affixKeySetting(
+ SETTING_PREFIX,
+ "algorithm",
+ k -> new Setting<>(k, "vegas", v -> {
+ try {
+ LimitAlgorithm.valueOf(v.toUpperCase(Locale.ROOT));
+ } catch (IllegalArgumentException e) {
+ throw new IllegalArgumentException("Unknown algorithm '" + v + "'. Must be one of: vegas, gradient2, aimd");
+ }
+ return v.toLowerCase(Locale.ROOT);
+ }, Property.Dynamic, Property.NodeScope)
+ );
+
+ static final Setting.AffixSetting LIMIT_CONFIG_SETTING = Setting.affixKeySetting(
+ SETTING_PREFIX,
+ "limit",
+ k -> Setting.groupSetting(k + ".", Property.Dynamic, Property.NodeScope)
+ );
+
+ /** Duration after (re)configuration during which the limiter does not reject requests. */
+ public static final Setting.AffixSetting WARMUP_DURATION_SETTING = Setting.affixKeySetting(
+ SETTING_PREFIX,
+ "warmup_duration",
+ k -> Setting.timeSetting(k, TimeValue.timeValueMinutes(5), TimeValue.timeValueMinutes(0), Property.Dynamic, Property.NodeScope)
+ );
+
+ static final Setting.AffixSetting AIMD_CONFIG_SETTING = Setting.affixKeySetting(
+ SETTING_PREFIX,
+ "aimd",
+ k -> Setting.groupSetting(k + ".", Property.Dynamic, Property.NodeScope)
+ );
+
+ static final Setting.AffixSetting GRADIENT2_CONFIG_SETTING = Setting.affixKeySetting(
+ SETTING_PREFIX,
+ "gradient2",
+ k -> Setting.groupSetting(k + ".", Property.Dynamic, Property.NodeScope)
+ );
+
+ static final Setting.AffixSetting VEGAS_CONFIG_SETTING = Setting.affixKeySetting(
+ SETTING_PREFIX,
+ "vegas",
+ k -> Setting.groupSetting(k + ".", Property.Dynamic, Property.NodeScope)
+ );
+
+ static final Setting.AffixSetting BURST_CONFIG_SETTING = Setting.affixKeySetting(
+ SETTING_PREFIX,
+ "burst",
+ k -> Setting.groupSetting(k + ".", Property.Dynamic, Property.NodeScope)
+ );
+
+ // -------------------------------------------------------------------------
+ // Partition settings
+ // -------------------------------------------------------------------------
+
+ /** Valid {@code partition.resolver} values. Empty string means "no resolver". */
+ static final Set VALID_RESOLVERS = Set.of("", "byHeader", "fixed", "bySearchType");
+
+ /** Ordered list of partition names, e.g. {@code ["premium", "standard", "default"]}. */
+ public static final Setting.AffixSetting> PARTITIONS_SETTING = Setting.affixKeySetting(
+ SETTING_PREFIX,
+ "partitions",
+ k -> Setting.listSetting(k, List.of(), Function.identity(), Property.Dynamic, Property.NodeScope)
+ );
+
+ /**
+ * Group setting capturing all per-alias partition configuration under
+ * {@code concurrency_limit.action..partition.*}. This includes:
+ *
+ * {@code .percent} — share of total limit (0.0–1.0)
+ * {@code .delay_ms} — optional reject-delay for this partition
+ * {@code resolver} — resolver type ({@code byHeader}, {@code fixed})
+ * {@code resolver.fixed.partition} — target partition for {@code fixed} resolver
+ *
+ */
+ public static final Setting.AffixSetting PARTITION_CONFIG_SETTING = Setting.affixKeySetting(
+ SETTING_PREFIX,
+ "partition",
+ k -> Setting.groupSetting(k + ".", Property.Dynamic, Property.NodeScope)
+ );
+
+ /** All settings registered by this module. */
+ public static final List> ALL_SETTINGS = List.of(
+ ACTION_NAME_SETTING,
+ MODE_SETTING,
+ ALGORITHM_SETTING,
+ LIMIT_CONFIG_SETTING,
+ WARMUP_DURATION_SETTING,
+ AIMD_CONFIG_SETTING,
+ GRADIENT2_CONFIG_SETTING,
+ VEGAS_CONFIG_SETTING,
+ BURST_CONFIG_SETTING,
+ PARTITIONS_SETTING,
+ PARTITION_CONFIG_SETTING
+ );
+
+ // -------------------------------------------------------------------------
+ // Algorithm enum
+ // -------------------------------------------------------------------------
+
+ enum LimitAlgorithm {
+ VEGAS {
+ @Override
+ Limit build(AliasConfig cfg) {
+ return OpenSearchVegasLimit.newBuilder()
+ .initialLimit(cfg.initialLimit)
+ .maxConcurrency(cfg.maxLimit)
+ .upDriftFactor(cfg.vegasUpDriftFactor)
+ .increaseHysteresis(cfg.vegasIncreaseBarrier)
+ .decreaseHysteresis(cfg.vegasDecreaseBarrier)
+ .probeInflightThreshold(cfg.vegasBaselineResetLoadThreshold)
+ .build();
+ }
+ },
+ GRADIENT2 {
+ @Override
+ Limit build(AliasConfig cfg) {
+ return Gradient2Limit.newBuilder()
+ .initialLimit(cfg.initialLimit)
+ .maxConcurrency(cfg.maxLimit)
+ .rttTolerance(cfg.gradient2RttTolerance)
+ .build();
+ }
+ },
+ AIMD {
+ @Override
+ Limit build(AliasConfig cfg) {
+ return AIMDLimit.newBuilder()
+ .initialLimit(cfg.initialLimit)
+ .minLimit(1)
+ .maxLimit(cfg.maxLimit)
+ .backoffRatio(cfg.aimdBackoffRatio)
+ .build();
+ }
+ };
+
+ abstract Limit build(AliasConfig cfg);
+ }
+
+ // -------------------------------------------------------------------------
+ // Per-alias mutable config
+ // -------------------------------------------------------------------------
+
+ static final class AliasConfig {
+ volatile String actionName = "";
+ volatile String mode = "disabled";
+ volatile String algorithm = "vegas";
+ volatile int initialLimit = 20;
+ volatile int maxLimit = 200;
+ volatile long warmupMillis = TimeValue.timeValueMinutes(5).getMillis();
+ volatile double aimdBackoffRatio = 0.9;
+ volatile double gradient2RttTolerance = 1.5;
+ volatile int vegasUpDriftFactor = 1;
+ volatile int vegasIncreaseBarrier = 1;
+ volatile int vegasDecreaseBarrier = 1;
+ volatile double vegasBaselineResetLoadThreshold = 0.5;
+ volatile int burstCapacity = 0;
+ volatile int burstCloseAfter = 5;
+ volatile int burstOpenAfter = 5;
+ // Partition config
+ volatile List partitions = List.of();
+ volatile Settings partitionConfig = Settings.EMPTY;
+ }
+
+ // -------------------------------------------------------------------------
+ // Per-alias limiter state
+ // -------------------------------------------------------------------------
+
+ private static final Limiter.Listener NOOP_LISTENER = new Limiter.Listener() {
+ @Override
+ public void onSuccess() {}
+
+ @Override
+ public void onIgnore() {}
+
+ @Override
+ public void onDropped() {}
+ };
+
+ static final class LimiterState {
+ final Limit limit;
+ final Limiter limiter;
+ final long warmupUntil;
+ boolean warmedUp;
+
+ LimiterState(Limit limit, Limiter limiter, long warmupMillis) {
+ this.limit = limit;
+ this.limiter = limiter;
+ this.warmedUp = false;
+ this.warmupUntil = System.currentTimeMillis() + warmupMillis;
+ }
+
+ boolean isWarmedUp() {
+ warmedUp = warmedUp || warmupUntil <= System.currentTimeMillis();
+ return warmedUp;
+ }
+ }
+
+ /**
+ * Concrete builder for partitioned limiters. Extends {@link AbstractPartitionedLimiter.Builder}
+ * to expose {@code partition()}, {@code partitionResolver()}, and {@code partitionRejectDelay()}.
+ */
+ private static final class PartitionedBuilder extends AbstractPartitionedLimiter.Builder<
+ PartitionedBuilder,
+ ContextT> {
+ @Override
+ protected PartitionedBuilder self() {
+ return this;
+ }
+ }
+
+ final class ActionLimiterInstance {
+ private final String alias;
+ private final AtomicReference state = new AtomicReference<>();
+ private final AtomicInteger inFlight = new AtomicInteger();
+ private final AtomicLong totalRejected = new AtomicLong();
+ private volatile String actionName;
+ private volatile String mode;
+ private volatile String algorithm;
+ // True when this limiter is partitioned. Used to avoid allocating a
+ // SearchRequestContext on the hot path when partitioning is not in use.
+ private volatile boolean partitioned;
+
+ ActionLimiterInstance(String alias) {
+ this.alias = alias;
+ }
+
+ synchronized void reconfigure(AliasConfig cfg) {
+ // Build the new state into a local FIRST. If construction throws (e.g. invalid
+ // partition percentages), no instance field is mutated and the caller's try/catch
+ // leaves this instance fully on its previous configuration.
+ LimiterState newState = null;
+ boolean newPartitioned = !cfg.partitions.isEmpty();
+ if (!"disabled".equals(cfg.mode)) {
+ LimitAlgorithm algo = LimitAlgorithm.valueOf(cfg.algorithm.toUpperCase(Locale.ROOT));
+ Limit limit = algo.build(cfg);
+ if (cfg.burstCapacity > 0) {
+ limit = new BurstAwareLimit((AbstractLimit) limit, cfg.burstCapacity, cfg.burstCloseAfter, cfg.burstOpenAfter);
+ }
+ Limiter limiter = buildLimiter(limit, cfg);
+ newState = new LimiterState(limit, limiter, cfg.warmupMillis);
+ }
+
+ // All construction succeeded — now commit. Write state BEFORE mode so that if
+ // another thread reads mode (volatile) and sees a non-disabled value, state is
+ // guaranteed to be non-null.
+ this.actionName = cfg.actionName;
+ this.algorithm = cfg.algorithm;
+ this.partitioned = newPartitioned;
+ if (newState != null) {
+ LimiterState prev = state.get();
+ if (prev != null && prev.isWarmedUp()) {
+ newState.warmedUp = true;
+ }
+ state.set(newState);
+ }
+ this.mode = cfg.mode;
+ }
+
+ String getMode() {
+ return mode;
+ }
+
+ String getActionName() {
+ return actionName;
+ }
+
+ boolean isPartitioned() {
+ return partitioned;
+ }
+
+ Optional tryAcquire(Task task, ActionRequest request) {
+ LimiterState s = state.get();
+ if (s == null) return Optional.empty();
+
+ // Only allocate the context object when the limiter is partitioned and actually
+ // needs it to resolve a partition. The non-partitioned SimpleLimiter ignores it.
+ SearchRequestContext ctx = partitioned ? new SearchRequestContext(task, actionName, request) : null;
+ Optional token = s.limiter.acquire(ctx);
+ if (token.isEmpty()) {
+ if (LOG.isDebugEnabled()) {
+ Limit rejLimit = s.limit;
+ if (rejLimit instanceof BurstAwareLimit) {
+ int baseLimit = ((BurstAwareLimit) rejLimit).getDelegate().getLimit();
+ int effectiveLimit = rejLimit.getLimit();
+ LOG.debug(
+ "Request rejected for alias [{}] action [{}]: limit={} [base={}, burst={}] inFlight={}",
+ alias,
+ actionName,
+ effectiveLimit,
+ baseLimit,
+ effectiveLimit - baseLimit,
+ inFlight.get()
+ );
+ } else {
+ LOG.debug(
+ "Request rejected for alias [{}] action [{}]: limit={} inFlight={}",
+ alias,
+ actionName,
+ rejLimit.getLimit(),
+ inFlight.get()
+ );
+ }
+ }
+ if ("monitor_only".equals(this.mode)) {
+ totalRejected.incrementAndGet();
+ return Optional.of(NOOP_LISTENER);
+ }
+ // enforced: reject after warmup, pass through during warmup
+ if (!s.isWarmedUp()) return Optional.of(NOOP_LISTENER);
+ totalRejected.incrementAndGet();
+ return Optional.empty();
+ }
+ inFlight.incrementAndGet();
+ Limiter.Listener inner = token.get();
+ return Optional.of(new Limiter.Listener() {
+ @Override
+ public void onSuccess() {
+ inFlight.decrementAndGet();
+ inner.onSuccess();
+ }
+
+ @Override
+ public void onIgnore() {
+ inFlight.decrementAndGet();
+ inner.onIgnore();
+ }
+
+ @Override
+ public void onDropped() {
+ inFlight.decrementAndGet();
+ inner.onDropped();
+ }
+ });
+ }
+
+ ActionLimiterSnapshot snapshot() {
+ LimiterState s = state.get();
+ int currentLimit = s != null ? s.limit.getLimit() : 0;
+ long lastRttMs = -1L;
+ long rttNoLoadMs = -1L;
+ if (s != null) {
+ Limit limit = s.limit;
+ if (limit instanceof BurstAwareLimit) {
+ limit = ((BurstAwareLimit) limit).getDelegate();
+ }
+ if (limit instanceof GradientLimit) {
+ lastRttMs = ((GradientLimit) limit).getLastRtt(TimeUnit.MILLISECONDS);
+ rttNoLoadMs = ((GradientLimit) limit).getRttNoLoad(TimeUnit.MILLISECONDS);
+ } else if (limit instanceof Gradient2Limit) {
+ lastRttMs = ((Gradient2Limit) limit).getLastRtt(TimeUnit.MILLISECONDS);
+ rttNoLoadMs = ((Gradient2Limit) limit).getRttNoLoad(TimeUnit.MILLISECONDS);
+ } else if (limit instanceof OpenSearchVegasLimit) {
+ rttNoLoadMs = ((OpenSearchVegasLimit) limit).getRttNoLoad(TimeUnit.MILLISECONDS);
+ }
+ }
+ return new ActionLimiterSnapshot(
+ alias,
+ actionName,
+ mode,
+ algorithm,
+ currentLimit,
+ inFlight.get(),
+ totalRejected.get(),
+ lastRttMs,
+ rttNoLoadMs
+ );
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Registry state
+ // -------------------------------------------------------------------------
+
+ private final ConcurrentHashMap aliasConfigs = new ConcurrentHashMap<>();
+ private final ConcurrentHashMap limiters = new ConcurrentHashMap<>();
+ private volatile Map actionToInstance = Collections.emptyMap();
+ private final ThreadContext threadContext;
+
+ // -------------------------------------------------------------------------
+ // Metrics listener (push telemetry)
+ // -------------------------------------------------------------------------
+
+ /**
+ * Lifecycle callback for a push-telemetry bridge. The registry notifies the listener whenever an
+ * alias becomes active (configured with a non-disabled mode and an action name) or inactive. The
+ * {@code snapshotSupplier} yields the same per-alias values the pull path ({@code snapshot()} →
+ * {@code NodeStats}) reports, so both telemetry paths publish identical numbers.
+ */
+ public interface MetricsListener {
+ /** Called when an alias becomes active or is reconfigured.
+ * @param alias the limiter alias
+ * @param snapshotSupplier supplies the live snapshot for the alias */
+ void onActive(String alias, Supplier snapshotSupplier);
+
+ /** Called when an alias becomes inactive (disabled or removed).
+ * @param alias the limiter alias */
+ void onInactive(String alias);
+ }
+
+ private volatile MetricsListener metricsListener;
+ private final Set activeAliases = ConcurrentHashMap.newKeySet();
+
+ /**
+ * Attaches the push-telemetry listener and replays the currently-active aliases (those loaded
+ * from initial settings before the listener was wired). Called once, after construction, from the
+ * plugin's telemetry-aware {@code createComponents}.
+ *
+ * @param listener the metrics listener to attach, or {@code null} to detach
+ */
+ public synchronized void setMetricsListener(MetricsListener listener) {
+ this.metricsListener = listener;
+ if (listener == null) return;
+ limiters.forEach((alias, inst) -> {
+ if (isActive(inst)) {
+ activeAliases.add(alias);
+ listener.onActive(alias, inst::snapshot);
+ }
+ });
+ }
+
+ private static boolean isActive(ActionLimiterInstance inst) {
+ return inst != null && !"disabled".equals(inst.getMode()) && !inst.getActionName().isEmpty();
+ }
+
+ /** Fire the metrics listener for one alias after a (re)configuration. Called under synchronization. */
+ private void notifyMetricsListener(String alias) {
+ MetricsListener listener = this.metricsListener;
+ if (listener == null) return;
+ ActionLimiterInstance inst = limiters.get(alias);
+ if (isActive(inst)) {
+ activeAliases.add(alias);
+ // Re-fire on every reconfigure so the bridge can refresh tags (mode/algorithm/action);
+ // the listener is expected to treat a repeat onActive as a refresh.
+ listener.onActive(alias, inst::snapshot);
+ } else if (activeAliases.remove(alias)) {
+ listener.onInactive(alias);
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Constructor
+ // -------------------------------------------------------------------------
+
+ /**
+ * Creates a new registry, loading initial limiter configurations and registering dynamic update consumers.
+ *
+ * @param initialSettings the node's initial settings
+ * @param clusterSettings the cluster settings service for dynamic updates
+ * @param threadContext the thread context for header propagation
+ */
+ public ActionConcurrencyLimiterRegistry(Settings initialSettings, ClusterSettings clusterSettings, ThreadContext threadContext) {
+ this.threadContext = threadContext;
+
+ // Load all aliases present in initial settings
+ Set aliases = new HashSet<>();
+ for (Setting> s : ALL_SETTINGS) {
+ aliases.addAll(((Setting.AffixSetting>) s).getNamespaces(initialSettings));
+ }
+ for (String alias : aliases) {
+ AliasConfig cfg = buildConfig(alias, initialSettings);
+ aliasConfigs.put(alias, cfg);
+ onAliasReconfigured(alias, cfg);
+ }
+
+ // Register update consumers
+ clusterSettings.addAffixUpdateConsumer(ACTION_NAME_SETTING, (alias, val) -> {
+ aliasConfigs.computeIfAbsent(alias, k -> new AliasConfig()).actionName = val;
+ onAliasReconfigured(alias, aliasConfigs.get(alias));
+ }, (alias, val) -> {});
+ clusterSettings.addAffixUpdateConsumer(MODE_SETTING, (alias, val) -> {
+ aliasConfigs.computeIfAbsent(alias, k -> new AliasConfig()).mode = val;
+ onAliasReconfigured(alias, aliasConfigs.get(alias));
+ }, (alias, val) -> {});
+ clusterSettings.addAffixUpdateConsumer(ALGORITHM_SETTING, (alias, val) -> {
+ aliasConfigs.computeIfAbsent(alias, k -> new AliasConfig()).algorithm = val;
+ onAliasReconfigured(alias, aliasConfigs.get(alias));
+ }, (alias, val) -> {});
+ clusterSettings.addAffixUpdateConsumer(LIMIT_CONFIG_SETTING, (alias, val) -> {
+ AliasConfig cfg = aliasConfigs.computeIfAbsent(alias, k -> new AliasConfig());
+ cfg.initialLimit = val.getAsInt("initial", 20);
+ cfg.maxLimit = val.getAsInt("max", 200);
+ onAliasReconfigured(alias, cfg);
+ }, (alias, val) -> validateLimitConfig(val));
+ clusterSettings.addAffixUpdateConsumer(WARMUP_DURATION_SETTING, (alias, val) -> {
+ aliasConfigs.computeIfAbsent(alias, k -> new AliasConfig()).warmupMillis = val.getMillis();
+ onAliasReconfigured(alias, aliasConfigs.get(alias));
+ }, (alias, val) -> {});
+ clusterSettings.addAffixUpdateConsumer(AIMD_CONFIG_SETTING, (alias, val) -> {
+ AliasConfig cfg = aliasConfigs.computeIfAbsent(alias, k -> new AliasConfig());
+ cfg.aimdBackoffRatio = val.getAsDouble("backoff_ratio", 0.9);
+ onAliasReconfigured(alias, cfg);
+ }, (alias, val) -> validateAimdConfig(val));
+ clusterSettings.addAffixUpdateConsumer(GRADIENT2_CONFIG_SETTING, (alias, val) -> {
+ AliasConfig cfg = aliasConfigs.computeIfAbsent(alias, k -> new AliasConfig());
+ cfg.gradient2RttTolerance = val.getAsDouble("rtt_tolerance", 1.5);
+ onAliasReconfigured(alias, cfg);
+ }, (alias, val) -> validateGradient2Config(val));
+ clusterSettings.addAffixUpdateConsumer(VEGAS_CONFIG_SETTING, (alias, val) -> {
+ AliasConfig cfg = aliasConfigs.computeIfAbsent(alias, k -> new AliasConfig());
+ cfg.vegasUpDriftFactor = val.getAsInt("updrift_factor", 1);
+ cfg.vegasIncreaseBarrier = val.getAsInt("increase_barrier", 1);
+ cfg.vegasDecreaseBarrier = val.getAsInt("decrease_barrier", 1);
+ cfg.vegasBaselineResetLoadThreshold = val.getAsDouble("baseline_reset_load_threshold", 0.5);
+ onAliasReconfigured(alias, cfg);
+ }, (alias, val) -> validateVegasConfig(val));
+ clusterSettings.addAffixUpdateConsumer(BURST_CONFIG_SETTING, (alias, val) -> {
+ AliasConfig cfg = aliasConfigs.computeIfAbsent(alias, k -> new AliasConfig());
+ cfg.burstCapacity = val.getAsInt("capacity", 0);
+ cfg.burstCloseAfter = val.getAsInt("close_after", 5);
+ cfg.burstOpenAfter = val.getAsInt("open_after", 5);
+ onAliasReconfigured(alias, cfg);
+ }, (alias, val) -> validateBurstConfig(val));
+ // partitions list and partition.* group are validated together (cross-field rules need
+ // both), so they share one compound consumer. The validator runs on update only and
+ // rejects a bad PUT with HTTP 400.
+ clusterSettings.addAffixUpdateConsumer(PARTITIONS_SETTING, PARTITION_CONFIG_SETTING, (alias, tuple) -> {
+ AliasConfig cfg = aliasConfigs.computeIfAbsent(alias, k -> new AliasConfig());
+ cfg.partitions = tuple.v1();
+ cfg.partitionConfig = tuple.v2();
+ onAliasReconfigured(alias, cfg);
+ }, (alias, tuple) -> validatePartitionConfig(tuple.v1(), tuple.v2()));
+ }
+
+ // -------------------------------------------------------------------------
+ // Public API
+ // -------------------------------------------------------------------------
+
+ /**
+ * Attempts to acquire a token for the given action. The {@code task} and {@code request}
+ * are only used to build a {@link SearchRequestContext} for partition resolution when the
+ * action's limiter is partitioned; for non-partitioned limiters no context is allocated.
+ * Returns empty if no limiter is configured for the action or the limit is reached.
+ *
+ * @param action the transport action name
+ * @param task the current task
+ * @param request the action request
+ */
+ public Optional tryAcquire(String action, Task task, ActionRequest request) {
+ ActionLimiterInstance inst = actionToInstance.get(action);
+ if (inst == null) return Optional.empty();
+ return inst.tryAcquire(task, request);
+ }
+
+ /**
+ * Returns true if an active (non-disabled) limiter is configured for this action.
+ *
+ * @param action the transport action name
+ */
+ public boolean hasLimiterFor(String action) {
+ return actionToInstance.containsKey(action);
+ }
+
+ /** Returns a snapshot of all configured limiter states. */
+ public ActionConcurrencyLimiterStats getStats() {
+ List snapshots = new ArrayList<>();
+ limiters.values().forEach(inst -> snapshots.add(inst.snapshot()));
+ return new ActionConcurrencyLimiterStats(snapshots);
+ }
+
+ // -------------------------------------------------------------------------
+ // Internal
+ // -------------------------------------------------------------------------
+
+ static void validateLimitConfig(Settings group) {
+ int initial = group.getAsInt("initial", 20);
+ int max = group.getAsInt("max", 200);
+ if (initial < 1) {
+ throw new IllegalArgumentException("limit.initial must be >= 1 but got " + initial);
+ }
+ if (max < 1) {
+ throw new IllegalArgumentException("limit.max must be >= 1 but got " + max);
+ }
+ if (max < initial) {
+ throw new IllegalArgumentException("limit.max [" + max + "] must be >= limit.initial [" + initial + "]");
+ }
+ }
+
+ static void validateVegasConfig(Settings group) {
+ int upDrift = group.getAsInt("updrift_factor", 1);
+ int incBarrier = group.getAsInt("increase_barrier", 1);
+ int decBarrier = group.getAsInt("decrease_barrier", 1);
+ double threshold = group.getAsDouble("baseline_reset_load_threshold", 0.5);
+ if (upDrift < 1) {
+ throw new IllegalArgumentException("vegas.updrift_factor must be >= 1 but got " + upDrift);
+ }
+ if (incBarrier < 1) {
+ throw new IllegalArgumentException("vegas.increase_barrier must be >= 1 but got " + incBarrier);
+ }
+ if (decBarrier < 1) {
+ throw new IllegalArgumentException("vegas.decrease_barrier must be >= 1 but got " + decBarrier);
+ }
+ if (threshold < 0.0 || threshold > 1.0) {
+ throw new IllegalArgumentException("vegas.baseline_reset_load_threshold must be in [0.0, 1.0] but got " + threshold);
+ }
+ }
+
+ static void validateBurstConfig(Settings group) {
+ int capacity = group.getAsInt("capacity", 0);
+ int closeAfter = group.getAsInt("close_after", 5);
+ int openAfter = group.getAsInt("open_after", 5);
+ if (capacity < 0) {
+ throw new IllegalArgumentException("burst.capacity must be >= 0 but got " + capacity);
+ }
+ if (closeAfter < 1) {
+ throw new IllegalArgumentException("burst.close_after must be >= 1 but got " + closeAfter);
+ }
+ if (openAfter < 1) {
+ throw new IllegalArgumentException("burst.open_after must be >= 1 but got " + openAfter);
+ }
+ }
+
+ static void validateAimdConfig(Settings group) {
+ double backoff = group.getAsDouble("backoff_ratio", 0.9);
+ if (backoff < 0.5 || backoff >= 1.0) {
+ throw new IllegalArgumentException("aimd.backoff_ratio must be in [0.5, 1.0) but got " + backoff);
+ }
+ }
+
+ static void validateGradient2Config(Settings group) {
+ double rttTolerance = group.getAsDouble("rtt_tolerance", 1.5);
+ if (rttTolerance < 1.0) {
+ throw new IllegalArgumentException("gradient2.rtt_tolerance must be >= 1.0 but got " + rttTolerance);
+ }
+ }
+
+ /**
+ * Validates the partition configuration for one alias at update time so invalid config is
+ * rejected with HTTP 400 rather than silently swallowed at limiter-build time. Wired into the
+ * compound affix update consumer (update-only — does not run on {@code get()}), so an
+ * already-persisted bad value cannot break startup reads.
+ */
+ static void validatePartitionConfig(List partitions, Settings group) {
+ // Per-partition percent range + cumulative sum; non-negative delay_ms.
+ double sum = 0.0;
+ for (String key : group.keySet()) {
+ if (key.endsWith(".percent")) {
+ double pct = group.getAsDouble(key, 0.0);
+ if (pct < 0.0 || pct > 1.0) {
+ throw new IllegalArgumentException("partition percent [" + key + "] must be in [0.0, 1.0] but got " + pct);
+ }
+ sum += pct;
+ } else if (key.endsWith(".delay_ms")) {
+ if (group.getAsLong(key, 0L) < 0) {
+ throw new IllegalArgumentException("partition [" + key + "] must be >= 0");
+ }
+ }
+ }
+ if (sum > 1.0) {
+ throw new IllegalArgumentException("Sum of partition percentages must be <= 1.0 but got " + sum);
+ }
+
+ String resolver = group.get("resolver", "");
+ if (!VALID_RESOLVERS.contains(resolver)) {
+ throw new IllegalArgumentException(
+ "Unknown partition resolver [" + resolver + "]. Must be one of: byHeader, fixed, bySearchType"
+ );
+ }
+
+ // Cross-field: partitions configured but no way to route requests to them. All traffic
+ // would funnel to unknownPartition (hard limit 1) and collapse. Reject early.
+ if (!partitions.isEmpty() && resolver.isEmpty()) {
+ throw new IllegalArgumentException(
+ "partitions "
+ + partitions
+ + " configured but no partition.resolver set; "
+ + "all requests would route to unknownPartition. Set partition.resolver "
+ + "(byHeader or fixed)."
+ );
+ }
+
+ // Cross-field: the fixed resolver must target a partition that exists in the list.
+ if ("fixed".equals(resolver)) {
+ String target = group.getAsSettings("resolver.fixed").get("partition", "default");
+ if (!partitions.contains(target)) {
+ throw new IllegalArgumentException(
+ "partition.resolver.fixed.partition [" + target + "] is not in partitions " + partitions
+ );
+ }
+ }
+ }
+
+ /**
+ * Builds a {@link Limiter} for the given limit, wiring up partition resolver
+ * and per-partition configuration when partitions are defined.
+ *
+ * When no partitions are configured, uses {@link SimpleLimiter} directly (zero overhead).
+ * When partitions are configured, uses {@link AbstractPartitionedLimiter} via
+ * {@link PartitionedBuilder}, which divides the limit across named sub-pools.
+ */
+ private Limiter buildLimiter(Limit limit, AliasConfig cfg) {
+ if (cfg.partitions.isEmpty()) {
+ return SimpleLimiter.newBuilder().limit(limit).build();
+ }
+
+ Settings partConfig = cfg.partitionConfig;
+
+ // Validate percentages up front so a bad value produces a clear error rather than
+ // an opaque exception from deep inside the Netflix builder. Netflix enforces
+ // each percent in [0.0, 1.0] and sum <= 1.0; mirror those checks here.
+ double sum = 0.0;
+ for (String partitionName : cfg.partitions) {
+ double pct = partConfig.getAsDouble(partitionName + ".percent", 0.0);
+ if (pct < 0.0 || pct > 1.0) {
+ throw new IllegalArgumentException("partition [" + partitionName + "] percent must be in [0.0, 1.0] but got " + pct);
+ }
+ if (pct == 0.0) {
+ LOG.warn(
+ "Partition [{}] for alias [{}] has 0%% capacity (no percent configured); "
+ + "all requests mapped to it will be rejected.",
+ partitionName,
+ cfg.actionName
+ );
+ }
+ sum += pct;
+ }
+ if (sum > 1.0) {
+ throw new IllegalArgumentException(
+ "Sum of partition percentages for alias [" + cfg.actionName + "] must be <= 1.0 but got " + sum
+ );
+ }
+
+ String resolverType = partConfig.get("resolver", "");
+ // Defense-in-depth for the initial-load path, where the settings-layer compound validator
+ // does not run: reject a missing/unknown resolver here so a persisted bad config is caught
+ // (logged by onAliasReconfigured, previous limiter kept) instead of silently funneling all
+ // traffic to unknownPartition.
+ if (resolverType.isEmpty()) {
+ throw new IllegalArgumentException("partitions configured for alias [" + cfg.actionName + "] but no partition.resolver set");
+ }
+ if (!VALID_RESOLVERS.contains(resolverType)) {
+ throw new IllegalArgumentException("Unknown partition resolver [" + resolverType + "] for alias [" + cfg.actionName + "]");
+ }
+ Settings resolverConfig = partConfig.getAsSettings("resolver." + resolverType);
+ PartitionResolver resolver = PartitionResolver.build(resolverType, resolverConfig, threadContext);
+
+ PartitionedBuilder builder = new PartitionedBuilder<>();
+ builder.limit(limit);
+ builder.partitionResolver(ctx -> ctx != null ? resolver.resolve(ctx) : null);
+
+ for (String partitionName : cfg.partitions) {
+ double pct = partConfig.getAsDouble(partitionName + ".percent", 0.0);
+ long delayMs = partConfig.getAsLong(partitionName + ".delay_ms", 0L);
+ builder.partition(partitionName, pct);
+ if (delayMs > 0) {
+ builder.partitionRejectDelay(partitionName, delayMs, TimeUnit.MILLISECONDS);
+ }
+ }
+
+ return builder.build();
+ }
+
+ private AliasConfig buildConfig(String alias, Settings s) {
+ AliasConfig cfg = new AliasConfig();
+ cfg.actionName = ACTION_NAME_SETTING.getConcreteSettingForNamespace(alias).get(s);
+ cfg.mode = MODE_SETTING.getConcreteSettingForNamespace(alias).get(s);
+ cfg.algorithm = ALGORITHM_SETTING.getConcreteSettingForNamespace(alias).get(s);
+ Settings limitConfig = LIMIT_CONFIG_SETTING.getConcreteSettingForNamespace(alias).get(s);
+ cfg.initialLimit = limitConfig.getAsInt("initial", 20);
+ cfg.maxLimit = limitConfig.getAsInt("max", 200);
+ cfg.warmupMillis = WARMUP_DURATION_SETTING.getConcreteSettingForNamespace(alias).get(s).getMillis();
+ Settings aimdConfig = AIMD_CONFIG_SETTING.getConcreteSettingForNamespace(alias).get(s);
+ cfg.aimdBackoffRatio = aimdConfig.getAsDouble("backoff_ratio", 0.9);
+ Settings gradient2Config = GRADIENT2_CONFIG_SETTING.getConcreteSettingForNamespace(alias).get(s);
+ cfg.gradient2RttTolerance = gradient2Config.getAsDouble("rtt_tolerance", 1.5);
+ Settings vegasConfig = VEGAS_CONFIG_SETTING.getConcreteSettingForNamespace(alias).get(s);
+ cfg.vegasUpDriftFactor = vegasConfig.getAsInt("updrift_factor", 1);
+ cfg.vegasIncreaseBarrier = vegasConfig.getAsInt("increase_barrier", 1);
+ cfg.vegasDecreaseBarrier = vegasConfig.getAsInt("decrease_barrier", 1);
+ cfg.vegasBaselineResetLoadThreshold = vegasConfig.getAsDouble("baseline_reset_load_threshold", 0.5);
+ Settings burstConfig = BURST_CONFIG_SETTING.getConcreteSettingForNamespace(alias).get(s);
+ cfg.burstCapacity = burstConfig.getAsInt("capacity", 0);
+ cfg.burstCloseAfter = burstConfig.getAsInt("close_after", 5);
+ cfg.burstOpenAfter = burstConfig.getAsInt("open_after", 5);
+ cfg.partitions = PARTITIONS_SETTING.getConcreteSettingForNamespace(alias).get(s);
+ cfg.partitionConfig = PARTITION_CONFIG_SETTING.getConcreteSettingForNamespace(alias).get(s);
+ return cfg;
+ }
+
+ private synchronized void onAliasReconfigured(String alias, AliasConfig cfg) {
+ boolean reconfigured = true;
+ if (cfg.actionName.isEmpty()) {
+ limiters.remove(alias);
+ } else {
+ ActionLimiterInstance inst = limiters.computeIfAbsent(alias, ActionLimiterInstance::new);
+ try {
+ inst.reconfigure(cfg);
+ } catch (RuntimeException e) {
+ reconfigured = false;
+ LOG.error(
+ "Failed to apply concurrency limit config for alias [{}] action [{}]; " + "keeping previous configuration. Error: {}",
+ alias,
+ cfg.actionName,
+ e.getMessage()
+ );
+ }
+ }
+ rebuildReverseMap();
+ if (reconfigured) {
+ notifyMetricsListener(alias);
+ }
+ }
+
+ private void rebuildReverseMap() {
+ Map newMap = new HashMap<>();
+ limiters.forEach((alias, inst) -> {
+ if (!"disabled".equals(inst.getMode()) && !inst.getActionName().isEmpty()) {
+ newMap.put(inst.getActionName(), inst);
+ }
+ });
+ actionToInstance = Collections.unmodifiableMap(newMap);
+ }
+}
diff --git a/modules/concurrency-limit/src/main/java/org/opensearch/concurrency/ConcurrencyLimiterMetricsPublisher.java b/modules/concurrency-limit/src/main/java/org/opensearch/concurrency/ConcurrencyLimiterMetricsPublisher.java
new file mode 100644
index 0000000000000..b0cb31bac57ee
--- /dev/null
+++ b/modules/concurrency-limit/src/main/java/org/opensearch/concurrency/ConcurrencyLimiterMetricsPublisher.java
@@ -0,0 +1,151 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.concurrency;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.opensearch.action.ActionConcurrencyLimiterStats.ActionLimiterSnapshot;
+import org.opensearch.telemetry.metrics.MetricsRegistry;
+import org.opensearch.telemetry.metrics.tags.Tags;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.Supplier;
+
+/**
+ * Push-telemetry bridge for the concurrency limiter.
+ *
+ * Registers one set of observable gauges per active alias with the OpenSearch
+ * {@link MetricsRegistry}. Each gauge's value provider reads the alias's live
+ * {@link ActionLimiterSnapshot} — the exact same values the pull path
+ * ({@code /_nodes/stats} {@code concurrency_limiters}) reports — so both telemetry mechanisms
+ * publish an identical metric set with identical values.
+ *
+ * Gauges are (re)registered on {@link #onActive} and torn down on {@link #onInactive}; re-firing
+ * {@code onActive} for an already-registered alias refreshes its tags (e.g. after a mode change).
+ * When telemetry is disabled the node supplies a no-op {@code MetricsRegistry}, so this bridge is
+ * inert with no special-casing here.
+ */
+public final class ConcurrencyLimiterMetricsPublisher implements ActionConcurrencyLimiterRegistry.MetricsListener, Closeable {
+
+ private static final Logger LOG = LogManager.getLogger(ConcurrencyLimiterMetricsPublisher.class);
+
+ static final String CURRENT_LIMIT = "concurrency_limit.current_limit";
+ static final String IN_FLIGHT = "concurrency_limit.in_flight";
+ static final String TOTAL_REJECTED = "concurrency_limit.total_rejected";
+ static final String LAST_RTT = "concurrency_limit.last_rtt";
+ static final String RTT_NOLOAD = "concurrency_limit.rtt_noload";
+
+ private final MetricsRegistry metricsRegistry;
+ private final Map> gaugesByAlias = new ConcurrentHashMap<>();
+
+ /**
+ * Creates a new publisher backed by the given metrics registry.
+ *
+ * @param metricsRegistry the metrics registry to register gauges with
+ */
+ public ConcurrencyLimiterMetricsPublisher(MetricsRegistry metricsRegistry) {
+ this.metricsRegistry = metricsRegistry;
+ }
+
+ @Override
+ public synchronized void onActive(String alias, Supplier snapshotSupplier) {
+ // Refresh: drop any prior registration so tags reflect the current config.
+ closeAlias(alias);
+
+ ActionLimiterSnapshot s0 = snapshotSupplier.get();
+ Tags tags = Tags.create()
+ .addTag("alias", alias)
+ .addTag("action_name", s0.getActionName())
+ .addTag("mode", s0.getMode())
+ .addTag("algorithm", s0.getAlgorithm());
+
+ List gauges = new ArrayList<>(5);
+ gauges.add(
+ metricsRegistry.createGauge(
+ CURRENT_LIMIT,
+ "Current adaptive concurrency limit",
+ "1",
+ () -> (double) snapshotSupplier.get().getCurrentLimit(),
+ tags
+ )
+ );
+ gauges.add(
+ metricsRegistry.createGauge(
+ IN_FLIGHT,
+ "Requests currently holding a token",
+ "1",
+ () -> (double) snapshotSupplier.get().getInFlight(),
+ tags
+ )
+ );
+ gauges.add(
+ metricsRegistry.createGauge(
+ TOTAL_REJECTED,
+ "Cumulative rejected requests (includes monitor_only would-be rejections)",
+ "1",
+ () -> (double) snapshotSupplier.get().getTotalRejected(),
+ tags
+ )
+ );
+ gauges.add(
+ metricsRegistry.createGauge(
+ LAST_RTT,
+ "Last observed round-trip time (-1 if unavailable)",
+ "ms",
+ () -> (double) snapshotSupplier.get().getLastRttMillis(),
+ tags
+ )
+ );
+ gauges.add(
+ metricsRegistry.createGauge(
+ RTT_NOLOAD,
+ "Round-trip time under no load (-1 if unavailable)",
+ "ms",
+ () -> (double) snapshotSupplier.get().getRttNoLoadMillis(),
+ tags
+ )
+ );
+
+ gaugesByAlias.put(alias, gauges);
+ }
+
+ @Override
+ public synchronized void onInactive(String alias) {
+ closeAlias(alias);
+ }
+
+ private void closeAlias(String alias) {
+ List gauges = gaugesByAlias.remove(alias);
+ if (gauges == null) return;
+ for (Closeable gauge : gauges) {
+ try {
+ gauge.close();
+ } catch (IOException e) {
+ LOG.warn("Failed to close concurrency-limit gauge for alias [{}]: {}", alias, e.getMessage());
+ }
+ }
+ }
+
+ // Visible for testing.
+ int registeredAliasCount() {
+ return gaugesByAlias.size();
+ }
+
+ @Override
+ public synchronized void close() {
+ for (String alias : new ArrayList<>(gaugesByAlias.keySet())) {
+ closeAlias(alias);
+ }
+ }
+}
diff --git a/modules/concurrency-limit/src/main/java/org/opensearch/concurrency/PartitionResolver.java b/modules/concurrency-limit/src/main/java/org/opensearch/concurrency/PartitionResolver.java
new file mode 100644
index 0000000000000..ab5de79f15cb7
--- /dev/null
+++ b/modules/concurrency-limit/src/main/java/org/opensearch/concurrency/PartitionResolver.java
@@ -0,0 +1,130 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.concurrency;
+
+import org.opensearch.action.ActionRequest;
+import org.opensearch.action.search.SearchRequest;
+import org.opensearch.common.settings.Settings;
+import org.opensearch.common.util.concurrent.ThreadContext;
+import org.opensearch.search.builder.SearchSourceBuilder;
+
+/**
+ * Maps an incoming request to a partition name for use with the partitioned concurrency limiter.
+ *
+ * Returning {@code null} routes the request to the built-in {@code unknownPartition}, which
+ * holds any unallocated share of the total limit.
+ */
+@FunctionalInterface
+public interface PartitionResolver {
+
+ /**
+ * Returns the partition name for this request, or {@code null} to route to
+ * {@code unknownPartition}.
+ *
+ * @param ctx the request context
+ */
+ String resolve(SearchRequestContext ctx);
+
+ /**
+ * Factory — creates the appropriate resolver from the alias configuration.
+ *
+ * @param resolverType value of {@code partition.resolver} setting (e.g. {@code "byHeader"})
+ * @param resolverConfig sub-settings under {@code partition.resolver..*}
+ * @param threadContext request-scoped thread context (used by {@code byHeader} resolver)
+ */
+ static PartitionResolver build(String resolverType, Settings resolverConfig, ThreadContext threadContext) {
+ if ("byHeader".equals(resolverType)) {
+ return new ByHeaderPartitionResolver(resolverConfig, threadContext);
+ } else if ("fixed".equals(resolverType)) {
+ return new FixedPartitionResolver(resolverConfig);
+ } else if ("bySearchType".equals(resolverType)) {
+ return new BySearchTypePartitionResolver(resolverConfig);
+ }
+ return ctx -> null; // unknown type → unknownPartition
+ }
+
+ // -------------------------------------------------------------------------
+ // Built-in implementations
+ // -------------------------------------------------------------------------
+
+ /**
+ * Reads the {@link ActionConcurrencyLimitPlugin#TIER_HEADER X-Request-Tier} header
+ * from the thread context. The header value must match a configured partition name
+ * exactly (case-sensitive). Requests without the header, or with an unrecognised
+ * value, fall through to {@code unknownPartition}.
+ */
+ final class ByHeaderPartitionResolver implements PartitionResolver {
+ private final ThreadContext threadContext;
+
+ ByHeaderPartitionResolver(Settings config, ThreadContext threadContext) {
+ this.threadContext = threadContext;
+ }
+
+ @Override
+ public String resolve(SearchRequestContext ctx) {
+ return threadContext.getHeader(ActionConcurrencyLimitPlugin.TIER_HEADER);
+ }
+ }
+
+ /**
+ * Always routes every request to the same partition. Useful for testing or for
+ * single-partition deployments where partitioning is used only for the delay
+ * (reject-delay) behaviour.
+ *
+ * Configuration: {@code partition.resolver.fixed.partition = premium}
+ */
+ final class FixedPartitionResolver implements PartitionResolver {
+ private final String partition;
+
+ FixedPartitionResolver(Settings config) {
+ this.partition = config.get("partition", "default");
+ }
+
+ @Override
+ public String resolve(SearchRequestContext ctx) {
+ return partition;
+ }
+ }
+
+ /**
+ * Routes a search to a partition by its shape: requests carrying aggregations go to the
+ * {@code aggregation} partition, all other searches go to the {@code filter} partition.
+ * Inspects the {@link SearchRequest} directly on the coordinator — no client header required.
+ *
+ * A request that has both a query and aggregations is classed as {@code aggregation}
+ * (aggregations dominate cost). Non-search requests (e.g. {@code _msearch}, or a non-search
+ * action) resolve to {@code null} → {@code unknownPartition}.
+ *
+ * Configuration:
+ *
+ * partition.resolver.bySearchType.aggregation = aggregation # partition for agg searches
+ * partition.resolver.bySearchType.filter = filter # partition for non-agg searches
+ *
+ */
+ final class BySearchTypePartitionResolver implements PartitionResolver {
+ private final String aggregationPartition;
+ private final String filterPartition;
+
+ BySearchTypePartitionResolver(Settings config) {
+ this.aggregationPartition = config.get("aggregation", "aggregation");
+ this.filterPartition = config.get("filter", "filter");
+ }
+
+ @Override
+ public String resolve(SearchRequestContext ctx) {
+ ActionRequest req = ctx.request();
+ if (!(req instanceof SearchRequest)) {
+ return null; // not a search → unknownPartition
+ }
+ SearchSourceBuilder source = ((SearchRequest) req).source();
+ boolean hasAggs = source != null && source.aggregations() != null && source.aggregations().count() > 0;
+ return hasAggs ? aggregationPartition : filterPartition;
+ }
+ }
+}
diff --git a/modules/concurrency-limit/src/main/java/org/opensearch/concurrency/SearchRequestContext.java b/modules/concurrency-limit/src/main/java/org/opensearch/concurrency/SearchRequestContext.java
new file mode 100644
index 0000000000000..369390a7a1adb
--- /dev/null
+++ b/modules/concurrency-limit/src/main/java/org/opensearch/concurrency/SearchRequestContext.java
@@ -0,0 +1,51 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.concurrency;
+
+import org.opensearch.action.ActionRequest;
+import org.opensearch.tasks.Task;
+
+/**
+ * Carries per-request context through the concurrency limiter's acquire path so that
+ * partition resolvers can inspect the request (e.g. read a header) to determine which
+ * partition the request belongs to.
+ */
+public final class SearchRequestContext {
+ private final Task task;
+ private final String action;
+ private final ActionRequest request;
+
+ /**
+ * Creates a new context for the given request.
+ *
+ * @param task the current task
+ * @param action the transport action name
+ * @param request the action request
+ */
+ public SearchRequestContext(Task task, String action, ActionRequest request) {
+ this.task = task;
+ this.action = action;
+ this.request = request;
+ }
+
+ /** Returns the task associated with this request. */
+ public Task task() {
+ return task;
+ }
+
+ /** Returns the transport action name. */
+ public String action() {
+ return action;
+ }
+
+ /** Returns the action request. */
+ public ActionRequest request() {
+ return request;
+ }
+}
diff --git a/modules/concurrency-limit/src/main/java/org/opensearch/concurrency/limit/BurstAwareLimit.java b/modules/concurrency-limit/src/main/java/org/opensearch/concurrency/limit/BurstAwareLimit.java
new file mode 100644
index 0000000000000..92ef9ef13bd84
--- /dev/null
+++ b/modules/concurrency-limit/src/main/java/org/opensearch/concurrency/limit/BurstAwareLimit.java
@@ -0,0 +1,112 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.concurrency.limit;
+
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.function.Consumer;
+
+import com.netflix.concurrency.limits.Limit;
+import com.netflix.concurrency.limits.limit.AbstractLimit;
+
+/**
+ * A limit wrapper that adds a fixed burst capacity above the delegate algorithm's adaptive base limit.
+ *
+ * The burst window starts open: effective semaphore size = {@code delegate.getLimit() + burstCapacity}.
+ * It closes after {@code closeThreshold} consecutive samples where {@code inflight >= delegate.getLimit()},
+ * and reopens after {@code recoveryThreshold} consecutive samples where {@code inflight < delegate.getLimit()}.
+ * Any sample that breaks the streak resets the active counter.
+ *
+ * Inflight is clamped to {@code delegate.getLimit()} before passing to the delegate so that
+ * burst-range requests do not distort the algorithm's queue-size estimate.
+ */
+public final class BurstAwareLimit implements Limit {
+
+ private final AbstractLimit delegate;
+ private final int burstCapacity;
+ private final int closeThreshold;
+ private final int recoveryThreshold;
+
+ private volatile int limit;
+ private final List> listeners = new CopyOnWriteArrayList<>();
+
+ private int consecutiveOverBase = 0;
+ private int consecutiveBelowBase = 0;
+ private boolean burstOpen = true;
+
+ /**
+ * Creates a new burst-aware limit wrapping the given delegate.
+ *
+ * @param delegate the adaptive limit algorithm
+ * @param burstCapacity extra capacity when burst window is open
+ * @param closeThreshold consecutive over-base samples to close the burst window
+ * @param recoveryThreshold consecutive below-base samples to reopen the burst window
+ */
+ public BurstAwareLimit(AbstractLimit delegate, int burstCapacity, int closeThreshold, int recoveryThreshold) {
+ this.delegate = delegate;
+ this.burstCapacity = burstCapacity;
+ this.closeThreshold = closeThreshold;
+ this.recoveryThreshold = recoveryThreshold;
+ this.limit = delegate.getLimit() + burstCapacity;
+ delegate.notifyOnChange(newBase -> {
+ int newEffective = newBase + (burstOpen ? burstCapacity : 0);
+ updateLimit(newEffective);
+ });
+ }
+
+ /** Returns the wrapped delegate limit algorithm. */
+ public AbstractLimit getDelegate() {
+ return delegate;
+ }
+
+ @Override
+ public int getLimit() {
+ return limit;
+ }
+
+ @Override
+ public void notifyOnChange(Consumer consumer) {
+ listeners.add(consumer);
+ }
+
+ @Override
+ public synchronized void onSample(long startTime, long rtt, int inflight, boolean dropped) {
+ int clamped = Math.min(inflight, delegate.getLimit());
+ delegate.onSample(startTime, rtt, clamped, dropped);
+
+ if (burstOpen) {
+ if (inflight >= delegate.getLimit()) {
+ if (++consecutiveOverBase >= closeThreshold) {
+ burstOpen = false;
+ consecutiveOverBase = 0;
+ updateLimit(delegate.getLimit());
+ }
+ } else {
+ consecutiveOverBase = 0;
+ }
+ } else {
+ if (inflight < delegate.getLimit()) {
+ if (++consecutiveBelowBase >= recoveryThreshold) {
+ burstOpen = true;
+ consecutiveBelowBase = 0;
+ updateLimit(delegate.getLimit() + burstCapacity);
+ }
+ } else {
+ consecutiveBelowBase = 0;
+ }
+ }
+ }
+
+ private void updateLimit(int newLimit) {
+ if (newLimit != limit) {
+ limit = newLimit;
+ listeners.forEach(l -> l.accept(newLimit));
+ }
+ }
+}
diff --git a/modules/concurrency-limit/src/main/java/org/opensearch/concurrency/limit/OpenSearchVegasLimit.java b/modules/concurrency-limit/src/main/java/org/opensearch/concurrency/limit/OpenSearchVegasLimit.java
new file mode 100644
index 0000000000000..1e04bcf081c66
--- /dev/null
+++ b/modules/concurrency-limit/src/main/java/org/opensearch/concurrency/limit/OpenSearchVegasLimit.java
@@ -0,0 +1,352 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.concurrency.limit;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.opensearch.common.Randomness;
+
+import java.util.Random;
+import java.util.concurrent.TimeUnit;
+import java.util.function.DoubleUnaryOperator;
+import java.util.function.IntUnaryOperator;
+
+import com.netflix.concurrency.limits.MetricIds;
+import com.netflix.concurrency.limits.MetricRegistry;
+import com.netflix.concurrency.limits.MetricRegistry.SampleListener;
+import com.netflix.concurrency.limits.internal.EmptyMetricRegistry;
+import com.netflix.concurrency.limits.limit.AbstractLimit;
+
+/**
+ * TCP-Vegas-based adaptive concurrency limit with an {@code upDriftFactor} extension.
+ *
+ * Extends Netflix's Vegas algorithm by scaling the alpha, beta, and threshold values
+ * and widening the drift-prevention window. At the default {@code upDriftFactor = 1}
+ * the output is mathematically identical to the stock Netflix {@code VegasLimit}.
+ * Values {@literal >} 1 make the algorithm more permissive about raising the limit,
+ * which helps during bursty workloads where requests cluster in time.
+ */
+public class OpenSearchVegasLimit extends AbstractLimit {
+
+ private static final Logger LOG = LogManager.getLogger(OpenSearchVegasLimit.class);
+
+ private static final IntUnaryOperator LOG10 = limit -> (int) Math.ceil(Math.log10(limit));
+
+ // -------------------------------------------------------------------------
+ // Builder
+ // -------------------------------------------------------------------------
+
+ /** Builder for {@link OpenSearchVegasLimit}. */
+ public static class Builder {
+ private int initialLimit = 20;
+ private int maxConcurrency = 1000;
+ private MetricRegistry registry = EmptyMetricRegistry.INSTANCE;
+ private double smoothing = 1.0;
+ private double upDriftFactor = 1.0;
+
+ private IntUnaryOperator alphaFunc = limit -> 3 * LOG10.applyAsInt(limit);
+ private IntUnaryOperator betaFunc = limit -> 6 * LOG10.applyAsInt(limit);
+ private IntUnaryOperator thresholdFunc = LOG10;
+ private DoubleUnaryOperator increaseFunc = limit -> limit + LOG10.applyAsInt((int) limit);
+ private DoubleUnaryOperator decreaseFunc = limit -> limit - LOG10.applyAsInt((int) limit);
+ private int probeMultiplier = 30;
+ private double probeInflightThreshold = 0.5;
+ private int increaseHysteresis = 1;
+ private int decreaseHysteresis = 1;
+
+ private Builder() {}
+
+ /**
+ * Sets the initial concurrency limit.
+ *
+ * @param initialLimit the initial limit
+ */
+ public Builder initialLimit(int initialLimit) {
+ this.initialLimit = initialLimit;
+ return this;
+ }
+
+ /**
+ * Sets the maximum concurrency limit.
+ *
+ * @param maxConcurrency the maximum limit
+ */
+ public Builder maxConcurrency(int maxConcurrency) {
+ this.maxConcurrency = maxConcurrency;
+ return this;
+ }
+
+ /**
+ * Sets the smoothing factor for limit updates.
+ *
+ * @param smoothing the smoothing factor
+ */
+ public Builder smoothing(double smoothing) {
+ this.smoothing = smoothing;
+ return this;
+ }
+
+ /**
+ * Scales alpha, beta, and threshold by this factor and widens the drift-prevention
+ * window from {@code inflight * 2} to {@code inflight * (upDriftFactor + 1)}.
+ * Must be >= 1.0. Default is 1.0 (standard Vegas behaviour).
+ * @param upDriftFactor the drift factor, must be >= 1.0
+ */
+ public Builder upDriftFactor(double upDriftFactor) {
+ this.upDriftFactor = upDriftFactor;
+ return this;
+ }
+
+ /**
+ * Sets the probe multiplier controlling probe frequency.
+ *
+ * @param probeMultiplier the probe multiplier
+ */
+ public Builder probeMultiplier(int probeMultiplier) {
+ this.probeMultiplier = probeMultiplier;
+ return this;
+ }
+
+ /**
+ * Fraction of the current limit below which a probe's RTT is accepted as
+ * the new no-load baseline. When {@code inflight > estimatedLimit * probeInflightThreshold},
+ * the probe still fires (counter and jitter reset) but the baseline is NOT
+ * updated, preventing heavy-load RTT from poisoning {@code rtt_noload}.
+ * Range: [0.0, 1.0]. Default 0.5.
+ *
+ * @param probeInflightThreshold the threshold fraction
+ */
+ public Builder probeInflightThreshold(double probeInflightThreshold) {
+ if (probeInflightThreshold < 0.0 || probeInflightThreshold > 1.0) {
+ throw new IllegalArgumentException("probeInflightThreshold must be in [0.0, 1.0] but got " + probeInflightThreshold);
+ }
+ this.probeInflightThreshold = probeInflightThreshold;
+ return this;
+ }
+
+ /**
+ * Number of consecutive qualifying samples required before increasing the limit.
+ * Default 1 (act immediately). Higher values dampen upward thrashing.
+ *
+ * @param increaseHysteresis the consecutive sample threshold
+ */
+ public Builder increaseHysteresis(int increaseHysteresis) {
+ this.increaseHysteresis = increaseHysteresis;
+ return this;
+ }
+
+ /**
+ * Number of consecutive qualifying samples required before decreasing the limit.
+ * Default 1 (act immediately). Higher values dampen downward thrashing.
+ *
+ * @param decreaseHysteresis the consecutive sample threshold
+ */
+ public Builder decreaseHysteresis(int decreaseHysteresis) {
+ this.decreaseHysteresis = decreaseHysteresis;
+ return this;
+ }
+
+ /**
+ * Sets the metric registry for RTT sampling.
+ *
+ * @param registry the metric registry
+ */
+ public Builder metricRegistry(MetricRegistry registry) {
+ this.registry = registry;
+ return this;
+ }
+
+ /** Builds the {@link OpenSearchVegasLimit} instance. */
+ public OpenSearchVegasLimit build() {
+ if (initialLimit > maxConcurrency) {
+ LOG.warn("Initial limit {} exceeded maximum limit {}", initialLimit, maxConcurrency);
+ }
+ return new OpenSearchVegasLimit(this);
+ }
+ }
+
+ /** Creates a new builder with default values. */
+ public static Builder newBuilder() {
+ return new Builder();
+ }
+
+ // -------------------------------------------------------------------------
+ // State
+ // -------------------------------------------------------------------------
+
+ private volatile double estimatedLimit;
+ private volatile long rtt_noload = 0;
+ private final int maxLimit;
+ private final double smoothing;
+ private final double upDriftFactor;
+ private final IntUnaryOperator alphaFunc;
+ private final IntUnaryOperator betaFunc;
+ private final IntUnaryOperator thresholdFunc;
+ private final DoubleUnaryOperator increaseFunc;
+ private final DoubleUnaryOperator decreaseFunc;
+ private final SampleListener rttSampleListener;
+ private final int probeMultiplier;
+ private final double probeInflightThreshold;
+ private int probeCount = 0;
+ private double probeJitter;
+ // Hysteresis: consecutive qualifying samples required before acting on increase/decrease signals.
+ private final int increaseHysteresis;
+ private final int decreaseHysteresis;
+ private int increaseConsecutive = 0;
+ private int decreaseConsecutive = 0;
+
+ private OpenSearchVegasLimit(Builder builder) {
+ super(builder.initialLimit);
+ this.estimatedLimit = builder.initialLimit;
+ this.maxLimit = builder.maxConcurrency;
+ this.smoothing = builder.smoothing;
+ this.upDriftFactor = builder.upDriftFactor;
+ this.alphaFunc = builder.alphaFunc;
+ this.betaFunc = builder.betaFunc;
+ this.thresholdFunc = builder.thresholdFunc;
+ this.increaseFunc = builder.increaseFunc;
+ this.decreaseFunc = builder.decreaseFunc;
+ this.probeMultiplier = builder.probeMultiplier;
+ this.probeInflightThreshold = builder.probeInflightThreshold;
+ this.increaseHysteresis = builder.increaseHysteresis;
+ this.decreaseHysteresis = builder.decreaseHysteresis;
+ this.rttSampleListener = builder.registry.distribution(MetricIds.MIN_RTT_NAME);
+ resetProbeJitter();
+ }
+
+ // -------------------------------------------------------------------------
+ // Vegas algorithm
+ // -------------------------------------------------------------------------
+
+ private void resetProbeJitter() {
+ Random rng = Randomness.get();
+ probeJitter = 0.5 + rng.nextDouble() * 0.5;
+ }
+
+ private boolean shouldProbe() {
+ return probeJitter * probeMultiplier * estimatedLimit <= probeCount;
+ }
+
+ @Override
+ protected int _update(long startTime, long rtt, int inflight, boolean didDrop) {
+ if (rtt <= 0) {
+ throw new IllegalArgumentException("rtt must be >0 but got " + rtt);
+ }
+
+ probeCount++;
+ if (this.shouldProbe()) {
+ this.resetProbeJitter();
+ this.probeCount = 0;
+ if (inflight <= estimatedLimit * probeInflightThreshold) {
+ LOG.debug(
+ "Probe accepted MinRTT {} ms (inflight={}, limit={})",
+ (double) TimeUnit.NANOSECONDS.toMicros(rtt) / 1000.0,
+ inflight,
+ (int) estimatedLimit
+ );
+ this.rtt_noload = rtt;
+ } else {
+ LOG.debug(
+ "Probe skipped baseline reset: inflight {} exceeds {}% of limit {}",
+ inflight,
+ (int) (probeInflightThreshold * 100),
+ (int) estimatedLimit
+ );
+ }
+ return (int) this.estimatedLimit;
+ } else if (this.rtt_noload != 0L && rtt >= this.rtt_noload) {
+ this.rttSampleListener.addSample(this.rtt_noload);
+ return this.updateEstimatedLimit(rtt, inflight, didDrop);
+ } else {
+ LOG.debug("New MinRTT {}", (double) TimeUnit.NANOSECONDS.toMicros(rtt) / (double) 1000.0F);
+ this.rtt_noload = rtt;
+ return (int) this.estimatedLimit;
+ }
+ }
+
+ private int updateEstimatedLimit(long rtt, int inflight, boolean didDrop) {
+ double estimatedLimit = this.estimatedLimit;
+ final int queueSize = (int) Math.ceil(estimatedLimit * (1 - (double) rtt_noload / rtt));
+ int alpha = -1, beta = -1, threshold = -1;
+ double newLimit;
+ if (didDrop) {
+ // Hard signal: bypass hysteresis, act immediately.
+ increaseConsecutive = 0;
+ decreaseConsecutive = 0;
+ newLimit = decreaseFunc.applyAsDouble(estimatedLimit);
+ } else if (inflight * (upDriftFactor + 1) < estimatedLimit) {
+ increaseConsecutive = 0;
+ decreaseConsecutive = 0;
+ return (int) estimatedLimit;
+ } else {
+ alpha = (int) upDriftFactor * alphaFunc.applyAsInt((int) estimatedLimit);
+ beta = (int) upDriftFactor * betaFunc.applyAsInt((int) estimatedLimit);
+ threshold = (int) upDriftFactor * thresholdFunc.applyAsInt((int) estimatedLimit);
+
+ if (queueSize <= threshold || queueSize < alpha) {
+ // Increase zone: require increaseHysteresis consecutive qualifying samples.
+ decreaseConsecutive = 0;
+ if (++increaseConsecutive < increaseHysteresis) return (int) estimatedLimit;
+ increaseConsecutive = 0;
+ newLimit = queueSize <= threshold ? estimatedLimit + beta : increaseFunc.applyAsDouble(estimatedLimit);
+ } else if (queueSize > beta) {
+ // Decrease zone: require decreaseHysteresis consecutive qualifying samples.
+ increaseConsecutive = 0;
+ if (++decreaseConsecutive < decreaseHysteresis) return (int) estimatedLimit;
+ decreaseConsecutive = 0;
+ newLimit = decreaseFunc.applyAsDouble(estimatedLimit);
+ } else {
+ increaseConsecutive = 0;
+ decreaseConsecutive = 0;
+ return (int) estimatedLimit;
+ }
+ }
+
+ newLimit = Math.max(1, Math.min(maxLimit, newLimit));
+ newLimit = (1 - smoothing) * estimatedLimit + smoothing * newLimit;
+ if ((int) newLimit != (int) estimatedLimit && LOG.isDebugEnabled()) {
+ LOG.debug(
+ "New limit={} old limit = {} minRtt={} ms winRtt={} ms queueSize={} alpha={} beta={} threshold={}",
+ (int) newLimit,
+ (int) estimatedLimit,
+ TimeUnit.NANOSECONDS.toMicros(rtt_noload) / 1000.0,
+ TimeUnit.NANOSECONDS.toMicros(rtt) / 1000.0,
+ queueSize,
+ alpha,
+ beta,
+ threshold
+ );
+ }
+ this.estimatedLimit = newLimit;
+ return (int) newLimit;
+ }
+
+ /**
+ * Returns the no-load baseline RTT in the requested time unit.
+ *
+ * @param units the time unit for the result
+ */
+ public long getRttNoLoad(TimeUnit units) {
+ return units.convert(rtt_noload, TimeUnit.NANOSECONDS);
+ }
+
+ @Override
+ public String toString() {
+ return "OpenSearchVegasLimit [limit="
+ + getLimit()
+ + ", rtt_noload="
+ + TimeUnit.NANOSECONDS.toMicros(rtt_noload) / 1000.0
+ + " ms"
+ + ", upDriftFactor="
+ + upDriftFactor
+ + ", probeInflightThreshold="
+ + probeInflightThreshold
+ + "]";
+ }
+}
diff --git a/modules/concurrency-limit/src/main/java/org/opensearch/concurrency/limit/package-info.java b/modules/concurrency-limit/src/main/java/org/opensearch/concurrency/limit/package-info.java
new file mode 100644
index 0000000000000..252532bb760e9
--- /dev/null
+++ b/modules/concurrency-limit/src/main/java/org/opensearch/concurrency/limit/package-info.java
@@ -0,0 +1,12 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+/**
+ * Adaptive concurrency limit algorithms (Vegas, Burst).
+ */
+package org.opensearch.concurrency.limit;
diff --git a/modules/concurrency-limit/src/main/java/org/opensearch/concurrency/package-info.java b/modules/concurrency-limit/src/main/java/org/opensearch/concurrency/package-info.java
new file mode 100644
index 0000000000000..5499ed3820dc0
--- /dev/null
+++ b/modules/concurrency-limit/src/main/java/org/opensearch/concurrency/package-info.java
@@ -0,0 +1,12 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+/**
+ * Adaptive per-action concurrency limiting module.
+ */
+package org.opensearch.concurrency;
diff --git a/modules/concurrency-limit/src/test/java/org/opensearch/concurrency/ActionConcurrencyLimitFilterTests.java b/modules/concurrency-limit/src/test/java/org/opensearch/concurrency/ActionConcurrencyLimitFilterTests.java
new file mode 100644
index 0000000000000..7bfb73c84f854
--- /dev/null
+++ b/modules/concurrency-limit/src/test/java/org/opensearch/concurrency/ActionConcurrencyLimitFilterTests.java
@@ -0,0 +1,177 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.concurrency;
+
+import org.opensearch.action.ActionRequest;
+import org.opensearch.action.search.SearchRequest;
+import org.opensearch.action.search.SearchResponse;
+import org.opensearch.action.support.ActionFilterChain;
+import org.opensearch.common.settings.ClusterSettings;
+import org.opensearch.common.settings.Setting;
+import org.opensearch.common.settings.Settings;
+import org.opensearch.core.action.ActionListener;
+import org.opensearch.core.action.ActionResponse;
+import org.opensearch.core.concurrency.OpenSearchRejectedExecutionException;
+import org.opensearch.tasks.Task;
+import org.opensearch.test.OpenSearchTestCase;
+
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.mockito.Mockito.mock;
+
+public class ActionConcurrencyLimitFilterTests extends OpenSearchTestCase {
+
+ private static final Task TASK = null;
+ private static final String SEARCH_ACTION = "indices:data/read/search";
+ private static final String OTHER_ACTION = "indices:data/write/bulk";
+
+ private static ClusterSettings clusterSettings(Settings settings) {
+ Set> all = new HashSet<>(ActionConcurrencyLimiterRegistry.ALL_SETTINGS);
+ return new ClusterSettings(settings, all);
+ }
+
+ private static ActionConcurrencyLimiterRegistry registryFor(String alias, String actionName, String mode) {
+ Settings s = Settings.builder()
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + alias + ".action_name", actionName)
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + alias + ".mode", mode)
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + alias + ".warmup_duration", "0s")
+ .build();
+ return new ActionConcurrencyLimiterRegistry(
+ s,
+ clusterSettings(s),
+ new org.opensearch.common.util.concurrent.ThreadContext(org.opensearch.common.settings.Settings.EMPTY)
+ );
+ }
+
+ private static ActionConcurrencyLimiterRegistry limitedRegistry(String alias, String actionName, int limit) {
+ Settings s = Settings.builder()
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + alias + ".action_name", actionName)
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + alias + ".mode", "enforced")
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + alias + ".limit.initial", limit)
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + alias + ".limit.max", limit)
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + alias + ".warmup_duration", "0s")
+ .build();
+ return new ActionConcurrencyLimiterRegistry(
+ s,
+ clusterSettings(s),
+ new org.opensearch.common.util.concurrent.ThreadContext(org.opensearch.common.settings.Settings.EMPTY)
+ );
+ }
+
+ public void testPassesThroughUnlimitedAction() {
+ ActionConcurrencyLimiterRegistry registry = registryFor("search", SEARCH_ACTION, "enforced");
+ ActionConcurrencyLimitFilter filter = new ActionConcurrencyLimitFilter(registry);
+ AtomicBoolean proceeded = new AtomicBoolean(false);
+ filter.apply(TASK, OTHER_ACTION, new SearchRequest(), null, noopListener(), passThroughChain(proceeded));
+ assertTrue("should pass through unconfigured action", proceeded.get());
+ }
+
+ public void testPassesThroughWhenWithinLimit() {
+ ActionConcurrencyLimiterRegistry registry = registryFor("search", SEARCH_ACTION, "enforced");
+ ActionConcurrencyLimitFilter filter = new ActionConcurrencyLimitFilter(registry);
+ AtomicBoolean proceeded = new AtomicBoolean(false);
+ filter.apply(TASK, SEARCH_ACTION, new SearchRequest(), null, noopListener(), passThroughChain(proceeded));
+ assertTrue("should pass through when within limit", proceeded.get());
+ }
+
+ public void testRejectsWhenLimitFull() {
+ ActionConcurrencyLimiterRegistry registry = limitedRegistry("search", SEARCH_ACTION, 1);
+ ActionConcurrencyLimitFilter filter = new ActionConcurrencyLimitFilter(registry);
+
+ // fill the one slot
+ filter.apply(TASK, SEARCH_ACTION, new SearchRequest(), null, noopListener(), passThroughChain(new AtomicBoolean()));
+
+ // next should be rejected
+ AtomicReference captured = new AtomicReference<>();
+ filter.apply(TASK, SEARCH_ACTION, new SearchRequest(), null, capturingFailureListener(captured), neverProceedChain());
+ assertNotNull(captured.get());
+ assertThat(captured.get(), org.hamcrest.Matchers.instanceOf(OpenSearchRejectedExecutionException.class));
+ }
+
+ public void testRejectIncrementsTotalRejected() {
+ ActionConcurrencyLimiterRegistry registry = limitedRegistry("search", SEARCH_ACTION, 1);
+ ActionConcurrencyLimitFilter filter = new ActionConcurrencyLimitFilter(registry);
+
+ filter.apply(TASK, SEARCH_ACTION, new SearchRequest(), null, noopListener(), passThroughChain(new AtomicBoolean()));
+ filter.apply(TASK, SEARCH_ACTION, new SearchRequest(), null, noopListener(), neverProceedChain());
+
+ assertEquals(1L, registry.getStats().getSnapshots().get(0).getTotalRejected());
+ }
+
+ public void testSuccessReleasesSlot() {
+ ActionConcurrencyLimiterRegistry registry = registryFor("search", SEARCH_ACTION, "enforced");
+ ActionConcurrencyLimitFilter filter = new ActionConcurrencyLimitFilter(registry);
+ AtomicReference> wrappedRef = new AtomicReference<>();
+
+ filter.apply(
+ TASK,
+ SEARCH_ACTION,
+ new SearchRequest(),
+ null,
+ noopListener(),
+ (ActionFilterChain) (t, a, req, listener) -> wrappedRef.set(listener)
+ );
+
+ assertEquals(1, registry.getStats().getSnapshots().get(0).getInFlight());
+ wrappedRef.get().onResponse(mock(SearchResponse.class));
+ assertEquals(0, registry.getStats().getSnapshots().get(0).getInFlight());
+ }
+
+ public void testFailureReleasesSlot() {
+ ActionConcurrencyLimiterRegistry registry = registryFor("search", SEARCH_ACTION, "enforced");
+ ActionConcurrencyLimitFilter filter = new ActionConcurrencyLimitFilter(registry);
+ AtomicReference> wrappedRef = new AtomicReference<>();
+
+ filter.apply(
+ TASK,
+ SEARCH_ACTION,
+ new SearchRequest(),
+ null,
+ noopListener(),
+ (ActionFilterChain) (t, a, req, listener) -> wrappedRef.set(listener)
+ );
+
+ wrappedRef.get().onFailure(new RuntimeException("error"));
+ assertEquals(0, registry.getStats().getSnapshots().get(0).getInFlight());
+ }
+
+ public void testOrderIsOne() {
+ ActionConcurrencyLimiterRegistry registry = registryFor("search", SEARCH_ACTION, "disabled");
+ assertEquals(1, new ActionConcurrencyLimitFilter(registry).order());
+ }
+
+ // -------------------------------------------------------------------------
+ // helpers
+ // -------------------------------------------------------------------------
+
+ @SuppressWarnings("unchecked")
+ private static ActionListener noopListener() {
+ return ActionListener.wrap(r -> {}, e -> {});
+ }
+
+ @SuppressWarnings("unchecked")
+ private static ActionListener capturingFailureListener(AtomicReference ref) {
+ return ActionListener.wrap(r -> {}, ref::set);
+ }
+
+ @SuppressWarnings("unchecked")
+ private static ActionFilterChain passThroughChain(
+ AtomicBoolean proceeded
+ ) {
+ return (task, action, request, listener) -> proceeded.set(true);
+ }
+
+ @SuppressWarnings("unchecked")
+ private static ActionFilterChain neverProceedChain() {
+ return (task, action, request, listener) -> fail("chain.proceed should not have been called");
+ }
+}
diff --git a/modules/concurrency-limit/src/test/java/org/opensearch/concurrency/ActionConcurrencyLimiterRegistryTests.java b/modules/concurrency-limit/src/test/java/org/opensearch/concurrency/ActionConcurrencyLimiterRegistryTests.java
new file mode 100644
index 0000000000000..5eb67c843d27d
--- /dev/null
+++ b/modules/concurrency-limit/src/test/java/org/opensearch/concurrency/ActionConcurrencyLimiterRegistryTests.java
@@ -0,0 +1,736 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.concurrency;
+
+import org.opensearch.action.ActionConcurrencyLimiterStats;
+import org.opensearch.action.ActionConcurrencyLimiterStats.ActionLimiterSnapshot;
+import org.opensearch.action.search.SearchRequest;
+import org.opensearch.common.settings.ClusterSettings;
+import org.opensearch.common.settings.Setting;
+import org.opensearch.common.settings.Settings;
+import org.opensearch.common.util.concurrent.ThreadContext;
+import org.opensearch.search.aggregations.AggregationBuilders;
+import org.opensearch.search.builder.SearchSourceBuilder;
+import org.opensearch.test.OpenSearchTestCase;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.Supplier;
+
+import com.netflix.concurrency.limits.Limiter;
+
+public class ActionConcurrencyLimiterRegistryTests extends OpenSearchTestCase {
+
+ private static ClusterSettings clusterSettings(Settings settings) {
+ Set> all = new HashSet<>(ActionConcurrencyLimiterRegistry.ALL_SETTINGS);
+ return new ClusterSettings(settings, all);
+ }
+
+ private static ActionConcurrencyLimiterRegistry newRegistry(Settings s, ClusterSettings cs) {
+ return new ActionConcurrencyLimiterRegistry(s, cs, new ThreadContext(Settings.EMPTY));
+ }
+
+ private static Settings enabled(String alias, String actionName) {
+ return Settings.builder()
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + alias + ".action_name", actionName)
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + alias + ".mode", "enforced")
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + alias + ".warmup_duration", "0s")
+ .build();
+ }
+
+ // -------------------------------------------------------------------------
+ // no limiter configured
+ // -------------------------------------------------------------------------
+
+ public void testNoLimiterPassesThrough() {
+ Settings s = Settings.EMPTY;
+ ActionConcurrencyLimiterRegistry registry = newRegistry(s, clusterSettings(s));
+
+ assertFalse(registry.hasLimiterFor("indices:data/read/search"));
+ assertTrue(registry.tryAcquire("indices:data/read/search", null, null).isEmpty());
+ }
+
+ // -------------------------------------------------------------------------
+ // single action
+ // -------------------------------------------------------------------------
+
+ public void testAcquireAndRelease() {
+ Settings s = enabled("search", "indices:data/read/search");
+ ActionConcurrencyLimiterRegistry registry = newRegistry(s, clusterSettings(s));
+
+ assertTrue(registry.hasLimiterFor("indices:data/read/search"));
+ Optional token = registry.tryAcquire("indices:data/read/search", null, null);
+ assertTrue("should acquire a token", token.isPresent());
+
+ ActionConcurrencyLimiterStats stats = registry.getStats();
+ assertEquals(1, stats.getSnapshots().size());
+ assertEquals(1, stats.getSnapshots().get(0).getInFlight());
+
+ token.get().onSuccess();
+ assertEquals(0, registry.getStats().getSnapshots().get(0).getInFlight());
+ }
+
+ public void testRejectionAfterLimit() {
+ Settings s = Settings.builder()
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.action_name", "indices:data/read/search")
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.mode", "enforced")
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.limit.initial", 1)
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.limit.max", 1)
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.warmup_duration", "0s")
+ .build();
+ ActionConcurrencyLimiterRegistry registry = newRegistry(s, clusterSettings(s));
+
+ Optional first = registry.tryAcquire("indices:data/read/search", null, null);
+ assertTrue(first.isPresent());
+
+ Optional second = registry.tryAcquire("indices:data/read/search", null, null);
+ assertFalse("second acquire should be rejected at limit=1", second.isPresent());
+
+ assertEquals(1L, registry.getStats().getSnapshots().get(0).getTotalRejected());
+ }
+
+ // -------------------------------------------------------------------------
+ // multiple independent actions
+ // -------------------------------------------------------------------------
+
+ public void testMultipleActionsAreIndependent() {
+ Settings s = Settings.builder()
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.action_name", "indices:data/read/search")
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.mode", "enforced")
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.warmup_duration", "0s")
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "bulk.action_name", "indices:data/write/bulk")
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "bulk.mode", "enforced")
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "bulk.warmup_duration", "0s")
+ .build();
+ ActionConcurrencyLimiterRegistry registry = newRegistry(s, clusterSettings(s));
+
+ assertTrue(registry.hasLimiterFor("indices:data/read/search"));
+ assertTrue(registry.hasLimiterFor("indices:data/write/bulk"));
+ assertFalse(registry.hasLimiterFor("indices:data/read/get"));
+
+ Optional searchToken = registry.tryAcquire("indices:data/read/search", null, null);
+ Optional bulkToken = registry.tryAcquire("indices:data/write/bulk", null, null);
+ assertTrue(searchToken.isPresent());
+ assertTrue(bulkToken.isPresent());
+
+ // Stats has two snapshots
+ assertEquals(2, registry.getStats().getSnapshots().size());
+
+ searchToken.get().onSuccess();
+ bulkToken.get().onSuccess();
+ }
+
+ // -------------------------------------------------------------------------
+ // dynamic settings changes
+ // -------------------------------------------------------------------------
+
+ public void testDynamicEnableViaSettings() {
+ Settings initial = Settings.builder()
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.action_name", "indices:data/read/search")
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.mode", "disabled")
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.warmup_duration", "0s")
+ .build();
+ ClusterSettings cs = clusterSettings(initial);
+ ActionConcurrencyLimiterRegistry registry = newRegistry(initial, cs);
+
+ // disabled — not in reverse map
+ assertFalse(registry.hasLimiterFor("indices:data/read/search"));
+
+ // enable dynamically
+ cs.applySettings(Settings.builder().put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.mode", "enforced").build());
+
+ assertTrue(registry.hasLimiterFor("indices:data/read/search"));
+ Optional token = registry.tryAcquire("indices:data/read/search", null, null);
+ assertTrue(token.isPresent());
+ token.get().onSuccess();
+ }
+
+ public void testDynamicActionNameChange() {
+ Settings s = enabled("search", "indices:data/read/search");
+ ClusterSettings cs = clusterSettings(s);
+ ActionConcurrencyLimiterRegistry registry = newRegistry(s, cs);
+
+ assertTrue(registry.hasLimiterFor("indices:data/read/search"));
+
+ cs.applySettings(
+ Settings.builder()
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.action_name", "indices:data/read/msearch")
+ .build()
+ );
+
+ assertFalse("old action name removed", registry.hasLimiterFor("indices:data/read/search"));
+ assertTrue("new action name mapped", registry.hasLimiterFor("indices:data/read/msearch"));
+ }
+
+ public void testWarmupAllowsRequestsDuringWarmup() {
+ Settings s = Settings.builder()
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.action_name", "indices:data/read/search")
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.mode", "enforced")
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.limit.initial", 1)
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.limit.max", 1)
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.warmup_duration", "5m")
+ .build();
+ ActionConcurrencyLimiterRegistry registry = newRegistry(s, clusterSettings(s));
+
+ registry.tryAcquire("indices:data/read/search", null, null); // fill slot
+ Optional second = registry.tryAcquire("indices:data/read/search", null, null);
+ assertTrue("during warmup, second acquire should return NOOP not empty", second.isPresent());
+ assertEquals(0L, registry.getStats().getSnapshots().get(0).getTotalRejected());
+ }
+
+ // -------------------------------------------------------------------------
+ // stats
+ // -------------------------------------------------------------------------
+
+ public void testStatsAlias() {
+ Settings s = enabled("my_search", "indices:data/read/search");
+ ActionConcurrencyLimiterRegistry registry = newRegistry(s, clusterSettings(s));
+ ActionConcurrencyLimiterStats stats = registry.getStats();
+ assertEquals(1, stats.getSnapshots().size());
+ assertEquals("my_search", stats.getSnapshots().get(0).getAlias());
+ assertEquals("indices:data/read/search", stats.getSnapshots().get(0).getActionName());
+ assertEquals("enforced", stats.getSnapshots().get(0).getMode());
+ }
+
+ // -------------------------------------------------------------------------
+ // mode-specific behaviour
+ // -------------------------------------------------------------------------
+
+ public void testDisabledModePassesThrough() {
+ Settings s = Settings.builder()
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.action_name", "indices:data/read/search")
+ // mode defaults to "disabled"
+ .build();
+ ActionConcurrencyLimiterRegistry registry = newRegistry(s, clusterSettings(s));
+ assertFalse("disabled mode should not appear in reverse map", registry.hasLimiterFor("indices:data/read/search"));
+ }
+
+ public void testMonitorOnlyNeverRejects() {
+ Settings s = Settings.builder()
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.action_name", "indices:data/read/search")
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.mode", "monitor_only")
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.limit.initial", 1)
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.limit.max", 1)
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.warmup_duration", "0s")
+ .build();
+ ActionConcurrencyLimiterRegistry registry = newRegistry(s, clusterSettings(s));
+
+ assertTrue("monitor_only should appear in reverse map", registry.hasLimiterFor("indices:data/read/search"));
+
+ // fill the single slot
+ Optional first = registry.tryAcquire("indices:data/read/search", null, null);
+ assertTrue(first.isPresent());
+
+ // even at limit, monitor_only never rejects
+ Optional second = registry.tryAcquire("indices:data/read/search", null, null);
+ assertTrue("monitor_only should never return empty", second.isPresent());
+ assertEquals("would-be rejections counted in monitor_only", 1L, registry.getStats().getSnapshots().get(0).getTotalRejected());
+ }
+
+ public void testModeTransitionDisabledToEnforced() {
+ Settings initial = Settings.builder()
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.action_name", "indices:data/read/search")
+ .build();
+ ClusterSettings cs = clusterSettings(initial);
+ ActionConcurrencyLimiterRegistry registry = newRegistry(initial, cs);
+
+ assertFalse(registry.hasLimiterFor("indices:data/read/search"));
+
+ cs.applySettings(Settings.builder().put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.mode", "enforced").build());
+
+ assertTrue("after transition to enforced, limiter should be active", registry.hasLimiterFor("indices:data/read/search"));
+ }
+
+ public void testModeTransitionEnforcedToMonitorOnly() {
+ Settings s = Settings.builder()
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.action_name", "indices:data/read/search")
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.mode", "enforced")
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.limit.initial", 1)
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.limit.max", 1)
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.warmup_duration", "0s")
+ .build();
+ ClusterSettings cs = clusterSettings(s);
+ ActionConcurrencyLimiterRegistry registry = newRegistry(s, cs);
+
+ // fill slot and verify rejection while enforced
+ registry.tryAcquire("indices:data/read/search", null, null);
+ assertFalse("should reject in enforced mode", registry.tryAcquire("indices:data/read/search", null, null).isPresent());
+
+ // switch to monitor_only
+ cs.applySettings(Settings.builder().put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.mode", "monitor_only").build());
+
+ // now even at limit, requests pass through
+ assertTrue("should not reject in monitor_only mode", registry.tryAcquire("indices:data/read/search", null, null).isPresent());
+ }
+
+ // -------------------------------------------------------------------------
+ // partitioning
+ // -------------------------------------------------------------------------
+
+ private static Settings.Builder partitionedBase() {
+ return Settings.builder()
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.action_name", "indices:data/read/search")
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.mode", "enforced")
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.warmup_duration", "0s")
+ .putList(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.partitions", "premium", "standard", "default");
+ }
+
+ public void testPartitionedLimiterBuildsAndAcquires() {
+ Settings s = partitionedBase().put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.partition.premium.percent", 0.6)
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.partition.standard.percent", 0.3)
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.partition.default.percent", 0.1)
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.partition.resolver", "fixed")
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.partition.resolver.fixed.partition", "premium")
+ .build();
+ ActionConcurrencyLimiterRegistry registry = newRegistry(s, clusterSettings(s));
+
+ assertTrue(registry.hasLimiterFor("indices:data/read/search"));
+ Optional token = registry.tryAcquire("indices:data/read/search", null, null);
+ assertTrue("partitioned limiter should grant a token", token.isPresent());
+ token.get().onSuccess();
+ }
+
+ public void testInvalidPartitionPercentRejectedAtUpdate() {
+ // percent > 1.0 must be rejected at PUT time (HTTP 400), not silently swallowed.
+ Settings initial = enabled("search", "indices:data/read/search");
+ ClusterSettings cs = clusterSettings(initial);
+ ActionConcurrencyLimiterRegistry registry = newRegistry(initial, cs);
+ assertTrue(registry.hasLimiterFor("indices:data/read/search"));
+
+ IllegalArgumentException e = expectThrows(
+ IllegalArgumentException.class,
+ () -> cs.applySettings(
+ Settings.builder()
+ .putList(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.partitions", "premium")
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.partition.premium.percent", 1.5)
+ .build()
+ )
+ );
+ assertTrue(e.getMessage(), e.getMessage().contains("must be in [0.0, 1.0]"));
+
+ // The previous (non-partitioned) limiter is retained and still functional.
+ assertTrue(registry.hasLimiterFor("indices:data/read/search"));
+ Optional token = registry.tryAcquire("indices:data/read/search", null, null);
+ assertTrue("limiter should still work after rejecting bad config", token.isPresent());
+ token.get().onSuccess();
+ }
+
+ public void testPartitionPercentSumOverOneRejectedAtUpdate() {
+ Settings initial = enabled("search", "indices:data/read/search");
+ ClusterSettings cs = clusterSettings(initial);
+ ActionConcurrencyLimiterRegistry registry = newRegistry(initial, cs);
+
+ // premium 0.7 + standard 0.5 = 1.2 > 1.0 — must be rejected at PUT time.
+ IllegalArgumentException e = expectThrows(
+ IllegalArgumentException.class,
+ () -> cs.applySettings(
+ Settings.builder()
+ .putList(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.partitions", "premium", "standard")
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.partition.premium.percent", 0.7)
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.partition.standard.percent", 0.5)
+ .build()
+ )
+ );
+ assertTrue(e.getMessage(), e.getMessage().contains("<= 1.0"));
+
+ // Node stays healthy; previous limiter retained.
+ assertTrue(registry.hasLimiterFor("indices:data/read/search"));
+ assertTrue(registry.tryAcquire("indices:data/read/search", null, null).isPresent());
+ }
+
+ public void testPartitionsWithoutResolverRejected() {
+ // partitions set but no resolver → all traffic would funnel to unknownPartition. Reject.
+ Settings initial = enabled("search", "indices:data/read/search");
+ ClusterSettings cs = clusterSettings(initial);
+ newRegistry(initial, cs);
+
+ IllegalArgumentException e = expectThrows(
+ IllegalArgumentException.class,
+ () -> cs.applySettings(
+ Settings.builder()
+ .putList(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.partitions", "premium")
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.partition.premium.percent", 0.5)
+ .build()
+ )
+ );
+ assertTrue(e.getMessage(), e.getMessage().contains("no partition.resolver"));
+ }
+
+ public void testUnknownResolverRejected() {
+ Settings initial = enabled("search", "indices:data/read/search");
+ ClusterSettings cs = clusterSettings(initial);
+ newRegistry(initial, cs);
+
+ IllegalArgumentException e = expectThrows(
+ IllegalArgumentException.class,
+ () -> cs.applySettings(
+ Settings.builder()
+ .putList(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.partitions", "premium")
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.partition.premium.percent", 0.5)
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.partition.resolver", "byTenant")
+ .build()
+ )
+ );
+ assertTrue(e.getMessage(), e.getMessage().contains("Unknown partition resolver"));
+ }
+
+ public void testFixedResolverTargetNotInListRejected() {
+ Settings initial = enabled("search", "indices:data/read/search");
+ ClusterSettings cs = clusterSettings(initial);
+ newRegistry(initial, cs);
+
+ IllegalArgumentException e = expectThrows(
+ IllegalArgumentException.class,
+ () -> cs.applySettings(
+ Settings.builder()
+ .putList(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.partitions", "premium")
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.partition.premium.percent", 0.5)
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.partition.resolver", "fixed")
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.partition.resolver.fixed.partition", "ghost")
+ .build()
+ )
+ );
+ assertTrue(e.getMessage(), e.getMessage().contains("is not in partitions"));
+ }
+
+ public void testValidPartitionConfigAccepted() {
+ Settings initial = enabled("search", "indices:data/read/search");
+ ClusterSettings cs = clusterSettings(initial);
+ ActionConcurrencyLimiterRegistry registry = newRegistry(initial, cs);
+
+ cs.applySettings(
+ Settings.builder()
+ .putList(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.partitions", "premium", "standard")
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.partition.premium.percent", 0.6)
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.partition.standard.percent", 0.3)
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.partition.resolver", "byHeader")
+ .build()
+ );
+
+ assertTrue(registry.hasLimiterFor("indices:data/read/search"));
+ Optional token = registry.tryAcquire("indices:data/read/search", null, null);
+ assertTrue("valid partitioned config should grant a token", token.isPresent());
+ token.get().onSuccess();
+ }
+
+ public void testBySearchTypeResolverConfigAccepted() {
+ Settings initial = enabled("search", "indices:data/read/search");
+ ClusterSettings cs = clusterSettings(initial);
+ ActionConcurrencyLimiterRegistry registry = newRegistry(initial, cs);
+
+ cs.applySettings(
+ Settings.builder()
+ .putList(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.partitions", "filter", "aggregation")
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.partition.filter.percent", 0.5)
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.partition.aggregation.percent", 0.5)
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.partition.resolver", "bySearchType")
+ .build()
+ );
+
+ assertTrue(registry.hasLimiterFor("indices:data/read/search"));
+
+ SearchRequest aggSearch = new SearchRequest().source(
+ new SearchSourceBuilder().aggregation(AggregationBuilders.max("m").field("f"))
+ );
+ Optional token = registry.tryAcquire("indices:data/read/search", null, aggSearch);
+ assertTrue("bySearchType partitioned limiter should grant a token", token.isPresent());
+ token.get().onSuccess();
+ }
+
+ // -------------------------------------------------------------------------
+ // metrics listener (push telemetry wiring)
+ // -------------------------------------------------------------------------
+
+ private static final class RecordingMetricsListener implements ActionConcurrencyLimiterRegistry.MetricsListener {
+ final List active = new ArrayList<>();
+ final List inactive = new ArrayList<>();
+ ActionLimiterSnapshot lastSnapshot;
+
+ @Override
+ public void onActive(String alias, Supplier snapshotSupplier) {
+ active.add(alias);
+ lastSnapshot = snapshotSupplier.get();
+ }
+
+ @Override
+ public void onInactive(String alias) {
+ inactive.add(alias);
+ }
+ }
+
+ public void testSetMetricsListenerReplaysActiveAliases() {
+ Settings s = enabled("search", "indices:data/read/search");
+ ActionConcurrencyLimiterRegistry registry = newRegistry(s, clusterSettings(s));
+
+ RecordingMetricsListener listener = new RecordingMetricsListener();
+ registry.setMetricsListener(listener);
+
+ assertEquals(List.of("search"), listener.active);
+ assertNotNull(listener.lastSnapshot);
+ assertEquals("indices:data/read/search", listener.lastSnapshot.getActionName());
+ assertEquals("enforced", listener.lastSnapshot.getMode());
+ }
+
+ public void testListenerFiresOnEnableAndDisable() {
+ Settings initial = Settings.builder()
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.action_name", "indices:data/read/search")
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.mode", "disabled")
+ .build();
+ ClusterSettings cs = clusterSettings(initial);
+ ActionConcurrencyLimiterRegistry registry = newRegistry(initial, cs);
+
+ RecordingMetricsListener listener = new RecordingMetricsListener();
+ registry.setMetricsListener(listener);
+ assertTrue("disabled alias should not be replayed as active", listener.active.isEmpty());
+
+ // enable → onActive
+ cs.applySettings(Settings.builder().put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.mode", "enforced").build());
+ assertEquals(List.of("search"), listener.active);
+
+ // disable → onInactive
+ cs.applySettings(Settings.builder().put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.mode", "disabled").build());
+ assertEquals(List.of("search"), listener.inactive);
+ }
+
+ public void testListenerRefiresOnReconfigure() {
+ Settings s = enabled("search", "indices:data/read/search");
+ ClusterSettings cs = clusterSettings(s);
+ ActionConcurrencyLimiterRegistry registry = newRegistry(s, cs);
+
+ RecordingMetricsListener listener = new RecordingMetricsListener();
+ registry.setMetricsListener(listener);
+ assertEquals(1, listener.active.size());
+
+ // reconfigure an active alias (mode change) → re-fire onActive so tags can refresh
+ cs.applySettings(Settings.builder().put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.mode", "monitor_only").build());
+
+ assertEquals(2, listener.active.size());
+ assertEquals("monitor_only", listener.lastSnapshot.getMode());
+ assertTrue(listener.inactive.isEmpty());
+ }
+
+ public void testMetricsListenerNotFiredOnReconfigureFailure() {
+ Settings s = enabled("search", "indices:data/read/search");
+ ClusterSettings cs = clusterSettings(s);
+ ActionConcurrencyLimiterRegistry registry = newRegistry(s, cs);
+
+ RecordingMetricsListener listener = new RecordingMetricsListener();
+ registry.setMetricsListener(listener);
+ assertEquals(1, listener.active.size()); // initial replay
+
+ int activeCountBefore = listener.active.size();
+
+ // Change algorithm — this is a successful reconfigure, listener should fire
+ cs.applySettings(Settings.builder().put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.algorithm", "aimd").build());
+ assertEquals(activeCountBefore + 1, listener.active.size());
+ }
+
+ public void testLimitChangePreservesWarmupExpired() {
+ Settings s = Settings.builder()
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.action_name", "indices:data/read/search")
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.mode", "enforced")
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.limit.initial", 1)
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.limit.max", 1)
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.warmup_duration", "0s")
+ .build();
+ ClusterSettings cs = clusterSettings(s);
+ ActionConcurrencyLimiterRegistry registry = newRegistry(s, cs);
+
+ // Warmup is 0s so it's already expired — limiter should reject
+ Optional first = registry.tryAcquire("indices:data/read/search", null, null);
+ assertTrue(first.isPresent());
+ assertFalse("should reject after warmup expired", registry.tryAcquire("indices:data/read/search", null, null).isPresent());
+ first.get().onSuccess();
+
+ // Now change limit — this triggers reconfigure. Warmup should stay expired.
+ cs.applySettings(
+ Settings.builder()
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.limit.initial", 2)
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.limit.max", 2)
+ .build()
+ );
+
+ // Fill up the new limit (2 slots)
+ Optional t1 = registry.tryAcquire("indices:data/read/search", null, null);
+ Optional t2 = registry.tryAcquire("indices:data/read/search", null, null);
+ assertTrue(t1.isPresent());
+ assertTrue(t2.isPresent());
+
+ // Third should be rejected (not pass-through via warmup restart)
+ assertFalse(
+ "warmup should not restart on limit change — should still reject",
+ registry.tryAcquire("indices:data/read/search", null, null).isPresent()
+ );
+
+ t1.get().onSuccess();
+ t2.get().onSuccess();
+ }
+
+ // -------------------------------------------------------------------------
+ // setting validation
+ // -------------------------------------------------------------------------
+
+ private static String rootCauseMessage(Throwable t) {
+ while (t.getCause() != null) {
+ t = t.getCause();
+ }
+ return t.getMessage();
+ }
+
+ public void testInvalidInitialLimitRejected() {
+ Settings initial = enabled("search", "indices:data/read/search");
+ ClusterSettings cs = clusterSettings(initial);
+ newRegistry(initial, cs);
+
+ IllegalArgumentException e = expectThrows(
+ IllegalArgumentException.class,
+ () -> cs.applySettings(
+ Settings.builder().put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.limit.initial", 0).build()
+ )
+ );
+ assertTrue(rootCauseMessage(e), rootCauseMessage(e).contains("limit.initial must be >= 1"));
+ }
+
+ public void testMaxLimitBelowInitialRejected() {
+ Settings initial = enabled("search", "indices:data/read/search");
+ ClusterSettings cs = clusterSettings(initial);
+ newRegistry(initial, cs);
+
+ IllegalArgumentException e = expectThrows(
+ IllegalArgumentException.class,
+ () -> cs.applySettings(
+ Settings.builder()
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.limit.initial", 50)
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.limit.max", 10)
+ .build()
+ )
+ );
+ assertTrue(rootCauseMessage(e), rootCauseMessage(e).contains("limit.max"));
+ }
+
+ public void testInvalidVegasBaselineThresholdRejected() {
+ Settings initial = enabled("search", "indices:data/read/search");
+ ClusterSettings cs = clusterSettings(initial);
+ newRegistry(initial, cs);
+
+ IllegalArgumentException e = expectThrows(
+ IllegalArgumentException.class,
+ () -> cs.applySettings(
+ Settings.builder()
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.vegas.baseline_reset_load_threshold", 2.0)
+ .build()
+ )
+ );
+ assertTrue(rootCauseMessage(e), rootCauseMessage(e).contains("baseline_reset_load_threshold must be in [0.0, 1.0]"));
+ }
+
+ public void testInvalidBurstCapacityRejected() {
+ Settings initial = enabled("search", "indices:data/read/search");
+ ClusterSettings cs = clusterSettings(initial);
+ newRegistry(initial, cs);
+
+ IllegalArgumentException e = expectThrows(
+ IllegalArgumentException.class,
+ () -> cs.applySettings(
+ Settings.builder().put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.burst.capacity", -1).build()
+ )
+ );
+ assertTrue(rootCauseMessage(e), rootCauseMessage(e).contains("burst.capacity must be >= 0"));
+ }
+
+ public void testInvalidAimdBackoffRatioRejected() {
+ Settings initial = enabled("search", "indices:data/read/search");
+ ClusterSettings cs = clusterSettings(initial);
+ newRegistry(initial, cs);
+
+ IllegalArgumentException e = expectThrows(
+ IllegalArgumentException.class,
+ () -> cs.applySettings(
+ Settings.builder().put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.aimd.backoff_ratio", 0.3).build()
+ )
+ );
+ assertTrue(rootCauseMessage(e), rootCauseMessage(e).contains("aimd.backoff_ratio must be in [0.5, 1.0)"));
+ }
+
+ public void testInvalidGradient2RttToleranceRejected() {
+ Settings initial = enabled("search", "indices:data/read/search");
+ ClusterSettings cs = clusterSettings(initial);
+ newRegistry(initial, cs);
+
+ IllegalArgumentException e = expectThrows(
+ IllegalArgumentException.class,
+ () -> cs.applySettings(
+ Settings.builder().put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.gradient2.rtt_tolerance", 0.5).build()
+ )
+ );
+ assertTrue(rootCauseMessage(e), rootCauseMessage(e).contains("gradient2.rtt_tolerance must be >= 1.0"));
+ }
+
+ public void testValidConfigAccepted() {
+ Settings initial = enabled("search", "indices:data/read/search");
+ ClusterSettings cs = clusterSettings(initial);
+ ActionConcurrencyLimiterRegistry registry = newRegistry(initial, cs);
+
+ // All valid values — should not throw
+ cs.applySettings(
+ Settings.builder()
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.limit.initial", 10)
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.limit.max", 100)
+ .build()
+ );
+ cs.applySettings(
+ Settings.builder()
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.vegas.updrift_factor", 2)
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.vegas.baseline_reset_load_threshold", 0.8)
+ .build()
+ );
+ cs.applySettings(Settings.builder().put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.burst.capacity", 5).build());
+ cs.applySettings(
+ Settings.builder().put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.aimd.backoff_ratio", 0.9).build()
+ );
+ cs.applySettings(
+ Settings.builder().put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.gradient2.rtt_tolerance", 2.0).build()
+ );
+
+ assertTrue(registry.hasLimiterFor("indices:data/read/search"));
+ }
+
+ // -------------------------------------------------------------------------
+ // Removing config entirely
+ // -------------------------------------------------------------------------
+
+ public void testClearingActionNameRemovesLimiterAndAllowsReactivation() {
+ Settings initial = enabled("search", "indices:data/read/search");
+ ClusterSettings cs = clusterSettings(initial);
+ ActionConcurrencyLimiterRegistry registry = newRegistry(initial, cs);
+ assertTrue(registry.hasLimiterFor("indices:data/read/search"));
+
+ // Simulate operator clearing all settings for the alias via null (REST API sends null).
+ // Consumers fire with their default values; action_name default is "".
+ cs.applySettings(Settings.builder().putNull(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.action_name").build());
+
+ assertFalse("limiter must be gone after clearing action_name", registry.hasLimiterFor("indices:data/read/search"));
+ assertEquals("no active limiters reported", 0, registry.getStats().getSnapshots().size());
+
+ // Re-enable with a fresh config — must work without any stale state.
+ cs.applySettings(
+ Settings.builder()
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.action_name", "indices:data/read/search")
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.mode", "enforced")
+ .put(ActionConcurrencyLimiterRegistry.SETTING_PREFIX + "search.warmup_duration", "0s")
+ .build()
+ );
+
+ assertTrue("limiter is active again after re-enabling", registry.hasLimiterFor("indices:data/read/search"));
+ assertEquals("one active limiter after re-enabling", 1, registry.getStats().getSnapshots().size());
+ }
+}
diff --git a/modules/concurrency-limit/src/test/java/org/opensearch/concurrency/ConcurrencyLimiterMetricsPublisherTests.java b/modules/concurrency-limit/src/test/java/org/opensearch/concurrency/ConcurrencyLimiterMetricsPublisherTests.java
new file mode 100644
index 0000000000000..76ae5a786eff4
--- /dev/null
+++ b/modules/concurrency-limit/src/test/java/org/opensearch/concurrency/ConcurrencyLimiterMetricsPublisherTests.java
@@ -0,0 +1,208 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.concurrency;
+
+import org.opensearch.action.ActionConcurrencyLimiterStats.ActionLimiterSnapshot;
+import org.opensearch.telemetry.metrics.Counter;
+import org.opensearch.telemetry.metrics.Histogram;
+import org.opensearch.telemetry.metrics.MetricsRegistry;
+import org.opensearch.telemetry.metrics.TaggedMeasurement;
+import org.opensearch.telemetry.metrics.noop.NoopMetricsRegistry;
+import org.opensearch.telemetry.metrics.tags.Tags;
+import org.opensearch.test.OpenSearchTestCase;
+
+import java.io.Closeable;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Supplier;
+
+public class ConcurrencyLimiterMetricsPublisherTests extends OpenSearchTestCase {
+
+ /** Recording MetricsRegistry: captures createGauge registrations and exposes close tracking. */
+ private static final class RecordingMetricsRegistry implements MetricsRegistry {
+ static final class GaugeReg {
+ final String name;
+ final String unit;
+ final Supplier supplier;
+ final Tags tags;
+ boolean closed = false;
+
+ GaugeReg(String name, String unit, Supplier supplier, Tags tags) {
+ this.name = name;
+ this.unit = unit;
+ this.supplier = supplier;
+ this.tags = tags;
+ }
+ }
+
+ final List gauges = new ArrayList<>();
+
+ @Override
+ public Closeable createGauge(String name, String description, String unit, Supplier valueProvider, Tags tags) {
+ GaugeReg reg = new GaugeReg(name, unit, valueProvider, tags);
+ gauges.add(reg);
+ return () -> reg.closed = true;
+ }
+
+ @Override
+ public Closeable createGauge(String name, String description, String unit, Supplier value) {
+ throw new UnsupportedOperationException("not used");
+ }
+
+ @Override
+ public Counter createCounter(String name, String description, String unit) {
+ throw new UnsupportedOperationException("not used");
+ }
+
+ @Override
+ public Counter createUpDownCounter(String name, String description, String unit) {
+ throw new UnsupportedOperationException("not used");
+ }
+
+ @Override
+ public Histogram createHistogram(String name, String description, String unit) {
+ throw new UnsupportedOperationException("not used");
+ }
+
+ @Override
+ public void close() {}
+
+ GaugeReg byName(String name) {
+ return gauges.stream().filter(g -> g.name.equals(name) && !g.closed).findFirst().orElse(null);
+ }
+
+ long openCount() {
+ return gauges.stream().filter(g -> !g.closed).count();
+ }
+ }
+
+ private static Supplier snap(
+ String alias,
+ String action,
+ String mode,
+ String algo,
+ int limit,
+ int inFlight,
+ long rejected,
+ long lastRtt,
+ long noLoad
+ ) {
+ return () -> new ActionLimiterSnapshot(alias, action, mode, algo, limit, inFlight, rejected, lastRtt, noLoad);
+ }
+
+ public void testRegistersFiveGaugesPerActiveAlias() {
+ RecordingMetricsRegistry mr = new RecordingMetricsRegistry();
+ ConcurrencyLimiterMetricsPublisher pub = new ConcurrencyLimiterMetricsPublisher(mr);
+
+ pub.onActive("coordinator_search", snap("coordinator_search", "indices:data/read/search", "enforced", "vegas", 150, 0, 0, -1, -1));
+
+ assertEquals(1, pub.registeredAliasCount());
+ assertEquals(5, mr.openCount());
+ assertNotNull(mr.byName(ConcurrencyLimiterMetricsPublisher.CURRENT_LIMIT));
+ assertNotNull(mr.byName(ConcurrencyLimiterMetricsPublisher.IN_FLIGHT));
+ assertNotNull(mr.byName(ConcurrencyLimiterMetricsPublisher.TOTAL_REJECTED));
+ assertNotNull(mr.byName(ConcurrencyLimiterMetricsPublisher.LAST_RTT));
+ assertNotNull(mr.byName(ConcurrencyLimiterMetricsPublisher.RTT_NOLOAD));
+ }
+
+ public void testGaugeTagsFromSnapshot() {
+ RecordingMetricsRegistry mr = new RecordingMetricsRegistry();
+ ConcurrencyLimiterMetricsPublisher pub = new ConcurrencyLimiterMetricsPublisher(mr);
+
+ pub.onActive("a", snap("a", "indices:data/read/search", "monitor_only", "aimd", 10, 0, 0, -1, -1));
+
+ Tags tags = mr.byName(ConcurrencyLimiterMetricsPublisher.CURRENT_LIMIT).tags;
+ assertEquals("a", tags.getTagsMap().get("alias"));
+ assertEquals("indices:data/read/search", tags.getTagsMap().get("action_name"));
+ assertEquals("monitor_only", tags.getTagsMap().get("mode"));
+ assertEquals("aimd", tags.getTagsMap().get("algorithm"));
+ }
+
+ public void testGaugeValuesTrackLiveSnapshot() {
+ RecordingMetricsRegistry mr = new RecordingMetricsRegistry();
+ ConcurrencyLimiterMetricsPublisher pub = new ConcurrencyLimiterMetricsPublisher(mr);
+
+ // Mutable backing values to prove the gauge samples live state at collection time.
+ AtomicInteger inFlight = new AtomicInteger(3);
+ Supplier live = () -> new ActionLimiterSnapshot(
+ "a",
+ "act",
+ "enforced",
+ "vegas",
+ 150,
+ inFlight.get(),
+ 7,
+ 42,
+ 5
+ );
+ pub.onActive("a", live);
+
+ Supplier inFlightGauge = mr.byName(ConcurrencyLimiterMetricsPublisher.IN_FLIGHT).supplier;
+ assertEquals(3.0, inFlightGauge.get(), 0.0);
+ inFlight.set(9);
+ assertEquals("gauge must re-sample live snapshot", 9.0, inFlightGauge.get(), 0.0);
+
+ assertEquals(150.0, mr.byName(ConcurrencyLimiterMetricsPublisher.CURRENT_LIMIT).supplier.get(), 0.0);
+ assertEquals(7.0, mr.byName(ConcurrencyLimiterMetricsPublisher.TOTAL_REJECTED).supplier.get(), 0.0);
+ assertEquals(42.0, mr.byName(ConcurrencyLimiterMetricsPublisher.LAST_RTT).supplier.get(), 0.0);
+ assertEquals(5.0, mr.byName(ConcurrencyLimiterMetricsPublisher.RTT_NOLOAD).supplier.get(), 0.0);
+ }
+
+ public void testGaugesClosedOnInactive() {
+ RecordingMetricsRegistry mr = new RecordingMetricsRegistry();
+ ConcurrencyLimiterMetricsPublisher pub = new ConcurrencyLimiterMetricsPublisher(mr);
+
+ pub.onActive("a", snap("a", "act", "enforced", "vegas", 10, 0, 0, -1, -1));
+ assertEquals(5, mr.openCount());
+
+ pub.onInactive("a");
+ assertEquals(0, pub.registeredAliasCount());
+ assertEquals("all gauges for the alias must be closed", 0, mr.openCount());
+ assertEquals(5, mr.gauges.size()); // 5 ever created, all now closed
+ }
+
+ public void testReconfigureRefreshesTagsWithoutLeak() {
+ RecordingMetricsRegistry mr = new RecordingMetricsRegistry();
+ ConcurrencyLimiterMetricsPublisher pub = new ConcurrencyLimiterMetricsPublisher(mr);
+
+ pub.onActive("a", snap("a", "act", "enforced", "vegas", 10, 0, 0, -1, -1));
+ // Re-fire (e.g. mode change) — old gauges must be closed, new ones registered.
+ pub.onActive("a", snap("a", "act", "monitor_only", "vegas", 10, 0, 0, -1, -1));
+
+ assertEquals(1, pub.registeredAliasCount());
+ assertEquals("only the latest generation of 5 gauges should be open", 5, mr.openCount());
+ assertEquals("first generation must have been closed", 10, mr.gauges.size());
+ assertEquals("monitor_only", mr.byName(ConcurrencyLimiterMetricsPublisher.CURRENT_LIMIT).tags.getTagsMap().get("mode"));
+ }
+
+ public void testMultipleAliasesIndependent() {
+ RecordingMetricsRegistry mr = new RecordingMetricsRegistry();
+ ConcurrencyLimiterMetricsPublisher pub = new ConcurrencyLimiterMetricsPublisher(mr);
+
+ pub.onActive("filterer", snap("filterer", "act1", "enforced", "vegas", 10, 0, 0, -1, -1));
+ pub.onActive("agger", snap("agger", "act2", "enforced", "vegas", 20, 0, 0, -1, -1));
+ assertEquals(2, pub.registeredAliasCount());
+ assertEquals(10, mr.openCount());
+
+ pub.onInactive("filterer");
+ assertEquals(1, pub.registeredAliasCount());
+ assertEquals(5, mr.openCount());
+ }
+
+ public void testNoopRegistryIsInert() {
+ // With the node's no-op registry (telemetry disabled), nothing should throw.
+ ConcurrencyLimiterMetricsPublisher pub = new ConcurrencyLimiterMetricsPublisher(NoopMetricsRegistry.INSTANCE);
+ pub.onActive("a", snap("a", "act", "enforced", "vegas", 10, 0, 0, -1, -1));
+ assertEquals(1, pub.registeredAliasCount());
+ pub.onInactive("a");
+ assertEquals(0, pub.registeredAliasCount());
+ pub.close();
+ }
+}
diff --git a/modules/concurrency-limit/src/test/java/org/opensearch/concurrency/PartitionResolverTests.java b/modules/concurrency-limit/src/test/java/org/opensearch/concurrency/PartitionResolverTests.java
new file mode 100644
index 0000000000000..5bd685661fb01
--- /dev/null
+++ b/modules/concurrency-limit/src/test/java/org/opensearch/concurrency/PartitionResolverTests.java
@@ -0,0 +1,109 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.concurrency;
+
+import org.opensearch.action.ActionRequest;
+import org.opensearch.action.ActionRequestValidationException;
+import org.opensearch.action.search.SearchRequest;
+import org.opensearch.common.settings.Settings;
+import org.opensearch.common.util.concurrent.ThreadContext;
+import org.opensearch.index.query.MatchAllQueryBuilder;
+import org.opensearch.search.aggregations.AggregationBuilders;
+import org.opensearch.search.builder.SearchSourceBuilder;
+import org.opensearch.test.OpenSearchTestCase;
+
+public class PartitionResolverTests extends OpenSearchTestCase {
+
+ private static SearchRequestContext ctxFor(ActionRequest request) {
+ return new SearchRequestContext(null, "indices:data/read/search", request);
+ }
+
+ private static SearchRequest aggSearch() {
+ return new SearchRequest().source(new SearchSourceBuilder().aggregation(AggregationBuilders.max("m").field("f")));
+ }
+
+ private static SearchRequest plainSearch() {
+ return new SearchRequest().source(new SearchSourceBuilder().query(new MatchAllQueryBuilder()));
+ }
+
+ // -------------------------------------------------------------------------
+ // byHeader
+ // -------------------------------------------------------------------------
+
+ public void testByHeaderReadsTierHeader() {
+ ThreadContext threadContext = new ThreadContext(Settings.EMPTY);
+ threadContext.putHeader(ActionConcurrencyLimitPlugin.TIER_HEADER, "premium");
+ PartitionResolver r = PartitionResolver.build("byHeader", Settings.EMPTY, threadContext);
+ assertEquals("premium", r.resolve(ctxFor(plainSearch())));
+ }
+
+ public void testByHeaderReturnsNullWhenHeaderMissing() {
+ ThreadContext threadContext = new ThreadContext(Settings.EMPTY);
+ PartitionResolver r = PartitionResolver.build("byHeader", Settings.EMPTY, threadContext);
+ assertNull(r.resolve(ctxFor(plainSearch())));
+ }
+
+ // -------------------------------------------------------------------------
+ // bySearchType
+ // -------------------------------------------------------------------------
+
+ public void testAggregationSearchRoutesToAggregationPartition() {
+ PartitionResolver r = new PartitionResolver.BySearchTypePartitionResolver(Settings.EMPTY);
+ assertEquals("aggregation", r.resolve(ctxFor(aggSearch())));
+ }
+
+ public void testPlainQuerySearchRoutesToFilterPartition() {
+ PartitionResolver r = new PartitionResolver.BySearchTypePartitionResolver(Settings.EMPTY);
+ assertEquals("filter", r.resolve(ctxFor(plainSearch())));
+ }
+
+ public void testNullSourceRoutesToFilter() {
+ PartitionResolver r = new PartitionResolver.BySearchTypePartitionResolver(Settings.EMPTY);
+ // bare SearchRequest has a null source
+ assertEquals("filter", r.resolve(ctxFor(new SearchRequest())));
+ }
+
+ public void testQueryWithAggregationsCountsAsAggregation() {
+ SearchRequest req = new SearchRequest().source(
+ new SearchSourceBuilder().query(new MatchAllQueryBuilder()).aggregation(AggregationBuilders.terms("t").field("f"))
+ );
+ PartitionResolver r = new PartitionResolver.BySearchTypePartitionResolver(Settings.EMPTY);
+ assertEquals("aggregation", r.resolve(ctxFor(req)));
+ }
+
+ public void testCustomPartitionNames() {
+ Settings config = Settings.builder().put("aggregation", "heavy").put("filter", "light").build();
+ PartitionResolver r = new PartitionResolver.BySearchTypePartitionResolver(config);
+ assertEquals("heavy", r.resolve(ctxFor(aggSearch())));
+ assertEquals("light", r.resolve(ctxFor(plainSearch())));
+ }
+
+ public void testNonSearchRequestResolvesToNull() {
+ ActionRequest notASearch = new ActionRequest() {
+ @Override
+ public ActionRequestValidationException validate() {
+ return null;
+ }
+ };
+ PartitionResolver r = new PartitionResolver.BySearchTypePartitionResolver(Settings.EMPTY);
+ assertNull(r.resolve(ctxFor(notASearch)));
+ }
+
+ public void testFactoryBuildsBySearchType() {
+ PartitionResolver r = PartitionResolver.build("bySearchType", Settings.EMPTY, null);
+ assertTrue(r instanceof PartitionResolver.BySearchTypePartitionResolver);
+ assertEquals("aggregation", r.resolve(ctxFor(aggSearch())));
+ assertEquals("filter", r.resolve(ctxFor(plainSearch())));
+ }
+
+ public void testFactoryUnknownTypeResolvesToNull() {
+ PartitionResolver r = PartitionResolver.build("byTenant", Settings.EMPTY, null);
+ assertNull(r.resolve(ctxFor(aggSearch())));
+ }
+}
diff --git a/modules/concurrency-limit/src/test/java/org/opensearch/concurrency/limit/BurstAwareLimitTests.java b/modules/concurrency-limit/src/test/java/org/opensearch/concurrency/limit/BurstAwareLimitTests.java
new file mode 100644
index 0000000000000..a66bc49addb6c
--- /dev/null
+++ b/modules/concurrency-limit/src/test/java/org/opensearch/concurrency/limit/BurstAwareLimitTests.java
@@ -0,0 +1,221 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.concurrency.limit;
+
+import org.opensearch.test.OpenSearchTestCase;
+
+import java.util.concurrent.TimeUnit;
+
+import com.netflix.concurrency.limits.limit.AbstractLimit;
+
+public class BurstAwareLimitTests extends OpenSearchTestCase {
+
+ /**
+ * A delegate whose limit never changes, regardless of onSample inputs.
+ * Used to isolate BurstAwareLimit's window state machine from Vegas's adaptive behavior.
+ */
+ private static class FixedLimit extends AbstractLimit {
+ FixedLimit(int limit) {
+ super(limit);
+ }
+
+ @Override
+ protected int _update(long s, long rtt, int inflight, boolean dropped) {
+ return getLimit();
+ }
+ }
+
+ /**
+ * A delegate that can be driven to a new limit externally, simulating algorithm updates.
+ */
+ private static class MutableLimit extends AbstractLimit {
+ MutableLimit(int initial) {
+ super(initial);
+ }
+
+ void updateTo(int newLimit) {
+ setLimit(newLimit);
+ }
+
+ @Override
+ protected int _update(long s, long rtt, int inflight, boolean dropped) {
+ return getLimit();
+ }
+ }
+
+ private static final long RTT = TimeUnit.MILLISECONDS.toNanos(10);
+
+ public void testGetLimitReturnsDelegatePlusBurst() {
+ FixedLimit base = new FixedLimit(20);
+ BurstAwareLimit burst = new BurstAwareLimit(base, 5, 3, 3);
+ assertEquals(25, burst.getLimit());
+ assertEquals(20, base.getLimit());
+ }
+
+ public void testZeroBurstCapacityIsTransparent() {
+ FixedLimit base = new FixedLimit(20);
+ BurstAwareLimit burst = new BurstAwareLimit(base, 0, 3, 3);
+ assertEquals(base.getLimit(), burst.getLimit());
+ }
+
+ public void testBurstLimitFollowsAlgorithmUpdates() {
+ MutableLimit base = new MutableLimit(20);
+ BurstAwareLimit burst = new BurstAwareLimit(base, 5, 2, 3);
+ assertEquals(25, burst.getLimit());
+
+ // Algorithm grows the base — burst follows.
+ base.updateTo(30);
+ assertEquals(35, burst.getLimit()); // 30 + 5
+
+ // Close the burst window — 2 consecutive samples with inflight >= base (30).
+ burst.onSample(0, RTT, 30, false); // counter=1
+ assertEquals("burst must not close before threshold", 35, burst.getLimit());
+ burst.onSample(0, RTT, 31, false); // counter=2 → closes
+ assertEquals("burst must be closed after N consecutive over-base samples", 30, burst.getLimit());
+
+ // Algorithm changes base while window is closed — propagated without burst.
+ base.updateTo(25);
+ assertEquals(25, burst.getLimit());
+
+ // After threshold quiet samples the window reopens.
+ burst.onSample(0, RTT, 10, false); // counter=1
+ burst.onSample(0, RTT, 12, false); // counter=2
+ assertEquals("burst window must remain closed before recovery threshold", 25, burst.getLimit());
+ burst.onSample(0, RTT, 14, false); // counter=3 → reopens
+ assertEquals(30, burst.getLimit()); // 25 + 5
+ }
+
+ public void testBurstWindowClosesAfterConsecutiveOverBaseSamples() {
+ FixedLimit base = new FixedLimit(20);
+ BurstAwareLimit burst = new BurstAwareLimit(base, 5, 3, 5);
+ assertEquals(25, burst.getLimit());
+
+ // inflight >= base (20) — these are over-base samples.
+ burst.onSample(0, RTT, 20, false); // counter=1
+ assertEquals("window must not close after 1 of 3", 25, burst.getLimit());
+ burst.onSample(0, RTT, 22, false); // counter=2
+ assertEquals("window must not close after 2 of 3", 25, burst.getLimit());
+ burst.onSample(0, RTT, 24, false); // counter=3 → closes
+ assertEquals("window must close after 3 consecutive over-base samples", 20, burst.getLimit());
+ }
+
+ public void testBurstWindowDoesNotCloseForBelowBaseSamples() {
+ FixedLimit base = new FixedLimit(20);
+ BurstAwareLimit burst = new BurstAwareLimit(base, 5, 3, 5);
+
+ // inflight in burst range but below base (< 20) — should not advance close counter.
+ burst.onSample(0, RTT, 18, false);
+ burst.onSample(0, RTT, 19, false);
+ burst.onSample(0, RTT, 15, false);
+ assertEquals("samples below base must not close the burst window", 25, burst.getLimit());
+ }
+
+ public void testNonSustainingSpikesDoNotCloseWindow() {
+ FixedLimit base = new FixedLimit(20);
+ BurstAwareLimit burst = new BurstAwareLimit(base, 5, 3, 5);
+
+ // Spike above base (counter=1), then quiet (counter resets), then spike (counter=1 again).
+ burst.onSample(0, RTT, 20, false); // counter=1
+ burst.onSample(0, RTT, 18, false); // resets to 0
+ burst.onSample(0, RTT, 21, false); // counter=1
+ burst.onSample(0, RTT, 17, false); // resets to 0
+ assertEquals("non-sustaining spikes must not close burst window", 25, burst.getLimit());
+ }
+
+ public void testBurstWindowReopensAfterSustainedQuiet() {
+ FixedLimit base = new FixedLimit(20);
+ BurstAwareLimit burst = new BurstAwareLimit(base, 5, 2, 3);
+
+ // Close the window.
+ burst.onSample(0, RTT, 20, false);
+ burst.onSample(0, RTT, 21, false);
+ assertEquals(20, burst.getLimit());
+
+ burst.onSample(0, RTT, 18, false); // quiet counter=1
+ burst.onSample(0, RTT, 19, false); // quiet counter=2
+ assertEquals("burst window must remain closed before recovery threshold", 20, burst.getLimit());
+
+ burst.onSample(0, RTT, 17, false); // quiet counter=3 → reopens
+ assertEquals("burst window must reopen after sustained quiet", 25, burst.getLimit());
+ }
+
+ public void testShortQuietPeriodDoesNotReopen() {
+ FixedLimit base = new FixedLimit(20);
+ BurstAwareLimit burst = new BurstAwareLimit(base, 5, 2, 5);
+
+ burst.onSample(0, RTT, 20, false);
+ burst.onSample(0, RTT, 21, false);
+ assertEquals(20, burst.getLimit());
+
+ burst.onSample(0, RTT, 18, false); // counter=1
+ burst.onSample(0, RTT, 15, false); // counter=2
+ burst.onSample(0, RTT, 10, false); // counter=3
+ burst.onSample(0, RTT, 19, false); // counter=4 — still < threshold=5
+ assertEquals("burst window must not reopen before recovery threshold", 20, burst.getLimit());
+ }
+
+ public void testAboveBaseInflightResetsQuietCounter() {
+ FixedLimit base = new FixedLimit(20);
+ BurstAwareLimit burst = new BurstAwareLimit(base, 5, 2, 3);
+
+ burst.onSample(0, RTT, 20, false);
+ burst.onSample(0, RTT, 21, false);
+ assertEquals(20, burst.getLimit());
+
+ burst.onSample(0, RTT, 15, false); // counter=1
+ burst.onSample(0, RTT, 15, false); // counter=2
+ burst.onSample(0, RTT, 21, false); // inflight >= base(20) → counter resets to 0
+ burst.onSample(0, RTT, 15, false); // counter=1 again
+ burst.onSample(0, RTT, 15, false); // counter=2 — still < threshold=3
+ assertEquals("quiet counter reset by above-base sample must prevent premature reopen", 20, burst.getLimit());
+
+ burst.onSample(0, RTT, 15, false); // counter=3 → reopens
+ assertEquals(25, burst.getLimit());
+ }
+
+ public void testIndependentCloseAndRecoveryThresholds() {
+ FixedLimit base = new FixedLimit(20);
+ // Close after 2 consecutive over-base; recover after 4 consecutive below-base.
+ BurstAwareLimit burst = new BurstAwareLimit(base, 5, 2, 4);
+
+ // Close should trigger after exactly 2 over-base samples.
+ burst.onSample(0, RTT, 20, false); // counter=1
+ assertEquals(25, burst.getLimit());
+ burst.onSample(0, RTT, 21, false); // counter=2 → closes
+ assertEquals(20, burst.getLimit());
+
+ // Recovery should not trigger until 4 below-base samples.
+ burst.onSample(0, RTT, 10, false); // counter=1
+ burst.onSample(0, RTT, 11, false); // counter=2
+ burst.onSample(0, RTT, 12, false); // counter=3 — not yet (threshold=4)
+ assertEquals("recovery must not trigger before threshold=4", 20, burst.getLimit());
+ burst.onSample(0, RTT, 13, false); // counter=4 → reopens
+ assertEquals(25, burst.getLimit());
+ }
+
+ public void testListenerNotifiedOnLimitChange() {
+ FixedLimit base = new FixedLimit(20);
+ BurstAwareLimit burst = new BurstAwareLimit(base, 5, 1, 1);
+
+ int[] lastNotified = { 0 };
+ burst.notifyOnChange(newLimit -> lastNotified[0] = newLimit);
+
+ burst.onSample(0, RTT, 20, false); // 1 over-base sample → closes: limit 25 → 20
+ assertEquals("listener must receive new limit on burst close", 20, lastNotified[0]);
+
+ burst.onSample(0, RTT, 10, false); // 1 quiet sample → reopens: limit 20 → 25
+ assertEquals("listener must receive new limit on burst reopen", 25, lastNotified[0]);
+ }
+
+ public void testGetDelegateReturnsDelegate() {
+ FixedLimit base = new FixedLimit(20);
+ BurstAwareLimit burst = new BurstAwareLimit(base, 5, 3, 3);
+ assertSame(base, burst.getDelegate());
+ }
+}
diff --git a/modules/concurrency-limit/src/test/java/org/opensearch/concurrency/limit/OpenSearchVegasLimitTests.java b/modules/concurrency-limit/src/test/java/org/opensearch/concurrency/limit/OpenSearchVegasLimitTests.java
new file mode 100644
index 0000000000000..879e0d6267977
--- /dev/null
+++ b/modules/concurrency-limit/src/test/java/org/opensearch/concurrency/limit/OpenSearchVegasLimitTests.java
@@ -0,0 +1,298 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.concurrency.limit;
+
+import org.opensearch.test.OpenSearchTestCase;
+
+import java.util.concurrent.TimeUnit;
+
+public class OpenSearchVegasLimitTests extends OpenSearchTestCase {
+
+ // -------------------------------------------------------------------------
+ // Builder defaults
+ // -------------------------------------------------------------------------
+
+ public void testDefaultUpDriftFactorIsOne() {
+ OpenSearchVegasLimit limit = OpenSearchVegasLimit.newBuilder().initialLimit(20).build();
+ assertEquals(20, limit.getLimit());
+ }
+
+ public void testBuilderRespectsBuildLimits() {
+ OpenSearchVegasLimit limit = OpenSearchVegasLimit.newBuilder().initialLimit(5).maxConcurrency(100).upDriftFactor(2.0).build();
+ assertEquals(5, limit.getLimit());
+ }
+
+ // -------------------------------------------------------------------------
+ // RTT tracking
+ // -------------------------------------------------------------------------
+
+ public void testRttNoLoadInitiallyZero() {
+ OpenSearchVegasLimit limit = OpenSearchVegasLimit.newBuilder().build();
+ assertEquals(0L, limit.getRttNoLoad(TimeUnit.NANOSECONDS));
+ }
+
+ public void testRttNoLoadUpdatedOnFirstSample() {
+ OpenSearchVegasLimit limit = OpenSearchVegasLimit.newBuilder().initialLimit(20).maxConcurrency(1000).build();
+ long rtt = TimeUnit.MILLISECONDS.toNanos(10);
+ limit.onSample(System.nanoTime() - rtt, rtt, 5, false);
+ assertEquals(rtt, limit.getRttNoLoad(TimeUnit.NANOSECONDS));
+ }
+
+ public void testRttNoLoadConvertsUnits() {
+ OpenSearchVegasLimit limit = OpenSearchVegasLimit.newBuilder().initialLimit(20).build();
+ long rttNanos = TimeUnit.MILLISECONDS.toNanos(50);
+ limit.onSample(System.nanoTime() - rttNanos, rttNanos, 5, false);
+ assertEquals(50L, limit.getRttNoLoad(TimeUnit.MILLISECONDS));
+ }
+
+ // -------------------------------------------------------------------------
+ // upDriftFactor = 1.0 matches standard Vegas behaviour at defaults
+ // -------------------------------------------------------------------------
+
+ public void testDefaultFactorDoesNotChangeInitialLimit() {
+ OpenSearchVegasLimit defaultFactor = OpenSearchVegasLimit.newBuilder().initialLimit(20).build();
+ OpenSearchVegasLimit explicitOne = OpenSearchVegasLimit.newBuilder().initialLimit(20).upDriftFactor(1.0).build();
+ assertEquals(defaultFactor.getLimit(), explicitOne.getLimit());
+ }
+
+ public void testDropAlwaysDecreasesLimit() {
+ OpenSearchVegasLimit limit = OpenSearchVegasLimit.newBuilder().initialLimit(20).maxConcurrency(200).build();
+ // Establish a baseline noload RTT first
+ long baseRtt = TimeUnit.MILLISECONDS.toNanos(10);
+ limit.onSample(0, baseRtt, 10, false);
+
+ int before = limit.getLimit();
+ // Simulate a drop with high RTT — limit should decrease
+ long highRtt = TimeUnit.MILLISECONDS.toNanos(50);
+ limit.onSample(0, highRtt, 10, true);
+ int after = limit.getLimit();
+ assertTrue("drop should not increase limit: before=" + before + " after=" + after, after <= before);
+ }
+
+ // -------------------------------------------------------------------------
+ // upDriftFactor > 1 widens the no-drift window
+ // -------------------------------------------------------------------------
+
+ public void testHigherUpDriftFactorAllowsMoreGrowth() {
+ // With upDriftFactor=3, the drift check is: inflight * 4 < limit
+ // So a limit of 20 with 4 in-flight (4*4=16 < 20) would NOT drift upward
+ // With default (factor=1): inflight * 2 < limit → 4*2=8 < 20 → also no drift
+ // But with factor=3 and inflight close to limit, it CAN drift:
+ // limit=20, inflight=10: factor=3 → 10*4=40 >= 20 → drift allowed
+ // factor=1 → 10*2=20 >= 20 → drift allowed too
+ // limit=20, inflight=5: factor=3 → 5*4=20 >= 20 → drift allowed
+ // factor=1 → 5*2=10 < 20 → drift PREVENTED
+
+ // We test that a limit with high factor doesn't reject updates when inflight
+ // is at the mid-range (which factor=1 would prevent)
+ OpenSearchVegasLimit limit = OpenSearchVegasLimit.newBuilder().initialLimit(20).maxConcurrency(1000).upDriftFactor(1.0).build();
+ long baseRtt = TimeUnit.MILLISECONDS.toNanos(10);
+ limit.onSample(0, baseRtt, 5, false); // record noload
+
+ // With factor=1 and inflight=5, limit=20: 5*2=10 < 20 → no update, returns same limit
+ int beforeFactor1 = limit.getLimit();
+ long sameRtt = TimeUnit.MILLISECONDS.toNanos(10);
+ limit.onSample(0, sameRtt, 5, false);
+ int afterFactor1 = limit.getLimit();
+ assertEquals("factor=1 should prevent upward drift at inflight=5 vs limit=20", beforeFactor1, afterFactor1);
+ }
+
+ // -------------------------------------------------------------------------
+ // toString
+ // -------------------------------------------------------------------------
+
+ public void testToStringContainsLimit() {
+ OpenSearchVegasLimit limit = OpenSearchVegasLimit.newBuilder().initialLimit(42).build();
+ String str = limit.toString();
+ assertTrue(str.contains("OpenSearchVegasLimit"));
+ assertTrue(str.contains("42"));
+ }
+
+ // -------------------------------------------------------------------------
+ // invalid rtt
+ // -------------------------------------------------------------------------
+
+ public void testNegativeRttThrows() {
+ OpenSearchVegasLimit limit = OpenSearchVegasLimit.newBuilder().initialLimit(20).build();
+ expectThrows(IllegalArgumentException.class, () -> limit.onSample(0, -1, 5, false));
+ }
+
+ public void testZeroRttThrows() {
+ OpenSearchVegasLimit limit = OpenSearchVegasLimit.newBuilder().initialLimit(20).build();
+ expectThrows(IllegalArgumentException.class, () -> limit.onSample(0, 0, 5, false));
+ }
+
+ // -------------------------------------------------------------------------
+ // Hysteresis
+ // -------------------------------------------------------------------------
+
+ public void testIncreaseHysteresisDelaysLimitGrowth() {
+ // increaseHysteresis=4: the counter increments on each qualifying sample; after N samples
+ // the Nth fires. Sample 1 establishes baseline (counter→1), so 3 more are needed.
+ // After samples 2+3 it still shouldn't fire (counter is 2 and 3, both < 4).
+ OpenSearchVegasLimit limit = OpenSearchVegasLimit.newBuilder().initialLimit(20).maxConcurrency(200).increaseHysteresis(4).build();
+
+ long baseRtt = TimeUnit.MILLISECONDS.toNanos(10);
+ limit.onSample(0, baseRtt, 10, false); // baseline + increaseConsecutive → 1
+ int before = limit.getLimit();
+
+ limit.onSample(0, baseRtt, 10, false); // counter → 2, no fire
+ limit.onSample(0, baseRtt, 10, false); // counter → 3, no fire
+ assertEquals("limit must not grow before hysteresis threshold", before, limit.getLimit());
+
+ limit.onSample(0, baseRtt, 10, false); // counter → 4, fires
+ assertTrue("limit should grow after hitting hysteresis threshold", limit.getLimit() >= before);
+ }
+
+ public void testDecreaseHysteresisDelaysLimitDrop() {
+ // increaseHysteresis=1000 freezes the limit during setup so it stays at initialLimit.
+ // decreaseHysteresis=3: after 2 slow samples limit must not drop; after the 3rd it must.
+ OpenSearchVegasLimit limit = OpenSearchVegasLimit.newBuilder()
+ .initialLimit(50)
+ .maxConcurrency(500)
+ .increaseHysteresis(1000)
+ .decreaseHysteresis(3)
+ .build();
+
+ long fastRtt = TimeUnit.MILLISECONDS.toNanos(5);
+ long slowRtt = TimeUnit.MILLISECONDS.toNanos(50);
+ // Establish a fast baseline; inflight=30 ensures the drift check (inflight*2 < limit) fails
+ // so updateEstimatedLimit is actually called on each sample.
+ for (int i = 0; i < 50; i++)
+ limit.onSample(0, fastRtt, 30, false);
+ int before = limit.getLimit();
+
+ // High RTT → queueSize ≈ 45 > beta ≈ 12 → decrease zone
+ limit.onSample(0, slowRtt, 30, false); // decreaseConsecutive → 1, no fire
+ limit.onSample(0, slowRtt, 30, false); // decreaseConsecutive → 2, no fire
+ assertEquals("limit must not drop before hysteresis threshold", before, limit.getLimit());
+
+ limit.onSample(0, slowRtt, 30, false); // decreaseConsecutive → 3, fires
+ assertTrue("limit should drop after hitting hysteresis threshold", limit.getLimit() <= before);
+ }
+
+ public void testDropBypassesDecreaseHysteresis() {
+ // didDrop=true is a hard signal and must bypass hysteresis regardless of the counter.
+ OpenSearchVegasLimit limit = OpenSearchVegasLimit.newBuilder().initialLimit(20).maxConcurrency(200).decreaseHysteresis(10).build();
+
+ long baseRtt = TimeUnit.MILLISECONDS.toNanos(10);
+ limit.onSample(0, baseRtt, 10, false);
+ int before = limit.getLimit();
+
+ // Single drop event — hysteresis counter should be irrelevant
+ long highRtt = TimeUnit.MILLISECONDS.toNanos(50);
+ limit.onSample(0, highRtt, 10, true);
+ assertTrue("drop must immediately reduce limit ignoring hysteresis", limit.getLimit() <= before);
+ }
+
+ // -------------------------------------------------------------------------
+ // Probe inflight threshold
+ // -------------------------------------------------------------------------
+
+ public void testProbeSkipsBaselineWhenInflightAboveThreshold() {
+ OpenSearchVegasLimit limit = OpenSearchVegasLimit.newBuilder()
+ .initialLimit(20)
+ .maxConcurrency(200)
+ .probeMultiplier(1)
+ .probeInflightThreshold(0.5)
+ .build();
+
+ long baseRtt = TimeUnit.MILLISECONDS.toNanos(10);
+ limit.onSample(0, baseRtt, 5, false);
+ assertEquals(baseRtt, limit.getRttNoLoad(TimeUnit.NANOSECONDS));
+
+ long inflatedRtt = TimeUnit.MILLISECONDS.toNanos(50);
+ // inflight=15 > 20*0.5=10 → probes should skip baseline reset
+ for (int i = 0; i < 200; i++) {
+ limit.onSample(0, inflatedRtt, 15, false);
+ }
+ assertEquals("baseline must not be poisoned by high-inflight probes", baseRtt, limit.getRttNoLoad(TimeUnit.NANOSECONDS));
+ }
+
+ public void testProbeAcceptsBaselineWhenInflightBelowThreshold() {
+ OpenSearchVegasLimit limit = OpenSearchVegasLimit.newBuilder()
+ .initialLimit(20)
+ .maxConcurrency(200)
+ .probeMultiplier(1)
+ .probeInflightThreshold(0.5)
+ .build();
+
+ long baseRtt = TimeUnit.MILLISECONDS.toNanos(10);
+ limit.onSample(0, baseRtt, 5, false);
+ assertEquals(baseRtt, limit.getRttNoLoad(TimeUnit.NANOSECONDS));
+
+ long newRtt = TimeUnit.MILLISECONDS.toNanos(15);
+ // inflight=5 <= 20*0.5=10 → probes should accept the new RTT
+ for (int i = 0; i < 200; i++) {
+ limit.onSample(0, newRtt, 5, false);
+ }
+ assertNotEquals("baseline should update when inflight is below threshold", baseRtt, limit.getRttNoLoad(TimeUnit.NANOSECONDS));
+ }
+
+ public void testProbeThresholdOneDisablesGuard() {
+ OpenSearchVegasLimit limit = OpenSearchVegasLimit.newBuilder()
+ .initialLimit(20)
+ .maxConcurrency(200)
+ .probeMultiplier(1)
+ .probeInflightThreshold(1.0)
+ .build();
+
+ long baseRtt = TimeUnit.MILLISECONDS.toNanos(10);
+ limit.onSample(0, baseRtt, 5, false);
+
+ long newRtt = TimeUnit.MILLISECONDS.toNanos(15);
+ // inflight=15, threshold=1.0 → 15 <= limit*1.0 as long as limit >= 15.
+ // With moderate RTT inflation and inflight well below limit, Vegas won't shrink limit below 15.
+ for (int i = 0; i < 200; i++) {
+ limit.onSample(0, newRtt, 15, false);
+ }
+ assertNotEquals("threshold=1.0 must disable the guard — baseline should change", baseRtt, limit.getRttNoLoad(TimeUnit.NANOSECONDS));
+ }
+
+ public void testProbeThresholdZeroNeverAcceptsProbe() {
+ OpenSearchVegasLimit limit = OpenSearchVegasLimit.newBuilder()
+ .initialLimit(20)
+ .maxConcurrency(200)
+ .probeMultiplier(1)
+ .probeInflightThreshold(0.0)
+ .build();
+
+ long baseRtt = TimeUnit.MILLISECONDS.toNanos(10);
+ limit.onSample(0, baseRtt, 5, false);
+
+ long inflatedRtt = TimeUnit.MILLISECONDS.toNanos(50);
+ // inflight=1 > 20*0.0=0 → probes should never accept
+ for (int i = 0; i < 200; i++) {
+ limit.onSample(0, inflatedRtt, 1, false);
+ }
+ assertEquals("threshold=0.0 must never accept probe baseline", baseRtt, limit.getRttNoLoad(TimeUnit.NANOSECONDS));
+ }
+
+ public void testMinRttPathUnaffectedByThreshold() {
+ OpenSearchVegasLimit limit = OpenSearchVegasLimit.newBuilder()
+ .initialLimit(20)
+ .maxConcurrency(200)
+ .probeInflightThreshold(0.0)
+ .build();
+
+ long highRtt = TimeUnit.MILLISECONDS.toNanos(50);
+ limit.onSample(0, highRtt, 5, false);
+ assertEquals(highRtt, limit.getRttNoLoad(TimeUnit.NANOSECONDS));
+
+ // A lower RTT should always update baseline regardless of threshold or inflight.
+ long lowRtt = TimeUnit.MILLISECONDS.toNanos(5);
+ limit.onSample(0, lowRtt, 20, false);
+ assertEquals("min-RTT path must be unconditional even with threshold=0.0", lowRtt, limit.getRttNoLoad(TimeUnit.NANOSECONDS));
+ }
+
+ public void testInvalidProbeInflightThresholdThrows() {
+ expectThrows(IllegalArgumentException.class, () -> OpenSearchVegasLimit.newBuilder().probeInflightThreshold(-0.1));
+ expectThrows(IllegalArgumentException.class, () -> OpenSearchVegasLimit.newBuilder().probeInflightThreshold(1.1));
+ }
+}
diff --git a/server/src/main/java/org/opensearch/action/ActionConcurrencyLimiterStats.java b/server/src/main/java/org/opensearch/action/ActionConcurrencyLimiterStats.java
new file mode 100644
index 0000000000000..d268681b03685
--- /dev/null
+++ b/server/src/main/java/org/opensearch/action/ActionConcurrencyLimiterStats.java
@@ -0,0 +1,152 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.action;
+
+import org.opensearch.core.common.io.stream.StreamInput;
+import org.opensearch.core.common.io.stream.StreamOutput;
+import org.opensearch.core.common.io.stream.Writeable;
+import org.opensearch.core.xcontent.ToXContentFragment;
+import org.opensearch.core.xcontent.XContentBuilder;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Per-node snapshot of all configured adaptive concurrency limiters,
+ * exposed via the {@code /_nodes/stats} API under the {@code concurrency_limiters} key.
+ */
+public class ActionConcurrencyLimiterStats implements Writeable, ToXContentFragment {
+
+ /**
+ * Snapshot for a single action alias.
+ */
+ public static class ActionLimiterSnapshot implements Writeable, ToXContentFragment {
+
+ private static final long RTT_UNAVAILABLE = -1L;
+
+ private final String alias;
+ private final String actionName;
+ private final String mode;
+ private final String algorithm;
+ private final int currentLimit;
+ private final int inFlight;
+ private final long totalRejected;
+ private final long lastRttMillis;
+ private final long rttNoLoadMillis;
+
+ public ActionLimiterSnapshot(
+ String alias,
+ String actionName,
+ String mode,
+ String algorithm,
+ int currentLimit,
+ int inFlight,
+ long totalRejected,
+ long lastRttMillis,
+ long rttNoLoadMillis
+ ) {
+ this.alias = alias;
+ this.actionName = actionName;
+ this.mode = mode;
+ this.algorithm = algorithm;
+ this.currentLimit = currentLimit;
+ this.inFlight = inFlight;
+ this.totalRejected = totalRejected;
+ this.lastRttMillis = lastRttMillis;
+ this.rttNoLoadMillis = rttNoLoadMillis;
+ }
+
+ public ActionLimiterSnapshot(StreamInput in) throws IOException {
+ this.alias = in.readString();
+ this.actionName = in.readString();
+ this.mode = in.readString();
+ this.algorithm = in.readString();
+ this.currentLimit = in.readVInt();
+ this.inFlight = in.readVInt();
+ this.totalRejected = in.readVLong();
+ this.lastRttMillis = in.readLong();
+ this.rttNoLoadMillis = in.readLong();
+ }
+
+ @Override
+ public void writeTo(StreamOutput out) throws IOException {
+ out.writeString(alias);
+ out.writeString(actionName);
+ out.writeString(mode);
+ out.writeString(algorithm);
+ out.writeVInt(currentLimit);
+ out.writeVInt(inFlight);
+ out.writeVLong(totalRejected);
+ out.writeLong(lastRttMillis);
+ out.writeLong(rttNoLoadMillis);
+ }
+
+ @Override
+ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
+ builder.startObject(alias);
+ builder.field("action_name", actionName);
+ builder.field("mode", mode);
+ builder.field("algorithm", algorithm);
+ builder.field("current_limit", currentLimit);
+ builder.field("in_flight", inFlight);
+ builder.field("total_rejected", totalRejected);
+ if (lastRttMillis != RTT_UNAVAILABLE) builder.field("last_rtt_millis", lastRttMillis);
+ if (rttNoLoadMillis != RTT_UNAVAILABLE) builder.field("rtt_no_load_millis", rttNoLoadMillis);
+ builder.endObject();
+ return builder;
+ }
+
+ public String getAlias() { return alias; }
+ public String getActionName() { return actionName; }
+ public String getMode() { return mode; }
+ public String getAlgorithm() { return algorithm; }
+ public int getCurrentLimit() { return currentLimit; }
+ public int getInFlight() { return inFlight; }
+ public long getTotalRejected() { return totalRejected; }
+ public long getLastRttMillis() { return lastRttMillis; }
+ public long getRttNoLoadMillis() { return rttNoLoadMillis; }
+ }
+
+ private final List snapshots;
+
+ public ActionConcurrencyLimiterStats(List snapshots) {
+ this.snapshots = Collections.unmodifiableList(snapshots);
+ }
+
+ public ActionConcurrencyLimiterStats(StreamInput in) throws IOException {
+ int size = in.readVInt();
+ List list = new ArrayList<>(size);
+ for (int i = 0; i < size; i++) {
+ list.add(new ActionLimiterSnapshot(in));
+ }
+ this.snapshots = Collections.unmodifiableList(list);
+ }
+
+ @Override
+ public void writeTo(StreamOutput out) throws IOException {
+ out.writeVInt(snapshots.size());
+ for (ActionLimiterSnapshot snap : snapshots) {
+ snap.writeTo(out);
+ }
+ }
+
+ @Override
+ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
+ builder.startObject("concurrency_limiters");
+ for (ActionLimiterSnapshot snap : snapshots) {
+ snap.toXContent(builder, params);
+ }
+ builder.endObject();
+ return builder;
+ }
+
+ public List getSnapshots() { return snapshots; }
+}
diff --git a/server/src/main/java/org/opensearch/action/admin/cluster/node/stats/NodeStats.java b/server/src/main/java/org/opensearch/action/admin/cluster/node/stats/NodeStats.java
index d638d11dee7fd..850ab20915d15 100644
--- a/server/src/main/java/org/opensearch/action/admin/cluster/node/stats/NodeStats.java
+++ b/server/src/main/java/org/opensearch/action/admin/cluster/node/stats/NodeStats.java
@@ -33,6 +33,7 @@
package org.opensearch.action.admin.cluster.node.stats;
import org.opensearch.Version;
+import org.opensearch.action.ActionConcurrencyLimiterStats;
import org.opensearch.action.support.nodes.BaseNodeResponse;
import org.opensearch.cluster.node.DiscoveryNode;
import org.opensearch.cluster.node.DiscoveryNodeRole;
@@ -180,6 +181,9 @@ public class NodeStats extends BaseNodeResponse implements ToXContentFragment {
@Nullable
private NativeAllocatorPoolStats nativeAllocatorStats;
+ @Nullable
+ private ActionConcurrencyLimiterStats concurrencyLimiterStats;
+
/**
* Process-level native-memory estimate captured on the data node hosting this {@code NodeStats}.
* Computed once in {@link org.opensearch.node.NodeService#stats} via
@@ -295,6 +299,11 @@ public NodeStats(StreamInput in) throws IOException {
// BWC: V_3_7_0 wrote AnalyticsBackendNativeMemoryStats here; read and discard.
in.readOptionalWriteable(AnalyticsBackendNativeMemoryStats::new);
}
+ if (in.getVersion().onOrAfter(Version.V_3_8_0)) {
+ concurrencyLimiterStats = in.readOptionalWriteable(ActionConcurrencyLimiterStats::new);
+ } else {
+ concurrencyLimiterStats = null;
+ }
if (in.getVersion().onOrAfter(Version.V_3_7_0)) {
totalEstimatedNativeBytes = in.readLong();
} else {
@@ -336,6 +345,7 @@ public NodeStats(
@Nullable NodeCacheStats nodeCacheStats,
@Nullable RemoteStoreNodeStats remoteStoreNodeStats,
@Nullable NativeAllocatorPoolStats nativeAllocatorStats,
+ @Nullable ActionConcurrencyLimiterStats concurrencyLimiterStats,
long totalEstimatedNativeBytes
) {
super(node);
@@ -371,6 +381,7 @@ public NodeStats(
this.nodeCacheStats = nodeCacheStats;
this.remoteStoreNodeStats = remoteStoreNodeStats;
this.nativeAllocatorStats = nativeAllocatorStats;
+ this.concurrencyLimiterStats = concurrencyLimiterStats;
this.totalEstimatedNativeBytes = totalEstimatedNativeBytes;
}
@@ -557,6 +568,11 @@ public NativeAllocatorPoolStats getNativeAllocatorStats() {
return nativeAllocatorStats;
}
+ @Nullable
+ public ActionConcurrencyLimiterStats getConcurrencyLimiterStats() {
+ return concurrencyLimiterStats;
+ }
+
/**
* Returns the process-level native-memory estimate captured on this node
* (RssAnon - JVM heap committed - JVM non-heap committed), or {@code -1} when the probe
@@ -641,6 +657,9 @@ public void writeTo(StreamOutput out) throws IOException {
// BWC: V_3_7_0 expects AnalyticsBackendNativeMemoryStats here; write null.
out.writeOptionalWriteable(null);
}
+ if (out.getVersion().onOrAfter(Version.V_3_8_0)) {
+ out.writeOptionalWriteable(concurrencyLimiterStats);
+ }
if (out.getVersion().onOrAfter(Version.V_3_7_0)) {
out.writeLong(totalEstimatedNativeBytes);
}
@@ -764,6 +783,9 @@ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws
if (getRemoteStoreNodeStats() != null) {
getRemoteStoreNodeStats().toXContent(builder, params);
}
+ if (getConcurrencyLimiterStats() != null) {
+ getConcurrencyLimiterStats().toXContent(builder, params);
+ }
// total_estimated_bytes ≈ RssAnon - JVM heap committed - JVM non-heap committed.
// native_memory: unified view of all native memory pools and jemalloc stats.
// NativeAllocatorPoolStats now includes jemalloc allocated/resident + all pools.
diff --git a/server/src/main/java/org/opensearch/action/admin/cluster/node/stats/NodesStatsRequest.java b/server/src/main/java/org/opensearch/action/admin/cluster/node/stats/NodesStatsRequest.java
index 266670cfad969..86eebb89eb28e 100644
--- a/server/src/main/java/org/opensearch/action/admin/cluster/node/stats/NodesStatsRequest.java
+++ b/server/src/main/java/org/opensearch/action/admin/cluster/node/stats/NodesStatsRequest.java
@@ -229,7 +229,8 @@ public enum Metric {
/** @deprecated Use {@link #NATIVE_MEMORY} instead. */
@Deprecated
NATIVE_ALLOCATOR("native_allocator"),
- NATIVE_MEMORY("native_memory");
+ NATIVE_MEMORY("native_memory"),
+ CONCURRENCY_LIMITER("concurrency_limiter");
private String metricName;
diff --git a/server/src/main/java/org/opensearch/action/admin/cluster/node/stats/TransportNodesStatsAction.java b/server/src/main/java/org/opensearch/action/admin/cluster/node/stats/TransportNodesStatsAction.java
index d3361ecbe8da9..61c5fc73ce4fa 100644
--- a/server/src/main/java/org/opensearch/action/admin/cluster/node/stats/TransportNodesStatsAction.java
+++ b/server/src/main/java/org/opensearch/action/admin/cluster/node/stats/TransportNodesStatsAction.java
@@ -132,7 +132,8 @@ protected NodeStats nodeOperation(NodeStatsRequest nodeStatsRequest) {
NodesStatsRequest.Metric.ADMISSION_CONTROL.containedIn(metrics),
NodesStatsRequest.Metric.CACHE_STATS.containedIn(metrics),
NodesStatsRequest.Metric.REMOTE_STORE.containedIn(metrics),
- NodesStatsRequest.Metric.NATIVE_MEMORY.containedIn(metrics) || NodesStatsRequest.Metric.NATIVE_ALLOCATOR.containedIn(metrics)
+ NodesStatsRequest.Metric.NATIVE_MEMORY.containedIn(metrics) || NodesStatsRequest.Metric.NATIVE_ALLOCATOR.containedIn(metrics),
+ NodesStatsRequest.Metric.CONCURRENCY_LIMITER.containedIn(metrics)
);
}
diff --git a/server/src/main/java/org/opensearch/action/admin/cluster/stats/TransportClusterStatsAction.java b/server/src/main/java/org/opensearch/action/admin/cluster/stats/TransportClusterStatsAction.java
index c8d06034e6fdf..1d39f635606d7 100644
--- a/server/src/main/java/org/opensearch/action/admin/cluster/stats/TransportClusterStatsAction.java
+++ b/server/src/main/java/org/opensearch/action/admin/cluster/stats/TransportClusterStatsAction.java
@@ -201,6 +201,7 @@ protected ClusterStatsNodeResponse nodeOperation(ClusterStatsNodeRequest nodeReq
false,
false,
false,
+ false,
false
);
List shardsStats = new ArrayList<>();
diff --git a/server/src/main/java/org/opensearch/node/Node.java b/server/src/main/java/org/opensearch/node/Node.java
index 9356c30644f66..11e7ddfd12406 100644
--- a/server/src/main/java/org/opensearch/node/Node.java
+++ b/server/src/main/java/org/opensearch/node/Node.java
@@ -40,6 +40,7 @@
import org.opensearch.OpenSearchParseException;
import org.opensearch.OpenSearchTimeoutException;
import org.opensearch.Version;
+import org.opensearch.action.ActionConcurrencyLimiterStats;
import org.opensearch.action.ActionModule;
import org.opensearch.action.ActionModule.DynamicActionRegistry;
import org.opensearch.action.ActionType;
@@ -224,6 +225,7 @@
import org.opensearch.plugins.CachePlugin;
import org.opensearch.plugins.CircuitBreakerPlugin;
import org.opensearch.plugins.ClusterPlugin;
+import org.opensearch.plugins.ConcurrencyLimiterStatsPlugin;
import org.opensearch.plugins.CryptoKeyProviderPlugin;
import org.opensearch.plugins.CryptoPlugin;
import org.opensearch.plugins.DefaultPluginComponentRegistry;
@@ -1684,7 +1686,8 @@ protected Node(final Environment initialEnvironment, Collection clas
repositoryService,
admissionControlService,
cacheService,
- nativeAllocatorStatsSupplier
+ nativeAllocatorStatsSupplier,
+ buildConcurrencyLimiterStatsSupplier(pluginsService)
);
final SearchService searchService = newSearchService(
@@ -2444,6 +2447,15 @@ PageCacheRecycler createPageCacheRecycler(Settings settings) {
return new PageCacheRecycler(settings);
}
+ private static Supplier buildConcurrencyLimiterStatsSupplier(PluginsService pluginsService) {
+ List providers = pluginsService.filterPlugins(ConcurrencyLimiterStatsPlugin.class);
+ if (providers.isEmpty()) {
+ return () -> null;
+ }
+ ConcurrencyLimiterStatsPlugin provider = providers.getFirst();
+ return provider::getConcurrencyLimiterStats;
+ }
+
/**
* Creates a new the SearchService. This method can be overwritten by tests to inject mock implementations.
*/
diff --git a/server/src/main/java/org/opensearch/node/NodeService.java b/server/src/main/java/org/opensearch/node/NodeService.java
index 1ab06c811b8d3..0cdb8460c778e 100644
--- a/server/src/main/java/org/opensearch/node/NodeService.java
+++ b/server/src/main/java/org/opensearch/node/NodeService.java
@@ -34,6 +34,7 @@
import org.opensearch.Build;
import org.opensearch.Version;
+import org.opensearch.action.ActionConcurrencyLimiterStats;
import org.opensearch.action.admin.cluster.node.info.NodeInfo;
import org.opensearch.action.admin.cluster.node.stats.NodeStats;
import org.opensearch.action.admin.indices.stats.CommonStatsFlags;
@@ -118,6 +119,7 @@ public class NodeService implements Closeable {
*/
@Nullable
private final Supplier nativeAllocatorStatsSupplier;
+ private final Supplier concurrencyLimiterStatsSupplier;
NodeService(
Settings settings,
@@ -146,7 +148,8 @@ public class NodeService implements Closeable {
RepositoriesService repositoriesService,
AdmissionControlService admissionControlService,
CacheService cacheService,
- @Nullable Supplier nativeAllocatorStatsSupplier
+ @Nullable Supplier nativeAllocatorStatsSupplier,
+ @Nullable Supplier concurrencyLimiterStatsSupplier
) {
this.settings = settings;
this.threadPool = threadPool;
@@ -177,6 +180,7 @@ public class NodeService implements Closeable {
this.segmentReplicationStatsTracker = segmentReplicationStatsTracker;
this.cacheService = cacheService;
this.nativeAllocatorStatsSupplier = nativeAllocatorStatsSupplier;
+ this.concurrencyLimiterStatsSupplier = concurrencyLimiterStatsSupplier;
}
public NodeInfo info(
@@ -263,7 +267,8 @@ public NodeStats stats(
boolean admissionControl,
boolean cacheService,
boolean remoteStoreNodeStats,
- boolean nativeMemory
+ boolean nativeMemory,
+ boolean concurrencyLimiter
) {
// for indices stats we want to include previous allocated shards stats as well (it will
// only be applied to the sensible ones to use, like refresh/merge/flush/indexing stats)
@@ -301,6 +306,7 @@ public NodeStats stats(
cacheService ? this.cacheService.stats(indices) : null,
remoteStoreNodeStats ? new RemoteStoreNodeStats() : null,
nativeMemory ? collectNativeAllocatorStats() : null,
+ concurrencyLimiter ? collectConcurrencyLimiterStats() : null,
// Always capture the process-level native memory estimate on this data node.
// Serialized over the wire so the coordinator renders the source node's value,
// not its own. Returns -1 on non-Linux platforms or when /proc/self/status is
@@ -314,6 +320,11 @@ private NativeAllocatorPoolStats collectNativeAllocatorStats() {
return nativeAllocatorStatsSupplier != null ? nativeAllocatorStatsSupplier.get() : null;
}
+ @Nullable
+ private ActionConcurrencyLimiterStats collectConcurrencyLimiterStats() {
+ return concurrencyLimiterStatsSupplier != null ? concurrencyLimiterStatsSupplier.get() : null;
+ }
+
public IngestService getIngestService() {
return ingestService;
}
diff --git a/server/src/main/java/org/opensearch/plugins/ConcurrencyLimiterStatsPlugin.java b/server/src/main/java/org/opensearch/plugins/ConcurrencyLimiterStatsPlugin.java
new file mode 100644
index 0000000000000..a6178b6670e3e
--- /dev/null
+++ b/server/src/main/java/org/opensearch/plugins/ConcurrencyLimiterStatsPlugin.java
@@ -0,0 +1,27 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.plugins;
+
+import org.opensearch.action.ActionConcurrencyLimiterStats;
+
+/**
+ * Plugin interface implemented by modules that provide adaptive per-action concurrency limiting.
+ *
+ * Implementing this interface allows the module to expose live limiter statistics
+ * through the standard {@code /_nodes/stats} API without creating a hard dependency
+ * from the server module on the concurrency-limit module.
+ */
+public interface ConcurrencyLimiterStatsPlugin {
+
+ /**
+ * Returns a snapshot of all configured limiters, or {@code null} if the registry
+ * has not been initialised yet (e.g. the node is still starting up).
+ */
+ ActionConcurrencyLimiterStats getConcurrencyLimiterStats();
+}
diff --git a/server/src/test/java/org/opensearch/action/admin/cluster/node/stats/NodeStatsTests.java b/server/src/test/java/org/opensearch/action/admin/cluster/node/stats/NodeStatsTests.java
index 7d9f77f1d0bd8..ec39bd5d8adac 100644
--- a/server/src/test/java/org/opensearch/action/admin/cluster/node/stats/NodeStatsTests.java
+++ b/server/src/test/java/org/opensearch/action/admin/cluster/node/stats/NodeStatsTests.java
@@ -1056,6 +1056,7 @@ public long getLastSuccessfulFetchOfPinnedTimestamps() {
nodeCacheStats,
remoteStoreNodeStats,
null,
+ null,
-1L
);
}
@@ -1700,6 +1701,7 @@ private static NodeStats newNodeStatsWithNativeAllocator(
null, // nodeCacheStats
null,
nativeAllocatorStats,
+ null, // concurrencyLimiterStats
totalEstimatedNativeBytes
);
}
diff --git a/server/src/test/java/org/opensearch/action/admin/cluster/stats/ClusterStatsNodesTests.java b/server/src/test/java/org/opensearch/action/admin/cluster/stats/ClusterStatsNodesTests.java
index fcdd26912848f..4c83f88c85bd9 100644
--- a/server/src/test/java/org/opensearch/action/admin/cluster/stats/ClusterStatsNodesTests.java
+++ b/server/src/test/java/org/opensearch/action/admin/cluster/stats/ClusterStatsNodesTests.java
@@ -354,6 +354,7 @@ private ClusterStatsNodeResponse createClusterStatsNodeResponse(
null,
null,
null, // nativeAllocator
+ null, // concurrencyLimiterStats
-1L // totalEstimatedNativeBytes
);
if (defaultBehavior) {
diff --git a/server/src/test/java/org/opensearch/action/admin/cluster/stats/ClusterStatsResponseTests.java b/server/src/test/java/org/opensearch/action/admin/cluster/stats/ClusterStatsResponseTests.java
index 7ff9dd3d6a89e..661cd9753d80d 100644
--- a/server/src/test/java/org/opensearch/action/admin/cluster/stats/ClusterStatsResponseTests.java
+++ b/server/src/test/java/org/opensearch/action/admin/cluster/stats/ClusterStatsResponseTests.java
@@ -226,6 +226,7 @@ private ClusterStatsNodeResponse createClusterStatsNodeResponse(DiscoveryNode no
null,
null,
null, // nativeAllocator
+ null, // concurrencyLimiterStats
-1L // totalEstimatedNativeBytes
);
return new ClusterStatsNodeResponse(node, null, nodeInfo, nodeStats, shardStats);
diff --git a/server/src/test/java/org/opensearch/cluster/DiskUsageTests.java b/server/src/test/java/org/opensearch/cluster/DiskUsageTests.java
index 2bf4b1fcd8b94..330ee68f446ad 100644
--- a/server/src/test/java/org/opensearch/cluster/DiskUsageTests.java
+++ b/server/src/test/java/org/opensearch/cluster/DiskUsageTests.java
@@ -216,6 +216,7 @@ public void testFillDiskUsage() {
null,
null,
null, // nativeAllocator
+ null, // concurrencyLimiterStats
-1L // totalEstimatedNativeBytes
),
new NodeStats(
@@ -252,6 +253,7 @@ public void testFillDiskUsage() {
null,
null,
null, // nativeAllocator
+ null, // concurrencyLimiterStats
-1L // totalEstimatedNativeBytes
),
new NodeStats(
@@ -288,6 +290,7 @@ public void testFillDiskUsage() {
null,
null,
null, // nativeAllocator
+ null, // concurrencyLimiterStats
-1L // totalEstimatedNativeBytes
)
);
@@ -355,6 +358,7 @@ public void testFillDiskUsageSomeInvalidValues() {
null,
null,
null, // nativeAllocator
+ null, // concurrencyLimiterStats
-1L // totalEstimatedNativeBytes
),
new NodeStats(
@@ -391,6 +395,7 @@ public void testFillDiskUsageSomeInvalidValues() {
null,
null,
null, // nativeAllocator
+ null, // concurrencyLimiterStats
-1L // totalEstimatedNativeBytes
),
new NodeStats(
@@ -427,6 +432,7 @@ public void testFillDiskUsageSomeInvalidValues() {
null,
null,
null, // nativeAllocator
+ null, // concurrencyLimiterStats
-1L // totalEstimatedNativeBytes
)
);
@@ -523,6 +529,7 @@ private NodeStats makeNodeStatsWithResourceUsage(DiscoveryNode node, NodesResour
null,
null,
null,
+ null,
-1L
);
diff --git a/server/src/test/java/org/opensearch/node/NodeServiceNativeMemoryTests.java b/server/src/test/java/org/opensearch/node/NodeServiceNativeMemoryTests.java
index e69eefd2be669..376ed88e62537 100644
--- a/server/src/test/java/org/opensearch/node/NodeServiceNativeMemoryTests.java
+++ b/server/src/test/java/org/opensearch/node/NodeServiceNativeMemoryTests.java
@@ -83,7 +83,8 @@ private NodeService createNodeService(Supplier nativeA
mock(RepositoriesService.class),
mock(AdmissionControlService.class),
null, // cacheService
- nativeAllocatorStatsSupplier
+ nativeAllocatorStatsSupplier,
+ null // concurrencyLimiterStatsSupplier
);
}
@@ -129,7 +130,8 @@ public void testStatsWithNativeMemoryTrueAndSupplierPresent() {
false, // admissionControl
false, // cacheService
false, // remoteStoreNodeStats
- true // nativeMemory
+ true, // nativeMemory
+ false // concurrencyLimiter
);
assertNotNull("nativeAllocatorStats should be present when supplier returns non-null", nodeStats.getNativeAllocatorStats());
@@ -173,7 +175,8 @@ public void testStatsWithNativeMemoryTrueAndNoSupplier() {
false, // admissionControl
false, // cacheService
false, // remoteStoreNodeStats
- true // nativeMemory
+ true, // nativeMemory
+ false // concurrencyLimiter
);
assertNull("nativeAllocatorStats should be null when no supplier registered", nodeStats.getNativeAllocatorStats());
@@ -221,7 +224,8 @@ public void testStatsWithNativeMemoryFalse() {
false, // admissionControl
false, // cacheService
false, // remoteStoreNodeStats
- false // nativeMemory
+ false, // nativeMemory
+ false // concurrencyLimiter
);
assertNull("nativeAllocatorStats should be null when nativeMemory=false", nodeStats.getNativeAllocatorStats());
diff --git a/test/framework/src/main/java/org/opensearch/cluster/MockInternalClusterInfoService.java b/test/framework/src/main/java/org/opensearch/cluster/MockInternalClusterInfoService.java
index bd2842cdaa20d..2dc1410ce7cdd 100644
--- a/test/framework/src/main/java/org/opensearch/cluster/MockInternalClusterInfoService.java
+++ b/test/framework/src/main/java/org/opensearch/cluster/MockInternalClusterInfoService.java
@@ -154,6 +154,7 @@ List adjustNodesStats(List nodesStats) {
nodeStats.getNodeCacheStats(),
nodeStats.getRemoteStoreNodeStats(),
nodeStats.getNativeAllocatorStats(),
+ nodeStats.getConcurrencyLimiterStats(),
nodeStats.getTotalEstimatedNativeBytes()
);
}).collect(Collectors.toList());
diff --git a/test/framework/src/main/java/org/opensearch/test/InternalTestCluster.java b/test/framework/src/main/java/org/opensearch/test/InternalTestCluster.java
index e92c1c6402a9f..e01f4d651d979 100644
--- a/test/framework/src/main/java/org/opensearch/test/InternalTestCluster.java
+++ b/test/framework/src/main/java/org/opensearch/test/InternalTestCluster.java
@@ -2709,6 +2709,7 @@ public void ensureEstimatedStats() {
false,
false,
false,
+ false,
false
);
assertThat(