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.
@@ -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