Skip to content

Commit 90e6499

Browse files
committed
Better thought out caching mechanism that actually works
This one follows up #794 again. That change included a template cache, with the idea being that we could reuse a rendered sqlc query when all input values were expected to be stable. For example, when replacing something like a schema name, we'd presumably always be replacing the template value with the same schema over and over again, so it'd be better to save on the work. But that caching system hadn't adequately been thought through, and wouldn't really work. I realized when I was putting #798 (explicit schemas) together that if you injected two schema values from two different clients then you'd end up using the same cache value, which is wrong. Here, we tool out the cache layer a little more so that it considers input values and named args, which all must be consistent for a cached value to be returned. We also add tests to make sure of it. Building a cache key unfortunately has to rely on concatenating some strings together and presorting map keys for stability, but even with the extra work involved, a cache hit still comes out to be quite a bit faster than a miss (~2.5x faster), so it seems worth doing: $ go test ./rivershared/sqlctemplate -bench=. goos: darwin goarch: arm64 pkg: github.com/riverqueue/river/rivershared/sqlctemplate cpu: Apple M4 BenchmarkReplacer/WithCache-10 1626988 735.7 ns/op BenchmarkReplacer/WithoutCache-10 695517 1710 ns/op PASS ok github.com/riverqueue/river/rivershared/sqlctemplate 3.419s
1 parent 6ce45ae commit 90e6499

2 files changed

Lines changed: 199 additions & 25 deletions

File tree

rivershared/sqlctemplate/sqlc_template.go

Lines changed: 75 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ type Replacement struct {
8888
// be initialized with a constructor. This lets it default to a usable instance
8989
// on drivers that may themselves not be initialized.
9090
type Replacer struct {
91-
cache map[string]string
91+
cache map[replacerCacheKey]string
9292
cacheMu sync.RWMutex
9393
}
9494

@@ -131,22 +131,25 @@ func (r *Replacer) RunSafely(ctx context.Context, sql string, args []any) (strin
131131
return "", nil, errors.New("sqlctemplate found template(s) in SQL, but no context container; bug?")
132132
}
133133

134-
r.cacheMu.RLock()
135-
var (
136-
cachedSQL string
137-
cachedSQLOK bool
138-
)
139-
if r.cache != nil { // protect against map not initialized yet
140-
cachedSQL, cachedSQLOK = r.cache[sql]
141-
}
142-
r.cacheMu.RUnlock()
134+
cacheKey, cacheEligible := replacerCacheKeyFrom(sql, container)
135+
if cacheEligible {
136+
r.cacheMu.RLock()
137+
var (
138+
cachedSQL string
139+
cachedSQLOK bool
140+
)
141+
if r.cache != nil { // protect against map not initialized yet
142+
cachedSQL, cachedSQLOK = r.cache[cacheKey]
143+
}
144+
r.cacheMu.RUnlock()
143145

144-
// If all input templates were stable, the finished SQL will have been cached.
145-
if cachedSQLOK {
146-
if len(container.NamedArgs) > 0 {
147-
args = append(args, maputil.Values(container.NamedArgs)...)
146+
// If all input templates were stable, the finished SQL will have been cached.
147+
if cachedSQLOK {
148+
if len(container.NamedArgs) > 0 {
149+
args = append(args, maputil.Values(container.NamedArgs)...)
150+
}
151+
return cachedSQL, args, nil
148152
}
149-
return cachedSQL, args, nil
150153
}
151154

152155
var (
@@ -212,19 +215,15 @@ func (r *Replacer) RunSafely(ctx context.Context, sql string, args []any) (strin
212215
}
213216
}
214217

215-
for _, replacement := range container.Replacements {
216-
if !replacement.Stable {
217-
return updatedSQL, args, nil
218+
if cacheEligible {
219+
r.cacheMu.Lock()
220+
if r.cache == nil {
221+
r.cache = make(map[replacerCacheKey]string)
218222
}
223+
r.cache[cacheKey] = updatedSQL
224+
r.cacheMu.Unlock()
219225
}
220226

221-
r.cacheMu.Lock()
222-
if r.cache == nil {
223-
r.cache = make(map[string]string)
224-
}
225-
r.cache[sql] = updatedSQL
226-
r.cacheMu.Unlock()
227-
228227
return updatedSQL, args, nil
229228
}
230229

@@ -254,3 +253,54 @@ func WithReplacements(ctx context.Context, replacements map[string]Replacement,
254253
Replacements: replacements,
255254
})
256255
}
256+
257+
// Comparable struct that's used as a key for template caching.
258+
type replacerCacheKey struct {
259+
namedArgs string // all arg names concatenated together
260+
replacementValues string // all values concatenated together
261+
sql string
262+
}
263+
264+
// Builds a cache key for the given SQL and context container.
265+
//
266+
// A key is only built if the given SQL/templates are cacheable, which means all
267+
// template values must be stable. The second return value is a boolean
268+
// indicating whether a cache key was built or not. If false, the input is not
269+
// eligible for caching, and no check against the cache should be made.
270+
func replacerCacheKeyFrom(sql string, container *contextContainer) (replacerCacheKey, bool) {
271+
// Only eligible for caching if all replacements are stable.
272+
for _, replacement := range container.Replacements {
273+
if !replacement.Stable {
274+
return replacerCacheKey{}, false
275+
}
276+
}
277+
278+
var (
279+
namedArgsBuilder strings.Builder
280+
281+
// Named args must be sorted for key stability because Go maps don't
282+
// provide any ordering guarantees.
283+
sortedNamedArgs = maputil.Keys(container.NamedArgs)
284+
)
285+
slices.Sort(sortedNamedArgs)
286+
for _, namedArg := range sortedNamedArgs {
287+
namedArgsBuilder.WriteRune('@') // useful as separator because not valid in the name of a named arg
288+
namedArgsBuilder.WriteString(namedArg)
289+
}
290+
291+
var (
292+
replacementValuesBuilder strings.Builder
293+
sortedReplacements = maputil.Keys(container.Replacements)
294+
)
295+
slices.Sort(sortedReplacements)
296+
for _, template := range sortedReplacements {
297+
replacementValuesBuilder.WriteRune('•') // use a separator that SQL would reject under most circumstances (this may be imperfect)
298+
replacementValuesBuilder.WriteString(container.Replacements[template].Value)
299+
}
300+
301+
return replacerCacheKey{
302+
namedArgs: namedArgsBuilder.String(),
303+
replacementValues: replacementValuesBuilder.String(),
304+
sql: sql,
305+
}, true
306+
}

rivershared/sqlctemplate/sqlc_template_test.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,96 @@ func TestReplacer(t *testing.T) {
199199
require.Empty(t, replacer.cache)
200200
})
201201

202+
t.Run("CacheBasedOnInputValues", func(t *testing.T) {
203+
t.Parallel()
204+
205+
replacer, _ := setup(t)
206+
207+
// SQL stays constant across all runs.
208+
const sql = `
209+
SELECT count(*)
210+
FROM /* TEMPLATE: schema */river_job
211+
WHERE kind = @kind
212+
AND state = @state;
213+
`
214+
215+
// Initially cached value
216+
{
217+
ctx := WithReplacements(ctx, map[string]Replacement{
218+
"schema": {Stable: true, Value: "test_schema."},
219+
}, nil)
220+
221+
_, _, err := replacer.RunSafely(ctx, sql, nil)
222+
require.NoError(t, err)
223+
}
224+
require.Len(t, replacer.cache, 1)
225+
226+
// Same SQL, but new value.
227+
{
228+
ctx := WithReplacements(ctx, map[string]Replacement{
229+
"schema": {Stable: true, Value: "other_schema."},
230+
}, nil)
231+
232+
_, _, err := replacer.RunSafely(ctx, sql, nil)
233+
require.NoError(t, err)
234+
}
235+
require.Len(t, replacer.cache, 2)
236+
237+
// Named arg added to the mix.
238+
{
239+
ctx := WithReplacements(ctx, map[string]Replacement{
240+
"schema": {Stable: true, Value: "test_schema."},
241+
}, map[string]any{
242+
"kind": "kind_value",
243+
})
244+
245+
_, _, err := replacer.RunSafely(ctx, sql, nil)
246+
require.NoError(t, err)
247+
}
248+
require.Len(t, replacer.cache, 3)
249+
250+
// Different named arg _value_ (i.e. still same named arg) can still use
251+
// the previous cached SQL.
252+
{
253+
ctx := WithReplacements(ctx, map[string]Replacement{
254+
"schema": {Stable: true, Value: "test_schema."},
255+
}, map[string]any{
256+
"kind": "other_kind_value",
257+
})
258+
259+
_, _, err := replacer.RunSafely(ctx, sql, nil)
260+
require.NoError(t, err)
261+
}
262+
require.Len(t, replacer.cache, 3) // unchanged
263+
264+
// New named arg adds a new cache value.
265+
{
266+
ctx := WithReplacements(ctx, map[string]Replacement{
267+
"schema": {Stable: true, Value: "test_schema."},
268+
}, map[string]any{
269+
"kind": "kind_value",
270+
"state": "state_value",
271+
})
272+
273+
_, _, err := replacer.RunSafely(ctx, sql, nil)
274+
require.NoError(t, err)
275+
}
276+
require.Len(t, replacer.cache, 4)
277+
278+
// Different input SQL.
279+
{
280+
ctx := WithReplacements(ctx, map[string]Replacement{
281+
"schema": {Stable: true, Value: "test_schema."},
282+
}, nil)
283+
284+
_, _, err := replacer.RunSafely(ctx, `
285+
SELECT /* TEMPLATE: schema */river_job;
286+
`, nil)
287+
require.NoError(t, err)
288+
}
289+
require.Len(t, replacer.cache, 5)
290+
})
291+
202292
t.Run("NamedArgsNoInitialArgs", func(t *testing.T) {
203293
t.Parallel()
204294

@@ -369,3 +459,37 @@ func TestReplacer(t *testing.T) {
369459
wg.Wait()
370460
})
371461
}
462+
463+
func BenchmarkReplacer(b *testing.B) {
464+
ctx := context.Background()
465+
466+
runReplacer := func(b *testing.B, replacer *Replacer, stable bool) {
467+
b.Helper()
468+
469+
ctx := WithReplacements(ctx, map[string]Replacement{
470+
"schema": {Stable: stable, Value: "test_schema."},
471+
}, nil)
472+
473+
_, _, err := replacer.RunSafely(ctx, `
474+
-- name: JobCountByState :one
475+
SELECT count(*)
476+
FROM /* TEMPLATE: schema */river_job
477+
WHERE state = @state;
478+
`, nil)
479+
require.NoError(b, err)
480+
}
481+
482+
b.Run("WithCache", func(b *testing.B) {
483+
var replacer Replacer
484+
for range b.N {
485+
runReplacer(b, &replacer, true)
486+
}
487+
})
488+
489+
b.Run("WithoutCache", func(b *testing.B) {
490+
var replacer Replacer
491+
for range b.N {
492+
runReplacer(b, &replacer, false)
493+
}
494+
})
495+
}

0 commit comments

Comments
 (0)