This is an automated email from the ASF dual-hosted git repository.

wilfred-s pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/yunikorn-core.git


The following commit(s) were added to refs/heads/master by this push:
     new 2a271ec1 [YUNIKORN-3352] Replace the O(n) ask-priority rescan with a 
pending histogram (#1122)
2a271ec1 is described below

commit 2a271ec187eba9a79a24be5496c5cf2a46c6ea56
Author: Tigerquoll <[email protected]>
AuthorDate: Thu Aug 20 19:34:42 2026 +1000

    [YUNIKORN-3352] Replace the O(n) ask-priority rescan with a pending 
histogram (#1122)
    
    updateAskMaxPriority recomputed an application's highest pending ask 
priority by
    rescanning every one of its asks, and it ran on every allocate, on every
    deallocate and on every ask removal whose priority was at or above the 
current
    maximum. Over a scheduling cycle that is quadratic in the number of asks an
    application holds.
    
    Replace the rescan with a small histogram of pending asks per priority 
value,
    maintained incrementally: incPendingPriority when an ask becomes pending,
    decPendingPriority when it leaves the pending set. askMaxPriority is then 
O(1) in
    the common case, and only a bucket emptying at the current maximum costs a 
walk -
    over the number of distinct priorities, not the number of asks. The queue is
    notified only when the value actually changes, where the rescan 
re-published it
    unconditionally.
    
    No scheduling behaviour changes: the golden decision-trace tests added in
    YUNIKORN-3338 reproduce byte-for-byte, goldens not regenerated.
    
    Generated by Author with assistance from Claude Code.
    
    Signed-off-by: Tigerquoll <[email protected]>
    
    Closes: #1122
    
    Signed-off-by: Wilfred Spiegelenburg <[email protected]>
---
 pkg/scheduler/objects/application.go               | 118 +++--
 pkg/scheduler/objects/application_property_test.go | 517 +++++++++++++++++++++
 pkg/scheduler/objects/application_test.go          | 194 ++++++++
 3 files changed, 798 insertions(+), 31 deletions(-)

diff --git a/pkg/scheduler/objects/application.go 
b/pkg/scheduler/objects/application.go
index 6d630423..71decadc 100644
--- a/pkg/scheduler/objects/application.go
+++ b/pkg/scheduler/objects/application.go
@@ -115,7 +115,8 @@ type Application struct {
        rejectedMessage      string                      // If the application 
is rejected, save the rejected message
        stateLog             []*StateLogEntry            // state log for this 
application
        placeholderData      map[string]*PlaceholderData // track placeholder 
and gang related info
-       askMaxPriority       int32                       // highest priority 
value of outstanding asks
+       askMaxPriority       int32                       // highest priority 
value of outstanding (pending) asks
+       pendingPriorities    map[int32]int               // count of pending 
(non-allocated) asks per priority value
        hasPlaceholderAlloc  bool                        // Whether there is at 
least one allocated placeholder
        runnableInQueue      bool                        // whether the 
application is runnable/schedulable in the queue. Default is true.
        runnableByUserLimit  bool                        // whether the 
application is runnable/schedulable based on user/group quota. Default is true.
@@ -155,6 +156,7 @@ func NewApplication(siApp *si.AddApplicationRequest, ugi 
security.UserGroup, eve
                rejectedMessage:       "",
                stateLog:              make([]*StateLogEntry, 0),
                askMaxPriority:        configs.MinPriority,
+               pendingPriorities:     make(map[int32]int),
                sortedRequests:        sortedRequests{},
                sendStateChangeEvents: true,
                runnableByUserLimit:   true,
@@ -597,6 +599,7 @@ func (sa *Application) removeAsksInternal(allocKey string, 
detail si.EventRecord
                }
                sa.requests = make(map[string]*Allocation)
                sa.sortedRequests = sortedRequests{}
+               sa.pendingPriorities = make(map[int32]int)
                sa.askMaxPriority = configs.MinPriority
                sa.queue.UpdateApplicationPriority(sa.ApplicationID, 
sa.askMaxPriority)
        } else {
@@ -612,13 +615,12 @@ func (sa *Application) removeAsksInternal(allocKey 
string, detail si.EventRecord
                                deltaPendingResource = 
ask.GetAllocatedResource()
                                sa.pending = resources.Sub(sa.pending, 
deltaPendingResource)
                                sa.pending.Prune()
+                               // the removed ask was pending: drop it from 
the priority histogram
+                               sa.removeFromPriorities(ask.GetPriority())
                        }
                        delete(sa.requests, allocKey)
                        sa.sortedRequests.remove(ask)
                        sa.appEvents.SendRemoveAskEvent(sa.ApplicationID, 
ask.allocationKey, ask.GetAllocatedResource(), detail)
-                       if priority := ask.GetPriority(); priority >= 
sa.askMaxPriority {
-                               sa.updateAskMaxPriority()
-                       }
                }
        }
        // clean up the queue pending resources
@@ -664,6 +666,9 @@ func (sa *Application) AddAllocationAsk(ask *Allocation) 
error {
        var oldAskResource *resources.Resource = nil
        if oldAsk := sa.requests[ask.GetAllocationKey()]; oldAsk != nil && 
!oldAsk.IsAllocated() {
                oldAskResource = oldAsk.GetAllocatedResource().Clone()
+               // the old ask was pending and is being replaced: drop it from 
the priority histogram so the
+               // new ask's addAllocationAskInternal (via addToPriorities) 
nets correctly.
+               sa.removeFromPriorities(oldAsk.GetPriority())
        }
 
        // Check if we need to change state based on the ask added, there are 
two cases:
@@ -778,15 +783,68 @@ func (sa *Application) RecoverAllocationAsk(alloc 
*Allocation) {
        }
 }
 
+// addToPriorities records that an ask at priority p just became pending 
(added, or
+// deallocated back to pending). Call with sa.Lock() held.
+func (sa *Application) addToPriorities(p int32) {
+       sa.pendingPriorities[p]++
+       if p > sa.askMaxPriority {
+               sa.setAskMaxPriority(p)
+       }
+}
+
+// removeFromPriorities records that a pending ask at priority p just left the 
pending set
+// (allocated, or removed). Call with sa.Lock() held.
+func (sa *Application) removeFromPriorities(p int32) {
+       // n is the count the band is left with. A band is removed as soon as 
it empties, so a band that
+       // is present always holds at least one ask - n < 0 therefore means 
there was no band at all,
+       // not a band that happened to be sitting at zero.
+       switch n := sa.pendingPriorities[p] - 1; {
+       case n < 0:
+               // nothing to decrement: an inc/dec pair was missed somewhere. 
Report it rather than
+               // absorbing it, because askMaxPriority is only correct while 
those pairs match.
+               log.Log(log.SchedApplication).DPanic("no pending priority band 
to decrement",
+                       zap.String("appID", sa.ApplicationID),
+                       zap.Int32("priority", p))
+       case n > 0:
+               // other pending asks remain at this priority, so the band 
survives and the maximum -
+               // whichever band it points at - cannot have moved.
+               sa.pendingPriorities[p] = n
+       default:
+               // that was the last pending ask at this priority, so the band 
goes away
+               delete(sa.pendingPriorities, p)
+               if p != sa.askMaxPriority {
+                       // the band that got deleted was not the max priority, 
so we don't change the existing
+                       // cached value
+                       return
+               }
+               // the top band emptied, so the maximum has to be recomputed. 
This walks the bands, not the
+               // asks: O(distinct priorities in use), not O(number of asks). 
An empty map yields
+               // MinPriority, which is what the full rescan this replaced 
also produced with nothing
+               // pending.
+               newMax := configs.MinPriority
+               for band := range sa.pendingPriorities {
+                       newMax = max(newMax, band)
+               }
+               sa.setAskMaxPriority(newMax)
+       }
+}
+
+// setAskMaxPriority updates askMaxPriority and only propagates to the queue 
when the value
+// actually changes. Call with sa.Lock() held.
+func (sa *Application) setAskMaxPriority(v int32) {
+       if v == sa.askMaxPriority {
+               return
+       }
+       sa.askMaxPriority = v
+       sa.queue.UpdateApplicationPriority(sa.ApplicationID, v)
+}
+
 func (sa *Application) addAllocationAskInternal(ask *Allocation) {
        sa.requests[ask.GetAllocationKey()] = ask
 
-       // update app priority
-       allocated := ask.IsAllocated()
-       priority := ask.GetPriority()
-       if !allocated && priority > sa.askMaxPriority {
-               sa.askMaxPriority = priority
-               sa.queue.UpdateApplicationPriority(sa.ApplicationID, 
sa.askMaxPriority)
+       // update app priority: a recovered (already allocated) ask must never 
enter the pending histogram
+       if !ask.IsAllocated() {
+               sa.addToPriorities(ask.GetPriority())
        }
 
        if ask.IsPlaceholder() {
@@ -862,10 +920,8 @@ func (sa *Application) allocateAsk(ask *Allocation) 
(*resources.Resource, error)
                return nil, fmt.Errorf("unable to allocate previously allocated 
ask %s on app %s", ask.GetAllocationKey(), sa.ApplicationID)
        }
 
-       if ask.GetPriority() >= sa.askMaxPriority {
-               // recalculate downward
-               sa.updateAskMaxPriority()
-       }
+       // the ask just left the pending set
+       sa.removeFromPriorities(ask.GetPriority())
 
        delta := ask.GetAllocatedResource()
        sa.pending = resources.Sub(sa.pending, delta)
@@ -881,11 +937,19 @@ func (sa *Application) deallocateAsk(ask *Allocation) 
(*resources.Resource, erro
                return nil, fmt.Errorf("unable to deallocate pending ask %s on 
app %s", ask.GetAllocationKey(), sa.ApplicationID)
        }
 
-       askPriority := ask.GetPriority()
-       if askPriority > sa.askMaxPriority {
-               // increase app priority
-               sa.askMaxPriority = askPriority
-               sa.queue.UpdateApplicationPriority(sa.ApplicationID, 
askPriority)
+       // The ask returns to the pending set, but only if it still IS this 
application's ask: an ask that
+       // has already been dropped from sa.requests must not be counted again. 
That is reachable today:
+       // removeAsksInternal("") wipes sa.requests while leaving 
sa.allocations intact until the shim
+       // confirms the releases, and a release arriving in that window reaches 
RollbackAllocation, which
+       // finds the entry in sa.allocations and deallocates it. The identity 
comparison (not just a
+       // presence check) also covers a stale ask object that has since been 
replaced by a new ask under
+       // the same key.
+       // This matches the converged behaviour of the full rescan this change 
replaced:
+       // updateAskMaxPriority derived the max by scanning sa.requests, so an 
ask absent from sa.requests
+       // never influenced it. Without the guard that pre-existing accounting 
drift would turn into a
+       // permanent leak in the incremental histogram instead.
+       if sa.requests[ask.GetAllocationKey()] == ask {
+               sa.addToPriorities(ask.GetPriority())
        }
 
        delta := ask.GetAllocatedResource()
@@ -2075,18 +2139,6 @@ func (sa *Application) 
removeAllocationInternal(allocationKey string, releaseTyp
        return alloc
 }
 
-func (sa *Application) updateAskMaxPriority() {
-       value := configs.MinPriority
-       for _, v := range sa.requests {
-               if v.IsAllocated() {
-                       continue
-               }
-               value = max(value, v.GetPriority())
-       }
-       sa.askMaxPriority = value
-       sa.queue.UpdateApplicationPriority(sa.ApplicationID, value)
-}
-
 func (sa *Application) hasZeroAllocations() bool {
        return resources.IsZero(sa.pending) && 
resources.IsZero(sa.allocatedResource)
 }
@@ -2282,6 +2334,10 @@ func (sa *Application) GetAskMaxPriority() int32 {
 func (sa *Application) cleanupAsks() {
        sa.requests = make(map[string]*Allocation)
        sa.sortedRequests = nil
+       // a Failed app can still hold pending asks: reset the histogram or the 
consistency check would
+       // fire on terminal apps.
+       sa.pendingPriorities = make(map[int32]int)
+       sa.askMaxPriority = configs.MinPriority
 }
 
 func (sa *Application) cleanupTrackedResource() {
diff --git a/pkg/scheduler/objects/application_property_test.go 
b/pkg/scheduler/objects/application_property_test.go
new file mode 100644
index 00000000..36d23f30
--- /dev/null
+++ b/pkg/scheduler/objects/application_property_test.go
@@ -0,0 +1,517 @@
+/*
+ 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 objects
+
+import (
+       "fmt"
+       "math/rand"
+       "sort"
+       "strconv"
+       "testing"
+       "time"
+
+       "gotest.tools/v3/assert"
+
+       "github.com/apache/yunikorn-core/pkg/common/configs"
+       "github.com/apache/yunikorn-core/pkg/common/resources"
+       siCommon "github.com/apache/yunikorn-scheduler-interface/lib/go/common"
+       "github.com/apache/yunikorn-scheduler-interface/lib/go/si"
+)
+
+// TestApplicationPropertyFuzzHistogram drives an Application through a long 
randomized sequence
+// of the real ask-management entry points (AddAllocationAsk, AllocateAsk, 
DeallocateAsk,
+// RemoveAllocationAsk, RecoverAllocationAsk, AddAllocation, 
RollbackAllocation and FSM-to-Failed
+// cleanup) and after every single step verifies that the incrementally 
maintained
+// askMaxPriority/pendingPriorities bookkeeping matches a reference model 
rebuilt from a simple set
+// of maps tracked alongside the application.
+//
+// The point is coverage of state combinations rather than of entry points: 
TestMaxAskPriority pins
+// the handful of transitions that are easy to reason about by hand, while 
this test reaches the
+// interleavings that are not - a replaced ask whose priority differs from the 
one it displaces, a
+// rollback of an allocation whose ask has already been dropped, an allocate 
that empties the top
+// priority bucket while lower buckets still hold pending asks. Those are 
precisely the cases where
+// incremental bookkeeping and a full rescan can disagree.
+func TestApplicationPropertyFuzzHistogram(t *testing.T) {
+       // A "ghost" rollback (case 7 reverting an allocation whose ask 
sa.requests no longer holds) needs
+       // a specific remove-then-release interleaving, so it is rare enough 
that individual seeds
+       // legitimately see none - measured, 6 of the 20 seeds below produce 
zero. Assert that coverage
+       // over the whole seed set rather than per seed: that still fails 
loudly if a change stops the
+       // fuzzer reaching the state at all, without making the test flaky on 
the seeds that never do.
+       totalGhostRollbacks := 0
+       for seed := int64(0); seed < 20; seed++ {
+               t.Run(fmt.Sprintf("seed-%d", seed), func(t *testing.T) {
+                       totalGhostRollbacks += runPropertyFuzz(t, seed)
+               })
+       }
+       assert.Assert(t, totalGhostRollbacks > 0, "no seed ever rolled back an 
ask missing from sa.requests")
+}
+
+// runPropertyFuzz executes one seeded run and returns the number of ghost 
rollbacks it performed, so
+// the caller can assert that coverage across the whole seed set.
+func runPropertyFuzz(t *testing.T, seed int64) int { //nolint:funlen
+       t.Helper()
+
+       // Pin the Completing->Completed state timer to an effectively-infinite 
duration for the
+       // duration of this run. enter_Completing (application_state.go) arms a 
real time.AfterFunc
+       // timer (completingTimeout, default 30s) that - completely 
independently of this goroutine's
+       // step loop - fires HandleApplicationEvent(CompleteApplication) and, 
on reaching Completed,
+       // app.cleanupAsks() wipes sa.requests/sortedRequests/pendingPriorities 
out from under this
+       // test. Under normal speed this fuzz loop finishes in a couple of 
seconds so the default 30s
+       // would not fire, but on a loaded or CI machine it could - and if it 
does, the wipe is
+       // invisible to the reference model,
+       // producing exactly the kind of run-dependent, seed-varying divergence 
this test exists to
+       // catch. Neutralizing the timer removes that residual, 
timing-dependent nondeterminism source
+       // entirely rather than relying on the test finishing "fast enough".
+       SetCompletingTimeout(time.Hour)
+       defer SetCompletingTimeout(30 * time.Second) // restore the documented 
production default
+
+       // AddAllocation (case 6) charges the confirmed allocation to the 
process-global user manager
+       // (application.go addAllocationInternal -> incUserResourceUsage) and 
only RollbackAllocation
+       // gives it back, so any allocation still confirmed when this run ends 
leaves usage attributed to
+       // the shared getTestUserGroup() user. Sibling tests in this package 
assert exact user/group
+       // totals (assertUserGroupResource in utilities_test.go), so hand the 
trackers back clean rather
+       // than leaking this run's fuzz totals into whatever test happens to 
run next.
+       defer setupUGM()
+
+       rng := rand.New(rand.NewSource(seed)) //nolint:gosec // deterministic 
PRNG is the point: reproducible fuzzing
+       appID := fmt.Sprintf("fuzz-app-%d", seed)
+       app := newApplication(appID, "default", "root.default")
+       queue, err := createRootQueue(nil)
+       assert.NilError(t, err, "queue create failed")
+       app.queue = queue
+
+       res := 
resources.NewResourceFromMap(map[string]resources.Quantity{"first": 1})
+       // nextCreationTime hands out a strictly increasing, deterministic 
"creationTime" SI allocation
+       // tag value to every ask this run constructs (see newFuzzAsk). Without 
this,
+       // NewAllocationFromSI (allocation.go) falls back to time.Now() 
whenever the tag is absent,
+       // which makes every Allocation's createTime - and therefore its 
position among LessThan ties in
+       // sortedRequests - depend on wall-clock time instead of on the 
(deterministic) sequence of
+       // operations this fuzzer performs. Pinning it removes that dependency 
so a fixed seed produces
+       // bit-for-bit identical Allocation state on every run.
+       nextCreationTime := int64(0)
+
+       // keyPriority records the priority assigned to every ask key ever 
created (in AddAllocationAsk
+       // or RecoverAllocationAsk); priority is immutable per ask for the 
lifetime of the key so this
+       // map is only ever added to (or wiped wholesale on clear-all/cleanup), 
never mutated in place.
+       keyPriority := make(map[string]int32)
+       // pendingKeys is the reference model of what the pending histogram 
should contain: keys that are
+       // currently pending (added or deallocated back to pending, and NOT yet 
allocated, removed, or
+       // recovered-as-allocated).
+       pendingKeys := make(map[string]bool)
+       // allocatedKeys is the reference model of keys that are currently 
allocated (via AllocateAsk or
+       // RecoverAllocationAsk) and not yet deallocated or removed.
+       allocatedKeys := make(map[string]bool)
+       // confirmedKeys mirrors sa.allocations: keys handed to AddAllocation 
and not yet removed from
+       // sa.allocations again (only a successful RollbackAllocation does that 
here - application.go
+       // ~line 886). Note that it is deliberately NOT wiped by the 
clear-all/Failed cleanup branches in
+       // case 5: removeAsksInternal and cleanupAsks only ever touch 
sa.requests, so a stale entry does
+       // survive in sa.allocations there and the model must say so too.
+       confirmedKeys := make(map[string]bool)
+       nextKey := 0
+
+       // Coverage high-water marks: proof this fuzz run actually reaches 
non-trivial states rather
+       // than passing trivially by never exercising the product. Checked at 
the end of this function.
+       maxPending, maxAllocated, maxDistinctPendingPriorities := 0, 0, 0
+       // successfulRollbacks counts RollbackAllocation calls that actually 
returned without error, i.e.
+       // that really ran deallocateAsk. A rejected call (wrong app state, 
unknown key) mutates nothing,
+       // so counting attempts would let this operation decay into a no-op 
unnoticed.
+       successfulRollbacks := 0
+       // ghostRollbacks counts the subset of those that rolled back an ask 
sa.requests no longer holds
+       // (case 7); replacedAsks counts the AddAllocationAsk calls that took 
the replace-existing-ask
+       // branch (case 8). Both are narrow branches that a small change to the 
candidate filters could
+       // stop reaching entirely, so both are asserted - replacedAsks per run 
at the end of this
+       // function, ghostRollbacks over the whole seed set by the caller.
+       ghostRollbacks := 0
+       replacedAsks := 0
+
+       const steps = 5000
+       for i := 0; i < steps; i++ {
+               switch rng.Intn(9) {
+               case 0: // AddAllocationAsk
+                       key := fmt.Sprintf("ask-%d-%d", seed, nextKey)
+                       nextKey++
+                       priority := int32(rng.Intn(11) - 5) //nolint:gosec // 
bounded to -5..5, no overflow
+                       placeholder := rng.Intn(2) == 0
+                       nextCreationTime++
+                       var ask *Allocation
+                       if placeholder {
+                               ask = newFuzzAsk(key, appID, "tg-"+key, res, 
true, priority, "", nextCreationTime)
+                       } else {
+                               ask = newFuzzAsk(key, appID, "", res, false, 
priority, "", nextCreationTime)
+                       }
+                       addErr := app.AddAllocationAsk(ask)
+                       if addErr == nil {
+                               // unique keys every time, so this operation 
always takes the "brand new ask" path
+                               // through AddAllocationAsk; the 
replace-existing-ask branch is driven by case 8.
+                               keyPriority[key] = priority
+                               pendingKeys[key] = true
+                       }
+
+               case 1: // AllocateAsk
+                       if len(pendingKeys) > 0 {
+                               key := pickRandomKey(rng, pendingKeys)
+                               if _, allocErr := app.AllocateAsk(key); 
allocErr == nil {
+                                       delete(pendingKeys, key)
+                                       allocatedKeys[key] = true
+                               }
+                       }
+                       // if pendingKeys is empty this step is a no-op; the 
invariant assertion below still
+                       // runs for uniform per-step coverage even though 
nothing changed.
+
+               case 2: // DeallocateAsk
+                       if len(allocatedKeys) > 0 {
+                               key := pickRandomKey(rng, allocatedKeys)
+                               if _, deallocErr := app.DeallocateAsk(key); 
deallocErr == nil {
+                                       delete(allocatedKeys, key)
+                                       // the ask returns to pending at the 
SAME priority it was created with; priority
+                                       // is immutable, so keyPriority already 
has the right value.
+                                       pendingKeys[key] = true
+                               }
+                       }
+
+               case 3: // RemoveAllocationAsk(key) - remove a single existing 
key
+                       if key, ok := pickRandomExistingKey(rng, keyPriority); 
ok {
+                               app.RemoveAllocationAsk(key)
+                               // removeAsksInternal deletes the key from 
sa.requests unconditionally regardless of
+                               // whether it was pending or allocated 
(application.go ~line 592-611: the pending
+                               // histogram/queue accounting is only adjusted 
`if !ask.IsAllocated()`, but the
+                               // sa.requests delete always happens) - so the 
reference model must drop the key from
+                               // every tracking map regardless of its prior 
pending/allocated state.
+                               delete(pendingKeys, key)
+                               delete(allocatedKeys, key)
+                               delete(keyPriority, key)
+                       }
+
+               case 4: // RecoverAllocationAsk - build an already-allocated 
ask and recover it
+                       key := fmt.Sprintf("recovered-%d-%d", seed, nextKey)
+                       nextKey++
+                       priority := int32(rng.Intn(11) - 5) //nolint:gosec // 
bounded to -5..5, no overflow
+                       nextCreationTime++
+                       alloc := newFuzzAsk(key, appID, "", res, false, 
priority, "recovered-node", nextCreationTime)
+                       assert.Assert(t, alloc != nil, "NewAllocationFromSI 
unexpectedly returned nil")
+                       app.RecoverAllocationAsk(alloc)
+                       // a recovered ask is already allocated (NodeID set => 
allocated=true in
+                       // NewAllocationFromSI) so addAllocationAskInternal 
must NOT add it to the pending
+                       // histogram: record it only as allocated, never as 
pending. This is the exact invariant
+                       // this fuzz operation is probing.
+                       keyPriority[key] = priority
+                       allocatedKeys[key] = true
+
+               case 5: // occasionally clear-all or fail-and-cleanup, 
otherwise another RemoveAllocationAsk-ish no-op
+                       switch rng.Intn(50) {
+                       case 0: // ~1-in-50 (of the ~1-in-8 chance for case 5): 
RemoveAllocationAsk("") clear-all
+                               app.RemoveAllocationAsk("")
+                               pendingKeys = make(map[string]bool)
+                               allocatedKeys = make(map[string]bool)
+                               keyPriority = make(map[string]int32)
+                       case 1: // ~1-in-50: attempt to drive to Failed (only 
if not already terminal), which
+                               // triggers cleanupAsks(). FailApplication is 
only a valid FSM transition from
+                               // New/Accepted/Running (-> Failing) and then 
from Failing (-> Failed)
+                               // (application_state.go eventDesc()). If the 
app is currently sitting in some other
+                               // non-terminal state - most notably 
Completing, which removeAsksInternal drives it
+                               // into once sa.pending AND 
sa.allocatedResource are both zero (application.go ~line
+                               // 635) - both calls below are silently 
rejected by the FSM, sa.requests is left
+                               // completely untouched, and cleanupAsks() 
never runs. Since case 6 confirms
+                               // allocations, sa.allocatedResource is no 
longer always zero: a confirmed allocation
+                               // now holds the app out of Completing on its 
own, so which of the two branches below
+                               // is taken depends on the confirm/rollback 
history as well as on the pending asks.
+                               // Only non-placeholder allocations are 
confirmed, so getPlaceholderAllocations() is
+                               // always empty and the 
hasPlaceHolderAllocations part of that condition never
+                               // changes the outcome. The previous version of 
this test wiped the reference model
+                               // unconditionally here, which diverged from 
the product whenever that rejection
+                               // happened: the model would claim empty while 
the product still held real
+                               // pending/allocated asks. Gating the wipe on 
the actual post-attempt state (the
+                               // operation's real result) keeps the model 
faithful to what the product actually did.
+                               // cleanupAsks() only clears 
sa.requests/sortedRequests/pendingPriorities, so
+                               // confirmedKeys (sa.allocations) is 
intentionally left alone by both wipes below.
+                               // The extra successfulRollbacks condition 
exists because Failed is an absorbing
+                               // state - application_state.go has no 
transition out of it other than Expire - and
+                               // RollbackAllocation refuses to do anything 
outside Accepted/Running, so a run that
+                               // fails at step 30 spends its remaining 4970 
steps unable to exercise case 7 at all
+                               // (measured: seeds 7 and 12 failed at step 
31/55 and then had every one of their
+                               // ~500 rollback attempts rejected on state). 
Holding the failure back until the
+                               // rollback path has run once costs no Failed 
coverage worth having: this branch
+                               // still comes up roughly every 400 steps 
afterwards.
+                               if successfulRollbacks > 0 && !app.IsFailed() {
+                                       _ = 
app.HandleApplicationEvent(FailApplication) //nolint:errcheck // 
New/Accepted/Running -> Failing
+                                       _ = 
app.HandleApplicationEvent(FailApplication) //nolint:errcheck // Failing -> 
Failed, runs cleanupAsks()
+                                       if app.IsFailed() {
+                                               pendingKeys = 
make(map[string]bool)
+                                               allocatedKeys = 
make(map[string]bool)
+                                               keyPriority = 
make(map[string]int32)
+                                       }
+                               }
+                       default:
+                               // no-op filler so cases 5's sub-branches don't 
dominate step count; still asserted.
+                       }
+
+               case 6: // AddAllocation - confirm an allocated ask so it lands 
in sa.allocations
+                       // RollbackAllocation (case 7) looks its target up in 
sa.allocations, not in sa.requests
+                       // (application.go ~line 868), so without a 
confirmation step there is nothing for it to
+                       // roll back and the whole operation would be dead 
code. Production confirms by handing
+                       // AddAllocation the very same *Allocation that already 
sits in sa.requests - either the
+                       // ask that AllocateAsk just flipped to allocated 
(partition.go ~line 1319-1336) or the
+                       // one RecoverAllocationAsk just added (~line 
1263-1264) - so the fuzzer does the same and
+                       // passes app.GetAllocationAsk(key) rather than 
building a second Allocation for the same
+                       // key. That matters here: RollbackAllocation 
deallocates the object it found in
+                       // sa.allocations, and deallocateAsk only counts an ask 
as pending again when it still IS
+                       // the object sa.requests holds for that key - so a 
second Allocation built for the same
+                       // key would fail that identity check for a reason the 
product itself cannot produce,
+                       // making the assertions test fiction.
+                       if key, ok := pickRandomFilteredKey(rng, allocatedKeys, 
func(k string) bool {
+                               if confirmedKeys[k] {
+                                       // already in sa.allocations: 
confirming the same key twice would add its
+                                       // resource to sa.allocatedResource and 
to the user tracker a second time, which
+                                       // no production path does.
+                                       return false
+                               }
+                               // Placeholders take the other branch of 
addAllocationInternal (application.go ~line
+                               // 1926) which arms the placeholder execution 
timer through initPlaceholderTimer:
+                               // execTimeout is defaultPlaceholderTimeout for 
these apps (NewApplication ~line 174),
+                               // not zero, so the timer really is armed and 
timeoutPlaceholderProcessing can mutate
+                               // the state this test asserts on from another 
goroutine. That is exactly the
+                               // async-interference class the 
SetCompletingTimeout pin at the top of this function
+                               // removes, and confirming real allocations is 
all RollbackAllocation needs, so
+                               // placeholders are never confirmed.
+                               ask := app.GetAllocationAsk(k)
+                               return ask != nil && !ask.IsPlaceholder()
+                       }); ok {
+                               ask := app.GetAllocationAsk(key)
+                               assert.Assert(t, ask != nil, "seed=%d step=%d: 
allocated key %s missing from sa.requests", seed, i, key)
+                               app.AddAllocation(ask)
+                               // AddAllocation touches 
sa.allocations/allocatedResource and the app state only: it
+                               // leaves sa.requests, the pending histogram 
and sortedRequests alone, so the pending
+                               // and allocated reference sets are unchanged 
by design.
+                               confirmedKeys[key] = true
+                       }
+
+               case 7: // RollbackAllocation - revert a confirmed allocation 
back to a pending ask
+                       // This is the new C1 caller under test: 
RollbackAllocation runs deallocateAsk
+                       // (application.go ~line 875), which is the function 
that does addToPriorities, so a
+                       // still-tracked ask has to reappear in the pending 
histogram at its original priority.
+                       // Candidates deliberately also include confirmed keys 
whose ask is no longer in
+                       // sa.requests at all. That "ghost" state IS reachable 
in production: sa.allocations is
+                       // only cleaned up once the shim confirms the release, 
so every path that drops asks while
+                       // leaving sa.allocations behind opens a window for a 
SCHEDULING_FAILED_ON_RM release to
+                       // still route to RollbackAllocation - 
RemoveAllocationAsk on an allocated key, or
+                       // removeAsksInternal("") from 
timeoutPlaceholderProcessing case 2, which is reached from
+                       // Running for a soft gang whose ResumeApplication 
transition is rejected (that event is
+                       // only valid from New/Accepted, application_state.go 
~line 125) so the app stays Running
+                       // and remains rollback-eligible. deallocateAsk must 
not count such an ask as pending
+                       // again - nothing tracks it any more, so nothing would 
ever take it back out of the
+                       // histogram - so the reference model below only marks 
the key pending again when it is
+                       // still tracked.
+                       // The only combination excluded is a confirmed key 
that is still tracked but already
+                       // pending (deallocated without being removed): 
ask.deallocate() rejects that outright, so
+                       // picking it would only ever burn a step.
+                       if key, ok := pickRandomFilteredKey(rng, confirmedKeys, 
func(k string) bool {
+                               if allocatedKeys[k] {
+                                       return true
+                               }
+                               // ghost: sa.requests no longer holds the ask, 
but sa.allocations still does
+                               _, tracked := keyPriority[k]
+                               return !tracked
+                       }); ok {
+                               if _, rollbackErr := 
app.RollbackAllocation(key); rollbackErr == nil {
+                                       // only a successful call ran 
deallocateAsk: RollbackAllocation refuses outright
+                                       // unless the app is Accepted or 
Running (application.go ~line 864) and bails out
+                                       // before touching anything if the ask 
is no longer allocated, so a returned error
+                                       // means the product state is unchanged 
and the model must be too.
+                                       delete(confirmedKeys, key)
+                                       delete(allocatedKeys, key)
+                                       successfulRollbacks++
+                                       if _, tracked := keyPriority[key]; 
tracked {
+                                               // same as DeallocateAsk (case 
2): the ask goes back to pending at its
+                                               // immutable creation priority, 
which keyPriority already holds.
+                                               pendingKeys[key] = true
+                                       } else {
+                                               // ghost rollback: the ask is 
gone from sa.requests, so deallocateAsk must
+                                               // leave the pending histogram 
completely untouched.
+                                               ghostRollbacks++
+                                       }
+                               }
+                       }
+
+               case 8: // AddAllocationAsk re-using an existing key - the 
replace-existing-ask branch
+                       // AddAllocationAsk (application.go ~line 668) handles 
a key that is already in
+                       // sa.requests and still pending separately: it has to 
unwind the old ask from the
+                       // pending histogram before addAllocationAskInternal 
counts the replacement, or the key
+                       // is counted twice and its old priority never drops 
out again. Case 0 only ever mints
+                       // fresh keys, so without this operation that branch is 
never executed here at all.
+                       if len(pendingKeys) > 0 {
+                               key := pickRandomKey(rng, pendingKeys)
+                               existing := app.GetAllocationAsk(key)
+                               assert.Assert(t, existing != nil, "seed=%d 
step=%d: pending key %s missing from sa.requests", seed, i, key)
+                               priority := int32(rng.Intn(11) - 5) 
//nolint:gosec // bounded to -5..5, no overflow
+                               nextCreationTime++
+                               // keep the placeholder/task-group shape of the 
ask being replaced: production
+                               // re-sends the same pod's ask, it never turns 
a placeholder into a regular ask.
+                               replacement := newFuzzAsk(key, appID, 
existing.GetTaskGroup(), res, existing.IsPlaceholder(), priority, "", 
nextCreationTime)
+                               if replaceErr := 
app.AddAllocationAsk(replacement); replaceErr == nil {
+                                       // the replacement carries its OWN 
priority: the key stays pending but the model
+                                       // must account for it at the new 
priority from here on.
+                                       keyPriority[key] = priority
+                                       replacedAsks++
+                               }
+                       }
+               }
+
+               distinctPriorities := assertFuzzInvariants(t, app, keyPriority, 
pendingKeys, seed, i)
+
+               if len(pendingKeys) > maxPending {
+                       maxPending = len(pendingKeys)
+               }
+               if len(allocatedKeys) > maxAllocated {
+                       maxAllocated = len(allocatedKeys)
+               }
+               if distinctPriorities > maxDistinctPendingPriorities {
+                       maxDistinctPendingPriorities = distinctPriorities
+               }
+       }
+
+       // Guard against this fuzz run passing trivially (e.g. because a change 
elsewhere caused every
+       // operation to be rejected/no-op'd): confirm it actually drove the 
application through
+       // non-trivial pending/allocated/multi-priority states, so a real 
regression in the product's
+       // bookkeeping (e.g. removeFromPriorities) has states to be caught in.
+       t.Logf("seed=%d coverage: maxPending=%d maxAllocated=%d 
maxDistinctPendingPriorities=%d successfulRollbacks=%d ghostRollbacks=%d 
replacedAsks=%d", seed, maxPending, maxAllocated, maxDistinctPendingPriorities, 
successfulRollbacks, ghostRollbacks, replacedAsks)
+       assert.Assert(t, maxPending > 0, "seed=%d: fuzz run never observed any 
pending asks", seed)
+       assert.Assert(t, maxAllocated > 0, "seed=%d: fuzz run never observed 
any allocated asks", seed)
+       assert.Assert(t, maxDistinctPendingPriorities > 1, "seed=%d: fuzz run 
never observed a multi-priority pending histogram", seed)
+       // RollbackAllocation is rejected outright unless the app is Accepted 
or Running, so a change that
+       // leaves the app parked in some other state (or that stops case 6 
producing confirmed
+       // allocations) would silently turn case 7 into a no-op and stop 
covering deallocateAsk's new
+       // caller entirely, while every assertion above kept passing.
+       assert.Assert(t, successfulRollbacks > 0, "seed=%d: fuzz run never 
completed a RollbackAllocation", seed)
+       // The replace-existing-ask branch (case 8) must not double count the 
key in the histogram. It is
+       // only reached while pendingKeys is non-empty, so assert it really 
happened rather than trusting
+       // that condition to keep holding. The ghost-rollback count is returned 
instead of asserted here:
+       // it is too rare to demand per seed, see 
TestApplicationPropertyFuzzHistogram.
+       assert.Assert(t, replacedAsks > 0, "seed=%d: fuzz run never took the 
replace-existing-ask branch", seed)
+
+       return ghostRollbacks
+}
+
+// newFuzzAsk builds an Allocation directly from an si.Allocation (bypassing 
the shared
+// newAllocationAsk* test helpers in utilities_test.go, which leave the 
CreationTime tag unset and
+// so fall back to time.Now() in NewAllocationFromSI). creationTime is a 
caller-supplied, strictly
+// increasing logical clock value (see nextCreationTime in runPropertyFuzz) 
encoded into the
+// si.AllocationTags[siCommon.CreationTime] tag, which NewAllocationFromSI 
parses back out as the
+// Allocation's createTime - making construction order (and therefore 
sortedRequests tie-breaking)
+// fully deterministic for a fixed seed instead of depending on wall-clock 
time.
+func newFuzzAsk(key, appID, taskGroup string, res *resources.Resource, 
placeholder bool, priority int32, nodeID string, creationTime int64) 
*Allocation {
+       alloc := &si.Allocation{
+               AllocationKey:    key,
+               ApplicationID:    appID,
+               PartitionName:    "default",
+               ResourcePerAlloc: res.ToProto(),
+               TaskGroupName:    taskGroup,
+               Placeholder:      placeholder,
+               Priority:         priority,
+               NodeID:           nodeID,
+               AllocationTags:   map[string]string{siCommon.CreationTime: 
strconv.FormatInt(creationTime, 10)},
+       }
+       return NewAllocationFromSI(alloc)
+}
+
+// pickRandomKey returns a random key from a non-empty set of keys 
(map[string]bool).
+func pickRandomKey(rng *rand.Rand, keys map[string]bool) string {
+       list := make([]string, 0, len(keys))
+       for k := range keys {
+               list = append(list, k)
+       }
+       // sort for determinism: ranging a map yields a randomized order, so 
without this the same
+       // rng seed would not reproduce the same operation sequence, defeating 
seed-based replay.
+       sort.Strings(list)
+       return list[rng.Intn(len(list))]
+}
+
+// pickRandomFilteredKey returns a random key from keys for which keep() 
returns true, or ok=false if
+// none qualifies. It exists because the AddAllocation/RollbackAllocation 
operations are only defined
+// on a subset of a tracking set (an allocated key that is not confirmed yet, 
a confirmed key that is
+// still allocated); picking blindly and then dropping the step would make 
those operations fire far
+// less often than their share of the step budget suggests.
+// The candidate list is sorted before indexing for the same determinism 
reason as pickRandomKey.
+func pickRandomFilteredKey(rng *rand.Rand, keys map[string]bool, keep 
func(string) bool) (string, bool) {
+       list := make([]string, 0, len(keys))
+       for k := range keys {
+               if keep(k) {
+                       list = append(list, k)
+               }
+       }
+       if len(list) == 0 {
+               return "", false
+       }
+       sort.Strings(list)
+       return list[rng.Intn(len(list))], true
+}
+
+// pickRandomExistingKey returns a random key from keyPriority (any key ever 
created that hasn't
+// been fully removed yet), or ok=false if there are none.
+func pickRandomExistingKey(rng *rand.Rand, keyPriority map[string]int32) 
(string, bool) {
+       if len(keyPriority) == 0 {
+               return "", false
+       }
+       list := make([]string, 0, len(keyPriority))
+       for k := range keyPriority {
+               list = append(list, k)
+       }
+       // sort for determinism: ranging a map yields a randomized order (Go 
randomizes map iteration
+       // order per-process), so without this the same rng seed would pick a 
different key here on
+       // different runs, cascading into a different overall operation 
sequence (which ask gets
+       // removed determines the FSM state transitions that follow it) and 
defeating seed-based replay
+       // - this was the residual nondeterminism source alongside 
pickRandomKey above.
+       sort.Strings(list)
+       return list[rng.Intn(len(list))], true
+}
+
+// assertFuzzInvariants rebuilds the expected pending-ask histogram and max 
from the reference model
+// (keyPriority + pendingKeys) and compares it against the application's 
incrementally maintained
+// state. On any mismatch it fails with the seed and step number embedded in 
the message so the
+// failure is reproducible via `go test -run .../seed-<seed>`.
+// It returns the number of distinct pending priorities observed this step, so 
the caller can track
+// coverage high-water marks without a second pass over pendingKeys.
+func assertFuzzInvariants(t *testing.T, app *Application, keyPriority 
map[string]int32, pendingKeys map[string]bool, seed int64, step int) 
(distinctPendingPriorities int) {
+       t.Helper()
+
+       wantHistogram := make(map[int32]int)
+       wantMax := configs.MinPriority
+       for k := range pendingKeys {
+               p := keyPriority[k]
+               wantHistogram[p]++
+               if p > wantMax {
+                       wantMax = p
+               }
+       }
+
+       app.RLock()
+       gotMax := app.askMaxPriority
+       gotHistogram := make(map[int32]int, len(app.pendingPriorities))
+       for p, c := range app.pendingPriorities {
+               gotHistogram[p] = c
+       }
+       app.RUnlock()
+
+       assert.Equal(t, gotMax, wantMax, "seed=%d step=%d: askMaxPriority 
mismatch", seed, step)
+       assert.Equal(t, len(gotHistogram), len(wantHistogram), "seed=%d 
step=%d: pendingPriorities histogram size mismatch, got=%v want=%v", seed, 
step, gotHistogram, wantHistogram)
+       for p, wantCount := range wantHistogram {
+               assert.Equal(t, gotHistogram[p], wantCount, "seed=%d step=%d: 
pendingPriorities[%d] mismatch, got histogram=%v want histogram=%v", seed, 
step, p, gotHistogram, wantHistogram)
+       }
+
+       return len(wantHistogram)
+}
diff --git a/pkg/scheduler/objects/application_test.go 
b/pkg/scheduler/objects/application_test.go
index 70db068f..27732e04 100644
--- a/pkg/scheduler/objects/application_test.go
+++ b/pkg/scheduler/objects/application_test.go
@@ -2646,6 +2646,30 @@ func 
TestTryAllocatePreemptNodeWithReservationsNotPossibleToCancel(t *testing.T)
        assert.Assert(t, allocs[1].IsPreempted(), "alloc2 should have been 
preempted")
 }
 
+// assertMaxPriorityConsistent recomputes askMaxPriority (and the 
pendingPriorities histogram it is
+// derived from) via a full scan over sa.requests and compares against the 
incrementally maintained
+// values. This guards the incremental accounting that replaced the old O(N^2)
+// updateAskMaxPriority rescan.
+func assertMaxPriorityConsistent(t *testing.T, app *Application) {
+       t.Helper()
+       app.RLock()
+       defer app.RUnlock()
+       wantMax := configs.MinPriority
+       wantHistogram := make(map[int32]int)
+       for _, req := range app.requests {
+               if req.IsAllocated() {
+                       continue
+               }
+               wantMax = max(wantMax, req.GetPriority())
+               wantHistogram[req.GetPriority()]++
+       }
+       assert.Equal(t, app.askMaxPriority, wantMax, "askMaxPriority 
inconsistent with full scan over requests")
+       assert.Equal(t, len(app.pendingPriorities), len(wantHistogram), 
"pendingPriorities histogram size mismatch")
+       for p, count := range wantHistogram {
+               assert.Equal(t, app.pendingPriorities[p], count, 
"pendingPriorities count mismatch for priority %d", p)
+       }
+}
+
 func TestMaxAskPriority(t *testing.T) {
        app := newApplication(appID1, "default", "root.unknown")
        if app == nil || app.ApplicationID != appID1 {
@@ -2660,32 +2684,38 @@ func TestMaxAskPriority(t *testing.T) {
 
        // initial state
        assert.Equal(t, app.GetAskMaxPriority(), configs.MinPriority, "wrong 
default priority")
+       assertMaxPriorityConsistent(t, app)
 
        // p=10 added
        ask1 := newAllocationAskPriority("prio-10", appID1, res, 10)
        err = app.AddAllocationAsk(ask1)
        assert.NilError(t, err, "ask should have been updated on app")
        assert.Equal(t, app.GetAskMaxPriority(), int32(10), "wrong priority 
after adding p=10")
+       assertMaxPriorityConsistent(t, app)
 
        // p=5 added
        ask2 := newAllocationAskPriority("prio-5", appID1, res, 5)
        err = app.AddAllocationAsk(ask2)
        assert.NilError(t, err, "ask should have been added to app")
        assert.Equal(t, app.GetAskMaxPriority(), int32(10), "wrong priority 
after adding p=5")
+       assertMaxPriorityConsistent(t, app)
 
        // p=15 added
        ask3 := newAllocationAskPriority("prio-15", appID1, res, 15)
        err = app.AddAllocationAsk(ask3)
        assert.NilError(t, err, "ask should have been added to app")
        assert.Equal(t, app.GetAskMaxPriority(), int32(15), "wrong priority 
after adding p=15")
+       assertMaxPriorityConsistent(t, app)
 
        // p=10 removed
        app.RemoveAllocationAsk(ask1.GetAllocationKey())
        assert.Equal(t, app.GetAskMaxPriority(), int32(15), "wrong priority 
after removing p=10")
+       assertMaxPriorityConsistent(t, app)
 
        // p=15 removed
        app.RemoveAllocationAsk(ask3.GetAllocationKey())
        assert.Equal(t, app.GetAskMaxPriority(), int32(5), "wrong priority 
after removing p=15")
+       assertMaxPriorityConsistent(t, app)
 
        // re-add removed asks
        err = app.AddAllocationAsk(ask1)
@@ -2694,26 +2724,109 @@ func TestMaxAskPriority(t *testing.T) {
        assert.NilError(t, err, "ask should have been added to app")
 
        assert.Equal(t, app.GetAskMaxPriority(), int32(15), "wrong priority 
after re-adding asks")
+       assertMaxPriorityConsistent(t, app)
 
        // update to allocated for p=15
        _, err = app.AllocateAsk(ask3.GetAllocationKey())
        assert.NilError(t, err, "ask should have been updated")
        assert.Equal(t, app.GetAskMaxPriority(), int32(10), "wrong priority 
after updating p=15 to allocated")
+       assertMaxPriorityConsistent(t, app)
 
        // update to allocated for p=5
        _, err = app.AllocateAsk(ask2.GetAllocationKey())
        assert.NilError(t, err, "ask should have been updated")
        assert.Equal(t, app.GetAskMaxPriority(), int32(10), "wrong priority 
after updating p=5 to allocated")
+       assertMaxPriorityConsistent(t, app)
 
        // update to unallocated for p=5
        _, err = app.DeallocateAsk(ask2.GetAllocationKey())
        assert.NilError(t, err, "ask should have been updated")
        assert.Equal(t, app.GetAskMaxPriority(), int32(10), "wrong priority 
after updating p=5 to unallocated")
+       assertMaxPriorityConsistent(t, app)
 
        // update to unallocated for p=15
        _, err = app.DeallocateAsk(ask3.GetAllocationKey())
        assert.NilError(t, err, "ask should have been updated")
        assert.Equal(t, app.GetAskMaxPriority(), int32(15), "wrong priority 
after updating p=15 to unallocated")
+       assertMaxPriorityConsistent(t, app)
+}
+
+// TestAddAllocationAskReplaceExistingPendingAsk covers the 
replace-existing-ask branch of
+// AddAllocationAsk: re-submitting an ask under a key that is already tracked 
and still pending.
+// The displaced ask has to leave the pending histogram before 
addAllocationAskInternal counts the
+// replacement, or a single allocation key is counted twice and the priority 
it was originally
+// submitted at never drops out again. The full rescan this replaced could not 
get that wrong: it
+// derived the maximum from sa.requests, which only ever holds one ask per key.
+func TestAddAllocationAskReplaceExistingPendingAsk(t *testing.T) {
+       app := newApplication(appID1, "default", "root.default")
+       queue, err := createRootQueue(nil)
+       assert.NilError(t, err, "queue create failed")
+       app.queue = queue
+
+       res := 
resources.NewResourceFromMap(map[string]resources.Quantity{"first": 5})
+       err = app.AddAllocationAsk(newAllocationAskPriority(aKey, appID1, res, 
5))
+       assert.NilError(t, err, "ask should have been added to app")
+       assertMaxPriorityConsistent(t, app)
+
+       // same key, still pending, different priority: the replace branch
+       replacement := newAllocationAskPriority(aKey, appID1, res, 3)
+       err = app.AddAllocationAsk(replacement)
+       assert.NilError(t, err, "ask should have been updated on app")
+
+       app.RLock()
+       assert.Equal(t, len(app.pendingPriorities), 1, "pending histogram must 
only hold the replacement's priority")
+       assert.Equal(t, app.pendingPriorities[3], 1, "wrong pending count for 
the replacement priority")
+       app.RUnlock()
+       assert.Equal(t, app.GetAskMaxPriority(), int32(3), "wrong priority 
after replacing p=5 with p=3")
+       assertMaxPriorityConsistent(t, app)
+
+       // allocating the only ask must empty the histogram: a double counted 
key would leave the
+       // replaced ask's priority behind.
+       _, err = app.AllocateAsk(aKey)
+       assert.NilError(t, err, "ask should have been allocated")
+       app.RLock()
+       assert.Equal(t, len(app.pendingPriorities), 0, "allocating the only ask 
must empty the pending histogram")
+       app.RUnlock()
+       assert.Equal(t, app.GetAskMaxPriority(), configs.MinPriority, "wrong 
priority after allocating the only ask")
+       assertMaxPriorityConsistent(t, app)
+}
+
+// TestRollbackAllocationAskNotTracked covers deallocateAsk running for an ask 
that sa.requests no
+// longer holds. removeAsksInternal("") wipes sa.requests and the pending 
histogram but deliberately
+// leaves sa.allocations alone until the shim confirms the releases, so a 
SCHEDULING_FAILED_ON_RM
+// release arriving in that window reaches RollbackAllocation, which looks the 
entry up in
+// sa.allocations and deallocates it. deallocateAsk must not count that ask as 
pending again: the
+// application does not track it any more, so nothing would ever take it back 
out of the histogram.
+func TestRollbackAllocationAskNotTracked(t *testing.T) {
+       setupUGM()
+       defer setupUGM()
+       app := newApplication(appID1, "default", "root.default")
+       queue, err := createRootQueue(nil)
+       assert.NilError(t, err, "queue create failed")
+       app.queue = queue
+
+       res := 
resources.NewResourceFromMap(map[string]resources.Quantity{"first": 5})
+       ask := newAllocationAskPriority(aKey, appID1, res, 5)
+       err = app.AddAllocationAsk(ask)
+       assert.NilError(t, err, "ask should have been added to app")
+       _, err = app.AllocateAsk(aKey)
+       assert.NilError(t, err, "ask should have been allocated")
+       // confirm the allocation so it lands in sa.allocations, which is what 
RollbackAllocation looks in
+       app.AddAllocation(ask)
+
+       // wipe the asks: sa.allocations (and so the rollback target) survives 
this
+       app.RemoveAllocationAsk("")
+       assert.Assert(t, app.GetAllocationAsk(aKey) == nil, "ask should no 
longer be tracked in requests")
+       assert.Assert(t, app.IsAccepted() || app.IsRunning(), "app must still 
be rollback-eligible, is %s", app.CurrentState())
+
+       _, err = app.RollbackAllocation(aKey)
+       assert.NilError(t, err, "rollback of the confirmed allocation should 
have succeeded")
+
+       app.RLock()
+       assert.Equal(t, len(app.pendingPriorities), 0, "rollback of an 
untracked ask must not change the pending histogram")
+       app.RUnlock()
+       assert.Equal(t, app.GetAskMaxPriority(), configs.MinPriority, "rollback 
of an untracked ask must not change askMaxPriority")
+       assertMaxPriorityConsistent(t, app)
 }
 
 func TestAskEvents(t *testing.T) {
@@ -3964,6 +4077,87 @@ func TestTryPlaceHolderAllocateDifferentNodes(t 
*testing.T) {
        assertPlaceholderData(t, app, tg1, 1, 0, 0, res)
 }
 
+// revertTriggerPredicatePlugin is a test-local predicate plugin whose 
Predicates call has the side
+// effect of marking a specific placeholder as preempted, then returning nil 
(i.e. the predicate
+// check itself still "passes"). This is used to reach the narrow window in 
tryPlaceholderAllocate
+// between the loop's IsPreempted() guard and the ph.SetReleased(true) call, 
so that SetReleased
+// fails and the revert path is exercised (see 
TestTryPlaceHolderAllocateRevertsOnPreemptedPlaceholder).
+type revertTriggerPredicatePlugin struct {
+       mockCommon.ResourceManagerCallback
+       ph *Allocation
+}
+
+func (p *revertTriggerPredicatePlugin) Predicates(_ *si.PredicatesArgs) error {
+       _ = p.ph.MarkPreempted() //nolint:errcheck
+       return nil
+}
+
+// TestTryPlaceHolderAllocateRevertsOnPreemptedPlaceholder drives 
tryPlaceholderAllocate down the
+// path where ph.SetReleased(true) fails because the placeholder became 
preempted in between the
+// loop's own IsPreempted() guard and the SetReleased(true) call. This 
exercises the FIRST revert
+// branch inside the phAllocs loop (injected via node.preReserveConditions): 
sa.deallocateAsk(request),
+// ClearRelease() on both sides, and continue.
+//
+// Because ph is registered only at the Application level 
(app.AddAllocation/addPlaceholderData) and
+// never actually consumes capacity on the *Node object itself (matching the 
pattern of every other
+// TestTryPlaceHolderAllocate* test in this file, none of which call 
node.AddAllocation(ph) either),
+// the node's tracked available resources are unaffected by ph and still show 
room for reqFit. As a
+// result, once the first branch reverts and falls through to the fallback 
ForEachNode retry
+// (phFit/reqFit, using node.preAllocateCheck/preAllocateConditions), that 
retry also finds room,
+// proceeds to TryAddAllocation + allocateAsk + SetReleased(true) on the SAME 
still-preempted ph, and
+// that SetReleased(true) call fails again for the same reason - so the 
SECOND/fallback revert branch
+// (node.RemoveAllocation, deallocateAsk, ClearRelease on both sides) is 
exercised too, verified by the
+// log line "allocation is already preempted, so not proceeding further and 
reverting to old state"
+// appearing twice when run with -v. The function then returns nil with all 
state reverted, which is
+// exactly what the assertions below check.
+func TestTryPlaceHolderAllocateRevertsOnPreemptedPlaceholder(t *testing.T) {
+       // node capacity equals the placeholder's (and the real ask's) resource 
size; this keeps the
+       // scenario minimal (single node, single ph, single ask) while both 
revert branches above still
+       // get exercised, since node-level capacity tracking is independent of 
the ph's Application-level
+       // bookkeeping in this test setup.
+       node := newNode(nodeID1, map[string]resources.Quantity{"first": 5})
+       nodeMap := map[string]*Node{nodeID1: node}
+       iterator := getNodeIteratorFn(node)
+       getNode := func(nodeID string) *Node {
+               return nodeMap[nodeID]
+       }
+
+       app := newApplication(appID0, "default", "root.default")
+
+       queue, err := createRootQueue(nil)
+       assert.NilError(t, err, "queue create failed")
+       app.queue = queue
+
+       res := 
resources.NewResourceFromMap(map[string]resources.Quantity{"first": 5})
+       ph := newPlaceholderAlloc(appID0, nodeID1, res, tg1)
+       app.AddAllocation(ph)
+       app.addPlaceholderData(ph)
+       assertPlaceholderData(t, app, tg1, 1, 0, 0, res)
+
+       // same size as the placeholder so we go down the main swap path, not 
the "placeholder is
+       // larger, cancel it" branch.
+       ask := newAllocationAsk(aKey, appID0, res)
+       ask.taskGroupName = tg1
+       err = app.AddAllocationAsk(ask)
+       assert.NilError(t, err, "ask should have been added to app")
+
+       // register a plugin whose Predicates call marks ph preempted right 
before SetReleased(true)
+       // is invoked, forcing that call to fail and the revert path to run.
+       plugin := &revertTriggerPredicatePlugin{ph: ph}
+       plugins.RegisterSchedulerPlugin(plugin)
+       defer plugins.UnregisterSchedulerPlugins()
+
+       result := app.tryPlaceholderAllocate(iterator, getNode)
+       assert.Assert(t, result == nil, "result should be nil: both the first 
swap attempt and the fallback retry failed SetReleased(true) on the 
still-preempted placeholder and reverted")
+       assert.Assert(t, ph.IsPreempted(), "placeholder should remain marked 
preempted (side effect stuck)")
+       assert.Assert(t, !ph.IsReleased(), "placeholder should not be released: 
the failing SetReleased(true) call never set it")
+       assert.Assert(t, !ask.IsAllocated(), "ask should have been reverted 
back to pending by deallocateAsk")
+       assert.Assert(t, ask.GetRelease() == nil, "ask's release link should 
have been cleared by the revert")
+       assert.Assert(t, ph.GetRelease() == nil, "placeholder's release link 
should have been cleared by the revert")
+
+       assertMaxPriorityConsistent(t, app)
+}
+
 func TestTryNodesNoReserve(t *testing.T) {
        app := newApplication(appID0, "default", "root.default")
 


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to