langgraph-runtime-inmem 0.37.0.dev2__tar.gz → 0.37.0.dev4__tar.gz
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.
- {langgraph_runtime_inmem-0.37.0.dev2 → langgraph_runtime_inmem-0.37.0.dev4}/Makefile +1 -1
- {langgraph_runtime_inmem-0.37.0.dev2 → langgraph_runtime_inmem-0.37.0.dev4}/PKG-INFO +1 -2
- {langgraph_runtime_inmem-0.37.0.dev2 → langgraph_runtime_inmem-0.37.0.dev4}/README.md +0 -1
- {langgraph_runtime_inmem-0.37.0.dev2 → langgraph_runtime_inmem-0.37.0.dev4}/langgraph_runtime_inmem/__init__.py +1 -1
- {langgraph_runtime_inmem-0.37.0.dev2 → langgraph_runtime_inmem-0.37.0.dev4}/langgraph_runtime_inmem/checkpoint.py +0 -47
- {langgraph_runtime_inmem-0.37.0.dev2 → langgraph_runtime_inmem-0.37.0.dev4}/langgraph_runtime_inmem/database.py +48 -2
- {langgraph_runtime_inmem-0.37.0.dev2 → langgraph_runtime_inmem-0.37.0.dev4}/langgraph_runtime_inmem/ops.py +230 -121
- {langgraph_runtime_inmem-0.37.0.dev2 → langgraph_runtime_inmem-0.37.0.dev4}/uv.lock +3 -3
- {langgraph_runtime_inmem-0.37.0.dev2 → langgraph_runtime_inmem-0.37.0.dev4}/.gitignore +0 -0
- {langgraph_runtime_inmem-0.37.0.dev2 → langgraph_runtime_inmem-0.37.0.dev4}/langgraph_runtime_inmem/_persistence.py +0 -0
- {langgraph_runtime_inmem-0.37.0.dev2 → langgraph_runtime_inmem-0.37.0.dev4}/langgraph_runtime_inmem/encryption.py +0 -0
- {langgraph_runtime_inmem-0.37.0.dev2 → langgraph_runtime_inmem-0.37.0.dev4}/langgraph_runtime_inmem/inmem_stream.py +0 -0
- {langgraph_runtime_inmem-0.37.0.dev2 → langgraph_runtime_inmem-0.37.0.dev4}/langgraph_runtime_inmem/lifespan.py +0 -0
- {langgraph_runtime_inmem-0.37.0.dev2 → langgraph_runtime_inmem-0.37.0.dev4}/langgraph_runtime_inmem/metrics.py +0 -0
- {langgraph_runtime_inmem-0.37.0.dev2 → langgraph_runtime_inmem-0.37.0.dev4}/langgraph_runtime_inmem/queue.py +0 -0
- {langgraph_runtime_inmem-0.37.0.dev2 → langgraph_runtime_inmem-0.37.0.dev4}/langgraph_runtime_inmem/retry.py +0 -0
- {langgraph_runtime_inmem-0.37.0.dev2 → langgraph_runtime_inmem-0.37.0.dev4}/langgraph_runtime_inmem/routes.py +0 -0
- {langgraph_runtime_inmem-0.37.0.dev2 → langgraph_runtime_inmem-0.37.0.dev4}/langgraph_runtime_inmem/store.py +0 -0
- {langgraph_runtime_inmem-0.37.0.dev2 → langgraph_runtime_inmem-0.37.0.dev4}/pyproject.toml +0 -0
|
@@ -16,4 +16,4 @@ bump-version:
|
|
|
16
16
|
uv run --with hatch hatch version $(VERSION_KIND)
|
|
17
17
|
|
|
18
18
|
check-base-imports:
|
|
19
|
-
DATABASE_URI="foo" REDIS_URI="bar" uv run python -c "from langgraph_runtime_inmem import *"
|
|
19
|
+
DATABASE_URI="foo" REDIS_URI="bar" uv run python -c "from langgraph_runtime_inmem import *"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: langgraph-runtime-inmem
|
|
3
|
-
Version: 0.37.0.
|
|
3
|
+
Version: 0.37.0.dev4
|
|
4
4
|
Summary: Inmem implementation for the LangGraph API server.
|
|
5
5
|
Author-email: Will Fu-Hinthorn <will@langchain.dev>
|
|
6
6
|
License: Elastic-2.0
|
|
@@ -17,4 +17,3 @@ Description-Content-Type: text/markdown
|
|
|
17
17
|
# LangGraph Runtime Inmem
|
|
18
18
|
|
|
19
19
|
This is the inmem implementation of the LangGraph Runtime API.
|
|
20
|
-
|
|
@@ -308,50 +308,3 @@ def Checkpointer(*args, unpack_hook=None, **kwargs):
|
|
|
308
308
|
|
|
309
309
|
|
|
310
310
|
__all__ = ["Checkpointer"]
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
def _nbytes(value: Any) -> int:
|
|
314
|
-
if isinstance(value, bytes | bytearray | memoryview):
|
|
315
|
-
return len(value)
|
|
316
|
-
if isinstance(value, tuple | list):
|
|
317
|
-
return sum(_nbytes(item) for item in value)
|
|
318
|
-
return 0
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
async def thread_storage(conn: Any, thread_id: str) -> dict[str, Any]:
|
|
322
|
-
"""Bytes a thread's checkpoints occupy in memory, per table and per channel.
|
|
323
|
-
|
|
324
|
-
Same shape as the postgres runtime's report, so one endpoint serves both.
|
|
325
|
-
"""
|
|
326
|
-
saver = Checkpointer()
|
|
327
|
-
by_channel: dict[str, dict[str, int]] = {}
|
|
328
|
-
for (tid, _ns, channel, _version), blob in list(saver.blobs.items()):
|
|
329
|
-
if tid != thread_id:
|
|
330
|
-
continue
|
|
331
|
-
entry = by_channel.setdefault(channel, {"versions": 0, "bytes": 0})
|
|
332
|
-
entry["versions"] += 1
|
|
333
|
-
entry["bytes"] += _nbytes(blob)
|
|
334
|
-
checkpoint_count = checkpoint_bytes = 0
|
|
335
|
-
if thread_id in saver.storage:
|
|
336
|
-
for checkpoints in saver.storage[thread_id].values():
|
|
337
|
-
for stored in checkpoints.values():
|
|
338
|
-
checkpoint_count += 1
|
|
339
|
-
checkpoint_bytes += _nbytes(stored)
|
|
340
|
-
write_count = write_bytes = 0
|
|
341
|
-
for (tid, _ns, _checkpoint_id), writes in list(saver.writes.items()):
|
|
342
|
-
if tid != thread_id:
|
|
343
|
-
continue
|
|
344
|
-
for write in writes.values():
|
|
345
|
-
write_count += 1
|
|
346
|
-
write_bytes += _nbytes(write)
|
|
347
|
-
channel_bytes = sum(entry["bytes"] for entry in by_channel.values())
|
|
348
|
-
return {
|
|
349
|
-
"total_bytes": checkpoint_bytes + channel_bytes + write_bytes,
|
|
350
|
-
"checkpoints": {"count": checkpoint_count, "bytes": checkpoint_bytes},
|
|
351
|
-
"channel_values": {
|
|
352
|
-
"count": sum(entry["versions"] for entry in by_channel.values()),
|
|
353
|
-
"bytes": channel_bytes,
|
|
354
|
-
"by_channel": by_channel,
|
|
355
|
-
},
|
|
356
|
-
"pending_writes": {"count": write_count, "bytes": write_bytes},
|
|
357
|
-
}
|
|
@@ -97,6 +97,8 @@ class GlobalStore(PersistentDict):
|
|
|
97
97
|
]
|
|
98
98
|
self["assistant_versions"] = []
|
|
99
99
|
self["crons"] = []
|
|
100
|
+
self["thread_has_next"] = {}
|
|
101
|
+
self["thread_failed"] = {}
|
|
100
102
|
|
|
101
103
|
|
|
102
104
|
OPS_FILENAME = os.path.join(".langgraph_api", ".langgraph_ops.pckl")
|
|
@@ -158,7 +160,10 @@ async def connect(
|
|
|
158
160
|
|
|
159
161
|
|
|
160
162
|
def _load_ops_file() -> None:
|
|
161
|
-
"""Load the saved ops file into ``GLOBAL_STORE``, setting it aside if unloadable.
|
|
163
|
+
"""Load the saved ops file into ``GLOBAL_STORE``, setting it aside if unloadable.
|
|
164
|
+
|
|
165
|
+
Runs the previous process left ``running`` are reset to ``pending`` to be retried.
|
|
166
|
+
"""
|
|
162
167
|
if not os.path.exists(OPS_FILENAME):
|
|
163
168
|
return
|
|
164
169
|
|
|
@@ -175,6 +180,44 @@ def _load_ops_file() -> None:
|
|
|
175
180
|
return
|
|
176
181
|
GLOBAL_STORE.update(data)
|
|
177
182
|
|
|
183
|
+
# No worker has started yet, so a run saved as running was cut off by a crash.
|
|
184
|
+
# Left running, it blocks every later run on its thread (Runs.next skips the thread).
|
|
185
|
+
orphaned = [r for r in GLOBAL_STORE.get("runs") or [] if r["status"] == "running"]
|
|
186
|
+
now = datetime.now(UTC)
|
|
187
|
+
for run in orphaned:
|
|
188
|
+
run["status"] = "pending"
|
|
189
|
+
run["updated_at"] = now
|
|
190
|
+
if orphaned:
|
|
191
|
+
logger.warning(
|
|
192
|
+
"Retrying runs left running by a previous process that did not shut down cleanly",
|
|
193
|
+
run_ids=[str(r["run_id"]) for r in orphaned],
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def upgrade_thread_status_facts(store: dict[str, Any]) -> None:
|
|
198
|
+
"""Bring threads from ops files saved by older versions in line with how
|
|
199
|
+
status is derived now, as migration 000066 does for postgres.
|
|
200
|
+
|
|
201
|
+
Interrupted threads are recorded as having next steps; other threads count as
|
|
202
|
+
having none until their next snapshot. Threads whose status was `error` are
|
|
203
|
+
recorded as having failed. Cancels, which older versions stored in the thread's
|
|
204
|
+
error, are cleared when the error reads as exactly one; anything else, encrypted
|
|
205
|
+
errors included, stays.
|
|
206
|
+
"""
|
|
207
|
+
has_next = store.setdefault("thread_has_next", {})
|
|
208
|
+
failed = store.setdefault("thread_failed", {})
|
|
209
|
+
for thread in store["threads"]:
|
|
210
|
+
if thread.get("status") == "interrupted":
|
|
211
|
+
has_next.setdefault(thread["thread_id"], True)
|
|
212
|
+
failed.setdefault(thread["thread_id"], thread.get("status") == "error")
|
|
213
|
+
error = thread.get("error")
|
|
214
|
+
if (
|
|
215
|
+
isinstance(error, dict)
|
|
216
|
+
and "__encryption_context__" not in error
|
|
217
|
+
and error.get("error") in ("UserInterrupt", "UserRollback")
|
|
218
|
+
):
|
|
219
|
+
thread["error"] = None
|
|
220
|
+
|
|
178
221
|
|
|
179
222
|
def _set_aside_ops_file(reason: Exception) -> None:
|
|
180
223
|
"""Rename an unloadable ops file to ``<file>.bak-<UTC timestamp>`` and log why."""
|
|
@@ -262,6 +305,7 @@ async def start_pool() -> None:
|
|
|
262
305
|
for k in ["runs", "threads", "assistant_versions", "assistants"]:
|
|
263
306
|
if not GLOBAL_STORE.get(k):
|
|
264
307
|
GLOBAL_STORE[k] = []
|
|
308
|
+
upgrade_thread_status_facts(GLOBAL_STORE)
|
|
265
309
|
# Remove agents that were created by the system to avoid removed graphs
|
|
266
310
|
if GLOBAL_STORE.get("assistants"):
|
|
267
311
|
for a in GLOBAL_STORE["assistants"]:
|
|
@@ -272,8 +316,10 @@ async def start_pool() -> None:
|
|
|
272
316
|
for k in ["crons"]:
|
|
273
317
|
if not GLOBAL_STORE.get(k):
|
|
274
318
|
GLOBAL_STORE[k] = []
|
|
275
|
-
|
|
319
|
+
# Flushed in this order: a crash between the two writes then over-counts an
|
|
320
|
+
# attempt rather than letting a retried run skip one.
|
|
276
321
|
register_persistent_dict(GLOBAL_RETRY_COUNTER._counters)
|
|
322
|
+
register_persistent_dict(GLOBAL_STORE)
|
|
277
323
|
await start_stream()
|
|
278
324
|
|
|
279
325
|
|
|
@@ -93,6 +93,120 @@ def _using_encryption() -> bool:
|
|
|
93
93
|
return using_encryption()
|
|
94
94
|
|
|
95
95
|
|
|
96
|
+
def _thread_has_next(conn: InMemConnectionProto) -> dict[UUID, bool]:
|
|
97
|
+
"""Whether each thread's graph has next steps, as of its latest snapshot.
|
|
98
|
+
|
|
99
|
+
Kept beside the threads rather than on them, because thread dicts are returned
|
|
100
|
+
to callers as they are and this is internal. A missing entry means unknown.
|
|
101
|
+
"""
|
|
102
|
+
return conn.store.setdefault("thread_has_next", {})
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _thread_failed(conn: InMemConnectionProto) -> dict[UUID, bool]:
|
|
106
|
+
"""Whether each thread's last reported run failed.
|
|
107
|
+
|
|
108
|
+
Kept beside the threads for the same reason as ``_thread_has_next``: it is
|
|
109
|
+
internal, and a missing entry means unknown.
|
|
110
|
+
"""
|
|
111
|
+
return conn.store.setdefault("thread_failed", {})
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _thread_failure(exception: BaseException | None) -> BaseException | None:
|
|
115
|
+
"""The exception if the run really failed, and None for a user cancel or rollback.
|
|
116
|
+
|
|
117
|
+
Those are not failures, so they don't belong in the thread's error; the reason
|
|
118
|
+
for a cancel is recorded on the run instead (``cancel_reason``).
|
|
119
|
+
"""
|
|
120
|
+
from langgraph_api.errors import UserInterrupt # noqa: PLC0415
|
|
121
|
+
|
|
122
|
+
return None if isinstance(exception, UserInterrupt) else exception
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _thread_status(
|
|
126
|
+
conn: InMemConnectionProto,
|
|
127
|
+
thread_id: UUID,
|
|
128
|
+
*,
|
|
129
|
+
failed: bool | None = None,
|
|
130
|
+
has_next: bool | None = None,
|
|
131
|
+
) -> ThreadStatus:
|
|
132
|
+
"""The one rule for a thread's status, used by every write that changes it.
|
|
133
|
+
|
|
134
|
+
busy if a run on the thread is pending or running; otherwise error if the last
|
|
135
|
+
run failed; otherwise interrupted if the graph has next steps (an interrupt or a
|
|
136
|
+
breakpoint); otherwise idle.
|
|
137
|
+
|
|
138
|
+
A nil ``failed`` or ``has_next`` means the caller doesn't know the fact, so it is
|
|
139
|
+
read from what is stored on the thread; an unknown fact counts as no.
|
|
140
|
+
"""
|
|
141
|
+
if any(
|
|
142
|
+
r["thread_id"] == thread_id and r["status"] in ("pending", "running")
|
|
143
|
+
for r in conn.store["runs"]
|
|
144
|
+
):
|
|
145
|
+
return "busy"
|
|
146
|
+
if failed is None:
|
|
147
|
+
failed = _thread_failed(conn).get(thread_id, False)
|
|
148
|
+
if failed:
|
|
149
|
+
return "error"
|
|
150
|
+
if has_next is None:
|
|
151
|
+
has_next = _thread_has_next(conn).get(thread_id, False)
|
|
152
|
+
if has_next:
|
|
153
|
+
return "interrupted"
|
|
154
|
+
return "idle"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
async def _save_thread_status(
|
|
158
|
+
conn: InMemConnectionProto,
|
|
159
|
+
thread_id: UUID,
|
|
160
|
+
thread: dict[str, Any],
|
|
161
|
+
stored_thread: dict[str, Any],
|
|
162
|
+
checkpoint: CheckpointPayload | None,
|
|
163
|
+
exception: BaseException | None,
|
|
164
|
+
now: datetime,
|
|
165
|
+
) -> None:
|
|
166
|
+
"""Write a thread's status and error from the facts this update knows.
|
|
167
|
+
|
|
168
|
+
Every writer derives the status from the thread's facts with the same rule.
|
|
169
|
+
Without a checkpoint there is nothing to replace values, interrupts and
|
|
170
|
+
has_next with, so the thread keeps the ones it already has. has_next is
|
|
171
|
+
recorded only once the thread is saved, so a failed save can't leave the two
|
|
172
|
+
apart.
|
|
173
|
+
"""
|
|
174
|
+
from langgraph_api.serde import json_dumpb, json_loads # noqa: PLC0415
|
|
175
|
+
|
|
176
|
+
failure = _thread_failure(exception)
|
|
177
|
+
has_next = None if checkpoint is None else bool(checkpoint["next"])
|
|
178
|
+
|
|
179
|
+
update: dict = {
|
|
180
|
+
"updated_at": now,
|
|
181
|
+
"state_updated_at": now,
|
|
182
|
+
"status": _thread_status(
|
|
183
|
+
conn, thread_id, failed=failure is not None, has_next=has_next
|
|
184
|
+
),
|
|
185
|
+
"error": json_loads(json_dumpb(failure)) if failure else None,
|
|
186
|
+
}
|
|
187
|
+
if checkpoint is not None:
|
|
188
|
+
update["values"] = json_loads(json_dumpb(checkpoint["values"]))
|
|
189
|
+
update["interrupts"] = json_loads(
|
|
190
|
+
json_dumpb(
|
|
191
|
+
{
|
|
192
|
+
t["id"]: [_patch_interrupt(i) for i in t["interrupts"]]
|
|
193
|
+
for t in checkpoint["tasks"]
|
|
194
|
+
if t.get("interrupts")
|
|
195
|
+
}
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
thread.update(update)
|
|
199
|
+
if _using_encryption():
|
|
200
|
+
encrypted_thread = await _encrypt_for_storage(thread, "thread")
|
|
201
|
+
stored_thread.clear()
|
|
202
|
+
stored_thread.update(encrypted_thread)
|
|
203
|
+
# Recorded only once the thread is saved, so a failed save can't leave the
|
|
204
|
+
# facts apart from the thread.
|
|
205
|
+
if has_next is not None:
|
|
206
|
+
_thread_has_next(conn)[thread_id] = has_next
|
|
207
|
+
_thread_failed(conn)[thread_id] = failure is not None
|
|
208
|
+
|
|
209
|
+
|
|
96
210
|
def _run_stream_mode_matches(event_mode: str, stream_mode: list[str] | None) -> bool:
|
|
97
211
|
"""Return True if a published run-stream event matches the join filter."""
|
|
98
212
|
if not stream_mode:
|
|
@@ -920,6 +1034,14 @@ def _patch_interrupt(
|
|
|
920
1034
|
}
|
|
921
1035
|
|
|
922
1036
|
|
|
1037
|
+
def _nbytes(value: Any) -> int:
|
|
1038
|
+
if isinstance(value, bytes | bytearray | memoryview):
|
|
1039
|
+
return len(value)
|
|
1040
|
+
if isinstance(value, tuple | list):
|
|
1041
|
+
return sum(_nbytes(item) for item in value)
|
|
1042
|
+
return 0
|
|
1043
|
+
|
|
1044
|
+
|
|
923
1045
|
class Threads(Authenticated):
|
|
924
1046
|
resource = "threads"
|
|
925
1047
|
|
|
@@ -1110,6 +1232,52 @@ class Threads(Authenticated):
|
|
|
1110
1232
|
|
|
1111
1233
|
return _yield_result()
|
|
1112
1234
|
|
|
1235
|
+
@staticmethod
|
|
1236
|
+
async def storage(
|
|
1237
|
+
conn: InMemConnectionProto,
|
|
1238
|
+
thread_id: UUID | str,
|
|
1239
|
+
ctx: Auth.types.BaseAuthContext | None = None,
|
|
1240
|
+
) -> dict[str, Any]:
|
|
1241
|
+
"""Bytes a thread's checkpoints occupy in memory, per table and per channel.
|
|
1242
|
+
|
|
1243
|
+
Same shape as the postgres runtime's report, so one endpoint serves both.
|
|
1244
|
+
Raises 404 if the thread doesn't exist or the auth handler hides it.
|
|
1245
|
+
"""
|
|
1246
|
+
await Threads.get(conn, thread_id, ctx=ctx)
|
|
1247
|
+
thread_id = str(thread_id)
|
|
1248
|
+
saver = Checkpointer()
|
|
1249
|
+
by_channel: dict[str, dict[str, int]] = {}
|
|
1250
|
+
for (tid, _ns, channel, _version), blob in list(saver.blobs.items()):
|
|
1251
|
+
if tid != thread_id:
|
|
1252
|
+
continue
|
|
1253
|
+
entry = by_channel.setdefault(channel, {"versions": 0, "bytes": 0})
|
|
1254
|
+
entry["versions"] += 1
|
|
1255
|
+
entry["bytes"] += _nbytes(blob)
|
|
1256
|
+
checkpoint_count = checkpoint_bytes = 0
|
|
1257
|
+
if thread_id in saver.storage:
|
|
1258
|
+
for checkpoints in saver.storage[thread_id].values():
|
|
1259
|
+
for stored in checkpoints.values():
|
|
1260
|
+
checkpoint_count += 1
|
|
1261
|
+
checkpoint_bytes += _nbytes(stored)
|
|
1262
|
+
write_count = write_bytes = 0
|
|
1263
|
+
for (tid, _ns, _checkpoint_id), writes in list(saver.writes.items()):
|
|
1264
|
+
if tid != thread_id:
|
|
1265
|
+
continue
|
|
1266
|
+
for write in writes.values():
|
|
1267
|
+
write_count += 1
|
|
1268
|
+
write_bytes += _nbytes(write)
|
|
1269
|
+
channel_bytes = sum(entry["bytes"] for entry in by_channel.values())
|
|
1270
|
+
return {
|
|
1271
|
+
"total_bytes": checkpoint_bytes + channel_bytes + write_bytes,
|
|
1272
|
+
"checkpoints": {"count": checkpoint_count, "bytes": checkpoint_bytes},
|
|
1273
|
+
"channel_values": {
|
|
1274
|
+
"count": sum(entry["versions"] for entry in by_channel.values()),
|
|
1275
|
+
"bytes": channel_bytes,
|
|
1276
|
+
"by_channel": by_channel,
|
|
1277
|
+
},
|
|
1278
|
+
"pending_writes": {"count": write_count, "bytes": write_bytes},
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1113
1281
|
@staticmethod
|
|
1114
1282
|
async def put(
|
|
1115
1283
|
conn: InMemConnectionProto | Any,
|
|
@@ -1253,17 +1421,8 @@ class Threads(Authenticated):
|
|
|
1253
1421
|
# This does not accept the auth context since it's only used internally
|
|
1254
1422
|
) -> None:
|
|
1255
1423
|
"""Set the status of a thread."""
|
|
1256
|
-
from langgraph_api.serde import json_dumpb, json_loads # noqa: PLC0415
|
|
1257
|
-
|
|
1258
1424
|
thread_id = _ensure_uuid(thread_id)
|
|
1259
1425
|
|
|
1260
|
-
async def has_pending_runs(conn_: InMemConnectionProto, tid: UUID) -> bool:
|
|
1261
|
-
"""Check if thread has any pending runs."""
|
|
1262
|
-
return any(
|
|
1263
|
-
run["status"] in ("pending", "running") and run["thread_id"] == tid
|
|
1264
|
-
for run in conn_.store["runs"]
|
|
1265
|
-
)
|
|
1266
|
-
|
|
1267
1426
|
# Find the thread
|
|
1268
1427
|
thread = next(
|
|
1269
1428
|
(
|
|
@@ -1282,47 +1441,15 @@ class Threads(Authenticated):
|
|
|
1282
1441
|
if _using_encryption():
|
|
1283
1442
|
thread = await _decrypt_from_storage(thread, "thread")
|
|
1284
1443
|
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
status = "idle"
|
|
1295
|
-
|
|
1296
|
-
# Check for pending runs and update to busy if found
|
|
1297
|
-
if await has_pending_runs(conn, thread_id):
|
|
1298
|
-
status = "busy"
|
|
1299
|
-
|
|
1300
|
-
# Update thread
|
|
1301
|
-
now = datetime.now(UTC)
|
|
1302
|
-
update: dict = {
|
|
1303
|
-
"updated_at": now,
|
|
1304
|
-
"state_updated_at": now,
|
|
1305
|
-
"status": status,
|
|
1306
|
-
"error": json_loads(json_dumpb(exception)) if exception else None,
|
|
1307
|
-
}
|
|
1308
|
-
# Without a checkpoint there is nothing to replace values and interrupts
|
|
1309
|
-
# with, so keep the ones the thread already has.
|
|
1310
|
-
if checkpoint is not None:
|
|
1311
|
-
update["values"] = json_loads(json_dumpb(checkpoint["values"]))
|
|
1312
|
-
update["interrupts"] = json_loads(
|
|
1313
|
-
json_dumpb(
|
|
1314
|
-
{
|
|
1315
|
-
t["id"]: [_patch_interrupt(i) for i in t["interrupts"]]
|
|
1316
|
-
for t in checkpoint["tasks"]
|
|
1317
|
-
if t.get("interrupts")
|
|
1318
|
-
}
|
|
1319
|
-
)
|
|
1320
|
-
)
|
|
1321
|
-
thread.update(update)
|
|
1322
|
-
if _using_encryption():
|
|
1323
|
-
encrypted_thread = await _encrypt_for_storage(thread, "thread")
|
|
1324
|
-
stored_thread.clear()
|
|
1325
|
-
stored_thread.update(encrypted_thread)
|
|
1444
|
+
await _save_thread_status(
|
|
1445
|
+
conn,
|
|
1446
|
+
thread_id,
|
|
1447
|
+
thread,
|
|
1448
|
+
stored_thread,
|
|
1449
|
+
checkpoint,
|
|
1450
|
+
exception,
|
|
1451
|
+
datetime.now(UTC),
|
|
1452
|
+
)
|
|
1326
1453
|
|
|
1327
1454
|
@staticmethod
|
|
1328
1455
|
async def set_joint_status(
|
|
@@ -1348,18 +1475,9 @@ class Threads(Authenticated):
|
|
|
1348
1475
|
exception: Exception that occurred (affects thread status)
|
|
1349
1476
|
"""
|
|
1350
1477
|
# No auth since it's internal
|
|
1351
|
-
from langgraph_api.errors import UserInterrupt, UserRollback # noqa: PLC0415
|
|
1352
|
-
from langgraph_api.serde import json_dumpb, json_loads # noqa: PLC0415
|
|
1353
|
-
|
|
1354
1478
|
thread_id = _ensure_uuid(thread_id)
|
|
1355
1479
|
run_id = _ensure_uuid(run_id)
|
|
1356
1480
|
|
|
1357
|
-
def _thread_has_active_runs() -> bool:
|
|
1358
|
-
return any(
|
|
1359
|
-
r["thread_id"] == thread_id and r["status"] in ("pending", "running")
|
|
1360
|
-
for r in conn.store["runs"]
|
|
1361
|
-
)
|
|
1362
|
-
|
|
1363
1481
|
thread = next(
|
|
1364
1482
|
(t for t in conn.store["threads"] if t["thread_id"] == thread_id), None
|
|
1365
1483
|
)
|
|
@@ -1380,55 +1498,16 @@ class Threads(Authenticated):
|
|
|
1380
1498
|
if run is None:
|
|
1381
1499
|
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
|
1382
1500
|
|
|
1383
|
-
has_next = bool(checkpoint and checkpoint["next"])
|
|
1384
|
-
if exception and not isinstance(exception, UserInterrupt | UserRollback):
|
|
1385
|
-
base_thread_status: ThreadStatus = "error"
|
|
1386
|
-
elif has_next:
|
|
1387
|
-
base_thread_status = "interrupted"
|
|
1388
|
-
else:
|
|
1389
|
-
base_thread_status = "idle"
|
|
1390
|
-
|
|
1391
1501
|
now = datetime.now(UTC)
|
|
1392
1502
|
|
|
1393
1503
|
if run_status == "rollback":
|
|
1394
1504
|
await Runs.delete(conn, run_id, thread_id=run["thread_id"])
|
|
1395
|
-
final_thread_status: ThreadStatus = (
|
|
1396
|
-
"busy" if _thread_has_active_runs() else base_thread_status
|
|
1397
|
-
)
|
|
1398
|
-
|
|
1399
1505
|
else:
|
|
1400
1506
|
run.update({"status": run_status, "updated_at": now})
|
|
1401
|
-
|
|
1402
|
-
if run_status in ("pending", "running") or _thread_has_active_runs():
|
|
1403
|
-
final_thread_status = "busy"
|
|
1404
|
-
else:
|
|
1405
|
-
final_thread_status = base_thread_status
|
|
1406
1507
|
thread["metadata"]["graph_id"] = graph_id
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
"status": final_thread_status,
|
|
1411
|
-
"error": json_loads(json_dumpb(exception)) if exception else None,
|
|
1412
|
-
}
|
|
1413
|
-
# Without a checkpoint (a run that ended before writing one, or a
|
|
1414
|
-
# rollback) there is nothing to replace values and interrupts with, so
|
|
1415
|
-
# keep the ones the thread already has.
|
|
1416
|
-
if checkpoint is not None:
|
|
1417
|
-
update["values"] = json_loads(json_dumpb(checkpoint["values"]))
|
|
1418
|
-
update["interrupts"] = json_loads(
|
|
1419
|
-
json_dumpb(
|
|
1420
|
-
{
|
|
1421
|
-
t["id"]: [_patch_interrupt(i) for i in t["interrupts"]]
|
|
1422
|
-
for t in checkpoint["tasks"]
|
|
1423
|
-
if t.get("interrupts")
|
|
1424
|
-
}
|
|
1425
|
-
)
|
|
1426
|
-
)
|
|
1427
|
-
thread.update(update)
|
|
1428
|
-
if _using_encryption():
|
|
1429
|
-
encrypted_thread = await _encrypt_for_storage(thread, "thread")
|
|
1430
|
-
stored_thread.clear()
|
|
1431
|
-
stored_thread.update(encrypted_thread)
|
|
1508
|
+
await _save_thread_status(
|
|
1509
|
+
conn, thread_id, thread, stored_thread, checkpoint, exception, now
|
|
1510
|
+
)
|
|
1432
1511
|
|
|
1433
1512
|
@staticmethod
|
|
1434
1513
|
async def delete(
|
|
@@ -1472,6 +1551,8 @@ class Threads(Authenticated):
|
|
|
1472
1551
|
for index, candidate in enumerate(thread_list):
|
|
1473
1552
|
if candidate["thread_id"] == thread_id:
|
|
1474
1553
|
thread_list.pop(index)
|
|
1554
|
+
_thread_has_next(conn).pop(thread_id, None)
|
|
1555
|
+
_thread_failed(conn).pop(thread_id, None)
|
|
1475
1556
|
|
|
1476
1557
|
async def id_iterator() -> AsyncIterator[UUID]:
|
|
1477
1558
|
yield thread_id
|
|
@@ -1598,7 +1679,10 @@ class Threads(Authenticated):
|
|
|
1598
1679
|
):
|
|
1599
1680
|
return _empty_generator()
|
|
1600
1681
|
|
|
1601
|
-
#
|
|
1682
|
+
# The copy gets the original's checkpoints below, so it starts where the
|
|
1683
|
+
# original stands: same values, interrupts and error, and the same fact
|
|
1684
|
+
# about whether its graph has next steps. Its status comes from those,
|
|
1685
|
+
# by the usual rule: the original's runs are not the copy's.
|
|
1602
1686
|
now = datetime.now(tz=UTC)
|
|
1603
1687
|
new_thread: Thread = {
|
|
1604
1688
|
"thread_id": new_thread_id,
|
|
@@ -1606,16 +1690,29 @@ class Threads(Authenticated):
|
|
|
1606
1690
|
"updated_at": now,
|
|
1607
1691
|
"state_updated_at": now,
|
|
1608
1692
|
"metadata": copy.deepcopy(original_thread["metadata"]),
|
|
1609
|
-
"status":
|
|
1693
|
+
"status": _thread_status(
|
|
1694
|
+
conn,
|
|
1695
|
+
new_thread_id,
|
|
1696
|
+
failed=_thread_failed(conn).get(thread_id, False),
|
|
1697
|
+
has_next=_thread_has_next(conn).get(thread_id, False),
|
|
1698
|
+
),
|
|
1610
1699
|
"config": {},
|
|
1700
|
+
"values": copy.deepcopy(original_thread.get("values")),
|
|
1701
|
+
"interrupts": copy.deepcopy(original_thread.get("interrupts", {})),
|
|
1702
|
+
"error": copy.deepcopy(original_thread.get("error")),
|
|
1611
1703
|
}
|
|
1612
|
-
|
|
1613
1704
|
# Add new thread to store
|
|
1614
1705
|
conn.store["threads"].append(
|
|
1615
1706
|
await _encrypt_for_storage(new_thread, "thread")
|
|
1616
1707
|
if _using_encryption()
|
|
1617
1708
|
else new_thread
|
|
1618
1709
|
)
|
|
1710
|
+
if thread_id in _thread_has_next(conn):
|
|
1711
|
+
_thread_has_next(conn)[new_thread_id] = _thread_has_next(conn)[
|
|
1712
|
+
thread_id
|
|
1713
|
+
]
|
|
1714
|
+
if thread_id in _thread_failed(conn):
|
|
1715
|
+
_thread_failed(conn)[new_thread_id] = _thread_failed(conn)[thread_id]
|
|
1619
1716
|
|
|
1620
1717
|
from langgraph_api import config as api_config # noqa: PLC0415
|
|
1621
1718
|
|
|
@@ -2544,10 +2641,7 @@ class Runs(Authenticated):
|
|
|
2544
2641
|
if run["thread_id"] == thread_id
|
|
2545
2642
|
):
|
|
2546
2643
|
continue
|
|
2547
|
-
|
|
2548
|
-
attempt = await conn.retry_counter.increment(run_id)
|
|
2549
|
-
# Set run as "running"
|
|
2550
|
-
run["status"] = "running"
|
|
2644
|
+
stored_run = run
|
|
2551
2645
|
encryption_context = None
|
|
2552
2646
|
if _using_encryption():
|
|
2553
2647
|
from langgraph_runtime_inmem.encryption import ( # noqa: PLC0415
|
|
@@ -2558,6 +2652,9 @@ class Runs(Authenticated):
|
|
|
2558
2652
|
run.get("kwargs", {}).get("config")
|
|
2559
2653
|
)
|
|
2560
2654
|
run = await _decrypt_from_storage(run, "run")
|
|
2655
|
+
# Claim only after decryption succeeds, so failures remain retryable.
|
|
2656
|
+
attempt = await conn.retry_counter.increment(run_id)
|
|
2657
|
+
stored_run["status"] = "running"
|
|
2561
2658
|
yield run, attempt, encryption_context
|
|
2562
2659
|
|
|
2563
2660
|
@asynccontextmanager
|
|
@@ -2850,6 +2947,7 @@ class Runs(Authenticated):
|
|
|
2850
2947
|
created_at=datetime.now(UTC) + timedelta(seconds=after_seconds),
|
|
2851
2948
|
updated_at=datetime.now(UTC),
|
|
2852
2949
|
langsmith_session_name=langsmith_session_name,
|
|
2950
|
+
cancel_reason=None,
|
|
2853
2951
|
)
|
|
2854
2952
|
stored_run = new_run
|
|
2855
2953
|
if encryption_enabled:
|
|
@@ -3071,6 +3169,7 @@ class Runs(Authenticated):
|
|
|
3071
3169
|
stream_manager = get_stream_manager()
|
|
3072
3170
|
coros = []
|
|
3073
3171
|
cancelable_runs = []
|
|
3172
|
+
pending_thread_ids: set[UUID] = set()
|
|
3074
3173
|
|
|
3075
3174
|
for run in candidate_runs:
|
|
3076
3175
|
run_id = run["run_id"]
|
|
@@ -3085,20 +3184,11 @@ class Runs(Authenticated):
|
|
|
3085
3184
|
|
|
3086
3185
|
if run["status"] in ("pending", "running"):
|
|
3087
3186
|
cancelable_runs.append(run)
|
|
3187
|
+
if run["status"] == "pending":
|
|
3188
|
+
pending_thread_ids.add(run["thread_id"])
|
|
3088
3189
|
if queues or action != "rollback":
|
|
3089
|
-
if run["status"] == "pending":
|
|
3090
|
-
thread = next(
|
|
3091
|
-
(
|
|
3092
|
-
t
|
|
3093
|
-
for t in conn.store["threads"]
|
|
3094
|
-
if t["thread_id"] == run["thread_id"]
|
|
3095
|
-
),
|
|
3096
|
-
None,
|
|
3097
|
-
)
|
|
3098
|
-
if thread:
|
|
3099
|
-
thread["status"] = "idle"
|
|
3100
|
-
thread["updated_at"] = datetime.now(tz=UTC)
|
|
3101
3190
|
run["status"] = "interrupted"
|
|
3191
|
+
run["cancel_reason"] = reason
|
|
3102
3192
|
run["updated_at"] = datetime.now(tz=UTC)
|
|
3103
3193
|
else:
|
|
3104
3194
|
await logger.ainfo(
|
|
@@ -3126,6 +3216,17 @@ class Runs(Authenticated):
|
|
|
3126
3216
|
if coros:
|
|
3127
3217
|
await asyncio.gather(*coros)
|
|
3128
3218
|
|
|
3219
|
+
# Cancelling a pending run changes which runs are active on its thread, so
|
|
3220
|
+
# re-derive the thread's status. Nothing else about the thread changed.
|
|
3221
|
+
if pending_thread_ids:
|
|
3222
|
+
for thread in conn.store["threads"]:
|
|
3223
|
+
if thread["thread_id"] in pending_thread_ids:
|
|
3224
|
+
thread["status"] = _thread_status(
|
|
3225
|
+
conn,
|
|
3226
|
+
thread["thread_id"],
|
|
3227
|
+
)
|
|
3228
|
+
thread["updated_at"] = datetime.now(tz=UTC)
|
|
3229
|
+
|
|
3129
3230
|
await logger.ainfo(
|
|
3130
3231
|
"Cancelled runs",
|
|
3131
3232
|
run_ids=[str(r["run_id"]) for r in cancelable_runs],
|
|
@@ -3853,7 +3954,15 @@ class Crons(Authenticated):
|
|
|
3853
3954
|
if _using_encryption()
|
|
3854
3955
|
else cron
|
|
3855
3956
|
)
|
|
3856
|
-
|
|
3957
|
+
# The scheduler mutates the payload to build each run; handing out the stored dict breaks every later fire.
|
|
3958
|
+
yield (
|
|
3959
|
+
{
|
|
3960
|
+
**plaintext,
|
|
3961
|
+
"payload": copy.deepcopy(plaintext["payload"]),
|
|
3962
|
+
"now": now,
|
|
3963
|
+
},
|
|
3964
|
+
encryption_context,
|
|
3965
|
+
)
|
|
3857
3966
|
|
|
3858
3967
|
@staticmethod
|
|
3859
3968
|
async def set_next_run_date(
|
|
@@ -1034,11 +1034,11 @@ wheels = [
|
|
|
1034
1034
|
|
|
1035
1035
|
[[package]]
|
|
1036
1036
|
name = "urllib3"
|
|
1037
|
-
version = "2.
|
|
1037
|
+
version = "2.8.0"
|
|
1038
1038
|
source = { registry = "https://pypi.org/simple" }
|
|
1039
|
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
|
1039
|
+
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
|
|
1040
1040
|
wheels = [
|
|
1041
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
1041
|
+
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
|
|
1042
1042
|
]
|
|
1043
1043
|
|
|
1044
1044
|
[[package]]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|