detached f8bb86d

fix(mr): alternate pending and dirty work in the mergeability queue

commit
f8bb86de9ffa9d7ddf675ef1f1ca34df3b8f7ace
author
brice · · 2026-10-19 14:52 UTC
committer
brice · · 2026-10-19 18:30 UTC
parent
a79212e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +45 -2
M internal/usecase/mergeability_queue.go +19 -2
@@ -32,6 +32,9 @@ type mergeQueue struct {
3232 // their next check is published whatever it is.
3333 seen map[mergeKey]bool
3434 wake chan struct{}
35+ // preferDirty alternates the service between pending keys and dirty
36+ // repositories, so that neither starves the other.
37+ preferDirty bool
3538 }
3639
3740 func newMergeQueue(max int) *mergeQueue {
@@ -80,10 +83,19 @@ func (q *mergeQueue) markDirtyLocked(repo domain.RepoName) {
8083 }
8184
8285 // next takes the next check, which it marks running, or the next dirty
83-// repository to rescan; pending checks first.
86+// repository to rescan, alternating between the two when both wait.
8487 func (q *mergeQueue) next() (key *mergeKey, dirty *domain.RepoName) {
8588 q.mu.Lock()
8689 defer q.mu.Unlock()
90+ if q.preferDirty && len(q.dirtyOrder) > 0 {
91+ q.preferDirty = false
92+ r := q.dirtyOrder[0]
93+ q.dirtyOrder = q.dirtyOrder[1:]
94+ delete(q.dirty, r)
95+ dirty = &r
96+ q.signalIfWaitingLocked()
97+ return nil, dirty
98+ }
8799 for key == nil && len(q.order) > 0 {
88100 k := q.order[0]
89101 q.order = q.order[1:]
@@ -98,16 +110,21 @@ func (q *mergeQueue) next() (key *mergeKey, dirty *domain.RepoName) {
98110 q.running[k] = true
99111 key = &k
100112 }
113+ q.preferDirty = key != nil
101114 if key == nil && len(q.dirtyOrder) > 0 {
102115 r := q.dirtyOrder[0]
103116 q.dirtyOrder = q.dirtyOrder[1:]
104117 delete(q.dirty, r)
105118 dirty = &r
106119 }
120+ q.signalIfWaitingLocked()
121+ return key, dirty
122+}
123+
124+func (q *mergeQueue) signalIfWaitingLocked() {
107125 if len(q.order) > 0 || len(q.dirtyOrder) > 0 {
108126 q.signal() // let another worker take the rest
109127 }
110- return key, dirty
111128 }
112129
113130 // tryRun marks k running for a rescan; when it already runs it asks for
@@ -32,6 +32,9 @@ type mergeQueue struct {
32 // their next check is published whatever it is.32 // their next check is published whatever it is.
33 seen map[mergeKey]bool33 seen map[mergeKey]bool
34 wake chan struct{}34 wake chan struct{}
35+ // preferDirty alternates the service between pending keys and dirty
36+ // repositories, so that neither starves the other.
37+ preferDirty bool
35 }38 }
36 39
37 func newMergeQueue(max int) *mergeQueue {40 func newMergeQueue(max int) *mergeQueue {
@@ -80,10 +83,19 @@ func (q *mergeQueue) markDirtyLocked(repo domain.RepoName) {
80 }83 }
81 84
82 // next takes the next check, which it marks running, or the next dirty85 // next takes the next check, which it marks running, or the next dirty
83-// repository to rescan; pending checks first.86+// repository to rescan, alternating between the two when both wait.
84 func (q *mergeQueue) next() (key *mergeKey, dirty *domain.RepoName) {87 func (q *mergeQueue) next() (key *mergeKey, dirty *domain.RepoName) {
85 q.mu.Lock()88 q.mu.Lock()
86 defer q.mu.Unlock()89 defer q.mu.Unlock()
90+ if q.preferDirty && len(q.dirtyOrder) > 0 {
91+ q.preferDirty = false
92+ r := q.dirtyOrder[0]
93+ q.dirtyOrder = q.dirtyOrder[1:]
94+ delete(q.dirty, r)
95+ dirty = &r
96+ q.signalIfWaitingLocked()
97+ return nil, dirty
98+ }
87 for key == nil && len(q.order) > 0 {99 for key == nil && len(q.order) > 0 {
88 k := q.order[0]100 k := q.order[0]
89 q.order = q.order[1:]101 q.order = q.order[1:]
@@ -98,16 +110,21 @@ func (q *mergeQueue) next() (key *mergeKey, dirty *domain.RepoName) {
98 q.running[k] = true110 q.running[k] = true
99 key = &k111 key = &k
100 }112 }
113+ q.preferDirty = key != nil
101 if key == nil && len(q.dirtyOrder) > 0 {114 if key == nil && len(q.dirtyOrder) > 0 {
102 r := q.dirtyOrder[0]115 r := q.dirtyOrder[0]
103 q.dirtyOrder = q.dirtyOrder[1:]116 q.dirtyOrder = q.dirtyOrder[1:]
104 delete(q.dirty, r)117 delete(q.dirty, r)
105 dirty = &r118 dirty = &r
106 }119 }
120+ q.signalIfWaitingLocked()
121+ return key, dirty
122+}
123+
124+func (q *mergeQueue) signalIfWaitingLocked() {
107 if len(q.order) > 0 || len(q.dirtyOrder) > 0 {125 if len(q.order) > 0 || len(q.dirtyOrder) > 0 {
108 q.signal() // let another worker take the rest126 q.signal() // let another worker take the rest
109 }127 }
110- return key, dirty
111 }128 }
112 129
113 // tryRun marks k running for a rescan; when it already runs it asks for130 // tryRun marks k running for a rescan; when it already runs it asks for
M internal/usecase/mergeability_queue_test.go +26 -0
@@ -46,3 +46,29 @@ func TestMergeQueueNextMarksAgainForARunningKey(t *testing.T) {
4646 assert.True(t, q.again[k])
4747 assert.False(t, q.pending[k])
4848 }
49+
50+func TestMergeQueueNextServicesADirtyRepoUnderSustainedRefill(t *testing.T) {
51+ repo, err := domain.ParseRepoName("proj")
52+ require.NoError(t, err)
53+ q := newMergeQueue(2)
54+ q.markDirty(repo)
55+ n := int64(0)
56+ refill := func() {
57+ for range 2 {
58+ n++
59+ q.add(mergeKey{repo, n})
60+ }
61+ }
62+ refill()
63+ for range 3 {
64+ key, dirty := q.next()
65+ if dirty != nil {
66+ assert.Equal(t, repo, *dirty)
67+ return
68+ }
69+ require.NotNil(t, key)
70+ q.finish(*key)
71+ refill()
72+ }
73+ t.Fatal("the dirty repository starved")
74+}
@@ -46,3 +46,29 @@ func TestMergeQueueNextMarksAgainForARunningKey(t *testing.T) {
46 assert.True(t, q.again[k])46 assert.True(t, q.again[k])
47 assert.False(t, q.pending[k])47 assert.False(t, q.pending[k])
48 }48 }
49+
50+func TestMergeQueueNextServicesADirtyRepoUnderSustainedRefill(t *testing.T) {
51+ repo, err := domain.ParseRepoName("proj")
52+ require.NoError(t, err)
53+ q := newMergeQueue(2)
54+ q.markDirty(repo)
55+ n := int64(0)
56+ refill := func() {
57+ for range 2 {
58+ n++
59+ q.add(mergeKey{repo, n})
60+ }
61+ }
62+ refill()
63+ for range 3 {
64+ key, dirty := q.next()
65+ if dirty != nil {
66+ assert.Equal(t, repo, *dirty)
67+ return
68+ }
69+ require.NotNil(t, key)
70+ q.finish(*key)
71+ refill()
72+ }
73+ t.Fatal("the dirty repository starved")
74+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 43

↑↓ moveenter openesc close