From 8cc2360c3a5e47782ab6da15a59381e25c62ccbc Mon Sep 17 00:00:00 2001 From: Feng Ruohang Date: Sat, 7 Nov 2026 02:49:44 +0800 Subject: [PATCH] fix(batch): release terminal markers after startup discovery Signed-off-by: Feng Ruohang --- cmd/batch-discovery_test.go | 16 ++++++++++++++-- cmd/batch-handlers.go | 18 ++++++++++-------- cmd/batch-retry.go | 26 ++++++++++++++++++++++---- 3 files changed, 46 insertions(+), 14 deletions(-) diff --git a/cmd/batch-discovery_test.go b/cmd/batch-discovery_test.go index 74229fa24..4ded78ce0 100644 --- a/cmd/batch-discovery_test.go +++ b/cmd/batch-discovery_test.go @@ -209,6 +209,9 @@ func TestBatchDiscoveryKeepsNewlyFinishedJobsTerminal(t *testing.T) { return errErasureReadQuorum } defer close(results) + if owner := pool.jobOwners["new-job"]; owner == nil || owner.state != "terminal" || owner.req != nil || owner.cancel != nil { + t.Fatalf("terminal marker lost or retained the job payload: %+v", owner) + } results <- itemOrErr[ObjectInfo]{Item: ObjectInfo{Name: "new-job"}} return nil }, @@ -229,8 +232,17 @@ func TestBatchDiscoveryKeepsNewlyFinishedJobsTerminal(t *testing.T) { if attempts != 2 || len(pool.jobCh) != 0 { t.Fatalf("discovery re-admitted a failed job: scans=%d queue=%d", attempts, len(pool.jobCh)) } - if owner := pool.jobOwners["new-job"]; owner.state != "terminal" || owner.req != nil || owner.cancel != nil { - t.Fatalf("terminal marker lost or retained the job payload: %+v", owner) + if !pool.discoveryDone || len(pool.jobOwners) != 0 { + t.Fatal("completed discovery retained terminal job markers") + } + job := &BatchJobRequest{ID: "after-discovery"} + mustIAM(t, pool.queueJob(job)) + if !pool.beginJob(<-pool.jobCh) { + t.Fatal("new submission after discovery has no owner") + } + pool.finishJob(job, nil) + if len(pool.jobOwners) != 0 { + t.Fatal("normal completion after discovery leaked an owner") } }) } diff --git a/cmd/batch-handlers.go b/cmd/batch-handlers.go index 29f1398cc..14467a6bf 100644 --- a/cmd/batch-handlers.go +++ b/cmd/batch-handlers.go @@ -2242,14 +2242,15 @@ func (a adminAPIHandlers) CancelBatchJob(w http.ResponseWriter, r *http.Request) // BatchJobPool batch job pool type BatchJobPool struct { - ctx context.Context - objLayer ObjectLayer - mu sync.Mutex - jobCh chan *BatchJobRequest - jmu sync.Mutex // protects jobOwners - jobOwners map[string]*batchJobOwner - workerKillCh chan struct{} - workerSize int + ctx context.Context + objLayer ObjectLayer + mu sync.Mutex + jobCh chan *BatchJobRequest + jmu sync.Mutex // protects jobOwners and discoveryDone + jobOwners map[string]*batchJobOwner + discoveryDone bool + workerKillCh chan struct{} + workerSize int } var globalBatchJobPool *BatchJobPool @@ -2326,6 +2327,7 @@ func (j *BatchJobPool) cleanupReports(randomWait func() time.Duration) { } func (j *BatchJobPool) resume(randomWait func() time.Duration) { + defer j.finishDiscovery() timer := time.NewTimer(randomWait()) defer timer.Stop() // A retry scans the manifest prefix again, but only unadmitted paths may diff --git a/cmd/batch-retry.go b/cmd/batch-retry.go index 9a5080813..b1fe7aea6 100644 --- a/cmd/batch-retry.go +++ b/cmd/batch-retry.go @@ -105,9 +105,9 @@ func validateBatchJobAdminReq(ctx context.Context, w http.ResponseWriter, r *htt } // One owner record spans queued, running and paused attempts. The request -// pointer is the generation token. Canceled and terminal records are small -// tombstones until process shutdown, so a delayed discovery cannot requeue a -// job after its owner finished, including a terminal partial failure. +// pointer is the generation token. Canceled records fence stale requests until +// shutdown. Terminal records fence discovery until the startup scan finishes, +// including jobs submitted and terminated while the scan was unavailable. type batchJobOwner struct { req *BatchJobRequest cancel context.CancelFunc @@ -141,7 +141,25 @@ func (j *BatchJobPool) finishJob(job *BatchJobRequest, err error) { return } o.cancel() - j.jobOwners[job.ID] = &batchJobOwner{state: "terminal"} + if j.discoveryDone { + delete(j.jobOwners, job.ID) + } else { + j.jobOwners[job.ID] = &batchJobOwner{state: "terminal"} + } +} + +// After the only startup discovery has returned, no loaded manifest remains +// to re-admit a terminal job. Release its marker, but keep queued, running, +// paused and canceled ownership intact. New submissions use new job IDs. +func (j *BatchJobPool) finishDiscovery() { + j.jmu.Lock() + defer j.jmu.Unlock() + j.discoveryDone = true + for id, owner := range j.jobOwners { + if owner.state == "terminal" { + delete(j.jobOwners, id) + } + } } func batchJobRetryDelay(retries uint) time.Duration {