You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Update (2026-05-18): Resolved by PR #119 (feat(cache): implement per-table tagging and raw SQL bypass), which combines Option B + Option C below per the architecture review. Closing once #119 merges. The original problem statement and option analysis are kept below for historical context.
Any post-merge follow-up (e.g. tail-chopping for high-write tables; tracked in #86) belongs in its own issue, not as a re-open here.
Problem
internal/api/query.go:QueryHandler caches /v1/query responses keyed on sha256(sql + params) with a 5s default TTL. The cache has no invalidation signal from ingest writes — reads can return stale data for up to TTL after a write to the same table(s).
Surfaced in #71's new E2E Tests job as a cache-key collision between two tests:
admin.test.ts:182 runs wh.sql('SELECT count() as cnt FROM default.clicks') against an empty clicks table at the top of the run → caches {cnt: 0}.
query.test.ts:97 runs the byte-identical query after beforeAll seeds 10 rows → hits the cached {cnt: 0} and fails toBeGreaterThan(0).
The test collision is trivially fixable, but it exposes the broader read-your-writes hole: in production, an admin that inserts a row and immediately runs sql(count()) sees the stale count for up to 5s. Structured queries (/v1/query-builder-style) have the same risk whenever they hit the same cache layer.
Option A — Read-your-writes via WAL buffer (Eric's initial proposal)
On read, augment the CH-cached result with rows still in the NATS WAL buffer for the tables the query references.
Pros: zero stale-read window by construction; users always see their own writes instantly.
Pushback (and why this is harder than it looks):
Trivial for simple aggregates (count, sum, avg) — you layer the in-memory buffer count on top of the cached result.
For anything richer — GROUP BY, projections, joins, WHERE filters on fields not in the cache envelope — you need to actually execute the query against the in-memory buffer. That's a mini query engine.
Call this out explicitly in the issue's design: don't drift toward 'let's write a query engine'. Scope the v1.
Option B — Per-table last-write-timestamp invalidation
Ingest writes to table X bump a last_write_ts[X] monotonic counter. Cache entries capture the list of tables they query (parse FROM at cache-set time). On cache Get, revalidate: miss if max(last_write_ts[tables-in-query]) > entry.written_at.
Pros: simple, works for every query shape (aggregates, GROUP BY, joins), no query engine needed.
Cons / tradeoffs:
Cache thrash in hot-ingest scenarios — every insert to clicks invalidates every clicks-touching cache entry. Tunable with coarser granularity (e.g. second-bucketed timestamps, per-tenant+table keys).
Requires a FROM-clause parser. Can be naive (regex-based) for v1; lift to a real SQL AST parser later.
Still has a visibility window between the NATS publish and the cache invalidation — reads in that window still see stale data. Acceptable if the ingest → invalidate step is fast.
Option C — Bypass cache entirely for raw SQL
One-line change: in QueryHandler.Handle, skip the cache lookup when the query hits the /v1/query path (raw SQL, admin-gated).
Problem
internal/api/query.go:QueryHandlercaches/v1/queryresponses keyed onsha256(sql + params)with a 5s default TTL. The cache has no invalidation signal from ingest writes — reads can return stale data for up to TTL after a write to the same table(s).Surfaced in #71's new
E2E Testsjob as a cache-key collision between two tests:admin.test.ts:182runswh.sql('SELECT count() as cnt FROM default.clicks')against an emptyclickstable at the top of the run → caches{cnt: 0}.query.test.ts:97runs the byte-identical query afterbeforeAllseeds 10 rows → hits the cached{cnt: 0}and failstoBeGreaterThan(0).The test collision is trivially fixable, but it exposes the broader read-your-writes hole: in production, an admin that inserts a row and immediately runs
sql(count())sees the stale count for up to 5s. Structured queries (/v1/query-builder-style) have the same risk whenever they hit the same cache layer.Related
internal/api/query.go—queryCacheKey(),h.Cache.Get/h.Cache.Set,h.sf.Dosingleflightinternal/cache(TieredCache — L1 local + optional L2 shared)Options to decide in next standup
Option A — Read-your-writes via WAL buffer (Eric's initial proposal)
On read, augment the CH-cached result with rows still in the NATS WAL buffer for the tables the query references.
Pros: zero stale-read window by construction; users always see their own writes instantly.
Pushback (and why this is harder than it looks):
count,sum,avg) — you layer the in-memory buffer count on top of the cached result.GROUP BY, projections, joins,WHEREfilters on fields not in the cache envelope — you need to actually execute the query against the in-memory buffer. That's a mini query engine.Option B — Per-table last-write-timestamp invalidation
Ingest writes to table
Xbump alast_write_ts[X]monotonic counter. Cache entries capture the list of tables they query (parseFROMat cache-set time). On cacheGet, revalidate: miss ifmax(last_write_ts[tables-in-query]) > entry.written_at.Pros: simple, works for every query shape (aggregates,
GROUP BY, joins), no query engine needed.Cons / tradeoffs:
clicksinvalidates everyclicks-touching cache entry. Tunable with coarser granularity (e.g. second-bucketed timestamps, per-tenant+table keys).FROM-clause parser. Can be naive (regex-based) for v1; lift to a real SQL AST parser later.Option C — Bypass cache entirely for raw SQL
One-line change: in
QueryHandler.Handle, skip the cache lookup when the query hits the/v1/querypath (raw SQL, admin-gated).