Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
1d8d350
feat(cache): namespace-versioned cache keys for queries and pipes
taitelee Jun 10, 2026
218cad0
condensed new namespace query functions
taitelee Jun 10, 2026
289fa11
refactor(cache): cut over to namespace-versioned Get/Set/Invalidate
taitelee Jun 10, 2026
13427bc
make fix
taitelee Jun 10, 2026
ad1c0ff
Merge remote-tracking branch 'origin/main' into pipe-cache-invalidation
taitelee Jun 10, 2026
1eeb929
perf(cache): skip redundant per-scope bumps on whole-table invalidation
taitelee Jun 10, 2026
377636c
docs(changelog): add Unreleased entry for the cache cutover; encode q…
taitelee Jun 10, 2026
97bbbc9
refactor(ingest): collapse whole-table-subsumed scopes in the worker,…
taitelee Jun 10, 2026
b614ea3
merge main
taitelee Jun 10, 2026
3bae7ec
changelog
taitelee Jun 10, 2026
27ae1f1
refactor(pipes): drop declared param-type enforcement
taitelee Jun 11, 2026
038a205
fix(pipes): handle escaped backticks in query-tree identifier parsing…
taitelee Jun 14, 2026
142dc00
merge main
taitelee Jun 14, 2026
43ac5c3
fix(pipes): resolve through-view table deps via system.tables
taitelee Jun 14, 2026
b1e4bcc
feat(pipes): log dep-resolution outcome, document TTL-only fallbacks,…
taitelee Jun 16, 2026
0d7097e
fix(pipes): resolve through registry-discovered views so ingest still…
taitelee Jun 16, 2026
394420d
Merge remote-tracking branch 'origin/main' into pipe-cache-invalidation
taitelee Jun 16, 2026
90e24b4
fix(pipes): drop view/MV names from resolved deps, keeping only their…
taitelee Jun 16, 2026
13803e1
test(pipes): table-driven dep-resolution tests, document cache invali…
taitelee Jun 16, 2026
f762148
test(integration): allowlist-sanitize createTable names so subtest pu…
taitelee Jun 16, 2026
d6f447a
fix(pipes): dummy-bind array params for table-dep resolution; tighten…
taitelee Jun 16, 2026
a24be1a
feat(pipes): invalidate cached results via table dependencies resolve…
taitelee Jun 26, 2026
5727a3b
Merge remote-tracking branch 'origin/main' into pipe-cache-invalidation
taitelee Jun 26, 2026
7f13a70
fix(pipes): TTL-floor cached results with unresolvable deps (unfoldab…
taitelee Jun 26, 2026
09a37ee
Merge remote-tracking branch 'origin/main' into pipe-cache-invalidation
taitelee Jun 26, 2026
1083703
feat(pipes): meter degraded dependency resolution; add weird-identifi…
taitelee Jun 29, 2026
d6f047d
doc updates
taitelee Jun 29, 2026
786e768
fix(pipes,cache): snapshot version-folded keys pre-query (#382); data…
taitelee Jul 10, 2026
8267f7a
Merge remote-tracking branch 'origin/main' into pipe-cache-invalidation
taitelee Jul 10, 2026
e7ed7fa
feat(discovery): cascade source writes through MV TO targets via trig…
taitelee Jul 16, 2026
acf93a1
test(e2e): cover MV TO-target pipe invalidation end-to-end (fixes e2e…
taitelee Jul 16, 2026
4d856a5
docs(architecture): cover discovery's dependency-cascade role and exp…
taitelee Jul 17, 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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- **Read-cache invalidation cut over to namespace-versioned keys, replacing the flat per-namespace version map** (`internal/cache/cache.go`, `internal/cache/local.go`, `internal/cache/version_manager.go`, `internal/api/structured_query.go`, `internal/api/pipes.go`, `internal/ingest/worker.go`, `internal/testutil/mocks.go`, plus tests in `internal/cache/{local,version_manager}_test.go` and `internal/ingest/worker_test.go`): the `Cache` interface is now keyed by what a result *depends on* rather than one opaque string. `Get`/`Set` take a `sha` (hash of SQL+params) plus the `[]Namespace` (`{table, scope}` pairs) the result reads — one for a structured query, several for a pipe — and `QueryKey` folds the table version and per-namespace version of every dependency into the key, so a write to any dependency misses it. Invalidation is two-level: `BumpTable(table)` advances a table version that is embedded in every namespace key, invalidating all scopes of that table in O(1); `BumpNamespace(table, scope)` advances one scope plus the whole-table view, leaving other scopes' cached entries valid. The old `GetCacheKey`/`GetVersion`/`IncrementVersion`/`InvalidateCache` flat-`versions` path is removed, and the structured-query handler and ingest worker are wired onto the new path via `Cache.Invalidate([]Namespace)` (empty scope → `BumpTable`, scoped → `BumpNamespace`); the ingest worker collapses a single-table batch to one whole-table bump before calling `Invalidate` (a whole-table bump subsumes every per-scope bump for that table), keeping `Invalidate` a simple one-pass bump. Pipes now report the tables they read and are version-invalidated on this same path (see the pipe-dependency entry below; [#178](https://github.com/Wave-RF/WaveHouse/issues/178)).
- **Pipes resolve the ingested tables they read and version-invalidate their cached results, instead of caching TTL-only** (`internal/api/pipe_deps.go` (new), `internal/api/pipes.go`, `internal/pipes/pipes.go`, `cmd/wavehouse/main.go`, plus tests in `internal/api/pipe_deps_test.go`, `internal/pipes/dummybind_test.go`, `tests/integration/pipe_deps_test.go`): closes [#178](https://github.com/Wave-RF/WaveHouse/issues/178). When a pipe is created or updated, `PipesHandler.Put` resolves the base tables its SQL reads — *through views and aliases* — by running `EXPLAIN QUERY TREE` over the dummy-bound SQL and keeping the names the schema registry knows in the configured database (`resolvePipeTables`), then stores them on the `NamedQuery` (`ResolvedTables`, server-owned — overwritten on every `Put`, never trusted from client input). `Execute` folds those tables into the cache key as whole-table `{table}` namespaces (`pipeDeps`), NATS-encoded exactly as the ingest worker encodes them, so a write to any of them invalidates the pipe's cached result on the same two-level path a structured query uses. The table set depends only on the SQL, not the per-request parameter values (parameters bind as values, and table identifiers can't be parameterized), so resolution runs once per definition — a rare admin write — and never on the read path. It is best-effort and bounded (`pipeResolveTimeout`): with no schema registry / ClickHouse connection wired, or if resolution fails, `ResolvedTables` is empty and the pipe keeps the previous TTL-only behavior. Tables a pipe reads that WaveHouse does not ingest (other databases, externally-written tables, system tables) have no change signal and stay covered by the TTL floor; pipe dependencies are deliberately whole-table-scoped, since a pipe can't know which scope it reads and a scoped write bumps the whole-table view too. Validated end-to-end by an integration test that reads a base table through a view and confirms an ingest into the base table invalidates the cached pipe result.
- **Docs site polish: navigation + reading progress bars, branded search/asides/404, and client-side error tracking** (`docs/src/components/{Head,Footer,PostHog,ProgressBar (new),ReadingProgress (new)}.astro`, `docs/src/content/docs/404.md`, `docs/src/styles/global.css`): builds on the astro-vtbot SPA integration above. Slow navigations get a thin accent progress bar — a new self-hosted `ProgressBar.astro` on vtbot's `loading()` hook, because vtbot's own `<ProgressBar/>` imports Swup's plugin from unpkg.com at runtime in every visitor's browser (a third-party CDN script in prod, against the repo's supply-chain bar; verified present in the built bundle before replacing). Doc pages additionally get a reading-progress bar (`ReadingProgress.astro`, rendered from the sidebar footer branch): a 2px accent line under the header filling with the scrolled-past fraction of the article body — the "how far through this page am I" signal the right-rail TOC gives desktop but mobile had nothing for; it spans only the content column (offset past the sidebar once that exists, hidden entirely ≥72rem where the TOC takes over), ignores footer chrome, hides on articles shorter than the viewport and under the open mobile menu, and updates via one rAF-throttled passive listener that survives view transitions. (A direction-aware page slide + title morph were also tried here and ripped out on review — too much motion for docs; a reduced-motion media query zeroing all view-transition animations stays as a belt-and-braces guard.) The search dialog — previously stock Pagefind chrome — now sits on the design system (surface/border/radius frame with a gentle reduced-motion-aware pop, code-surface input with accent focus, card result groups with a header band + surface-tint row hover replacing Starlight's bare bright outline, accent `<mark>` hits, branded clear/load-more controls, blurred backdrop), as do asides (the stock blue/purple/orange/red tokens were never remapped, so the first `:::note` authored would have landed off-palette), prose `kbd`/`mark`, heading-anchor hover, inner-surface scrollbars, and the mobile menu button (stock inverted to a white circle in dark mode). With asides on the design system, nineteen titled note-blockquotes across api/pipes/access-control/sdk/development became real `:::note`/`:::caution` asides (titles promoted to aside labels; the bodies are unchanged — short untitled clarification quotes stay blockquotes), and the sidebar gained a "Reference" group (API Reference + TypeScript SDK out of "Guides" — lookup material, not narrative; no URLs changed). Getting Started gains a "Troubleshooting first runs" section — the five first-session trip-ups (schema-refresh 404, pre-flush empty query, fail-closed 403 on non-demo tables, port conflicts, cold-start timing), each already documented behavior, now answered where a new user actually hits them — and its SDK pointer became a `:::tip` aside. A fresh-eyes screenshot audit then tightened the loose ends: Starlight's stock tab underline (a `box-shadow` that survived the segmented restyle as floating arcs under inactive tabs), a bottom scrim where search results hard-clipped against the dialog's rounded edge, an 88% glass header (scrolled body text bled through at 78%), the mobile menu button joining the search trigger's bordered-surface control family in both states, and full-width stacked hero CTAs on small phones. The landing page gains an SDK showcase — Ingest / Query / Live-updates tabs of `@wavehouse/sdk` snippets (verified against sdk.md's documented API) under a "Query it like a database. Subscribe to it like a socket." section — with Starlight's `<Tabs>` restyled as a segmented control, and the lone "How it ships" card folded into the quickstart prose. The 404 page replaces its bare title + bullet list with a "signal lost" treatment: the landing's grid + glow atmosphere, a mono 404, an EKG-style trace (heartbeat decaying into a flatline, drawn by a looping monitor-sweep dash animation over a live→dead gradient, with a blinking sample dot; static full trace for reduced motion), and the card-style quick links — its custom `h1` replaces the hidden stock title panel so the page keeps exactly one heading. PostHog gains `capture_exceptions: true`, surfacing broken docs JS (LiveDemo SSE, copy buttons, theme scripts) in error tracking; the SPA pageview capture (`capture_pageview: 'history_change'`) was verified against vtbot's lifecycle docs and is unchanged.
- **Docs "Last updated" footer shows each page's real last-edit date, and robots.txt declares open AI content signals** (`.github/workflows/ci.yml`, `docs/public/robots.txt`): Starlight's `lastUpdated` reads git history, but CI checked out with the default shallow (depth-1) clone, so every page silently fell back to the build time — the live site showed the same "Last updated" on every page regardless of when it actually changed. CI now checks out with `fetch-depth: 0`. Separately, `robots.txt` declares open Content-Signals (`search=yes, ai-input=yes, ai-train=yes`): WaveHouse is open source, so agents are welcome to read, index, answer from, and train on these docs. (A markdown-twin `Link` header — the other half of the agent-discoverability pass — is being upstreamed to [`cloudflare-md-router`](https://github.com/Wave-RF/cloudflare-md-router) instead, where the twin concept lives.)
- **Mermaid diagrams are click-to-zoom, with a top-level house style to author them vertically** (`docs/src/components/MermaidZoom.astro` (new), `docs/src/components/Footer.astro`, `docs/src/styles/global.css`, `AGENTS.md`): wide `flowchart LR` diagrams shrink to ~36% in the content column and are hard to read inline. Clicking any diagram now opens it in a lightbox — small diagrams scale up to fill the viewport (capped 2.5×), wide ones show at natural size with pan-scroll for full detail; Esc / backdrop / button closes, keyboard-focusable with Enter. View-transition-safe (one delegated `document` listener guarded by a window flag, re-enhanced on `astro:page-load`). The clone is re-id'd so its id-scoped inline `<style>` (font, themed fills) and `url(#…)` marker refs keep working. The fit calculation sizes against the stage's content box — subtracting the stage's own padding and flooring fractional pixels — because sizing against raw viewport fractions overshot by the padding, so the "fully zoomed" state always rendered both scrollbars inside the sheet. The modal now always FITS the diagram (oversized ones scale down rather than panning by default — no scrollbars, rounded corners intact), with a 1:1 toggle appearing for diagrams larger than the stage: natural size + pan with the two-sided edge fades; pinch-zoom works natively on touch. Every inline diagram also carries a persistent (dimmed, hover-brightening) corner zoom chip — cursor:zoom-in only ever spoke to a pointer that happened to hover. AGENTS.md §"Authoring Mermaid diagrams" adds the directive: default to top-down (`TB`/`TD`), reserve `LR` for short chains, never sit two large diagrams side-by-side, keep labels short.
Expand Down
9 changes: 8 additions & 1 deletion cmd/wavehouse/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,13 @@ func run() int {
return 1
}

// Pipes resolve their ingested table dependencies at Put() time via the schema
// registry + ClickHouse connection, so writes invalidate cached pipe results;
// set as fields (see PipesHandler) rather than constructor args.
pipesHandler := api.NewPipesHandler(pipesStore, policyStore, chConn, cache, cfg.ClickHouse.QueryTimeout, logger)
pipesHandler.Registry = registry
pipesHandler.Database = cfg.ClickHouse.Database
Comment thread
taitelee marked this conversation as resolved.
Resolved

deps := api.Dependencies{
Ingest: ingestHandler,
Query: queryHandler,
Expand All @@ -373,7 +380,7 @@ func run() int {
Schema: api.NewSchemaHandler(registry),
DLQ: dlqHandler,
Policy: api.NewPolicyHandler(policyStore),
Pipes: api.NewPipesHandler(pipesStore, policyStore, chConn, cache, cfg.ClickHouse.QueryTimeout, logger),
Pipes: pipesHandler,
StructuredQuery: api.NewStructuredQueryHandler(chConn, cache, registry, policyStore, cfg.Cache.TimestampBucketSeconds, cfg.ClickHouse.QueryTimeout, logger),
AuthMW: authMW,
PolicyStore: policyStore,
Expand Down
215 changes: 215 additions & 0 deletions internal/api/pipe_deps.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
package api

import (
"context"
"sort"
"strings"
"time"

"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
"github.com/Wave-RF/WaveHouse/internal/cache"
"github.com/Wave-RF/WaveHouse/internal/discovery"
"github.com/Wave-RF/WaveHouse/internal/pipes"
"github.com/Wave-RF/WaveHouse/internal/query"
)

// pipeResolveTimeout bounds the EXPLAIN round-trip a pipe Put() makes to resolve
// its table dependencies, so a slow or stuck ClickHouse can't hang pipe
// creation. Generous because EXPLAIN QUERY TREE only analyzes (it scans no data)
// and Put() is a rare admin write, not a per-request cost.
const pipeResolveTimeout = 10 * time.Second

// resolvePipeDeps resolves the ingested base tables a pipe reads, for cache
// invalidation. Best-effort: if no schema registry / ClickHouse connection is
// wired, or resolution fails, it returns nil and the pipe falls back to TTL-only
// caching. Because the table set depends only on the SQL (not on per-request
// parameter values), resolving here at Put() — a rare admin write — keeps it off
// the per-request read path and means it is computed once per definition.
Comment thread
taitelee marked this conversation as resolved.
Resolved
func (h *PipesHandler) resolvePipeDeps(ctx context.Context, q *pipes.NamedQuery) []string {
if h.Registry == nil || h.CHConn == nil {
return nil
}
ctx, cancel := context.WithTimeout(ctx, pipeResolveTimeout)
defer cancel()
return resolvePipeTables(ctx, h.CHConn, h.Registry, h.Database, q)
}

// resolvePipeTables binds the pipe with dummy parameters, asks ClickHouse to
// resolve the query — through views and aliases — to the tables it actually
// reads via EXPLAIN QUERY TREE, then keeps only the names the schema registry
// knows in the configured database (i.e. tables the ingest worker writes and can
// therefore version-invalidate). Any failure returns nil so the caller degrades
// to TTL-only with no error surfaced.
func resolvePipeTables(ctx context.Context, conn driver.Conn, registry *discovery.SchemaRegistry, database string, q *pipes.NamedQuery) []string {
if conn == nil || registry == nil || q == nil {
return nil
}
boundSQL, err := pipes.DummyBind(q)
if err != nil {
return nil
}
raw, err := explainQueryTreeTables(ctx, conn, boundSQL)
if err != nil {
return nil
}
return filterKnownTables(raw, database, registry)
}

// explainQueryTreeTables runs EXPLAIN QUERY TREE over sql and returns the raw
// `database.table` identifiers it references. Running the analyzer's passes
// (run_passes = 1 runs them all) resolves views and aliases down to their
// underlying tables, which static SQL parsing cannot do — the whole reason this
// goes through ClickHouse rather than parsing the FROM clause. Read-only, so it
// is safe to issue for any pipe (pipes are read queries).
func explainQueryTreeTables(ctx context.Context, conn driver.Conn, sql string) ([]string, error) {
rows, err := conn.Query(ctx, "EXPLAIN QUERY TREE run_passes = 1 "+sql)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()

var lines []string
for rows.Next() {
var line string
if err := rows.Scan(&line); err != nil {
return nil, err
}
lines = append(lines, line)
}
if err := rows.Err(); err != nil {
return nil, err
}
return parseQueryTreeTables(lines), nil
}

// parseQueryTreeTables pulls table identifiers out of an EXPLAIN QUERY TREE dump.
// After the analyzer passes run, every table the query reads — including those
// reached through views and aliases — appears as a TABLE node carrying a
// `table_name: <database>.<table>` field. We collect those raw identifiers; the
// database qualifier and backtick quoting are handled by filterKnownTables.
func parseQueryTreeTables(lines []string) []string {
const marker = "table_name:"
var out []string
for _, line := range lines {
_, after, found := strings.Cut(line, marker)
if !found {
continue
}
if id := identifierField(after); id != "" {
out = append(out, id)
}
}
return out
}

// identifierField returns the leading identifier of a query-tree field value:
// everything up to the first comma that is not inside backticks, trimmed. The
// dump separates a node's fields with ", " and quotes identifiers containing
// special characters with backticks, so a naive comma split would truncate a
// quoted name that itself contains a comma. A backslash-escaped backtick (\`)
// inside a quoted name does not end the quoting.
func identifierField(s string) string {
s = strings.TrimSpace(s)
inTick := false
for i := 0; i < len(s); i++ {
switch s[i] {
case '\\':
if inTick {
i++ // skip the escaped char (\` or \\) so it can't flip inTick
}
case '`':
inTick = !inTick
case ',':
if !inTick {
return strings.TrimSpace(s[:i])
}
}
}
return strings.TrimSpace(s)
}

// filterKnownTables normalizes raw `database.table` identifiers to bare table
// names and keeps only those the schema registry knows in the configured
// database — i.e. tables the ingest worker writes and can version-invalidate. A
// qualifier naming a different database is dropped (ingest can't signal changes
// to it), as is anything the registry hasn't discovered (views, system tables,
// unknown names): a pipe may still read those, but WaveHouse has no change signal
// for them, so they stay covered by the TTL floor. Results are deduped and
// sorted for a stable, order-independent dependency set.
func filterKnownTables(raw []string, database string, registry *discovery.SchemaRegistry) []string {
seen := make(map[string]struct{})
out := make([]string, 0, len(raw))
for _, r := range raw {
db, table := splitQualified(r)
if db != "" && db != database {
continue // a table in another database — outside the ingest universe
Comment thread
taitelee marked this conversation as resolved.
Resolved
}
if table == "" || registry.Get(table) == nil {
continue
}
if _, ok := seen[table]; ok {
continue
}
seen[table] = struct{}{}
out = append(out, table)
}
sort.Strings(out)
Comment thread
taitelee marked this conversation as resolved.
Resolved
return out
}

// splitQualified splits a ClickHouse `database.table` (or bare `table`)
// identifier into its parts, tolerating the backtick quoting ClickHouse uses for
// names with special characters. Only the first dot outside backticks separates
// the database qualifier, so a quoted table name containing dots stays intact. A
// backslash-escaped backtick (\`) inside a quoted part does not end the quoting.
// A bare name returns db "".
func splitQualified(s string) (db, table string) {
s = strings.TrimSpace(s)
inTick := false
for i := 0; i < len(s); i++ {
switch s[i] {
case '\\':
if inTick {
i++ // skip the escaped char (\` or \\) so it can't flip inTick
}
case '`':
inTick = !inTick
case '.':
if !inTick {
return unquoteIdent(s[:i]), unquoteIdent(s[i+1:])
}
}
}
return "", unquoteIdent(s)
}

// unquoteIdent strips ClickHouse backtick quoting from a single identifier and
// unescapes the `\“ and `\\` sequences inside it, so the result matches the
// bare name the schema registry is keyed by.
func unquoteIdent(s string) string {
s = strings.TrimSpace(s)
if len(s) >= 2 && s[0] == '`' && s[len(s)-1] == '`' {
s = s[1 : len(s)-1]
s = strings.ReplaceAll(s, "\\`", "`")
s = strings.ReplaceAll(s, "\\\\", "\\")
}
return s
}
Comment thread
taitelee marked this conversation as resolved.
Resolved

// pipeDeps maps a pipe's resolved base tables to cache dependency namespaces.
// The scope is empty (whole-table): a pipe can't know which scope of a table it
// reads, and a scoped write bumps the whole-table view too, so a whole-table
// dependency is still correctly invalidated by any write to the table. Each name
// is NATS-encoded exactly as the ingest worker encodes it (worker.go
// handleSuccess), so the read and invalidation sides build identical keys. nil
// in, nil out — keeping pipes keyed by sha alone (TTL-only).
func pipeDeps(tables []string) []cache.Namespace {
if len(tables) == 0 {
return nil
}
deps := make([]cache.Namespace, 0, len(tables))
for _, t := range tables {
deps = append(deps, cache.Namespace{Table: query.SafeEncodeNATS(t)})
}
return deps
}
Comment thread
taitelee marked this conversation as resolved.
Resolved
Loading
Loading