detached 2794dd4

fix(kvstore): guard the repository key when updating an issue so a repo delete leaves no open-issue index keys

commit
2794dd48b17ea72ce2cede01a87760233d3464b5
author
brice · · 2026-10-19 06:10 UTC
parent
02692c4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +36 -1
M internal/adapters/kvstore/issues.go +5 -1
@@ -186,7 +186,8 @@ func (i *Issues) ListOpen(ctx context.Context, repo domain.RepoName) ([]domain.I
186186 }
187187
188188 // Update replaces the issue with a version precondition and moves its index key
189-// in the same batch.
189+// in the same batch. The repository must exist (see guardRepoKey), so that a
190+// concurrent delete of the repository leaves no index key behind.
190191 //
191192 //hexcheck:ignore suspicious-business-logic-in-adapter port method name only
192193 func (i *Issues) Update(ctx context.Context, issue domain.Issue) error {
@@ -202,6 +203,9 @@ func (i *Issues) Update(ctx context.Context, issue domain.Issue) error {
202203 return fmt.Errorf("update issue %d of %q: read it first: %w", issue.Number, issue.Repo, domain.ErrConflict)
203204 }
204205 var b kv.Batch
206+ if err := guardRepoKey(ctx, i.db, &b, issue.Repo); err != nil {
207+ return err
208+ }
205209 b.RequireVersion(key, issue.Version)
206210 b.Put(key, val)
207211 indexOpenIssue(&b, issue)
@@ -186,7 +186,8 @@ func (i *Issues) ListOpen(ctx context.Context, repo domain.RepoName) ([]domain.I
186 }186 }
187 187
188 // Update replaces the issue with a version precondition and moves its index key188 // Update replaces the issue with a version precondition and moves its index key
189-// in the same batch.189+// in the same batch. The repository must exist (see guardRepoKey), so that a
190+// concurrent delete of the repository leaves no index key behind.
190 //191 //
191 //hexcheck:ignore suspicious-business-logic-in-adapter port method name only192 //hexcheck:ignore suspicious-business-logic-in-adapter port method name only
192 func (i *Issues) Update(ctx context.Context, issue domain.Issue) error {193 func (i *Issues) Update(ctx context.Context, issue domain.Issue) error {
@@ -202,6 +203,9 @@ func (i *Issues) Update(ctx context.Context, issue domain.Issue) error {
202 return fmt.Errorf("update issue %d of %q: read it first: %w", issue.Number, issue.Repo, domain.ErrConflict)203 return fmt.Errorf("update issue %d of %q: read it first: %w", issue.Number, issue.Repo, domain.ErrConflict)
203 }204 }
204 var b kv.Batch205 var b kv.Batch
206+ if err := guardRepoKey(ctx, i.db, &b, issue.Repo); err != nil {
207+ return err
208+ }
205 b.RequireVersion(key, issue.Version)209 b.RequireVersion(key, issue.Version)
206 b.Put(key, val)210 b.Put(key, val)
207 indexOpenIssue(&b, issue)211 indexOpenIssue(&b, issue)
M internal/adapters/kvstore/issues_test.go +31 -0
@@ -337,3 +337,34 @@ func TestSchemaNewerThanThisBuildStopsIssuesData(t *testing.T) {
337337 require.ErrorIs(t, err, kvstore.ErrSchemaTooNew)
338338 assert.Equal(t, uint32(4), uint32(kvstore.SchemaVersion))
339339 }
340+
341+// A reopen racing the delete of the repository either lands before the delete
342+// removes it or fails; it never leaves an isopen/ key of a repository that is
343+// gone.
344+func TestDeleteVersusReopenLeavesNoIssueKeys(t *testing.T) {
345+ ctx := context.Background()
346+ for range 30 {
347+ s, _, n := repoWithMRs(t)
348+ is := s.Issues()
349+ issue, err := is.Create(ctx, newIssue(t, n, "t"))
350+ require.NoError(t, err)
351+ issue.State = domain.IssueStateClosed
352+ require.NoError(t, is.Update(ctx, issue))
353+ closed, err := is.Get(ctx, n, issue.Number)
354+ require.NoError(t, err)
355+ closed.State = domain.IssueStateOpen
356+
357+ var wg sync.WaitGroup
358+ start := make(chan struct{})
359+ wg.Add(2)
360+ go func() { defer wg.Done(); <-start; _ = is.Update(ctx, closed) }()
361+ go func() { defer wg.Done(); <-start; _ = s.Delete(ctx, n) }()
362+ close(start)
363+ wg.Wait()
364+
365+ for _, prefix := range []string{"is/proj/", "isc/proj/", "isopen/proj/"} {
366+ assert.Zero(t, countKeys(t, s, prefix), prefix)
367+ }
368+ assert.ErrorIs(t, is.Update(ctx, closed), domain.ErrNotFound)
369+ }
370+}
@@ -337,3 +337,34 @@ func TestSchemaNewerThanThisBuildStopsIssuesData(t *testing.T) {
337 require.ErrorIs(t, err, kvstore.ErrSchemaTooNew)337 require.ErrorIs(t, err, kvstore.ErrSchemaTooNew)
338 assert.Equal(t, uint32(4), uint32(kvstore.SchemaVersion))338 assert.Equal(t, uint32(4), uint32(kvstore.SchemaVersion))
339 }339 }
340+
341+// A reopen racing the delete of the repository either lands before the delete
342+// removes it or fails; it never leaves an isopen/ key of a repository that is
343+// gone.
344+func TestDeleteVersusReopenLeavesNoIssueKeys(t *testing.T) {
345+ ctx := context.Background()
346+ for range 30 {
347+ s, _, n := repoWithMRs(t)
348+ is := s.Issues()
349+ issue, err := is.Create(ctx, newIssue(t, n, "t"))
350+ require.NoError(t, err)
351+ issue.State = domain.IssueStateClosed
352+ require.NoError(t, is.Update(ctx, issue))
353+ closed, err := is.Get(ctx, n, issue.Number)
354+ require.NoError(t, err)
355+ closed.State = domain.IssueStateOpen
356+
357+ var wg sync.WaitGroup
358+ start := make(chan struct{})
359+ wg.Add(2)
360+ go func() { defer wg.Done(); <-start; _ = is.Update(ctx, closed) }()
361+ go func() { defer wg.Done(); <-start; _ = s.Delete(ctx, n) }()
362+ close(start)
363+ wg.Wait()
364+
365+ for _, prefix := range []string{"is/proj/", "isc/proj/", "isopen/proj/"} {
366+ assert.Zero(t, countKeys(t, s, prefix), prefix)
367+ }
368+ assert.ErrorIs(t, is.Update(ctx, closed), domain.ErrNotFound)
369+ }
370+}

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