feat(cache): implement per-table tagging and raw SQL bypass - #119
feat(cache): implement per-table tagging and raw SQL bypass#119taitelee wants to merge 43 commits into
Conversation
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request introduces a robust mechanism to maintain data consistency between the API cache and the underlying ClickHouse database. By implementing table-based tagging, the system can now selectively invalidate stale cache entries when data is ingested. Additionally, it addresses consistency concerns for raw SQL queries by bypassing the cache entirely, ensuring that manual and automated debugging tasks always reflect the current state of the database. Highlights
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here. Footnotes
|
There was a problem hiding this comment.
Code Review
This pull request introduces a tag-based cache invalidation system to ensure data consistency when tables are modified. It updates the Cache interface with InvalidateByTags, implements this in LocalCache using a tagsMap, and integrates invalidation logic into the PipesHandler, QueryHandler, and StructuredQueryHandler. Additionally, the ingest worker now triggers invalidation upon writing batches. Feedback focuses on a potential race condition in InvalidateByTags that could lead to orphaned cache entries and suggests refactoring duplicated table extraction logic into a shared, validated helper to align with repository security standards.
There was a problem hiding this comment.
Code Review
This pull request introduces tag-based cache invalidation to ensure consistency between ClickHouse and the API cache. Key updates include modifying the Cache interface to support tags, implementing a tag-to-key index in LocalCache, and integrating invalidation triggers within the ingestion pipeline and raw SQL query handler. Feedback highlights a high-severity race condition in the InvalidateByTags implementation that could result in stale cache entries and recommends refactoring duplicated table extraction logic into a shared helper that enforces strict identifier validation. Iterate.
|
2 [MUST], 2 [SHOULD], 0 [MAY] — see inline threads for detail. Verdict: Iterate. The implementation direction is sound and the Gemini-flagged race from the prior commit was correctly addressed via
The |
… mocks, and sync documentation
Review summary1 [SHOULD], 3 [MAY], 0 [MUST] — see inline threads for detail. The core implementation is solid: the double-indexed tag map ( Threads:
Ship it — address the [SHOULD] test gap; the [MAY] nits can go in a follow-up if preferred. |
…, and defer setCancel in cache populates
| l.ttls.Delete(key) | ||
| delete(l.keyVersion, key) // Clean up version tracker | ||
| l.removeKeyFromTagsLocked(key) | ||
| l.tagsMu.Unlock() |
There was a problem hiding this comment.
[SHOULD] Security: NONE / Correctness: LOW — Residual TOCTOU in the expiry eviction path.
The prior round's taitelee reply (3231258193) stated "once the tagsMu.Lock() is acquired, we verify again the TTL status. If a concurrent update happened, we gracefully abort the deletion and return the fresh value." But the code does not implement that re-check. The race scenario is:
Getcallsl.ttls.Load(key)(line 110) and reads the old expiry.- Concurrent
SetacquirestagsMu.Lock(), writes a new value + new TTL intol.ttls, updateskeyVersion, releases lock. Getcomputesremaining <= 0(true based on the old expiry), acquirestagsMu.Lock().GetdeleteskeyVersion[key], removes tags, and callsl.cache.Del(key)— all of which were just written by the concurrentSet.
Result: a live, freshly-written entry is silently evicted. The next caller gets a spurious MISS and re-queries ClickHouse — no data loss, but the invariant the author described is not actually enforced.
Fix: re-read and re-check the TTL inside the lock before deleting:
| l.tagsMu.Unlock() | |
| // Re-check TTL inside the lock: a concurrent Set may have | |
| // refreshed the expiry between our outer check and here. | |
| if freshExpVal, freshOk := l.ttls.Load(key); freshOk { | |
| freshExp := freshExpVal.(time.Time) | |
| if !freshExp.IsZero() && time.Until(freshExp) > 0 { | |
| // Entry was refreshed; return the live value. | |
| l.tagsMu.Unlock() | |
| return val.data, time.Until(freshExp), nil | |
| } | |
| } | |
| l.cache.Del(key) | |
| l.ttls.Delete(key) | |
| delete(l.keyVersion, key) // Clean up version tracker | |
| l.removeKeyFromTagsLocked(key) | |
| l.tagsMu.Unlock() | |
| return nil, 0, nil |
| consumer jetstream.Consumer | ||
| iter jetstream.MessagesContext | ||
| chConn driver.Conn | ||
| cache cache.Cache |
There was a problem hiding this comment.
[SHOULD] Correctness: LOW — registerOnce (the package-level sync.Once at line 49) captures apiCache from the first StartIngestWorker call. Any subsequent call with a different apiCache silently discards its argument: the Bento plugin factories close over the original value.
The integration test documents this as "non-negotiable" (setup_test.go lines 10-13) and cmd/wavehouse/main.go only calls StartIngestWorker once, so there is no current regression. However the API is a silent trap — a future hot-reload handler, a second integration suite, or any caller that passes a fresh cache will observe stale invalidations with no error.
Minimum fix: add a godoc comment on StartIngestWorker stating the one-call-per-process constraint, consistent with the constraint the integration test already documents internally:
| cache cache.Cache | |
| cache cache.Cache // captured in the registerOnce factory; see StartIngestWorker godoc |
And on StartIngestWorker:
// StartIngestWorker may only be called once per process: Bento's
// service.Register* functions are process-global singletons (guarded by
// registerOnce). The apiCache and chConn arguments are captured in the plugin
// factory closures on the first call; subsequent calls reuse those captured
// values regardless of the arguments passed.
Review — feat(cache): per-table tagging + raw SQL bypass (round 9)0 [MUST] · 2 [SHOULD] · 0 [MAY] — see inline threads for detail. What this round resolvedThe two [MUST] items from round 8 are closed:
Also well done: Remaining findings
Verdict: Ship itNo [MUST]-level findings remain. Both [SHOULD] items are low-probability correctness issues (spurious cache MISS and silent no-op argument) with no data-loss or security impact. The TOCTOU fix is a one-liner and the godoc addition is trivial — worth doing before merge, but not a blocker. Address both, resolve the threads, and this is ready. |
| return &TieredCache{l1: l1, l2: l2} | ||
| func NewTiered(l1, l2 Cache) (*TieredCache, error) { | ||
| if l2 != nil { | ||
| return nil, errors.New("L2 cache requires tag-propagation support (not yet implemented); pass nil for standalone mode") |
There was a problem hiding this comment.
We don't have any L2/shared cache right now... it's just the same as the general cache interface, so technically the interface does support this we just don't have an implementation right now but we can/should still use the interface as if we do, no?
There was a problem hiding this comment.
See comments in file, but this needs revisions, should build it as if fully implemented/supported
There was a problem hiding this comment.
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
| Store *pipes.Store | ||
| CHConn driver.Conn | ||
| Cache *cache.TieredCache | ||
| Cache cache.Cache |
There was a problem hiding this comment.
Why? Why not leave as TieredCache? They do the same things for us under the hood, no?
| 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", |
There was a problem hiding this comment.
Should this not be the same context as the setCtx is here for the cache set, why/why not?
There was a problem hiding this comment.
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?
| type StructuredQueryHandler struct { | ||
| CHConn driver.Conn | ||
| Cache *cache.TieredCache | ||
| Cache cache.Cache |
There was a problem hiding this comment.
Same note on this file, why not TieredCache?
| // 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) |
There was a problem hiding this comment.
The more I look at this/see it in several places, the more it concerns me – we don't know that 5sec will be enough and is a clear path to DoS risks and feels like a premature optimization that would be better tracked and implemented in one pass everywhere, as tracked by #120.
EricAndrechek
left a comment
There was a problem hiding this comment.
Right direction, but I think there are a significant number of things that need changing before approval. Namely:
- singleflight optimizations for dropped initial connections should be removed from this PR to keep it smaller, and tracked in #120 instead
- TieredCache should still be used and templated as if it works with an L2 cache, no harm if there is no layered cache but the interface for it should be the same regardless
- Not a huge fan of the regex, especially as part of the already bloated API package, let's consider cleaning it up/reorganizing where its put and if there are other/better ways we can do it, as this is effectively a custom partial lite SQL parser, and will become a thorn in our side with edge cases very quickly
- I need some more help making sense of the actual cache interface's implementation (in
local.go), perhaps some more of it could be abstracted out/shared in a way to let it work with minimal repeated code regardless of if usingristrettoorredis, etc.
|
Edit: Closing in favor of #164 first, it will make this issue significantly easier to address in a new PR. |
## Summary Umbrella PR setting up shared Claude Code + AI agent infrastructure for the WaveHouse team. Two work streams: 1. **AI rules drift cleanup** — corrected stale references that AI tools (Claude Code, Gemini Code Assist, Copilot, CodeRabbit) were following blindly. 2. **Claude Code native tooling** — committed `.claude/` configuration and `.githooks/` so every teammate gets identical dev affordances out of the box, with agent-specific gating layered on top. The team just got Max 20x subscriptions across the board; this lands the team-wide config so everyone is on the same agentic dev experience by default. ## Scope ### 1. AI rules drift cleanup - **17 doc-path references corrected** across AGENTS.md + CONTRIBUTING.md to `docs/src/content/docs/*.md` (the actual Astro Starlight location, not the old flat layout). - **`.github/copilot-instructions.md` shrunk to a pointer** — was drifting on Go 1.25 (vs current 1.26.3) and 60% coverage (vs current 80% total / 70% unit per `.testcoverage.yml`). - **`.gemini/styleguide.md`** — stale `#67` / 60% claim fixed (issue closed, 70% restored); duplicated doc-sync bullet collapsed to defer to AGENTS.md (already authoritative). - **`.github/labeler.yml`** — dropped non-existent `cmd/wavehouse-{api,worker}/**` entries; fixed `tests/{compose.yaml,sdk/**}` → `tests/e2e/...`; added `cmd/wavehouse/**` to `area/infra`. - **`.github/prompts/pr-review.md`** — doc-sync list collapsed; vestigial "tenant" wording dropped (no tenant model in WaveHouse); hard-wrap reflowed (180 → 81 lines). - **AGENTS.md** — `cmd/*/main.go` (plural) → `cmd/wavehouse/main.go` (one binary); `tests/fixtures/` → `tests/e2e/fixtures/`; removed stale "update `triage.yml` area enumeration" step (workflow now discovers `area/*` labels dynamically); fixed internal-package count. - **CONTRIBUTING.md** — vestigial "tenant isolation" wording removed. - **TODO.md deleted** — audit summary below. ### 2. Claude Code native tooling - **`.claude/`** — shared configuration: `settings.json` (deny rules + worktree config + three hooks wired), `agents/pre-push-reviewer.md`, `hooks/agent-bash-gate.sh` (PreToolUse Bash gate), `hooks/review-marker.sh` (PostToolUse Agent marker writer), `hooks/gofumpt-on-save.sh` (auto-format), `skills/pr-review-locally/`, `skills/pr-sync-with-main/`, `commands/cover.md`. - **`.githooks/`** — universal team hooks installed by `make tools`: `pre-commit` runs `make verify`; `pre-push` requires `tmp/ci-passed-<HEAD-sha>` marker (written by `make ci`). - **`.config/wt.toml`** — worktrunk project hooks so parallel-agent worktrees install `.githooks/` correctly. - **AGENTS.md §"Agent PR Discipline"** — new section codifying the agent-only ruleset: - Drafts-only PR creation; human-only ready/approve/request-changes/reviewer-add transitions. - Bot reviewer re-triggers go through PR comments (`@coderabbitai review`, `@gemini-code-assist`, `@claude` / `/review`). - **Pre-push self-review mandatory** on PR branches: agent invokes `pre-push-reviewer` subagent in fresh context. `ship_it` requires zero findings at any severity — any `[MUST]` / `[SHOULD]` / `[MAY]` forces iterate; the orchestrator loops review → fix → review until clean. - **Honest-agent marker policy**: `--no-verify` regex-blocked + the obvious marker-write idioms denied at the permission layer (`Bash(touch tmp/ci-passed:*)`, `Write`/`Edit` on the canonical paths); everything else is a documented rule, not regex-enforced. Bash can write a file by a dozen paths and regex enforcement is a porous game of whack-a-mole. - **`docs/src/content/docs/claude-code.md`** — contributor-facing page documenting the four-layer model (universal git hooks → agent gate → ergonomic hooks → skills/agents/commands), quick setup, and discipline rules. - **CHANGELOG.md** — `[Unreleased]` entry covering all of the above. ## Out-of-tree GitHub changes that pair with the AI-rules cleanup Done via `gh` CLI as part of the same audit: - **Closed #46** (Graceful Shutdown) — verified shipped in `cmd/wavehouse/main.go:378-393` (SIGINT/SIGTERM → bounded shutCtx → ingestStream.Stop → srv.Shutdown → promSrv.Shutdown). - **Scope notes added to #44, #50, #94** with current-status / boundary info (ldflags shipped vs `/version` remaining; DLQ shipped vs retry remaining + scope boundary with #91; per-component logger source field as a #94 complement). - **Opened 4 new issues from orphan TODOs**: #143 (pprof), #144 (K8s `/healthz` + per-dep health), #145 (RequireRoles fail-closed), #146 (split `internal/api/` into focused subpackages). ## TODO.md audit (one-time, for record) | Bucket | Count | Disposition | |--------|-------|-------------| | Already shipped per closed issues (#11, #14, #16, #28, #40, #41, #42, #45) + current code | ~12 | Deleted from TODO | | Tracked as open issues (#32, #33, #34, #37, #39, #44, #48, #49, #50, #51, #94) | ~12 | Kept as issues, scope notes added where useful | | In-flight via open PRs (#83, #92, #119, #122, #125, #136, #137) | 4 | Untouched | | #46 Graceful Shutdown | 1 | Verified shipped, closed with comment | | Orphan items | 4 | Split into #143-146 | | Aspirational ("more tests", "update README") | 2 | Deleted — covered by AGENTS.md doc-sync rules | Projects #7 board + triage automation is now the single canonical backlog. ## Test plan - [x] `make ci` passes locally for each push (gated by `.githooks/pre-push`) - [x] CI green on the latest HEAD (8fbd7db) - [x] PR-title-lint accepts the title (`chore: claude code native improvements`) - [x] All `docs/src/content/docs/*.md` paths in AGENTS.md resolve to real files - [x] Labeler workflow auto-labels correctly per the updated paths - [x] `pre-push-reviewer` subagent loop reached `VERDICT: ship_it` with zero findings under the strict rubric before the final push (validated end-to-end across five iterations on this branch — each surfacing a real doc-sync / off-by-one / quote-strip issue and forcing a fix before the marker auto-wrote) - [x] `agent-bash-gate.sh` quote-strip generalization sanity-tested live: `echo "git push to deploy"` passes through; `git push --no-verify` and `git commit --no-verify` still block (`bash -n` clean, JSON wiring valid) - [ ] Human review ## Related issues - Closed during this work: #46 - Scope notes added: #44, #50, #94 - New follow-up issues created: #143, #144, #145, #146 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Claude Code integration: local pre-push reviewer with strict ship/iterate/block verdicts, push gating via CI/review markers, automatic review-marker creation, and a coverage-reporting command. * **Documentation** * Comprehensive Claude Code & agent docs, new skill guides for PR review/sync, updated README/CONTRIBUTING/CHANGELOG/styleguide, and site sidebar/page additions. * **Chores** * Added git and agent hooks, CI marker creation, worktrunk config, labeler tweaks, and simplified Copilot instructions. <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/Wave-RF/WaveHouse/pull/147?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
This PR implements the Read-Your-Writes architectural consensus established in #73. It addresses the issue of stale data in the API query cache by introducing a granular, table-based invalidation signal triggered by the ingest pipeline.
/v1/query). Admin and ad-hoc queries now hit ClickHouse directly to ensure absolute consistency for manual debugging and E2E tests.Cacheinterface to support tagging.tagsMapinLocalCacheto track which cache keys are associated with specific database tables.pipes.goand table-name extraction instructured_query.goto tag cache entries during theSetoperation.TieredCacheinto the Bento ingest worker. Upon a successful batch flush to ClickHouse, Bento now triggers a targeted invalidation of all cache entries tagged with that table name.Test plan
internal/cachetests to cover tag-based eviction and verified signature consistency across mocks.X-Cache: BYPASSheaders.Related Issues
Closes #73, #85, #93
Summary by CodeRabbit
New Features
Breaking Changes
Bug Fixes
Documentation
Tests