Skip to content

[AMORO-4271] AIP-5 Phase 2: demand-driven scale-up for dynamic allocation groups#4272

Open
j1wonpark wants to merge 10 commits into
apache:masterfrom
j1wonpark:dra-phase2-scaleup
Open

[AMORO-4271] AIP-5 Phase 2: demand-driven scale-up for dynamic allocation groups#4272
j1wonpark wants to merge 10 commits into
apache:masterfrom
j1wonpark:dra-phase2-scaleup

Conversation

@j1wonpark

Copy link
Copy Markdown
Contributor

Why are the changes needed?

Close #4271.

This is the second implementation phase of AIP-5: Dynamic Resource Allocation for Optimizer (#4191), building on the configuration foundation merged in #4254. It adds demand-driven scale-up for dynamic-allocation-enabled groups; scale-down lands in later phases. Everything remains opt-in: groups without dynamic-allocation.enabled = true are unaffected.

Why a dedicated keeper. The existing OptimizerGroupKeeper checks a group at optimizer-group.min-parallelism-check-interval × consecutive attempts — minutes apart by design, which is right for its floor duty but makes the DRA backlog timeouts (scheduler-backlog-timeout 1min, sustained-backlog-timeout 30s) physically unreachable. OptimizerScaleKeeper reuses the same AbstractKeeper infrastructure (HA leader gating, delay queue, lifecycle) but each group's scale evaluations run at its own sustained-backlog-timeout. The legacy keeper skips DRA groups (continuing the implicit isEffectivelyEnabled pattern from the Phase 1 review) while keeping them under watch, so it resumes floor duty if DRA is disabled later.

Why a "future demand" signal. Planning is only driven by pollTask: with zero optimizers nothing polls, planning never runs, and PLANNED tasks never materialize — so a task-based signal alone can never wake a scaled-to-zero group. PENDING tables are the one signal observable without optimizers; they ignite a single probe instance, and once planning materializes tasks, the task-based signal takes over.

Brief change log

  • OptimizerProperties / DynamicAllocationConfig: new dynamic-allocation.executor-parallelism (default 1) — the scaling unit is one homogeneous K-thread optimizer instance (the Spark executor model). Validation: 1 ≤ executor-parallelism ≤ max-parallelism, and the floor must be reachable in K units (ceil(min/K)×K ≤ max) — otherwise the group would silently sit below its floor forever.

  • OptimizerScaleKeeper (new, in DefaultOptimizingService): owns both the floor and the demand scaling of DRA groups. Groups are watched on create, startup load, and update (covering enabling DRA on an existing group at runtime). Per evaluation round:

    • Floor: min-parallelism deficits are satisfied immediately in K-thread units, resetting any demand-phase timers so a recovery does not fire through a stale gate.
    • Immediate demand: busy + serviceable PLANNED > effective threads scales out with an exponential ramp (1, 2, 4, 8 instances) clamped to the actual need — Spark ExecutorAllocationManager semantics, including resetting the ramp when the clamp binds or demand clears.
    • Future demand: pending tables while every thread is busy add one probe instance (pending tables are not quantified demand before planning).
    • 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. The max-parallelism cap always wins.
    • Failure paths: a transient group-read failure retries instead of being treated as deletion; an enabled group whose queue is momentarily absent (delete/recreate racing the config watcher) is transient too; group deletion cleans all keeper state immediately.
  • PendingRegistrations (new): requested-but-unregistered capacity counts toward effective threads so a booting pod is not re-requested every round. Registration is optimizer-driven and heartbeat expiry only starts after it, so entries carry their own boot deadline (3 minutes). A synchronously failed request is dropped and retried; a pod whose requestResource succeeded but whose persistence failed keeps its entry (it will self-register) instead of being re-requested as a duplicate.

  • OptimizingQueue.collectDynamicAllocationLoad() (new): snapshots the demand side — busy threads (SCHEDULED and ACKED: a thread is occupied from assignment, not from ack), quota-mode-aware serviceable PLANNED tasks (a proportional quota ≤ 1 counts its whole backlog since scaling raises its ceil(quota × availableCore) limit; an absolute quota > 1 counts only free slots), and PENDING tables. Table runtimes are copied under tableLock via a new SchedulingPolicy.snapshotTableRuntimes() rather than iterating the lock-guarded map from another thread.

  • Planning-bound guard: idle registered threads while tables wait as PENDING and nothing is PLANNED means the bottleneck is the serialized planning (optimizer.max-planning-parallelism), not thread capacity — scaling out would only add idle threads. The keeper warns (once per episode, after the condition persists across two evaluations) naming the config to raise, and does not scale.

  • Docs: the optimizer group property table now documents all dynamic-allocation.* properties (as promised in the Phase 1 PR for the phase that first exposes runtime behavior), marks the flat min-parallelism deprecated, and recommends executor-parallelism 4–8 for Kubernetes groups.

Known trade-offs

  • Proportional-quota convergence: counting a proportional table's whole backlog can over-provision toward E ≈ planned/(1−q) while the table only occupies ceil(q×E) threads; the excess is reclaimed by idle-timeout when scale-down lands (Phase 4), and max-parallelism is the operator's bound until then.
  • Manual optimizer operations: a pod created via the dashboard scale-out endpoint is invisible to the boot-window accounting, so the keeper may briefly scale on top of it (bounded; corrects once it registers). Manual operations on DRA groups are documented as not recommended.
  • HA failover: boot-window accounting is leader-local, so a new leader can re-request capacity the old leader already requested within the boot window — bounded to one duplicate round and self-correcting on registration.
  • Boot deadline: 3 minutes is sized for Kubernetes pods (the AIP's primary target); slow container types may exceed it and briefly double-request, reclaimed by idle-timeout in Phase 4. Making it configurable is left to a follow-up to avoid growing the public config surface here.

How was this patch tested?

  • Add some test cases that check the changes thoroughly including negative and positive cases if possible
    • TestComputeScaleUp (15): floor immediacy and cap clamping, backlog/sustained timing gates, exponential ramp including reset-on-clamp-binding, trickle filtering, cold-start ignition, future-demand single-probe semantics, stale-gate reset after floor recovery.
    • TestDynamicAllocationState (17): quota-mode-aware serviceable counting (including fractional absolute quotas), SCHEDULED-occupies-thread semantics, planning-bound predicate.
    • TestPendingRegistrations (6): boot-deadline expiry per entry, registration/failure removal.
    • TestDynamicAllocationConfig (29): executor-parallelism parsing and validation including the unreachable-floor rejection.
    • TestOptimizerScaleKeeper (4, integration): K-unit floor satisfaction (vs. the legacy single deficit-sized instance), boot-window duplicate prevention (the legacy path re-requests the full deficit every round), failure retry, runtime enablement.
    • TestOptimizingQueue#testCollectDynamicAllocationLoad: the PENDING-table cold-start signal and the SCHEDULED transition on a real table.
  • Run test locally before making a pull request: full amoro-common and amoro-ams suites pass (the AMS suite takes ~34 min); spotless and checkstyle clean.

Documentation

  • Does this pull request introduce a new feature? (yes)
  • If yes, how is the feature documented? (docs — the optimizer group property table in docs/admin-guides/managing-optimizers.md; the AIP-5 page will be revised alongside this PR to document dynamic-allocation.executor-parallelism, which replaces the page's incorrect reference to execution-parallel)

j1wonpark added 10 commits July 11, 2026 21:16
…sm 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>
…ed, 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>
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>
… 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>
…tion 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>
…ad 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>
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>
…idation 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>
…ing 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>
…perties

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>
@github-actions github-actions Bot added type:docs Improvements or additions to documentation module:ams-server Ams server module module:common labels Jul 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

module:ams-server Ams server module module:common type:docs Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Subtask]: AIP-5 Phase 2 — Demand-driven scale-up for dynamic allocation groups

1 participant