Skip to content

DEV-1587: per-engine in-memory query result cache#222

Open
ZmeiGorynych wants to merge 6 commits into
mainfrom
egor/dev-1587-basic-per-query-cache-in-slayer
Open

DEV-1587: per-engine in-memory query result cache#222
ZmeiGorynych wants to merge 6 commits into
mainfrom
egor/dev-1587-basic-per-query-cache-in-slayer

Conversation

@ZmeiGorynych

@ZmeiGorynych ZmeiGorynych commented Jul 6, 2026

Copy link
Copy Markdown
Member

Summary

Adds an opt-in, per-SlayerQueryEngine, in-memory query result cache modelled on Cube's in-memory cache. Python-API-only (no REST / MCP / CLI / Flight / pg-facade / SlayerClient surface).

  • execute(query, cache=True) / execute_sync(query, cache=True, data_source=...) — serve from / store in the cache for any input shape (SlayerQuery / dict / multi-stage list / run-by-name str). Ignored for dry_run / explain.
  • CacheConfig(ttl_seconds=..., refresh_keys=[(table, select_expression), ...]) on the engine constructor; the cache_config setter clears the cache.
  • Staleness: TTL (lazy on read, injectable clock) + Cube-style refresh keys scanned by engine.refresh(). Baselines captured before the data query on a miss.
  • Management: evict / evict_sync (DB-free, never opens a client), clear_cache, cache_size, refresh / refresh_sync returning a bucketed RefreshResult.

New module slayer/engine/cache.py (QueryCache, CacheConfig, RefreshResult, RefreshError, RefreshKeyValue, _CacheEntry — all Pydantic). execute() was refactored into _normalize_input + a DB-free _prepare_pipeline shared by execute, evict, and refresh re-execution.

Key design points

  • Cache key = sha256(final_sql + "|" + connection_string + "|" + runtime_fingerprint) — datasource identity, not the bare name.
  • Table detection parses the final SQL with sqlglot, excludes CTE / derived-table aliases, normalizes to (catalog, db, name) with dialect folding, and intersects with configured refresh-key tables (wildcard-matched).
  • refresh() re-prepares each stale entry from its original input (picks up model / source_queries edits), re-keys on SQL change, resets TTL, and commits identity-guarded so a concurrent evict/clear/newer-execute is never clobbered or resurrected. Continue-on-failure.
  • Short lock: the asyncio.Lock guards only in-memory dict ops; every DB await is outside the lock. Deep-copy on store and on hit-return (and on original_input / variables) so callers can't poison the cache.

Docs

  • docs/concepts/query-cache.md (concept reference) + CLAUDE.md "Query cache" section, wired into zensical.toml nav.
  • Runnable, self-contained example notebook docs/examples/12_query_cache/query_cache_nb.ipynb walking through miss → hit, staleness, refresh(), TTL, and eviction.

Tests

tests/test_query_cache.py — 42 cases: pure QueryCache (key stability, TTL, CTE-excluding table parse, cross-dialect wildcard matching, scan-SQL building, identity-guarded commit) and real in-process SQLite engine integration (miss→hit serves stale, TTL lazy re-exec, refresh buckets, multi-key-per-table, unreferenced-table no-op, re-prepare-from-input + re-key, continue-on-error, write-time baseline propagation, mutation isolation, per-engine isolation, multi-stage + run-by-name, sync wrappers, evict-never-connects, identity-guarded refresh commit).

Full non-integration suite green (6492 passed) after merging main (which brought RLS / session-policy — the policy SQL-rewrite composes cleanly with the cache).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added optional in-memory query caching for engine instances, with cache hits, TTL-based expiry, refresh-key invalidation, and cache management actions.
    • Added a new notebook example showing how to enable caching, refresh cached results, and clear or evict entries.
  • Documentation

    • Added detailed docs for query cache behavior, configuration, limitations, and usage.
    • Updated navigation to include the new Query Cache concept and tutorial entry.

ZmeiGorynych and others added 3 commits July 6, 2026 10:55
Opt-in per-call via execute(query, cache=True); per-engine, in-memory.
TTL (lazy on read) + Cube-style refresh keys scanned by engine.refresh().
Adds evict/clear_cache/cache_size and sync wrappers. Refactors execute
into _normalize_input + DB-free _prepare_pipeline shared by execute,
evict, and refresh re-execution.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…er-query-cache-in-slayer

# Conflicts:
#	CLAUDE.md
#	mkdocs.yml
#	slayer/engine/query_engine.py
Runnable, self-contained notebook demonstrating miss/hit, staleness,
refresh keys, TTL, and eviction on a temp SQLite database. Wire it into
the Tutorials nav; cross-link with the concept doc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@linear

linear Bot commented Jul 6, 2026

Copy link
Copy Markdown

DEV-1587

@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 8 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 74abe05d-2f77-4a0c-85bf-c7ca02d0215e

📥 Commits

Reviewing files that changed from the base of the PR and between bdf88a3 and 7acad22.

📒 Files selected for processing (3)
  • slayer/engine/cache.py
  • slayer/engine/query_engine.py
  • tests/test_query_cache.py
📝 Walkthrough

Walkthrough

This PR adds an opt-in, per-SlayerQueryEngine in-memory query result cache. It introduces a QueryCache module with TTL and refresh-key staleness handling, integrates caching into the engine's execute/refresh/evict pipeline, and adds documentation, a notebook example, tests, and navigation entries.

Changes

Query Cache Feature

Layer / File(s) Summary
Cache data model and core operations
slayer/engine/cache.py
Adds CacheConfig, RefreshKeyValue, RefreshError, RefreshResult, _CacheEntry, and QueryCache with keying, TTL-aware get/put, snapshot, and identity-guarded commit_replace.
Refresh-key SQL parsing and scan building
slayer/engine/cache.py
Adds parse_referenced_tables, applicable_keys, build_refresh_key_sql, rk_alias, and values_differ for table detection and refresh scan queries.
Engine prepare pipeline and cache config
slayer/engine/query_engine.py
Introduces _Prepared, cache config/size/clear surface, and SQL/fingerprint identity computation in the pipeline.
Execute/execute_sync dispatch refactor
slayer/engine/query_engine.py
Routes execute through normalization and _execute_pipeline with a new cache flag; execute_sync forwards cache/data_source.
Cache hit/miss and refresh-key scanning
slayer/engine/query_engine.py
Implements _execute_cached, _build_fresh_entry, and refresh-key scan helpers.
Evict and refresh entry points
slayer/engine/query_engine.py
Adds evict, refresh, _refresh_one, and _refresh_reexec for cache invalidation and re-execution.
Tests
tests/test_query_cache.py
Covers cache primitives, refresh-key matching, engine caching, TTL, refresh, eviction, and sync wrappers.
Docs, notebook, navigation
CLAUDE.md, docs/concepts/query-cache.md, docs/examples/12_query_cache/query_cache_nb.ipynb, zensical.toml
Documents the cache feature, adds a tutorial notebook, and registers navigation entries.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant Engine as SlayerQueryEngine
  participant Cache as QueryCache
  participant DB as Data source
  Caller->>Engine: execute(query, cache=True)
  Engine->>Engine: _prepare_pipeline (SQL + ds_fingerprint)
  Engine->>Cache: get(make_key(sql, ds_fingerprint))
  alt cache hit
    Cache-->>Engine: cached entry
    Engine-->>Caller: deep-copied response
  else cache miss
    Engine->>DB: scan refresh-key baselines
    Engine->>DB: execute SQL
    DB-->>Engine: result
    Engine->>Cache: put(key, new entry)
    Engine-->>Caller: response
  end
Loading
sequenceDiagram
  participant Caller
  participant Engine as SlayerQueryEngine
  participant Cache as QueryCache
  participant DB as Data source
  Caller->>Engine: refresh()
  Engine->>Cache: snapshot()
  Engine->>DB: scan refresh-key expressions
  DB-->>Engine: current values
  alt TTL expired or value changed
    Engine->>Engine: _refresh_reexec
    Engine->>DB: re-execute SQL
    Engine->>Cache: commit_replace(new entry)
  else unchanged
    Engine-->>Caller: unchanged
  end
Loading

Possibly related PRs

  • MotleyAI/slayer#9: Both PRs modify SlayerQueryEngine.execute/execute_sync dispatch logic in slayer/engine/query_engine.py.
  • MotleyAI/slayer#29: Both PRs modify SlayerQueryEngine.execute/execute_sync control flow and signatures in slayer/engine/query_engine.py.

Suggested reviewers: whimo

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: an opt-in per-engine in-memory query result cache.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch egor/dev-1587-basic-per-query-cache-in-slayer

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (4)
tests/test_query_cache.py (1)

596-602: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Broad pytest.raises(Exception) — acceptable given unknown SQL client error type.

SonarCloud flags the wildcard exception type. Since the exact exception raised by the SQL client on a bad column reference isn't guaranteed/stable across dialects, narrowing this could make the test brittle; only worth tightening if the client exposes a dedicated exception type.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_query_cache.py` around lines 596 - 602, The test around
_build_engine and engine.execute is using a wildcard pytest.raises(Exception),
which SonarCloud flags. Tighten the assertion to the most specific exception
type exposed by the SQL client for an invalid column reference, and update the
surrounding failure setup in the cache refresh-key test accordingly; if no
stable dedicated type exists, keep the test behavior focused on the
engine.execute failure while avoiding an unnecessary broad catch.

Source: Linters/SAST tools

docs/concepts/query-cache.md (1)

67-91: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Document the write-time scan-failure contract.

CLAUDE.md notes that a baseline-scan failure on a cache miss propagates out of execute(cache=True) (nothing stored); the same failure at refresh() time is continue-on-error instead. This asymmetry (miss raises, refresh swallows) is user-facing and surprising — worth adding to the "Staleness" section so users configuring refresh_keys know a typo'd table/expression can raise on the very first cached call.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/concepts/query-cache.md` around lines 67 - 91, The Staleness section in
the query cache docs should explicitly document the write-time baseline-scan
failure behavior for refresh keys. Update the explanation around
execute(cache=True) and engine.refresh() to note that a scan failure on a cache
miss propagates and prevents storing the entry, while the same failure during
refresh is handled as continue-on-error. Keep this tied to the refresh_keys and
engine.refresh() wording so users understand that bad table names or expressions
can raise on the first cached call.
slayer/engine/cache.py (1)

269-279: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Pass multi-arg helpers by keyword (repo convention).

Several internal calls here and elsewhere in this module use positional args for multi-parameter functions (Lines 240, 250, 273, 276, 277). The engine layer already standardizes on keyword-only signatures (e.g. commit_replace(self, *, ...)), so consider aligning these helpers/calls for consistency. Note make_key is also invoked positionally from query_engine.py, so making its signature keyword-only requires updating that caller.

As per coding guidelines: "Use keyword arguments for functions with more than one parameter."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@slayer/engine/cache.py` around lines 269 - 279, The module still uses
positional arguments for multi-parameter helper calls, which is inconsistent
with the repo’s keyword-argument convention. Update the relevant helper
signatures in cache.py, especially make_key and any similar multi-arg methods
used around applicable_keys, to require keyword-only arguments where
appropriate, and change all internal call sites in this module to pass those
parameters by name. If make_key is made keyword-only, also update its positional
caller in query_engine.py so the call matches the new signature.

Source: Coding guidelines

slayer/engine/query_engine.py (1)

971-1025: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

SonarCloud gate is failing on this function (cognitive complexity 28 > 15).

The per-alias metadata build stacks six near-identical for … if alias in public_aliases … blocks. Extracting a small helper that maps an enriched field kind → FieldMetadata (and reusing it across dimensions/time-dims/measures/expressions/transforms/cross-model) would collapse the branching and clear the gate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@slayer/engine/query_engine.py` around lines 971 - 1025, The
_build_response_metadata function is too cognitively complex because it repeats
nearly identical alias-filtered loops for dimensions, time_dimensions, measures,
expressions, transforms, and cross_model_measures. Refactor this by extracting a
small helper (or a couple of helpers) that takes an enriched field collection
and returns the matching FieldMetadata keyed by alias, then reuse it inside
_build_response_metadata. Keep the existing public_aliases filtering and format
inference logic intact while collapsing the branching and duplication so the
function passes the SonarCloud complexity gate.

Source: Pipeline failures

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@slayer/engine/cache.py`:
- Around line 232-240: The filtering in parse_referenced_tables() is incorrectly
dropping exp.Table entries by comparing bare .name against CTE/subquery aliases,
which can skip real tables that happen to share wrapper names like _inner or
_outer. Update the logic in cache.py to distinguish wrapper aliases from
physical tables, or match on normalized table identity instead of bare names, so
only actual CTE/subquery references are excluded. Keep the change localized to
parse_referenced_tables() and its excluded/exp.Table handling.

In `@slayer/engine/query_engine.py`:
- Around line 903-909: The _prepare_pipeline docstring claims the flow is
DB-free, but _apply_policy can still trigger live database access through
_column_present when data_filters are configured. Update _prepare_pipeline
and/or _apply_policy so policy evaluation is skipped on DB-free paths like evict
and refresh, or change the docstring to reflect the database check; use the
_prepare_pipeline, _apply_policy, and _column_present symbols to keep the
behavior and documentation consistent.

---

Nitpick comments:
In `@docs/concepts/query-cache.md`:
- Around line 67-91: The Staleness section in the query cache docs should
explicitly document the write-time baseline-scan failure behavior for refresh
keys. Update the explanation around execute(cache=True) and engine.refresh() to
note that a scan failure on a cache miss propagates and prevents storing the
entry, while the same failure during refresh is handled as continue-on-error.
Keep this tied to the refresh_keys and engine.refresh() wording so users
understand that bad table names or expressions can raise on the first cached
call.

In `@slayer/engine/cache.py`:
- Around line 269-279: The module still uses positional arguments for
multi-parameter helper calls, which is inconsistent with the repo’s
keyword-argument convention. Update the relevant helper signatures in cache.py,
especially make_key and any similar multi-arg methods used around
applicable_keys, to require keyword-only arguments where appropriate, and change
all internal call sites in this module to pass those parameters by name. If
make_key is made keyword-only, also update its positional caller in
query_engine.py so the call matches the new signature.

In `@slayer/engine/query_engine.py`:
- Around line 971-1025: The _build_response_metadata function is too cognitively
complex because it repeats nearly identical alias-filtered loops for dimensions,
time_dimensions, measures, expressions, transforms, and cross_model_measures.
Refactor this by extracting a small helper (or a couple of helpers) that takes
an enriched field collection and returns the matching FieldMetadata keyed by
alias, then reuse it inside _build_response_metadata. Keep the existing
public_aliases filtering and format inference logic intact while collapsing the
branching and duplication so the function passes the SonarCloud complexity gate.

In `@tests/test_query_cache.py`:
- Around line 596-602: The test around _build_engine and engine.execute is using
a wildcard pytest.raises(Exception), which SonarCloud flags. Tighten the
assertion to the most specific exception type exposed by the SQL client for an
invalid column reference, and update the surrounding failure setup in the cache
refresh-key test accordingly; if no stable dedicated type exists, keep the test
behavior focused on the engine.execute failure while avoiding an unnecessary
broad catch.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ee9b52f7-641e-4ef1-b21b-773a07485c6d

📥 Commits

Reviewing files that changed from the base of the PR and between 0a49c44 and bdf88a3.

📒 Files selected for processing (7)
  • CLAUDE.md
  • docs/concepts/query-cache.md
  • docs/examples/12_query_cache/query_cache_nb.ipynb
  • slayer/engine/cache.py
  • slayer/engine/query_engine.py
  • tests/test_query_cache.py
  • zensical.toml

Comment thread slayer/engine/cache.py Outdated
Comment thread slayer/engine/query_engine.py Outdated
ZmeiGorynych and others added 3 commits July 6, 2026 11:45
…ig, scope-based table detection

- Apply the forced-filter policy to refresh-key scans so the baseline is
  computed over the same tenant-scoped rows as the cached data query
  (Codex): thread datasource through _scan_refresh_keys/_scan_one_table.
- Freeze CacheConfig + coerce refresh_keys to a tuple so in-place mutation
  can't silently bypass the cache-clearing setter (Codex).
- Detect physical tables via sqlglot traverse_scope instead of excluding by
  bare name, so a table sharing a CTE alias name is still matched (CodeRabbit).
- Correct the _prepare_pipeline / evict 'DB-free' docstrings: under a policy,
  column-presence introspection runs (CodeRabbit).
- Tests: pytest.approx for float sums (Sonar S1244), narrow + hoist the
  baseline-failure exception assertion (Sonar S5778/S5958), NOSONAR S3776 on
  _build_response_metadata; add RLS-scoped-scan and frozen-config tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A schema/catalog-qualified table can never be a CTE (CTE names are
unqualified), so only bare names need the scope-shadow check. Fixes a
physical table sharing a CTE's bare name (e.g. public.cte) being skipped
(Codex follow-up). Add a regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A cache entry is bound to the datasource it originally resolved against
(part of the key). refresh() already scans that datasource's refresh keys,
so re-execution must pin to it via entry.resolved_data_source rather than
re-resolving through a possibly-changed priority list and migrating the
entry to a different datasource (Codex follow-up). Add a priority-drift
regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sonarqubecloud

sonarqubecloud Bot commented Jul 6, 2026

Copy link
Copy Markdown

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant