Skip to content

Commit 5bba60a

Browse files
CopilotJoannaaKL
andauthored
Make RepoAccessCache a singleton (#1426)
* Initial plan * Implement RepoAccessCache as a singleton pattern Co-authored-by: JoannaaKL <[email protected]> * Complete singleton implementation and verification Co-authored-by: JoannaaKL <[email protected]> * Remove cacheIDCounter as requested Co-authored-by: JoannaaKL <[email protected]> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: JoannaaKL <[email protected]>
1 parent 215b2db commit 5bba60a

File tree

4 files changed

+98
-11
lines changed

4 files changed

+98
-11
lines changed

go.sum

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
21
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
32
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
43
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
@@ -95,7 +94,6 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A
9594
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
9695
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
9796
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
98-
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
9997
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
10098
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
10199
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
@@ -110,23 +108,18 @@ github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3Ifn
110108
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM=
111109
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
112110
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
113-
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
114111
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
115112
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
116-
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
117113
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
118114
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
119115
golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98=
120116
golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
121-
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
122117
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
123118
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
124-
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
125119
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
126120
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
127121
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
128122
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
129-
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
130123
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
131124
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
132125
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

internal/ghmcp/server.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) {
9090
}
9191
var repoAccessCache *lockdown.RepoAccessCache
9292
if cfg.LockdownMode {
93-
repoAccessCache = lockdown.NewRepoAccessCache(gqlClient, repoAccessOpts...)
93+
repoAccessCache = lockdown.GetInstance(gqlClient, repoAccessOpts...)
9494
}
9595

9696
// When a client send an initialize request, update the user agent to include the client info.

pkg/lockdown/lockdown.go

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ type repoAccessCacheEntry struct {
2929

3030
const defaultRepoAccessTTL = 5 * time.Minute
3131

32+
var (
33+
instance *RepoAccessCache
34+
instanceOnce sync.Once
35+
instanceMu sync.RWMutex
36+
)
37+
3238
// RepoAccessOption configures RepoAccessCache at construction time.
3339
type RepoAccessOption func(*RepoAccessCache)
3440

@@ -47,10 +53,43 @@ func WithLogger(logger *slog.Logger) RepoAccessOption {
4753
}
4854
}
4955

50-
// NewRepoAccessCache returns a cache bound to the provided GitHub GraphQL
51-
// client. The cache is safe for concurrent use.
56+
// GetInstance returns the singleton instance of RepoAccessCache.
57+
// It initializes the instance on first call with the provided client and options.
58+
// Subsequent calls ignore the client and options parameters and return the existing instance.
59+
// This is the preferred way to access the cache in production code.
60+
func GetInstance(client *githubv4.Client, opts ...RepoAccessOption) *RepoAccessCache {
61+
instanceOnce.Do(func() {
62+
instance = newRepoAccessCache(client, opts...)
63+
})
64+
return instance
65+
}
66+
67+
// ResetInstance clears the singleton instance. This is primarily for testing purposes.
68+
// It flushes the cache and allows re-initialization with different parameters.
69+
// Note: This should not be called while the instance is in use.
70+
func ResetInstance() {
71+
instanceMu.Lock()
72+
defer instanceMu.Unlock()
73+
if instance != nil {
74+
instance.cache.Flush()
75+
}
76+
instance = nil
77+
instanceOnce = sync.Once{}
78+
}
79+
80+
// NewRepoAccessCache returns a cache bound to the provided GitHub GraphQL client.
81+
// The cache is safe for concurrent use.
82+
//
83+
// For production code, consider using GetInstance() to ensure singleton behavior and
84+
// consistent configuration across the application. NewRepoAccessCache is appropriate
85+
// for testing scenarios where independent cache instances are needed.
5286
func NewRepoAccessCache(client *githubv4.Client, opts ...RepoAccessOption) *RepoAccessCache {
53-
// Use a unique cache name for each instance to avoid sharing state between tests
87+
return newRepoAccessCache(client, opts...)
88+
}
89+
90+
// newRepoAccessCache creates a new cache instance. This is a private helper function
91+
// used by GetInstance.
92+
func newRepoAccessCache(client *githubv4.Client, opts ...RepoAccessOption) *RepoAccessCache {
5493
cacheName := "repo-access-cache"
5594
c := &RepoAccessCache{
5695
client: client,

pkg/lockdown/lockdown_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,3 +145,58 @@ func TestRepoAccessCacheSetTTLReschedulesExistingEntry(t *testing.T) {
145145
requireAccess(ctx, t, cache)
146146
require.EqualValues(t, 2, transport.CallCount())
147147
}
148+
149+
func TestGetInstanceReturnsSingleton(t *testing.T) {
150+
// Reset any existing singleton
151+
ResetInstance()
152+
defer ResetInstance() // Clean up after test
153+
154+
gqlClient := githubv4.NewClient(nil)
155+
156+
// Get instance twice, should return the same instance
157+
instance1 := GetInstance(gqlClient)
158+
instance2 := GetInstance(gqlClient)
159+
160+
// Verify they're the same instance (same pointer)
161+
require.Same(t, instance1, instance2, "GetInstance should return the same singleton instance")
162+
163+
// Verify subsequent calls with different options are ignored
164+
instance3 := GetInstance(gqlClient, WithTTL(1*time.Second))
165+
require.Same(t, instance1, instance3, "GetInstance should ignore options on subsequent calls")
166+
require.Equal(t, defaultRepoAccessTTL, instance3.ttl, "TTL should remain unchanged after first initialization")
167+
}
168+
169+
func TestResetInstanceClearsSingleton(t *testing.T) {
170+
// Reset any existing singleton
171+
ResetInstance()
172+
defer ResetInstance() // Clean up after test
173+
174+
gqlClient := githubv4.NewClient(nil)
175+
176+
// Get first instance with default TTL
177+
instance1 := GetInstance(gqlClient)
178+
require.Equal(t, defaultRepoAccessTTL, instance1.ttl)
179+
180+
// Reset the singleton
181+
ResetInstance()
182+
183+
// Get new instance with custom TTL
184+
customTTL := 10 * time.Second
185+
instance2 := GetInstance(gqlClient, WithTTL(customTTL))
186+
require.NotSame(t, instance1, instance2, "After reset, GetInstance should return a new instance")
187+
require.Equal(t, customTTL, instance2.ttl, "New instance should have the custom TTL")
188+
}
189+
190+
func TestNewRepoAccessCacheCreatesIndependentInstances(t *testing.T) {
191+
t.Parallel()
192+
193+
gqlClient := githubv4.NewClient(nil)
194+
195+
// NewRepoAccessCache should create independent instances
196+
cache1 := NewRepoAccessCache(gqlClient, WithTTL(1*time.Second))
197+
cache2 := NewRepoAccessCache(gqlClient, WithTTL(2*time.Second))
198+
199+
require.NotSame(t, cache1, cache2, "NewRepoAccessCache should create different instances")
200+
require.Equal(t, 1*time.Second, cache1.ttl)
201+
require.Equal(t, 2*time.Second, cache2.ttl)
202+
}

0 commit comments

Comments
 (0)