Skip to content

perf(sdk): cache tools.list by catalog + policy revision - #50

Merged
aryasaatvik merged 3 commits into
devfrom
feat/tools-list-revision-cache
Jun 21, 2026
Merged

aryasaatvik merged 3 commits into
devfrom
feat/tools-list-revision-cache

Conversation

@aryasaatvik

@aryasaatvik aryasaatvik commented Jun 21, 2026 •

Copy link
Copy Markdown
Owner

Phase 1 of Tier-2 tool-catalog caching — stacked on #49.

Caches executor.tools.list (today a full tool scan + tool_policy read on every call) behind a content-derived revision key, so the list stays warm and only re-reads D1 when the catalog or policies actually change. No TTL.

Invalidation

  • produceConnectionTools writes a per-connection toolset_revision = sha256 of the connection's manifest rows' (path, index_fingerprint), in plugin_storage (owner-scoped, no schema migration), at all 5 catalog-mutation sites (produce, both clear branches, connection-remove, integration-remove).
  • catalogGen combines the in-scope connections' revisions; policyGen combines the tool_policy rows. Key = sha256(owner, integration, connection, includeBlocked, catalogGen, policyGen).
  • Token rotation does not bust (never calls produceConnectionTools); add/remove/change a tool, or any policy edit, does.

Scope

  • Cached value = the DB-derived, policy-filtered list. Static tools unioned live (never cached); query applied post-cache so distinct queries share one cached set.
  • Hand-authored ToolListItem Schema (list projection only — no input/output schema); branded ids stored as strings, rebuilt on read.

Notes (intentional, single-tenant self-host)

  • catalogGen/policyGen reads are tenant-wide → per-tenant-coarse invalidation (a catalog change re-warms all owners' list caches). Correct, just coarse.

Gate

tsgo (sdk) · oxlint --deny-warnings · oxfmt · vitest sdk 350/350 (new cache suite: add/remove/content invalidation, policy bust, query post-cache, static-union-on-hit).

Phase 2 (stacked) moves the UI list onto the compact manifest tier.

Stack

  1. perf(sdk): precompile tool-schema TypeScript during indexing #49
  2. perf(sdk): cache tools.list by catalog + policy revision #50 👈 current

@greptile-apps

greptile-apps Bot commented Jun 21, 2026 •

Copy link
Copy Markdown

Greptile Summary

Adds a content-addressed KV cache for executor.tools.list to replace the per-call full tool scan. The cache key is derived from a policyGen (SHA-256 of all tool_policy rows) and catalogGen (SHA-256 of per-connection toolset_revision rows written to plugin_storage by produceConnectionTools), so no TTL is needed and the cache stays warm through token rotation. Static tools are always unioned live, and the query substring filter is applied post-cache so distinct queries share one cached entry.

  • Cache invalidation is handled at all 5 mutation sites (produce, two clear branches, connection-remove, integration-remove); (owner, key) is included in the sort so the all-owners revision read is deterministic (addresses the previous cross-owner thread concern).
  • Intentional coarseness: catalogGen and policyGen are tenant-wide reads, so any catalog or policy change busts all in-scope list caches — acknowledged in the PR notes. Cache entries are never evicted (no TTL), so stale orphans accumulate; the platform's KV eviction or a future Phase 2 cleanup would address this.

Confidence Score: 5/5

Safe to merge; the caching layer is best-effort (errors fall back to a live DB read) and the invalidation logic is transactional at every mutation site.

All five catalog-mutation sites correctly write or delete the plugin_storage revision row inside the same transaction as the tool writes. The policyGen/catalogGen computation is deterministic after the (owner, key) sort fix from the previous thread. Cache hits and misses both produce identical output for the same filter; the test suite covers add/remove invalidation, content-change invalidation, policy bust, query post-cache sharing, and static tool live union. No correctness issues found.

No files require special attention.

Important Files Changed

Filename Overview
packages/core/sdk/src/tool-list-cache.ts New module defining the schema, key derivation, and constants for the tools.list KV cache; version-locked schema, deterministic key via sha256, and well-named constants. No issues.
packages/core/sdk/src/executor.ts Core caching logic added: revision computation in produceConnectionTools, revision cleanup at all 5 mutation sites, and the revamped toolsList that computes catalogGen/policyGen live then uses KV as a best-effort cache. Previous cross-owner and error-swallow concerns addressed (per thread replies).
packages/core/sdk/src/executor.test.ts Adds 5 targeted cache tests: add/remove invalidation, content-change invalidation, policy bust, query post-cache sharing, and static-tool live union. All test the right invariants; no gaps in coverage for the stated scope.
.changeset/tools-list-revision-cache.md Minor changeset for @executor-js/sdk accurately describing the caching behaviour, invalidation triggers, and what remains live.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Caller
    participant toolsList
    participant D1 as D1 (tool_policy / plugin_storage)
    participant KV as KV Cache

    Caller->>toolsList: tools.list(filter)
    toolsList->>D1: "findMany(tool_policy, {})"
    D1-->>toolsList: policyRows → policyGen (sha256)
    toolsList->>D1: "findMany(plugin_storage, {plugin_id=catalog-revision, owner?})"
    D1-->>toolsList: revisionRows → catalogGen (sha256)
    toolsList->>toolsList: "key = sha256(owner, integration, connection, includeBlocked, catalogGen, policyGen)"
    toolsList->>KV: get(key)

    alt Cache HIT
        KV-->>toolsList: ToolListCacheEntry
        toolsList->>toolsList: "dbTools = entry.tools.map(cachedItemToTool)"
    else Cache MISS
        KV-->>toolsList: Option.none
        toolsList->>D1: "findMany(tool, {integration, owner, connection})"
        D1-->>toolsList: toolRows
        toolsList->>toolsList: apply policy filter → dbTools
        toolsList->>KV: "set(key, {version, tools: dbTools}) [Effect.ignore]"
    end

    toolsList->>toolsList: union staticTools (live, no cache)
    toolsList->>toolsList: apply query filter post-cache
    toolsList-->>Caller: Tool[]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Caller
    participant toolsList
    participant D1 as D1 (tool_policy / plugin_storage)
    participant KV as KV Cache

    Caller->>toolsList: tools.list(filter)
    toolsList->>D1: "findMany(tool_policy, {})"
    D1-->>toolsList: policyRows → policyGen (sha256)
    toolsList->>D1: "findMany(plugin_storage, {plugin_id=catalog-revision, owner?})"
    D1-->>toolsList: revisionRows → catalogGen (sha256)
    toolsList->>toolsList: "key = sha256(owner, integration, connection, includeBlocked, catalogGen, policyGen)"
    toolsList->>KV: get(key)

    alt Cache HIT
        KV-->>toolsList: ToolListCacheEntry
        toolsList->>toolsList: "dbTools = entry.tools.map(cachedItemToTool)"
    else Cache MISS
        KV-->>toolsList: Option.none
        toolsList->>D1: "findMany(tool, {integration, owner, connection})"
        D1-->>toolsList: toolRows
        toolsList->>toolsList: apply policy filter → dbTools
        toolsList->>KV: "set(key, {version, tools: dbTools}) [Effect.ignore]"
    end

    toolsList->>toolsList: union staticTools (live, no cache)
    toolsList->>toolsList: apply query filter post-cache
    toolsList-->>Caller: Tool[]
Loading

Reviews (4): Last reviewed commit: "fix(sdk): scope tools.list catalogGen by..." | Re-trigger Greptile

Comment thread packages/core/sdk/src/executor.ts Resolved
Comment thread packages/core/sdk/src/executor.ts Resolved
aryasaatvik added a commit that referenced this pull request Jun 21, 2026
## Why

Production traces showed the web UI's tool-detail path (`GET
/api/tools/schema`, `includeTypeScript: true`) paying the json-schema →
TypeScript compile on the first view of every tool. The reindex pipeline
already visits every tool's schema, but it called `tools.schema({
includeTypeScript: false })` specifically to *skip* that compile — so it
warmed the wrong cache variant and the UI's variant stayed cold until
first manual view.

## What

- `executor.tools.schema` always renders the TypeScript preview; the
`includeTypeScript` option and the now-empty `ToolSchemaOptions` type
are removed.
- The reindex pipeline (`collectDocForTool`) calls `tools.schema`
plainly, so every reindex pre-warms exactly the KV schema-view the UI
reads → first tool-detail views become cache hits instead of a cold
compile.
- `includeTypeScript` is dropped from the schema-view cache key (content
+ manifest), collapsing the two variants into one.

## Safety

- Embedding/lexical text is unchanged: `collectDocForTool` only reads
the raw schema facets (`inputSchema` / `outputSchema` /
`schemaDefinitions`), never the rendered TypeScript — so no re-embedding
/ index churn.
- The cache-key change supersedes existing entries; they re-warm on the
next reindex or view (regenerable derived data).

## Gate

tsgo (43/43), `oxlint --deny-warnings`, oxfmt, and vitest for
`@executor-js/sdk` + `@executor-js/plugin-semantic-search` all green.

Follow-up: a reindex on the deployed instance warms the new unified
variant.

<!-- stack:links:start -->
### [Stack](https://github.com/kitlangton/stack)

1. **#49** 👈 current
2. #50
<!-- stack:links:end -->
@aryasaatvik
aryasaatvik changed the base branch from perf/precompile-tool-schema-typescript to dev June 21, 2026 21:52
@aryasaatvik
aryasaatvik force-pushed the feat/tools-list-revision-cache branch from e20d74b to 2a50b8b Compare June 21, 2026 21:52
tools.list was an uncached full `tool` scan + `tool_policy` read on every call.
produceConnectionTools now records a per-connection toolset_revision (a hash of
the connection's manifest (path, index_fingerprint) pairs) in plugin_storage at
every catalog-mutation site (produce, both clear branches, connection-remove,
integration-remove). tools.list derives a per-owner catalogGen from those rows
and a policyGen from tool_policy, and caches the DB-derived, policy-filtered list
keyed on both. Static tools are unioned live; the `query` substring filter is
applied post-cache so distinct queries share one cached set.

The cache stays warm through token rotation (no produceConnectionTools call) and
invalidates exactly when a connection's tool set, a tool's schema, or a policy
changes -- including OpenAPI/MCP tool additions on refresh and removals.
Adds a "tools.list cache" suite: tool-set additions/removals across a refresh,
a tool content change, a block policy busting the cache, the query filter applied
post-cache (distinct queries share a cached set), and static tools unioned live on
a hit.
The catalog-revision read was tenant-wide and the combine sorted only by the
`integration:connection` key, so the all-owners list (filter.owner undefined)
could surface same-key rows for different owners in nondeterministic order,
destabilizing the cache key (it would never hit). Scope the read by
filter.owner when set, and carry `owner` in the combine tuple + sort.
@aryasaatvik
aryasaatvik force-pushed the feat/tools-list-revision-cache branch from 2a50b8b to ce07a7e Compare June 21, 2026 21:53
@aryasaatvik
aryasaatvik merged commit 81e65df into dev Jun 21, 2026
8 checks passed
@aryasaatvik
aryasaatvik deleted the feat/tools-list-revision-cache branch June 21, 2026 22:00
aryasaatvik added a commit that referenced this pull request Jun 23, 2026
## Why

Production traces showed the web UI's tool-detail path (`GET
/api/tools/schema`, `includeTypeScript: true`) paying the json-schema →
TypeScript compile on the first view of every tool. The reindex pipeline
already visits every tool's schema, but it called `tools.schema({
includeTypeScript: false })` specifically to *skip* that compile — so it
warmed the wrong cache variant and the UI's variant stayed cold until
first manual view.

## What

- `executor.tools.schema` always renders the TypeScript preview; the
`includeTypeScript` option and the now-empty `ToolSchemaOptions` type
are removed.
- The reindex pipeline (`collectDocForTool`) calls `tools.schema`
plainly, so every reindex pre-warms exactly the KV schema-view the UI
reads → first tool-detail views become cache hits instead of a cold
compile.
- `includeTypeScript` is dropped from the schema-view cache key (content
+ manifest), collapsing the two variants into one.

## Safety

- Embedding/lexical text is unchanged: `collectDocForTool` only reads
the raw schema facets (`inputSchema` / `outputSchema` /
`schemaDefinitions`), never the rendered TypeScript — so no re-embedding
/ index churn.
- The cache-key change supersedes existing entries; they re-warm on the
next reindex or view (regenerable derived data).

## Gate

tsgo (43/43), `oxlint --deny-warnings`, oxfmt, and vitest for
`@executor-js/sdk` + `@executor-js/plugin-semantic-search` all green.

Follow-up: a reindex on the deployed instance warms the new unified
variant.

<!-- stack:links:start -->
### [Stack](https://github.com/kitlangton/stack)

1. **#49** 👈 current
2. #50
<!-- stack:links:end -->
aryasaatvik added a commit that referenced this pull request Jun 23, 2026
**Phase 1** of Tier-2 tool-catalog caching — stacked on #49.

Caches `executor.tools.list` (today a full `tool` scan + `tool_policy`
read on every call) behind a **content-derived revision key**, so the
list stays warm and only re-reads D1 when the catalog or policies
actually change. No TTL.

- `produceConnectionTools` writes a per-connection
**`toolset_revision`** = `sha256` of the connection's manifest rows'
`(path, index_fingerprint)`, in **`plugin_storage`** (owner-scoped, **no
schema migration**), at all 5 catalog-mutation sites (produce, both
clear branches, connection-remove, integration-remove).
- `catalogGen` combines the in-scope connections' revisions; `policyGen`
combines the `tool_policy` rows. Key = `sha256(owner, integration,
connection, includeBlocked, catalogGen, policyGen)`.
- **Token rotation does not bust** (never calls
`produceConnectionTools`); add/remove/change a tool, or any policy edit,
does.

- Cached value = the DB-derived, policy-filtered list. **Static tools
unioned live** (never cached); **`query` applied post-cache** so
distinct queries share one cached set.
- Hand-authored `ToolListItem` Schema (list projection only — no
input/output schema); branded ids stored as strings, rebuilt on read.

- `catalogGen`/`policyGen` reads are tenant-wide → per-tenant-coarse
invalidation (a catalog change re-warms all owners' list caches).
Correct, just coarse.

tsgo (sdk) · `oxlint --deny-warnings` · oxfmt · vitest **sdk 350/350**
(new cache suite: add/remove/content invalidation, policy bust, query
post-cache, static-union-on-hit).

Phase 2 (stacked) moves the UI list onto the compact manifest tier.

<!-- stack:links:start -->

1. #49
2. **#50** 👈 current
<!-- stack:links:end -->
aryasaatvik added a commit that referenced this pull request Jun 23, 2026
## Why

Production traces showed the web UI's tool-detail path (`GET
/api/tools/schema`, `includeTypeScript: true`) paying the json-schema →
TypeScript compile on the first view of every tool. The reindex pipeline
already visits every tool's schema, but it called `tools.schema({
includeTypeScript: false })` specifically to *skip* that compile — so it
warmed the wrong cache variant and the UI's variant stayed cold until
first manual view.

## What

- `executor.tools.schema` always renders the TypeScript preview; the
`includeTypeScript` option and the now-empty `ToolSchemaOptions` type
are removed.
- The reindex pipeline (`collectDocForTool`) calls `tools.schema`
plainly, so every reindex pre-warms exactly the KV schema-view the UI
reads → first tool-detail views become cache hits instead of a cold
compile.
- `includeTypeScript` is dropped from the schema-view cache key (content
+ manifest), collapsing the two variants into one.

## Safety

- Embedding/lexical text is unchanged: `collectDocForTool` only reads
the raw schema facets (`inputSchema` / `outputSchema` /
`schemaDefinitions`), never the rendered TypeScript — so no re-embedding
/ index churn.
- The cache-key change supersedes existing entries; they re-warm on the
next reindex or view (regenerable derived data).

## Gate

tsgo (43/43), `oxlint --deny-warnings`, oxfmt, and vitest for
`@executor-js/sdk` + `@executor-js/plugin-semantic-search` all green.

Follow-up: a reindex on the deployed instance warms the new unified
variant.

<!-- stack:links:start -->
### [Stack](https://github.com/kitlangton/stack)

1. **#49** 👈 current
2. #50
<!-- stack:links:end -->
aryasaatvik added a commit that referenced this pull request Jun 23, 2026
**Phase 1** of Tier-2 tool-catalog caching — stacked on #49.

Caches `executor.tools.list` (today a full `tool` scan + `tool_policy`
read on every call) behind a **content-derived revision key**, so the
list stays warm and only re-reads D1 when the catalog or policies
actually change. No TTL.

- `produceConnectionTools` writes a per-connection
**`toolset_revision`** = `sha256` of the connection's manifest rows'
`(path, index_fingerprint)`, in **`plugin_storage`** (owner-scoped, **no
schema migration**), at all 5 catalog-mutation sites (produce, both
clear branches, connection-remove, integration-remove).
- `catalogGen` combines the in-scope connections' revisions; `policyGen`
combines the `tool_policy` rows. Key = `sha256(owner, integration,
connection, includeBlocked, catalogGen, policyGen)`.
- **Token rotation does not bust** (never calls
`produceConnectionTools`); add/remove/change a tool, or any policy edit,
does.

- Cached value = the DB-derived, policy-filtered list. **Static tools
unioned live** (never cached); **`query` applied post-cache** so
distinct queries share one cached set.
- Hand-authored `ToolListItem` Schema (list projection only — no
input/output schema); branded ids stored as strings, rebuilt on read.

- `catalogGen`/`policyGen` reads are tenant-wide → per-tenant-coarse
invalidation (a catalog change re-warms all owners' list caches).
Correct, just coarse.

tsgo (sdk) · `oxlint --deny-warnings` · oxfmt · vitest **sdk 350/350**
(new cache suite: add/remove/content invalidation, policy bust, query
post-cache, static-union-on-hit).

Phase 2 (stacked) moves the UI list onto the compact manifest tier.

<!-- stack:links:start -->

1. #49
2. **#50** 👈 current
<!-- stack:links:end -->
aryasaatvik added a commit that referenced this pull request Aug 18, 2026
Production traces showed the web UI's tool-detail path (`GET
/api/tools/schema`, `includeTypeScript: true`) paying the json-schema →
TypeScript compile on the first view of every tool. The reindex pipeline
already visits every tool's schema, but it called `tools.schema({
includeTypeScript: false })` specifically to *skip* that compile — so it
warmed the wrong cache variant and the UI's variant stayed cold until
first manual view.

- `executor.tools.schema` always renders the TypeScript preview; the
`includeTypeScript` option and the now-empty `ToolSchemaOptions` type
are removed.
- The reindex pipeline (`collectDocForTool`) calls `tools.schema`
plainly, so every reindex pre-warms exactly the KV schema-view the UI
reads → first tool-detail views become cache hits instead of a cold
compile.
- `includeTypeScript` is dropped from the schema-view cache key (content
+ manifest), collapsing the two variants into one.

- Embedding/lexical text is unchanged: `collectDocForTool` only reads
the raw schema facets (`inputSchema` / `outputSchema` /
`schemaDefinitions`), never the rendered TypeScript — so no re-embedding
/ index churn.
- The cache-key change supersedes existing entries; they re-warm on the
next reindex or view (regenerable derived data).

tsgo (43/43), `oxlint --deny-warnings`, oxfmt, and vitest for
`@executor-js/sdk` + `@executor-js/plugin-semantic-search` all green.

Follow-up: a reindex on the deployed instance warms the new unified
variant.

<!-- stack:links:start -->

1. **#49** 👈 current
2. #50
<!-- stack:links:end -->
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