This is an automated email from the ASF dual-hosted git repository.
zhoujinsong pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/amoro.git
The following commit(s) were added to refs/heads/master by this push:
new d29cbf4bc [AMORO-4271] AIP-5 Phase 2: demand-driven scale-up for
dynamic allocation groups (#4272)
d29cbf4bc is described below
commit d29cbf4bcfba2ee60b24c0708aa5e7a1962205b0
Author: Jiwon Park <[email protected]>
AuthorDate: Wed Jul 22 16:06:17 2026 +0900
[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 <[email protected]>
* [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 <[email protected]>
* [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 <[email protected]>
* [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 <[email protected]>
* [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 <[email protected]>
* [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 <[email protected]>
* [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 <[email protected]>
* [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 <[email protected]>
* [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 <[email protected]>
* [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 <[email protected]>
---------
Signed-off-by: Jiwon Park <[email protected]>
Co-authored-by: ZhouJinsong <[email protected]>
---
.../amoro/server/DefaultOptimizingService.java | 281 +++++++++++++++++++-
.../amoro/server/optimizing/OptimizingQueue.java | 42 +++
.../amoro/server/optimizing/SchedulingPolicy.java | 16 ++
.../optimizing/dra/DynamicAllocationConfig.java | 52 ++++
.../optimizing/dra/DynamicAllocationState.java | 206 +++++++++++++++
.../optimizing/dra/PendingRegistrations.java | 71 +++++
.../amoro/server/TestOptimizerScaleKeeper.java | 286 +++++++++++++++++++++
.../server/optimizing/TestOptimizingQueue.java | 26 ++
.../server/optimizing/dra/TestComputeScaleUp.java | 231 +++++++++++++++++
.../dra/TestDynamicAllocationConfig.java | 64 +++++
.../optimizing/dra/TestDynamicAllocationState.java | 175 +++++++++++++
.../optimizing/dra/TestPendingRegistrations.java | 87 +++++++
.../java/org/apache/amoro/OptimizerProperties.java | 6 +
docs/admin-guides/managing-optimizers.md | 11 +-
14 files changed, 1542 insertions(+), 12 deletions(-)
diff --git
a/amoro-ams/src/main/java/org/apache/amoro/server/DefaultOptimizingService.java
b/amoro-ams/src/main/java/org/apache/amoro/server/DefaultOptimizingService.java
index 52345d2cc..6831d57c7 100644
---
a/amoro-ams/src/main/java/org/apache/amoro/server/DefaultOptimizingService.java
+++
b/amoro-ams/src/main/java/org/apache/amoro/server/DefaultOptimizingService.java
@@ -46,6 +46,8 @@ import org.apache.amoro.server.optimizing.OptimizingQueue;
import org.apache.amoro.server.optimizing.OptimizingStatus;
import org.apache.amoro.server.optimizing.TaskRuntime;
import org.apache.amoro.server.optimizing.dra.DynamicAllocationConfig;
+import org.apache.amoro.server.optimizing.dra.DynamicAllocationState;
+import org.apache.amoro.server.optimizing.dra.PendingRegistrations;
import org.apache.amoro.server.persistence.StatedPersistentBase;
import org.apache.amoro.server.persistence.mapper.OptimizerMapper;
import org.apache.amoro.server.persistence.mapper.ResourceMapper;
@@ -59,6 +61,7 @@ import org.apache.amoro.server.resource.QuotaProvider;
import org.apache.amoro.server.table.DefaultTableRuntime;
import org.apache.amoro.server.table.RuntimeHandlerChain;
import org.apache.amoro.server.table.TableService;
+import
org.apache.amoro.shade.guava32.com.google.common.annotations.VisibleForTesting;
import org.apache.amoro.shade.guava32.com.google.common.base.Preconditions;
import org.apache.amoro.shade.guava32.com.google.common.collect.Sets;
import
org.apache.amoro.shade.guava32.com.google.common.util.concurrent.ThreadFactoryBuilder;
@@ -116,6 +119,8 @@ public class DefaultOptimizingService extends
StatedPersistentBase
private final OptimizerKeeper optimizerKeeper = new
OptimizerKeeper("optimizer-keeper-thread");
private final OptimizerGroupKeeper optimizerGroupKeeper =
new OptimizerGroupKeeper("optimizer-group-keeper-thread");
+ private final OptimizerScaleKeeper optimizerScaleKeeper =
+ new OptimizerScaleKeeper("optimizer-scale-keeper-thread");
private final OptimizingConfigWatcher optimizingConfigWatcher = new
OptimizingConfigWatcher();
private final CatalogManager catalogManager;
private final OptimizerManager optimizerManager;
@@ -193,6 +198,7 @@ public class DefaultOptimizingService extends
StatedPersistentBase
maxPlanningParallelism);
optimizingQueueByGroup.put(groupName, optimizingQueue);
optimizerGroupKeeper.keepInTouch(groupName, 1);
+ optimizerScaleKeeper.watch(group);
});
optimizers.forEach(optimizer -> registerOptimizer(optimizer, false));
// Avoid keeping the tables in processing/pending status forever in below
cases:
@@ -229,6 +235,7 @@ public class DefaultOptimizingService extends
StatedPersistentBase
authOptimizers.put(optimizer.getToken(), optimizer);
optimizingQueueByToken.put(optimizer.getToken(), optimizingQueue);
optimizerKeeper.keepInTouch(optimizer);
+ optimizerScaleKeeper.onOptimizerRegistered(optimizer);
}
private void unregisterOptimizer(String token) {
@@ -405,17 +412,25 @@ public class DefaultOptimizingService extends
StatedPersistentBase
String groupName = resourceGroup.getName();
optimizingQueueByGroup.put(groupName, optimizingQueue);
optimizerGroupKeeper.keepInTouch(groupName, 1);
+ optimizerScaleKeeper.watch(resourceGroup);
});
}
public void deleteResourceGroup(String groupName) {
OptimizingQueue optimizingQueue = optimizingQueueByGroup.remove(groupName);
optimizingQueue.dispose();
+ optimizerScaleKeeper.onGroupDeleted(groupName);
}
public void updateResourceGroup(ResourceGroup resourceGroup) {
Optional.ofNullable(optimizingQueueByGroup.get(resourceGroup.getName()))
.ifPresent(queue -> queue.updateOptimizerGroup(resourceGroup));
+ optimizerScaleKeeper.watch(resourceGroup);
+ }
+
+ @VisibleForTesting
+ int pendingScaleThreads(String groupName) {
+ return optimizerScaleKeeper.pendingThreads(groupName);
}
public void dispose() {
@@ -426,6 +441,7 @@ public class DefaultOptimizingService extends
StatedPersistentBase
optimizingQueueByGroup.values().forEach(OptimizingQueue::dispose);
optimizerKeeper.dispose();
optimizerGroupKeeper.dispose();
+ optimizerScaleKeeper.dispose();
tableHandlerChain.dispose();
optimizingQueueByGroup.clear();
optimizingQueueByToken.clear();
@@ -497,6 +513,7 @@ public class DefaultOptimizingService extends
StatedPersistentBase
.collect(Collectors.toList()));
optimizerKeeper.start();
optimizerGroupKeeper.start();
+ optimizerScaleKeeper.start();
optimizingConfigWatcher.start();
LOG.info("SuspendingDetector for Optimizer has been started.");
LOG.info("OptimizerManagementService initializing has completed");
@@ -900,6 +917,13 @@ public class DefaultOptimizingService extends
StatedPersistentBase
return;
}
+ if (DynamicAllocationConfig.isEffectivelyEnabled(resourceGroup)) {
+ // Dynamic allocation owns this group's floor and demand scaling (see
+ // OptimizerScaleKeeper); keep watching in case it is disabled later.
+ keepInTouch(resourceGroup.getName(), 1);
+ return;
+ }
+
int requiredCores = keepingTask.tryKeeping(resourceGroup);
if (requiredCores <= 0) {
LOG.debug(
@@ -910,17 +934,6 @@ public class DefaultOptimizingService extends
StatedPersistentBase
if (keepingTask.getAttempts() > groupMaxKeepingAttempts) {
int minParallelism = keepingTask.getMinParallelism(resourceGroup);
- if (DynamicAllocationConfig.isEffectivelyEnabled(resourceGroup)) {
- // Dynamic allocation owns scale decisions for the group; never
erode its
- // min-parallelism floor automatically.
- LOG.warn(
- "Resource Group:{}, creating optimizer {} times in a row,
optimizers still below min-parallel:{}; dynamic allocation is enabled so
min-parallel is kept",
- resourceGroup.getName(),
- keepingTask.getAttempts(),
- minParallelism);
- keepInTouch(resourceGroup.getName(), 1);
- return;
- }
LOG.warn(
"Resource Group:{}, creating optimizer {} times in a row,
optimizers still below min-parallel:{}, will reset min-parallel to {}",
resourceGroup.getName(),
@@ -957,4 +970,250 @@ public class DefaultOptimizingService extends
StatedPersistentBase
requiredCores);
}
}
+
+ private class DraScaleTask implements Delayed {
+
+ private final String groupName;
+ private final long readyTimeMs;
+
+ private DraScaleTask(String groupName, long delayMs) {
+ this.groupName = groupName;
+ this.readyTimeMs = System.currentTimeMillis() + delayMs;
+ }
+
+ @Override
+ public long getDelay(@NotNull TimeUnit unit) {
+ return unit.convert(readyTimeMs - System.currentTimeMillis(),
TimeUnit.MILLISECONDS);
+ }
+
+ @Override
+ public int compareTo(@NotNull Delayed other) {
+ return Long.compare(readyTimeMs, ((DraScaleTask) other).readyTimeMs);
+ }
+ }
+
+ /**
+ * Keeper owning both the floor and the demand scaling of
dynamic-allocation-enabled groups
+ * (AIP-5). It is separate from {@link OptimizerGroupKeeper}, whose
min-parallelism-check cadence
+ * (minutes, multiplied by attempts) would render the DRA backlog timeouts
(seconds) unreachable;
+ * a group's scale evaluations run at its own sustained-backlog-timeout
instead.
+ */
+ private class OptimizerScaleKeeper extends AbstractKeeper<DraScaleTask> {
+
+ // Must exceed a normal pod boot including image pull: evicting a
legitimately booting pod
+ // from the pending accounting would cause duplicate scale-outs, which is
worse than a few
+ // conservative rounds with phantom capacity.
+ private static final long BOOT_TIMEOUT_MS = 3 * 60 * 1000L;
+
+ // Retry delay after a transient resource-group read failure, when the
group's configured
+ // cadence is unknown because the group itself could not be loaded.
+ private static final long TRANSIENT_RETRY_DELAY_MS = 5_000L;
+
+ private final Map<String, DynamicAllocationState> scaleStates = new
ConcurrentHashMap<>();
+ private final Map<String, PendingRegistrations> pendingRegistrations =
+ new ConcurrentHashMap<>();
+ private final Set<String> watchedGroups = ConcurrentHashMap.newKeySet();
+ private final Map<String, Integer> planningBoundStreaks = new
ConcurrentHashMap<>();
+
+ public OptimizerScaleKeeper(String threadName) {
+ super(threadName);
+ }
+
+ /** Start watching a group if dynamic allocation is effectively enabled on
it. Idempotent. */
+ public void watch(ResourceGroup resourceGroup) {
+ if (!DynamicAllocationConfig.isEffectivelyEnabled(resourceGroup)) {
+ return;
+ }
+ if (watchedGroups.add(resourceGroup.getName())) {
+ suspendingQueue.add(new DraScaleTask(resourceGroup.getName(), 0));
+ }
+ }
+
+ /** Clear the boot-window accounting of a registered optimizer
(AMS-launched ones only). */
+ public void onOptimizerRegistered(OptimizerInstance optimizer) {
+ if (optimizer.getResourceId() == null) {
+ return;
+ }
+ PendingRegistrations pending =
pendingRegistrations.get(optimizer.getGroupName());
+ if (pending != null) {
+ pending.registered(optimizer.getResourceId());
+ }
+ }
+
+ private void unwatch(String groupName) {
+ watchedGroups.remove(groupName);
+ scaleStates.remove(groupName);
+ planningBoundStreaks.remove(groupName);
+ // pendingRegistrations is deliberately kept: a pod requested before a
disable survives its
+ // boot window, so re-enabling within it does not re-request the same
capacity. Entries
+ // self-prune past their deadline.
+ }
+
+ /**
+ * Full cleanup on group deletion. Unlike a disable, a deleted group's
boot-window accounting
+ * must go too: leaving it would leak the entry and, if a group with the
same name is created
+ * before the next evaluation, suppress its scale-up with the old group's
phantom capacity.
+ */
+ public void onGroupDeleted(String groupName) {
+ unwatch(groupName);
+ pendingRegistrations.remove(groupName);
+ }
+
+ @Override
+ protected void processTask(DraScaleTask task) {
+ ResourceGroup resourceGroup;
+ try {
+ resourceGroup = optimizerManager.getResourceGroup(task.groupName);
+ } catch (Exception e) {
+ // A transient failure (e.g. a database hiccup) must not be treated as
deletion: there is
+ // no periodic re-watch, so dropping the group here would silently
disable its dynamic
+ // allocation until the next config change. Keep the task alive and
retry.
+ LOG.warn(
+ "Failed to load resource group {} for dynamic allocation, will
retry",
+ task.groupName,
+ e);
+ suspendingQueue.add(new DraScaleTask(task.groupName,
TRANSIENT_RETRY_DELAY_MS));
+ return;
+ }
+ if (resourceGroup == null ||
!DynamicAllocationConfig.isEffectivelyEnabled(resourceGroup)) {
+ // Deleted or disabled: stop watching; an update re-enabling DRA
re-watches the group.
+ unwatch(task.groupName);
+ // An update may have re-enabled the group between our read and the
unwatch, in which
+ // case its watch() call was swallowed by the still-present
watchedGroups entry:
+ // double-check on a fresh read so such a group is not orphaned until
its next change.
+ recheckAfterUnwatch(task.groupName);
+ return;
+ }
+ OptimizingQueue queue = optimizingQueueByGroup.get(task.groupName);
+ if (queue == null) {
+ // The group exists with DRA enabled but its queue is momentarily
absent (e.g. a
+ // delete/recreate racing the config watcher). Unwatch + rewatch here
would spin a
+ // delay-0 hot loop until the watcher recreates the queue; treat it as
transient.
+ suspendingQueue.add(new DraScaleTask(task.groupName,
TRANSIENT_RETRY_DELAY_MS));
+ return;
+ }
+ DynamicAllocationConfig config =
DynamicAllocationConfig.parse(resourceGroup);
+ try {
+ scaleIfNeeded(resourceGroup, queue, config);
+ } catch (Throwable t) {
+ LOG.error("Dynamic allocation scale evaluation failed for group {}",
task.groupName, t);
+ } finally {
+ suspendingQueue.add(
+ new DraScaleTask(task.groupName,
config.getSustainedBacklogTimeout().toMillis()));
+ }
+ }
+
+ /** Threads still expected to register for the group; testing hook for
boot accounting. */
+ private int pendingThreads(String groupName) {
+ PendingRegistrations pending = pendingRegistrations.get(groupName);
+ return pending == null ? 0 :
pending.pendingThreads(System.currentTimeMillis());
+ }
+
+ private void recheckAfterUnwatch(String groupName) {
+ try {
+ ResourceGroup fresh = optimizerManager.getResourceGroup(groupName);
+ if (fresh != null) {
+ watch(fresh);
+ }
+ } catch (Exception e) {
+ // The group became unreadable right after a successful read; its next
update watches it.
+ LOG.warn("Failed to re-check resource group {} after unwatch",
groupName, e);
+ }
+ }
+
+ /**
+ * Warn when the planning-bound state (idle threads, PENDING tables,
nothing PLANNED — the
+ * bottleneck is {@code optimizer.max-planning-parallelism}, so scaling
out would only add idle
+ * threads) persists across two consecutive evaluations. A single snapshot
can hold this
+ * condition transiently while planning is merely in flight, so one round
is not evidence;
+ * counting registered threads only keeps a booting pod's phantom capacity
from being mistaken
+ * for idle threads. Warns once per episode.
+ */
+ private void warnOnPlanningBoundTransition(
+ String groupName, int registeredThreads,
DynamicAllocationState.GroupLoad load) {
+ boolean planningBound =
+ DynamicAllocationState.isPlanningBound(
+ registeredThreads,
+ load.getBusyThreads(),
+ load.getServiceablePlanned(),
+ load.getPendingTables());
+ if (!planningBound) {
+ planningBoundStreaks.remove(groupName);
+ return;
+ }
+ int streak = planningBoundStreaks.merge(groupName, 1, Integer::sum);
+ if (streak == 2) {
+ LOG.warn(
+ "Resource group {} is planning-bound: {} idle thread(s) while {}
table(s) are "
+ + "PENDING and no tasks are PLANNED. Scaling out will not
help; consider "
+ + "raising {}.",
+ groupName,
+ registeredThreads - load.getBusyThreads(),
+ load.getPendingTables(),
+ AmoroManagementConf.OPTIMIZER_MAX_PLANNING_PARALLELISM.key());
+ }
+ }
+
+ private void scaleIfNeeded(
+ ResourceGroup resourceGroup, OptimizingQueue queue,
DynamicAllocationConfig config) {
+ String groupName = resourceGroup.getName();
+ long now = System.currentTimeMillis();
+ PendingRegistrations pending =
+ pendingRegistrations.computeIfAbsent(
+ groupName, name -> new PendingRegistrations(BOOT_TIMEOUT_MS));
+ DynamicAllocationState state =
+ scaleStates.computeIfAbsent(groupName, name -> new
DynamicAllocationState());
+ int registeredThreads = getTotalQuota(groupName);
+ int effectiveThreads = registeredThreads + pending.pendingThreads(now);
+ DynamicAllocationState.GroupLoad load =
queue.collectDynamicAllocationLoad();
+ warnOnPlanningBoundTransition(groupName, registeredThreads, load);
+ int addInstances =
+ state.computeScaleUp(
+ effectiveThreads,
+ load.getBusyThreads(),
+ load.getServiceablePlanned(),
+ load.getPendingTables(),
+ config,
+ now);
+ if (addInstances <= 0) {
+ return;
+ }
+ int threadsPerInstance = config.getExecutorParallelism();
+ LOG.info(
+ "Dynamic allocation scaling out group {}: {} instance(s) of {}
thread(s), effective threads {}",
+ groupName,
+ addInstances,
+ threadsPerInstance,
+ effectiveThreads);
+ for (int i = 0; i < addInstances; i++) {
+ Resource resource =
+ new Resource.Builder(resourceGroup.getContainer(), groupName,
ResourceType.OPTIMIZER)
+ .setProperties(resourceGroup.getProperties())
+ .setThreadCount(threadsPerInstance)
+ .build();
+ ResourceContainer resourceContainer =
Containers.get(resource.getContainerName());
+ pending.requested(resource.getResourceId(), threadsPerInstance, now);
+ boolean podRequested = false;
+ try {
+ ((AbstractOptimizerContainer)
resourceContainer).requestResource(resource);
+ podRequested = true;
+ optimizerManager.createResource(resource);
+ } catch (Throwable t) {
+ if (podRequested) {
+ // The pod was started; only its persistence failed. Keep the
pending accounting —
+ // the pod will self-register — instead of erasing it and
re-requesting a duplicate.
+ LOG.warn(
+ "Dynamic allocation scale-out of group {} requested resource
{} but failed to "
+ + "persist it",
+ groupName,
+ resource.getResourceId(),
+ t);
+ } else {
+ pending.failed(resource.getResourceId());
+ LOG.warn("Dynamic allocation scale-out failed for group {}",
groupName, t);
+ }
+ }
+ }
+ }
+ }
}
diff --git
a/amoro-ams/src/main/java/org/apache/amoro/server/optimizing/OptimizingQueue.java
b/amoro-ams/src/main/java/org/apache/amoro/server/optimizing/OptimizingQueue.java
index 52eb46b4c..bbbd6b288 100644
---
a/amoro-ams/src/main/java/org/apache/amoro/server/optimizing/OptimizingQueue.java
+++
b/amoro-ams/src/main/java/org/apache/amoro/server/optimizing/OptimizingQueue.java
@@ -38,6 +38,7 @@ import org.apache.amoro.server.AmoroServiceConstants;
import org.apache.amoro.server.catalog.CatalogManager;
import org.apache.amoro.server.manager.MetricManager;
import org.apache.amoro.server.optimizing.TaskRuntime.Status;
+import org.apache.amoro.server.optimizing.dra.DynamicAllocationState;
import org.apache.amoro.server.persistence.OptimizingProcessState;
import org.apache.amoro.server.persistence.PersistentBase;
import org.apache.amoro.server.persistence.TaskFilesPersistence;
@@ -418,6 +419,47 @@ public class OptimizingQueue extends PersistentBase {
.collect(Collectors.toList());
}
+ /**
+ * Snapshot the demand-side load of this queue for dynamic allocation: busy
threads, serviceable
+ * PLANNED tasks (quota-mode aware, see {@link
DynamicAllocationState#serviceablePlannedCount}),
+ * and PENDING tables. PENDING tables are observable with zero optimizers,
which makes them the
+ * only scale-up signal on a cold group where nothing polls and planning
never runs.
+ */
+ public DynamicAllocationState.GroupLoad collectDynamicAllocationLoad() {
+ Map<Long, Integer> plannedByTable = Maps.newHashMap();
+ Map<Long, Integer> occupiedByTable = Maps.newHashMap();
+ int busyThreads = 0;
+ for (TaskRuntime<?> task : collectTasks()) {
+ if (DynamicAllocationState.occupiesThread(task.getStatus())) {
+ busyThreads++;
+ occupiedByTable.merge(task.getTableId(), 1, Integer::sum);
+ } else if (task.getStatus() == Status.PLANNED) {
+ plannedByTable.merge(task.getTableId(), 1, Integer::sum);
+ }
+ }
+ Map<Long, Double> targetQuotaByTable = Maps.newHashMap();
+ int pendingTables = 0;
+ for (DefaultTableRuntime tableRuntime : scheduler.snapshotTableRuntimes())
{
+ targetQuotaByTable.put(
+ tableRuntime.getTableIdentifier().getId(),
+ tableRuntime.getOptimizingConfig().getTargetQuota());
+ if (tableRuntime.getOptimizingStatus() == OptimizingStatus.PENDING) {
+ pendingTables++;
+ }
+ }
+ List<DynamicAllocationState.TableDemand> demands = Lists.newArrayList();
+ plannedByTable.forEach(
+ (tableId, planned) ->
+ demands.add(
+ new DynamicAllocationState.TableDemand(
+ planned,
+ // Unknown table (racing removal): default to
proportional, counting in full.
+ targetQuotaByTable.getOrDefault(tableId, 1.0),
+ occupiedByTable.getOrDefault(tableId, 0))));
+ return new DynamicAllocationState.GroupLoad(
+ busyThreads, DynamicAllocationState.serviceablePlannedCount(demands),
pendingTables);
+ }
+
public void retryTask(TaskRuntime<?> taskRuntime) {
findProcess(taskRuntime.getTaskId()).resetTask((TaskRuntime<RewriteStageTask>)
taskRuntime);
}
diff --git
a/amoro-ams/src/main/java/org/apache/amoro/server/optimizing/SchedulingPolicy.java
b/amoro-ams/src/main/java/org/apache/amoro/server/optimizing/SchedulingPolicy.java
index 0759f1e36..8bb2d0eb1 100644
---
a/amoro-ams/src/main/java/org/apache/amoro/server/optimizing/SchedulingPolicy.java
+++
b/amoro-ams/src/main/java/org/apache/amoro/server/optimizing/SchedulingPolicy.java
@@ -28,9 +28,11 @@ import
org.apache.amoro.shade.guava32.com.google.common.collect.Maps;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.Iterator;
+import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.ServiceLoader;
@@ -146,4 +148,18 @@ public class SchedulingPolicy {
Map<ServerTableIdentifier, DefaultTableRuntime> getTableRuntimeMap() {
return tableRuntimeMap;
}
+
+ /**
+ * Copy the current table runtimes under {@code tableLock}. {@code
tableRuntimeMap} is a plain
+ * {@link HashMap} whose canonical accesses all hold the lock, so callers on
other threads (e.g.
+ * the dynamic-allocation scale keeper) must iterate a snapshot instead of
the live map.
+ */
+ public List<DefaultTableRuntime> snapshotTableRuntimes() {
+ tableLock.lock();
+ try {
+ return new ArrayList<>(tableRuntimeMap.values());
+ } finally {
+ tableLock.unlock();
+ }
+ }
}
diff --git
a/amoro-ams/src/main/java/org/apache/amoro/server/optimizing/dra/DynamicAllocationConfig.java
b/amoro-ams/src/main/java/org/apache/amoro/server/optimizing/dra/DynamicAllocationConfig.java
index ad62ad291..15c9de841 100644
---
a/amoro-ams/src/main/java/org/apache/amoro/server/optimizing/dra/DynamicAllocationConfig.java
+++
b/amoro-ams/src/main/java/org/apache/amoro/server/optimizing/dra/DynamicAllocationConfig.java
@@ -42,6 +42,7 @@ public class DynamicAllocationConfig {
private final boolean enabled;
private final Integer minParallelism;
private final Integer maxParallelism;
+ private final int executorParallelism;
private final Duration schedulerBacklogTimeout;
private final Duration sustainedBacklogTimeout;
private final Duration executorIdleTimeout;
@@ -54,6 +55,7 @@ public class DynamicAllocationConfig {
boolean enabled,
Integer minParallelism,
Integer maxParallelism,
+ int executorParallelism,
Duration schedulerBacklogTimeout,
Duration sustainedBacklogTimeout,
Duration executorIdleTimeout,
@@ -64,6 +66,7 @@ public class DynamicAllocationConfig {
this.enabled = enabled;
this.minParallelism = minParallelism;
this.maxParallelism = maxParallelism;
+ this.executorParallelism = executorParallelism;
this.schedulerBacklogTimeout = schedulerBacklogTimeout;
this.sustainedBacklogTimeout = sustainedBacklogTimeout;
this.executorIdleTimeout = executorIdleTimeout;
@@ -88,12 +91,19 @@ public class DynamicAllocationConfig {
PropertyUtil.propertyAsNullableInt(
properties,
OptimizerProperties.DYNAMIC_ALLOCATION_MAX_PARALLELISM);
+ int executorParallelism =
+ PropertyUtil.propertyAsInt(
+ properties,
+ OptimizerProperties.DYNAMIC_ALLOCATION_EXECUTOR_PARALLELISM,
+
OptimizerProperties.DYNAMIC_ALLOCATION_EXECUTOR_PARALLELISM_DEFAULT);
+
return new DynamicAllocationConfig(
group.getName(),
group.getContainer(),
enabled,
minParallelism,
maxParallelism,
+ executorParallelism,
parseDuration(
properties,
OptimizerProperties.DYNAMIC_ALLOCATION_SCHEDULER_BACKLOG_TIMEOUT,
@@ -269,6 +279,44 @@ public class DynamicAllocationConfig {
maxParallelism,
OptimizerProperties.DYNAMIC_ALLOCATION_MAX_PARALLELISM_LIMIT));
}
+ if (executorParallelism < 1) {
+ throw new IllegalArgumentException(
+ String.format(
+ "Resource group:%s '%s'(%d) must be >= 1.",
+ groupName,
+ OptimizerProperties.DYNAMIC_ALLOCATION_EXECUTOR_PARALLELISM,
+ executorParallelism));
+ }
+ // A single executorParallelism-thread instance is the scaling unit; if it
alone exceeds
+ // max-parallelism, scale-up could never create anything, leaving a silent
no-op group.
+ if (executorParallelism > maxParallelism) {
+ throw new IllegalArgumentException(
+ String.format(
+ "Resource group:%s '%s'(%d) must not exceed '%s'(%d).",
+ groupName,
+ OptimizerProperties.DYNAMIC_ALLOCATION_EXECUTOR_PARALLELISM,
+ executorParallelism,
+ OptimizerProperties.DYNAMIC_ALLOCATION_MAX_PARALLELISM,
+ maxParallelism));
+ }
+ // The floor is satisfied in executor-parallelism-thread instance units;
if covering it would
+ // already exceed max-parallelism, the group would silently sit below its
floor forever.
+ int floorThreads =
+ (minParallelism + executorParallelism - 1) / executorParallelism *
executorParallelism;
+ if (floorThreads > maxParallelism) {
+ throw new IllegalArgumentException(
+ String.format(
+ "Resource group:%s '%s'(%d) is not reachable in '%s'(%d) units:
covering the floor "
+ + "requires %d threads, exceeding '%s'(%d).",
+ groupName,
+ OptimizerProperties.DYNAMIC_ALLOCATION_MIN_PARALLELISM,
+ minParallelism,
+ OptimizerProperties.DYNAMIC_ALLOCATION_EXECUTOR_PARALLELISM,
+ executorParallelism,
+ floorThreads,
+ OptimizerProperties.DYNAMIC_ALLOCATION_MAX_PARALLELISM,
+ maxParallelism));
+ }
Duration idleMin =
ConfigHelpers.TimeUtils.parseDuration(
OptimizerProperties.DYNAMIC_ALLOCATION_EXECUTOR_IDLE_TIMEOUT_MIN);
@@ -340,6 +388,10 @@ public class DynamicAllocationConfig {
return maxParallelism;
}
+ public int getExecutorParallelism() {
+ return executorParallelism;
+ }
+
public Duration getSchedulerBacklogTimeout() {
return schedulerBacklogTimeout;
}
diff --git
a/amoro-ams/src/main/java/org/apache/amoro/server/optimizing/dra/DynamicAllocationState.java
b/amoro-ams/src/main/java/org/apache/amoro/server/optimizing/dra/DynamicAllocationState.java
new file mode 100644
index 000000000..9ac84334a
--- /dev/null
+++
b/amoro-ams/src/main/java/org/apache/amoro/server/optimizing/dra/DynamicAllocationState.java
@@ -0,0 +1,206 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+
+package org.apache.amoro.server.optimizing.dra;
+
+import org.apache.amoro.server.optimizing.TaskRuntime;
+
+import java.util.Collection;
+
+/**
+ * Per-group scale-up decision state for dynamic allocation (AIP-5): the
backlog timer, the
+ * scale-out cadence, and the exponential ramp. {@link #computeScaleUp} is
driven by injected inputs
+ * (loads, config, time), so the decision logic is deterministic and testable
without a live
+ * optimizing queue; the static demand-accounting helpers are pure functions.
+ */
+public final class DynamicAllocationState {
+
+ /** When demand was first observed; {@code -1} while there is no demand. */
+ private long backlogSinceMs = -1;
+
+ /** Earliest time the next scale-out may happen; {@code -1} before the first
one. */
+ private long nextAllowedAddMs = -1;
+
+ /** Instances to add in the next immediate-demand round (1, 2, 4, 8 ...). */
+ private int rampInstances = 1;
+
+ /**
+ * Decide how many executor-parallelism-thread optimizer instances to create
in this round.
+ *
+ * <p>Ordered checks: the {@code min-parallelism} floor is enforced
immediately (no timing gate);
+ * immediate demand ({@code busy + serviceable > effective}) scales
exponentially, clamped to the
+ * actual need (Spark semantics: the ramp resets when the clamp binds, and a
round with no demand
+ * resets it too); future demand (pending tables while every thread is busy
— including the
+ * zero-optimizer cold start, where nothing polls and planning never runs)
adds a single probe
+ * instance, because pending tables are not quantified demand before
planning. Demand must persist
+ * for {@code scheduler-backlog-timeout} before the first scale-out;
subsequent ones are spaced by
+ * {@code sustained-backlog-timeout}. The {@code max-parallelism} cap always
wins.
+ *
+ * @return the number of instances of {@code executor-parallelism} threads
to create, {@code >= 0}
+ */
+ public int computeScaleUp(
+ int effectiveThreads,
+ int busyThreads,
+ int serviceablePlanned,
+ int pendingTables,
+ DynamicAllocationConfig config,
+ long nowMs) {
+ int k = config.getExecutorParallelism();
+ int allowedInstances = Math.max(0, (config.getMaxParallelism() -
effectiveThreads) / k);
+
+ int minParallelism = config.getMinParallelism();
+ if (effectiveThreads < minParallelism) {
+ // A floor deficit (optimizers died or the group is new) invalidates any
demand-phase
+ // state: after recovery, demand must re-prove backlog persistence
instead of firing
+ // through a stale gate with a stale ramp.
+ backlogSinceMs = -1;
+ nextAllowedAddMs = -1;
+ rampInstances = 1;
+ int neededInstances = ceilDiv(minParallelism - effectiveThreads, k);
+ return Math.min(neededInstances, allowedInstances);
+ }
+
+ int actionableNeed = Math.max(busyThreads + serviceablePlanned -
effectiveThreads, 0);
+ boolean futureDemand = pendingTables > 0 && busyThreads >=
effectiveThreads;
+ if (actionableNeed <= 0 && !futureDemand) {
+ backlogSinceMs = -1;
+ nextAllowedAddMs = -1;
+ rampInstances = 1;
+ return 0;
+ }
+
+ if (backlogSinceMs < 0) {
+ backlogSinceMs = nowMs;
+ nextAllowedAddMs = -1;
+ }
+ long gate =
+ nextAllowedAddMs >= 0
+ ? nextAllowedAddMs
+ : backlogSinceMs + config.getSchedulerBacklogTimeout().toMillis();
+ if (nowMs < gate) {
+ return 0;
+ }
+
+ int add;
+ if (actionableNeed > 0) {
+ int wantInstances = ceilDiv(actionableNeed, k);
+ add = Math.min(Math.min(wantInstances, rampInstances), allowedInstances);
+ if (add <= 0) {
+ return 0;
+ }
+ // Spark semantics: keep doubling only while the ramp is the binding
constraint; once the
+ // actual need clamps the add, a grown ramp is no longer justified by
demand.
+ rampInstances = wantInstances > rampInstances ? rampInstances * 2 : 1;
+ } else {
+ add = Math.min(1, allowedInstances);
+ if (add <= 0) {
+ return 0;
+ }
+ rampInstances = 1;
+ }
+ nextAllowedAddMs = nowMs + config.getSustainedBacklogTimeout().toMillis();
+ return add;
+ }
+
+ private static int ceilDiv(int value, int divisor) {
+ return (value + divisor - 1) / divisor;
+ }
+
+ /** Snapshot of a group's current load, the demand-side inputs of {@link
#computeScaleUp}. */
+ public static class GroupLoad {
+ private final int busyThreads;
+ private final int serviceablePlanned;
+ private final int pendingTables;
+
+ public GroupLoad(int busyThreads, int serviceablePlanned, int
pendingTables) {
+ this.busyThreads = busyThreads;
+ this.serviceablePlanned = serviceablePlanned;
+ this.pendingTables = pendingTables;
+ }
+
+ public int getBusyThreads() {
+ return busyThreads;
+ }
+
+ public int getServiceablePlanned() {
+ return serviceablePlanned;
+ }
+
+ public int getPendingTables() {
+ return pendingTables;
+ }
+ }
+
+ /** Per-table demand snapshot consumed by {@link
#serviceablePlannedCount(Collection)}. */
+ public static class TableDemand {
+ private final int plannedCount;
+ private final double targetQuota;
+ private final int occupiedThreads;
+
+ public TableDemand(int plannedCount, double targetQuota, int
occupiedThreads) {
+ this.plannedCount = plannedCount;
+ this.targetQuota = targetQuota;
+ this.occupiedThreads = occupiedThreads;
+ }
+ }
+
+ /**
+ * Count the PLANNED tasks that adding optimizer capacity could actually
drain.
+ *
+ * <p>A table with a proportional quota ({@code targetQuota <= 1}) is
limited to {@code
+ * ceil(targetQuota * availableCore)} threads, so scaling up raises its
limit and the whole
+ * backlog is serviceable. A table with an absolute quota ({@code > 1}) has
a fixed thread limit
+ * that scaling cannot raise, so only its currently free slots are
serviceable.
+ */
+ public static int serviceablePlannedCount(Collection<TableDemand> demands) {
+ int total = 0;
+ for (TableDemand demand : demands) {
+ if (demand.targetQuota <= 1) {
+ total += demand.plannedCount;
+ } else {
+ int freeSlots = Math.max(0, (int) demand.targetQuota -
demand.occupiedThreads);
+ total += Math.min(demand.plannedCount, freeSlots);
+ }
+ }
+ return total;
+ }
+
+ /**
+ * Whether a task in this status occupies an optimizer thread. A thread is
occupied from
+ * assignment ({@code SCHEDULED}, set by {@code pollTask}) until the task
terminates; counting
+ * only {@code ACKED} would overestimate headroom during the poll-to-ack
window.
+ */
+ public static boolean occupiesThread(TaskRuntime.Status status) {
+ return status == TaskRuntime.Status.SCHEDULED || status ==
TaskRuntime.Status.ACKED;
+ }
+
+ /**
+ * Whether the group is bottlenecked on planning rather than on thread
capacity: threads sit idle
+ * while tables wait as PENDING and no PLANNED tasks materialize (planning
is serialized by {@code
+ * optimizer.max-planning-parallelism}). Scaling out in this state would
only add more idle
+ * threads, so the condition is surfaced as a warning instead of a
scale-out. A cold group (zero
+ * threads) is the future-demand case, not a planning bottleneck.
+ */
+ public static boolean isPlanningBound(
+ int effectiveThreads, int busyThreads, int serviceablePlanned, int
pendingTables) {
+ return effectiveThreads > 0
+ && busyThreads < effectiveThreads
+ && serviceablePlanned == 0
+ && pendingTables > 0;
+ }
+}
diff --git
a/amoro-ams/src/main/java/org/apache/amoro/server/optimizing/dra/PendingRegistrations.java
b/amoro-ams/src/main/java/org/apache/amoro/server/optimizing/dra/PendingRegistrations.java
new file mode 100644
index 000000000..abc80c537
--- /dev/null
+++
b/amoro-ams/src/main/java/org/apache/amoro/server/optimizing/dra/PendingRegistrations.java
@@ -0,0 +1,71 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+
+package org.apache.amoro.server.optimizing.dra;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Requested-but-not-yet-registered optimizer capacity of one resource group.
Registration is
+ * optimizer-driven — the pod self-registers after boot — so scale-up must
count capacity it has
+ * already requested to avoid duplicate scale-outs during the boot window, but
a request that never
+ * registers (image pull failure, exhausted ResourceQuota, crash loop) must
not count forever:
+ * heartbeat expiry only starts after registration, so entries carry their own
boot deadline.
+ * Thread-safe: the scale keeper and the registration RPC touch this
concurrently.
+ */
+public class PendingRegistrations {
+
+ private final long timeoutMs;
+ private final Map<String, Entry> entries = new ConcurrentHashMap<>();
+
+ private static class Entry {
+ private final int threads;
+ private final long deadlineMs;
+
+ private Entry(int threads, long deadlineMs) {
+ this.threads = threads;
+ this.deadlineMs = deadlineMs;
+ }
+ }
+
+ public PendingRegistrations(long timeoutMs) {
+ this.timeoutMs = timeoutMs;
+ }
+
+ /** Record a resource request whose optimizer has not registered yet. */
+ public void requested(String resourceId, int threads, long nowMs) {
+ entries.put(resourceId, new Entry(threads, nowMs + timeoutMs));
+ }
+
+ /** The optimizer of this resource registered; its capacity is now real. */
+ public void registered(String resourceId) {
+ entries.remove(resourceId);
+ }
+
+ /** The resource request failed synchronously; drop it immediately. */
+ public void failed(String resourceId) {
+ entries.remove(resourceId);
+ }
+
+ /** Total threads still expected to register, pruning entries past their
boot deadline. */
+ public int pendingThreads(long nowMs) {
+ entries.values().removeIf(entry -> nowMs >= entry.deadlineMs);
+ return entries.values().stream().mapToInt(entry -> entry.threads).sum();
+ }
+}
diff --git
a/amoro-ams/src/test/java/org/apache/amoro/server/TestOptimizerScaleKeeper.java
b/amoro-ams/src/test/java/org/apache/amoro/server/TestOptimizerScaleKeeper.java
new file mode 100644
index 000000000..8a57acb6c
--- /dev/null
+++
b/amoro-ams/src/test/java/org/apache/amoro/server/TestOptimizerScaleKeeper.java
@@ -0,0 +1,286 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+
+package org.apache.amoro.server;
+
+import org.apache.amoro.BasicTableTestHelper;
+import org.apache.amoro.OptimizerProperties;
+import org.apache.amoro.TableFormat;
+import org.apache.amoro.TableTestHelper;
+import org.apache.amoro.catalog.BasicCatalogTestHelper;
+import org.apache.amoro.catalog.CatalogTestHelper;
+import org.apache.amoro.resource.ResourceContainer;
+import org.apache.amoro.resource.ResourceGroup;
+import org.apache.amoro.server.resource.ContainerMetadata;
+import org.apache.amoro.server.resource.Containers;
+import org.apache.amoro.server.resource.OptimizerInstance;
+import org.apache.amoro.server.table.AMSTableTestBase;
+import org.apache.amoro.shade.guava32.com.google.common.collect.Maps;
+import org.apache.iceberg.common.DynFields;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.jupiter.api.Assertions;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+/**
+ * Integration tests for the dynamic-allocation scale keeper: DRA-enabled
groups are taken over from
+ * the legacy floor keeper and scaled in executor-parallelism-thread instance
units, with
+ * requested-but-unregistered capacity preventing duplicate scale-outs during
the pod boot window.
+ */
+@RunWith(Parameterized.class)
+public class TestOptimizerScaleKeeper extends AMSTableTestBase {
+
+ private static final String TEST_GROUP_NAME = "test-scale-keeper-group";
+ private static final String MOCK_CONTAINER_NAME = "mock-scale-container";
+
+ private final AtomicBoolean resourceAvailable = new AtomicBoolean(true);
+ private final AtomicInteger scaleOutCallCount = new AtomicInteger(0);
+ // Null simulates a pod that was requested but never boots far enough to
self-register.
+ private volatile Function<org.apache.amoro.api.OptimizerRegisterInfo,
String> optimizerRegistrar;
+ private static boolean originIsInitialized = false;
+ private String currentGroupName;
+
+ public TestOptimizerScaleKeeper(
+ CatalogTestHelper catalogTestHelper, TableTestHelper tableTestHelper) {
+ super(catalogTestHelper, tableTestHelper, false);
+ }
+
+ @Parameterized.Parameters(name = "{0}, {1}")
+ public static Object[] parameters() {
+ return new Object[][] {
+ {new BasicCatalogTestHelper(TableFormat.ICEBERG), new
BasicTableTestHelper(true, false)}
+ };
+ }
+
+ @Before
+ public void prepare() throws Exception {
+ optimizerRegistrar = registerInfo ->
optimizingService().authenticate(registerInfo);
+ setupMockContainer(() -> currentGroupName);
+ }
+
+ @After
+ public void clear() {
+ if (currentGroupName == null) {
+ return;
+ }
+ try {
+ optimizerManager()
+ .listOptimizers(currentGroupName)
+ .forEach(
+ optimizer ->
+ optimizingService()
+ .deleteOptimizer(optimizer.getGroupName(),
optimizer.getResourceId()));
+ try {
+ optimizingService().deleteResourceGroup(currentGroupName);
+ } catch (Exception ignored) {
+ }
+ try {
+ optimizerManager().deleteResourceGroup(currentGroupName);
+ } catch (Exception ignored) {
+ }
+ } catch (Exception e) {
+ // ignore
+ } finally {
+ currentGroupName = null;
+ }
+ }
+
+ @AfterClass
+ public static void cleanup() {
+ if (!originIsInitialized) {
+ DynFields.UnboundField<Boolean> initializedField =
+ DynFields.builder().hiddenImpl(Containers.class,
"isInitialized").build();
+ initializedField.asStatic().set(false);
+ }
+ }
+
+ private void setupMockContainer(Supplier<String> targetGroupNameSupplier)
throws Exception {
+ TestOptimizerGroupKeeper.MockOptimizerContainer mockContainer =
+ new TestOptimizerGroupKeeper.MockOptimizerContainer(
+ resourceAvailable,
+ scaleOutCallCount,
+ registerInfo -> {
+ Function<org.apache.amoro.api.OptimizerRegisterInfo, String>
registrar =
+ optimizerRegistrar;
+ return registrar == null ? null : registrar.apply(registerInfo);
+ },
+ targetGroupNameSupplier);
+
+ DynFields.UnboundField<Boolean> initializedField =
+ DynFields.builder().hiddenImpl(Containers.class,
"isInitialized").build();
+ if (!initializedField.asStatic().get()) {
+ originIsInitialized = false;
+ initializedField.asStatic().set(true);
+ }
+
+ DynFields.UnboundField<Map<String, Object>> containersField =
+ DynFields.builder().hiddenImpl(Containers.class,
"globalContainers").build();
+ Map<String, Object> globalContainers = containersField.asStatic().get();
+
+ ContainerMetadata metadata =
+ new ContainerMetadata(
+ MOCK_CONTAINER_NAME,
TestOptimizerGroupKeeper.MockOptimizerContainer.class.getName());
+ Map<String, String> properties = Maps.newHashMap();
+ properties.put(OptimizerProperties.AMS_HOME, "/tmp");
+ properties.put(OptimizerProperties.AMS_OPTIMIZER_URI,
"thrift://localhost:1261");
+ properties.put("memory", "1024");
+ metadata.setProperties(properties);
+
+ Class<?> wrapperClass =
+
Class.forName("org.apache.amoro.server.resource.Containers$ContainerWrapper");
+ java.lang.reflect.Constructor<?> constructor =
+ wrapperClass.getDeclaredConstructor(ContainerMetadata.class,
ResourceContainer.class);
+ constructor.setAccessible(true);
+ Object wrapper = constructor.newInstance(metadata, mockContainer);
+ globalContainers.put(MOCK_CONTAINER_NAME, wrapper);
+ }
+
+ private ResourceGroup buildDraResourceGroup(String groupName, int
minParallelism, int k) {
+ this.currentGroupName = groupName;
+ Map<String, String> properties = Maps.newHashMap();
+ properties.put(OptimizerProperties.DYNAMIC_ALLOCATION_ENABLED, "true");
+ properties.put(
+ OptimizerProperties.DYNAMIC_ALLOCATION_MIN_PARALLELISM,
String.valueOf(minParallelism));
+ properties.put(OptimizerProperties.DYNAMIC_ALLOCATION_MAX_PARALLELISM,
"8");
+
properties.put(OptimizerProperties.DYNAMIC_ALLOCATION_EXECUTOR_PARALLELISM,
String.valueOf(k));
+ // Fast timings so the scale keeper runs several rounds within a short
sleep.
+
properties.put(OptimizerProperties.DYNAMIC_ALLOCATION_SCHEDULER_BACKLOG_TIMEOUT,
"1ms");
+
properties.put(OptimizerProperties.DYNAMIC_ALLOCATION_SUSTAINED_BACKLOG_TIMEOUT,
"50ms");
+ properties.put("memory", "1024");
+ return new ResourceGroup.Builder(groupName, MOCK_CONTAINER_NAME)
+ .addProperties(properties)
+ .build();
+ }
+
+ /**
+ * The floor of a DRA group is satisfied in executor-parallelism-thread
instance units by the
+ * scale keeper, not by the legacy keeper's single deficit-sized instance:
min-parallelism=2 with
+ * executor-parallelism=1 must produce two 1-thread instances, not one
2-thread instance.
+ */
+ @Test
+ public void testDraFloorSatisfiedInExecutorParallelismUnits() throws
InterruptedException {
+ resourceAvailable.set(true);
+ scaleOutCallCount.set(0);
+ ResourceGroup group = buildDraResourceGroup(TEST_GROUP_NAME + "-1", 2, 1);
+
+ optimizerManager().createResourceGroup(group);
+ optimizingService().createResourceGroup(group);
+
+ Thread.sleep(500);
+
+ List<OptimizerInstance> optimizers =
optimizerManager().listOptimizers(group.getName());
+ Assertions.assertEquals(
+ 2, optimizers.size(), "floor should be satisfied by K-thread
instances");
+ optimizers.forEach(
+ optimizer ->
+ Assertions.assertEquals(
+ 1,
+ optimizer.getThreadCount(),
+ "each instance should have executor-parallelism threads"));
+ Assertions.assertEquals(
+ 0,
+ optimizingService().pendingScaleThreads(group.getName()),
+ "registration must clear the boot-window accounting, or registered
capacity would be "
+ + "double-counted and suppress demand scaling");
+ }
+
+ /**
+ * Requested-but-unregistered capacity counts toward the effective threads:
while pods are booting
+ * (never registering here), the scale keeper must not re-request the same
deficit every round the
+ * way the legacy keeper would.
+ */
+ @Test
+ public void testBootWindowPreventsDuplicateScaleOuts() throws
InterruptedException {
+ resourceAvailable.set(true);
+ scaleOutCallCount.set(0);
+ optimizerRegistrar = null; // pods are requested but never self-register
+ ResourceGroup group = buildDraResourceGroup(TEST_GROUP_NAME + "-2", 2, 1);
+
+ optimizerManager().createResourceGroup(group);
+ optimizingService().createResourceGroup(group);
+
+ // ~10 scale rounds at the 50ms cadence; without boot-window accounting
each round would
+ // re-request the full deficit.
+ Thread.sleep(500);
+
+ Assertions.assertEquals(
+ 2,
+ scaleOutCallCount.get(),
+ "the deficit must be requested exactly once while the pods are still
booting");
+ }
+
+ /**
+ * A synchronous scale-out failure must not stick as phantom pending
capacity: the failed request
+ * is dropped immediately and retried on a later round.
+ */
+ @Test
+ public void testFailedScaleOutIsRetriedNextRound() throws
InterruptedException {
+ resourceAvailable.set(false);
+ scaleOutCallCount.set(0);
+ ResourceGroup group = buildDraResourceGroup(TEST_GROUP_NAME + "-3", 1, 1);
+
+ optimizerManager().createResourceGroup(group);
+ optimizingService().createResourceGroup(group);
+
+ Thread.sleep(500);
+
+ Assertions.assertTrue(
+ scaleOutCallCount.get() >= 2,
+ "failed requests should be retried instead of freezing scale-up: "
+ + scaleOutCallCount.get());
+ }
+
+ /** Enabling DRA on an existing group at runtime brings it under the scale
keeper. */
+ @Test
+ public void testEnablingDraAtRuntimeBringsGroupUnderScaleKeeper() throws
InterruptedException {
+ resourceAvailable.set(true);
+ scaleOutCallCount.set(0);
+ this.currentGroupName = TEST_GROUP_NAME + "-4";
+ Map<String, String> legacyProps = Maps.newHashMap();
+ legacyProps.put("memory", "1024");
+ ResourceGroup legacyGroup =
+ new ResourceGroup.Builder(currentGroupName, MOCK_CONTAINER_NAME)
+ .addProperties(legacyProps)
+ .build();
+
+ optimizerManager().createResourceGroup(legacyGroup);
+ optimizingService().createResourceGroup(legacyGroup);
+ Thread.sleep(100);
+ Assertions.assertEquals(0, scaleOutCallCount.get(), "no demand and no
floor: no scale-out");
+
+ ResourceGroup draGroup = buildDraResourceGroup(currentGroupName, 2, 1);
+ optimizerManager().updateResourceGroup(draGroup);
+ optimizingService().updateResourceGroup(draGroup);
+
+ Thread.sleep(500);
+
+ List<OptimizerInstance> optimizers =
optimizerManager().listOptimizers(currentGroupName);
+ Assertions.assertEquals(
+ 2, optimizers.size(), "runtime-enabled DRA group should reach its
floor in K units");
+ }
+}
diff --git
a/amoro-ams/src/test/java/org/apache/amoro/server/optimizing/TestOptimizingQueue.java
b/amoro-ams/src/test/java/org/apache/amoro/server/optimizing/TestOptimizingQueue.java
index 772f15c01..78a9e22e3 100644
---
a/amoro-ams/src/test/java/org/apache/amoro/server/optimizing/TestOptimizingQueue.java
+++
b/amoro-ams/src/test/java/org/apache/amoro/server/optimizing/TestOptimizingQueue.java
@@ -50,6 +50,7 @@ import org.apache.amoro.optimizing.TaskProperties;
import org.apache.amoro.process.ProcessStatus;
import org.apache.amoro.resource.ResourceGroup;
import org.apache.amoro.server.manager.MetricManager;
+import org.apache.amoro.server.optimizing.dra.DynamicAllocationState;
import org.apache.amoro.server.resource.OptimizerInstance;
import org.apache.amoro.server.resource.OptimizerThread;
import org.apache.amoro.server.resource.QuotaProvider;
@@ -199,6 +200,31 @@ public class TestOptimizingQueue extends AMSTableTestBase {
queue.dispose();
}
+ @Test
+ public void testCollectDynamicAllocationLoad() {
+ DefaultTableRuntime tableRuntime = initTableWithFiles();
+ OptimizingQueue queue = buildOptimizingGroupService(tableRuntime);
+
+ // Before any poll nothing has been planned: the PENDING table is the only
demand signal —
+ // exactly what dynamic allocation must observe on a cold group with zero
optimizers.
+ DynamicAllocationState.GroupLoad before =
queue.collectDynamicAllocationLoad();
+ Assert.assertEquals(0, before.getBusyThreads());
+ Assert.assertEquals(0, before.getServiceablePlanned());
+ Assert.assertEquals(1, before.getPendingTables());
+
+ // A poll drives planning and takes the produced task: the thread is busy
from SCHEDULED
+ // (not only from ACKED), and the table is no longer PENDING.
+ TaskRuntime<?> task = queue.pollTask(optimizerThread, MAX_POLLING_TIME);
+ Assert.assertNotNull(task);
+ Assert.assertEquals(TaskRuntime.Status.SCHEDULED, task.getStatus());
+
+ DynamicAllocationState.GroupLoad after =
queue.collectDynamicAllocationLoad();
+ Assert.assertEquals(1, after.getBusyThreads());
+ Assert.assertEquals(0, after.getServiceablePlanned());
+ Assert.assertEquals(0, after.getPendingTables());
+ queue.dispose();
+ }
+
@Test
public void testPollTaskWithOverQuotaDisabled() {
DefaultTableRuntime tableRuntime = initTableWithPartitionedFiles();
diff --git
a/amoro-ams/src/test/java/org/apache/amoro/server/optimizing/dra/TestComputeScaleUp.java
b/amoro-ams/src/test/java/org/apache/amoro/server/optimizing/dra/TestComputeScaleUp.java
new file mode 100644
index 000000000..d5b08ec56
--- /dev/null
+++
b/amoro-ams/src/test/java/org/apache/amoro/server/optimizing/dra/TestComputeScaleUp.java
@@ -0,0 +1,231 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+
+package org.apache.amoro.server.optimizing.dra;
+
+import org.apache.amoro.OptimizerProperties;
+import org.apache.amoro.resource.ResourceGroup;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Tests for {@link DynamicAllocationState#computeScaleUp}. Time is injected,
so every scenario is
+ * deterministic; the returned value is the number of
executor-parallelism-thread instances to
+ * create in this round.
+ */
+public class TestComputeScaleUp {
+
+ private static final long T0 = 0L;
+ private static final long BACKLOG_MS = 60_000L; // scheduler-backlog-timeout
default 1min
+ private static final long SUSTAINED_MS = 30_000L; //
sustained-backlog-timeout default 30s
+
+ private DynamicAllocationConfig config(int minParallelism, int
maxParallelism, int k) {
+ Map<String, String> props = new HashMap<>();
+ props.put(OptimizerProperties.DYNAMIC_ALLOCATION_ENABLED, "true");
+ props.put(
+ OptimizerProperties.DYNAMIC_ALLOCATION_MIN_PARALLELISM,
String.valueOf(minParallelism));
+ props.put(
+ OptimizerProperties.DYNAMIC_ALLOCATION_MAX_PARALLELISM,
String.valueOf(maxParallelism));
+ props.put(OptimizerProperties.DYNAMIC_ALLOCATION_EXECUTOR_PARALLELISM,
String.valueOf(k));
+ return DynamicAllocationConfig.parse(
+ new ResourceGroup.Builder("group1",
"flink").addProperties(props).build());
+ }
+
+ // --- floor enforcement: immediate, no timing gate ---
+
+ @Test
+ void floorDeficitScalesImmediately() {
+ DynamicAllocationState state = new DynamicAllocationState();
+ // min=5, K=2: ceil(5/2) = 3 instances, on the very first evaluation.
+ Assertions.assertEquals(3, state.computeScaleUp(0, 0, 0, 0, config(5, 100,
2), T0));
+ }
+
+ @Test
+ void floorNeverExceedsMaxParallelism() {
+ DynamicAllocationState state = new DynamicAllocationState();
+ // min=5, max=6, K=4: ceil(5/4)=2 instances would be 8 threads > max; cap
allows only 1.
+ // validate() rejects this combination up front (unreachable floor); the
cap clamp here is
+ // defense in depth for a config that bypassed validation (e.g. persisted
before upgrade).
+ Assertions.assertEquals(1, state.computeScaleUp(0, 0, 0, 0, config(5, 6,
4), T0));
+ }
+
+ // --- no demand ---
+
+ @Test
+ void noDemandReturnsZero() {
+ DynamicAllocationState state = new DynamicAllocationState();
+ Assertions.assertEquals(0, state.computeScaleUp(4, 2, 0, 0, config(0, 100,
2), T0));
+ }
+
+ @Test
+ void idleCapacityCoveringBacklogReturnsZero() {
+ DynamicAllocationState state = new DynamicAllocationState();
+ // busy(2) + serviceable(3) <= effective(8): idle threads will pick the
tasks up.
+ Assertions.assertEquals(0, state.computeScaleUp(8, 2, 3, 0, config(0, 100,
2), T0));
+ }
+
+ // --- immediate demand (Layer 1): backlog timer, ramp, clamp ---
+
+ @Test
+ void immediateBacklogWaitsForSchedulerBacklogTimeout() {
+ DynamicAllocationState state = new DynamicAllocationState();
+ DynamicAllocationConfig config = config(0, 100, 2);
+ Assertions.assertEquals(0, state.computeScaleUp(2, 2, 5, 0, config, T0));
+ Assertions.assertEquals(0, state.computeScaleUp(2, 2, 5, 0, config, T0 +
BACKLOG_MS - 1));
+ Assertions.assertEquals(1, state.computeScaleUp(2, 2, 5, 0, config, T0 +
BACKLOG_MS));
+ }
+
+ @Test
+ void exponentialRampAcrossSustainedRounds() {
+ DynamicAllocationState state = new DynamicAllocationState();
+ DynamicAllocationConfig config = config(0, 1000, 2);
+ // A large persistent backlog; effective/busy grow by the created threads
each round.
+ Assertions.assertEquals(0, state.computeScaleUp(2, 2, 100, 0, config, T0));
+ Assertions.assertEquals(1, state.computeScaleUp(2, 2, 100, 0, config, T0 +
BACKLOG_MS));
+ Assertions.assertEquals(
+ 2, state.computeScaleUp(4, 4, 100, 0, config, T0 + BACKLOG_MS +
SUSTAINED_MS));
+ Assertions.assertEquals(
+ 4, state.computeScaleUp(8, 8, 100, 0, config, T0 + BACKLOG_MS + 2 *
SUSTAINED_MS));
+ Assertions.assertEquals(
+ 8, state.computeScaleUp(16, 16, 100, 0, config, T0 + BACKLOG_MS + 3 *
SUSTAINED_MS));
+ }
+
+ @Test
+ void sustainedCadenceGatesConsecutiveAdds() {
+ DynamicAllocationState state = new DynamicAllocationState();
+ DynamicAllocationConfig config = config(0, 1000, 2);
+ Assertions.assertEquals(0, state.computeScaleUp(2, 2, 100, 0, config, T0));
+ Assertions.assertEquals(1, state.computeScaleUp(2, 2, 100, 0, config, T0 +
BACKLOG_MS));
+ // 10s after the first add: sustained-backlog-timeout (30s) has not
elapsed.
+ Assertions.assertEquals(
+ 0, state.computeScaleUp(4, 4, 100, 0, config, T0 + BACKLOG_MS +
10_000));
+ Assertions.assertEquals(
+ 2, state.computeScaleUp(4, 4, 100, 0, config, T0 + BACKLOG_MS +
SUSTAINED_MS));
+ }
+
+ @Test
+ void rampResetsWhenClampBinds() {
+ DynamicAllocationState state = new DynamicAllocationState();
+ DynamicAllocationConfig config = config(0, 1000, 1);
+ long t1 = T0 + BACKLOG_MS;
+ long t2 = t1 + SUSTAINED_MS;
+ long t3 = t2 + SUSTAINED_MS;
+ long t4 = t3 + SUSTAINED_MS;
+ Assertions.assertEquals(0, state.computeScaleUp(2, 2, 10, 0, config, T0));
+ // A: want=10 > ramp=1 -> add 1, ramp doubles to 2.
+ Assertions.assertEquals(1, state.computeScaleUp(2, 2, 10, 0, config, t1));
+ // B: want=1 < ramp=2 -> clamp binds: add 1 and the ramp resets to 1
(Spark semantics),
+ // instead of keeping a grown ramp no demand justified.
+ Assertions.assertEquals(1, state.computeScaleUp(3, 3, 1, 0, config, t2));
+ // C: demand returns: ramp restarts from 1, not from the stale doubled
value.
+ Assertions.assertEquals(1, state.computeScaleUp(4, 4, 10, 0, config, t3));
+ // D: ramp doubling resumes normally.
+ Assertions.assertEquals(2, state.computeScaleUp(5, 5, 10, 0, config, t4));
+ }
+
+ @Test
+ void addIsCappedByMaxParallelismAndStopsAtCap() {
+ DynamicAllocationState state = new DynamicAllocationState();
+ DynamicAllocationConfig config = config(0, 10, 2);
+ Assertions.assertEquals(0, state.computeScaleUp(8, 8, 100, 0, config, T0));
+ // Only floor((10-8)/2) = 1 instance fits under the cap.
+ Assertions.assertEquals(1, state.computeScaleUp(8, 8, 100, 0, config, T0 +
BACKLOG_MS));
+ // At the cap: nothing more can be created no matter the backlog.
+ Assertions.assertEquals(
+ 0, state.computeScaleUp(10, 10, 100, 0, config, T0 + BACKLOG_MS +
SUSTAINED_MS));
+ }
+
+ @Test
+ void floorRoundsResetDemandTiming() {
+ DynamicAllocationState state = new DynamicAllocationState();
+ DynamicAllocationConfig config = config(2, 100, 1);
+ // A demand phase establishes a passed cadence gate.
+ Assertions.assertEquals(0, state.computeScaleUp(2, 2, 10, 0, config, T0));
+ Assertions.assertEquals(1, state.computeScaleUp(2, 2, 10, 0, config, T0 +
BACKLOG_MS));
+ // Optimizers die: effective drops below the floor; floor rounds bypass
demand timing.
+ Assertions.assertEquals(2, state.computeScaleUp(0, 0, 0, 0, config, T0 +
BACKLOG_MS + 10_000));
+ // After recovery, fresh demand must re-prove backlog persistence instead
of firing
+ // immediately through the stale gate left over from the earlier demand
phase.
+ long recovered = T0 + BACKLOG_MS + 120_000;
+ Assertions.assertEquals(0, state.computeScaleUp(2, 2, 10, 0, config,
recovered));
+ Assertions.assertEquals(1, state.computeScaleUp(2, 2, 10, 0, config,
recovered + BACKLOG_MS));
+ }
+
+ @Test
+ void oscillatingDemandResetsBacklogTimer() {
+ DynamicAllocationState state = new DynamicAllocationState();
+ DynamicAllocationConfig config = config(0, 100, 2);
+ // A trickle whose tasks are drained between rounds must not accumulate
toward the timeout:
+ // arrival keeping pace with processing is not under-capacity.
+ Assertions.assertEquals(0, state.computeScaleUp(2, 2, 5, 0, config, T0));
+ Assertions.assertEquals(0, state.computeScaleUp(2, 2, 0, 0, config, T0 +
30_000)); // resets
+ Assertions.assertEquals(0, state.computeScaleUp(2, 2, 5, 0, config, T0 +
45_000)); // restarts
+ Assertions.assertEquals(
+ 0, state.computeScaleUp(2, 2, 5, 0, config, T0 + 45_000 + BACKLOG_MS -
1));
+ Assertions.assertEquals(1, state.computeScaleUp(2, 2, 5, 0, config, T0 +
45_000 + BACKLOG_MS));
+ }
+
+ // --- future demand (Layer 2): pending tables, all threads busy, one
instance ---
+
+ @Test
+ void coldStartWithPendingTablesAddsOneInstance() {
+ DynamicAllocationState state = new DynamicAllocationState();
+ DynamicAllocationConfig config = config(0, 100, 4);
+ // Zero optimizers: no pollTask, no planning, no PLANNED tasks — only
PENDING tables are
+ // observable. Layer 2 must ignite the group.
+ Assertions.assertEquals(0, state.computeScaleUp(0, 0, 0, 3, config, T0));
+ Assertions.assertEquals(1, state.computeScaleUp(0, 0, 0, 3, config, T0 +
BACKLOG_MS));
+ }
+
+ @Test
+ void futureDemandRequiresAllThreadsBusy() {
+ DynamicAllocationState state = new DynamicAllocationState();
+ DynamicAllocationConfig config = config(0, 100, 2);
+ // An idle thread exists: it will drive planning via pollTask, so pending
tables alone are
+ // not a scale signal.
+ Assertions.assertEquals(0, state.computeScaleUp(4, 3, 0, 5, config, T0));
+ Assertions.assertEquals(0, state.computeScaleUp(4, 3, 0, 5, config, T0 +
BACKLOG_MS));
+ }
+
+ @Test
+ void futureDemandAddsOneInstanceRegardlessOfPendingTableCount() {
+ DynamicAllocationState state = new DynamicAllocationState();
+ DynamicAllocationConfig config = config(0, 100, 4);
+ // Pending tables are not quantified demand (tasks per table are unknown
before planning);
+ // Layer 2 probes with a single instance and lets Layer 1 take over once
tasks materialize.
+ Assertions.assertEquals(0, state.computeScaleUp(0, 0, 0, 100, config, T0));
+ Assertions.assertEquals(1, state.computeScaleUp(0, 0, 0, 100, config, T0 +
BACKLOG_MS));
+ }
+
+ @Test
+ void futureDemandDoesNotRamp() {
+ DynamicAllocationState state = new DynamicAllocationState();
+ DynamicAllocationConfig config = config(0, 100, 4);
+ Assertions.assertEquals(0, state.computeScaleUp(0, 0, 0, 10, config, T0));
+ Assertions.assertEquals(1, state.computeScaleUp(0, 0, 0, 10, config, T0 +
BACKLOG_MS));
+ // Still pending-only demand after the first instance registered and is
busy: another single
+ // instance, not an exponentially grown batch.
+ Assertions.assertEquals(
+ 1, state.computeScaleUp(4, 4, 0, 10, config, T0 + BACKLOG_MS +
SUSTAINED_MS));
+ Assertions.assertEquals(
+ 1, state.computeScaleUp(8, 8, 0, 10, config, T0 + BACKLOG_MS + 2 *
SUSTAINED_MS));
+ }
+}
diff --git
a/amoro-ams/src/test/java/org/apache/amoro/server/optimizing/dra/TestDynamicAllocationConfig.java
b/amoro-ams/src/test/java/org/apache/amoro/server/optimizing/dra/TestDynamicAllocationConfig.java
index 837584285..66d5aa41f 100644
---
a/amoro-ams/src/test/java/org/apache/amoro/server/optimizing/dra/TestDynamicAllocationConfig.java
+++
b/amoro-ams/src/test/java/org/apache/amoro/server/optimizing/dra/TestDynamicAllocationConfig.java
@@ -70,6 +70,70 @@ public class TestDynamicAllocationConfig {
Assertions.assertThrows(IllegalArgumentException.class, () ->
parseAndValidate(group(props)));
}
+ @Test
+ void executorParallelismDefaultsToOne() {
+ DynamicAllocationConfig config =
DynamicAllocationConfig.parse(group(enabledProps()));
+ assertDoesNotThrow(config::validate);
+ Assertions.assertEquals(1, config.getExecutorParallelism());
+ }
+
+ @Test
+ void executorParallelismIsParsed() {
+ Map<String, String> props = enabledProps();
+ props.put(OptimizerProperties.DYNAMIC_ALLOCATION_EXECUTOR_PARALLELISM,
"4");
+ DynamicAllocationConfig config =
DynamicAllocationConfig.parse(group(props));
+ assertDoesNotThrow(config::validate);
+ Assertions.assertEquals(4, config.getExecutorParallelism());
+ }
+
+ @Test
+ void executorParallelismBelowOneIsRejected() {
+ Map<String, String> props = enabledProps();
+ props.put(OptimizerProperties.DYNAMIC_ALLOCATION_EXECUTOR_PARALLELISM,
"0");
+ Assertions.assertThrows(IllegalArgumentException.class, () ->
parseAndValidate(group(props)));
+ }
+
+ @Test
+ void executorParallelismAboveMaxParallelismIsRejected() {
+ // A single K-thread instance would already exceed the max-parallelism
cap, so scale-up
+ // could never create anything; reject up front instead of leaving a
silent no-op group.
+ Map<String, String> props = enabledProps();
+ props.put(OptimizerProperties.DYNAMIC_ALLOCATION_EXECUTOR_PARALLELISM,
"32");
+ Assertions.assertThrows(IllegalArgumentException.class, () ->
parseAndValidate(group(props)));
+ }
+
+ @Test
+ void executorParallelismMakingFloorUnreachableIsRejected() {
+ // min=5, max=6, K=4: covering the floor needs ceil(5/4)=2 instances = 8
threads > max,
+ // so the floor could never be satisfied in K units and the group would
silently sit
+ // below its floor forever.
+ Map<String, String> props = enabledProps();
+ props.put(OptimizerProperties.DYNAMIC_ALLOCATION_MIN_PARALLELISM, "5");
+ props.put(OptimizerProperties.DYNAMIC_ALLOCATION_MAX_PARALLELISM, "6");
+ props.put(OptimizerProperties.DYNAMIC_ALLOCATION_EXECUTOR_PARALLELISM,
"4");
+ Assertions.assertThrows(IllegalArgumentException.class, () ->
parseAndValidate(group(props)));
+ }
+
+ @Test
+ void reachableFloorInExecutorParallelismUnitsIsAccepted() {
+ // min=5, max=8, K=4: ceil(5/4)=2 instances = 8 threads fits under max.
+ Map<String, String> props = enabledProps();
+ props.put(OptimizerProperties.DYNAMIC_ALLOCATION_MIN_PARALLELISM, "5");
+ props.put(OptimizerProperties.DYNAMIC_ALLOCATION_MAX_PARALLELISM, "8");
+ props.put(OptimizerProperties.DYNAMIC_ALLOCATION_EXECUTOR_PARALLELISM,
"4");
+ assertDoesNotThrow(() -> parseAndValidate(group(props)));
+ }
+
+ @Test
+ void malformedExecutorParallelismIsRejectedAtParse() {
+ // Same parse() contract as min/max-parallelism: a malformed numeric is
rejected at parse
+ // regardless of enabled.
+ Map<String, String> props = new HashMap<>();
+ props.put(OptimizerProperties.DYNAMIC_ALLOCATION_EXECUTOR_PARALLELISM,
"abc");
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () ->
DynamicAllocationConfig.parse(group(props)));
+ }
+
@Test
void enabledWithUnparsableMinParallelismIsRejected() {
// resolveMinParallelism() is lenient (legacy/keeper path), but an
opted-in group must not
diff --git
a/amoro-ams/src/test/java/org/apache/amoro/server/optimizing/dra/TestDynamicAllocationState.java
b/amoro-ams/src/test/java/org/apache/amoro/server/optimizing/dra/TestDynamicAllocationState.java
new file mode 100644
index 000000000..fc5233aee
--- /dev/null
+++
b/amoro-ams/src/test/java/org/apache/amoro/server/optimizing/dra/TestDynamicAllocationState.java
@@ -0,0 +1,175 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+
+package org.apache.amoro.server.optimizing.dra;
+
+import org.apache.amoro.server.optimizing.TaskRuntime;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+public class TestDynamicAllocationState {
+
+ private DynamicAllocationState.TableDemand demand(
+ int plannedCount, double targetQuota, int occupiedThreads) {
+ return new DynamicAllocationState.TableDemand(plannedCount, targetQuota,
occupiedThreads);
+ }
+
+ // --- serviceablePlannedCount: proportional mode (targetQuota <= 1) ---
+ // The per-table quota limit is ceil(targetQuota * availableCore), so adding
threads raises the
+ // limit; a quota-blocked backlog is still serviceable demand and counts in
full.
+
+ @Test
+ void proportionalQuotaCountsAllPlannedTasks() {
+ Assertions.assertEquals(
+ 10,
+ DynamicAllocationState.serviceablePlannedCount(
+ Collections.singletonList(demand(10, 0.5, 4))));
+ }
+
+ @Test
+ void targetQuotaOfExactlyOneIsProportional() {
+ // Absolute mode starts strictly above 1 (see OptimizingUtil quota
resolution).
+ Assertions.assertEquals(
+ 7,
+ DynamicAllocationState.serviceablePlannedCount(
+ Collections.singletonList(demand(7, 1.0, 3))));
+ }
+
+ // --- serviceablePlannedCount: absolute mode (targetQuota > 1) ---
+ // The limit is a fixed thread count that scaling cannot raise; only free
slots are serviceable.
+
+ @Test
+ void absoluteQuotaCountsOnlyFreeSlots() {
+ Assertions.assertEquals(
+ 2,
+ DynamicAllocationState.serviceablePlannedCount(
+ Collections.singletonList(demand(10, 3.0, 1))));
+ }
+
+ @Test
+ void absoluteQuotaExhaustedCountsZero() {
+ Assertions.assertEquals(
+ 0,
+ DynamicAllocationState.serviceablePlannedCount(
+ Collections.singletonList(demand(5, 2.0, 2))));
+ }
+
+ @Test
+ void absoluteQuotaOverOccupiedDoesNotGoNegative() {
+ // occupied can transiently exceed the limit (e.g. after a quota config
decrease); the table
+ // must contribute zero, not a negative count offsetting other tables.
+ Assertions.assertEquals(
+ 0,
+ DynamicAllocationState.serviceablePlannedCount(
+ Collections.singletonList(demand(5, 2.0, 3))));
+ }
+
+ @Test
+ void absoluteQuotaFractionalLimitTruncates() {
+ // The limit is (int) targetQuota, matching the poll gate's getQuotaLimit
cast: 2.5 -> 2
+ // slots, so 2 occupied threads leave zero free.
+ Assertions.assertEquals(
+ 0,
+ DynamicAllocationState.serviceablePlannedCount(
+ Collections.singletonList(demand(5, 2.5, 2))));
+ }
+
+ @Test
+ void absoluteQuotaFreeSlotsCappedByPlanned() {
+ // Free slots exceed the planned backlog; only actual tasks count.
+ Assertions.assertEquals(
+ 3,
+ DynamicAllocationState.serviceablePlannedCount(
+ Collections.singletonList(demand(3, 8.0, 1))));
+ }
+
+ // --- serviceablePlannedCount: aggregation ---
+
+ @Test
+ void mixedModesSumPerTable() {
+ Assertions.assertEquals(
+ 12,
+ DynamicAllocationState.serviceablePlannedCount(
+ Arrays.asList(demand(10, 0.5, 4), demand(10, 3.0, 1))));
+ }
+
+ @Test
+ void emptyDemandsCountZero() {
+ Assertions.assertEquals(
+ 0,
DynamicAllocationState.serviceablePlannedCount(Collections.emptyList()));
+ }
+
+ // --- isPlanningBound ---
+ // Idle threads while tables wait as PENDING and no PLANNED tasks
materialize means the
+ // bottleneck is planning (maxPlanningParallelism), not thread capacity:
scaling out would only
+ // add more idle threads. The condition is surfaced as a warning, never as a
scale-out.
+
+ @Test
+ void planningBoundWhenThreadsIdleTablesPendingAndNothingPlanned() {
+ Assertions.assertTrue(DynamicAllocationState.isPlanningBound(4, 2, 0, 10));
+ }
+
+ @Test
+ void notPlanningBoundWhenPlannedTasksAwaitPickup() {
+ // Idle threads have work to poll; planning is keeping up.
+ Assertions.assertFalse(DynamicAllocationState.isPlanningBound(4, 2, 3,
10));
+ }
+
+ @Test
+ void notPlanningBoundWhenAllThreadsBusy() {
+ // Saturated threads are Layer-2 territory, not a planning bottleneck
signal.
+ Assertions.assertFalse(DynamicAllocationState.isPlanningBound(4, 4, 0,
10));
+ }
+
+ @Test
+ void notPlanningBoundWithoutPendingTables() {
+ Assertions.assertFalse(DynamicAllocationState.isPlanningBound(4, 2, 0, 0));
+ }
+
+ @Test
+ void notPlanningBoundOnColdGroup() {
+ // Zero optimizers is the cold-start case handled by future demand, not a
planning issue.
+ Assertions.assertFalse(DynamicAllocationState.isPlanningBound(0, 0, 0,
10));
+ }
+
+ // --- occupiesThread ---
+ // A task occupies an optimizer thread from the moment it is assigned
(SCHEDULED, set by
+ // pollTask) until it terminates; counting only ACKED would overestimate
headroom during the
+ // poll-to-ack window.
+
+ @Test
+ void scheduledTaskOccupiesAThread() {
+
Assertions.assertTrue(DynamicAllocationState.occupiesThread(TaskRuntime.Status.SCHEDULED));
+ }
+
+ @Test
+ void ackedTaskOccupiesAThread() {
+
Assertions.assertTrue(DynamicAllocationState.occupiesThread(TaskRuntime.Status.ACKED));
+ }
+
+ @Test
+ void terminalAndQueuedStatusesDoNotOccupyThreads() {
+
Assertions.assertFalse(DynamicAllocationState.occupiesThread(TaskRuntime.Status.PLANNED));
+
Assertions.assertFalse(DynamicAllocationState.occupiesThread(TaskRuntime.Status.SUCCESS));
+
Assertions.assertFalse(DynamicAllocationState.occupiesThread(TaskRuntime.Status.FAILED));
+
Assertions.assertFalse(DynamicAllocationState.occupiesThread(TaskRuntime.Status.CANCELED));
+ }
+}
diff --git
a/amoro-ams/src/test/java/org/apache/amoro/server/optimizing/dra/TestPendingRegistrations.java
b/amoro-ams/src/test/java/org/apache/amoro/server/optimizing/dra/TestPendingRegistrations.java
new file mode 100644
index 000000000..2ec07734e
--- /dev/null
+++
b/amoro-ams/src/test/java/org/apache/amoro/server/optimizing/dra/TestPendingRegistrations.java
@@ -0,0 +1,87 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+
+package org.apache.amoro.server.optimizing.dra;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for {@link PendingRegistrations}: requested-but-not-yet-registered
optimizer capacity.
+ * Registration is optimizer-driven (the pod self-registers after boot), so a
request that never
+ * registers — image pull failure, exhausted ResourceQuota, crash loop — must
not count as capacity
+ * forever: it would freeze scale-up below the real demand. Heartbeat expiry
cannot cover this
+ * window because it only starts after registration.
+ */
+public class TestPendingRegistrations {
+
+ private static final long TIMEOUT_MS = 180_000L; // boot timeout, must
exceed pod boot time
+ private static final long T0 = 0L;
+
+ @Test
+ void requestedThreadsArePending() {
+ PendingRegistrations pending = new PendingRegistrations(TIMEOUT_MS);
+ pending.requested("r1", 4, T0);
+ pending.requested("r2", 4, T0);
+ Assertions.assertEquals(8, pending.pendingThreads(T0 + 1_000));
+ }
+
+ @Test
+ void registrationRemovesThePendingEntry() {
+ PendingRegistrations pending = new PendingRegistrations(TIMEOUT_MS);
+ pending.requested("r1", 4, T0);
+ pending.requested("r2", 4, T0);
+ pending.registered("r1");
+ Assertions.assertEquals(4, pending.pendingThreads(T0 + 1_000));
+ }
+
+ @Test
+ void requestFailureRemovesThePendingEntryImmediately() {
+ PendingRegistrations pending = new PendingRegistrations(TIMEOUT_MS);
+ pending.requested("r1", 4, T0);
+ pending.failed("r1");
+ Assertions.assertEquals(0, pending.pendingThreads(T0 + 1_000));
+ }
+
+ @Test
+ void entriesExpireAfterTheBootTimeout() {
+ PendingRegistrations pending = new PendingRegistrations(TIMEOUT_MS);
+ pending.requested("r1", 4, T0);
+ // Within the timeout the phantom capacity is accepted: evicting a
legitimately booting pod
+ // would cause duplicate scale-ups, which is worse than a few conservative
rounds.
+ Assertions.assertEquals(4, pending.pendingThreads(T0 + TIMEOUT_MS - 1));
+ Assertions.assertEquals(0, pending.pendingThreads(T0 + TIMEOUT_MS));
+ }
+
+ @Test
+ void expiryIsPerEntry() {
+ PendingRegistrations pending = new PendingRegistrations(TIMEOUT_MS);
+ pending.requested("r1", 4, T0);
+ pending.requested("r2", 2, T0 + 60_000);
+ Assertions.assertEquals(2, pending.pendingThreads(T0 + TIMEOUT_MS));
+ }
+
+ @Test
+ void unknownResourceIdIsIgnored() {
+ PendingRegistrations pending = new PendingRegistrations(TIMEOUT_MS);
+ pending.requested("r1", 4, T0);
+ pending.registered("unknown");
+ pending.failed("also-unknown");
+ Assertions.assertEquals(4, pending.pendingThreads(T0 + 1_000));
+ }
+}
diff --git
a/amoro-common/src/main/java/org/apache/amoro/OptimizerProperties.java
b/amoro-common/src/main/java/org/apache/amoro/OptimizerProperties.java
index c69a8aa94..134811580 100644
--- a/amoro-common/src/main/java/org/apache/amoro/OptimizerProperties.java
+++ b/amoro-common/src/main/java/org/apache/amoro/OptimizerProperties.java
@@ -78,6 +78,12 @@ public class OptimizerProperties {
public static final int DYNAMIC_ALLOCATION_MAX_PARALLELISM_LIMIT = 1024;
+ /** @since 0.9.0 */
+ public static final String DYNAMIC_ALLOCATION_EXECUTOR_PARALLELISM =
+ "dynamic-allocation.executor-parallelism";
+
+ public static final int DYNAMIC_ALLOCATION_EXECUTOR_PARALLELISM_DEFAULT = 1;
+
/** @since 0.9.0 */
public static final String DYNAMIC_ALLOCATION_SCHEDULER_BACKLOG_TIMEOUT =
"dynamic-allocation.scheduler-backlog-timeout";
diff --git a/docs/admin-guides/managing-optimizers.md
b/docs/admin-guides/managing-optimizers.md
index 35a0d935f..5abc4faf1 100644
--- a/docs/admin-guides/managing-optimizers.md
+++ b/docs/admin-guides/managing-optimizers.md
@@ -288,8 +288,17 @@ The optimizer group supports the following properties:
| cache-max-total-size | All | No | 128mb
| Max
total size in optimier cache.
[...]
| cache-max-entry-size | All | No | 64mb
| Max
entry size in optimizer cache.
[...]
| cache-timeout | All | No | 10min
| Timeout
in optimizer cache.
[...]
-| min-parallelism | All | No | 0
| The
minimum total parallelism (CPU cores) that the optimizer group should maintain.
When the total cores of running optimizers fall below this value,
`OptimizerGroupKeeper` will automatically scale out new optimizers. Set to `0`
to disable auto-scaling. Note: The behavior of the auto-scaling mechanism is
controlled by the AMS-level configu [...]
+| min-parallelism | All | No | 0
|
Deprecated since 0.9.0 in favor of `dynamic-allocation.min-parallelism`; still
honored as a fallback. The minimum total parallelism (CPU cores) that the
optimizer group should maintain. When the total cores of running optimizers
fall below this value, `OptimizerGroupKeeper` will automatically scale out new
optimizers. Set to `0` to disable [...]
| shutdown-timeout-ms | All | No | 600000(10min)
|
Graceful shutdown timeout in milliseconds. On shutdown the optimizer waits up
to this long for in-progress tasks to complete before force-interrupting them.
For Kubernetes optimizers, the pod's `terminationGracePeriodSeconds` is derived
from this value plus a 30s buffer. For Flink optimizers, the effective wait is
additionally capped below [...]
+| dynamic-allocation.enabled | All | No | false
| Whether
to enable dynamic resource allocation (AIP-5) for this group: the group then
grows its optimizer count automatically in response to optimizing demand,
bounded by `dynamic-allocation.max-parallelism`. Not supported on
externally-registered optimizers. Manual optimizer operations
(scale-out/release via the dashboard) are not recommen [...]
+| dynamic-allocation.min-parallelism | All | No | 0
| Lower
bound on the group's total optimizer threads under dynamic allocation.
Supersedes the deprecated flat `min-parallelism`.
[...]
+| dynamic-allocation.max-parallelism | All | Yes (when enabled) | N/A
| Upper
bound on the group's total optimizer threads under dynamic allocation; must not
exceed 1024. Also configure Kubernetes `ResourceQuota`/`LimitRange` as the
authoritative cluster-side limit.
[...]
+| dynamic-allocation.executor-parallelism | All | No | 1
| Threads
per optimizer instance created by dynamic allocation (the scaling unit, like
Spark's `spark.executor.cores`). The floor and the cap must be reachable in
units of this size. For Kubernetes groups a value of 4–8 is recommended so
per-pod JVM overhead is shared across threads.
[...]
+| dynamic-allocation.scheduler-backlog-timeout | All | No | 1min
| How
long optimizing demand must persist before the first scale-out.
[...]
+| dynamic-allocation.sustained-backlog-timeout | All | No | 30s
|
Interval between subsequent scale-outs while demand persists; also the group's
scale evaluation cadence.
[...]
+| dynamic-allocation.executor-idle-timeout | All | No | 5min
| Idle
duration before an optimizer becomes a scale-down candidate (minimum 30s).
Scale-down lands in a later release.
[...]
+| dynamic-allocation.scale-down-cooldown | All | No | 1min
| Minimum
interval between scale-down removals. Scale-down lands in a later release.
[...]
+| dynamic-allocation.drain-timeout | All | No | 15min
|
Force-removal safety net for a draining optimizer during scale-down. Scale-down
lands in a later release.
[...]
| memory | Local | Yes | N/A
| The max
memory of JVM for local optimizer, in MBs.
[...]
| flink-conf.\<key\> | Flink | No | N/A
| Any
flink config options could be overwritten, priority is optimizing-group >
optimizing-container > flink-conf.yaml.
[...]
| spark-conf.\<key\> | Spark | No | N/A
| Any
spark config options could be overwritten, priority is optimizing-group >
optimizing-container > spark-defaults.conf.
[...]