Skip to content

Commit f727bd6

Browse files
yosiharanclaude
andcommitted
perf: async cache update after Check to reduce write lock contention
UpdateCacheWithChecks holds a write lock while doing N LRU insertions, blocking all concurrent readers (CheckRelation needs RLock). With 500+ relations this becomes a bottleneck. Make the cache update async by default (configurable via AUTHZCACHE_ASYNC_CACHE_UPDATE) so results return to clients immediately. Resolves descope/etc#14193 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c4b65ad commit f727bd6

File tree

4 files changed

+171
-1
lines changed

4 files changed

+171
-1
lines changed

internal/config/config.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const (
1717
ConfigKeyLookupCacheSizePerProject = "AUTHZCACHE_LOOKUP_CACHE_SIZE_PER_PROJECT" // max entries per project
1818
ConfigKeyLookupCacheTTLInSeconds = "AUTHZCACHE_LOOKUP_CACHE_TTL_IN_SECONDS" // TTL for lookup cache entries
1919
ConfigKeyLookupCacheMaxResultSize = "AUTHZCACHE_LOOKUP_CACHE_MAX_RESULT_SIZE" // max result size to cache (skip caching large results)
20+
ConfigKeyAsyncCacheUpdate = "AUTHZCACHE_ASYNC_CACHE_UPDATE" // TRUE/FALSE, default is TRUE
2021
)
2122

2223
func GetDirectRelationCacheSizePerProject() int {
@@ -54,3 +55,7 @@ func GetLookupCacheTTLInSeconds() int {
5455
func GetLookupCacheMaxResultSize() int {
5556
return cconfig.GetIntOrProvidedLocal(ConfigKeyLookupCacheMaxResultSize, 1000)
5657
}
58+
59+
func GetAsyncCacheUpdate() bool {
60+
return cconfig.GetBoolOrProvidedLocal(ConfigKeyAsyncCacheUpdate, true)
61+
}

internal/config/config_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,43 @@ func TestGetSDKDebugLog(t *testing.T) {
137137
}
138138
}
139139

140+
func TestGetAsyncCacheUpdate(t *testing.T) {
141+
tests := []struct {
142+
name string
143+
value string
144+
setEnv bool
145+
expected bool
146+
}{
147+
{
148+
name: "Config value not set (default true)",
149+
setEnv: false,
150+
expected: true,
151+
},
152+
{
153+
name: "Config value set to false",
154+
value: "false",
155+
setEnv: true,
156+
expected: false,
157+
},
158+
{
159+
name: "Config value set to true",
160+
value: "true",
161+
setEnv: true,
162+
expected: true,
163+
},
164+
}
165+
166+
for _, tt := range tests {
167+
t.Run(tt.name, func(t *testing.T) {
168+
if tt.setEnv {
169+
t.Setenv(ConfigKeyAsyncCacheUpdate, tt.value)
170+
}
171+
actual := GetAsyncCacheUpdate()
172+
require.Equal(t, tt.expected, actual)
173+
})
174+
}
175+
}
176+
140177
func TestGetPurgeCooldownWindowInMinutes(t *testing.T) {
141178
tests := []struct {
142179
name string

internal/services/authz.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"sync"
66

7+
"github.com/descope/authzcache/internal/config"
78
"github.com/descope/authzcache/internal/services/caches"
89
cctx "github.com/descope/common/pkg/common/context"
910
"github.com/descope/go-sdk/descope"
@@ -127,7 +128,11 @@ func (a *authzCache) Check(ctx context.Context, relations []*descope.FGARelation
127128
return nil, err // notest
128129
}
129130
// update cache
130-
projectCache.UpdateCacheWithChecks(ctx, sdkChecks)
131+
if config.GetAsyncCacheUpdate() {
132+
go projectCache.UpdateCacheWithChecks(context.WithoutCancel(ctx), sdkChecks)
133+
} else {
134+
projectCache.UpdateCacheWithChecks(ctx, sdkChecks)
135+
}
131136
// merge cached and sdk checks in the same order as input relations and return them
132137
var result []*descope.FGACheck
133138
var j int

internal/services/authz_test.go

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@ package services
22

33
import (
44
"context"
5+
"fmt"
6+
"sync"
7+
"sync/atomic"
58
"testing"
69

10+
"github.com/descope/authzcache/internal/config"
711
"github.com/descope/authzcache/internal/services/caches"
812
"github.com/descope/go-sdk/descope"
913
"github.com/descope/go-sdk/descope/logger"
@@ -531,3 +535,122 @@ func TestWhatCanTargetAccess_IndirectRelationRemoved_FilteredImmediately(t *test
531535
require.Len(t, result, 1)
532536
require.Equal(t, "doc1", result[0].Resource)
533537
}
538+
539+
func TestCheckAsyncCacheUpdate(t *testing.T) {
540+
t.Setenv(config.ConfigKeyAsyncCacheUpdate, "true")
541+
// setup mocks
542+
ac, mockSDK, mockCache := injectAuthzMocks(t)
543+
relations := []*descope.FGARelation{{Resource: "mario", Target: "luigi", Relation: "bigBro"}}
544+
sdkChecks := []*descope.FGACheck{{Allowed: true, Relation: relations[0], Info: &descope.FGACheckInfo{Direct: true}}}
545+
mockSDK.MockFGA.CheckResponse = sdkChecks
546+
mockCache.CheckRelationFunc = func(_ context.Context, _ *descope.FGARelation) (bool, bool, bool) {
547+
return false, false, false
548+
}
549+
// use a WaitGroup to detect when the async goroutine calls UpdateCacheWithChecks
550+
var wg sync.WaitGroup
551+
wg.Add(1)
552+
mockCache.UpdateCacheWithChecksFunc = func(_ context.Context, checks []*descope.FGACheck) {
553+
defer wg.Done()
554+
require.Equal(t, sdkChecks, checks)
555+
}
556+
// run test
557+
result, err := ac.Check(context.TODO(), relations)
558+
require.NoError(t, err)
559+
require.Equal(t, sdkChecks, result)
560+
// wait for the async cache update to complete
561+
wg.Wait()
562+
}
563+
564+
func TestCheckSyncCacheUpdate(t *testing.T) {
565+
t.Setenv(config.ConfigKeyAsyncCacheUpdate, "false")
566+
// setup mocks
567+
ac, mockSDK, mockCache := injectAuthzMocks(t)
568+
relations := []*descope.FGARelation{{Resource: "mario", Target: "luigi", Relation: "bigBro"}}
569+
sdkChecks := []*descope.FGACheck{{Allowed: true, Relation: relations[0], Info: &descope.FGACheckInfo{Direct: true}}}
570+
mockSDK.MockFGA.CheckResponse = sdkChecks
571+
mockCache.CheckRelationFunc = func(_ context.Context, _ *descope.FGARelation) (bool, bool, bool) {
572+
return false, false, false
573+
}
574+
// track that UpdateCacheWithChecks is called before Check returns
575+
cacheUpdated := false
576+
mockCache.UpdateCacheWithChecksFunc = func(_ context.Context, checks []*descope.FGACheck) {
577+
require.Equal(t, sdkChecks, checks)
578+
cacheUpdated = true
579+
}
580+
// run test
581+
result, err := ac.Check(context.TODO(), relations)
582+
require.NoError(t, err)
583+
require.Equal(t, sdkChecks, result)
584+
// in sync mode, cache must already be updated when Check returns
585+
require.True(t, cacheUpdated, "UpdateCacheWithChecks should be called synchronously before Check returns")
586+
}
587+
588+
func BenchmarkCheck(b *testing.B) {
589+
for _, numRelations := range []int{500, 1000, 5000} {
590+
for _, async := range []bool{false, true} {
591+
name := fmt.Sprintf("relations=%d/async=%v", numRelations, async)
592+
b.Run(name, func(b *testing.B) {
593+
if async {
594+
b.Setenv(config.ConfigKeyAsyncCacheUpdate, "true")
595+
} else {
596+
b.Setenv(config.ConfigKeyAsyncCacheUpdate, "false")
597+
}
598+
// setup: real cache + mock SDK returning instant results
599+
ctx := context.TODO()
600+
projectCache, err := caches.NewProjectAuthzCache(ctx, nil)
601+
if err != nil {
602+
b.Fatal(err)
603+
}
604+
// fixed SDK response — same length as input, reused across goroutines (read-only)
605+
sdkResponse := make([]*descope.FGACheck, numRelations)
606+
for i := range sdkResponse {
607+
sdkResponse[i] = &descope.FGACheck{
608+
Allowed: i%2 == 0,
609+
Relation: &descope.FGARelation{Resource: fmt.Sprintf("sdk-r-%d", i), Target: fmt.Sprintf("sdk-t-%d", i), Relation: "viewer", ResourceType: "doc"},
610+
Info: &descope.FGACheckInfo{Direct: true},
611+
}
612+
}
613+
mockFGA := &mocksmgmt.MockFGA{CheckResponse: sdkResponse}
614+
mockSDK := &mocksmgmt.MockManagement{
615+
MockFGA: mockFGA,
616+
MockAuthz: &mocksmgmt.MockAuthz{},
617+
}
618+
mockRemoteClientCreator := func(_ string, _ logger.LoggerInterface) (sdk.Management, error) {
619+
return mockSDK, nil
620+
}
621+
mockCacheCreator := func(_ context.Context, _ caches.RemoteChangesChecker) (caches.ProjectAuthzCache, error) {
622+
return projectCache, nil
623+
}
624+
ac, err := New(ctx, mockCacheCreator, mockRemoteClientCreator)
625+
if err != nil {
626+
b.Fatal(err)
627+
}
628+
// warm up: trigger project cache creation
629+
warmupRel := []*descope.FGARelation{{Resource: "warmup", Target: "warmup", Relation: "viewer", ResourceType: "doc"}}
630+
origResp := mockFGA.CheckResponse
631+
mockFGA.CheckResponse = []*descope.FGACheck{{Allowed: true, Relation: warmupRel[0], Info: &descope.FGACheckInfo{Direct: true}}}
632+
_, _ = ac.Check(ctx, warmupRel)
633+
mockFGA.CheckResponse = origResp
634+
635+
var counter atomic.Int64
636+
b.ResetTimer()
637+
b.RunParallel(func(pb *testing.PB) {
638+
for pb.Next() {
639+
id := counter.Add(1)
640+
// unique relations per call — always cache misses
641+
rels := make([]*descope.FGARelation, numRelations)
642+
for j := range numRelations {
643+
rels[j] = &descope.FGARelation{
644+
Resource: fmt.Sprintf("r-%d-%d", id, j),
645+
Target: fmt.Sprintf("t-%d-%d", id, j),
646+
Relation: "viewer",
647+
ResourceType: "doc",
648+
}
649+
}
650+
_, _ = ac.Check(ctx, rels)
651+
}
652+
})
653+
})
654+
}
655+
}
656+
}

0 commit comments

Comments
 (0)