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

HuangTing-Yao pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/yunikorn-k8shim.git


The following commit(s) were added to refs/heads/master by this push:
     new ba0fe9a4 [YUNIKORN-3427] Fix unsynchronised context and task access on 
deferred release (#1097)
ba0fe9a4 is described below

commit ba0fe9a45345aa4686021de638ab596a8d5624e6
Author: rjgoyln <[email protected]>
AuthorDate: Mon Sep 21 21:20:26 2026 +0800

    [YUNIKORN-3427] Fix unsynchronised context and task access on deferred 
release (#1097)
    
    flushReleaseableTasks runs as the enter-Accepted callback under the 
application
    lock and nothing else. On the all-terminated path it deleted the application
    from the context map directly, while the scheduling loop lists that map on 
every
    tick: a concurrent map read and write, which the runtime treats as fatal. 
On the
    other path it read task fields that the core callback writes when an 
allocation
    lands.
    
    The context lock is taken before the application lock everywhere else, so 
the
    removal cannot run from inside the transition and moves to handle(), which 
has
    released the application lock by then. The task lock is the one that comes 
after
    the application lock, so the release replay can take it directly.
    [YUNIKORN-3427] Name the locked half of handle after what it does
    The two halves were called handle and handleEvent, which are interchangeable
    names that leave the doc comment to carry the whole lock invariant. The test
    helper the second test grew is useful to the first one as well.
    [YUNIKORN-3427] Say which lock the callback rule is about
    The rule above handle() predates a callback that takes a lock of its own, 
and
    reads as if any lock were forbidden. Only the application lock is.
    
    Closes: #1097
    
    Signed-off-by: HuangTing-Yao <[email protected]>
---
 pkg/cache/application.go      | 31 +++++++++++----
 pkg/cache/application_test.go | 90 +++++++++++++++++++++++++++++++++++++++++++
 pkg/cache/context.go          |  4 --
 pkg/cache/task.go             |  8 ++++
 4 files changed, 122 insertions(+), 11 deletions(-)

diff --git a/pkg/cache/application.go b/pkg/cache/application.go
index 3295d888..2faa0f4c 100644
--- a/pkg/cache/application.go
+++ b/pkg/cache/application.go
@@ -64,6 +64,7 @@ type Application struct {
        originatingTask            *Task // Original Pod which creates the 
requests
        releaseableTasks           []*Task
        context                    *Context
+       removeFromContext          bool // handle() does the removal: it needs 
the context lock
 }
 
 const transitionErr = "no transition"
@@ -98,6 +99,18 @@ func NewApplication(appID, queueName, user string, groups 
[]string, tags map[str
 }
 
 func (app *Application) handle(ev events.ApplicationEvent) error {
+       removeFrom, err := app.runTransition(ev)
+       // Context.RemoveApplication takes the context lock, which every other 
path takes before
+       // the application lock, so the removal can only run once runTransition 
has released it.
+       if removeFrom != nil {
+               removeFrom.RemoveApplication(app.applicationID)
+       }
+       return err
+}
+
+// runTransition runs the event through the state machine while holding the 
application lock and
+// reports the context the transition asked to remove the application from, if 
any.
+func (app *Application) runTransition(ev events.ApplicationEvent) (*Context, 
error) {
        // Locking mechanism:
        // 1) when handle event transitions, we first obtain the object's lock,
        //    this helps us to place a pre-check before entering here, in case
@@ -106,15 +119,21 @@ func (app *Application) handle(ev 
events.ApplicationEvent) error {
        //    to protect the transition phase.
        // 2) Note, state machine calls those callbacks here, we must ensure
        //    they are lock-free calls. Otherwise the callback will be blocked
-       //    because the lock is already held here.
+       //    because the lock is already held here. A lock that is ordered 
after
+       //    this one, the task lock, is safe for a callback to take.
        app.lock.Lock()
        defer app.lock.Unlock()
        err := app.sm.Event(context.Background(), ev.GetEvent(), app, 
ev.GetArgs())
+       var removeFrom *Context
+       if app.removeFromContext {
+               app.removeFromContext = false
+               removeFrom = app.context
+       }
        // handle the same state transition not nil error (limit of fsm).
        if err != nil && err.Error() != transitionErr {
-               return err
+               return removeFrom, err
        }
-       return nil
+       return removeFrom, nil
 }
 
 func (app *Application) canHandle(ev events.ApplicationEvent) bool {
@@ -781,14 +800,12 @@ func (app *Application) flushReleaseableTasks() {
 
        if app.areAllTasksTerminated() {
                app.removeFromSchedulerCore()
-               if app.context != nil {
-                       app.context.removeApplication(app.applicationID)
-               }
+               app.removeFromContext = true
                return
        }
 
        for _, task := range tasks {
-               task.releaseAllocation(true)
+               task.forceReleaseAllocation()
        }
 }
 
diff --git a/pkg/cache/application_test.go b/pkg/cache/application_test.go
index 4f7fe911..afb5619d 100644
--- a/pkg/cache/application_test.go
+++ b/pkg/cache/application_test.go
@@ -1672,6 +1672,96 @@ func TestTaskMapReadersAreRaceFree(t *testing.T) {
        }
 }
 
+// TestAcceptRemovesApplicationUnderContextLock covers the removal of an 
application that has no
+// live task left by the time the core accepts it. The delete used to run 
under the application
+// lock only, while the scheduling loop lists the applications on every tick.
+func TestAcceptRemovesApplicationUnderContextLock(t *testing.T) {
+       context := initContextForTest()
+
+       const appCount = 50
+       apps := make([]*Application, 0, appCount)
+       for i := 0; i < appCount; i++ {
+               app := NewApplication(fmt.Sprintf("app-%d", i), "root.a", 
"testuser", testGroups, map[string]string{}, newMockSchedulerAPI())
+               context.addApplicationToContext(app)
+               task := addTaskHelper(context, app, fmt.Sprintf("task-%d", i))
+               assert.NilError(t, 
app.handle(NewSubmitApplicationEvent(app.applicationID)))
+               assert.NilError(t, 
task.handle(NewSimpleTaskEvent(app.applicationID, task.taskID, CompleteTask)))
+               apps = append(apps, app)
+       }
+
+       start := make(chan struct{})
+       acceptErrs := make([]error, appCount)
+       var wg sync.WaitGroup
+       wg.Add(2)
+       go func() {
+               defer wg.Done()
+               <-start
+               for i, app := range apps {
+                       acceptErrs[i] = 
app.handle(NewSimpleApplicationEvent(app.applicationID, AcceptApplication))
+               }
+       }()
+       go func() {
+               defer wg.Done()
+               <-start
+               for i := 0; i < appCount; i++ {
+                       context.GetAllApplications()
+               }
+       }()
+
+       close(start)
+       wg.Wait()
+
+       for _, err := range acceptErrs {
+               assert.NilError(t, err)
+       }
+       assert.Equal(t, len(context.GetAllApplications()), 0)
+}
+
+// TestDeferredReleaseReadsTaskFieldsUnderTaskLock covers the release replay 
that runs when the
+// application still has live tasks. It read the task fields that the core 
callback writes when
+// an allocation lands, without holding the task lock.
+func TestDeferredReleaseReadsTaskFieldsUnderTaskLock(t *testing.T) {
+       context := initContextForTest()
+       app := NewApplication(appID, "root.a", "testuser", testGroups, 
map[string]string{}, newMockSchedulerAPI())
+       context.addApplicationToContext(app)
+
+       deferred := addTaskHelper(context, app, "task-deferred")
+       addTaskHelper(context, app, "task-running")
+
+       assert.NilError(t, 
app.handle(NewSubmitApplicationEvent(app.applicationID)))
+       assert.NilError(t, 
deferred.handle(NewSimpleTaskEvent(app.applicationID, deferred.taskID, 
CompleteTask)))
+       assert.Equal(t, len(app.releaseableTasks), 1)
+
+       start := make(chan struct{})
+       var acceptErr error
+       var wg sync.WaitGroup
+       wg.Add(2)
+       go func() {
+               defer wg.Done()
+               <-start
+               acceptErr = 
app.handle(NewSimpleApplicationEvent(app.applicationID, AcceptApplication))
+       }()
+       go func() {
+               defer wg.Done()
+               <-start
+               deferred.setAllocationKey("alloc-01")
+       }()
+
+       close(start)
+       wg.Wait()
+
+       assert.NilError(t, acceptErr)
+       assert.Equal(t, app.GetApplicationState(), ApplicationStates().Accepted)
+       assert.Assert(t, context.GetApplication(app.applicationID) != nil, "app 
with a live task must stay in the cache")
+}
+
+func addTaskHelper(context *Context, app *Application, taskID string) *Task {
+       pod := &v1.Pod{ObjectMeta: apis.ObjectMeta{Name: taskID, UID: 
types.UID(taskID)}}
+       task := NewTask(taskID, app, context, pod)
+       app.addTask(task)
+       return task
+}
+
 func (ctx *Context) addApplicationToContext(app *Application) {
        app.setContext(ctx)
        ctx.lock.Lock()
diff --git a/pkg/cache/context.go b/pkg/cache/context.go
index 6e7e2fec..ee0cf9c0 100644
--- a/pkg/cache/context.go
+++ b/pkg/cache/context.go
@@ -1137,10 +1137,6 @@ func (ctx *Context) getApplication(appID string) 
*Application {
 func (ctx *Context) RemoveApplication(appID string) {
        ctx.lock.Lock()
        defer ctx.lock.Unlock()
-       ctx.removeApplication(appID)
-}
-
-func (ctx *Context) removeApplication(appID string) {
        if _, exist := ctx.applications[appID]; !exist {
                log.Log(log.ShimContext).Debug("Attempted to remove 
non-existent application", zap.String("appID", appID))
                return
diff --git a/pkg/cache/task.go b/pkg/cache/task.go
index 20da78b6..61fb7afb 100644
--- a/pkg/cache/task.go
+++ b/pkg/cache/task.go
@@ -555,6 +555,14 @@ func (task *Task) releaseAllocation(force bool) {
        }
 }
 
+// forceReleaseAllocation releases the allocation for callers that do not 
already hold the task
+// lock. The application lock is taken before the task lock on every path that 
holds both.
+func (task *Task) forceReleaseAllocation() {
+       task.lock.Lock()
+       defer task.lock.Unlock()
+       task.releaseAllocation(true)
+}
+
 func (task *Task) shouldAppRelease() bool {
        task.lock.Unlock()
        defer task.lock.Lock()


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

Reply via email to