Skip to content

feat(appkit): cache ai-search queries with OBO-safe keys - #514

Open
MarioCadenas wants to merge 1 commit into
mainfrom
feat/ai-search-cache
Open

feat(appkit): cache ai-search queries with OBO-safe keys#514
MarioCadenas wants to merge 1 commit into
mainfrom
feat/ai-search-cache

Conversation

@MarioCadenas

@MarioCadenas MarioCadenas commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Problem

aiSearch did no caching. Two gaps compounded: aiSearchDefaults had cache: { enabled: false }, and — more importantly — none of the execute() calls passed a cacheKey. The CacheInterceptor only engages when cache.enabled && cache.cacheKey?.length (see plugin.ts _buildInterceptors), so caching never engaged even if enabled were flipped.

What this does

Wires up query caching, mirroring the analytics plugin's per-call config pattern.

  • Enable cache in aiSearchDefaults with a short 60s TTL. Vector Search results shift as the index resyncs, so the cache only lives long enough to absorb bursts (repeated queries, React StrictMode double-mounts) without serving stale results for long.
  • Per-call cacheKey covering everything that changes a result: resolved index, queryText, hashed queryVector (vectors are large), queryType, numResults, post-allowlist columns, filters, and whether reranking is on.
  • Single source of default resolution (_resolveQueryParams) shared by _prepareQuery (the payload) and _cacheKeyFor (the key), so the key can't silently drift from what's actually sent to VS.
  • Programmatic query() runs _prepareQuery inside execute now (the HTTP route already did), so a cache hit skips both embeddingFn and the VS call on both paths.
  • next-page is deliberately uncached: a page token is a single-use cursor that already identifies the exact page.
  • Docs: added a Caching section (TTL, key composition, per-user isolation) to the ai-search plugin page.

Security invariant

OBO queries must never read a service-principal-populated cache entry or another user's entry. The caller identity is folded into the key ("global" for SP, the resolved user id for auth: "on-behalf-of-user"), and the interceptor independently namespaces by context.userKey. On the OBO path execute runs inside runInUserContext, so both cover it. SP indexes intentionally share one entry.

Tests

Added a caching suite to ai-search.test.ts (replaced the stub cache mock with a faithful in-memory getOrExecute keyed like the real CacheManager):

  • identical query twice → connector called once (hit)
  • distinct keys per differing field (queryText / numResults / queryType / columns / filters / reranker) → 2 calls (parametrized)
  • managed-embedding: keyed by queryText, embedding + connector skipped on a hit — asserted on both the route and programmatic paths
  • OBO two users → distinct keys → both hit the connector (no cross-user leak); same user re-served from cache

Verification

  • pnpm --filter=@databricks/appkit build:package
  • pnpm exec vitest run ai-search — 61/61 ✓
  • pnpm -r typecheck
  • pnpm check (biome) — exit 0 ✓

@MarioCadenas
MarioCadenas requested a review from a team as a code owner August 5, 2026 14:34
@MarioCadenas
MarioCadenas requested review from calvarjorge and removed request for a team August 5, 2026 14:34
Base automatically changed from feat/export-vector-search-plugin to main August 5, 2026 14:50
aiSearch did no caching: defaults had cache disabled and, more importantly,
no execute() call passed a cacheKey, so the CacheInterceptor never engaged
even when enabled. Wire up query caching mirroring the analytics plugin.

- Enable cache in aiSearchDefaults with a short 60s TTL (VS results shift as
  the index resyncs; cache only long enough to absorb bursts/StrictMode).
- Build a per-call cacheKey covering everything that changes results: index,
  queryText, hashed queryVector, queryType, numResults, post-allowlist
  columns, filters, reranker. Fold the caller identity in ("global" for SP,
  user id for OBO) so an OBO query never reads an SP or another user's entry.
- Add _resolveQueryParams as the single source of default resolution for both
  _prepareQuery (payload) and _cacheKeyFor (key), so the key can't drift from
  what's sent.
- Run _prepareQuery inside execute on the programmatic query() path too (the
  route already did), so a cache hit skips embeddingFn and the VS call.
- Leave next-page uncached: a page token is a single-use cursor.
- Document caching + per-user isolation in the ai-search plugin docs.

Signed-off-by: MarioCadenas <MarioCadenas@users.noreply.github.com>
@MarioCadenas
MarioCadenas force-pushed the feat/ai-search-cache branch from 3208fd3 to 2fb3cad Compare August 5, 2026 15:02
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

📦 Bundle size report

Compared against bundle-size-baseline.json (main).

@databricks/appkit

npm tarball (packed): 831 KB (+18 KB) — gzipped download (dist + bin; excludes release-only docs/NOTICE).

dist raw gzip
JS (runtime) 857 KB (+22 KB) 299 KB (+7.8 KB)
Type declarations 315 KB (+7.6 KB) 109 KB (+3.3 KB)
Source maps 1.7 MB (+37 KB) 559 KB (+12 KB)
Other 11 KB 3.7 KB
Total 2.8 MB (+66 KB) 971 KB (+24 KB)
Per-entry composition (own code — deps external (as shipped))
Entry Initial (gz) Lazy (gz) Total (gz) node_modules (min) Own code (min)
. 87 KB (+45 B) 2.5 KB 90 KB (+45 B) external 285 KB (+122 B)
./beta 48 KB (+3.3 KB) 459 B (+1 B) 48 KB (+3.3 KB) external 141 KB (+11 KB)
./type-generator 20 KB 0 B 20 KB external 57 KB

Chunks:

Entry Chunk Load Size (gz)
. index.js initial 83 KB
. utils.js initial 4.0 KB
. remote-tunnel-manager.js lazy 2.5 KB
./beta beta.js initial 32 KB
./beta stream-manager.js initial 5.8 KB
./beta wide-event-emitter.js initial 3.2 KB
./beta databricks.js initial 3.0 KB
./beta configuration.js initial 2.1 KB
./beta service-context.js initial 1.3 KB
./beta client.js initial 431 B
./beta client-options.js initial 220 B
./beta supervisor-api.js lazy 194 B
./beta databricks.js lazy 142 B
./beta index.js lazy 123 B
./type-generator index.js initial 20 KB

@databricks/appkit-ui

npm tarball (packed): 309 KB (+3.5 KB) — gzipped download (dist + bin; excludes release-only docs/NOTICE).

dist raw gzip
JS (runtime) 362 KB (+2.6 KB) 121 KB (+1.1 KB)
Type declarations 209 KB (+3.1 KB) 76 KB (+1.2 KB)
Source maps 693 KB (+7.1 KB) 227 KB (+2.8 KB)
CSS 16 KB 3.3 KB
Total 1.3 MB (+13 KB) 427 KB (+5.0 KB)
Per-entry composition (consumer bundle — deps bundled, peerDeps external)
Entry Initial (gz) Lazy (gz) Total (gz) node_modules (min) Own code (min)
./js 4.3 KB 49 KB 54 KB 208 KB 12 KB
./js/beta 20 B 0 B 20 B 0 B 0 B
./react 429 KB 49 KB 478 KB 1.3 MB 168 KB
./react/beta 1.0 KB (+1015 B) 0 B 1.0 KB (+1015 B) 0 B 1.9 KB (+1.9 KB)

Chunks:

Entry Chunk Load Size (gz)
./js index.js initial 4.2 KB
./js chunk initial 120 B
./js apache-arrow lazy 49 KB
./js/beta beta.js initial 20 B
./react index.js initial 427 KB
./react tslib initial 2.1 KB
./react apache-arrow lazy 49 KB
./react/beta beta.js initial 1.0 KB

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

🤖 AppKit PR bot

🔬 Run evals

Start an eval for this PR from the evals-monitor app: Go to Evals Monitor →

📦 Try this PR's app template

Scaffolds a new app from this PR's SDK build. Run it in any folder (requires the GitHub CLI — gh auth login — and the Databricks CLI):

gh run download 31018243730 -R databricks/appkit -n appkit-template-0.54.0-pr.a2165c6-feat-ai-search-cache-514 -D appkit-pr-514 \
  && unzip -o "appkit-pr-514/appkit-template-0.54.0-pr.a2165c6-feat-ai-search-cache-514.zip" -d "appkit-pr-514" \
  && databricks apps init --template "appkit-pr-514"

The template pins @databricks/appkit and @databricks/appkit-ui to tarballs built from this branch, so the scaffolded app runs against this PR's code.

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