Skip to content

perf(cache): query cache returns stale data after writes (read-your-writes) #73

Description

@EricAndrechek

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.

Related

  • Surfaced by: ci: bundle CI hygiene fixes for #70 #71 (bundled CI hygiene PR)
  • Code: internal/api/query.go — queryCacheKey(), h.Cache.Get / h.Cache.Set, h.sf.Do singleflight
  • Cache layer: internal/cache (TieredCache — L1 local + optional L2 shared)
  • Ingest path (the WAL): Bento → NATS JetStream → ClickHouse, 5s batch window

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):

  • 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).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

area/apiHTTP handlers, routing, middlewarearea/cacheLocal / shared / tiered cachingarea/ingestIngest pipeline (Bento, batching, DLQ)area/queryStructured query AST, SQL builderbreaking-changeBreaking change to public API, CLI, or configbugSomething isn't workingenhancementNew feature or request

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions