Skip to content

Commit bd4c12b

Browse files
committed
chore: rework metrics to use less locking on the hotpath
1 parent e39f515 commit bd4c12b

2 files changed

Lines changed: 180 additions & 60 deletions

File tree

metrics.go

Lines changed: 137 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"net/url"
1111
"runtime"
1212
"sync"
13+
"sync/atomic"
1314
"time"
1415

1516
"github.com/Unleash/unleash-go-sdk/v5/internal/api"
@@ -87,20 +88,28 @@ type metric struct {
8788
Enabled bool
8889
}
8990

91+
type toggleCounters struct {
92+
yes int64
93+
no int64
94+
95+
mu sync.Mutex
96+
variants map[string]int64
97+
}
98+
9099
type metrics struct {
91100
metricsChannels
92-
options metricsOptions
93-
started time.Time
94-
bucketMu sync.Mutex
95-
bucket api.Bucket
96-
ticker *time.Ticker
97-
close chan struct{}
98-
closed chan struct{}
99-
ctx context.Context
100-
cancel func()
101-
maxSkips float64
102-
errors float64
103-
skips float64
101+
options metricsOptions
102+
started time.Time
103+
last_close_time time.Time
104+
counters sync.Map // map[string]*toggleCounters
105+
ticker *time.Ticker
106+
close chan struct{}
107+
closed chan struct{}
108+
ctx context.Context
109+
cancel func()
110+
maxSkips float64
111+
errors float64
112+
skips float64
104113
}
105114

106115
func newMetrics(options metricsOptions, channels metricsChannels) *metrics {
@@ -113,6 +122,7 @@ func newMetrics(options metricsOptions, channels metricsChannels) *metrics {
113122
maxSkips: 10,
114123
errors: 0,
115124
skips: 0,
125+
last_close_time: time.Now(),
116126
}
117127
ctx, cancel := context.WithCancel(context.Background())
118128
m.ctx = ctx
@@ -122,7 +132,6 @@ func newMetrics(options metricsOptions, channels metricsChannels) *metrics {
122132
m.options.httpClient = http.DefaultClient
123133
}
124134

125-
m.resetBucket()
126135
if m.options.metricsInterval <= 0 {
127136
m.options.disableMetrics = true
128137
}
@@ -196,13 +205,70 @@ func (m *metrics) successfulPost() {
196205
func (m *metrics) decrementSkip() {
197206
m.skips = math.Max(0, m.skips-1)
198207
}
208+
209+
// This does not remove stale toggle names from the map. I don't think there's a safe, lock free way to do that
210+
// The consequence is that if the user archives a lot of toggles this internal representation will not lose those
211+
// toggles until the process is terminated. In practice, I don't believe this is a big problem, just means a
212+
// little bit more memory is held than necessary
213+
func (m *metrics) buildBucketAndReset(last_close_time time.Time) (api.Bucket, bool) {
214+
bucket := api.Bucket{
215+
Start: last_close_time,
216+
Toggles: make(map[string]api.ToggleCount),
217+
}
218+
219+
m.counters.Range(func(key, value any) bool {
220+
name := key.(string)
221+
c := value.(*toggleCounters)
222+
223+
yes := atomic.SwapInt64(&c.yes, 0)
224+
no := atomic.SwapInt64(&c.no, 0)
225+
226+
if yes == 0 && no == 0 {
227+
c.mu.Lock()
228+
emptyVariants := len(c.variants) == 0
229+
c.mu.Unlock()
230+
if emptyVariants {
231+
return true
232+
}
233+
}
234+
235+
tc := api.ToggleCount{
236+
Yes: int32(yes),
237+
No: int32(no),
238+
}
239+
240+
// we can have a little locking, as a treat. Variants are likely a luke warm path at best
241+
// until we have evidence that this is a hot path API, I'd like to keep this simple
242+
// simple here means a local lock per toggle counter while we swap out the variants map
243+
c.mu.Lock()
244+
if len(c.variants) > 0 {
245+
vars := make(map[string]int32, len(c.variants))
246+
for vName, cnt := range c.variants {
247+
vars[vName] = int32(cnt)
248+
}
249+
tc.Variants = vars
250+
251+
c.variants = make(map[string]int64)
252+
}
253+
c.mu.Unlock()
254+
255+
bucket.Toggles[name] = tc
256+
return true
257+
})
258+
259+
if len(bucket.Toggles) == 0 {
260+
return api.Bucket{}, false
261+
}
262+
263+
return bucket, true
264+
}
265+
199266
func (m *metrics) sendMetrics() {
200-
m.bucketMu.Lock()
201-
bucket := m.resetBucket()
202-
m.bucketMu.Unlock()
203-
if bucket.IsEmpty() {
267+
bucket, ok := m.buildBucketAndReset(m.last_close_time)
268+
if !ok {
204269
return
205270
}
271+
m.last_close_time = time.Now()
206272
bucket.Stop = time.Now()
207273
payload := MetricsData{
208274
AppName: m.options.appName,
@@ -233,23 +299,19 @@ func (m *metrics) sendMetrics() {
233299
m.warn(fmt.Errorf("%s return %d", u.String(), resp.StatusCode))
234300
// The post failed, re-add the metrics we attempted to send so
235301
// they are included in the next post.
236-
for name, tc := range bucket.Toggles {
237-
m.add(name, true, tc.Yes)
238-
m.add(name, false, tc.No)
239-
}
302+
m.reinsertBucket(bucket)
240303

241-
m.bucketMu.Lock()
242304
// Set the start time of the current bucket to the one we
243305
// attempted to send.
244-
m.bucket.Start = bucket.Start
245-
m.bucketMu.Unlock()
306+
m.last_close_time = bucket.Start
307+
246308
} else {
247309
m.successfulPost()
248310
m.sent <- payload
249311
}
250312
}
251313

252-
func (m *metrics) doPost(url *url.URL, payload any) (*http.Response, error) {
314+
func (m *metrics) doPost(url *url.URL, payload interface{}) (*http.Response, error) {
253315
var body bytes.Buffer
254316
enc := json.NewEncoder(&body)
255317
if err := enc.Encode(payload); err != nil {
@@ -274,24 +336,57 @@ func (m *metrics) doPost(url *url.URL, payload any) (*http.Response, error) {
274336
return m.options.httpClient.Do(req)
275337
}
276338

339+
func (m *metrics) getOrCreateCounter(name string) *toggleCounters {
340+
c, ok := m.counters.Load(name)
341+
if ok {
342+
return c.(*toggleCounters)
343+
}
344+
345+
nc := &toggleCounters{
346+
variants: make(map[string]int64),
347+
}
348+
actual, _ := m.counters.LoadOrStore(name, nc)
349+
return actual.(*toggleCounters)
350+
}
351+
352+
func (m *metrics) reinsertBucket(bucket api.Bucket) {
353+
for name, tc := range bucket.Toggles {
354+
c := m.getOrCreateCounter(name)
355+
if tc.Yes != 0 {
356+
atomic.AddInt64(&c.yes, int64(tc.Yes))
357+
}
358+
if tc.No != 0 {
359+
atomic.AddInt64(&c.no, int64(tc.No))
360+
}
361+
362+
if len(tc.Variants) > 0 {
363+
c := m.getOrCreateCounter(name)
364+
365+
c.mu.Lock()
366+
if c.variants == nil {
367+
c.variants = make(map[string]int64, len(tc.Variants))
368+
}
369+
for vName, cnt := range tc.Variants {
370+
if cnt == 0 {
371+
continue
372+
}
373+
c.variants[vName] += int64(cnt)
374+
}
375+
c.mu.Unlock()
376+
}
377+
}
378+
}
379+
277380
func (m *metrics) add(name string, enabled bool, num int32) {
278381
if m.options.disableMetrics || num == 0 {
279382
return
280383
}
281-
m.bucketMu.Lock()
282-
defer m.bucketMu.Unlock()
283-
t, exists := m.bucket.Toggles[name]
284-
if !exists {
285-
t = api.ToggleCount{
286-
Variants: map[string]int32{},
287-
}
288-
}
384+
c := m.getOrCreateCounter(name)
289385
if enabled {
290-
t.Yes += num
386+
atomic.AddInt64(&c.yes, int64(num))
291387
} else {
292-
t.No += num
388+
atomic.AddInt64(&c.no, int64(num))
293389
}
294-
m.bucket.Toggles[name] = t
295390
}
296391

297392
func (m *metrics) count(name string, enabled bool) {
@@ -310,29 +405,14 @@ func (m *metrics) countVariants(name string, enabled bool, variantName string) {
310405
m.add(name, enabled, 1)
311406
m.metricsChannels.count <- metric{Name: name, Enabled: enabled}
312407

313-
m.bucketMu.Lock()
314-
defer m.bucketMu.Unlock()
315-
316-
t := m.bucket.Toggles[name]
317-
if len(t.Variants) == 0 {
318-
t.Variants = make(map[string]int32)
319-
}
320-
321-
if _, ok := t.Variants[variantName]; !ok {
322-
t.Variants[variantName] = 1
323-
} else {
324-
t.Variants[variantName] += 1
325-
}
326-
m.bucket.Toggles[name] = t
327-
}
408+
c := m.getOrCreateCounter(name)
328409

329-
func (m *metrics) resetBucket() api.Bucket {
330-
prev := m.bucket
331-
m.bucket = api.Bucket{
332-
Start: time.Now(),
333-
Toggles: map[string]api.ToggleCount{},
410+
c.mu.Lock()
411+
if c.variants == nil {
412+
c.variants = make(map[string]int64)
334413
}
335-
return prev
414+
c.variants[variantName]++
415+
c.mu.Unlock()
336416
}
337417

338418
func (m *metrics) getClientData() ClientData {

metrics_test.go

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,9 @@ func TestMetrics_VariantsCountToggles(t *testing.T) {
8686
client.WaitForReady()
8787
client.GetVariant("foo")
8888

89-
assert.EqualValues(client.metrics.bucket.Toggles["foo"].No, 1)
89+
registered_metric, ok := client.metrics.counters.Load("foo")
90+
assert.True(ok, "should have a count for 'foo'")
91+
assert.EqualValues(registered_metric.(*toggleCounters).no, 1)
9092
client.Close()
9193

9294
assert.Nil(err, "client should not return an error")
@@ -335,8 +337,13 @@ func TestMetrics_ShouldNotCountMetricsForParentToggles(t *testing.T) {
335337
client.WaitForReady()
336338
client.IsEnabled("child")
337339

338-
assert.EqualValues(client.metrics.bucket.Toggles["child"].Yes, 1)
339-
assert.EqualValues(client.metrics.bucket.Toggles["parent"].Yes, 0)
340+
child_metric, ok := client.metrics.counters.Load("child")
341+
assert.True(ok, "should have a count for 'child'")
342+
assert.EqualValues(child_metric.(*toggleCounters).yes, 1)
343+
344+
_, ok = client.metrics.counters.Load("parent")
345+
assert.False(ok, "should not have a count for parent'")
346+
340347
err = client.Close()
341348

342349
assert.Nil(err, "client should not return an error")
@@ -592,3 +599,36 @@ func TestMetrics_metricsData_includes_new_metadata(t *testing.T) {
592599

593600
st.Expect(t, gock.IsDone(), true)
594601
}
602+
603+
func TestReinsertingBucketsAlsoRestoresVariants(t *testing.T) {
604+
metrics_handler := &metrics{
605+
metricsChannels: metricsChannels{
606+
count: make(chan metric, 100),
607+
},
608+
}
609+
610+
metrics_handler.countVariants("some-feature", true, "some-variant")
611+
registered_metric, ok := metrics_handler.counters.Load("some-feature")
612+
613+
if !ok {
614+
t.Fatal("should have a count for 'some-feature'")
615+
}
616+
617+
assert.EqualValues(t, 1, registered_metric.(*toggleCounters).variants["some-variant"])
618+
619+
retrieved_bucket, ok := metrics_handler.buildBucketAndReset(time.Now())
620+
if !ok {
621+
t.Fatal("Missing bucket for 'some-feature'")
622+
}
623+
624+
assert.EqualValues(t, 1, retrieved_bucket.Toggles["some-feature"].Variants["some-variant"])
625+
626+
metrics_handler.reinsertBucket(retrieved_bucket)
627+
628+
registered_metric, ok = metrics_handler.counters.Load("some-feature")
629+
if !ok {
630+
t.Fatal("should have a count for 'some-feature'")
631+
}
632+
633+
assert.EqualValues(t, 1, registered_metric.(*toggleCounters).variants["some-variant"])
634+
}

0 commit comments

Comments
 (0)