Skip to content

Commit 06c4941

Browse files
authored
feat: add LruBanditLogger (#65)
* feat: add LruBanditLogger * docs: update readme for LruBanditLogger * test: add flip-flop test for LruBanditLogger
1 parent 303ff31 commit 06c4941

File tree

5 files changed

+197
-13
lines changed

5 files changed

+197
-13
lines changed

README.md

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -203,30 +203,32 @@ event store. This increases the cost of storage as well as warehouse costs durin
203203

204204
To mitigate this, an in-memory assignment cache is optionally available with expiration based on the least recently accessed time.
205205

206-
It can be configured with a maximum size to fit your desired memory allocation.
206+
The caching can be configured individually for assignment logs and bandit action logs using `LruAssignmentLogger` and `LruBanditLogger` respectively.
207+
Both loggers are configured with a maximum size to fit your desired memory allocation.
207208

208209
```go
209210
import (
210-
"github.com/Eppo-exp/golang-sdk/v5/eppoclient"
211+
"github.com/Eppo-exp/golang-sdk/v5/eppoclient"
211212
)
212213

213214
var eppoClient *eppoclient.EppoClient
214215

215216
func main() {
216-
assignmentLogger := NewExampleAssignmentLogger()
217+
assignmentLogger := NewExampleAssignmentLogger()
217218

218-
eppoClient, _ = eppoclient.InitClient(eppoclient.Config{
219-
ApiKey: "<your_sdk_key>",
220-
// 10000 is the maximum number of assignments to cache
221-
// Depending on the length of your flag and subject keys, taking a median
222-
// length of 32 characters, each assignment cache entry uses approximately 112 bytes.
223-
// Use this calculation to determine the maximum number of assignments to cache
224-
// for the memory you wish to allocate.
225-
AssignmentLogger: eppoclient.NewLruAssignmentLogger(assignmentLogger, 10000),
226-
})
219+
// 10000 is the maximum number of assignments to cache.
220+
assignmentLogger = eppoclient.NewLruAssignmentLogger(assignmentLogger, 10000)
221+
assignmentLogger = eppoclient.NewLruBanditLogger(assignmentLogger, 10000)
222+
223+
eppoClient, _ = eppoclient.InitClient(eppoclient.Config{
224+
ApiKey: "<your_sdk_key>",
225+
AssignmentLogger: assignmentLogger,
226+
})
227227
}
228228
```
229229

230+
Internally, both loggers are simple proxying wrappers around [`lru.TwoQueueCache`](https://pkg.go.dev/github.com/hashicorp/golang-lru/v2#TwoQueueCache). If you require more customized caching behavior, you can copy the implementation and modify it to suit your needs. (We’d love to hear about your use case if you do!)
231+
230232
## Philosophy
231233

232234
Eppo's SDKs are built for simplicity, speed and reliability. Flag configurations are compressed and distributed over a global CDN (Fastly), typically reaching your servers in under 15ms. Server SDKs continue polling Eppo’s API at 10-second intervals. Configurations are then cached locally, ensuring that each assignment is made instantly. Evaluation logic within each SDK consists of a few lines of simple numeric and string comparisons. The typed functions listed above are all developers need to understand, abstracting away the complexity of the Eppo's underlying (and expanding) feature set.

eppoclient/initclient.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ package eppoclient
44

55
import "net/http"
66

7-
var __version__ = "5.1.1"
7+
var __version__ = "5.2.0"
88

99
// InitClient is required to start polling of experiments configurations and create
1010
// an instance of EppoClient, which could be used to get assignments information.

eppoclient/lrubanditlogger.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package eppoclient
2+
3+
import (
4+
"fmt"
5+
6+
lru "github.com/hashicorp/golang-lru/v2"
7+
)
8+
9+
type LruBanditLogger struct {
10+
cache *lru.TwoQueueCache[lruBanditKey, lruBanditValue]
11+
inner IAssignmentLogger
12+
}
13+
14+
type lruBanditKey struct {
15+
flagKey string
16+
subjectKey string
17+
}
18+
type lruBanditValue struct {
19+
banditKey string
20+
actionKey string
21+
}
22+
23+
func NewLruBanditLogger(logger IAssignmentLogger, cacheSize int) (IAssignmentLogger, error) {
24+
cache, err := lru.New2Q[lruBanditKey, lruBanditValue](cacheSize)
25+
if err != nil {
26+
// err is only returned if `cacheSize` is invalid
27+
// (e.g., <0) which should normally never happen.
28+
return nil, fmt.Errorf("failed to create LRU cache: %w", err)
29+
}
30+
return &LruBanditLogger{
31+
cache: cache,
32+
inner: logger,
33+
}, nil
34+
}
35+
36+
func (logger *LruBanditLogger) LogAssignment(event AssignmentEvent) {
37+
logger.inner.LogAssignment(event)
38+
}
39+
40+
func (logger *LruBanditLogger) LogBanditAction(event BanditEvent) {
41+
inner, ok := logger.inner.(BanditActionLogger)
42+
if !ok {
43+
return
44+
}
45+
46+
key := lruBanditKey{
47+
flagKey: event.FlagKey,
48+
subjectKey: event.Subject,
49+
}
50+
value := lruBanditValue{
51+
banditKey: event.BanditKey,
52+
actionKey: event.Action,
53+
}
54+
previousValue, recentlyLogged := logger.cache.Get(key)
55+
if !recentlyLogged || previousValue != value {
56+
inner.LogBanditAction(event)
57+
// Adding to cache after `LogBanditAction` returned in
58+
// case it panics.
59+
logger.cache.Add(key, value)
60+
}
61+
}

eppoclient/lrubanditlogger_test.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package eppoclient
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/mock"
8+
)
9+
10+
func Test_LruBanditLogger_cacheBanditAction(t *testing.T) {
11+
innerLogger := new(mockLogger)
12+
innerLogger.On("LogAssignment", mock.Anything).Return()
13+
innerLogger.On("LogBanditAction", mock.Anything).Return()
14+
15+
logger, err := NewLruBanditLogger(innerLogger, 1000)
16+
assert.NoError(t, err)
17+
18+
event := BanditEvent{
19+
FlagKey: "flag",
20+
BanditKey: "bandit",
21+
Subject: "subject",
22+
Action: "action",
23+
ActionProbability: 0.1,
24+
OptimalityGap: 0.1,
25+
ModelVersion: "model-version",
26+
Timestamp: "timestamp",
27+
SubjectNumericAttributes: map[string]float64{},
28+
SubjectCategoricalAttributes: map[string]string{},
29+
ActionNumericAttributes: map[string]float64{},
30+
ActionCategoricalAttributes: map[string]string{},
31+
MetaData: map[string]string{},
32+
}
33+
34+
banditLogger := logger.(BanditActionLogger)
35+
banditLogger.LogBanditAction(event)
36+
banditLogger.LogBanditAction(event)
37+
38+
innerLogger.AssertNumberOfCalls(t, "LogBanditAction", 1)
39+
}
40+
41+
func Test_LruBanditLogger_flipFlopAction(t *testing.T) {
42+
innerLogger := new(mockLogger)
43+
innerLogger.On("LogAssignment", mock.Anything).Return()
44+
innerLogger.On("LogBanditAction", mock.Anything).Return()
45+
46+
logger, err := NewLruBanditLogger(innerLogger, 1000)
47+
assert.NoError(t, err)
48+
banditLogger := logger.(BanditActionLogger)
49+
50+
event := BanditEvent{
51+
FlagKey: "flag",
52+
BanditKey: "bandit",
53+
Subject: "subject",
54+
Action: "action1",
55+
ActionProbability: 0.1,
56+
OptimalityGap: 0.1,
57+
ModelVersion: "model-version",
58+
Timestamp: "timestamp",
59+
SubjectNumericAttributes: map[string]float64{},
60+
SubjectCategoricalAttributes: map[string]string{},
61+
ActionNumericAttributes: map[string]float64{},
62+
ActionCategoricalAttributes: map[string]string{},
63+
MetaData: map[string]string{},
64+
}
65+
66+
event.Action = "action1"
67+
banditLogger.LogBanditAction(event)
68+
banditLogger.LogBanditAction(event)
69+
70+
innerLogger.AssertNumberOfCalls(t, "LogBanditAction", 1)
71+
72+
event.Action = "action2"
73+
banditLogger.LogBanditAction(event)
74+
75+
innerLogger.AssertNumberOfCalls(t, "LogBanditAction", 2)
76+
77+
event.Action = "action1"
78+
banditLogger.LogBanditAction(event)
79+
80+
innerLogger.AssertNumberOfCalls(t, "LogBanditAction", 3)
81+
}
82+
83+
func Test_LruBanditLogger_okIfInnerLoggerIsNotBandit(t *testing.T) {
84+
innerLogger := new(mockNonBanditLogger)
85+
innerLogger.On("LogAssignment", mock.Anything).Return()
86+
87+
logger, err := NewLruBanditLogger(innerLogger, 1000)
88+
assert.NoError(t, err)
89+
90+
event := BanditEvent{
91+
FlagKey: "flag",
92+
BanditKey: "bandit",
93+
Subject: "subject",
94+
Action: "action",
95+
ActionProbability: 0.1,
96+
OptimalityGap: 0.1,
97+
ModelVersion: "model-version",
98+
Timestamp: "timestamp",
99+
SubjectNumericAttributes: map[string]float64{},
100+
SubjectCategoricalAttributes: map[string]string{},
101+
ActionNumericAttributes: map[string]float64{},
102+
ActionCategoricalAttributes: map[string]string{},
103+
MetaData: map[string]string{},
104+
}
105+
106+
banditLogger := logger.(BanditActionLogger)
107+
banditLogger.LogBanditAction(event)
108+
banditLogger.LogBanditAction(event)
109+
110+
innerLogger.AssertNumberOfCalls(t, "LogBanditAction", 0)
111+
}

eppoclient/mocks_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,13 @@ func (ml *mockLogger) LogAssignment(event AssignmentEvent) {
1515
func (ml *mockLogger) LogBanditAction(event BanditEvent) {
1616
ml.MethodCalled("LogBanditAction", event)
1717
}
18+
19+
// `mockNonBanditLogger` is missing `LogBanditAction` and therefore
20+
// does not implement `BanditActionLogger`.
21+
type mockNonBanditLogger struct {
22+
mock.Mock
23+
}
24+
25+
func (ml *mockNonBanditLogger) LogAssignment(event AssignmentEvent) {
26+
ml.MethodCalled("LogAssignment", event)
27+
}

0 commit comments

Comments
 (0)