Skip to content

Commit e45ee14

Browse files
chmouelclaude
andcommitted
feat(gitlab): Cache results acl project membership
Implemented caching in gitlab for results returned by `checkMembership`. This was done to reduce repeated calls to the GitLab API when checking the membership status of the same user multiple times during processing of an event. Co-authored-by: Claude <[email protected]> Jira: https://issues.redhat.com/browse/SRVKP-9056 Signed-off-by: Chmouel Boudjnah <[email protected]>
1 parent e18390d commit e45ee14

File tree

4 files changed

+140
-1
lines changed

4 files changed

+140
-1
lines changed

pkg/provider/gitlab/acl.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,30 @@ func (v *Provider) IsAllowedOwnersFile(_ context.Context, event *info.Event) (bo
2727
}
2828

2929
func (v *Provider) checkMembership(ctx context.Context, event *info.Event, userid int) bool {
30+
// Initialize cache lazily
31+
if v.memberCache == nil {
32+
v.memberCache = map[int]bool{}
33+
}
34+
35+
if allowed, ok := v.memberCache[userid]; ok {
36+
return allowed
37+
}
38+
3039
member, _, err := v.Client().ProjectMembers.GetInheritedProjectMember(v.targetProjectID, userid)
31-
if err == nil && member.ID != 0 && member.ID == userid {
40+
if err != nil {
41+
// If the API call fails, fall back without caching the result so a
42+
// transient failure can be retried on the next invocation.
43+
isAllowed, _ := v.IsAllowedOwnersFile(ctx, event)
44+
return isAllowed
45+
}
46+
47+
if member.ID != 0 && member.ID == userid {
48+
v.memberCache[userid] = true
3249
return true
3350
}
3451

3552
isAllowed, _ := v.IsAllowedOwnersFile(ctx, event)
53+
v.memberCache[userid] = isAllowed
3654
return isAllowed
3755
}
3856

pkg/provider/gitlab/acl_test.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package gitlab
22

33
import (
4+
"fmt"
5+
"net/http"
46
"testing"
57

68
"github.com/openshift-pipelines/pipelines-as-code/pkg/params/info"
@@ -155,3 +157,107 @@ func TestIsAllowed(t *testing.T) {
155157
})
156158
}
157159
}
160+
161+
func TestMembershipCaching(t *testing.T) {
162+
ctx, _ := rtesting.SetupFakeContext(t)
163+
164+
v := &Provider{
165+
targetProjectID: 3030,
166+
userID: 4242,
167+
}
168+
169+
client, mux, tearDown := thelp.Setup(t)
170+
defer tearDown()
171+
v.gitlabClient = client
172+
173+
// Count how many times the membership API is hit.
174+
var calls int
175+
thelp.MuxAllowUserIDCounting(mux, v.targetProjectID, v.userID, &calls)
176+
177+
ev := &info.Event{Sender: "someone", PullRequestNumber: 1}
178+
179+
// First call should hit the API once and cache the result.
180+
allowed, err := v.IsAllowed(ctx, ev)
181+
if err != nil {
182+
t.Fatalf("unexpected error: %v", err)
183+
}
184+
if !allowed {
185+
t.Fatalf("expected allowed on first membership check")
186+
}
187+
if calls < 1 {
188+
t.Fatalf("expected at least 1 membership API call, got %d", calls)
189+
}
190+
191+
// Second call should use the cache and not hit the API again.
192+
allowed, err = v.IsAllowed(ctx, ev)
193+
if err != nil {
194+
t.Fatalf("unexpected error: %v", err)
195+
}
196+
if !allowed {
197+
t.Fatalf("expected allowed on cached membership check")
198+
}
199+
if calls != 1 {
200+
t.Fatalf("expected cached result with no extra API call, got %d calls", calls)
201+
}
202+
}
203+
204+
func TestMembershipAPIFailureDoesNotCacheApiError(t *testing.T) {
205+
ctx, _ := rtesting.SetupFakeContext(t)
206+
207+
v := &Provider{
208+
targetProjectID: 3030,
209+
userID: 4242,
210+
}
211+
212+
client, mux, tearDown := thelp.Setup(t)
213+
defer tearDown()
214+
v.gitlabClient = client
215+
216+
ev := &info.Event{Sender: "someone"}
217+
218+
var (
219+
calls int
220+
success bool
221+
)
222+
path := fmt.Sprintf("/projects/%d/members/all/%d", v.targetProjectID, v.userID)
223+
mux.HandleFunc(path, func(rw http.ResponseWriter, _ *http.Request) {
224+
calls++
225+
if !success {
226+
rw.WriteHeader(http.StatusInternalServerError)
227+
_, _ = rw.Write([]byte(`{}`))
228+
return
229+
}
230+
_, err := fmt.Fprintf(rw, `{"id": %d}`, v.userID)
231+
if err != nil {
232+
t.Fatalf("failed to write response: %v", err)
233+
}
234+
})
235+
236+
thelp.MuxDiscussionsNoteEmpty(mux, v.targetProjectID, ev.PullRequestNumber)
237+
238+
allowed, err := v.IsAllowed(ctx, ev)
239+
if err != nil {
240+
t.Fatalf("unexpected error on failure path: %v", err)
241+
}
242+
if allowed {
243+
t.Fatalf("expected not allowed when membership API fails and no fallback grants access")
244+
}
245+
if calls < 1 {
246+
t.Fatalf("expected at least 1 membership API call, got %d", calls)
247+
}
248+
initialCallCount := calls
249+
250+
// Make the next API call succeed; the provider should retry because the previous failure wasn't cached.
251+
success = true
252+
253+
allowed, err = v.IsAllowed(ctx, ev)
254+
if err != nil {
255+
t.Fatalf("unexpected error on retry path: %v", err)
256+
}
257+
if !allowed {
258+
t.Fatalf("expected allowed when membership API succeeds on retry")
259+
}
260+
if calls <= initialCallCount {
261+
t.Fatalf("expected membership API to be called again after retry, got %d total calls (initial %d)", calls, initialCallCount)
262+
}
263+
}

pkg/provider/gitlab/gitlab.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ type Provider struct {
6262
eventEmitter *events.EventEmitter
6363
repo *v1alpha1.Repository
6464
triggerEvent string
65+
// memberCache caches membership/permission checks by user ID within the
66+
// current provider instance lifecycle to avoid repeated API calls.
67+
memberCache map[int]bool
6568
}
6669

6770
func (v *Provider) Client() *gitlab.Client {

pkg/provider/gitlab/test/test.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,18 @@ func MuxDisallowUserID(mux *http.ServeMux, projectID, userID int) {
6666
})
6767
}
6868

69+
// MuxAllowUserIDCounting registers a handler that returns an allowed member and increments
70+
// the provided counter each time it is called. Useful to assert caching behavior.
71+
func MuxAllowUserIDCounting(mux *http.ServeMux, projectID, userID int, counter *int) {
72+
path := fmt.Sprintf("/projects/%d/members/all/%d", projectID, userID)
73+
mux.HandleFunc(path, func(rw http.ResponseWriter, _ *http.Request) {
74+
if counter != nil {
75+
*counter++
76+
}
77+
fmt.Fprintf(rw, `{"id": %d}`, userID)
78+
})
79+
}
80+
6981
func MuxListTektonDir(_ *testing.T, mux *http.ServeMux, pid int, ref, prs string, wantTreeAPIErr, wantFilesAPIErr bool) {
7082
mux.HandleFunc(fmt.Sprintf("/projects/%d/repository/tree", pid), func(rw http.ResponseWriter, r *http.Request) {
7183
if wantTreeAPIErr {

0 commit comments

Comments
 (0)