Files
langgraph/langgraph/checkpoint/sqlite.py
T

174 lines
5.8 KiB
Python

import pickle
import sqlite3
from contextlib import AbstractContextManager, contextmanager
from types import TracebackType
from typing import Any, Iterator, Optional
from langchain_core.runnables import RunnableConfig
from typing_extensions import Self
from langgraph.checkpoint.base import (
BaseCheckpointSaver,
Checkpoint,
CheckpointAt,
CheckpointTuple,
SerializerProtocol,
)
from langgraph.serde.jsonplus import JsonPlusSerializer
# for backwards compat we continue to support loading pickled checkpoints
class JsonPlusSerializerCompat(JsonPlusSerializer):
def loads(self, data: bytes) -> Any:
if data.startswith(b"\x80") and data.endswith(b"."):
return pickle.loads(data)
return super().loads(data)
class SqliteSaver(BaseCheckpointSaver, AbstractContextManager):
serde = JsonPlusSerializerCompat()
conn: sqlite3.Connection
is_setup: bool
def __init__(
self,
conn: sqlite3.Connection,
*,
serde: Optional[SerializerProtocol] = None,
at: Optional[CheckpointAt] = None,
) -> None:
super().__init__(serde=serde, at=at)
self.conn = conn
self.is_setup = False
@classmethod
def from_conn_string(cls, conn_string: str) -> "SqliteSaver":
return SqliteSaver(conn=sqlite3.connect(conn_string))
def __enter__(self) -> Self:
return self
def __exit__(
self,
__exc_type: Optional[type[BaseException]],
__exc_value: Optional[BaseException],
__traceback: Optional[TracebackType],
) -> Optional[bool]:
return self.conn.close()
def setup(self) -> None:
if self.is_setup:
return
self.conn.executescript(
"""
CREATE TABLE IF NOT EXISTS checkpoints (
thread_id TEXT NOT NULL,
thread_ts TEXT NOT NULL,
parent_ts TEXT,
checkpoint BLOB,
PRIMARY KEY (thread_id, thread_ts)
);
"""
)
self.is_setup = True
@contextmanager
def cursor(self, transaction: bool = True):
self.setup()
cur = self.conn.cursor()
try:
yield cur
finally:
if transaction:
self.conn.commit()
cur.close()
def get_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
with self.cursor(transaction=False) as cur:
if config["configurable"].get("thread_ts"):
cur.execute(
"SELECT checkpoint, parent_ts FROM checkpoints WHERE thread_id = ? AND thread_ts = ?",
(
str(config["configurable"]["thread_id"]),
str(config["configurable"]["thread_ts"]),
),
)
if value := cur.fetchone():
return CheckpointTuple(
config,
self.serde.loads(value[0]),
{
"configurable": {
"thread_id": config["configurable"]["thread_id"],
"thread_ts": value[1],
}
}
if value[1]
else None,
)
else:
cur.execute(
"SELECT thread_id, thread_ts, parent_ts, checkpoint FROM checkpoints WHERE thread_id = ? ORDER BY thread_ts DESC LIMIT 1",
(str(config["configurable"]["thread_id"]),),
)
if value := cur.fetchone():
return CheckpointTuple(
{
"configurable": {
"thread_id": value[0],
"thread_ts": value[1],
}
},
self.serde.loads(value[3]),
{
"configurable": {
"thread_id": value[0],
"thread_ts": value[2],
}
}
if value[2]
else None,
)
def list(self, config: RunnableConfig) -> Iterator[CheckpointTuple]:
with self.cursor(transaction=False) as cur:
cur.execute(
"SELECT thread_id, thread_ts, parent_ts, checkpoint FROM checkpoints WHERE thread_id = ? ORDER BY thread_ts DESC",
(str(config["configurable"]["thread_id"]),),
)
for thread_id, thread_ts, parent_ts, value in cur:
yield CheckpointTuple(
{"configurable": {"thread_id": thread_id, "thread_ts": thread_ts}},
self.serde.loads(value),
{
"configurable": {
"thread_id": thread_id,
"thread_ts": parent_ts,
}
}
if parent_ts
else None,
)
def put(self, config: RunnableConfig, checkpoint: Checkpoint) -> RunnableConfig:
with self.cursor() as cur:
cur.execute(
"INSERT OR REPLACE INTO checkpoints (thread_id, thread_ts, parent_ts, checkpoint) VALUES (?, ?, ?, ?)",
(
str(config["configurable"]["thread_id"]),
checkpoint["ts"],
config["configurable"].get("thread_ts"),
self.serde.dumps(checkpoint),
),
)
return {
"configurable": {
"thread_id": config["configurable"]["thread_id"],
"thread_ts": checkpoint["ts"],
}
}