detached 17b5f65

fix(kvstore): bound the numbering locks

commit
17b5f65f1ffa1940fe6b5b577a4f79e02f4b4e49
author
brice · · 2026-10-19 08:52 UTC
parent
b0db7ad
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +34 -21
M internal/adapters/kvstore/issues.go +4 -3
@@ -31,14 +31,15 @@ const (
3131 // Issues is the ermite-kv backed ports.IssueStore. It shares the database of
3232 // the Store it comes from.
3333 type Issues struct {
34- db *kv.DB
34+ db *kv.DB
35+ locks *numberLocks
3536 }
3637
3738 var _ ports.IssueStore = (*Issues)(nil)
3839
3940 // Issues returns the issue store over the same database. It is valid until the
4041 // Store is closed.
41-func (s *Store) Issues() *Issues { return &Issues{db: s.db} }
42+func (s *Store) Issues() *Issues { return &Issues{db: s.db, locks: s.locks} }
4243
4344 func issueRepoPrefix(repo domain.RepoName) string { return issuePrefix + repo.String() + "/" }
4445 func issueKey(repo domain.RepoName, n int64) []byte {
@@ -86,7 +87,7 @@ func issueKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][
8687 //
8788 //hexcheck:ignore suspicious-business-logic-in-adapter takes the next number under a version precondition: a storage concern, no policy
8889 func (i *Issues) Create(ctx context.Context, issue domain.Issue) (domain.Issue, error) {
89- err := createNumbered(ctx, i.db, issue.Repo, func(b *kv.Batch, n int64) error {
90+ err := createNumbered(ctx, i.db, i.locks, issue.Repo, func(b *kv.Batch, n int64) error {
9091 issue.Number = n
9192 val, err := encodeIssue(issue)
9293 if err != nil {
@@ -31,14 +31,15 @@ const (
31 // Issues is the ermite-kv backed ports.IssueStore. It shares the database of31 // Issues is the ermite-kv backed ports.IssueStore. It shares the database of
32 // the Store it comes from.32 // the Store it comes from.
33 type Issues struct {33 type Issues struct {
34- db *kv.DB34+ db *kv.DB
35+ locks *numberLocks
35 }36 }
36 37
37 var _ ports.IssueStore = (*Issues)(nil)38 var _ ports.IssueStore = (*Issues)(nil)
38 39
39 // Issues returns the issue store over the same database. It is valid until the40 // Issues returns the issue store over the same database. It is valid until the
40 // Store is closed.41 // Store is closed.
41-func (s *Store) Issues() *Issues { return &Issues{db: s.db} }42+func (s *Store) Issues() *Issues { return &Issues{db: s.db, locks: s.locks} }
42 43
43 func issueRepoPrefix(repo domain.RepoName) string { return issuePrefix + repo.String() + "/" }44 func issueRepoPrefix(repo domain.RepoName) string { return issuePrefix + repo.String() + "/" }
44 func issueKey(repo domain.RepoName, n int64) []byte {45 func issueKey(repo domain.RepoName, n int64) []byte {
@@ -86,7 +87,7 @@ func issueKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][
86 //87 //
87 //hexcheck:ignore suspicious-business-logic-in-adapter takes the next number under a version precondition: a storage concern, no policy88 //hexcheck:ignore suspicious-business-logic-in-adapter takes the next number under a version precondition: a storage concern, no policy
88 func (i *Issues) Create(ctx context.Context, issue domain.Issue) (domain.Issue, error) {89 func (i *Issues) Create(ctx context.Context, issue domain.Issue) (domain.Issue, error) {
89- err := createNumbered(ctx, i.db, issue.Repo, func(b *kv.Batch, n int64) error {90+ err := createNumbered(ctx, i.db, i.locks, issue.Repo, func(b *kv.Batch, n int64) error {
90 issue.Number = n91 issue.Number = n
91 val, err := encodeIssue(issue)92 val, err := encodeIssue(issue)
92 if err != nil {93 if err != nil {
M internal/adapters/kvstore/issues_pending.go +5 -4
@@ -37,14 +37,15 @@ const (
3737 // PendingIssues is the ermite-kv backed ports.PendingIssueStore. It shares the
3838 // database of the Store it comes from.
3939 type PendingIssues struct {
40- db *kv.DB
40+ db *kv.DB
41+ locks *numberLocks
4142 }
4243
4344 var _ ports.PendingIssueStore = (*PendingIssues)(nil)
4445
4546 // PendingIssues returns the pending issue store over the same database. It is
4647 // valid until the Store is closed.
47-func (s *Store) PendingIssues() *PendingIssues { return &PendingIssues{db: s.db} }
48+func (s *Store) PendingIssues() *PendingIssues { return &PendingIssues{db: s.db, locks: s.locks} }
4849
4950 func pendingRepoPrefix(repo domain.RepoName) string { return pendingPrefix + repo.String() + "/" }
5051 func pendingKey(repo domain.RepoName, id domain.PendingIssueID) []byte {
@@ -222,7 +223,7 @@ func (s *PendingIssues) Count(ctx context.Context) (int, error) {
222223 //hexcheck:ignore suspicious-business-logic-in-adapter takes the next number under a version precondition: a storage concern, no policy
223224 func (s *PendingIssues) Approve(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID) (domain.Issue, error) {
224225 var number int64
225- err := createNumbered(ctx, s.db, repo, func(b *kv.Batch, n int64) error {
226+ err := createNumbered(ctx, s.db, s.locks, repo, func(b *kv.Batch, n int64) error {
226227 key := pendingKey(repo, id)
227228 item, ok := s.db.Get(ctx, key)
228229 if !ok {
@@ -258,7 +259,7 @@ func (s *PendingIssues) Approve(ctx context.Context, repo domain.RepoName, id do
258259 }
259260 return domain.Issue{}, fmt.Errorf("approve pending issue of %q: %w", repo, err)
260261 }
261- return (&Issues{db: s.db}).Get(ctx, repo, number)
262+ return (&Issues{db: s.db, locks: s.locks}).Get(ctx, repo, number)
262263 }
263264
264265 // Delete removes the pending record, or returns domain.ErrNotFound.
@@ -37,14 +37,15 @@ const (
37 // PendingIssues is the ermite-kv backed ports.PendingIssueStore. It shares the37 // PendingIssues is the ermite-kv backed ports.PendingIssueStore. It shares the
38 // database of the Store it comes from.38 // database of the Store it comes from.
39 type PendingIssues struct {39 type PendingIssues struct {
40- db *kv.DB40+ db *kv.DB
41+ locks *numberLocks
41 }42 }
42 43
43 var _ ports.PendingIssueStore = (*PendingIssues)(nil)44 var _ ports.PendingIssueStore = (*PendingIssues)(nil)
44 45
45 // PendingIssues returns the pending issue store over the same database. It is46 // PendingIssues returns the pending issue store over the same database. It is
46 // valid until the Store is closed.47 // valid until the Store is closed.
47-func (s *Store) PendingIssues() *PendingIssues { return &PendingIssues{db: s.db} }48+func (s *Store) PendingIssues() *PendingIssues { return &PendingIssues{db: s.db, locks: s.locks} }
48 49
49 func pendingRepoPrefix(repo domain.RepoName) string { return pendingPrefix + repo.String() + "/" }50 func pendingRepoPrefix(repo domain.RepoName) string { return pendingPrefix + repo.String() + "/" }
50 func pendingKey(repo domain.RepoName, id domain.PendingIssueID) []byte {51 func pendingKey(repo domain.RepoName, id domain.PendingIssueID) []byte {
@@ -222,7 +223,7 @@ func (s *PendingIssues) Count(ctx context.Context) (int, error) {
222 //hexcheck:ignore suspicious-business-logic-in-adapter takes the next number under a version precondition: a storage concern, no policy223 //hexcheck:ignore suspicious-business-logic-in-adapter takes the next number under a version precondition: a storage concern, no policy
223 func (s *PendingIssues) Approve(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID) (domain.Issue, error) {224 func (s *PendingIssues) Approve(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID) (domain.Issue, error) {
224 var number int64225 var number int64
225- err := createNumbered(ctx, s.db, repo, func(b *kv.Batch, n int64) error {226+ err := createNumbered(ctx, s.db, s.locks, repo, func(b *kv.Batch, n int64) error {
226 key := pendingKey(repo, id)227 key := pendingKey(repo, id)
227 item, ok := s.db.Get(ctx, key)228 item, ok := s.db.Get(ctx, key)
228 if !ok {229 if !ok {
@@ -258,7 +259,7 @@ func (s *PendingIssues) Approve(ctx context.Context, repo domain.RepoName, id do
258 }259 }
259 return domain.Issue{}, fmt.Errorf("approve pending issue of %q: %w", repo, err)260 return domain.Issue{}, fmt.Errorf("approve pending issue of %q: %w", repo, err)
260 }261 }
261- return (&Issues{db: s.db}).Get(ctx, repo, number)262+ return (&Issues{db: s.db, locks: s.locks}).Get(ctx, repo, number)
262 }263 }
263 264
264 // Delete removes the pending record, or returns domain.ErrNotFound.265 // Delete removes the pending record, or returns domain.ErrNotFound.
M internal/adapters/kvstore/mergerequests.go +22 -12
@@ -6,6 +6,7 @@ import (
66 "context"
77 "errors"
88 "fmt"
9+ "hash/fnv"
910 "net/url"
1011 "slices"
1112 "strconv"
@@ -45,14 +46,15 @@ const (
4546 // MergeRequests is the ermite-kv backed ports.MergeRequestStore. It shares the
4647 // database of the Store it comes from.
4748 type MergeRequests struct {
48- db *kv.DB
49+ db *kv.DB
50+ locks *numberLocks
4951 }
5052
5153 var _ ports.MergeRequestStore = (*MergeRequests)(nil)
5254
5355 // MergeRequests returns the merge request store over the same database. It is
5456 // valid until the Store is closed.
55-func (s *Store) MergeRequests() *MergeRequests { return &MergeRequests{db: s.db} }
57+func (s *Store) MergeRequests() *MergeRequests { return &MergeRequests{db: s.db, locks: s.locks} }
5658
5759 func mrRepoPrefix(repo domain.RepoName) string { return mrPrefix + repo.String() + "/" }
5860 func mrKey(repo domain.RepoName, n int64) []byte {
@@ -199,7 +201,7 @@ func (m *MergeRequests) guardRepo(ctx context.Context, b *kv.Batch, repo domain.
199201 //
200202 //hexcheck:ignore suspicious-business-logic-in-adapter takes the next number under a version precondition: a storage concern, no policy
201203 func (m *MergeRequests) Create(ctx context.Context, mr domain.MergeRequest) (domain.MergeRequest, error) {
202- err := createNumbered(ctx, m.db, mr.Repo, func(b *kv.Batch, n int64) error {
204+ err := createNumbered(ctx, m.db, m.locks, mr.Repo, func(b *kv.Batch, n int64) error {
203205 mr.Number = n
204206 val, err := encodeMergeRequest(mr)
205207 if err != nil {
@@ -224,11 +226,11 @@ func (m *MergeRequests) Create(ctx context.Context, mr domain.MergeRequest) (dom
224226 // number. It retries when the batch conflicts; a missing repository gives
225227 // domain.ErrNotFound.
226228 //
227-// Creations in one repository are serialized in the process (numberLock), so
229+// Creations in one repository are serialized in the process (numberLocks), so
228230 // a burst of merge requests and issues never exhausts the retries; the version
229231 // checks still guard against any other writer.
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+func createNumbered(ctx context.Context, db *kv.DB, locks *numberLocks, repo domain.RepoName, add func(b *kv.Batch, number int64) error) error {
233+ unlock := locks.lock(repo)
232234 defer unlock()
233235 for range createTries {
234236 repoItem, repoOK := db.Get(ctx, repoKey(repo))
@@ -266,13 +268,21 @@ func createNumbered(ctx context.Context, db *kv.DB, repo domain.RepoName, add fu
266268 return domain.ErrConflict
267269 }
268270
269-// numberLocks holds one mutex per repository for createNumbered.
270-var numberLocks sync.Map // domain.RepoName -> *sync.Mutex
271+// numberLockStripes is the size of the table of numbering locks.
272+const numberLockStripes = 64
271273
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)
274+// numberLocks is a fixed table of mutexes, one stripe per hash of the
275+// repository name, for createNumbered: the memory is bounded whatever the
276+// number of repositories, and two repositories sharing a stripe only wait for
277+// each other. One table is shared by every store of a Store, so the merge
278+// requests and the issues of a repository are numbered one at a time.
279+type numberLocks [numberLockStripes]sync.Mutex
280+
281+// lock locks the numbering of repo and returns the unlock function.
282+func (l *numberLocks) lock(repo domain.RepoName) func() {
283+ h := fnv.New32a()
284+ _, _ = h.Write([]byte(repo.String()))
285+ mu := &l[h.Sum32()%numberLockStripes]
276286 mu.Lock()
277287 return mu.Unlock
278288 }
@@ -6,6 +6,7 @@ import (
6 "context"6 "context"
7 "errors"7 "errors"
8 "fmt"8 "fmt"
9+ "hash/fnv"
9 "net/url"10 "net/url"
10 "slices"11 "slices"
11 "strconv"12 "strconv"
@@ -45,14 +46,15 @@ const (
45 // MergeRequests is the ermite-kv backed ports.MergeRequestStore. It shares the46 // MergeRequests is the ermite-kv backed ports.MergeRequestStore. It shares the
46 // database of the Store it comes from.47 // database of the Store it comes from.
47 type MergeRequests struct {48 type MergeRequests struct {
48- db *kv.DB49+ db *kv.DB
50+ locks *numberLocks
49 }51 }
50 52
51 var _ ports.MergeRequestStore = (*MergeRequests)(nil)53 var _ ports.MergeRequestStore = (*MergeRequests)(nil)
52 54
53 // MergeRequests returns the merge request store over the same database. It is55 // MergeRequests returns the merge request store over the same database. It is
54 // valid until the Store is closed.56 // valid until the Store is closed.
55-func (s *Store) MergeRequests() *MergeRequests { return &MergeRequests{db: s.db} }57+func (s *Store) MergeRequests() *MergeRequests { return &MergeRequests{db: s.db, locks: s.locks} }
56 58
57 func mrRepoPrefix(repo domain.RepoName) string { return mrPrefix + repo.String() + "/" }59 func mrRepoPrefix(repo domain.RepoName) string { return mrPrefix + repo.String() + "/" }
58 func mrKey(repo domain.RepoName, n int64) []byte {60 func mrKey(repo domain.RepoName, n int64) []byte {
@@ -199,7 +201,7 @@ func (m *MergeRequests) guardRepo(ctx context.Context, b *kv.Batch, repo domain.
199 //201 //
200 //hexcheck:ignore suspicious-business-logic-in-adapter takes the next number under a version precondition: a storage concern, no policy202 //hexcheck:ignore suspicious-business-logic-in-adapter takes the next number under a version precondition: a storage concern, no policy
201 func (m *MergeRequests) Create(ctx context.Context, mr domain.MergeRequest) (domain.MergeRequest, error) {203 func (m *MergeRequests) Create(ctx context.Context, mr domain.MergeRequest) (domain.MergeRequest, error) {
202- err := createNumbered(ctx, m.db, mr.Repo, func(b *kv.Batch, n int64) error {204+ err := createNumbered(ctx, m.db, m.locks, mr.Repo, func(b *kv.Batch, n int64) error {
203 mr.Number = n205 mr.Number = n
204 val, err := encodeMergeRequest(mr)206 val, err := encodeMergeRequest(mr)
205 if err != nil {207 if err != nil {
@@ -224,11 +226,11 @@ func (m *MergeRequests) Create(ctx context.Context, mr domain.MergeRequest) (dom
224 // number. It retries when the batch conflicts; a missing repository gives226 // number. It retries when the batch conflicts; a missing repository gives
225 // domain.ErrNotFound.227 // domain.ErrNotFound.
226 //228 //
227-// Creations in one repository are serialized in the process (numberLock), so229+// Creations in one repository are serialized in the process (numberLocks), so
228 // a burst of merge requests and issues never exhausts the retries; the version230 // a burst of merge requests and issues never exhausts the retries; the version
229 // checks still guard against any other writer.231 // checks still guard against any other writer.
230-func createNumbered(ctx context.Context, db *kv.DB, repo domain.RepoName, add func(b *kv.Batch, number int64) error) error {232+func createNumbered(ctx context.Context, db *kv.DB, locks *numberLocks, repo domain.RepoName, add func(b *kv.Batch, number int64) error) error {
231- unlock := numberLock(repo)233+ unlock := locks.lock(repo)
232 defer unlock()234 defer unlock()
233 for range createTries {235 for range createTries {
234 repoItem, repoOK := db.Get(ctx, repoKey(repo))236 repoItem, repoOK := db.Get(ctx, repoKey(repo))
@@ -266,13 +268,21 @@ func createNumbered(ctx context.Context, db *kv.DB, repo domain.RepoName, add fu
266 return domain.ErrConflict268 return domain.ErrConflict
267 }269 }
268 270
269-// numberLocks holds one mutex per repository for createNumbered.271+// numberLockStripes is the size of the table of numbering locks.
270-var numberLocks sync.Map // domain.RepoName -> *sync.Mutex272+const numberLockStripes = 64
271 273
272-// numberLock locks the numbering of repo and returns the unlock function.274+// numberLocks is a fixed table of mutexes, one stripe per hash of the
273-func numberLock(repo domain.RepoName) func() {275+// repository name, for createNumbered: the memory is bounded whatever the
274- v, _ := numberLocks.LoadOrStore(repo, new(sync.Mutex))276+// number of repositories, and two repositories sharing a stripe only wait for
275- mu := v.(*sync.Mutex)277+// each other. One table is shared by every store of a Store, so the merge
278+// requests and the issues of a repository are numbered one at a time.
279+type numberLocks [numberLockStripes]sync.Mutex
280+
281+// lock locks the numbering of repo and returns the unlock function.
282+func (l *numberLocks) lock(repo domain.RepoName) func() {
283+ h := fnv.New32a()
284+ _, _ = h.Write([]byte(repo.String()))
285+ mu := &l[h.Sum32()%numberLockStripes]
276 mu.Lock()286 mu.Lock()
277 return mu.Unlock287 return mu.Unlock
278 }288 }
M internal/adapters/kvstore/store.go +3 -2
@@ -48,7 +48,8 @@ var ErrSchemaTooNew = errors.New("storage schema is newer than this ermite suppo
4848
4949 // Store is the ermite-kv backed metadata store.
5050 type Store struct {
51- db *kv.DB
51+ db *kv.DB
52+ locks *numberLocks // shared by the merge request and issue stores
5253 }
5354
5455 var _ ports.RepoMetaStore = (*Store)(nil)
@@ -61,7 +62,7 @@ func Open(ctx context.Context, dir string) (*Store, error) {
6162 if err != nil {
6263 return nil, fmt.Errorf("open ermite-kv in %s: %w", dir, err)
6364 }
64- s := &Store{db: db}
65+ s := &Store{db: db, locks: new(numberLocks)}
6566 if err := s.checkSchema(ctx); err != nil {
6667 _ = db.Close(context.WithoutCancel(ctx))
6768 return nil, err
@@ -48,7 +48,8 @@ var ErrSchemaTooNew = errors.New("storage schema is newer than this ermite suppo
48 48
49 // Store is the ermite-kv backed metadata store.49 // Store is the ermite-kv backed metadata store.
50 type Store struct {50 type Store struct {
51- db *kv.DB51+ db *kv.DB
52+ locks *numberLocks // shared by the merge request and issue stores
52 }53 }
53 54
54 var _ ports.RepoMetaStore = (*Store)(nil)55 var _ ports.RepoMetaStore = (*Store)(nil)
@@ -61,7 +62,7 @@ func Open(ctx context.Context, dir string) (*Store, error) {
61 if err != nil {62 if err != nil {
62 return nil, fmt.Errorf("open ermite-kv in %s: %w", dir, err)63 return nil, fmt.Errorf("open ermite-kv in %s: %w", dir, err)
63 }64 }
64- s := &Store{db: db}65+ s := &Store{db: db, locks: new(numberLocks)}
65 if err := s.checkSchema(ctx); err != nil {66 if err := s.checkSchema(ctx); err != nil {
66 _ = db.Close(context.WithoutCancel(ctx))67 _ = db.Close(context.WithoutCancel(ctx))
67 return nil, err68 return nil, err

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