Skip to content

Cache LangChain tool results in a Hotdata managed table - #33

Draft
rohan-hotdata wants to merge 1 commit into
mainfrom
feat/tool-result-caching
Draft

Cache LangChain tool results in a Hotdata managed table#33
rohan-hotdata wants to merge 1 commit into
mainfrom
feat/tool-result-caching

Conversation

@rohan-hotdata

Copy link
Copy Markdown
Contributor

Summary

LangChain caches LLM calls (set_llm_cache/BaseCache) but has no equivalent for tool
calls -- confirmed by reading the installed langchain_core source directly: neither
BaseTool nor Runnable exposes a cache hook. Every StructuredTool invocation
re-executes, even on an agent retry or a repeated question across sessions.

  • HotdataToolCache (hotdata_langchain/cache.py) -- the storage backend. Keys an
    entry by sha256(version + tool_name + args), stores it in a Hotdata managed table
    (cache_key, tool_name, args_json, result_json, created_at) via
    load_managed_table(..., mode="upsert"), reads it back with a plain SQL SELECT.
  • cached() -- wraps any plain function or tool callable, not just this package's
    own. Fails open: a broken cache backend degrades to "uncached", never to a broken tool
    call.
  • make_hotdata_tools(client, cache=..., cache_ttl=...) wires this into the two read
    tools only (hotdata_execute_sql, hotdata_list_managed_databases) -- the mutating
    tools are never cached, since skipping a mutation on a cache hit would be a correctness
    bug, not caching.

Why now

This was originally blocked: managed tables historically only supported full-table
replace writes (per hotdata-dlt-destination's own docs, which still describe
read-modify-write emulation), which would have made a per-cache-entry write cost
O(cache size). Reading hotdata_framework's actual installed source and runtimedb's git
history directly showed this is now out of date -- native, key-based mode="upsert"
shipped server-side on 2026-07-09. That's what unblocked a simple per-call write path, and
is why this PR also bumps hotdata/hotdata-framework to >=0.8.0 (both released 0.8.0
the same day this was scoped -- newer than Dependabot's open #30/#31, which target the
same version and can likely be closed once this merges) and adds pyarrow as a direct
dependency (already transitive via hotdata-framework; the new module writes parquet
directly).

Verification

  • 41/41 tests passing, 15 new in tests/test_cache.py against a fake backend that
    round-trips through real pyarrow parquet writes/reads (not just mocked return values).
    ruff check/format clean; mypy clean on all source files.
  • Live-tested against a real workspace this session. Found and confirmed handled: a
    managed table with zero rows ever loaded returns HTTP 400 on query instead of an empty
    result -- cached()'s fail-open logic absorbs this correctly (tool call still
    succeeds), confirmed it doesn't recur once the table holds at least one row.
  • Live LangGraph benchmark against real TPCH sf=1 data (6M-row lineitem, a 6-way join
    for revenue-by-nation) showed 3.5-4.8x faster over 12 calls once the cache warms up;
    cold-write cost (~4-6s) breaks even in 2-3 repeats for queries at this scale. Trivial
    queries (SELECT 1) don't clearly benefit -- a cache lookup has its own ~0.6-0.7s
    floor, since it's still a network round trip. This scoping (expensive + repeated, not
    cheap + occasional) is called out in the new README section.

Known limitations (documented in README)

  • Managed-database names aren't unique/identifying -- concurrent first-use across
    processes can each create a distinct database with the same name, silently splitting
    the cache. Mitigation: pass database_id= to pin one explicitly.
  • All cache writes funnel through one per-table write lock regardless of key.
  • HotdataToolCache is memoized per-instance -- construct one per process and reuse it.

Not in this PR

Semantic/fuzzy cache matching (embedding-based similarity for near-duplicate calls) is a
separate follow-up that would reuse Hotdata's vector-index infrastructure; not a
dependency of this change.

Status

Draft -- not ready to merge. Opening for early visibility/feedback.

🤖 Generated with Claude Code

LangChain caches LLM calls but has no equivalent for tool calls -- every
StructuredTool invocation re-executes, even on a retry or a repeated
question. HotdataToolCache + cached() fill that gap: a pluggable,
persistent, queryable cache backend for any tool call (not just this
package's own), keyed by tool name and arguments.

Wires into make_hotdata_tools via cache=/cache_ttl= params, applied only
to the two read/idempotent tools -- the mutating tools are never cached,
since skipping a mutation on a cache hit would be a correctness bug.

Unblocked by hotdata-framework 0.8.0's native key-based mode="upsert" on
load_managed_table (shipped 2026-07-09), avoiding the full-table
read-modify-write hotdata-dlt-destination has to emulate on older
managed-table APIs. Bumps hotdata/hotdata-framework to >=0.8.0 (both
released 0.8.0 the same day this was scoped) and adds pyarrow as a
direct dependency.

Verified with 15 new unit tests (fake backend round-tripping through
real pyarrow parquet writes/reads) plus a live run against a real
workspace: confirmed read/write fidelity, and found that a managed
table with zero rows ever loaded 400s on query instead of returning
empty -- handled by the existing fail-open policy in cached().

Co-Authored-By: Claude Sonnet 5 <[email protected]>
rohan-hotdata added a commit that referenced this pull request Jul 24, 2026
Archives the benchmark work that asked whether HotdataToolCache (draft PR #33)
is the right store for tool-result caching, given that nothing in its design is
Hotdata-specific.

  benchmarks/backends.py     SqliteToolCache, LayeredToolCache, ToolCache protocol
  benchmarks/bench_*.py      query cost, cache primitives, end-to-end, fleet,
                             server-side (network excluded)
  benchmarks/crossover.py    model over the measured constants, incl. an RTT sweep
  benchmarks/provision_tpch.py  idempotent TPCH sf=1 fixture via DuckDB dbgen
  tests/test_backend_parity.py  49 tests asserting both backends behave identically

What it found, in short: a cache lookup costs the engine 71-79ms against Q1's
98-101ms of compute, so the ceiling for a same-region deployment is ~1.3x on a
cheap query and ~3.4x on an expensive one. Larger cached results make it worse,
not better. Full write-up in benchmarks/FINDINGS.md, including three defects in
the PR #33 write path and why Redis rather than SQLite is the real comparison.

Based on feat/tool-result-caching, since every module imports
hotdata_langchain.cache. Runs as-is on this branch; not meant to merge on its
own, and parked rather than pursued.

pyproject: allow print in benchmarks/, matching examples/ and scripts/.
rohan-hotdata added a commit that referenced this pull request Aug 6, 2026
…d table (#49)

* docs: refresh the VectorStore plan against shipped state

Four things had gone stale since the plan was written.

It leaned on cache.py in four places -- as the sibling file, the constructor
precedent, the _ensure_ready/_resolve_and_declare plumbing to copy "verbatim",
and the test fixture style. cache.py exists only on draft PR #33, which is
parked, so a meaningful part of the plan had no referent on main. No caching
behaviour is involved in a VectorStore at runtime; the reference was structural
precedent only. The plumbing is now specified directly, in four concrete steps.

The constructor contradicted #38, shipped in 0.3.0: it took both a name-defaulted
`database` and an optional `database_id`, and resolve-or-created by name. It now
requires `database_id` (id or a resolved ManagedDatabase). The single resolve at
construction is the only lookup in the class; every read and load addresses the
resolved record, so id-addressing propagates by construction.

The header said "Not committed -- decide later"; it has been in-repo since #43.
The testing strategy said no embedding credentials exist; OPENAI_EMBEDDING_KEY is
in .env, so live verification of the HNSW fast path -- the plan's one explicitly
unverified claim -- is now reachable. Added a step 0 to confirm that key's scope
before any code, since a scope mismatch only surfaces as a 403 mid-implementation.

Also added: a Tracking section recording the phase-to-issue breakdown (none of
those issues exist yet), and a note that #39 is a different surface -- the
agent-facing tool -- not this.

* docs: record the confirmed embedding-key scope in the VectorStore plan

* feat: HotdataVectorStore, LangChain's VectorStore backed by a managed table

Implements add_texts/add_documents, the four similarity_search* variants,
get_by_ids, delete and from_texts, plus equality filtering on metadata keys
promoted to typed columns via metadata_columns.

Searches compile to one scalar-UDF query -- ORDER BY <distance_fn>(embedding,
ARRAY[...]) ASC LIMIT k -- which is correct with no index and is rewritten into
an HNSW lookup once a matching-metric index exists. The embedding column is
never projected, since a vector in the output declines that rewrite.

database_id is required and resolved once at construction; every read and write
addresses the resolved record. delete requires ids.

Validated against LangChain's published conformance suite (langchain-tests) as
well as unit tests over a fake client that writes real parquet and ranks real
vectors. Verified end to end against a live workspace by demo/vectorstore_demo.py.

Closes #48

* docs: audit the README against what the integration actually does

Qualifies the HNSW fast-path claim, which was stated as fact but rests on
reading the engine's optimizer rule rather than an observed EXPLAIN for the
queries this package generates.

Adds examples/langchain_vectorstore.py, updates the tagline and dependency
note for the vector store, and states that the store is a primitive rather
than a tool.

* fix: address review — fail loudly on declaration, tighten id and type checks

_declare_table checked existence first instead of declaring and swallowing
whatever came back. The client reports a permission failure, an outage and an
already-declared table as the same RuntimeError, so the blanket catch could
construct a store that looked correctly keyed and appended duplicates on every
write. A lost declaration race stays tolerated, but only once the table is
confirmed present.

add_texts treated an empty-string id as an absent one and generated a
replacement; it now raises, and only None gets a generated id.

bool is a subclass of int, so an int-declared metadata column accepted True and
stored 1 while metadata_json kept true. _matches_type only ever admits a bool to
a bool column, on both the write and filter paths.

README: the vector store is not shipped as a tool but composes into one with
create_retriever_tool, with a worked example and when to prefer each shape.
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