Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ ALLOCATIONS_LOG=./allocations.out

EASYJSON_TAG=-tags launchdarkly_easyjson

.PHONY: all build build-easyjson clean test test-easyjson lint test-coverage benchmarks benchmark-allocs
.PHONY: all build build-easyjson clean test test-easyjson lint test-coverage benchmarks benchmarks-easyjson benchmarks-intern-json benchmarks-nointern benchmark-allocs

all: build build-easyjson

Expand Down Expand Up @@ -52,6 +52,12 @@ benchmarks: build
benchmarks-easyjson: build-easyjson
go test $(EASYJSON_TAG) -benchmem '-run=^$$' '-bench=.*' ./...

benchmarks-intern-json: build
go test -tags launchdarkly_intern_json -benchmem '-run=^$$' '-bench=.*' ./...

benchmarks: build
go test -benchmem '-run=^$$' '-bench=.*' ./...

# See CONTRIBUTING.md regarding the use of the benchmark-allocs target. Notes about this implementation:
# 1. We precompile the test code because otherwise the allocation traces will include the actions of the compiler itself.
# 2. "benchtime=3x" means the run count (b.N) is set to 3. Setting it to 1 would produce less redundant output, but the
Expand Down
52 changes: 52 additions & 0 deletions README-intern-json.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Optional JSON interning (Go 1.23+)

This repo supports an optional build-time feature to reduce steady-state heap
and allocations by deduplicating repeated JSON strings via Go 1.23's `unique`
package. It is off by default and opt-in via a build tag.

- Default build: no interning (minimum Go remains 1.18)
- Opt-in interning: requires Go 1.23+

Enable interning:

- Build: `go build -tags launchdarkly_intern_json ./...`
- Test/bench: `go test -tags launchdarkly_intern_json ./...`

Fail-fast on older Go:

- The unique-backed code uses `//go:build go1.23 && launchdarkly_intern_json`
- A guard file `//go:build launchdarkly_intern_json && !go1.23` produces a
compile-time error if the tag is used on Go < 1.23

How interning works:

- Only strings with high deduplication value are interned: operators (like "in", "matches"),
context kinds (like "user", "device"), and attribute strings
- Uses an LRU cache that retains `unique.Handle`s for frequently accessed strings
- Cache size defaults to 8192 entries, configurable at runtime via `ldmodel.SetStringInterningCacheSize(size)`
- Provides deduplication both within GC windows and across GCs for cached entries
- Cache can be disabled entirely by setting size to 0 or negative values

Makefile shortcuts:

- `make benchmarks-nointern` — default suite, no tag
- `make benchmarks-intern-json` — runs with `-tags launchdarkly_intern_json`

Runtime configuration:

```go
import "github.com/launchdarkly/go-server-sdk-evaluation/v3/ldmodel"

// Set cache size to 1000 entries
ldmodel.SetStringInterningCacheSize(1000)

// Disable interning entirely
ldmodel.SetStringInterningCacheSize(0)
```

Notes:

- This feature does not modify the public API beyond the optional cache control function
- Interning is applied selectively to strings with proven deduplication benefits
- If issues arise, interning can be disabled at runtime without rebuilding

5 changes: 4 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
module github.com/launchdarkly/go-server-sdk-evaluation/v3

go 1.18
go 1.21

toolchain go1.23.7

require (
github.com/google/go-cmp v0.7.0
github.com/launchdarkly/go-jsonstream/v3 v3.1.0
github.com/launchdarkly/go-sdk-common/v3 v3.1.0
github.com/launchdarkly/go-semver v1.0.3
Expand Down
3 changes: 3 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
Expand Down Expand Up @@ -31,6 +33,7 @@ golang.org/x/exp v0.0.0-20220823124025-807a23277127 h1:S4NrSKDfihhl3+4jSTgwoIevK
golang.org/x/exp v0.0.0-20220823124025-807a23277127/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
226 changes: 226 additions & 0 deletions internal/intern/intern_lru.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
//go:build go1.23 && launchdarkly_intern_json

package intern

import (
"strings"
"sync"
"sync/atomic"
"unique"

"github.com/launchdarkly/go-sdk-common/v3/ldvalue"
)

// LRU cache-backed interning that retains handles for frequently used strings.
// String() uses an LRU cache to store handles so canonical bytes survive
// across GCs and are reused by future lookups. Cache size is configurable
// via LAUNCHDARKLY_INTERN_CACHE_SIZE environment variable.

// LRU cache node for doubly-linked list
type lruNode struct {
key string
handle unique.Handle[string]
prev *lruNode
next *lruNode
}

// LRU cache implementation for bounded string interning
type lruCache struct {
mu sync.RWMutex
capacity int
cache map[string]*lruNode
head *lruNode // dummy head node
tail *lruNode // dummy tail node
// Statistics
hits uint64
misses uint64
evicted uint64
}

func newLRUCache(capacity int) *lruCache {
if capacity <= 0 {
capacity = 8192 // default capacity
}

lru := &lruCache{
capacity: capacity,
cache: make(map[string]*lruNode, capacity),
}

// Create dummy head and tail nodes
lru.head = &lruNode{}
lru.tail = &lruNode{}
lru.head.next = lru.tail
lru.tail.prev = lru.head

return lru
}

func (lru *lruCache) addToHead(node *lruNode) {
node.prev = lru.head
node.next = lru.head.next
lru.head.next.prev = node
lru.head.next = node
}

func (lru *lruCache) removeNode(node *lruNode) {
node.prev.next = node.next
node.next.prev = node.prev
}

func (lru *lruCache) moveToHead(node *lruNode) {
lru.removeNode(node)
lru.addToHead(node)
}

func (lru *lruCache) removeTail() *lruNode {
lastNode := lru.tail.prev
if lastNode == lru.head {
// Cache is empty, nothing to remove
return nil
}
lru.removeNode(lastNode)
return lastNode
}

func (lru *lruCache) get(key string) (unique.Handle[string], bool) {
lru.mu.Lock()
defer lru.mu.Unlock()

if node, exists := lru.cache[key]; exists {
// Move to head (most recently used)
lru.moveToHead(node)
lru.hits++
return node.handle, true
}
lru.misses++
return unique.Handle[string]{}, false
}

func (lru *lruCache) put(key string, handle unique.Handle[string]) {
lru.mu.Lock()
defer lru.mu.Unlock()

if node, exists := lru.cache[key]; exists {
// Update existing node
node.handle = handle
lru.moveToHead(node)
return
}

// Create new node
newNode := &lruNode{
key: key,
handle: handle,
}

if len(lru.cache) >= lru.capacity {
// Remove least recently used
tail := lru.removeTail()
if tail != nil {
delete(lru.cache, tail.key)
lru.evicted++
}
}

lru.cache[key] = newNode
lru.addToHead(newNode)
}

func (lru *lruCache) stats() (hits, misses, evicted uint64, size, capacity int) {
lru.mu.RLock()
defer lru.mu.RUnlock()
return lru.hits, lru.misses, lru.evicted, len(lru.cache), lru.capacity
}

func getRetainCacheSize() int {
return 8192 // default: 8K entries
}

var (
cache = newLRUCache(getRetainCacheSize())
internDisabled atomic.Bool
cacheMutex sync.RWMutex
)

func getCache() *lruCache {
cacheMutex.RLock()
defer cacheMutex.RUnlock()
return cache
}

// SetCacheSize allows runtime control of cache size and interning behavior.
// If size <= 0, interning is disabled entirely.
// If size > 0, creates a new cache with the specified capacity.
func SetCacheSize(size int) {
cacheMutex.Lock()
defer cacheMutex.Unlock()

if size <= 0 {
internDisabled.Store(true)
cache = nil
return
}

internDisabled.Store(false)
cache = newLRUCache(size)
}

// String returns a canonical form of s and retains a handle so the
// canonical bytes persist across GCs.
func String(s string) string {
if internDisabled.Load() {
return s
}

if s == "" {
return ""
}

cache := getCache()

// Check cache first
if handle, exists := cache.get(s); exists {
return handle.Value()
}

// Create new handle and cache it
h := unique.Make(strings.Clone(s))
v := h.Value()
cache.put(v, h)

return v
}

// Value canonicalizes only string ldvalue.Value; others are returned unchanged.
// String values retain their handle (same as String).
func Value(v ldvalue.Value) ldvalue.Value {
switch v.Type() {
case ldvalue.StringType:
return ldvalue.String(String(v.StringValue()))
default:
return v
}
}

// StringSlice applies String (retained) to all elements.
func StringSlice(ss []string) []string {
for i, s := range ss {
ss[i] = String(s)
}
return ss
}

// ValueSlice applies Value (retained for strings) to all elements.
func ValueSlice(vs []ldvalue.Value) []ldvalue.Value {
for i, v := range vs {
vs[i] = Value(v)
}
return vs
}

// CacheStats returns cache statistics for monitoring and debugging.
// Returns hits, misses, evicted count, current size, and capacity.
func CacheStats() (hits, misses, evicted uint64, size, capacity int) {
return getCache().stats()
}
Loading