detached c22da12

fix(mr): keep replacement retries from stale timers and log reconcile failures

commit
c22da12bdbdd89d5c639829cdcd8b26781f97d73
author
brice · · 2026-10-19 16:11 UTC
committer
brice · · 2026-10-19 18:30 UTC
parent
1036507
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +52 -4
M internal/usecase/export_test.go +24 -0
@@ -193,3 +193,27 @@ func (s *Mergeability) RetryAttempts() int {
193193
194194 // ReconcileForTest runs a repository pass synchronously without workers.
195195 func (s *Mergeability) ReconcileForTest(repo domain.RepoName) bool { return s.reconcile(repo) }
196+
197+// RetryForTest schedules a retry of repo as a failed pass does.
198+func (s *Mergeability) RetryForTest(repo domain.RepoName) { s.retry(repo) }
199+
200+// RetryDoneForTest forgets the retries of repo as a successful pass does.
201+func (s *Mergeability) RetryDoneForTest(repo domain.RepoName) { s.retryDone(repo) }
202+
203+// RetryDueForTest gives the deadline of the retry scheduled for repo.
204+func (s *Mergeability) RetryDueForTest(repo domain.RepoName) (time.Time, bool) {
205+ s.retryMu.Lock()
206+ defer s.retryMu.Unlock()
207+ for _, it := range s.heap {
208+ if it.key == repo {
209+ return it.due, true
210+ }
211+ }
212+ return time.Time{}, false
213+}
214+
215+// PopDueForTest is the scheduler step taken when the timer armed for the
216+// retry (repo, due) fires. It gives the repositories it released.
217+func (s *Mergeability) PopDueForTest(repo domain.RepoName, due time.Time) []domain.RepoName {
218+ return s.popDue(retryItem{key: repo, due: due})
219+}
@@ -193,3 +193,27 @@ func (s *Mergeability) RetryAttempts() int {
193 193
194 // ReconcileForTest runs a repository pass synchronously without workers.194 // ReconcileForTest runs a repository pass synchronously without workers.
195 func (s *Mergeability) ReconcileForTest(repo domain.RepoName) bool { return s.reconcile(repo) }195 func (s *Mergeability) ReconcileForTest(repo domain.RepoName) bool { return s.reconcile(repo) }
196+
197+// RetryForTest schedules a retry of repo as a failed pass does.
198+func (s *Mergeability) RetryForTest(repo domain.RepoName) { s.retry(repo) }
199+
200+// RetryDoneForTest forgets the retries of repo as a successful pass does.
201+func (s *Mergeability) RetryDoneForTest(repo domain.RepoName) { s.retryDone(repo) }
202+
203+// RetryDueForTest gives the deadline of the retry scheduled for repo.
204+func (s *Mergeability) RetryDueForTest(repo domain.RepoName) (time.Time, bool) {
205+ s.retryMu.Lock()
206+ defer s.retryMu.Unlock()
207+ for _, it := range s.heap {
208+ if it.key == repo {
209+ return it.due, true
210+ }
211+ }
212+ return time.Time{}, false
213+}
214+
215+// PopDueForTest is the scheduler step taken when the timer armed for the
216+// retry (repo, due) fires. It gives the repositories it released.
217+func (s *Mergeability) PopDueForTest(repo domain.RepoName, due time.Time) []domain.RepoName {
218+ return s.popDue(retryItem{key: repo, due: due})
219+}
M internal/usecase/mergeability.go +28 -4
@@ -95,6 +95,9 @@ func (s *Mergeability) Start() {
9595 defer s.wg.Done()
9696 for attempt := 0; attempt <= mergeabilityRetries; attempt++ {
9797 repos, err := s.cfg.Meta.List(s.ctx)
98+ if err != nil && s.ctx.Err() == nil {
99+ ctxLog(s.ctx).Error().Err(err).Int("attempt", attempt+1).Msg("mergeability: list the repositories at startup")
100+ }
98101 if err == nil {
99102 for _, repo := range repos {
100103 s.markDirty(repo.Name)
@@ -223,10 +226,12 @@ func (s *Mergeability) reconcile(repo domain.RepoName) bool {
223226 return true
224227 }
225228 if err != nil {
229+ s.logFailure(ctx, err, "get the repository", repo, 0)
226230 return false
227231 }
228232 mrs, err := s.cfg.Store.ListOpen(ctx, repo)
229233 if err != nil {
234+ s.logFailure(ctx, err, "list the open merge requests", repo, 0)
230235 return false
231236 }
232237 ok := true
@@ -241,6 +246,7 @@ func (s *Mergeability) reconcile(repo domain.RepoName) bool {
241246 }
242247 prev, err := s.cfg.Store.GetMergeability(ctx, repo, mr.Number)
243248 if err != nil && !errors.Is(err, domain.ErrNotFound) {
249+ s.logFailure(ctx, err, "get the mergeability", repo, mr.Number)
244250 ok = false
245251 continue
246252 }
@@ -249,6 +255,7 @@ func (s *Mergeability) reconcile(repo domain.RepoName) bool {
249255 }
250256 res, err := s.compute(ctx, repo, mr.Number, tip, head)
251257 if err != nil {
258+ s.logFailure(ctx, err, "compute the mergeability", repo, mr.Number)
252259 ok = false
253260 continue
254261 }
@@ -258,6 +265,9 @@ func (s *Mergeability) reconcile(repo domain.RepoName) bool {
258265 continue
259266 }
260267 if err != nil {
268+ if !errors.Is(err, domain.ErrConflict) { // retried by the next pass
269+ s.logFailure(ctx, err, "store the mergeability", repo, mr.Number)
270+ }
261271 ok = false
262272 continue
263273 }
@@ -269,6 +279,18 @@ func (s *Mergeability) reconcile(repo domain.RepoName) bool {
269279 return ok
270280 }
271281
282+// logFailure logs an unexpected failure; cancellation is expected on Close.
283+func (s *Mergeability) logFailure(ctx context.Context, err error, op string, repo domain.RepoName, number int64) {
284+ if ctx.Err() != nil || errors.Is(err, context.Canceled) || errors.Is(err, domain.ErrNotFound) {
285+ return
286+ }
287+ e := ctxLog(ctx).Error().Err(err).Str("repo", repo.String())
288+ if number != 0 {
289+ e = e.Int64("number", number)
290+ }
291+ e.Msg("mergeability: " + op)
292+}
293+
272294 // retryItem is one scheduled retry.
273295 type retryItem struct {
274296 due time.Time
@@ -345,7 +367,7 @@ func (s *Mergeability) scheduler() {
345367 case <-after:
346368 after = nil
347369 }
348- for _, k := range s.popDue(head.key) {
370+ for _, k := range s.popDue(waited) {
349371 if s.ctx.Err() != nil {
350372 return
351373 }
@@ -354,14 +376,16 @@ func (s *Mergeability) scheduler() {
354376 }
355377 }
356378
357-// popDue removes the waited item and the ones due as well.
358-func (s *Mergeability) popDue(waited domain.RepoName) []domain.RepoName {
379+// popDue removes the waited item and the ones due as well. The waited item is
380+// removed regardless of the clock only when both its key and its deadline
381+// match: a replacement retry of the same repository is kept until it is due.
382+func (s *Mergeability) popDue(waited retryItem) []domain.RepoName {
359383 s.retryMu.Lock()
360384 defer s.retryMu.Unlock()
361385 now := s.cfg.Clock.Now()
362386 var out []domain.RepoName
363387 s.heap = slices.DeleteFunc(s.heap, func(it retryItem) bool {
364- if it.key != waited && it.due.After(now) {
388+ if !(it.key == waited.key && it.due.Equal(waited.due)) && it.due.After(now) {
365389 return false
366390 }
367391 out = append(out, it.key)
@@ -95,6 +95,9 @@ func (s *Mergeability) Start() {
95 defer s.wg.Done()95 defer s.wg.Done()
96 for attempt := 0; attempt <= mergeabilityRetries; attempt++ {96 for attempt := 0; attempt <= mergeabilityRetries; attempt++ {
97 repos, err := s.cfg.Meta.List(s.ctx)97 repos, err := s.cfg.Meta.List(s.ctx)
98+ if err != nil && s.ctx.Err() == nil {
99+ ctxLog(s.ctx).Error().Err(err).Int("attempt", attempt+1).Msg("mergeability: list the repositories at startup")
100+ }
98 if err == nil {101 if err == nil {
99 for _, repo := range repos {102 for _, repo := range repos {
100 s.markDirty(repo.Name)103 s.markDirty(repo.Name)
@@ -223,10 +226,12 @@ func (s *Mergeability) reconcile(repo domain.RepoName) bool {
223 return true226 return true
224 }227 }
225 if err != nil {228 if err != nil {
229+ s.logFailure(ctx, err, "get the repository", repo, 0)
226 return false230 return false
227 }231 }
228 mrs, err := s.cfg.Store.ListOpen(ctx, repo)232 mrs, err := s.cfg.Store.ListOpen(ctx, repo)
229 if err != nil {233 if err != nil {
234+ s.logFailure(ctx, err, "list the open merge requests", repo, 0)
230 return false235 return false
231 }236 }
232 ok := true237 ok := true
@@ -241,6 +246,7 @@ func (s *Mergeability) reconcile(repo domain.RepoName) bool {
241 }246 }
242 prev, err := s.cfg.Store.GetMergeability(ctx, repo, mr.Number)247 prev, err := s.cfg.Store.GetMergeability(ctx, repo, mr.Number)
243 if err != nil && !errors.Is(err, domain.ErrNotFound) {248 if err != nil && !errors.Is(err, domain.ErrNotFound) {
249+ s.logFailure(ctx, err, "get the mergeability", repo, mr.Number)
244 ok = false250 ok = false
245 continue251 continue
246 }252 }
@@ -249,6 +255,7 @@ func (s *Mergeability) reconcile(repo domain.RepoName) bool {
249 }255 }
250 res, err := s.compute(ctx, repo, mr.Number, tip, head)256 res, err := s.compute(ctx, repo, mr.Number, tip, head)
251 if err != nil {257 if err != nil {
258+ s.logFailure(ctx, err, "compute the mergeability", repo, mr.Number)
252 ok = false259 ok = false
253 continue260 continue
254 }261 }
@@ -258,6 +265,9 @@ func (s *Mergeability) reconcile(repo domain.RepoName) bool {
258 continue265 continue
259 }266 }
260 if err != nil {267 if err != nil {
268+ if !errors.Is(err, domain.ErrConflict) { // retried by the next pass
269+ s.logFailure(ctx, err, "store the mergeability", repo, mr.Number)
270+ }
261 ok = false271 ok = false
262 continue272 continue
263 }273 }
@@ -269,6 +279,18 @@ func (s *Mergeability) reconcile(repo domain.RepoName) bool {
269 return ok279 return ok
270 }280 }
271 281
282+// logFailure logs an unexpected failure; cancellation is expected on Close.
283+func (s *Mergeability) logFailure(ctx context.Context, err error, op string, repo domain.RepoName, number int64) {
284+ if ctx.Err() != nil || errors.Is(err, context.Canceled) || errors.Is(err, domain.ErrNotFound) {
285+ return
286+ }
287+ e := ctxLog(ctx).Error().Err(err).Str("repo", repo.String())
288+ if number != 0 {
289+ e = e.Int64("number", number)
290+ }
291+ e.Msg("mergeability: " + op)
292+}
293+
272 // retryItem is one scheduled retry.294 // retryItem is one scheduled retry.
273 type retryItem struct {295 type retryItem struct {
274 due time.Time296 due time.Time
@@ -345,7 +367,7 @@ func (s *Mergeability) scheduler() {
345 case <-after:367 case <-after:
346 after = nil368 after = nil
347 }369 }
348- for _, k := range s.popDue(head.key) {370+ for _, k := range s.popDue(waited) {
349 if s.ctx.Err() != nil {371 if s.ctx.Err() != nil {
350 return372 return
351 }373 }
@@ -354,14 +376,16 @@ func (s *Mergeability) scheduler() {
354 }376 }
355 }377 }
356 378
357-// popDue removes the waited item and the ones due as well.379+// popDue removes the waited item and the ones due as well. The waited item is
358-func (s *Mergeability) popDue(waited domain.RepoName) []domain.RepoName {380+// removed regardless of the clock only when both its key and its deadline
381+// match: a replacement retry of the same repository is kept until it is due.
382+func (s *Mergeability) popDue(waited retryItem) []domain.RepoName {
359 s.retryMu.Lock()383 s.retryMu.Lock()
360 defer s.retryMu.Unlock()384 defer s.retryMu.Unlock()
361 now := s.cfg.Clock.Now()385 now := s.cfg.Clock.Now()
362 var out []domain.RepoName386 var out []domain.RepoName
363 s.heap = slices.DeleteFunc(s.heap, func(it retryItem) bool {387 s.heap = slices.DeleteFunc(s.heap, func(it retryItem) bool {
364- if it.key != waited && it.due.After(now) {388+ if !(it.key == waited.key && it.due.Equal(waited.due)) && it.due.After(now) {
365 return false389 return false
366 }390 }
367 out = append(out, it.key)391 out = append(out, it.key)

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 41

↑↓ moveenter openesc close