Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
5d0ec08
feat: migrate cache logic to feature branch
taitelee May 5, 2026
0e95298
invalidate prefix test
taitelee May 6, 2026
87d578e
feat(cache): implement per-table tagging and raw SQL bypass for read-…
taitelee May 12, 2026
d0c2880
make fix
taitelee May 12, 2026
a38fa69
refactor(cache): fix tag invalidation race condition and DRY table ex…
taitelee May 12, 2026
41efec7
fix(cache): resolve TOCTOU race, fix tagsMap memory leak, refine test…
taitelee May 12, 2026
8960bb2
fix(cache): resolve tag invalidation races, fix memory leaks, and add…
taitelee May 12, 2026
1347c02
fix(cache): close TOCTOU race during TTL eviction tag cleanup
taitelee May 12, 2026
8fc97a6
fix(cache): implement atomic tag index cleanup via value wrapping and…
taitelee May 13, 2026
79f4258
make fix
taitelee May 13, 2026
9afeefa
refactor: centralize SQL validation, fix Bento cache capture race, an…
taitelee May 13, 2026
f73c424
fix(api): expand mutation regex to include DDL keywords and sync docs
taitelee May 13, 2026
1148a02
Merge remote-tracking branch 'origin/main' into cache_updates
taitelee May 13, 2026
a208da9
fix(api): restore singleflight for raw queries and document bento clo…
taitelee May 13, 2026
4e50cab
fix(api): handle SQL comments, decouple mutation contexts, and fix un…
taitelee May 13, 2026
86e85eb
fix(api): bypass singleflight for mutations and strip SQL comments/li…
taitelee May 13, 2026
976f793
Merge remote-tracking branch 'origin/main' into cache_updates
taitelee May 13, 2026
2898cb2
fix(api): decouple mutation logic from cache state and fix pipe tag e…
taitelee May 13, 2026
f5269d8
Merge remote-tracking branch 'origin/main' into cache_updates
taitelee May 13, 2026
e0c9bbb
fix: prevent nil cache panic, use interfaces for dependency injection…
taitelee May 13, 2026
0f606a3
fix: decouple mutation logic, enforce interface-first injection, and …
taitelee May 13, 2026
63e3b70
fix: resolve local cache race conditions, regex escapes, and bento po…
taitelee May 13, 2026
47aff71
fix(cache): prevent tag orphaning on admission rejection and handle s…
taitelee May 13, 2026
3604b56
fix(cache): seal TOCTOU window in Set, add multi-tag tests, and updat…
taitelee May 13, 2026
735d060
test(cache): reorder assertions in InvalidateByTags to account for se…
taitelee May 13, 2026
fe97e13
fix(cache): move to synchronous tag cleanup to avoid OnEvict race con…
taitelee May 13, 2026
d8f7b5c
Merge remote-tracking branch 'origin/main' into cache_updates
taitelee May 13, 2026
755985a
fix: restore full-string mutation detection for CTEs and document cac…
taitelee May 13, 2026
9b522c8
Merge branch 'main' into cache_updates
taitelee May 13, 2026
348b5ea
fix: make bento DB config dynamic, enforce tag cleanup, and internali…
taitelee May 13, 2026
593304a
Merge remote-tracking branch 'origin/main' into cache_updates
taitelee May 13, 2026
2652fd3
fix: implement final strict concurrency and architectural invariants
taitelee May 13, 2026
d1d18a0
fix(cache): enforce versioned eviction nonces to guarantee read-your-…
taitelee May 13, 2026
eff476d
fix(cache): safeguard tag index on cache rejection and use idiomatic …
taitelee May 14, 2026
28622d4
fix(cache): flush ristretto async buffers before tag invalidation to …
taitelee May 14, 2026
9c1bb4f
test: assert read queries do not trigger cache invalidation and remov…
taitelee May 14, 2026
57d4e19
refactor: ensure cache consistency with detached contexts and precise…
taitelee May 15, 2026
24bcf54
Make fix
taitelee May 15, 2026
246a1fa
fix: added unit test cases for extracting mutation targets, and addre…
taitelee May 15, 2026
9052b8b
fix(cache): seal InvalidateByTags slip-through and cover pipes tag w…
taitelee May 15, 2026
381d3d3
fix(cache): tag comma-join tables, log Ristretto rejections, add RYW …
taitelee May 15, 2026
c577ab8
test(cache): exercise RYW through HTTP and document intentional ctx-drop
taitelee May 15, 2026
ac2ed2d
test(api): pin mutationRe column-prefix invariant, cover REPLACE INTO…
taitelee May 18, 2026
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
1 change: 1 addition & 0 deletions cmd/wavehouse/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ func run() int {
ctx,
embeddedMQ.NatsConn(),
chConn,
tiered,
cfg.ClickHouse.Addr,
cfg.ClickHouse.HTTPPort, // Uses 8123 by default
cfg.ClickHouse.HTTPScheme,
Expand Down
25 changes: 23 additions & 2 deletions internal/api/pipes.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"net/http"
"regexp"
"time"

"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
Expand All @@ -13,6 +14,25 @@ import (
"golang.org/x/sync/singleflight"
)

// Naive regex to extract table names from queries
var extractTablesRe = regexp.MustCompile(`(?i)\b(?:FROM|JOIN)\s+([a-zA-Z_][a-zA-Z0-9_]*)\b`)

func extractTables(sql string) []string {
matches := extractTablesRe.FindAllStringSubmatch(sql, -1)
var tables []string
seen := make(map[string]bool)
for _, m := range matches {
if len(m) > 1 {
tbl := m[1]
if !seen[tbl] {
seen[tbl] = true
tables = append(tables, tbl)
}
}
}
return tables
}

// PipesHandler handles named query pipe endpoints.
type PipesHandler struct {
Store *pipes.Store
Expand Down Expand Up @@ -134,7 +154,6 @@ func (h *PipesHandler) Execute(w http.ResponseWriter, r *http.Request) {
}
}

// Execute with singleflight.
v, err, _ := h.sf.Do(cacheKey, func() (interface{}, error) {
queryCtx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
Expand All @@ -151,7 +170,9 @@ func (h *PipesHandler) Execute(w http.ResponseWriter, r *http.Request) {
}

if h.Cache != nil {
_ = h.Cache.Set(r.Context(), cacheKey, data, h.DefaultTTL)
// Extract tags and assign to the cache entry
tags := extractTables(sql)
_ = h.Cache.Set(r.Context(), cacheKey, data, h.DefaultTTL, tags)
}
return data, nil
})
Expand Down
64 changes: 34 additions & 30 deletions internal/api/query.go

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really don't like all the regex in this file... it scares me... also worries me that is may go out of sync with regex we have in the internal query package. How much of this is needed here vs should/could be shared and may be useful more generally, and/or what of this do you think will be removed anyway when we work on #167?

Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,19 @@ import (
"fmt"
"net/http"
"reflect"
"regexp"
"time"

"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
"github.com/Wave-RF/WaveHouse/internal/cache"
"github.com/Wave-RF/WaveHouse/internal/policy"
"github.com/google/uuid"
"golang.org/x/sync/singleflight"
)

// Regex to detect mutating queries and extract table names
var (
mutationRe = regexp.MustCompile(`(?i)^\s*(INSERT|UPDATE|DELETE|ALTER|DROP|TRUNCATE)`)
extractTablesRawRe = regexp.MustCompile(`(?i)\b(?:FROM|JOIN|INTO|UPDATE|TABLE)\s+([a-zA-Z_][a-zA-Z0-9_]*)\b`)
)

// QueryHandler handles POST /v1/query.
Expand All @@ -23,7 +29,6 @@ type QueryHandler struct {
Cache *cache.TieredCache
DefaultTTL time.Duration
PolicyStore *policy.Store
sf singleflight.Group
}

func NewQueryHandler(conn driver.Conn, c *cache.TieredCache, defaultTTL time.Duration) *QueryHandler {
Expand All @@ -40,7 +45,6 @@ func (h *QueryHandler) Handle(w http.ResponseWriter, r *http.Request) {
if h.PolicyStore != nil {
role := RoleFromContext(r.Context())
if role != "" && role != "admin" && role != "service" {
// Check if the role has raw_sql permission on any table.
p := h.PolicyStore.Get()
if p != nil {
claims, _ := ClaimsFromContext(r.Context())
Expand Down Expand Up @@ -70,45 +74,45 @@ func (h *QueryHandler) Handle(w http.ResponseWriter, r *http.Request) {
return
}

cacheKey := queryCacheKey(req.SQL, req.Params)
queryCtx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
Comment thread
taitelee marked this conversation as resolved.
Resolved
defer cancel()

// Try cache.
if h.Cache != nil {
if data, _, err := h.Cache.Get(r.Context(), cacheKey); err == nil && data != nil {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Cache", "HIT")
_, _ = w.Write(data)
return
}
// Execute immediately (Bypassing the Cache)
result, err := h.executeQuery(queryCtx, req.SQL, req.Params)
Comment thread
taitelee marked this conversation as resolved.
Resolved
if err != nil {
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}

// Execute query with singleflight to protect ClickHouse from thundering herds.
v, err, _ := h.sf.Do(cacheKey, func() (interface{}, error) {
queryCtx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
result, err := h.executeQuery(queryCtx, req.SQL, req.Params)
if err != nil {
return nil, err
// CRUDE INVALIDATION STOP-GAP:
// If this raw SQL was a mutation, extract the table names and invalidate them.
if h.Cache != nil && mutationRe.MatchString(req.SQL) {
matches := extractTablesRawRe.FindAllStringSubmatch(req.SQL, -1)
var tags []string
seen := make(map[string]bool)
for _, m := range matches {
if len(m) > 1 {
tbl := m[1]
if !seen[tbl] {
seen[tbl] = true
tags = append(tags, tbl)
}
}
}
Comment thread
taitelee marked this conversation as resolved.
Resolved
Comment thread
taitelee marked this conversation as resolved.
Resolved
Comment thread
taitelee marked this conversation as resolved.
Resolved

data, err := json.Marshal(result)
if err != nil {
return nil, err
if len(tags) > 0 {
_ = h.Cache.InvalidateByTags(r.Context(), tags)
}
}

if h.Cache != nil {
_ = h.Cache.Set(r.Context(), cacheKey, data, h.DefaultTTL)
}
return data, nil
})
data, err := json.Marshal(result)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}

w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Cache", "MISS")
_, _ = w.Write(v.([]byte))
w.Header().Set("X-Cache", "BYPASS")
_, _ = w.Write(data)
}

func (h *QueryHandler) executeQuery(ctx context.Context, sql string, params []any) ([]map[string]any, error) {
Expand Down
2 changes: 1 addition & 1 deletion internal/api/structured_query.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ func (h *StructuredQueryHandler) Handle(w http.ResponseWriter, r *http.Request)
}

if h.Cache != nil {
_ = h.Cache.Set(r.Context(), cacheKey, data, ttl)
_ = h.Cache.Set(r.Context(), cacheKey, data, ttl, []string{table})
}
return data, nil
})
Expand Down
7 changes: 5 additions & 2 deletions internal/cache/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@ type Cache interface {
// Get retrieves a value and its remaining TTL. Returns nil, 0, nil on miss.
Get(ctx context.Context, key string) ([]byte, time.Duration, error)

// Set stores a value with the given TTL.
Set(ctx context.Context, key string, value []byte, ttl time.Duration) error
// Set stores a value with the given TTL and tags.
Set(ctx context.Context, key string, value []byte, ttl time.Duration, tags []string) error

// InvalidateByTags drops all keys associated with the provided tags.
InvalidateByTags(ctx context.Context, tags []string) error
Comment thread
taitelee marked this conversation as resolved.
Resolved
Comment thread
taitelee marked this conversation as resolved.
Resolved

// Close releases resources.
Close() error
Expand Down
51 changes: 39 additions & 12 deletions internal/cache/local.go

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would like to talk about this file and help understanding it in-person review later, lot's of questions/confusion on it and could use a walkthrough

Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ import (

// LocalCache is an L1 in-process cache backed by Ristretto.
type LocalCache struct {
cache *ristretto.Cache[string, []byte]
ttls sync.Map // key -> expiry time.Time
cache *ristretto.Cache[string, []byte]
ttls sync.Map // key -> expiry time.Time
tagsMap sync.Map // tag (string) -> *sync.Map (key -> struct{})
}

// NewLocal creates a new Ristretto-backed local cache.
Expand All @@ -34,27 +35,53 @@ func (l *LocalCache) Get(_ context.Context, key string) ([]byte, time.Duration,
}

var remaining time.Duration
if exp, ok := l.ttls.Load(key); ok {
remaining = time.Until(exp.(time.Time))
if remaining <= 0 {
l.cache.Del(key)
l.ttls.Delete(key)
return nil, 0, nil
if expVal, ok := l.ttls.Load(key); ok {
exp := expVal.(time.Time)
if !exp.IsZero() {
remaining = time.Until(exp)

@taitelee taitelee May 15, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@EricAndrechek I've made Get do a expired key cleanup, but I'm not sure if having a routine do this be a better option. In terms of overall efficiency, a background routine would ensure we don't leave 'stale links' in our tag maps for data that is never asked for again. However, I’m worried that a background worker might slow down the system by locking the cache frequently, whereas the current approach only does the work when someone actually needs the data.

if remaining <= 0 {
l.cache.Del(key)
l.ttls.Delete(key)
Comment thread
taitelee marked this conversation as resolved.
Resolved
return nil, 0, nil
}
Comment thread
taitelee marked this conversation as resolved.
Resolved
Comment thread
taitelee marked this conversation as resolved.
Resolved
}
}
return val, remaining, nil
}

func (l *LocalCache) Set(_ context.Context, key string, value []byte, ttl time.Duration) error {
func (l *LocalCache) Set(_ context.Context, key string, value []byte, ttl time.Duration, tags []string) error {
l.cache.SetWithTTL(key, value, int64(len(value)), ttl)
var exp time.Time
if ttl > 0 {
l.ttls.Store(key, time.Now().Add(ttl))
exp = time.Now().Add(ttl)
}
l.ttls.Store(key, exp)
Comment thread
taitelee marked this conversation as resolved.
Resolved

// Register key under each tag
for _, tag := range tags {
m, _ := l.tagsMap.LoadOrStore(tag, &sync.Map{})
m.(*sync.Map).Store(key, struct{}{})
Comment thread
taitelee marked this conversation as resolved.
Resolved
}
return nil
}

func (l *LocalCache) InvalidateByTags(_ context.Context, tags []string) error {
Comment thread
taitelee marked this conversation as resolved.
Resolved
for _, tag := range tags {
if keysMap, ok := l.tagsMap.Load(tag); ok {
// Iterate through all keys associated with this tag
keysMap.(*sync.Map).Range(func(key, _ any) bool {
k := key.(string)
l.cache.Del(k)
Comment thread
taitelee marked this conversation as resolved.
Resolved
l.ttls.Delete(k)
return true
})
// Clear the tag index entirely
l.tagsMap.Delete(tag)
}
}
return nil
}
Comment thread
taitelee marked this conversation as resolved.
Resolved
Comment thread
taitelee marked this conversation as resolved.
Resolved

// Wait blocks until all buffered writes have been applied.
// Exposed for testing; production callers rarely need this.
func (l *LocalCache) Wait() {
l.cache.Wait()
}
Expand Down
43 changes: 38 additions & 5 deletions internal/cache/local_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func TestLocalCache_SetAndGet(t *testing.T) {
defer func() { _ = c.Close() }()

ctx := context.Background()
err = c.Set(ctx, "key1", []byte("hello"), 10*time.Second)
err = c.Set(ctx, "key1", []byte("hello"), 10*time.Second, nil)
assert.NoError(t, err)

// Ristretto uses async admission — wait briefly for it to be admitted.
Expand All @@ -48,7 +48,7 @@ func TestLocalCache_ExpiredKey(t *testing.T) {

ctx := context.Background()
// Set with very short TTL.
err = c.Set(ctx, "expires", []byte("data"), 1*time.Millisecond)
err = c.Set(ctx, "expires", []byte("data"), 1*time.Millisecond, nil)
assert.NoError(t, err)

// Ensure async admission completes, then wait for expiry.
Expand All @@ -67,9 +67,9 @@ func TestLocalCache_Overwrite(t *testing.T) {
defer func() { _ = c.Close() }()

ctx := context.Background()
require.NoError(t, c.Set(ctx, "key", []byte("v1"), 10*time.Second))
require.NoError(t, c.Set(ctx, "key", []byte("v1"), 10*time.Second, nil))
c.Wait()
require.NoError(t, c.Set(ctx, "key", []byte("v2"), 10*time.Second))
require.NoError(t, c.Set(ctx, "key", []byte("v2"), 10*time.Second, nil))
c.Wait()

val, _, err := c.Get(ctx, "key")
Expand All @@ -84,7 +84,7 @@ func TestLocalCache_ZeroTTL(t *testing.T) {
defer func() { _ = c.Close() }()

ctx := context.Background()
err = c.Set(ctx, "notimed", []byte("data"), 0)
err = c.Set(ctx, "notimed", []byte("data"), 0, nil)
assert.NoError(t, err)

time.Sleep(10 * time.Millisecond)
Expand All @@ -96,3 +96,36 @@ func TestLocalCache_ZeroTTL(t *testing.T) {
assert.Zero(t, ttl, "expected zero remaining TTL for key without TTL")
}
}

func TestLocalCache_InvalidateByTags(t *testing.T) {
t.Parallel()
c, err := NewLocal(1 << 20)
require.NoError(t, err)
defer func() { _ = c.Close() }()

ctx := context.Background()

// Pass the table names as the tags slice
require.NoError(t, c.Set(ctx, "query:clicks:123", []byte("val1"), 10*time.Second, []string{"clicks"}))
require.NoError(t, c.Set(ctx, "query:clicks:456", []byte("val2"), 0, []string{"clicks"}))

// This key belongs to 'users', so it won't be wiped when we invalidate 'clicks'
require.NoError(t, c.Set(ctx, "query:users:789", []byte("val3"), 10*time.Second, []string{"users"}))

c.Wait()

// Wipe out the clicks table cache
err = c.InvalidateByTags(ctx, []string{"clicks"})
assert.NoError(t, err)

// Verify the target keys are gone
val1, _, _ := c.Get(ctx, "query:clicks:123")
assert.Nil(t, val1)

val2, _, _ := c.Get(ctx, "query:clicks:456")
assert.Nil(t, val2)

// Verify the innocent key is still there
val3, _, _ := c.Get(ctx, "query:users:789")
assert.Equal(t, []byte("val3"), val3)
}
Comment thread
taitelee marked this conversation as resolved.
Resolved
21 changes: 17 additions & 4 deletions internal/cache/tiered.go

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See comments in file, but this needs revisions, should build it as if fully implemented/supported

Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ func (t *TieredCache) Get(ctx context.Context, key string) ([]byte, time.Duratio
val, ttl, err := t.l2.Get(ctx, key)
if err == nil && val != nil {
// Backfill L1 with remaining TTL from L2.
_ = t.l1.Set(ctx, key, val, ttl)
// Note: We don't have the original tags here, but L2 is out-of-scope for v1 Standalone.
_ = t.l1.Set(ctx, key, val, ttl, nil)
Comment thread
taitelee marked this conversation as resolved.
Resolved
Comment thread
taitelee marked this conversation as resolved.
Resolved
Comment thread
taitelee marked this conversation as resolved.
Resolved
Comment thread
taitelee marked this conversation as resolved.
Resolved
return &cacheResult{data: val, ttl: ttl}, nil
}
}
Expand All @@ -48,12 +49,24 @@ func (t *TieredCache) Get(ctx context.Context, key string) ([]byte, time.Duratio
return nil, 0, nil
}

func (t *TieredCache) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error {
if err := t.l1.Set(ctx, key, value, ttl); err != nil {
// UPDATE: Accept tags and pass them down
Comment thread
taitelee marked this conversation as resolved.
Resolved
func (t *TieredCache) Set(ctx context.Context, key string, value []byte, ttl time.Duration, tags []string) error {
if err := t.l1.Set(ctx, key, value, ttl, tags); err != nil {
return err
}
if t.l2 != nil {
return t.l2.Set(ctx, key, value, ttl)
return t.l2.Set(ctx, key, value, ttl, tags)
}
return nil
}

// UPDATE: Replace InvalidateByPrefix with InvalidateByTags
func (t *TieredCache) InvalidateByTags(ctx context.Context, tags []string) error {
if err := t.l1.InvalidateByTags(ctx, tags); err != nil {
return err
}
if t.l2 != nil {
return t.l2.InvalidateByTags(ctx, tags)
}
return nil
}
Expand Down
Loading
Loading