detached 68460c1

fix(kvstore): serialize numbering per repository so concurrent creates never exhaust retries

commit
68460c13938ad84411cfbc24cb85e81abd579fd9
author
brice · · 2026-10-19 06:17 UTC
parent
b2212f2
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +18 -0
M internal/adapters/kvstore/mergerequests.go +18 -0
@@ -10,6 +10,7 @@ import (
1010 "slices"
1111 "strconv"
1212 "strings"
13+ "sync"
1314 "unicode/utf8"
1415
1516 kv "bnema.dev/ermite-kv"
@@ -222,7 +223,13 @@ func (m *MergeRequests) Create(ctx context.Context, mr domain.MergeRequest) (dom
222223 // guardRepoKey): two concurrent creations, of either kind, never share a
223224 // number. It retries when the batch conflicts; a missing repository gives
224225 // domain.ErrNotFound.
226+//
227+// Creations in one repository are serialized in the process (numberLock), so
228+// a burst of merge requests and issues never exhausts the retries; the version
229+// checks still guard against any other writer.
225230 func createNumbered(ctx context.Context, db *kv.DB, repo domain.RepoName, add func(b *kv.Batch, number int64) error) error {
231+ unlock := numberLock(repo)
232+ defer unlock()
226233 for range createTries {
227234 repoItem, repoOK := db.Get(ctx, repoKey(repo))
228235 if !repoOK {
@@ -259,6 +266,17 @@ func createNumbered(ctx context.Context, db *kv.DB, repo domain.RepoName, add fu
259266 return domain.ErrConflict
260267 }
261268
269+// numberLocks holds one mutex per repository for createNumbered.
270+var numberLocks sync.Map // domain.RepoName -> *sync.Mutex
271+
272+// numberLock locks the numbering of repo and returns the unlock function.
273+func numberLock(repo domain.RepoName) func() {
274+ v, _ := numberLocks.LoadOrStore(repo, new(sync.Mutex))
275+ mu := v.(*sync.Mutex)
276+ mu.Lock()
277+ return mu.Unlock
278+}
279+
262280 // Get reads mr/<repo>/<number>.
263281 func (m *MergeRequests) Get(ctx context.Context, repo domain.RepoName, number int64) (domain.MergeRequest, error) {
264282 if number < 1 {
@@ -10,6 +10,7 @@ import (
10 "slices"10 "slices"
11 "strconv"11 "strconv"
12 "strings"12 "strings"
13+ "sync"
13 "unicode/utf8"14 "unicode/utf8"
14 15
15 kv "bnema.dev/ermite-kv"16 kv "bnema.dev/ermite-kv"
@@ -222,7 +223,13 @@ func (m *MergeRequests) Create(ctx context.Context, mr domain.MergeRequest) (dom
222 // guardRepoKey): two concurrent creations, of either kind, never share a223 // guardRepoKey): two concurrent creations, of either kind, never share a
223 // number. It retries when the batch conflicts; a missing repository gives224 // number. It retries when the batch conflicts; a missing repository gives
224 // domain.ErrNotFound.225 // domain.ErrNotFound.
226+//
227+// Creations in one repository are serialized in the process (numberLock), so
228+// a burst of merge requests and issues never exhausts the retries; the version
229+// checks still guard against any other writer.
225 func createNumbered(ctx context.Context, db *kv.DB, repo domain.RepoName, add func(b *kv.Batch, number int64) error) error {230 func createNumbered(ctx context.Context, db *kv.DB, repo domain.RepoName, add func(b *kv.Batch, number int64) error) error {
231+ unlock := numberLock(repo)
232+ defer unlock()
226 for range createTries {233 for range createTries {
227 repoItem, repoOK := db.Get(ctx, repoKey(repo))234 repoItem, repoOK := db.Get(ctx, repoKey(repo))
228 if !repoOK {235 if !repoOK {
@@ -259,6 +266,17 @@ func createNumbered(ctx context.Context, db *kv.DB, repo domain.RepoName, add fu
259 return domain.ErrConflict266 return domain.ErrConflict
260 }267 }
261 268
269+// numberLocks holds one mutex per repository for createNumbered.
270+var numberLocks sync.Map // domain.RepoName -> *sync.Mutex
271+
272+// numberLock locks the numbering of repo and returns the unlock function.
273+func numberLock(repo domain.RepoName) func() {
274+ v, _ := numberLocks.LoadOrStore(repo, new(sync.Mutex))
275+ mu := v.(*sync.Mutex)
276+ mu.Lock()
277+ return mu.Unlock
278+}
279+
262 // Get reads mr/<repo>/<number>.280 // Get reads mr/<repo>/<number>.
263 func (m *MergeRequests) Get(ctx context.Context, repo domain.RepoName, number int64) (domain.MergeRequest, error) {281 func (m *MergeRequests) Get(ctx context.Context, repo domain.RepoName, number int64) (domain.MergeRequest, error) {
264 if number < 1 {282 if number < 1 {

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