Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 2 additions & 18 deletions services/runners/job_pool.go
Original file line number Diff line number Diff line change
Expand Up @@ -327,39 +327,23 @@ func (p *JobPool) Run() {
err := running.job.Run(t.username, t.incomingVersion, t.alias)

if err != nil {

log.WithFields(log.Fields{
"context": "job_running",
"task_id": t.taskID,
"task_status": t.status,
}).WithError(err).Error("launch job failed")

running.Log("Unable to launch the application. Please contact your system administrator for assistance.")

if running.getStatus() == task_logger.TaskStoppingStatus {
running.SetStatus(task_logger.TaskStoppedStatus)
} else {
running.SetStatus(task_logger.TaskFailStatus)
}
} else {

log.WithFields(log.Fields{
"context": "job_running",
"task_id": running.taskID,
"status": string(running.getStatus()),
}).Debug("Job run returned")

if running.getStatus().IsFinished() {
return
}

if running.getStatus() == task_logger.TaskStoppingStatus {
running.SetStatus(task_logger.TaskStoppedStatus)
} else {
running.SetStatus(task_logger.TaskSuccessStatus)
}
}

running.finalizeAfterRun(err)

log.WithFields(log.Fields{
"context": "job_running",
"task_id": running.taskID,
Expand Down
24 changes: 24 additions & 0 deletions services/runners/running_job.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,30 @@ func (p *runningJob) getStatus() task_logger.TaskStatus {
return p.status
}

// finalizeAfterRun applies the terminal status after job.Run returns. If the job
// was already brought to a terminal state (e.g. emergency-stopped via
// terminated_jobs while Run was still unwinding), the status is left unchanged.
func (p *runningJob) finalizeAfterRun(err error) {
if p.getStatus().IsFinished() {
return
}

if err != nil {
if p.getStatus() == task_logger.TaskStoppingStatus {
p.SetStatus(task_logger.TaskStoppedStatus)
} else {
p.SetStatus(task_logger.TaskFailStatus)
}
return
}

if p.getStatus() == task_logger.TaskStoppingStatus {
p.SetStatus(task_logger.TaskStoppedStatus)
} else {
p.SetStatus(task_logger.TaskSuccessStatus)
}
}

// getProgress atomically snapshots the data needed to report progress to the
// server. The returned slice is a copy, so the caller can read it freely while
// the job keeps appending records.
Expand Down
37 changes: 37 additions & 0 deletions services/runners/running_job_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package runners

import (
"errors"
"fmt"
"sync"
"testing"
Expand Down Expand Up @@ -152,3 +153,39 @@ func TestRunningJob_ConcurrentAccess(t *testing.T) {
close(start)
wg.Wait()
}

func TestRunningJob_finalizeAfterRun_KeepsFinishedStatusOnRunError(t *testing.T) {
rj := newTestRunningJob(1)
rj.SetStatus(task_logger.TaskStoppedStatus)

rj.finalizeAfterRun(errors.New("process killed"))

assert.Equal(t, task_logger.TaskStoppedStatus, rj.getStatus())
}

func TestRunningJob_finalizeAfterRun_SetsFailOnRunError(t *testing.T) {
rj := newTestRunningJob(2)
rj.SetStatus(task_logger.TaskRunningStatus)

rj.finalizeAfterRun(errors.New("ansible failed"))

assert.Equal(t, task_logger.TaskFailStatus, rj.getStatus())
}

func TestRunningJob_finalizeAfterRun_SetsSuccessOnCleanReturn(t *testing.T) {
rj := newTestRunningJob(3)
rj.SetStatus(task_logger.TaskRunningStatus)

rj.finalizeAfterRun(nil)

assert.Equal(t, task_logger.TaskSuccessStatus, rj.getStatus())
}

func TestRunningJob_finalizeAfterRun_StoppingBecomesStopped(t *testing.T) {
rj := newTestRunningJob(4)
rj.SetStatus(task_logger.TaskStoppingStatus)

rj.finalizeAfterRun(nil)

assert.Equal(t, task_logger.TaskStoppedStatus, rj.getStatus())
}
15 changes: 8 additions & 7 deletions services/tasks/TaskPool.go
Original file line number Diff line number Diff line change
Expand Up @@ -471,13 +471,14 @@ func (p *TaskPool) FinalizeRemoteTask(tsk *TaskRunner, runner *db.Runner) {
func (p *TaskPool) finalizeRemoteTaskLocked(tsk *TaskRunner, runner *db.Runner) {
if util.HAEnabled() {
p.refreshTaskStatusFromDB(tsk)
if tsk.Task.End != nil {
// Another node may have persisted End before onTaskStop ran (e.g.
// crash between saveStatus and the queue drain). Release any stale
// shared pool state without re-running finish or autorun.
p.onTaskStop(tsk)
return
}
}

if tsk.Task.End != nil {
// Another node may have persisted End before onTaskStop ran (e.g.
// crash between saveStatus and the queue drain). Release any stale
// shared pool state without re-running finish or autorun.
p.onTaskStop(tsk)
return
}

if runner != nil {
Expand Down
13 changes: 9 additions & 4 deletions services/tasks/TaskRunner.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,9 +191,7 @@ func (t *TaskRunner) run() {

defer func() {
if requeued {
// Task is being re-queued, don't mark as finished
log.Info("Task " + strconv.Itoa(t.Task.ID) + " re-queued (waiting for available runner)")
t.pool.queueEvents <- PoolEvent{EventTypeRequeued, t}
// EventTypeRequeued was sent synchronously before return.
return
}

Expand Down Expand Up @@ -282,9 +280,16 @@ func (t *TaskRunner) run() {

if err != nil {
if errors.Is(err, ErrAllRunnersBusy) {
// No runners available right now, put task back in waiting state
// No runners available right now, put task back in waiting state.
// Release running/active bookkeeping before enqueueing so a concurrent
// queue tick cannot ClaimAndDequeue the task while it is still in the
// running set (duplicate dispatch). Notify the pool synchronously so
// the current queue pass skips an immediate retry.
t.SetStatus(task_logger.TaskWaitingStatus)
t.pool.onTaskStop(t)
t.pool.state.Enqueue(t)
log.Info("Task " + strconv.Itoa(t.Task.ID) + " re-queued (waiting for available runner)")
t.pool.queueEvents <- PoolEvent{EventTypeRequeued, t}
Comment on lines +289 to +292

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Do not delete replacement claims on requeue

In HA, this cleanup removes the old claim before the task is visible in the shared queue, but the EventTypeRequeued sent just below is still handled by handleQueue with another onTaskStop. If another node claims the requeued task in the window after Enqueue and before this node receives the event, that later cleanup can delete the new owner's claim/running record, leaving an actively running task without HA ownership and subject to duplicate/orphan recovery. Keep the cleanup in only one place, or make the requeue event skip cleanup after pre-cleaning.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requeue races queue sweep

High Severity

Requeue paths call onTaskStop, then Enqueue, then block on sending EventTypeRequeued. While handleQueue is finishing another event’s queue sweep, the task can already be in the queue without running/claim bookkeeping, so ClaimAndDequeue may start a second run() on the same TaskRunner. When the requeue event is handled, onTaskStop can strip pool state from that active dispatch.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit be92c89. Configure here.

requeued = true
return
}
Expand Down
42 changes: 42 additions & 0 deletions services/tasks/TaskRunner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"
"path"
"strings"
"sync"
"testing"

"github.com/semaphoreui/semaphore/db/sql"
Expand Down Expand Up @@ -71,6 +72,47 @@ func (l *mockLogWriteService) WriteResult(task any) error {
return nil
}

func TestTaskRunner_ErrAllRunnersBusy_ReleasesRunningBeforeEnqueue(t *testing.T) {
state := NewMemoryTaskStateStore()
pool := TaskPool{
queueEvents: make(chan PoolEvent),
state: state,
}

var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
pool.handleQueue()
}()

tr := &TaskRunner{
Task: db.Task{
ID: 42,
ProjectID: 1,
TemplateID: 7,
Status: task_logger.TaskStartingStatus,
},
Template: db.Template{ID: 7, Name: "tpl"},
pool: &pool,
}

state.SetRunning(tr)
state.AddActive(tr.Task.ProjectID, tr)

// Mirrors the ErrAllRunnersBusy path in TaskRunner.run.
tr.Task.Status = task_logger.TaskWaitingStatus
pool.onTaskStop(tr)
state.Enqueue(tr)
pool.queueEvents <- PoolEvent{EventTypeRequeued, tr}

assert.Equal(t, 0, state.RunningCount(), "requeued task must not remain in running set")
assert.Equal(t, 1, state.QueueLen(), "requeued task must remain in the queue")

close(pool.queueEvents)
wg.Wait()
}

func TestTaskRunnerRun(t *testing.T) {

store := sql.CreateTestStore()
Expand Down
35 changes: 31 additions & 4 deletions services/tasks/runner_reconciler.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,11 @@ func (p *TaskPool) failTaskRunnerLost(tsk *TaskRunner, runner *db.Runner, reason
}

if tsk.Task.Status.IsFinished() {
// Another node (or the runner report on this node) already persisted a
// terminal status. Complete finalization instead of bailing: if we won
// the finalize lock over FinalizeRemoteTask, that path will not run and
// pool/Redis state (running set, claims, End, autorun) would leak.
p.finalizeRemoteTaskLocked(tsk, runner)
return
}

Expand Down Expand Up @@ -269,6 +274,22 @@ func (p *TaskPool) requeueTaskRunnerOffline(tsk *TaskRunner, runnerID int, reaso

tsk.Logf("Runner #%d lost the task: %s. Returning task to queue.", runnerID, reason)

// Re-check the DB immediately before mutating: another node may have
// received a concurrent "running" report while we held the finalize lock.
if util.HAEnabled() {
p.refreshTaskStatusFromDB(tsk)
if tsk.Task.Status != task_logger.TaskStartingStatus &&
tsk.Task.Status != task_logger.TaskWaitingStatus {
return
}
if tsk.Task.RunnerID == nil || *tsk.Task.RunnerID != runnerID {
return
}
}

prevRunnerID := tsk.Task.RunnerID
prevStatus := tsk.Task.Status

tsk.Task.RunnerID = nil
tsk.SetStatus(task_logger.TaskWaitingStatus)

Expand All @@ -279,12 +300,17 @@ func (p *TaskPool) requeueTaskRunnerOffline(tsk *TaskRunner, runnerID int, reaso
"task_id": tsk.Task.ID,
"context": "runner_reconciler",
}).Error("failed to persist requeued task")
// Roll back in-memory changes so the next reconcile tick retries via
// the runner-liveness path instead of mis-routing as undispatched.
tsk.Task.RunnerID = prevRunnerID
tsk.Task.Status = prevStatus

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rollback ignores SetStatus persistence

Medium Severity

When offline requeue clears RunnerID and calls SetStatus to waiting, saveStatus may already persist that row. If the follow-up UpdateTask fails, the new rollback restores prior in-memory RunnerID and status while the database still holds the cleared assignment—pool state and reconciler decisions can diverge until something else refreshes.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit be92c89. Configure here.

return
}

// Same flow as the ErrAllRunnersBusy requeue in TaskRunner.run:
// put the task back into the queue, then let the pool release its
// running/active bookkeeping (EventTypeRequeued -> onTaskStop).
// release running/active bookkeeping before enqueueing, then notify the
// pool so the current queue pass skips an immediate retry.
p.onTaskStop(tsk)
p.state.Enqueue(tsk)
p.queueEvents <- PoolEvent{EventTypeRequeued, tsk}
}
Expand Down Expand Up @@ -339,8 +365,9 @@ func (p *TaskPool) requeueUndispatchedTask(tsk *TaskRunner) {
return
}

// EventTypeRequeued -> onTaskStop releases the running/active bookkeeping and
// the claim, so the task can be re-claimed from the queue by any live node.
// Release running/active bookkeeping before enqueueing, then notify the
// pool so the task can be re-claimed from the queue by any live node.
p.onTaskStop(tsk)
p.state.Enqueue(tsk)
p.queueEvents <- PoolEvent{EventTypeRequeued, tsk}
}
Loading
Loading