a7d6d88f6f
CI / changes (push) Has been cancelled
CI / cd libs/checkpoint (push) Has been cancelled
CI / cd libs/checkpoint-conformance (push) Has been cancelled
CI / cd libs/checkpoint-postgres (push) Has been cancelled
CI / cd libs/checkpoint-sqlite (push) Has been cancelled
CI / cd libs/cli (push) Has been cancelled
CI / cd libs/prebuilt (push) Has been cancelled
CI / cd libs/sdk-py (push) Has been cancelled
CI / cd libs/langgraph (push) Has been cancelled
CI / Check SDK methods matching (push) Has been cancelled
CI / Check CLI schema hasn't changed #3.13 (push) Has been cancelled
CI / CLI integration test (push) Has been cancelled
CI / sdk-py integration test (push) Has been cancelled
CI / CI Success (push) Has been cancelled
baseline / benchmark (push) Has been cancelled
Deploy Redirects to GitHub Pages / deploy (push) Has been cancelled
121 lines
4.7 KiB
Python
121 lines
4.7 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import datetime
|
|
import sqlite3
|
|
import threading
|
|
from collections.abc import Mapping, Sequence
|
|
|
|
from langgraph.cache.base import BaseCache, FullKey, Namespace, ValueT
|
|
from langgraph.checkpoint.serde.base import SerializerProtocol
|
|
|
|
|
|
class SqliteCache(BaseCache[ValueT]):
|
|
"""File-based cache using SQLite."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
path: str,
|
|
serde: SerializerProtocol | None = None,
|
|
) -> None:
|
|
"""Initialize the cache with a file path."""
|
|
super().__init__(serde=serde)
|
|
# SQLite backing store
|
|
self._conn = sqlite3.connect(
|
|
path,
|
|
check_same_thread=False,
|
|
)
|
|
# Serialize access to the shared connection across threads
|
|
self._lock = threading.RLock()
|
|
# Better concurrency & atomicity
|
|
self._conn.execute("PRAGMA journal_mode=WAL;")
|
|
# Schema: key -> (expiry, encoding, value)
|
|
self._conn.execute(
|
|
"""CREATE TABLE IF NOT EXISTS cache (
|
|
ns TEXT,
|
|
key TEXT,
|
|
expiry REAL,
|
|
encoding TEXT NOT NULL,
|
|
val BLOB NOT NULL,
|
|
PRIMARY KEY (ns, key)
|
|
)"""
|
|
)
|
|
self._conn.commit()
|
|
|
|
def get(self, keys: Sequence[FullKey]) -> dict[FullKey, ValueT]:
|
|
"""Get the cached values for the given keys."""
|
|
with self._lock, self._conn:
|
|
now = datetime.datetime.now(datetime.timezone.utc).timestamp()
|
|
if not keys:
|
|
return {}
|
|
placeholders = ",".join("(?, ?)" for _ in keys)
|
|
params: list[str] = []
|
|
for ns_tuple, key in keys:
|
|
params.extend((",".join(ns_tuple), key))
|
|
cursor = self._conn.execute(
|
|
f"SELECT ns, key, expiry, encoding, val FROM cache WHERE (ns, key) IN ({placeholders})",
|
|
tuple(params),
|
|
)
|
|
values: dict[FullKey, ValueT] = {}
|
|
rows = cursor.fetchall()
|
|
for ns, key, expiry, encoding, raw in rows:
|
|
if expiry is not None and now > expiry:
|
|
# purge expired entry
|
|
self._conn.execute(
|
|
"DELETE FROM cache WHERE (ns, key) = (?, ?)", (ns, key)
|
|
)
|
|
continue
|
|
values[(tuple(ns.split(",")), key)] = self.serde.loads_typed(
|
|
(encoding, raw)
|
|
)
|
|
return values
|
|
|
|
async def aget(self, keys: Sequence[FullKey]) -> dict[FullKey, ValueT]:
|
|
"""Asynchronously get the cached values for the given keys."""
|
|
return await asyncio.to_thread(self.get, keys)
|
|
|
|
def set(self, mapping: Mapping[FullKey, tuple[ValueT, int | None]]) -> None:
|
|
"""Set the cached values for the given keys and TTLs."""
|
|
with self._lock, self._conn:
|
|
now = datetime.datetime.now(datetime.timezone.utc)
|
|
for key, (value, ttl) in mapping.items():
|
|
if ttl is not None:
|
|
delta = datetime.timedelta(seconds=ttl)
|
|
expiry: float | None = (now + delta).timestamp()
|
|
else:
|
|
expiry = None
|
|
encoding, raw = self.serde.dumps_typed(value)
|
|
self._conn.execute(
|
|
"INSERT OR REPLACE INTO cache (ns, key, expiry, encoding, val) VALUES (?, ?, ?, ?, ?)",
|
|
(",".join(key[0]), key[1], expiry, encoding, raw),
|
|
)
|
|
|
|
async def aset(self, mapping: Mapping[FullKey, tuple[ValueT, int | None]]) -> None:
|
|
"""Asynchronously set the cached values for the given keys and TTLs."""
|
|
await asyncio.to_thread(self.set, mapping)
|
|
|
|
def clear(self, namespaces: Sequence[Namespace] | None = None) -> None:
|
|
"""Delete the cached values for the given namespaces.
|
|
If no namespaces are provided, clear all cached values."""
|
|
with self._lock, self._conn:
|
|
if namespaces is None:
|
|
self._conn.execute("DELETE FROM cache")
|
|
else:
|
|
placeholders = ",".join("?" for _ in namespaces)
|
|
self._conn.execute(
|
|
f"DELETE FROM cache WHERE (ns) IN ({placeholders})",
|
|
tuple(",".join(key) for key in namespaces),
|
|
)
|
|
|
|
async def aclear(self, namespaces: Sequence[Namespace] | None = None) -> None:
|
|
"""Asynchronously delete the cached values for the given namespaces.
|
|
If no namespaces are provided, clear all cached values."""
|
|
await asyncio.to_thread(self.clear, namespaces)
|
|
|
|
def __del__(self) -> None:
|
|
try:
|
|
self._conn.close()
|
|
except Exception:
|
|
pass
|