Skip to content

Commit d29cbf4

Browse files
[AMORO-4271] AIP-5 Phase 2: demand-driven scale-up for dynamic allocation groups (#4272)
* [AMORO-4271] AIP-5 Phase 2: add dynamic-allocation.executor-parallelism config The scaling unit of dynamic allocation is one homogeneous K-thread optimizer instance (the Spark executor model). K is configured by the new dynamic-allocation.executor-parallelism property (default 1). validate() rejects K < 1 and K > max-parallelism: a single K-thread instance already exceeding the cap could never be created, which would leave an enabled group as a silent no-op. Signed-off-by: Jiwon Park <jpark92@outlook.kr> * [AMORO-4271] AIP-5 Phase 2: pure demand accounting (serviceable planned, thread occupancy) serviceablePlannedCount implements quota-mode-aware demand counting: a proportional quota (targetQuota <= 1) scales with availableCore, so the whole backlog is serviceable; an absolute quota (> 1) is a fixed limit that scaling cannot raise, so only free slots count. occupiesThread counts SCHEDULED as occupying: a thread is busy from assignment (pollTask), not from ack; counting only ACKED would overestimate headroom during the poll-to-ack window. Signed-off-by: Jiwon Park <jpark92@outlook.kr> * [AMORO-4271] AIP-5 Phase 2: computeScaleUp scale-out decision Per-group decision state (backlog timer, cadence, exponential ramp) with injected time, so every scenario is deterministic. Ordered checks: - min-parallelism floor: enforced immediately, no timing gate. - Immediate demand (busy + serviceable > effective): exponential ramp (1, 2, 4, 8) clamped to the actual need; the ramp resets when the clamp binds (Spark addExecutors semantics) or when demand clears. - Future demand (pending tables while all threads are busy, including the zero-optimizer cold start where nothing polls and planning never runs): a single probe instance. Pending tables are not quantified demand before planning, so no exponential growth on this signal. Demand must persist for scheduler-backlog-timeout before the first scale-out; later rounds are spaced by sustained-backlog-timeout, so a trickle drained between rounds never accumulates toward a scale-out. The max-parallelism cap always wins. Signed-off-by: Jiwon Park <jpark92@outlook.kr> * [AMORO-4271] AIP-5 Phase 2: pending registration accounting with boot deadline Registration is optimizer-driven (the pod self-registers after boot), so scale-up counts requested-but-unregistered capacity to avoid duplicate scale-outs during the boot window. Entries carry their own boot deadline: a request that never registers (image pull failure, exhausted ResourceQuota, crash loop) is pruned instead of freezing scale-up below real demand forever. Heartbeat expiry cannot cover this window because it only starts after registration. Signed-off-by: Jiwon Park <jpark92@outlook.kr> * [AMORO-4271] AIP-5 Phase 2: dedicated scale keeper for dynamic-allocation groups DRA-enabled groups are taken over from the legacy floor keeper, whose min-parallelism-check cadence (minutes, multiplied by consecutive attempts) would render the DRA backlog timeouts (seconds) physically unreachable. OptimizerScaleKeeper reuses the AbstractKeeper infrastructure (HA leader gating, DelayQueue, lifecycle) and evaluates each group at its own sustained-backlog-timeout; the legacy keeper keeps watching DRA groups only to resume floor duty if DRA is disabled later. Scale-outs are executed in executor-parallelism-thread instance units (computeScaleUp), with requested-but-unregistered capacity counted via PendingRegistrations so a booting pod is not re-requested every round; a synchronous request failure is dropped immediately and retried. Groups are watched on create, startup load, and update (covering enabling DRA on an existing group at runtime); deleted or disabled groups drop out of the watch set on their next evaluation. OptimizingQueue.collectDynamicAllocationLoad() snapshots the demand side: busy threads (SCHEDULED and ACKED), quota-mode-aware serviceable PLANNED tasks, and PENDING tables - the only signal observable on a cold group with zero optimizers. Signed-off-by: Jiwon Park <jpark92@outlook.kr> * [AMORO-4271] AIP-5 Phase 2: warn when a group is planning-bound instead of scaling Idle threads while tables wait as PENDING and no PLANNED tasks materialize means the bottleneck is the serialized planning (optimizer.max-planning-parallelism), not thread capacity: scaling out would only add more idle threads. The scale keeper surfaces this as an edge-triggered warning naming the config to raise, and does not scale. A cold group (zero threads) stays the future-demand case. Signed-off-by: Jiwon Park <jpark92@outlook.kr> * [AMORO-4271] AIP-5 Phase 2: distinguish the scale keeper's failure modes The keeper's drop-out path treated every irregularity the same way, which conflates situations that need opposite handling: - A transient group-read failure (e.g. a database hiccup) is not a deletion: there is no periodic re-watch, so dropping the group there would silently disable its dynamic allocation until the next config change. Keep the task alive and retry shortly. - After unwatching a disabled group, re-read it once: an update re-enabling DRA concurrently would have had its watch() call swallowed by the still-present watched-set entry, orphaning the group until its next change. - An enabled group whose queue is momentarily absent (a delete/create racing the config watcher) is transient too; unwatch-plus-rewatch there would spin a delay-0 hot loop of DB reads. - A scale-out that fails after requestResource succeeded has started a real pod; erasing its boot-window entry would re-request a duplicate next round. Only a failure before the request is dropped and retried. - Disabling DRA keeps the boot-window accounting (re-enabling within the window must not re-request the same capacity); deleting the group drops everything immediately, so a same-name group created before the next evaluation does not inherit phantom capacity. - The planning-bound warning now counts registered threads only (a booting pod's phantom capacity is not idle threads) and requires the condition to persist across two consecutive evaluations, since a single snapshot can hold transiently while planning is in flight. Also pins in the integration test that registration clears the boot-window accounting: double-counted capacity would suppress demand scaling. Signed-off-by: Jiwon Park <jpark92@outlook.kr> * [AMORO-4271] AIP-5 Phase 2: keep floor handling consistent across validation and scaling Two floor edge cases: - A floor unreachable in executor-parallelism units (e.g. min=5, max=6, K=4: covering the floor needs 2 instances = 8 threads > max) passed validation and then sat permanently below its floor with no signal - the same silent no-op the validation rules exist to prevent. Reject it up front; the keeper-side cap clamp stays as defense in depth for configs persisted before this rule. - A floor deficit (optimizers died, or a new group) now resets the demand-phase state: previously a demand phase before the deficit left a passed cadence gate and a grown ramp behind, so the first demand after recovery fired immediately and oversized instead of re-proving backlog persistence. Also pins that a fractional absolute quota truncates like the poll gate does ((int) 2.5 = 2 slots), keeping the two accountings aligned. Signed-off-by: Jiwon Park <jpark92@outlook.kr> * [AMORO-4271] AIP-5 Phase 2: snapshot table runtimes under the scheduling lock SchedulingPolicy's table runtime map is a plain HashMap whose canonical accesses all hold tableLock; the dynamic-allocation load snapshot iterated it lock-free from the scale keeper thread, so a concurrent addTable/removeTable could throw ConcurrentModificationException and skip that whole evaluation round. Add a snapshot accessor that copies the runtimes under the lock and use it for the load snapshot. Also covers collectDynamicAllocationLoad with a queue-level test on a real table: the PENDING table is the only demand signal before any poll (the cold-start case), and a polled task occupies its thread from SCHEDULED on. Signed-off-by: Jiwon Park <jpark92@outlook.kr> * [AMORO-4271] AIP-5 Phase 2: document the dynamic-allocation group properties Add the dynamic-allocation.* properties to the optimizer group property table, mark the flat min-parallelism row deprecated in favor of the namespaced key, and recommend executor-parallelism 4-8 for Kubernetes groups so per-pod JVM overhead is shared across threads. The scale-down-related properties are documented as landing in a later release. Signed-off-by: Jiwon Park <jpark92@outlook.kr> --------- Signed-off-by: Jiwon Park <jpark92@outlook.kr> Co-authored-by: ZhouJinsong <zhoujinsong0505@163.com>
1 parent cbd7d65 commit d29cbf4

14 files changed

Lines changed: 1542 additions & 12 deletions

File tree

amoro-ams/src/main/java/org/apache/amoro/server/DefaultOptimizingService.java

Lines changed: 270 additions & 11 deletions
Large diffs are not rendered by default.

amoro-ams/src/main/java/org/apache/amoro/server/optimizing/OptimizingQueue.java

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
import org.apache.amoro.server.catalog.CatalogManager;
3939
import org.apache.amoro.server.manager.MetricManager;
4040
import org.apache.amoro.server.optimizing.TaskRuntime.Status;
41+
import org.apache.amoro.server.optimizing.dra.DynamicAllocationState;
4142
import org.apache.amoro.server.persistence.OptimizingProcessState;
4243
import org.apache.amoro.server.persistence.PersistentBase;
4344
import org.apache.amoro.server.persistence.TaskFilesPersistence;
@@ -418,6 +419,47 @@ public List<TaskRuntime<?>> collectTasks(Predicate<TaskRuntime<?>> predicate) {
418419
.collect(Collectors.toList());
419420
}
420421

422+
/**
423+
* Snapshot the demand-side load of this queue for dynamic allocation: busy threads, serviceable
424+
* PLANNED tasks (quota-mode aware, see {@link DynamicAllocationState#serviceablePlannedCount}),
425+
* and PENDING tables. PENDING tables are observable with zero optimizers, which makes them the
426+
* only scale-up signal on a cold group where nothing polls and planning never runs.
427+
*/
428+
public DynamicAllocationState.GroupLoad collectDynamicAllocationLoad() {
429+
Map<Long, Integer> plannedByTable = Maps.newHashMap();
430+
Map<Long, Integer> occupiedByTable = Maps.newHashMap();
431+
int busyThreads = 0;
432+
for (TaskRuntime<?> task : collectTasks()) {
433+
if (DynamicAllocationState.occupiesThread(task.getStatus())) {
434+
busyThreads++;
435+
occupiedByTable.merge(task.getTableId(), 1, Integer::sum);
436+
} else if (task.getStatus() == Status.PLANNED) {
437+
plannedByTable.merge(task.getTableId(), 1, Integer::sum);
438+
}
439+
}
440+
Map<Long, Double> targetQuotaByTable = Maps.newHashMap();
441+
int pendingTables = 0;
442+
for (DefaultTableRuntime tableRuntime : scheduler.snapshotTableRuntimes()) {
443+
targetQuotaByTable.put(
444+
tableRuntime.getTableIdentifier().getId(),
445+
tableRuntime.getOptimizingConfig().getTargetQuota());
446+
if (tableRuntime.getOptimizingStatus() == OptimizingStatus.PENDING) {
447+
pendingTables++;
448+
}
449+
}
450+
List<DynamicAllocationState.TableDemand> demands = Lists.newArrayList();
451+
plannedByTable.forEach(
452+
(tableId, planned) ->
453+
demands.add(
454+
new DynamicAllocationState.TableDemand(
455+
planned,
456+
// Unknown table (racing removal): default to proportional, counting in full.
457+
targetQuotaByTable.getOrDefault(tableId, 1.0),
458+
occupiedByTable.getOrDefault(tableId, 0))));
459+
return new DynamicAllocationState.GroupLoad(
460+
busyThreads, DynamicAllocationState.serviceablePlannedCount(demands), pendingTables);
461+
}
462+
421463
public void retryTask(TaskRuntime<?> taskRuntime) {
422464
findProcess(taskRuntime.getTaskId()).resetTask((TaskRuntime<RewriteStageTask>) taskRuntime);
423465
}

amoro-ams/src/main/java/org/apache/amoro/server/optimizing/SchedulingPolicy.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,11 @@
2828
import org.slf4j.Logger;
2929
import org.slf4j.LoggerFactory;
3030

31+
import java.util.ArrayList;
3132
import java.util.Comparator;
3233
import java.util.HashMap;
3334
import java.util.Iterator;
35+
import java.util.List;
3436
import java.util.Map;
3537
import java.util.Optional;
3638
import java.util.ServiceLoader;
@@ -146,4 +148,18 @@ public void removeTable(DefaultTableRuntime tableRuntime) {
146148
Map<ServerTableIdentifier, DefaultTableRuntime> getTableRuntimeMap() {
147149
return tableRuntimeMap;
148150
}
151+
152+
/**
153+
* Copy the current table runtimes under {@code tableLock}. {@code tableRuntimeMap} is a plain
154+
* {@link HashMap} whose canonical accesses all hold the lock, so callers on other threads (e.g.
155+
* the dynamic-allocation scale keeper) must iterate a snapshot instead of the live map.
156+
*/
157+
public List<DefaultTableRuntime> snapshotTableRuntimes() {
158+
tableLock.lock();
159+
try {
160+
return new ArrayList<>(tableRuntimeMap.values());
161+
} finally {
162+
tableLock.unlock();
163+
}
164+
}
149165
}

amoro-ams/src/main/java/org/apache/amoro/server/optimizing/dra/DynamicAllocationConfig.java

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ public class DynamicAllocationConfig {
4242
private final boolean enabled;
4343
private final Integer minParallelism;
4444
private final Integer maxParallelism;
45+
private final int executorParallelism;
4546
private final Duration schedulerBacklogTimeout;
4647
private final Duration sustainedBacklogTimeout;
4748
private final Duration executorIdleTimeout;
@@ -54,6 +55,7 @@ private DynamicAllocationConfig(
5455
boolean enabled,
5556
Integer minParallelism,
5657
Integer maxParallelism,
58+
int executorParallelism,
5759
Duration schedulerBacklogTimeout,
5860
Duration sustainedBacklogTimeout,
5961
Duration executorIdleTimeout,
@@ -64,6 +66,7 @@ private DynamicAllocationConfig(
6466
this.enabled = enabled;
6567
this.minParallelism = minParallelism;
6668
this.maxParallelism = maxParallelism;
69+
this.executorParallelism = executorParallelism;
6770
this.schedulerBacklogTimeout = schedulerBacklogTimeout;
6871
this.sustainedBacklogTimeout = sustainedBacklogTimeout;
6972
this.executorIdleTimeout = executorIdleTimeout;
@@ -88,12 +91,19 @@ public static DynamicAllocationConfig parse(ResourceGroup group) {
8891
PropertyUtil.propertyAsNullableInt(
8992
properties, OptimizerProperties.DYNAMIC_ALLOCATION_MAX_PARALLELISM);
9093

94+
int executorParallelism =
95+
PropertyUtil.propertyAsInt(
96+
properties,
97+
OptimizerProperties.DYNAMIC_ALLOCATION_EXECUTOR_PARALLELISM,
98+
OptimizerProperties.DYNAMIC_ALLOCATION_EXECUTOR_PARALLELISM_DEFAULT);
99+
91100
return new DynamicAllocationConfig(
92101
group.getName(),
93102
group.getContainer(),
94103
enabled,
95104
minParallelism,
96105
maxParallelism,
106+
executorParallelism,
97107
parseDuration(
98108
properties,
99109
OptimizerProperties.DYNAMIC_ALLOCATION_SCHEDULER_BACKLOG_TIMEOUT,
@@ -269,6 +279,44 @@ public void validate() {
269279
maxParallelism,
270280
OptimizerProperties.DYNAMIC_ALLOCATION_MAX_PARALLELISM_LIMIT));
271281
}
282+
if (executorParallelism < 1) {
283+
throw new IllegalArgumentException(
284+
String.format(
285+
"Resource group:%s '%s'(%d) must be >= 1.",
286+
groupName,
287+
OptimizerProperties.DYNAMIC_ALLOCATION_EXECUTOR_PARALLELISM,
288+
executorParallelism));
289+
}
290+
// A single executorParallelism-thread instance is the scaling unit; if it alone exceeds
291+
// max-parallelism, scale-up could never create anything, leaving a silent no-op group.
292+
if (executorParallelism > maxParallelism) {
293+
throw new IllegalArgumentException(
294+
String.format(
295+
"Resource group:%s '%s'(%d) must not exceed '%s'(%d).",
296+
groupName,
297+
OptimizerProperties.DYNAMIC_ALLOCATION_EXECUTOR_PARALLELISM,
298+
executorParallelism,
299+
OptimizerProperties.DYNAMIC_ALLOCATION_MAX_PARALLELISM,
300+
maxParallelism));
301+
}
302+
// The floor is satisfied in executor-parallelism-thread instance units; if covering it would
303+
// already exceed max-parallelism, the group would silently sit below its floor forever.
304+
int floorThreads =
305+
(minParallelism + executorParallelism - 1) / executorParallelism * executorParallelism;
306+
if (floorThreads > maxParallelism) {
307+
throw new IllegalArgumentException(
308+
String.format(
309+
"Resource group:%s '%s'(%d) is not reachable in '%s'(%d) units: covering the floor "
310+
+ "requires %d threads, exceeding '%s'(%d).",
311+
groupName,
312+
OptimizerProperties.DYNAMIC_ALLOCATION_MIN_PARALLELISM,
313+
minParallelism,
314+
OptimizerProperties.DYNAMIC_ALLOCATION_EXECUTOR_PARALLELISM,
315+
executorParallelism,
316+
floorThreads,
317+
OptimizerProperties.DYNAMIC_ALLOCATION_MAX_PARALLELISM,
318+
maxParallelism));
319+
}
272320
Duration idleMin =
273321
ConfigHelpers.TimeUtils.parseDuration(
274322
OptimizerProperties.DYNAMIC_ALLOCATION_EXECUTOR_IDLE_TIMEOUT_MIN);
@@ -340,6 +388,10 @@ public int getMaxParallelism() {
340388
return maxParallelism;
341389
}
342390

391+
public int getExecutorParallelism() {
392+
return executorParallelism;
393+
}
394+
343395
public Duration getSchedulerBacklogTimeout() {
344396
return schedulerBacklogTimeout;
345397
}
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
package org.apache.amoro.server.optimizing.dra;
20+
21+
import org.apache.amoro.server.optimizing.TaskRuntime;
22+
23+
import java.util.Collection;
24+
25+
/**
26+
* Per-group scale-up decision state for dynamic allocation (AIP-5): the backlog timer, the
27+
* scale-out cadence, and the exponential ramp. {@link #computeScaleUp} is driven by injected inputs
28+
* (loads, config, time), so the decision logic is deterministic and testable without a live
29+
* optimizing queue; the static demand-accounting helpers are pure functions.
30+
*/
31+
public final class DynamicAllocationState {
32+
33+
/** When demand was first observed; {@code -1} while there is no demand. */
34+
private long backlogSinceMs = -1;
35+
36+
/** Earliest time the next scale-out may happen; {@code -1} before the first one. */
37+
private long nextAllowedAddMs = -1;
38+
39+
/** Instances to add in the next immediate-demand round (1, 2, 4, 8 ...). */
40+
private int rampInstances = 1;
41+
42+
/**
43+
* Decide how many executor-parallelism-thread optimizer instances to create in this round.
44+
*
45+
* <p>Ordered checks: the {@code min-parallelism} floor is enforced immediately (no timing gate);
46+
* immediate demand ({@code busy + serviceable > effective}) scales exponentially, clamped to the
47+
* actual need (Spark semantics: the ramp resets when the clamp binds, and a round with no demand
48+
* resets it too); future demand (pending tables while every thread is busy — including the
49+
* zero-optimizer cold start, where nothing polls and planning never runs) adds a single probe
50+
* instance, because pending tables are not quantified demand before planning. Demand must persist
51+
* for {@code scheduler-backlog-timeout} before the first scale-out; subsequent ones are spaced by
52+
* {@code sustained-backlog-timeout}. The {@code max-parallelism} cap always wins.
53+
*
54+
* @return the number of instances of {@code executor-parallelism} threads to create, {@code >= 0}
55+
*/
56+
public int computeScaleUp(
57+
int effectiveThreads,
58+
int busyThreads,
59+
int serviceablePlanned,
60+
int pendingTables,
61+
DynamicAllocationConfig config,
62+
long nowMs) {
63+
int k = config.getExecutorParallelism();
64+
int allowedInstances = Math.max(0, (config.getMaxParallelism() - effectiveThreads) / k);
65+
66+
int minParallelism = config.getMinParallelism();
67+
if (effectiveThreads < minParallelism) {
68+
// A floor deficit (optimizers died or the group is new) invalidates any demand-phase
69+
// state: after recovery, demand must re-prove backlog persistence instead of firing
70+
// through a stale gate with a stale ramp.
71+
backlogSinceMs = -1;
72+
nextAllowedAddMs = -1;
73+
rampInstances = 1;
74+
int neededInstances = ceilDiv(minParallelism - effectiveThreads, k);
75+
return Math.min(neededInstances, allowedInstances);
76+
}
77+
78+
int actionableNeed = Math.max(busyThreads + serviceablePlanned - effectiveThreads, 0);
79+
boolean futureDemand = pendingTables > 0 && busyThreads >= effectiveThreads;
80+
if (actionableNeed <= 0 && !futureDemand) {
81+
backlogSinceMs = -1;
82+
nextAllowedAddMs = -1;
83+
rampInstances = 1;
84+
return 0;
85+
}
86+
87+
if (backlogSinceMs < 0) {
88+
backlogSinceMs = nowMs;
89+
nextAllowedAddMs = -1;
90+
}
91+
long gate =
92+
nextAllowedAddMs >= 0
93+
? nextAllowedAddMs
94+
: backlogSinceMs + config.getSchedulerBacklogTimeout().toMillis();
95+
if (nowMs < gate) {
96+
return 0;
97+
}
98+
99+
int add;
100+
if (actionableNeed > 0) {
101+
int wantInstances = ceilDiv(actionableNeed, k);
102+
add = Math.min(Math.min(wantInstances, rampInstances), allowedInstances);
103+
if (add <= 0) {
104+
return 0;
105+
}
106+
// Spark semantics: keep doubling only while the ramp is the binding constraint; once the
107+
// actual need clamps the add, a grown ramp is no longer justified by demand.
108+
rampInstances = wantInstances > rampInstances ? rampInstances * 2 : 1;
109+
} else {
110+
add = Math.min(1, allowedInstances);
111+
if (add <= 0) {
112+
return 0;
113+
}
114+
rampInstances = 1;
115+
}
116+
nextAllowedAddMs = nowMs + config.getSustainedBacklogTimeout().toMillis();
117+
return add;
118+
}
119+
120+
private static int ceilDiv(int value, int divisor) {
121+
return (value + divisor - 1) / divisor;
122+
}
123+
124+
/** Snapshot of a group's current load, the demand-side inputs of {@link #computeScaleUp}. */
125+
public static class GroupLoad {
126+
private final int busyThreads;
127+
private final int serviceablePlanned;
128+
private final int pendingTables;
129+
130+
public GroupLoad(int busyThreads, int serviceablePlanned, int pendingTables) {
131+
this.busyThreads = busyThreads;
132+
this.serviceablePlanned = serviceablePlanned;
133+
this.pendingTables = pendingTables;
134+
}
135+
136+
public int getBusyThreads() {
137+
return busyThreads;
138+
}
139+
140+
public int getServiceablePlanned() {
141+
return serviceablePlanned;
142+
}
143+
144+
public int getPendingTables() {
145+
return pendingTables;
146+
}
147+
}
148+
149+
/** Per-table demand snapshot consumed by {@link #serviceablePlannedCount(Collection)}. */
150+
public static class TableDemand {
151+
private final int plannedCount;
152+
private final double targetQuota;
153+
private final int occupiedThreads;
154+
155+
public TableDemand(int plannedCount, double targetQuota, int occupiedThreads) {
156+
this.plannedCount = plannedCount;
157+
this.targetQuota = targetQuota;
158+
this.occupiedThreads = occupiedThreads;
159+
}
160+
}
161+
162+
/**
163+
* Count the PLANNED tasks that adding optimizer capacity could actually drain.
164+
*
165+
* <p>A table with a proportional quota ({@code targetQuota <= 1}) is limited to {@code
166+
* ceil(targetQuota * availableCore)} threads, so scaling up raises its limit and the whole
167+
* backlog is serviceable. A table with an absolute quota ({@code > 1}) has a fixed thread limit
168+
* that scaling cannot raise, so only its currently free slots are serviceable.
169+
*/
170+
public static int serviceablePlannedCount(Collection<TableDemand> demands) {
171+
int total = 0;
172+
for (TableDemand demand : demands) {
173+
if (demand.targetQuota <= 1) {
174+
total += demand.plannedCount;
175+
} else {
176+
int freeSlots = Math.max(0, (int) demand.targetQuota - demand.occupiedThreads);
177+
total += Math.min(demand.plannedCount, freeSlots);
178+
}
179+
}
180+
return total;
181+
}
182+
183+
/**
184+
* Whether a task in this status occupies an optimizer thread. A thread is occupied from
185+
* assignment ({@code SCHEDULED}, set by {@code pollTask}) until the task terminates; counting
186+
* only {@code ACKED} would overestimate headroom during the poll-to-ack window.
187+
*/
188+
public static boolean occupiesThread(TaskRuntime.Status status) {
189+
return status == TaskRuntime.Status.SCHEDULED || status == TaskRuntime.Status.ACKED;
190+
}
191+
192+
/**
193+
* Whether the group is bottlenecked on planning rather than on thread capacity: threads sit idle
194+
* while tables wait as PENDING and no PLANNED tasks materialize (planning is serialized by {@code
195+
* optimizer.max-planning-parallelism}). Scaling out in this state would only add more idle
196+
* threads, so the condition is surfaced as a warning instead of a scale-out. A cold group (zero
197+
* threads) is the future-demand case, not a planning bottleneck.
198+
*/
199+
public static boolean isPlanningBound(
200+
int effectiveThreads, int busyThreads, int serviceablePlanned, int pendingTables) {
201+
return effectiveThreads > 0
202+
&& busyThreads < effectiveThreads
203+
&& serviceablePlanned == 0
204+
&& pendingTables > 0;
205+
}
206+
}

0 commit comments

Comments
 (0)