Cache LangChain tool results in a Hotdata managed table - #33
Draft
rohan-hotdata wants to merge 1 commit into
Draft
Cache LangChain tool results in a Hotdata managed table#33rohan-hotdata wants to merge 1 commit into
rohan-hotdata wants to merge 1 commit into
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
LangChain caches LLM calls (
set_llm_cache/BaseCache) but has no equivalent for toolcalls -- confirmed by reading the installed
langchain_coresource directly: neitherBaseToolnorRunnableexposes a cache hook. EveryStructuredToolinvocationre-executes, even on an agent retry or a repeated question across sessions.
HotdataToolCache(hotdata_langchain/cache.py) -- the storage backend. Keys anentry by
sha256(version + tool_name + args), stores it in a Hotdata managed table(
cache_key,tool_name,args_json,result_json,created_at) viaload_managed_table(..., mode="upsert"), reads it back with a plain SQLSELECT.cached()-- wraps any plain function or tool callable, not just this package'sown. 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 readtools only (
hotdata_execute_sql,hotdata_list_managed_databases) -- the mutatingtools 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
replacewrites (perhotdata-dlt-destination's own docs, which still describeread-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 githistory 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-frameworkto>=0.8.0(both released 0.8.0the 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
pyarrowas a directdependency (already transitive via
hotdata-framework; the new module writes parquetdirectly).
Verification
tests/test_cache.pyagainst a fake backend thatround-trips through real pyarrow parquet writes/reads (not just mocked return values).
ruff check/formatclean;mypyclean on all source files.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 stillsucceeds), confirmed it doesn't recur once the table holds at least one row.
lineitem, a 6-way joinfor 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.7sfloor, 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)
processes can each create a distinct database with the same name, silently splitting
the cache. Mitigation: pass
database_id=to pin one explicitly.HotdataToolCacheis 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