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(