Queue WLM requests denied by a throttle limit instead of rejecting - #11
Queue WLM requests denied by a throttle limit instead of rejecting#11dzane17 wants to merge 2 commits into
Conversation
| boolean granted = tracker.tryAcquire(request.bucketKey, request.sharedLimit, request.leaseId, request.ttlNanos); | ||
| if (granted == false && request.wantsQueue && request.requestingNode != null) { | ||
| // Denied and the coordinator will park the request -> remember it so a freed slot is pushed back as a grant. | ||
| registerWaiter(request.bucketKey, request.requestingNode); |
There was a problem hiding this comment.
The coordinator is registered as a waiter here at acquire time, but the request isn't actually put into the queue until later in the async denial callback tryEnqueue. A concurrent release could grant in the gap, find the queue still empty in admitWithPermit, and deregister the coordinator. The request then is added to the queue with no registered waiter, and a shared-only config has no node-tier drain to recover it. Could we register the waiter after tryEnqueue instead?
There was a problem hiding this comment.
Fixed, via enqueue-first rather than your exact suggestion: the shared path now parks the request before the owner acquire, so a grant or owner-push always finds a non-empty queue.
| final String leaseId; | ||
| final long ttlNanos; | ||
| // Owner-push: who is asking, and whether they will park the request on denial (so the owner should register a | ||
| // waiter and later push a grant). requestingNode may be null from an older node that predates queueing. |
There was a problem hiding this comment.
A message from an older node would fail to deserialize rather than read as null because the fields are read unconditionally.
There was a problem hiding this comment.
I think this depends if throttling and queueing are released together. I will add a todo to note version gate may be missing.
| if (deque == null) { | ||
| return false; | ||
| } | ||
| boolean removed = deque.remove(req); |
There was a problem hiding this comment.
ArrayDeque.remove is O(n), and it runs once per cancelled task through evictCancelled. If a workload group is deleted, every task in it is cancelled through cancelTasksFromDeletedWorkloadGroups, so a group delete on a large single-bucket queue is O(n^2). Could we considered doing a LinkedHashedSet so then it's a O(1) on average remove?
There was a problem hiding this comment.
Fixed as suggested — LinkedHashSet per bucket, O(1) average removal, and insertion order still gives the drains their FIFO.
| * node permit if one is free; a no-op otherwise. Wired into {@link WorkloadGroupQueueService#sweep}. This recovers | ||
| * a request the node-completion chain missed without re-running full admission or re-contacting the shared owner | ||
| * (the owner recovers its own lost grants and reservations), and — crucially — without dequeuing-and-re-parking, | ||
| * so a still-waiting request keeps its original {@code queue.timeout} deadline. |
There was a problem hiding this comment.
I believe queue.timeout is removed now?
|
|
||
| // Cross-field checks on the merged throttling config (attribute required with a limit; ceiling must be >= 1). | ||
| WorkloadGroupThrottleSettings.validateMergedConfig(mutableWorkloadGroupFragment.getThrottling()); | ||
| // Cross-field checks on the merged queue config (timeout requires size > 0). |
There was a problem hiding this comment.
validateMergedConfig is a no-op for WorkloadGroupQueueSettings, should we remove this?
| } | ||
| return () -> { | ||
| raw.close(); | ||
| if (qs.totalDepth() > 0) { |
There was a problem hiding this comment.
drainNode is local to the group, so this guard should be currentDepth(groupId) > 0, not totalDepth() > 0 (totalDepth sums all currentDepths of groups that have been queued) as this would mean a completion drains whenever any group is backlogged.
| return task; | ||
| } | ||
|
|
||
| public String principal() { |
There was a problem hiding this comment.
The only time principal is used is for a test in WorkloadGroupQueueTests.testCapturesListenerAndBucket, so is it necessary to pass it into the constructor for QueuedRequest (and thus in this function too)?
| })); | ||
| // wantsQueue: on a shared-tier denial the owner should register this coordinator as a waiter for owner-push, | ||
| // but only if this group actually queues (size > 0) and the queue service is wired. | ||
| boolean wantsQueue = queueService != null && plan.queueSize > 0; |
There was a problem hiding this comment.
A group in monitor mode with a shared_limit > 0 and queue.size > 0 still sends wantsQueue=true so handleAcquire registers it as a waiter, and since it never gets added to the queue, the next freed slot pushes a grant that finds nothing and deregisters it. We could add && !plan.monitorMode check here to remove that extra work.
|
Also, I think some of the javadoc is behind the current code, may want to check and re-update the comments after the code updates. |
| qs.drainNode( | ||
| groupId, | ||
| bucketKey, | ||
| key -> wrapNodePermit(throttleTracker.tryAcquire(key, nodeLimit), groupId, key, nodeLimit) |
There was a problem hiding this comment.
This uses the existing nodeLimit when the drain chain started, but if the nodeLimit is lowered or increased while a bucket is busy with a queue, that value is ignored here. Could the completion drain re-read the current nodeLimit so then we get the live updated value while draining.
There was a problem hiding this comment.
Fixed — the drain re-reads the live node_limit per hop via currentNodeLimit(groupId). sweepDrainNode already re-read it, so the two drain paths had disagreed.
There was a problem hiding this comment.
The new onSharedSlotFreed calls ensure that if a slot is freed, we know, but here we call tracker.sweepExpired without onSharedSlotFreed so we could have a situation where a slot never goes to a registered waiter so should we add that to this function?
There was a problem hiding this comment.
sweepExpired() now returns the buckets that freed capacity and runs the next queued request (if present)
9292d59 to
8b6c534
Compare
Signed-off-by: David Zane <davizane@amazon.com>
Signed-off-by: David Zane <davizane@amazon.com>
Summary
Adds request queueing on top of the shipped WLM throttling. When a search exceeds its workload group's throttle limit, instead of an immediate HTTP 429 the coordinator parks it in a bounded queue and admits it once a permit frees. Scope: throttle-limit denials only (resource-limit rejection is unchanged).
Base branch:
3.7-wlm-throttling.How it works
queueobject on the workload group, sibling ofthrottling, with a single settingsize_per_bucket(default0= queueing disabled; over-limit requests are rejected immediately as before). The cap is per throttle bucket, mirroring hownode_limit/shared_limitare themselves per-bucket, so withattribute=username/roleone principal's flood cannot consume another principal's queue capacity. Forattribute=groupthere is a single bucket, so it is simply the group's queue depth. Wire-gated atV_3_7_0.MAX_GROUP_QUEUE_DEPTH= 10,000) on the total parked requests across all of a group's buckets on one coordinator. It is a footprint backstop, not a fairness knob:username/rolebucket keys are derived from the request principal and so are attacker-controlled, and a purely per-bucket cap would let unbounded distinct principals each allocatesize_per_bucketslots.size_per_bucketis validated against the same value, so no configured depth is silently unreachable. Note the per-bucket fairness is therefore bounded rather than absolute: at the ceiling, admission reverts to first-come-first-served across buckets.ActionListeneris held (no thread consumed, only the listener + open connection). On the shared tier the request is parked before the owner acquire ("enqueue-first"): the owner registers the coordinator as a waiter insidehandleAcquire, before the reply is even sent, so parking only after the denial left a window in which a racing grant found an empty queue, returned the slot as unused, and deregistered the coordinator — stranding the request, with no node-tier drain to recover it on a shared-only group. Because the request is already queued, a granted acquire drains the oldest queued request for the bucket (FIFO) rather than running its own;shared_limitremains gated solely by the owner'stryAcquire, so this cannot over-admit.close()drains the freed bucket (immediate, local). It re-reads the group's livenode_limiton each drain, so a dynamic limit change takes effect on the very next drain instead of the chain reusing a value captured when it started.cancel_after_time_interval(per-request or thesearch.cancel_after_time_intervalcluster setting) or by disconnecting — either cancels the task, which evicts the parked entry promptly. The sweep only reaps already-cancelled tasks as defense-in-depth.GET _wlm/statsgainstotal_queued,total_queue_rejections,queued_current,queue_peak, and queue-wait aggregates (queue_wait_count,total_queue_wait_millis,max_queue_wait_millis).Testing
queue.timeoutand the earlierqueue.sizeboth rejected as unknown keys, and the per-bucket max pinned to the group ceiling); owner-push drain, round-robin fairness, and TTL-sweep-driven grant; monitor-mode-never-parks on both the node and shared tiers; livenode_limitdecrease honoured mid-drain; backlog released on throttling disable; stats wire round-trip.WlmQueueingIT: node-tier park-then-admit and queue-full → 429 end-to-end.WlmClusterThrottlingIT: a shared-only group parks a request and drains it via cross-node owner-push (the exact strand scenario the enqueue-first change fixes).cancel_after_time_interval/_tasks/_cancel) evicting parked requests promptly, cross-node owner-push draining with no stranding, round-robin fairness, mass-cancel recursion safety, and throughput/latency behavior under bursts up to 3000 concurrent../gradlew :server:precommitand:plugins:workload-management:precommitpass.Notes
queue.size_per_bucketfield on the workload group API.queue.timeoutis intentionally not a setting, and neither is the earlierqueue.size— both are rejected as unknown keys so a stale config surfaces clearly rather than being silently ignored.ThrottleOwnerSelector.MIN_OWNER_VERSIONis a placeholder (V_3_7_0). It must be bumped to whatever release actually first ships these transport handlers, or a rolling upgrade could map buckets to nodes that lack them.