Skip to content

Commit 1094fda

Browse files
mfenniakMathieu Fenniak
authored andcommitted
fix: prevent stuck runner jobs when FetchTask performance is slower than client-side timeout (#13658)
Forgejo has recently experienced a significant increase in the number of Action jobs which appear stuck in "Set up job" and never progress. This was supposed to be prevented by #11401. This commit adds app-level locking on the runner request key, and when a concurrent request is received to send an error back to the client to allow it to retry. Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/13658 Reviewed-by: Andreas Ahlenstorf <aahlenst@noreply.codeberg.org> Reviewed-by: Gusted <gusted@noreply.codeberg.org>
1 parent 9964c74 commit 1094fda

5 files changed

Lines changed: 199 additions & 14 deletions

File tree

modules/cache/mutex_map.go

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,27 @@ type refcountMutex struct {
2222
sync.Mutex
2323
}
2424

25-
// Locks the given key, and returns a function that must be invoked to unlock the key.
25+
// Locks the given key, and returns a function that must be invoked to unlock the key and cleanup the mutex.
2626
func (m *MutexMap) Lock(key string) func() {
27+
mutex := m.getOrCreateMutex(key)
28+
mutex.Lock()
29+
return m.makeUnlock(mutex, key)
30+
}
31+
32+
// Attempts to lock the given key, and returns whether the key was locked, and a function that must be invoked to unlock
33+
// the key and cleanup the mutex. The returned function must be invoked even if the lock acqusition result was false.
34+
func (m *MutexMap) TryLock(key string) (bool, func()) {
35+
mutex := m.getOrCreateMutex(key)
36+
lockAcquired := mutex.TryLock()
37+
if !lockAcquired {
38+
return false, func() {
39+
m.releaseMutex(mutex, key)
40+
}
41+
}
42+
return true, m.makeUnlock(mutex, key)
43+
}
44+
45+
func (m *MutexMap) getOrCreateMutex(key string) *refcountMutex {
2746
m.mu.Lock()
2847
if m.mutexMap == nil {
2948
m.mutexMap = make(map[string]*refcountMutex)
@@ -36,8 +55,10 @@ func (m *MutexMap) Lock(key string) func() {
3655
mutex.refCount++
3756
m.mu.Unlock()
3857

39-
mutex.Lock()
58+
return mutex
59+
}
4060

61+
func (m *MutexMap) makeUnlock(mutex *refcountMutex, key string) func() {
4162
unlockPending := true
4263

4364
return func() {
@@ -46,15 +67,18 @@ func (m *MutexMap) Lock(key string) func() {
4667
// to detect and panic so that this programming error can be found closest to the source.
4768
panic("MutexMap unlock invoked twice")
4869
}
49-
5070
unlockPending = false
5171
mutex.Unlock()
5272

53-
m.mu.Lock()
54-
mutex.refCount--
55-
if mutex.refCount == 0 {
56-
delete(m.mutexMap, key)
57-
}
58-
m.mu.Unlock()
73+
m.releaseMutex(mutex, key)
74+
}
75+
}
76+
77+
func (m *MutexMap) releaseMutex(mutex *refcountMutex, key string) {
78+
m.mu.Lock()
79+
mutex.refCount--
80+
if mutex.refCount == 0 {
81+
delete(m.mutexMap, key)
5982
}
83+
m.mu.Unlock()
6084
}

modules/cache/mutex_map_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,31 @@ func TestMutexMap_BasicLockUnlock(t *testing.T) {
2424
unlock2()
2525
}
2626

27+
func TestMutexMap_BasicTryLockUnlock(t *testing.T) {
28+
mm := &MutexMap{}
29+
30+
locked, unlock := mm.TryLock("test-key")
31+
assert.True(t, locked)
32+
unlock()
33+
34+
// Should be able to lock again
35+
locked, unlock2 := mm.TryLock("test-key")
36+
assert.True(t, locked)
37+
unlock2()
38+
}
39+
40+
func TestMutexMap_TryLock(t *testing.T) {
41+
mm := &MutexMap{}
42+
43+
locked, unlock1 := mm.TryLock("test-key")
44+
defer unlock1()
45+
assert.True(t, locked)
46+
47+
locked, unlock2 := mm.TryLock("test-key")
48+
defer unlock2()
49+
assert.False(t, locked)
50+
}
51+
2752
func TestMutexMap_ConcurrentSameKey(t *testing.T) {
2853
mm := &MutexMap{}
2954
var anotherLockActive atomic.Bool
@@ -91,6 +116,23 @@ func TestMutexMap_SimpleCleanup(t *testing.T) {
91116
mm.mu.Unlock()
92117
}
93118

119+
func TestMutexMap_TryLockCleanup(t *testing.T) {
120+
mm := &MutexMap{}
121+
_, unlock1 := mm.TryLock("test-key-1")
122+
_, unlock2 := mm.TryLock("test-key-1")
123+
124+
mm.mu.Lock()
125+
assert.Len(t, mm.mutexMap, 1)
126+
mm.mu.Unlock()
127+
128+
unlock1()
129+
unlock2()
130+
131+
mm.mu.Lock()
132+
assert.Empty(t, mm.mutexMap)
133+
mm.mu.Unlock()
134+
}
135+
94136
func TestMutexMap_ConcurrentCleanup(t *testing.T) {
95137
mm := &MutexMap{}
96138
var foundRefGreaterThanOne atomic.Bool

routers/api/actions/runner/runner.go

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
repo_model "forgejo.org/models/repo"
1414
user_model "forgejo.org/models/user"
1515
"forgejo.org/modules/actions"
16+
"forgejo.org/modules/cache"
1617
"forgejo.org/modules/log"
1718
"forgejo.org/modules/setting"
1819
"forgejo.org/modules/util"
@@ -36,10 +37,11 @@ var _ runnerv1connect.RunnerServiceClient = (*Service)(nil)
3637

3738
type Service struct {
3839
runnerv1connect.UnimplementedRunnerServiceHandler
40+
runnerRequestKeyMutexMap cache.MutexMap
3941
}
4042

4143
// Register for new runner.
42-
func (s *Service) Register(
44+
func (*Service) Register(
4345
ctx context.Context,
4446
req *connect.Request[runnerv1.RegisterRequest],
4547
) (*connect.Response[runnerv1.RegisterResponse], error) {
@@ -109,7 +111,7 @@ func (s *Service) Register(
109111
return res, nil
110112
}
111113

112-
func (s *Service) Declare(
114+
func (*Service) Declare(
113115
ctx context.Context,
114116
req *connect.Request[runnerv1.DeclareRequest],
115117
) (*connect.Response[runnerv1.DeclareResponse], error) {
@@ -142,6 +144,23 @@ func (s *Service) FetchTask(
142144

143145
requestKey := getRequestKey(ctx)
144146
if requestKey != nil {
147+
// It's possible for Forgejo to receive multiple concurrent requests for a given request key if the client made
148+
// a request (A), request (A) took longer than the client's HTTP timeout, request (A) continues to run on
149+
// Forgejo, and the client sends request (B). In that case, we need to protect against reading from the
150+
// database and sending only *some* of the tasks for the request key back to the runner, as they get assigned
151+
// and committed to the database from request (A), but while request (A) is still running and request (B) is
152+
// received. To do this, we lock on the request key with a MutexMap.
153+
//
154+
// The lock must be held for the entirety of `FetchTask`, so even if the request key isn't used to recover
155+
// tasks, the lock is held while new tasks are picked.
156+
locked, cleanup := s.runnerRequestKeyMutexMap.TryLock(*requestKey)
157+
defer cleanup()
158+
if !locked {
159+
// Another goroutine is currently processing some work for this request key. Provide an error to the
160+
// client. This will allow the client to retry with the same request key at its typical fetch interval.
161+
return nil, connect.NewError(connect.CodeInternal, errors.New("request key is currently locked; retry soon"))
162+
}
163+
145164
recoveredTasks, err := recoverTasks(ctx, runner, *requestKey)
146165
if err != nil {
147166
return nil, connect.NewError(connect.CodeInternal, err)
@@ -201,7 +220,7 @@ func (s *Service) FetchTask(
201220
return res, nil
202221
}
203222

204-
func (s *Service) FetchSingleTask(
223+
func (*Service) FetchSingleTask(
205224
ctx context.Context,
206225
req *connect.Request[runnerv1.FetchSingleTaskRequest],
207226
) (*connect.Response[runnerv1.FetchSingleTaskResponse], error) {
@@ -253,7 +272,7 @@ func (s *Service) FetchSingleTask(
253272
}
254273

255274
// UpdateTask updates the task status.
256-
func (s *Service) UpdateTask(
275+
func (*Service) UpdateTask(
257276
ctx context.Context,
258277
req *connect.Request[runnerv1.UpdateTaskRequest],
259278
) (*connect.Response[runnerv1.UpdateTaskResponse], error) {
@@ -334,7 +353,7 @@ func (s *Service) UpdateTask(
334353
}
335354

336355
// UpdateLog uploads log of the task.
337-
func (s *Service) UpdateLog(
356+
func (*Service) UpdateLog(
338357
ctx context.Context,
339358
req *connect.Request[runnerv1.UpdateLogRequest],
340359
) (*connect.Response[runnerv1.UpdateLogResponse], error) {

tests/integration/actions_fetch_task_test.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package integration
55

66
import (
77
"net/url"
8+
"sync"
89
"testing"
910
"testing/fstest"
1011

@@ -13,10 +14,12 @@ import (
1314
unit_model "forgejo.org/models/unit"
1415
"forgejo.org/models/unittest"
1516
user_model "forgejo.org/models/user"
17+
"forgejo.org/modules/container"
1618
"forgejo.org/modules/setting"
1719
"forgejo.org/modules/util"
1820
"forgejo.org/tests/forgery"
1921

22+
runnerv1 "code.forgejo.org/forgejo/actions-proto/runner/v1"
2023
"code.forgejo.org/xorm/xorm/convert"
2124
"github.com/stretchr/testify/assert"
2225
"github.com/stretchr/testify/require"
@@ -201,6 +204,91 @@ jobs:
201204
})
202205
}
203206

207+
func TestActionFetchTask_IdempotentConcurrent(t *testing.T) {
208+
if !setting.Database.Type.IsSQLite3() {
209+
// mock repo runner only supported on SQLite testing
210+
t.Skip()
211+
}
212+
213+
onApplicationRun(t, func(t *testing.T, u *url.URL) {
214+
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
215+
216+
// create the repo
217+
repo := createFetchTaskTestRepository(t, user2, "matrix.yml", `
218+
on:
219+
push:
220+
jobs:
221+
job1:
222+
strategy:
223+
matrix:
224+
d1: [a, b, c, d, e]
225+
d2: [a, b, c, d, e]
226+
runs-on: ubuntu-latest
227+
steps:
228+
- run: sleep 2
229+
`)
230+
231+
runner := newMockRunner()
232+
runner.registerAsRepoRunner(t, user2.Name, repo.Name, "mock-runner", []string{"ubuntu-latest"})
233+
234+
runner.setRequestKey("c6dacc80-dace-4cea-9aad-f0e266355d8e")
235+
236+
// If we make two simultaneous requests with the same runner request key, we should get either the error
237+
// "request key is currently locked; retry soon", or, the same tasks from both requests.
238+
concurrentCount := 15
239+
type fetchResult struct {
240+
index int
241+
task *runnerv1.Task
242+
addtTasks []*runnerv1.Task
243+
err error
244+
}
245+
fetchResults := make(chan fetchResult, concurrentCount)
246+
247+
var wg sync.WaitGroup
248+
for i := range concurrentCount {
249+
wg.Go(func() {
250+
// Larger task capacity is used to make the successful call take longer, cause higher chance of problems if
251+
// concurrency isn't handled correctly
252+
task, addtTasks, err := runner.fetchTaskOrError(t, 10)
253+
fetchResults <- fetchResult{index: i, task: task, addtTasks: addtTasks, err: err}
254+
})
255+
}
256+
257+
wg.Wait()
258+
close(fetchResults)
259+
260+
var firstResponseTaskIDs container.Set[int64]
261+
for res := range fetchResults {
262+
t.Logf("res = %#v", res)
263+
if res.task != nil {
264+
// This response had tasks, so let's ensure they're always the same for every response.
265+
taskIDs := container.Set[int64]{}
266+
taskIDs.Add(res.task.GetId())
267+
for _, extraTask := range res.addtTasks {
268+
assert.True(t, taskIDs.Add(extraTask.GetId()))
269+
}
270+
if firstResponseTaskIDs == nil {
271+
// first response with tasks -- record the IDs
272+
firstResponseTaskIDs = taskIDs
273+
assert.Len(t, taskIDs, 10)
274+
} else {
275+
// we've already found one response with tasks, so assert that they're all the same
276+
d1 := firstResponseTaskIDs.Difference(taskIDs)
277+
assert.Empty(t, d1, "first response taskIDs minus current response taskIDs should be empty")
278+
d2 := taskIDs.Difference(firstResponseTaskIDs)
279+
assert.Empty(t, d2, "current response taskIDs minus first response taskIDs should be empty")
280+
}
281+
} else if res.err != nil {
282+
require.ErrorContains(t, res.err, "request key is currently locked")
283+
} else {
284+
assert.Fail(t, "unexpected condition - res.task = nil, res.err = nil")
285+
}
286+
}
287+
288+
assert.NotNil(t, firstResponseTaskIDs, "at least one response should return tasks")
289+
})
290+
}
291+
204292
func TestActionFetchTask_RequestedJob(t *testing.T) {
205293
if !setting.Database.Type.IsSQLite3() {
206294
// mock repo runner only supported on SQLite testing

tests/integration/actions_runner_test.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,18 @@ func (r *mockRunner) registerAsEphemeralRepoRunner(t *testing.T, ownerName, repo
141141
r.doRegisterEphemeral(t, runnerName, registrationToken.Token, labels)
142142
}
143143

144+
func (r *mockRunner) fetchTaskOrError(t *testing.T, taskCapacity int64) (*runnerv1.Task, []*runnerv1.Task, error) {
145+
resp, err := r.client.runnerServiceClient.FetchTask(t.Context(), connect.NewRequest(&runnerv1.FetchTaskRequest{
146+
TasksVersion: r.lastTasksVersion,
147+
TaskCapacity: &taskCapacity,
148+
}))
149+
if err != nil {
150+
return nil, nil, err
151+
}
152+
r.lastTasksVersion = resp.Msg.TasksVersion
153+
return resp.Msg.Task, resp.Msg.AdditionalTasks, nil
154+
}
155+
144156
func (r *mockRunner) maybeFetchTask(t *testing.T) *runnerv1.Task {
145157
resp, err := r.client.runnerServiceClient.FetchTask(t.Context(), connect.NewRequest(&runnerv1.FetchTaskRequest{
146158
TasksVersion: r.lastTasksVersion,

0 commit comments

Comments
 (0)