[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
Open
[AMORO-4295] AIP-5 Phase 3+4: idle-driven scale-down with graceful drain for dynamic allocation groups#4296j1wonpark wants to merge 7 commits into
j1wonpark wants to merge 7 commits into
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 = trueare 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 (
pollTaskreturnsnull), finishes in-flight work, and is removed; adrain-timeoutforce 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).DefaultOptimizingService: a pending-removal set with deadlines;pollTaskblocks draining tokens at entry and re-checks after the internal poll returns (closes the parked long-poll race);executeRemovalfollows 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.OptimizerScaleKeeperround order: drain progress (unconditional) → demand accounting excluding draining instances → idle observation → scale-up → scale-down. Evaluation is time-parameterized behind a@VisibleForTestinghook — our own validation forbids idle timeouts under 30s, so real-time tests are impossible.managing-optimizers.mdnow describe the actual behavior instead of "lands in a later release".Known trade-offs
How was this patch tested?
TestComputeScaleDown(10),TestComputeScaleUp(+3),TestDynamicAllocationConfig(+2),TestOptimizingQueue(+2),TestDefaultOptimizingService(+5),TestOptimizerScaleKeeper(+7): idle observation semantics, floor/cooldown selection, the parked long-poll race,executeRemovalfailure paths, drain-state cleanup, and idle scale-down end-to-end via injected rounds. Not covered: end-to-end busy-drain anddrain-timeoutagainst real running tasks, and a scale-to-zero round trip (each constituent decision is unit-covered).Documentation