Skip to content

[AMORO-4295] AIP-5 Phase 3+4: idle-driven scale-down with graceful drain for dynamic allocation groups - #4296

Open
j1wonpark wants to merge 7 commits into
apache:masterfrom
j1wonpark:dra-phase34-scaledown
Open

[AMORO-4295] AIP-5 Phase 3+4: idle-driven scale-down with graceful drain for dynamic allocation groups#4296
j1wonpark wants to merge 7 commits into
apache:masterfrom
j1wonpark:dra-phase34-scaledown

Conversation

@j1wonpark

Copy link
Copy Markdown
Contributor

Why are the changes needed?

Close #4295.

Third and fourth implementation phase of AIP-5: Dynamic Resource Allocation for Optimizer (#4191), building on the scale-up merged in #4272: per-optimizer idle tracking and idle-driven scale-down with graceful drain for dynamic-allocation-enabled groups. One PR because idle tracking's only consumer is the scale-down decision. Opt-in as before: groups without dynamic-allocation.enabled = true are unaffected.

One deliberate deviation from the AIP page: idle tracking is derived from the per-round load snapshot (per-token in-flight counts) instead of the sketched event-based OptimizerCounters. Several task-reclamation paths offer no service-level release hook (resetStaleTasksForThread, the suspending predication reclaiming timed-out tasks of live optimizers, process close), so an event counter would silently drift — and one leaked increment makes an optimizer permanently non-idle, i.e. a permanent pod leak. Snapshot-derived state is re-derived every round and immune to all of them, consistent with the state-derived position taken in #4272. The AIP page will be revised alongside this PR.

Drain is an efficiency layer only — correctness stays with the existing retry-on-expiry machinery. A draining optimizer stops receiving tasks (pollTask returns null), finishes in-flight work, and is removed; a drain-timeout force removal falls back to the existing orphan recovery.

Brief change log

  • OptimizingQueue.collectDynamicAllocationLoad() additionally aggregates per-token in-flight counts; recovered tasks keep their tokens, so the first post-restart snapshot is accurate.
  • DynamicAllocationState: observe() tracks last-busy time per token (new tokens are seeded idle, so an instance that never gets a task is still reclaimed); computeScaleDown() applies cooldown → idle filter → floor (registered − draining − candidate ≥ min-parallelism) → longest-idle selection, one per round, and runs only in rounds with no scale-up demand signal (wasDemandActive()). New validation: sustained-backlog-timeout ≤ executor-idle-timeout / 2 (observation resolution equals the keeper cadence).
  • Drain plumbing in DefaultOptimizingService: a pending-removal set with deadlines; pollTask blocks draining tokens at entry and re-checks after the internal poll returns (closes the parked long-poll race); executeRemoval follows the dashboard release order, tolerates a missing resource row (the [AMORO-4271] AIP-5 Phase 2: demand-driven scale-up for dynamic allocation groups #4272 boot path), and retries idempotently on transient failures. Only AMS-launched optimizers are candidates.
  • OptimizerScaleKeeper round order: drain progress (unconditional) → demand accounting excluding draining instances → idle observation → scale-up → scale-down. Evaluation is time-parameterized behind a @VisibleForTesting hook — our own validation forbids idle timeouts under 30s, so real-time tests are impossible.
  • Lifecycle: heartbeat-expiry unregistration clears drain state; unwatching or deleting a group lifts the poll blocks so no draining pod is starved.
  • Docs: the scale-down rows in managing-optimizers.md now describe the actual behavior instead of "lands in a later release".

Known trade-offs

  • Idle observation is round-granular: a task fitting entirely between two snapshots goes unseen — a frequent-but-bounded error, versus the rare-but-unbounded drift of an event counter.
  • All state is leader-local (consistent with [AMORO-4271] AIP-5 Phase 2: demand-driven scale-up for dynamic allocation groups #4272): failover means a delayed scale-down, never a lost task.
  • A heartbeat-expiry unregistration racing a removal in the same round can dip the group below its floor for one round; self-correcting, deliberately unguarded (untestable).

How was this patch tested?

  • Add some test cases that check the changes thoroughly including negative and positive cases if possible
    • TestComputeScaleDown (10), TestComputeScaleUp (+3), TestDynamicAllocationConfig (+2), TestOptimizingQueue (+2), TestDefaultOptimizingService (+5), TestOptimizerScaleKeeper (+7): idle observation semantics, floor/cooldown selection, the parked long-poll race, executeRemoval failure paths, drain-state cleanup, and idle scale-down end-to-end via injected rounds. Not covered: end-to-end busy-drain and drain-timeout against real running tasks, and a scale-to-zero round trip (each constituent decision is unit-covered).
  • Run test locally before making a pull request: full DRA-related suite green (160 tests, Phase 1/2 unmodified); spotless and checkstyle clean.

Documentation

  • Does this pull request introduce a new feature? (yes)
  • If yes, how is the feature documented? (docs — see the change log; no new configuration. The AIP-5 page will be revised alongside this PR; metrics docs land in the final observability phase)

…ion load snapshot

Scale-down needs to know which optimizer instances are busy, not only how
many threads are. Aggregate SCHEDULED/ACKED task counts per optimizer token
in the same scan collectDynamicAllocationLoad() already performs, so one
snapshot supplies both scale directions. Recovered tasks keep their token
across an AMS restart, which makes the very first snapshot after a restart
accurate without any rebuild step.

Signed-off-by: Jiwon Park <jpark92@outlook.kr>
… decision

Track per-token idle time by observation instead of event counters: each
keeper round feeds the task snapshot into observe(), which refreshes busy
timestamps, seeds first-seen tokens as idle (an instance that never
receives a task must still become removable), and prunes unregistered
ones. Event-maintained counters were rejected because three existing
paths reclaim assigned tasks without any service-level decrement hook
(queue-internal stale-ACKED resets, ack/exec-timeout reclaims of live
optimizers, and process close), each silently inflating a counter until
the instance can never look idle again.

computeScaleDown picks at most one candidate per round: the longest-idle
instance past executor-idle-timeout, rate-limited by scale-down-cooldown,
whose removal keeps registered-minus-draining threads at or above
min-parallelism. Draining threads count as already gone so consecutive
removals cannot pass the floor check together and land below the floor
once they all complete.

validate() now also requires sustained-backlog-timeout to be at most half
of executor-idle-timeout: the keeper cadence is the observation
resolution, and sampling slower than that misjudges an instance that
worked between samples as continuously idle.

Signed-off-by: Jiwon Park <jpark92@outlook.kr>
… removal

A draining optimizer's token enters a pending-removal set consulted twice
in pollTask: once on entry, and once after the queue poll returns, because
the entry check cannot stop a thread already parked in the queue's
long-poll — it may fetch a task after the drain began, and that task is
handed back (PLANNED again) instead of being assigned. touch/ack/complete
stay open so in-flight tasks finish normally.

executeRemoval releases the container resource, deletes the persisted row,
and unregisters. A missing resource row is handled by releasing through
the optimizer instance itself (it extends Resource and carries the
container identity): a pod that self-registered after a persist failure
has no row and never will, so treating that as a retryable error would
drain-block it forever while its heartbeat keeps it registered. A failed
container release keeps the drain state and retries on a later round —
Kubernetes deletion is idempotent.

The mock container used by the keeper tests now records released
resources and can simulate release failures.

Signed-off-by: Jiwon Park <jpark92@outlook.kr>
…eeper round

Each round now runs in a fixed order. Drain progress first and
unconditionally — a drain that completed or hit its deadline converts to
a removal even in rounds that scale up, or a busy drain would linger to
its full timeout while backlog persists. Draining instances are then
accounted as already gone on both sides of the demand math: leaving
their threads in the capacity undercounts demand by up to their thread
count, and leaving their tasks in the load keeps the future-demand
signal (busy >= effective) from ever firing mid-drain. Idle observation
runs every round including scale-up ones, so an instance busy through a
burst does not come out of it looking idle since before the burst began.

Scale-down runs only in rounds with no demand signal at all — including
demand still held back by the backlog gate, which computeScaleUp now
exposes: removing a warm instance right before the gate opens would
free exactly the capacity the next round re-requests. A selected victim
begins a graceful drain and is removed in the same round only if a
snapshot taken after the token entered the pending-removal set shows it
idle; the pre-insert snapshot may miss a task fetched by a long-poll
racing the drain start.

The keeper tests drive rounds with injected times because the validated
minimum executor-idle-timeout (30s) puts real idle waits beyond sane
test durations.

Signed-off-by: Jiwon Park <jpark92@outlook.kr>
…ation disable

Two lifecycle paths could strand a token in the pending-removal set. An
optimizer that dies mid-drain is unregistered by heartbeat expiry, and
its token can never be matched again (the replacement pod registers
under a fresh one), so unregisterOptimizer now clears the drain state.
And a group whose dynamic allocation is disabled mid-drain returns to
the legacy floor keeper, where a leftover drain block would starve the
still-running pod forever — unwatching the group re-admits its tokens
to task assignment.

Signed-off-by: Jiwon Park <jpark92@outlook.kr>
Signed-off-by: Jiwon Park <jpark92@outlook.kr>
…group property table

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 labels Jul 28, 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 type:docs Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Subtask]: AIP-5 Phase 3+4 — Idle-driven scale-down with graceful drain for dynamic allocation groups

1 participant