langchain-rushdb 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- langchain_rushdb/__init__.py +18 -0
- langchain_rushdb/_connection.py +202 -0
- langchain_rushdb/checkpoint.py +449 -0
- langchain_rushdb/filters.py +66 -0
- langchain_rushdb/py.typed +0 -0
- langchain_rushdb/retrievers.py +73 -0
- langchain_rushdb/store.py +525 -0
- langchain_rushdb/vectorstores.py +326 -0
- langchain_rushdb-0.1.0.dist-info/METADATA +133 -0
- langchain_rushdb-0.1.0.dist-info/RECORD +12 -0
- langchain_rushdb-0.1.0.dist-info/WHEEL +4 -0
- langchain_rushdb-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""LangChain and LangGraph integration for RushDB, Cloud or self-hosted."""
|
|
2
|
+
|
|
3
|
+
from ._connection import client_from_env
|
|
4
|
+
from .checkpoint import RushDBSaver
|
|
5
|
+
from .filters import FilterError, translate_filter
|
|
6
|
+
from .retrievers import RushDBHybridRetriever
|
|
7
|
+
from .store import RushDBStore
|
|
8
|
+
from .vectorstores import RushDBVectorStore
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"FilterError",
|
|
12
|
+
"RushDBHybridRetriever",
|
|
13
|
+
"RushDBSaver",
|
|
14
|
+
"RushDBStore",
|
|
15
|
+
"RushDBVectorStore",
|
|
16
|
+
"client_from_env",
|
|
17
|
+
"translate_filter",
|
|
18
|
+
]
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Client construction and the record helpers every component shares."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import math
|
|
9
|
+
import os
|
|
10
|
+
import random
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Iterator, Mapping
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from rushdb import RushDB, RushDBError
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("langchain_rushdb")
|
|
19
|
+
|
|
20
|
+
PAGE_SIZE = 1000
|
|
21
|
+
"""Records fetched per request when reading every match."""
|
|
22
|
+
|
|
23
|
+
FAR_FUTURE = "9999-12-31T23:59:59.000Z"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def client_from_env(env: Mapping[str, str] | None = None) -> RushDB:
|
|
27
|
+
"""Build a RushDB client from the environment.
|
|
28
|
+
|
|
29
|
+
RushDB Cloud: ``RUSHDB_API_KEY`` (and optionally ``RUSHDB_URL``).
|
|
30
|
+
Self-hosted engine: ``RUSHDB_ENGINE_URL`` and ``RUSHDB_ENGINE_TOKEN``
|
|
31
|
+
(and ``RUSHDB_SCOPE`` on a multitenant engine). Setting both is an error.
|
|
32
|
+
"""
|
|
33
|
+
env = os.environ if env is None else env
|
|
34
|
+
api_key = env.get("RUSHDB_API_KEY")
|
|
35
|
+
engine_url = env.get("RUSHDB_ENGINE_URL")
|
|
36
|
+
if api_key and engine_url:
|
|
37
|
+
raise ValueError(
|
|
38
|
+
"Both RUSHDB_API_KEY (RushDB Cloud) and RUSHDB_ENGINE_URL (self-hosted) are set; "
|
|
39
|
+
"set the variables of one mode only."
|
|
40
|
+
)
|
|
41
|
+
if engine_url:
|
|
42
|
+
token = env.get("RUSHDB_ENGINE_TOKEN")
|
|
43
|
+
if not token:
|
|
44
|
+
raise ValueError("RUSHDB_ENGINE_URL is set but RUSHDB_ENGINE_TOKEN is not.")
|
|
45
|
+
return RushDB(
|
|
46
|
+
token,
|
|
47
|
+
direct=True,
|
|
48
|
+
engine_url=engine_url,
|
|
49
|
+
scope=env.get("RUSHDB_SCOPE") or None,
|
|
50
|
+
)
|
|
51
|
+
if api_key:
|
|
52
|
+
return RushDB(api_key, url=env.get("RUSHDB_URL") or None)
|
|
53
|
+
raise ValueError(
|
|
54
|
+
"No RushDB connection configured: set RUSHDB_API_KEY for RushDB Cloud, or "
|
|
55
|
+
"RUSHDB_ENGINE_URL and RUSHDB_ENGINE_TOKEN for a self-hosted engine."
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def is_self_hosted(client: RushDB) -> bool:
|
|
60
|
+
return bool(getattr(client, "direct", False))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def find_all(client: RushDB, search: dict[str, Any]) -> Iterator[dict[str, Any]]:
|
|
64
|
+
"""Yield the data of every record ``search`` matches, page by page."""
|
|
65
|
+
skip = 0
|
|
66
|
+
while True:
|
|
67
|
+
page = client.records.find({**search, "skip": skip, "limit": PAGE_SIZE})
|
|
68
|
+
rows = [record.data for record in page.data]
|
|
69
|
+
yield from rows
|
|
70
|
+
if len(rows) < PAGE_SIZE:
|
|
71
|
+
return
|
|
72
|
+
skip += PAGE_SIZE
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def create_rows(
|
|
76
|
+
client: RushDB,
|
|
77
|
+
label: str,
|
|
78
|
+
rows: list[dict[str, Any]],
|
|
79
|
+
vectors: list[list[dict[str, Any]]] | None = None,
|
|
80
|
+
transaction: Any = None,
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Create ``rows`` in one engine commit, each with its inline ``vectors``.
|
|
83
|
+
|
|
84
|
+
``records.import_json`` rather than ``create_many``: rows may hold flat
|
|
85
|
+
arrays, which the engine stores as array properties.
|
|
86
|
+
"""
|
|
87
|
+
if not rows:
|
|
88
|
+
return
|
|
89
|
+
data = [
|
|
90
|
+
{**row, "$vectors": vectors[i]} if vectors and vectors[i] else row
|
|
91
|
+
for i, row in enumerate(rows)
|
|
92
|
+
]
|
|
93
|
+
client.records.import_json(
|
|
94
|
+
data,
|
|
95
|
+
label=label,
|
|
96
|
+
options={"returnResult": False, "suggestTypes": True},
|
|
97
|
+
transaction=transaction,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
CONFLICT_RETRIES = 10
|
|
102
|
+
CONFLICT_BACKOFF_CAP = 2.0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def transact(
|
|
106
|
+
client: RushDB,
|
|
107
|
+
delete: list[str],
|
|
108
|
+
create: list[tuple[str, list[dict[str, Any]]]],
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Delete records by id and create rows in one transaction.
|
|
111
|
+
|
|
112
|
+
Concurrent transactions can conflict; the engine reports that as
|
|
113
|
+
retryable, so the transaction is replayed with backoff.
|
|
114
|
+
"""
|
|
115
|
+
if not delete and not any(rows for _, rows in create):
|
|
116
|
+
return
|
|
117
|
+
for attempt in range(CONFLICT_RETRIES):
|
|
118
|
+
tx = client.tx.begin()
|
|
119
|
+
committing = False
|
|
120
|
+
try:
|
|
121
|
+
for record in delete:
|
|
122
|
+
client.records.delete_by_id(record, transaction=tx)
|
|
123
|
+
for label, rows in create:
|
|
124
|
+
create_rows(client, label, rows, transaction=tx)
|
|
125
|
+
committing = True
|
|
126
|
+
client.tx.commit(tx)
|
|
127
|
+
return
|
|
128
|
+
except BaseException as exc:
|
|
129
|
+
# A failed commit has already ended the transaction.
|
|
130
|
+
if not committing:
|
|
131
|
+
_rollback(client, tx, exc)
|
|
132
|
+
if (
|
|
133
|
+
not isinstance(exc, RushDBError)
|
|
134
|
+
or "transaction_conflict" not in str(exc)
|
|
135
|
+
or attempt == CONFLICT_RETRIES - 1
|
|
136
|
+
):
|
|
137
|
+
raise
|
|
138
|
+
time.sleep(min(CONFLICT_BACKOFF_CAP, 0.05 * 2**attempt) * (1 + random.random()))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _rollback(client: RushDB, tx: Any, cause: BaseException) -> None:
|
|
142
|
+
try:
|
|
143
|
+
client.tx.rollback(tx)
|
|
144
|
+
except RushDBError as exc:
|
|
145
|
+
logger.warning("rollback after %r also failed: %s", cause, exc)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def delete_ids(client: RushDB, ids: list[str]) -> None:
|
|
149
|
+
if ids:
|
|
150
|
+
client.records.delete_by_id(ids)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def is_scalar(value: Any) -> bool:
|
|
154
|
+
if isinstance(value, bool) or isinstance(value, str):
|
|
155
|
+
return True
|
|
156
|
+
return isinstance(value, (int, float)) and math.isfinite(value)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _kind(value: Any) -> type:
|
|
160
|
+
return bool if isinstance(value, bool) else str if isinstance(value, str) else float
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def mirror(prefix: str, obj: Mapping[str, Any]) -> dict[str, Any]:
|
|
164
|
+
"""The filterable properties of ``obj``: top-level scalars and flat arrays."""
|
|
165
|
+
out: dict[str, Any] = {}
|
|
166
|
+
for key, value in obj.items():
|
|
167
|
+
if is_scalar(value):
|
|
168
|
+
out[f"{prefix}.{key}"] = value
|
|
169
|
+
elif (
|
|
170
|
+
isinstance(value, list)
|
|
171
|
+
and value
|
|
172
|
+
and all(is_scalar(v) for v in value)
|
|
173
|
+
and len({_kind(v) for v in value}) == 1
|
|
174
|
+
):
|
|
175
|
+
out[f"{prefix}.{key}"] = list(value)
|
|
176
|
+
return out
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def to_json(value: Any) -> str:
|
|
180
|
+
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), default=str)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def b64(data: bytes) -> str:
|
|
184
|
+
return base64.b64encode(data).decode("ascii")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def unb64(data: str) -> bytes:
|
|
188
|
+
return base64.b64decode(data)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def iso(dt: datetime) -> str:
|
|
192
|
+
return dt.astimezone(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def parse_iso(value: str) -> datetime:
|
|
196
|
+
"""Parse an engine datetime; the engine may echo midnight as a bare date."""
|
|
197
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
198
|
+
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def now_iso() -> str:
|
|
202
|
+
return iso(datetime.now(timezone.utc))
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
"""RushDB-backed LangGraph checkpointer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import builtins
|
|
7
|
+
from collections.abc import AsyncIterator, Iterator, Sequence
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from langchain_core.runnables import RunnableConfig
|
|
11
|
+
from langgraph.checkpoint.base import (
|
|
12
|
+
WRITES_IDX_MAP,
|
|
13
|
+
BaseCheckpointSaver,
|
|
14
|
+
ChannelVersions,
|
|
15
|
+
Checkpoint,
|
|
16
|
+
CheckpointMetadata,
|
|
17
|
+
CheckpointTuple,
|
|
18
|
+
get_checkpoint_id,
|
|
19
|
+
get_checkpoint_metadata,
|
|
20
|
+
writes_sort_key,
|
|
21
|
+
)
|
|
22
|
+
from langgraph.checkpoint.serde.base import SerializerProtocol
|
|
23
|
+
from rushdb import RushDB
|
|
24
|
+
|
|
25
|
+
from ._connection import b64, client_from_env, delete_ids, find_all, mirror, transact, unb64
|
|
26
|
+
|
|
27
|
+
DEFAULT_LABEL = "LG_CHECKPOINT"
|
|
28
|
+
METADATA_PREFIX = "metadata"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RushDBSaver(BaseCheckpointSaver[str]):
|
|
32
|
+
"""LangGraph checkpointer on RushDB, Cloud or self-hosted alike.
|
|
33
|
+
|
|
34
|
+
Checkpoints, channel values and pending writes are records of
|
|
35
|
+
``<label>``, ``<label>_BLOB`` and ``<label>_WRITE`` (see the
|
|
36
|
+
repository's ``spec/README.md``); serialized bytes are stored base64-encoded.
|
|
37
|
+
Each ``put`` and ``put_writes`` is one RushDB transaction.
|
|
38
|
+
|
|
39
|
+
The async methods run the sync ones in a worker thread.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
client: A ``rushdb.RushDB`` client; built from the environment when omitted.
|
|
43
|
+
serde: The serializer; LangGraph's default when omitted.
|
|
44
|
+
label: Label of checkpoint records; channel values and pending writes
|
|
45
|
+
use ``<label>_BLOB`` and ``<label>_WRITE``. Separate labels keep
|
|
46
|
+
separate applications in one RushDB project apart.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
*,
|
|
52
|
+
client: RushDB | None = None,
|
|
53
|
+
serde: SerializerProtocol | None = None,
|
|
54
|
+
label: str = DEFAULT_LABEL,
|
|
55
|
+
) -> None:
|
|
56
|
+
super().__init__(serde=serde)
|
|
57
|
+
self.client = client if client is not None else client_from_env()
|
|
58
|
+
self.label = label
|
|
59
|
+
self.blob_label = f"{label}_BLOB"
|
|
60
|
+
self.write_label = f"{label}_WRITE"
|
|
61
|
+
|
|
62
|
+
# ── reads ──────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
def get_tuple(self, config: RunnableConfig) -> CheckpointTuple | None:
|
|
65
|
+
configurable = config["configurable"]
|
|
66
|
+
thread_id = str(configurable["thread_id"])
|
|
67
|
+
checkpoint_ns = configurable.get("checkpoint_ns", "")
|
|
68
|
+
where: dict[str, Any] = {"thread_id": thread_id, "checkpoint_ns": checkpoint_ns}
|
|
69
|
+
if checkpoint_id := get_checkpoint_id(config):
|
|
70
|
+
where["checkpoint_id"] = checkpoint_id
|
|
71
|
+
page = self.client.records.find(
|
|
72
|
+
{
|
|
73
|
+
"labels": [self.label],
|
|
74
|
+
"where": where,
|
|
75
|
+
"orderBy": {"checkpoint_id": "desc"},
|
|
76
|
+
"limit": 1,
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
if not page.data:
|
|
80
|
+
return None
|
|
81
|
+
return self._tuple(page.data[0].data)
|
|
82
|
+
|
|
83
|
+
def list(
|
|
84
|
+
self,
|
|
85
|
+
config: RunnableConfig | None,
|
|
86
|
+
*,
|
|
87
|
+
filter: dict[str, Any] | None = None,
|
|
88
|
+
before: RunnableConfig | None = None,
|
|
89
|
+
limit: int | None = None,
|
|
90
|
+
) -> Iterator[CheckpointTuple]:
|
|
91
|
+
where: dict[str, Any] = {}
|
|
92
|
+
if config is not None:
|
|
93
|
+
configurable = config["configurable"]
|
|
94
|
+
if "thread_id" in configurable:
|
|
95
|
+
where["thread_id"] = str(configurable["thread_id"])
|
|
96
|
+
if configurable.get("checkpoint_ns") is not None:
|
|
97
|
+
where["checkpoint_ns"] = configurable["checkpoint_ns"]
|
|
98
|
+
if checkpoint_id := get_checkpoint_id(config):
|
|
99
|
+
where["checkpoint_id"] = checkpoint_id
|
|
100
|
+
# The engine compares numbers and datetimes only: `before` (a string
|
|
101
|
+
# id) is applied here, on rows already ordered by id, newest first.
|
|
102
|
+
before_id = get_checkpoint_id(before) if before is not None else None
|
|
103
|
+
pushed, exact = _metadata_pushdown(filter)
|
|
104
|
+
exact = exact and before_id is None
|
|
105
|
+
where.update(pushed)
|
|
106
|
+
search: dict[str, Any] = {
|
|
107
|
+
"labels": [self.label],
|
|
108
|
+
"orderBy": {"checkpoint_id": "desc"},
|
|
109
|
+
}
|
|
110
|
+
if where:
|
|
111
|
+
search["where"] = where
|
|
112
|
+
if exact and limit is not None:
|
|
113
|
+
if limit <= 0:
|
|
114
|
+
return
|
|
115
|
+
rows: Iterator[dict[str, Any]] = (
|
|
116
|
+
r.data for r in self.client.records.find({**search, "limit": limit}).data
|
|
117
|
+
)
|
|
118
|
+
else:
|
|
119
|
+
rows = find_all(self.client, search)
|
|
120
|
+
count = 0
|
|
121
|
+
for row in rows:
|
|
122
|
+
if limit is not None and count >= limit:
|
|
123
|
+
return
|
|
124
|
+
if before_id is not None and row["checkpoint_id"] >= before_id:
|
|
125
|
+
continue
|
|
126
|
+
checkpoint_tuple = self._tuple(row)
|
|
127
|
+
if filter and not all(checkpoint_tuple.metadata.get(k) == v for k, v in filter.items()):
|
|
128
|
+
continue
|
|
129
|
+
count += 1
|
|
130
|
+
yield checkpoint_tuple
|
|
131
|
+
|
|
132
|
+
def _tuple(self, row: dict[str, Any]) -> CheckpointTuple:
|
|
133
|
+
thread_id = row["thread_id"]
|
|
134
|
+
checkpoint_ns = row["checkpoint_ns"]
|
|
135
|
+
checkpoint_id = row["checkpoint_id"]
|
|
136
|
+
checkpoint: Checkpoint = self.serde.loads_typed(
|
|
137
|
+
(row["checkpoint_type"], unb64(row["checkpoint"]))
|
|
138
|
+
)
|
|
139
|
+
metadata: CheckpointMetadata = self.serde.loads_typed(
|
|
140
|
+
(row["metadata_type"], unb64(row["metadata"]))
|
|
141
|
+
)
|
|
142
|
+
parent = row.get("parent_checkpoint_id")
|
|
143
|
+
return CheckpointTuple(
|
|
144
|
+
config={
|
|
145
|
+
"configurable": {
|
|
146
|
+
"thread_id": thread_id,
|
|
147
|
+
"checkpoint_ns": checkpoint_ns,
|
|
148
|
+
"checkpoint_id": checkpoint_id,
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
checkpoint={
|
|
152
|
+
**checkpoint,
|
|
153
|
+
"channel_values": self._load_blobs(
|
|
154
|
+
thread_id, checkpoint_ns, checkpoint["channel_versions"]
|
|
155
|
+
),
|
|
156
|
+
},
|
|
157
|
+
metadata=metadata,
|
|
158
|
+
parent_config=(
|
|
159
|
+
{
|
|
160
|
+
"configurable": {
|
|
161
|
+
"thread_id": thread_id,
|
|
162
|
+
"checkpoint_ns": checkpoint_ns,
|
|
163
|
+
"checkpoint_id": parent,
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if parent
|
|
167
|
+
else None
|
|
168
|
+
),
|
|
169
|
+
pending_writes=self._load_writes(thread_id, checkpoint_ns, checkpoint_id),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def _load_blobs(
|
|
173
|
+
self, thread_id: str, checkpoint_ns: str, versions: ChannelVersions
|
|
174
|
+
) -> dict[str, Any]:
|
|
175
|
+
if not versions:
|
|
176
|
+
return {}
|
|
177
|
+
wanted = {(channel, str(version)) for channel, version in versions.items()}
|
|
178
|
+
rows = find_all(
|
|
179
|
+
self.client,
|
|
180
|
+
{
|
|
181
|
+
"labels": [self.blob_label],
|
|
182
|
+
"where": {
|
|
183
|
+
"thread_id": thread_id,
|
|
184
|
+
"checkpoint_ns": checkpoint_ns,
|
|
185
|
+
"channel": {"$in": sorted(versions)},
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
)
|
|
189
|
+
values: dict[str, Any] = {}
|
|
190
|
+
for row in rows:
|
|
191
|
+
if (row["channel"], row["version"]) not in wanted or row["blob_type"] == "empty":
|
|
192
|
+
continue
|
|
193
|
+
values[row["channel"]] = self.serde.loads_typed(
|
|
194
|
+
(row["blob_type"], unb64(row.get("blob", "")))
|
|
195
|
+
)
|
|
196
|
+
return values
|
|
197
|
+
|
|
198
|
+
def _load_writes(
|
|
199
|
+
self, thread_id: str, checkpoint_ns: str, checkpoint_id: str
|
|
200
|
+
) -> builtins.list[tuple[str, str, Any]]:
|
|
201
|
+
rows = list(
|
|
202
|
+
find_all(
|
|
203
|
+
self.client,
|
|
204
|
+
{
|
|
205
|
+
"labels": [self.write_label],
|
|
206
|
+
"where": {
|
|
207
|
+
"thread_id": thread_id,
|
|
208
|
+
"checkpoint_ns": checkpoint_ns,
|
|
209
|
+
"checkpoint_id": checkpoint_id,
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
rows.sort(key=lambda r: writes_sort_key(r["task_path"], r["task_id"], int(r["idx"])))
|
|
215
|
+
return [
|
|
216
|
+
(
|
|
217
|
+
r["task_id"],
|
|
218
|
+
r["channel"],
|
|
219
|
+
self.serde.loads_typed((r["blob_type"], unb64(r.get("blob", "")))),
|
|
220
|
+
)
|
|
221
|
+
for r in rows
|
|
222
|
+
]
|
|
223
|
+
|
|
224
|
+
# ── writes ─────────────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
def put(
|
|
227
|
+
self,
|
|
228
|
+
config: RunnableConfig,
|
|
229
|
+
checkpoint: Checkpoint,
|
|
230
|
+
metadata: CheckpointMetadata,
|
|
231
|
+
new_versions: ChannelVersions,
|
|
232
|
+
) -> RunnableConfig:
|
|
233
|
+
configurable = config["configurable"]
|
|
234
|
+
thread_id = str(configurable["thread_id"])
|
|
235
|
+
checkpoint_ns = configurable.get("checkpoint_ns", "")
|
|
236
|
+
parent_id = configurable.get("checkpoint_id")
|
|
237
|
+
stored = checkpoint.copy()
|
|
238
|
+
values: dict[str, Any] = stored.pop("channel_values") # type: ignore[misc]
|
|
239
|
+
|
|
240
|
+
present = (
|
|
241
|
+
{
|
|
242
|
+
(r["channel"], r["version"])
|
|
243
|
+
for r in find_all(
|
|
244
|
+
self.client,
|
|
245
|
+
{
|
|
246
|
+
"labels": [self.blob_label],
|
|
247
|
+
"where": {
|
|
248
|
+
"thread_id": thread_id,
|
|
249
|
+
"checkpoint_ns": checkpoint_ns,
|
|
250
|
+
"channel": {"$in": sorted(new_versions)},
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
)
|
|
254
|
+
}
|
|
255
|
+
if new_versions
|
|
256
|
+
else set()
|
|
257
|
+
)
|
|
258
|
+
blobs = []
|
|
259
|
+
for channel, version in new_versions.items():
|
|
260
|
+
if (channel, str(version)) in present:
|
|
261
|
+
continue
|
|
262
|
+
row: dict[str, Any] = {
|
|
263
|
+
"thread_id": thread_id,
|
|
264
|
+
"checkpoint_ns": checkpoint_ns,
|
|
265
|
+
"channel": channel,
|
|
266
|
+
"version": str(version),
|
|
267
|
+
}
|
|
268
|
+
if channel in values:
|
|
269
|
+
blob_type, data = self.serde.dumps_typed(values[channel])
|
|
270
|
+
row.update(blob_type=blob_type, blob=b64(data))
|
|
271
|
+
else:
|
|
272
|
+
row["blob_type"] = "empty"
|
|
273
|
+
blobs.append(row)
|
|
274
|
+
|
|
275
|
+
full_metadata = get_checkpoint_metadata(config, metadata)
|
|
276
|
+
checkpoint_type, checkpoint_data = self.serde.dumps_typed(stored)
|
|
277
|
+
metadata_type, metadata_data = self.serde.dumps_typed(full_metadata)
|
|
278
|
+
record: dict[str, Any] = {
|
|
279
|
+
"thread_id": thread_id,
|
|
280
|
+
"checkpoint_ns": checkpoint_ns,
|
|
281
|
+
"checkpoint_id": checkpoint["id"],
|
|
282
|
+
"checkpoint_type": checkpoint_type,
|
|
283
|
+
"checkpoint": b64(checkpoint_data),
|
|
284
|
+
"metadata_type": metadata_type,
|
|
285
|
+
"metadata": b64(metadata_data),
|
|
286
|
+
**mirror(METADATA_PREFIX, full_metadata),
|
|
287
|
+
}
|
|
288
|
+
if parent_id:
|
|
289
|
+
record["parent_checkpoint_id"] = parent_id
|
|
290
|
+
replaced = [
|
|
291
|
+
r["__id"]
|
|
292
|
+
for r in find_all(
|
|
293
|
+
self.client,
|
|
294
|
+
{
|
|
295
|
+
"labels": [self.label],
|
|
296
|
+
"where": {
|
|
297
|
+
"thread_id": thread_id,
|
|
298
|
+
"checkpoint_ns": checkpoint_ns,
|
|
299
|
+
"checkpoint_id": checkpoint["id"],
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
)
|
|
303
|
+
]
|
|
304
|
+
transact(self.client, replaced, [(self.blob_label, blobs), (self.label, [record])])
|
|
305
|
+
return {
|
|
306
|
+
"configurable": {
|
|
307
|
+
"thread_id": thread_id,
|
|
308
|
+
"checkpoint_ns": checkpoint_ns,
|
|
309
|
+
"checkpoint_id": checkpoint["id"],
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
def put_writes(
|
|
314
|
+
self,
|
|
315
|
+
config: RunnableConfig,
|
|
316
|
+
writes: Sequence[tuple[str, Any]],
|
|
317
|
+
task_id: str,
|
|
318
|
+
task_path: str = "",
|
|
319
|
+
) -> None:
|
|
320
|
+
configurable = config["configurable"]
|
|
321
|
+
thread_id = str(configurable["thread_id"])
|
|
322
|
+
checkpoint_ns = configurable.get("checkpoint_ns", "")
|
|
323
|
+
checkpoint_id = configurable["checkpoint_id"]
|
|
324
|
+
existing = {
|
|
325
|
+
int(r["idx"]): r["__id"]
|
|
326
|
+
for r in find_all(
|
|
327
|
+
self.client,
|
|
328
|
+
{
|
|
329
|
+
"labels": [self.write_label],
|
|
330
|
+
"where": {
|
|
331
|
+
"thread_id": thread_id,
|
|
332
|
+
"checkpoint_ns": checkpoint_ns,
|
|
333
|
+
"checkpoint_id": checkpoint_id,
|
|
334
|
+
"task_id": task_id,
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
replaced: list[str] = []
|
|
340
|
+
rows: list[dict[str, Any]] = []
|
|
341
|
+
for position, (channel, value) in enumerate(writes):
|
|
342
|
+
idx = WRITES_IDX_MAP.get(channel, position)
|
|
343
|
+
if idx in existing:
|
|
344
|
+
if idx >= 0:
|
|
345
|
+
continue
|
|
346
|
+
replaced.append(existing[idx])
|
|
347
|
+
blob_type, data = self.serde.dumps_typed(value)
|
|
348
|
+
rows.append(
|
|
349
|
+
{
|
|
350
|
+
"thread_id": thread_id,
|
|
351
|
+
"checkpoint_ns": checkpoint_ns,
|
|
352
|
+
"checkpoint_id": checkpoint_id,
|
|
353
|
+
"task_id": task_id,
|
|
354
|
+
"task_path": task_path,
|
|
355
|
+
"idx": idx,
|
|
356
|
+
"channel": channel,
|
|
357
|
+
"blob_type": blob_type,
|
|
358
|
+
"blob": b64(data),
|
|
359
|
+
}
|
|
360
|
+
)
|
|
361
|
+
transact(self.client, replaced, [(self.write_label, rows)])
|
|
362
|
+
|
|
363
|
+
def delete_thread(self, thread_id: str) -> None:
|
|
364
|
+
for label in (self.label, self.blob_label, self.write_label):
|
|
365
|
+
self.client.records.delete({"labels": [label], "where": {"thread_id": str(thread_id)}})
|
|
366
|
+
|
|
367
|
+
def delete_for_runs(self, run_ids: Sequence[str]) -> None:
|
|
368
|
+
"""Delete the checkpoints whose metadata ``run_id`` is one of ``run_ids``,
|
|
369
|
+
with their pending writes."""
|
|
370
|
+
if not run_ids:
|
|
371
|
+
return
|
|
372
|
+
rows = list(
|
|
373
|
+
find_all(
|
|
374
|
+
self.client,
|
|
375
|
+
{
|
|
376
|
+
"labels": [self.label],
|
|
377
|
+
"where": {f"{METADATA_PREFIX}.run_id": {"$in": [str(r) for r in run_ids]}},
|
|
378
|
+
},
|
|
379
|
+
)
|
|
380
|
+
)
|
|
381
|
+
for row in rows:
|
|
382
|
+
self.client.records.delete(
|
|
383
|
+
{
|
|
384
|
+
"labels": [self.write_label],
|
|
385
|
+
"where": {
|
|
386
|
+
"thread_id": row["thread_id"],
|
|
387
|
+
"checkpoint_ns": row["checkpoint_ns"],
|
|
388
|
+
"checkpoint_id": row["checkpoint_id"],
|
|
389
|
+
},
|
|
390
|
+
}
|
|
391
|
+
)
|
|
392
|
+
delete_ids(self.client, [r["__id"] for r in rows])
|
|
393
|
+
|
|
394
|
+
# ── async (worker thread) ──────────────────────────────────────────────
|
|
395
|
+
|
|
396
|
+
async def aget_tuple(self, config: RunnableConfig) -> CheckpointTuple | None:
|
|
397
|
+
return await asyncio.to_thread(self.get_tuple, config)
|
|
398
|
+
|
|
399
|
+
async def alist(
|
|
400
|
+
self,
|
|
401
|
+
config: RunnableConfig | None,
|
|
402
|
+
*,
|
|
403
|
+
filter: dict[str, Any] | None = None,
|
|
404
|
+
before: RunnableConfig | None = None,
|
|
405
|
+
limit: int | None = None,
|
|
406
|
+
) -> AsyncIterator[CheckpointTuple]:
|
|
407
|
+
items = await asyncio.to_thread(
|
|
408
|
+
lambda: list(self.list(config, filter=filter, before=before, limit=limit))
|
|
409
|
+
)
|
|
410
|
+
for item in items:
|
|
411
|
+
yield item
|
|
412
|
+
|
|
413
|
+
async def aput(
|
|
414
|
+
self,
|
|
415
|
+
config: RunnableConfig,
|
|
416
|
+
checkpoint: Checkpoint,
|
|
417
|
+
metadata: CheckpointMetadata,
|
|
418
|
+
new_versions: ChannelVersions,
|
|
419
|
+
) -> RunnableConfig:
|
|
420
|
+
return await asyncio.to_thread(self.put, config, checkpoint, metadata, new_versions)
|
|
421
|
+
|
|
422
|
+
async def aput_writes(
|
|
423
|
+
self,
|
|
424
|
+
config: RunnableConfig,
|
|
425
|
+
writes: Sequence[tuple[str, Any]],
|
|
426
|
+
task_id: str,
|
|
427
|
+
task_path: str = "",
|
|
428
|
+
) -> None:
|
|
429
|
+
await asyncio.to_thread(self.put_writes, config, writes, task_id, task_path)
|
|
430
|
+
|
|
431
|
+
async def adelete_thread(self, thread_id: str) -> None:
|
|
432
|
+
await asyncio.to_thread(self.delete_thread, thread_id)
|
|
433
|
+
|
|
434
|
+
async def adelete_for_runs(self, run_ids: Sequence[str]) -> None:
|
|
435
|
+
await asyncio.to_thread(self.delete_for_runs, run_ids)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _metadata_pushdown(filter: dict[str, Any] | None) -> tuple[dict[str, Any], bool]:
|
|
439
|
+
"""Equality on scalar metadata runs in the engine; the rest in the client."""
|
|
440
|
+
if not filter:
|
|
441
|
+
return {}, True
|
|
442
|
+
pushed: dict[str, Any] = {}
|
|
443
|
+
exact = True
|
|
444
|
+
for key, value in filter.items():
|
|
445
|
+
if isinstance(value, (str, bool, int, float)):
|
|
446
|
+
pushed[f"{METADATA_PREFIX}.{key}"] = value
|
|
447
|
+
else:
|
|
448
|
+
exact = False
|
|
449
|
+
return pushed, exact
|