Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased
### Added
- **Per-table cache invalidation via Bento** (`internal/cache/`, `internal/ingest/bento.go`): the ingest worker now triggers a targeted cache purge after successful ClickHouse batch flushes. This ensures "Read-Your-Writes" consistency within the standalone deployment without requiring a complex WAL-merging query engine.
- **Cache Tagging system** (`internal/cache/local.go`, `internal/cache/tiered.go`): the `Cache` interface now supports `Set(..., tags []string)` and `InvalidateByTags(ctx, tags)`. `LocalCache` uses a double-indexed map (`tagsMap` and `keyTags`) to track which cache keys are associated with which tables, with automatic cleanup of the tag index on eviction to prevent memory leaks.
- **Safe SQL Tag Extraction** (`internal/api/query.go`, `internal/api/pipes.go`): introduced `extractCacheTags()` helper that parses SQL for `FROM`, `JOIN`, and `INTO` clauses to automatically tag cache entries. Includes regex-based validation of identifiers to prevent cache-poisoning via malformed SQL.

### Fixed
- **Race condition in cache invalidation** (`internal/cache/local.go`): replaced `sync.Map` with a standard map guarded by a `sync.RWMutex` to resolve a TOCTOU (Time-of-Check to Time-of-Use) race where a concurrent `Set` could orphan a new cache entry during an active `Invalidate` sweep.
- **Coarse test mocks** (`internal/testutil/mocks.go`, `internal/cache/tiered_test.go`): updated test mocks to correctly handle tag intersection logic, ensuring that invalidating table "A" does not accidentally wipe cached results for table "B".

### Changed
- **Breaking: Raw SQL Cache Bypass** (`internal/api/query.go`, `docs/api.md`): the `/v1/query` endpoint now strictly bypasses the cache (`X-Cache: BYPASS`). This provides a safety hatch for administrative and E2E testing workflows that require absolute real-time consistency.
- **Breaking:** The `Cache` interface methods `Set` and `InvalidateByPrefix` (now `InvalidateByTags`) have updated signatures to support the tagging architecture.

Comment thread
taitelee marked this conversation as resolved.
Resolved
### Removed

Expand Down
8 changes: 7 additions & 1 deletion cmd/wavehouse/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,12 @@ func run() int {
logger.Error("cache init", "error", err)
return 1
}
tiered := cache.NewTiered(l1, nil)
tiered, err := cache.NewTiered(l1, nil)
if err != nil {
logger.Error("tiered cache init", "error", err)
return 1
}

defer func() { _ = tiered.Close() }()

// Policy store (NATS KV + optional file bootstrap).
Expand Down Expand Up @@ -281,6 +286,7 @@ func run() int {
ctx,
embeddedMQ.NatsConn(),
chConn,
tiered,
cfg.ClickHouse.Addr,
cfg.ClickHouse.HTTPPort, // Uses 8123 by default
cfg.ClickHouse.HTTPScheme,
Expand Down
5 changes: 3 additions & 2 deletions docs/src/content/docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,8 +209,9 @@ Executes a SQL query directly against ClickHouse. Results are cached in-process

The response includes a cache header:

- `X-Cache: HIT` — served from cache
- `X-Cache: MISS` — fetched from ClickHouse
- `X-Cache: BYPASS` — the query bypassed the cache to ensure consistency (standard for raw SQL). Additionally, mutation statements (`INSERT`, `UPDATE`, `DELETE`, `TRUNCATE`, `DROP`, `ALTER`, `REPLACE`) trigger [`InvalidateByTags`](../architecture.md) for the affected tables, clearing any cached results from `/v1/tables/{table}/query` and `/v1/pipes/{name}` for those tables.
- `X-Cache: HIT` — served from cache (Standard for Pipes and Structured Queries)
- `X-Cache: MISS` — fetched from ClickHouse and subsequently cached

**Error responses:**

Expand Down
27 changes: 20 additions & 7 deletions docs/src/content/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,13 +153,18 @@ Client POST /v1/ingest/{table}

Bento ingest pipeline (StartIngestWorker):
← JetStream pull consumer (buffer-consumer) on ingest.>
→ Validate table name against safeIdentifierRe
→ Validate table name against query.SafeIdentifierRe (internal/query/sqlutil.go)
→ Batch events per table, bulk INSERT to ClickHouse
Comment thread
taitelee marked this conversation as resolved.
Resolved
→ Delete actions handled inline (chConn.Exec)
→ On success: DoubleAck and continue
→ On failure: route original envelope to dlq.{table} and DoubleAck (Phase 1: all delete errors treated as permanent to prevent infinite Nak loops)
→ On success: DoubleAck messages
→ On failure: route to DLQ output (dlq.{table}), then Ack to prevent infinite retry
→ On success:
→ Trigger Cache.InvalidateByTags([table_name])
→ (Purges all L1/L2 entries associated with that table)
→ DoubleAck messages to NATS
→ On failure:
→ Route batch to DLQ output (subject: dlq.{table})
→ Ack to NATS to prevent infinite retry loops

Active Sweeper (async goroutine, every 60s):
→ Read buffer consumer's AckFloor (highest contiguous ACKed seq)
Expand All @@ -171,14 +176,22 @@ Active Sweeper (async goroutine, every 60s):
### Query Path

```text
Client POST /v1/query
Client POST /v1/query (Raw SQL)
→ Optional JWT auth middleware
→ Execute query directly on ClickHouse (Cache Bypass)
→ If SQL is a mutation (INSERT/UPDATE/DELETE/TRUNCATE/DROP/ALTER/REPLACE):
→ Extract table names
→ Trigger InvalidateByTags across Cache tiers
→ Return result (X-Cache: BYPASS header)

Client POST /v1/tables/{table}/query (Structured) or /v1/pipes/{name}
→ Optional JWT auth middleware
→ Check tiered cache (L1 → L2)
→ Cache HIT: return cached result (X-Cache: HIT header)
→ Cache MISS:
→ Execute query directly on ClickHouse
→ Convert UUID/DateTime types to strings
→ Store result in L1 + L2
→ Execute query on ClickHouse
→ Extract table tags from SQL
→ Store result in L1 + L2 associated with table tags
→ Return result (X-Cache: MISS header)
```

Expand Down
21 changes: 17 additions & 4 deletions internal/api/pipes.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package api
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"time"

Expand All @@ -17,12 +18,12 @@ import (
type PipesHandler struct {
Store *pipes.Store
CHConn driver.Conn
Cache *cache.TieredCache
Cache cache.Cache

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.

Why? Why not leave as TieredCache? They do the same things for us under the hood, no?

DefaultTTL time.Duration
sf singleflight.Group
}

func NewPipesHandler(store *pipes.Store, conn driver.Conn, c *cache.TieredCache, defaultTTL time.Duration) *PipesHandler {
func NewPipesHandler(store *pipes.Store, conn driver.Conn, c cache.Cache, defaultTTL time.Duration) *PipesHandler {
return &PipesHandler{Store: store, CHConn: conn, Cache: c, DefaultTTL: defaultTTL}
}

Expand Down Expand Up @@ -134,7 +135,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 +151,20 @@ func (h *PipesHandler) Execute(w http.ResponseWriter, r *http.Request) {
}

if h.Cache != nil {
_ = h.Cache.Set(r.Context(), cacheKey, data, h.DefaultTTL)
cleanedSQL := cleanSQLForTags(sql)
tags := extractCacheTagsFromCleaned(cleanedSQL)

// Detach from r.Context() so a cancelled originating request doesn't
// abort the cache populate — singleflight followers may already have
// received the result, and the next request should be served warm.
setCtx, setCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer setCancel()
if err := h.Cache.Set(setCtx, cacheKey, data, h.DefaultTTL, tags); err != nil {
slog.WarnContext(r.Context(), "cache set failed for pipe execution",

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.

Should this not be the same context as the setCtx is here for the cache set, why/why not?

"key", cacheKey,
"error", err,
)
}
}
return data, nil
})
Expand Down
30 changes: 30 additions & 0 deletions internal/api/pipes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/Wave-RF/WaveHouse/internal/pipes"
"github.com/Wave-RF/WaveHouse/internal/testutil"
"github.com/go-chi/chi/v5"
"github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -327,3 +329,31 @@ func TestPipesHandler_Execute_NoRolesAllowsAll(t *testing.T) {
assert.NotEqual(t, http.StatusForbidden, w.Code)
assert.NotEqual(t, http.StatusNotFound, w.Code)
}

func TestPipesHandler_Execute_CacheTags(t *testing.T) {
t.Parallel()

mockCache := testutil.NewMockCache()
mockConn := &testutil.MockConn{}

pipe := &pipes.NamedQuery{
Name: "active_users",
SQL: "SELECT * FROM users WHERE active = 1",
}
store := pipes.NewMemoryStore(pipe)
h := NewPipesHandler(store, mockConn, mockCache, time.Minute)

r := pipesRequest(t, http.MethodPost, "/v1/pipes/active_users/execute", "active_users", nil)
w := httptest.NewRecorder()
h.Execute(w, r)

require.Equal(t, http.StatusOK, w.Code)

// Reconstruct the key the handler computed so we can probe the mock.
sql, params, err := pipes.BindParams(pipe, map[string]any{})
require.NoError(t, err)
key := queryCacheKey(sql, params)

assert.Equal(t, []string{"users"}, mockCache.TagsForKey(key),
"Execute must call Cache.Set with the table tags extracted from the pipe SQL")
}
Loading
Loading