-
Notifications
You must be signed in to change notification settings - Fork 0
Enforce node-level WLM request throttling (node_limit) #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: 3.7-wlm-throttling
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,7 +16,9 @@ | |
| import org.opensearch.cluster.metadata.Metadata; | ||
| import org.opensearch.cluster.metadata.WorkloadGroup; | ||
| import org.opensearch.cluster.service.ClusterService; | ||
| import org.opensearch.common.lease.Releasable; | ||
| import org.opensearch.common.lifecycle.AbstractLifecycleComponent; | ||
| import org.opensearch.common.settings.Settings; | ||
| import org.opensearch.core.concurrency.OpenSearchRejectedExecutionException; | ||
| import org.opensearch.monitor.jvm.JvmStats; | ||
| import org.opensearch.monitor.process.ProcessProbe; | ||
|
|
@@ -59,6 +61,8 @@ public class WorkloadGroupService extends AbstractLifecycleComponent | |
| private final Set<WorkloadGroup> deletedWorkloadGroups; | ||
| private final NodeDuressTrackers nodeDuressTrackers; | ||
| private final WorkloadGroupsStateAccessor workloadGroupsStateAccessor; | ||
| // Node-local in-flight throttle counters, keyed by throttle bucket. No cross-node coordination in this tier. | ||
| private final WorkloadGroupThrottleTracker throttleTracker = new WorkloadGroupThrottleTracker(); | ||
|
|
||
| public WorkloadGroupService( | ||
| WorkloadGroupTaskCancellationService taskCancellationService, | ||
|
|
@@ -312,6 +316,82 @@ public void rejectIfNeeded(String workloadGroupId) { | |
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Acquires one node-level throttle permit for the request, or returns {@code null} (nothing to release) when the | ||
| * request is not throttled: WLM disabled, default/unknown group, no {@code node_limit}, or no resolvable bucket | ||
| * (see {@link #resolveThrottleBucketKey}). The bucket depends on the group's throttle {@code attribute}. | ||
| * | ||
| * @param workloadGroupId the workload group the request is assigned to | ||
| * @param principal the raw {@code WORKLOAD_GROUP_PRINCIPAL_HEADER} value, or {@code null} (see resolver) | ||
| * @return a permit to close on request completion, or {@code null} if not throttled | ||
| * @throws OpenSearchRejectedExecutionException if the bucket is already at its node limit | ||
| */ | ||
| public Releasable acquireThrottleOrReject(String workloadGroupId, String principal) { | ||
| if (workloadManagementSettings.getWlmMode() != WlmMode.ENABLED) { | ||
| return null; | ||
| } | ||
| if (workloadGroupId == null || workloadGroupId.equals(WorkloadGroupTask.DEFAULT_WORKLOAD_GROUP_ID_SUPPLIER.get())) { | ||
| return null; | ||
| } | ||
| try { | ||
| WorkloadGroup workloadGroup = getWorkloadGroupById(workloadGroupId); | ||
| if (workloadGroup == null) { | ||
| return null; | ||
| } | ||
| Settings throttling = workloadGroup.getMutableWorkloadGroupFragment().getThrottling(); | ||
| int nodeLimit = WorkloadGroupThrottleSettings.NODE_LIMIT.get(throttling); | ||
| if (nodeLimit == WorkloadGroupThrottleSettings.UNSET_LIMIT) { | ||
| return null; | ||
| } | ||
| // A null bucket means the request can't be attributed (e.g. username/role with no principal) -> fail open. | ||
| String bucketKey = resolveThrottleBucketKey( | ||
| workloadGroupId, | ||
| WorkloadGroupThrottleSettings.ATTRIBUTE.get(throttling), | ||
| principal | ||
| ); | ||
| if (bucketKey == null) { | ||
| return null; | ||
| } | ||
| try { | ||
| return throttleTracker.acquire(bucketKey, nodeLimit); | ||
| } catch (OpenSearchRejectedExecutionException e) { | ||
| workloadGroupsStateAccessor.getWorkloadGroupState(workloadGroupId).totalThrottled.inc(); | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (minor) Rejection may be counted against the DEFAULT group during the state-registration lag.
Suggest reading via |
||
| throw e; | ||
| } | ||
| } catch (OpenSearchRejectedExecutionException e) { | ||
| throw e; // the intended 429 | ||
| } catch (Exception e) { | ||
| // A bug in the throttle path must never fail an otherwise-valid search, so fail open. | ||
| logger.warn("Skipping node-level throttle for workload group [" + workloadGroupId + "] due to an error", e); | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Builds the throttle bucket key: {@code <groupId>:group} for whole-group throttling, or | ||
| * {@code <groupId>:<attribute>:<value>} keyed by the matching principal subfield for {@code username}/{@code role}. | ||
| * Returns {@code null} (fail open, not throttled) when the principal is absent or lacks a token for the subfield. | ||
| */ | ||
| private String resolveThrottleBucketKey(String workloadGroupId, String attribute, String principal) { | ||
| if ("group".equals(attribute)) { | ||
| return workloadGroupId + ":group"; | ||
| } | ||
| if (principal == null || principal.isEmpty()) { | ||
| return null; | ||
| } | ||
| String subfieldPrefix = attribute + "|"; | ||
| for (String token : principal.split(WorkloadGroupTask.WORKLOAD_GROUP_PRINCIPAL_VALUE_DELIMITER)) { | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The principal is joined with |
||
| String trimmed = token.trim(); | ||
| if (trimmed.startsWith(subfieldPrefix)) { | ||
| String value = trimmed.substring(subfieldPrefix.length()); | ||
| if (value.isEmpty() == false) { | ||
| return workloadGroupId + ":" + attribute + ":" + value; | ||
| } | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| private double getNormalisedRejectionThreshold(double limit, ResourceType resourceType) { | ||
| if (resourceType == ResourceType.CPU) { | ||
| return limit * workloadManagementSettings.getNodeLevelCpuRejectionThreshold(); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(major) This stat update can silently turn a 429 into an admit.
getWorkloadGroupState(...)readsWorkloadGroupsStateAccessor.workloadGroupStateMap, which is a plainHashMap, from the search-admission thread — concurrently withclusterChanged()mutating it (putIfAbsent/remove) on the cluster-applier thread. An unlucky concurrent read can throw aRuntimeException(orNPE), which is not anOpenSearchRejectedExecutionException, so it skips thethrow eon the next line and is caught by the fail-opencatch (Exception)below → the method returnsnull→ the request that should have been rejected is admitted. Rare and fail-open, but it silently defeats the limit under concurrency.Cheapest, most robust fix: make
WorkloadGroupsStateAccessor.workloadGroupStateMapaConcurrentHashMap(kills this whole race class). As defense-in-depth, also wrap just the counter increment so it can never swallow the rejection:There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, good catch!