langgraph-api 0.17.0.dev3__py3-none-any.whl → 0.17.0.dev5__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.
- LICENSE +1 -1
- langgraph_api/__init__.py +1 -1
- langgraph_api/api/event_streaming.py +31 -11
- langgraph_api/api/threads.py +2 -7
- langgraph_api/cache.py +86 -26
- langgraph_api/config/__init__.py +45 -5
- langgraph_api/config/schemas.py +12 -12
- langgraph_api/cron_scheduler.py +16 -1
- langgraph_api/event_streaming/__init__.py +0 -1
- langgraph_api/event_streaming/service.py +22 -4
- langgraph_api/feature_flags.py +35 -4
- langgraph_api/grpc/ops/crons.py +3 -2
- langgraph_api/grpc/ops/runs.py +3 -0
- langgraph_api/grpc/ops/threads.py +44 -0
- langgraph_api/js/package.json +1 -1
- langgraph_api/js/yarn.lock +7 -7
- langgraph_api/models/run.py +4 -0
- langgraph_api/schema.py +3 -0
- langgraph_api/store.py +7 -6
- langgraph_api/stream.py +1 -0
- langgraph_api/utils/cache.py +22 -10
- langgraph_api/worker.py +2 -0
- {langgraph_api-0.17.0.dev3.dist-info → langgraph_api-0.17.0.dev5.dist-info}/METADATA +2 -2
- {langgraph_api-0.17.0.dev3.dist-info → langgraph_api-0.17.0.dev5.dist-info}/RECORD +34 -34
- {langgraph_api-0.17.0.dev3.dist-info → langgraph_api-0.17.0.dev5.dist-info}/licenses/LICENSE +1 -1
- langgraph_grpc_common/conversion/checkpoint.py +19 -14
- langgraph_grpc_common/proto/checkpointer_pb2.pyi +1 -1
- langgraph_grpc_common/proto/core_api_pb2.py +135 -123
- langgraph_grpc_common/proto/core_api_pb2.pyi +163 -2
- langgraph_grpc_common/proto/core_api_pb2_grpc.py +47 -3
- langgraph_grpc_common/proto/core_api_pb2_grpc.pyi +15 -3
- openapi.json +9 -2
- {langgraph_api-0.17.0.dev3.dist-info → langgraph_api-0.17.0.dev5.dist-info}/WHEEL +0 -0
- {langgraph_api-0.17.0.dev3.dist-info → langgraph_api-0.17.0.dev5.dist-info}/entry_points.txt +0 -0
LICENSE
CHANGED
langgraph_api/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.17.0.
|
|
1
|
+
__version__ = "0.17.0.dev5"
|
|
@@ -33,7 +33,7 @@ from starlette.websockets import WebSocketDisconnect
|
|
|
33
33
|
|
|
34
34
|
from langgraph_api.event_streaming.service import ThreadRunManager
|
|
35
35
|
from langgraph_api.event_streaming.session import _is_supported_channel
|
|
36
|
-
from langgraph_api.event_streaming.types import Subscription
|
|
36
|
+
from langgraph_api.event_streaming.types import ErrorResponse, Subscription
|
|
37
37
|
from langgraph_api.feature_flags import IS_POSTGRES_OR_GRPC_BACKEND
|
|
38
38
|
from langgraph_api.route import ApiRoute, ApiWebSocketRoute
|
|
39
39
|
from langgraph_api.serde import json_dumpb
|
|
@@ -61,6 +61,19 @@ logger = structlog.stdlib.get_logger(__name__)
|
|
|
61
61
|
# connection, vs. the unbounded set this replaces.
|
|
62
62
|
_DELIVERED_DEDUP_WINDOW = 2048
|
|
63
63
|
|
|
64
|
+
# Final frame on SSE and WebSocket when the thread-stream consumer stops
|
|
65
|
+
# without being cancelled. A thread stream has no natural end, so that always
|
|
66
|
+
# means the read failed. The message is fixed because the exception text can
|
|
67
|
+
# carry raw backend errors; the consumer has already logged it.
|
|
68
|
+
_THREAD_STREAM_FAILED_DATA = json_dumpb(
|
|
69
|
+
ErrorResponse(
|
|
70
|
+
type="error",
|
|
71
|
+
id=None,
|
|
72
|
+
error="unknown_error",
|
|
73
|
+
message="Thread event stream failed.",
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
|
|
64
77
|
|
|
65
78
|
def _json_response(content: Any, *, status_code: int = 200) -> Response:
|
|
66
79
|
"""JSON response backed by the repo's orjson helper.
|
|
@@ -118,6 +131,10 @@ async def _thread_events(request: Request) -> Response:
|
|
|
118
131
|
The filter applies for the lifetime of the connection; closing the
|
|
119
132
|
connection unsubscribes. No state is persisted server-side beyond the
|
|
120
133
|
connection.
|
|
134
|
+
|
|
135
|
+
If reading the thread's event stream fails, the server ends the
|
|
136
|
+
stream with a final ``event: error`` frame carrying a protocol error
|
|
137
|
+
(``"type": "error"``, ``"id": null``, ``"error": "unknown_error"``).
|
|
121
138
|
"""
|
|
122
139
|
thread_id = request.path_params["thread_id"]
|
|
123
140
|
try:
|
|
@@ -245,10 +262,9 @@ async def _thread_events(request: Request) -> Response:
|
|
|
245
262
|
try:
|
|
246
263
|
while True:
|
|
247
264
|
# Race ``flush_pending`` against the thread-stream consumer
|
|
248
|
-
# ending. If the consumer
|
|
249
|
-
#
|
|
250
|
-
#
|
|
251
|
-
# never arrive.
|
|
265
|
+
# ending. If the consumer stops (its read failed or its
|
|
266
|
+
# source ended), we want this loop to exit rather than
|
|
267
|
+
# wedge forever waiting for events that will never arrive.
|
|
252
268
|
flush_task = asyncio.create_task(flush_pending.wait())
|
|
253
269
|
done_task = asyncio.create_task(manager.wait_for_thread_stream_end())
|
|
254
270
|
try:
|
|
@@ -285,6 +301,8 @@ async def _thread_events(request: Request) -> Response:
|
|
|
285
301
|
str(sse_id).encode() if isinstance(sse_id, int) else None,
|
|
286
302
|
)
|
|
287
303
|
if stream_finished:
|
|
304
|
+
if manager.thread_stream_failed:
|
|
305
|
+
yield (b"error", _THREAD_STREAM_FAILED_DATA)
|
|
288
306
|
break
|
|
289
307
|
finally:
|
|
290
308
|
await manager.close()
|
|
@@ -366,14 +384,16 @@ async def _thread_websocket(websocket: WebSocket) -> None:
|
|
|
366
384
|
manager = _make_manager(thread_id, send_event=ws_send_event)
|
|
367
385
|
manager.start_thread_stream()
|
|
368
386
|
|
|
369
|
-
# Watchdog: when the thread-stream consumer
|
|
370
|
-
#
|
|
371
|
-
#
|
|
372
|
-
#
|
|
373
|
-
#
|
|
374
|
-
# loop through its existing handler.
|
|
387
|
+
# Watchdog: when the thread-stream consumer stops, send the error frame
|
|
388
|
+
# (if it wasn't cancelled) and close the socket, instead of leaving the
|
|
389
|
+
# client on an open connection that will never deliver another event.
|
|
390
|
+
# ``receive_text`` below raises ``WebSocketDisconnect`` once the close
|
|
391
|
+
# lands, exiting the loop through its existing handler.
|
|
375
392
|
async def _stream_watchdog() -> None:
|
|
376
393
|
await manager.wait_for_thread_stream_end()
|
|
394
|
+
if manager.thread_stream_failed:
|
|
395
|
+
with contextlib.suppress(Exception):
|
|
396
|
+
await websocket.send_text(_THREAD_STREAM_FAILED_DATA.decode())
|
|
377
397
|
with contextlib.suppress(Exception):
|
|
378
398
|
await websocket.close(code=1011, reason="thread stream ended")
|
|
379
399
|
|
langgraph_api/api/threads.py
CHANGED
|
@@ -36,7 +36,6 @@ from langgraph_api.validation import (
|
|
|
36
36
|
ThreadStateSearch,
|
|
37
37
|
ThreadStateUpdate,
|
|
38
38
|
)
|
|
39
|
-
from langgraph_runtime import checkpoint as runtime_checkpoint
|
|
40
39
|
from langgraph_runtime.database import connect
|
|
41
40
|
from langgraph_runtime.retry import retry_db
|
|
42
41
|
|
|
@@ -349,17 +348,13 @@ async def get_thread_storage(request: ApiRequest):
|
|
|
349
348
|
"""Bytes a thread's checkpoints occupy, per table and per channel."""
|
|
350
349
|
thread_id = request.path_params["thread_id"]
|
|
351
350
|
validate_uuid(thread_id, "Invalid thread ID: must be a UUID")
|
|
352
|
-
|
|
353
|
-
if USE_CUSTOM_CHECKPOINTER or thread_storage is None:
|
|
351
|
+
if USE_CUSTOM_CHECKPOINTER or not hasattr(Threads, "storage"):
|
|
354
352
|
raise HTTPException(
|
|
355
353
|
status_code=501,
|
|
356
354
|
detail="Checkpoint storage is only reported for the built-in checkpointer.",
|
|
357
355
|
)
|
|
358
356
|
async with connect() as conn:
|
|
359
|
-
|
|
360
|
-
# The built-in tables are read directly; the core-api stand-in cannot run SQL.
|
|
361
|
-
async with connect(supports_core_api=False) as conn:
|
|
362
|
-
storage = await thread_storage(conn, thread_id)
|
|
357
|
+
storage = await Threads.storage(conn, thread_id)
|
|
363
358
|
return ApiResponse({"thread_id": thread_id, **storage})
|
|
364
359
|
|
|
365
360
|
|
langgraph_api/cache.py
CHANGED
|
@@ -26,12 +26,17 @@ else:
|
|
|
26
26
|
|
|
27
27
|
async def _cache_set(key: str, value: bytes, ttl: timedelta | None = None) -> None:
|
|
28
28
|
ttl = _clamp_ttl(ttl)
|
|
29
|
-
_CACHE.set(key, value)
|
|
29
|
+
_CACHE.set(key, value, ttl=ttl.total_seconds())
|
|
30
30
|
|
|
31
31
|
|
|
32
32
|
logger = logging.getLogger(__name__)
|
|
33
33
|
|
|
34
34
|
_SWR_KEY_PREFIX = "__lg_swr__:"
|
|
35
|
+
# Deliberately a disjoint prefix (diverges from _SWR_KEY_PREFIX at the first
|
|
36
|
+
# character after the shared "__lg_swr" stem), not a suffix appended to a
|
|
37
|
+
# cache_key: a caller using swr(key="foo:marker") would otherwise produce a
|
|
38
|
+
# cache_key identical to the marker key for swr(key="foo"), clobbering it.
|
|
39
|
+
_SWR_MARKER_KEY_PREFIX = "__lg_swr_marker__:"
|
|
35
40
|
_SWR_SCHEMA_VERSION = 1
|
|
36
41
|
_DELTA_ZERO = timedelta(0)
|
|
37
42
|
_UNSET: Any = object()
|
|
@@ -127,6 +132,31 @@ class _SWREntry(TypedDict):
|
|
|
127
132
|
stored_at_ms: int
|
|
128
133
|
|
|
129
134
|
|
|
135
|
+
class _SWRMarker(TypedDict):
|
|
136
|
+
"""A tiny, payload-free record of when a key was last written.
|
|
137
|
+
|
|
138
|
+
Stored under a separate key with a much longer ttl than the full entry,
|
|
139
|
+
so swr() can still tell "this existed and is now expired" from "never
|
|
140
|
+
written" long after the (possibly large) payload itself has expired.
|
|
141
|
+
|
|
142
|
+
Two accepted trade-offs from using a second key, deliberately not
|
|
143
|
+
engineered around:
|
|
144
|
+
- A write's ttl=max_age reflects that call's own max_age, not the
|
|
145
|
+
largest max_age any caller has ever used for this key. A key read
|
|
146
|
+
with different max_age values across calls can see an earlier,
|
|
147
|
+
shorter-lived write evict the entry before a later call's own window
|
|
148
|
+
would have, forcing an extra reload. The reload still returns a
|
|
149
|
+
correct value; only cache-hit efficiency is affected.
|
|
150
|
+
- Every swr() key now costs two slots in the in-memory backend's
|
|
151
|
+
max_size=1000 LRU instead of one. swr() is scoped to moderate-
|
|
152
|
+
cardinality use (auth/metadata lookups per its own docstring), so this
|
|
153
|
+
is judged an acceptable capacity cost for bounding payload memory.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
v: int
|
|
157
|
+
stored_at_ms: int
|
|
158
|
+
|
|
159
|
+
|
|
130
160
|
@dataclass(slots=True)
|
|
131
161
|
class _SWRState:
|
|
132
162
|
task: asyncio.Task[Any] | None = None
|
|
@@ -158,10 +188,15 @@ async def swr(
|
|
|
158
188
|
Defaults to ``timedelta(0)`` so every access triggers a background
|
|
159
189
|
revalidate while still returning the cached value instantly. Values
|
|
160
190
|
above :data:`MAX_CACHE_TTL` are clamped to the backend maximum.
|
|
161
|
-
max_age: Total lifetime of a cached entry
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
backend
|
|
191
|
+
max_age: Total lifetime of a cached entry before it's considered
|
|
192
|
+
expired rather than stale. After this, the next access blocks on
|
|
193
|
+
the loader. Defaults to :data:`MAX_CACHE_TTL` (24 h by default).
|
|
194
|
+
Values above :data:`MAX_CACHE_TTL` are clamped to the backend
|
|
195
|
+
maximum. The full entry is physically stored for `max_age`, so a
|
|
196
|
+
short `max_age` doesn't pin a large payload in the backend for
|
|
197
|
+
longer than it's needed; a small marker without the payload is
|
|
198
|
+
kept for the backend's max TTL so `swr` can still classify a
|
|
199
|
+
later access as `"expired"` instead of missing it entirely.
|
|
165
200
|
model: Optional Pydantic model class. When provided, values are
|
|
166
201
|
serialized via ``model_dump(mode="json")`` before storage and
|
|
167
202
|
deserialized via ``model.model_validate()`` on read.
|
|
@@ -179,7 +214,7 @@ async def swr(
|
|
|
179
214
|
"""
|
|
180
215
|
resolved_fresh_for = min(fresh_for, MAX_CACHE_TTL)
|
|
181
216
|
resolved_max_age = _resolve_swr_max_age(max_age)
|
|
182
|
-
fresh_for_ms, max_age_ms
|
|
217
|
+
fresh_for_ms, max_age_ms = _validate_swr_windows(
|
|
183
218
|
resolved_fresh_for, resolved_max_age
|
|
184
219
|
)
|
|
185
220
|
cache_key = _swr_cache_key(key)
|
|
@@ -216,8 +251,11 @@ async def swr(
|
|
|
216
251
|
|
|
217
252
|
entry = _parse_swr_entry(await cache_get(cache_key))
|
|
218
253
|
if entry is None:
|
|
254
|
+
# The full entry is gone, but a marker surviving past the entry's own
|
|
255
|
+
# ttl means it existed before and is now expired, not merely missing.
|
|
256
|
+
marker = _parse_swr_marker(await cache_get(_swr_marker_key(cache_key)))
|
|
219
257
|
raw = await _await_swr_load(cache_key, json_loader, resolved_max_age)
|
|
220
|
-
return _result(_deserialize(raw), "miss")
|
|
258
|
+
return _result(_deserialize(raw), "expired" if marker is not None else "miss")
|
|
221
259
|
|
|
222
260
|
age_ms = max(0, _now_ms() - entry["stored_at_ms"])
|
|
223
261
|
cached_value = _deserialize(entry["value"])
|
|
@@ -252,9 +290,11 @@ def _swr_cache_key(key: str) -> str:
|
|
|
252
290
|
return f"{_SWR_KEY_PREFIX}{key}"
|
|
253
291
|
|
|
254
292
|
|
|
255
|
-
def
|
|
256
|
-
|
|
257
|
-
|
|
293
|
+
def _swr_marker_key(cache_key: str) -> str:
|
|
294
|
+
return _SWR_MARKER_KEY_PREFIX + cache_key.removeprefix(_SWR_KEY_PREFIX)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _validate_swr_windows(fresh_for: timedelta, max_age: timedelta) -> tuple[int, int]:
|
|
258
298
|
if fresh_for < _DELTA_ZERO:
|
|
259
299
|
raise ValueError("fresh_for must be >= 0")
|
|
260
300
|
if max_age <= _DELTA_ZERO:
|
|
@@ -265,7 +305,6 @@ def _validate_swr_windows(
|
|
|
265
305
|
return (
|
|
266
306
|
int(fresh_for.total_seconds() * 1000),
|
|
267
307
|
int(max_age.total_seconds() * 1000),
|
|
268
|
-
max_age,
|
|
269
308
|
)
|
|
270
309
|
|
|
271
310
|
|
|
@@ -291,6 +330,18 @@ def _parse_swr_entry(value: Any) -> _SWREntry | None:
|
|
|
291
330
|
}
|
|
292
331
|
|
|
293
332
|
|
|
333
|
+
def _parse_swr_marker(value: Any) -> _SWRMarker | None:
|
|
334
|
+
if not isinstance(value, dict):
|
|
335
|
+
return None
|
|
336
|
+
|
|
337
|
+
version = value.get("v")
|
|
338
|
+
stored_at_ms = value.get("stored_at_ms")
|
|
339
|
+
if version != _SWR_SCHEMA_VERSION or not isinstance(stored_at_ms, int):
|
|
340
|
+
return None
|
|
341
|
+
|
|
342
|
+
return {"v": _SWR_SCHEMA_VERSION, "stored_at_ms": stored_at_ms}
|
|
343
|
+
|
|
344
|
+
|
|
294
345
|
def _acquire_swr_state(cache_key: str) -> _SWRState:
|
|
295
346
|
state = _SWR_STATES.get(cache_key)
|
|
296
347
|
if state is None:
|
|
@@ -315,34 +366,41 @@ def _maybe_cleanup_swr_state(cache_key: str, state: _SWRState) -> None:
|
|
|
315
366
|
_SWR_STATES.pop(cache_key, None)
|
|
316
367
|
|
|
317
368
|
|
|
318
|
-
async def _cache_swr_entry(cache_key: str, value: Any,
|
|
369
|
+
async def _cache_swr_entry(cache_key: str, value: Any, max_age: timedelta) -> None:
|
|
370
|
+
stored_at_ms = _now_ms()
|
|
319
371
|
entry: _SWREntry = {
|
|
320
372
|
"v": _SWR_SCHEMA_VERSION,
|
|
321
373
|
"value": value,
|
|
322
|
-
"stored_at_ms":
|
|
374
|
+
"stored_at_ms": stored_at_ms,
|
|
323
375
|
}
|
|
324
|
-
|
|
376
|
+
marker: _SWRMarker = {"v": _SWR_SCHEMA_VERSION, "stored_at_ms": stored_at_ms}
|
|
377
|
+
# The full payload expires at max_age, so a short max_age doesn't pin a
|
|
378
|
+
# large value in the backend longer than it's needed. The marker carries
|
|
379
|
+
# no payload, so it's cheap to keep around for the backend's own max ttl
|
|
380
|
+
# regardless of max_age - see swr()'s cache_get(cache_key) is None branch.
|
|
381
|
+
await cache_set(cache_key, entry, ttl=max_age)
|
|
382
|
+
await cache_set(_swr_marker_key(cache_key), marker)
|
|
325
383
|
|
|
326
384
|
|
|
327
|
-
async def _write_swr_value(cache_key: str, value: Any,
|
|
385
|
+
async def _write_swr_value(cache_key: str, value: Any, max_age: timedelta) -> None:
|
|
328
386
|
state = _acquire_swr_state(cache_key)
|
|
329
387
|
try:
|
|
330
388
|
async with state.write_lock:
|
|
331
389
|
state.write_epoch += 1
|
|
332
|
-
await _cache_swr_entry(cache_key, value,
|
|
390
|
+
await _cache_swr_entry(cache_key, value, max_age)
|
|
333
391
|
finally:
|
|
334
392
|
_release_swr_state(cache_key, state)
|
|
335
393
|
|
|
336
394
|
|
|
337
395
|
async def _store_loaded_swr_value(
|
|
338
|
-
cache_key: str, value: Any,
|
|
396
|
+
cache_key: str, value: Any, max_age: timedelta, *, start_epoch: int
|
|
339
397
|
) -> bool:
|
|
340
398
|
state = _acquire_swr_state(cache_key)
|
|
341
399
|
try:
|
|
342
400
|
async with state.write_lock:
|
|
343
401
|
if start_epoch != state.write_epoch:
|
|
344
402
|
return False
|
|
345
|
-
await _cache_swr_entry(cache_key, value,
|
|
403
|
+
await _cache_swr_entry(cache_key, value, max_age)
|
|
346
404
|
return True
|
|
347
405
|
finally:
|
|
348
406
|
_release_swr_state(cache_key, state)
|
|
@@ -351,12 +409,14 @@ async def _store_loaded_swr_value(
|
|
|
351
409
|
async def _load_and_store_swr_value(
|
|
352
410
|
cache_key: str,
|
|
353
411
|
loader: Callable[[], Awaitable[T]],
|
|
354
|
-
|
|
412
|
+
max_age: timedelta,
|
|
355
413
|
*,
|
|
356
414
|
start_epoch: int,
|
|
357
415
|
) -> T:
|
|
358
416
|
value = await loader()
|
|
359
|
-
if await _store_loaded_swr_value(
|
|
417
|
+
if await _store_loaded_swr_value(
|
|
418
|
+
cache_key, value, max_age, start_epoch=start_epoch
|
|
419
|
+
):
|
|
360
420
|
return value
|
|
361
421
|
|
|
362
422
|
entry = _parse_swr_entry(await cache_get(cache_key))
|
|
@@ -368,7 +428,7 @@ async def _load_and_store_swr_value(
|
|
|
368
428
|
def _ensure_swr_load_task(
|
|
369
429
|
cache_key: str,
|
|
370
430
|
loader: Callable[[], Awaitable[T]],
|
|
371
|
-
|
|
431
|
+
max_age: timedelta,
|
|
372
432
|
*,
|
|
373
433
|
log_errors: bool,
|
|
374
434
|
) -> asyncio.Task[T]:
|
|
@@ -380,7 +440,7 @@ def _ensure_swr_load_task(
|
|
|
380
440
|
|
|
381
441
|
task = asyncio.create_task(
|
|
382
442
|
_load_and_store_swr_value(
|
|
383
|
-
cache_key, loader,
|
|
443
|
+
cache_key, loader, max_age, start_epoch=state.write_epoch
|
|
384
444
|
)
|
|
385
445
|
)
|
|
386
446
|
state.task = task
|
|
@@ -407,12 +467,12 @@ def _ensure_swr_load_task(
|
|
|
407
467
|
|
|
408
468
|
|
|
409
469
|
async def _await_swr_load(
|
|
410
|
-
cache_key: str, loader: Callable[[], Awaitable[T]],
|
|
470
|
+
cache_key: str, loader: Callable[[], Awaitable[T]], max_age: timedelta
|
|
411
471
|
) -> T:
|
|
412
|
-
return await _ensure_swr_load_task(cache_key, loader,
|
|
472
|
+
return await _ensure_swr_load_task(cache_key, loader, max_age, log_errors=False)
|
|
413
473
|
|
|
414
474
|
|
|
415
475
|
def _start_swr_refresh(
|
|
416
|
-
cache_key: str, loader: Callable[[], Awaitable[Any]],
|
|
476
|
+
cache_key: str, loader: Callable[[], Awaitable[Any]], max_age: timedelta
|
|
417
477
|
) -> None:
|
|
418
|
-
_ensure_swr_load_task(cache_key, loader,
|
|
478
|
+
_ensure_swr_load_task(cache_key, loader, max_age, log_errors=True)
|
langgraph_api/config/__init__.py
CHANGED
|
@@ -23,6 +23,11 @@ from langgraph_api.config.schemas import (
|
|
|
23
23
|
WebhooksConfig,
|
|
24
24
|
webhooks_validator,
|
|
25
25
|
)
|
|
26
|
+
from langgraph_api.feature_flags import (
|
|
27
|
+
IS_POSTGRES_BACKEND,
|
|
28
|
+
OMIT_PENDING_SENDS,
|
|
29
|
+
PREFER_GRPC_CHECKPOINTER,
|
|
30
|
+
)
|
|
26
31
|
|
|
27
32
|
if TYPE_CHECKING:
|
|
28
33
|
from collections.abc import Callable
|
|
@@ -50,6 +55,21 @@ STATS_INTERVAL_SECS = env("STATS_INTERVAL_SECS", cast=int, default=60)
|
|
|
50
55
|
DATABASE_URI: str | None = env(
|
|
51
56
|
"DATABASE_URI", cast=str, default=getenv("POSTGRES_URI", None)
|
|
52
57
|
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _validate_langgraph_without_database(
|
|
61
|
+
*, langgraph_supported: bool, is_postgres: bool
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Reject langgraph < 0.5 when the Postgres runtime has no DATABASE_URI."""
|
|
64
|
+
# langgraph < 0.5 always uses the Python checkpointer, which needs a direct
|
|
65
|
+
# connection (see feature_flags.PREFER_GRPC_CHECKPOINTER).
|
|
66
|
+
if is_postgres and not langgraph_supported and not DATABASE_URI:
|
|
67
|
+
raise ValueError("langgraph >= 0.5 is required when core-api owns the database")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
_validate_langgraph_without_database(
|
|
71
|
+
langgraph_supported=OMIT_PENDING_SENDS, is_postgres=IS_POSTGRES_BACKEND
|
|
72
|
+
)
|
|
53
73
|
AGENT_POSTGRES_IAM_AUTH_PROVIDER = env(
|
|
54
74
|
"AGENT_POSTGRES_IAM_AUTH_PROVIDER", cast=str, default=""
|
|
55
75
|
)
|
|
@@ -155,6 +175,28 @@ LANGGRAPH_AES_JSON_KEYS: frozenset[str] | None = env(
|
|
|
155
175
|
"LANGGRAPH_AES_JSON_KEYS", default=None, cast=_get_aes_json_keys
|
|
156
176
|
)
|
|
157
177
|
|
|
178
|
+
|
|
179
|
+
def _validate_aes_rotation(
|
|
180
|
+
prefer_grpc_checkpointer: bool, *, is_postgres: bool
|
|
181
|
+
) -> None:
|
|
182
|
+
"""Reject AES key rotation when Python owns checkpoints, as core-server does."""
|
|
183
|
+
# Uses the derived PREFER_GRPC_CHECKPOINTER (false on langgraph < 0.5), while
|
|
184
|
+
# core-server checks the raw env var. A missing LANGGRAPH_AES_KEY is left to
|
|
185
|
+
# core-server, which reports it first.
|
|
186
|
+
rotation_keys = getenv("LANGGRAPH_AES_LEGACY_KEY") or getenv(
|
|
187
|
+
"LANGGRAPH_AES_FALLBACK_KEYS"
|
|
188
|
+
)
|
|
189
|
+
if (
|
|
190
|
+
is_postgres
|
|
191
|
+
and not prefer_grpc_checkpointer
|
|
192
|
+
and rotation_keys
|
|
193
|
+
and LANGGRAPH_AES_KEY
|
|
194
|
+
):
|
|
195
|
+
raise ValueError("AES key rotation requires PREFER_GRPC_CHECKPOINTER=true")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
_validate_aes_rotation(PREFER_GRPC_CHECKPOINTER, is_postgres=IS_POSTGRES_BACKEND)
|
|
199
|
+
|
|
158
200
|
# redis
|
|
159
201
|
# Not in public docs: infrastructure, set by platform
|
|
160
202
|
REDIS_URI = env("REDIS_URI_CUSTOM", cast=str, default="") or env("REDIS_URI", cast=str)
|
|
@@ -275,9 +317,6 @@ WEBHOOKS_ENABLED = HTTP_CONFIG and HTTP_CONFIG.get("disable_webhooks")
|
|
|
275
317
|
STORE_CONFIG = env(
|
|
276
318
|
"LANGGRAPH_STORE", cast=_parse.parse_schema(StoreConfig), default=None
|
|
277
319
|
)
|
|
278
|
-
LANGGRAPH_STORE_BACKEND = env("LANGGRAPH_STORE_BACKEND", cast=str, default="python")
|
|
279
|
-
USE_GRPC_STORE = LANGGRAPH_STORE_BACKEND == "grpc"
|
|
280
|
-
|
|
281
320
|
# When true, the Go core-server owns Postgres schema setup (DB migrations +
|
|
282
321
|
# vector index)
|
|
283
322
|
DB_MIGRATION_BY_CORE_API = env("DB_MIGRATION_BY_CORE_API", cast=bool, default=False)
|
|
@@ -469,6 +508,8 @@ FF_USE_REDIS_QUEUE = env("FF_USE_REDIS_QUEUE", cast=bool, default=True)
|
|
|
469
508
|
|
|
470
509
|
# Internal flag intended for testing only
|
|
471
510
|
CRON_SCHEDULER_SLEEP_TIME = env("CRON_SCHEDULER_SLEEP_TIME", cast=int, default=5)
|
|
511
|
+
# Not in public docs: max due crons each scheduler claims per poll; <= 0 means no limit
|
|
512
|
+
CRON_SCHEDULER_BATCH_SIZE = env("CRON_SCHEDULER_BATCH_SIZE", cast=int, default=-1)
|
|
472
513
|
|
|
473
514
|
|
|
474
515
|
# auth
|
|
@@ -718,6 +759,7 @@ __all__ = [
|
|
|
718
759
|
"CHECKPOINT_MAX_BATCH_SIZE",
|
|
719
760
|
"CORS_ALLOW_ORIGINS",
|
|
720
761
|
"CORS_CONFIG",
|
|
762
|
+
"CRON_SCHEDULER_BATCH_SIZE",
|
|
721
763
|
"CRON_SCHEDULER_SLEEP_TIME",
|
|
722
764
|
"DATABASE_URI",
|
|
723
765
|
"DATADOG_METRICS_ENABLED",
|
|
@@ -756,7 +798,6 @@ __all__ = [
|
|
|
756
798
|
"LANGGRAPH_METRICS_ENDPOINT",
|
|
757
799
|
"LANGGRAPH_METRICS_EXPORT_INTERVAL_MS",
|
|
758
800
|
"LANGGRAPH_POSTGRES_EXTENSIONS",
|
|
759
|
-
"LANGGRAPH_STORE_BACKEND",
|
|
760
801
|
"LANGSMITH_API_KEY",
|
|
761
802
|
"LANGSMITH_AUTH_ENDPOINT",
|
|
762
803
|
"LANGSMITH_AUTH_VERIFY_TENANT_ID",
|
|
@@ -804,7 +845,6 @@ __all__ = [
|
|
|
804
845
|
"USES_STORE_TTL",
|
|
805
846
|
"USES_THREAD_TTL",
|
|
806
847
|
"USE_CUSTOM_CHECKPOINTER",
|
|
807
|
-
"USE_GRPC_STORE",
|
|
808
848
|
"AuthConfig",
|
|
809
849
|
"CheckpointerConfig",
|
|
810
850
|
"CorsConfig",
|
langgraph_api/config/schemas.py
CHANGED
|
@@ -150,7 +150,7 @@ class IndexConfig(TypedDict, total=False):
|
|
|
150
150
|
|
|
151
151
|
dims: int
|
|
152
152
|
"""Number of dimensions in the embedding vectors.
|
|
153
|
-
|
|
153
|
+
|
|
154
154
|
Common embedding models have the following dimensions:
|
|
155
155
|
- OpenAI text-embedding-3-large: 256, 1024, or 3072
|
|
156
156
|
- OpenAI text-embedding-3-small: 512 or 1536
|
|
@@ -164,13 +164,13 @@ class IndexConfig(TypedDict, total=False):
|
|
|
164
164
|
embed: str
|
|
165
165
|
"""Either a path to an embedding model (./path/to/file.py:embedding_model)
|
|
166
166
|
or a name of an embedding model (openai:text-embedding-3-small)
|
|
167
|
-
|
|
167
|
+
|
|
168
168
|
Note: LangChain is required to use the model format specification.
|
|
169
169
|
"""
|
|
170
170
|
|
|
171
171
|
fields: list[str] | None
|
|
172
172
|
"""Fields to extract text from for embedding generation.
|
|
173
|
-
|
|
173
|
+
|
|
174
174
|
Defaults to the root ["$"], which embeds the json object as a whole.
|
|
175
175
|
"""
|
|
176
176
|
|
|
@@ -180,21 +180,21 @@ class TTLConfig(TypedDict, total=False):
|
|
|
180
180
|
|
|
181
181
|
refresh_on_read: bool
|
|
182
182
|
"""Default behavior for refreshing TTLs on read operations (GET and SEARCH).
|
|
183
|
-
|
|
183
|
+
|
|
184
184
|
If True, TTLs will be refreshed on read operations (get/search) by default.
|
|
185
185
|
This can be overridden per-operation by explicitly setting refresh_ttl.
|
|
186
186
|
Defaults to True if not configured.
|
|
187
187
|
"""
|
|
188
188
|
default_ttl: float | None
|
|
189
189
|
"""Default TTL (time-to-live) in minutes for new items.
|
|
190
|
-
|
|
190
|
+
|
|
191
191
|
If provided, new items will expire after this many minutes after their last access.
|
|
192
192
|
The expiration timer refreshes on both read and write operations.
|
|
193
193
|
Defaults to None (no expiration).
|
|
194
194
|
"""
|
|
195
195
|
sweep_interval_minutes: int | None
|
|
196
196
|
"""Interval in minutes between TTL sweep operations.
|
|
197
|
-
|
|
197
|
+
|
|
198
198
|
If provided, the store will periodically delete expired items based on TTL.
|
|
199
199
|
Defaults to None (no sweeping).
|
|
200
200
|
"""
|
|
@@ -223,11 +223,11 @@ class SerdeConfig(TypedDict, total=False):
|
|
|
223
223
|
|
|
224
224
|
allowed_json_modules: list[list[str]] | Literal[True] | None
|
|
225
225
|
"""Optional. List of allowed python modules to de-serialize custom objects from.
|
|
226
|
-
|
|
226
|
+
|
|
227
227
|
If provided, only the specified modules will be allowed to be deserialized.
|
|
228
228
|
If omitted, no modules are allowed, and the object returned will simply be a json object OR
|
|
229
229
|
a deserialized langchain object.
|
|
230
|
-
|
|
230
|
+
|
|
231
231
|
Example:
|
|
232
232
|
{...
|
|
233
233
|
"serde": {
|
|
@@ -245,11 +245,11 @@ class SerdeConfig(TypedDict, total=False):
|
|
|
245
245
|
"allowed_json_modules": true
|
|
246
246
|
}
|
|
247
247
|
}
|
|
248
|
-
|
|
248
|
+
|
|
249
249
|
"""
|
|
250
250
|
pickle_fallback: bool
|
|
251
251
|
"""Optional. Whether to allow pickling as a fallback for deserialization.
|
|
252
|
-
|
|
252
|
+
|
|
253
253
|
If True, pickling will be allowed as a fallback for deserialization.
|
|
254
254
|
If False, pickling will not be allowed as a fallback for deserialization.
|
|
255
255
|
Defaults to True if not configured."""
|
|
@@ -261,7 +261,7 @@ class DefaultCheckpointerConfig(TypedDict):
|
|
|
261
261
|
backend: Literal["default"]
|
|
262
262
|
ttl: NotRequired[ThreadTTLConfig | None]
|
|
263
263
|
"""Optional. Defines the TTL (time-to-live) behavior configuration.
|
|
264
|
-
|
|
264
|
+
|
|
265
265
|
If provided, the checkpointer will apply TTL settings according to the configuration.
|
|
266
266
|
If omitted, no TTL behavior is configured.
|
|
267
267
|
"""
|
|
@@ -284,7 +284,7 @@ class CustomCheckpointerConfig(TypedDict):
|
|
|
284
284
|
"""
|
|
285
285
|
ttl: NotRequired[ThreadTTLConfig | None]
|
|
286
286
|
"""Optional. Defines the TTL (time-to-live) behavior configuration.
|
|
287
|
-
|
|
287
|
+
|
|
288
288
|
If provided, the checkpointer will apply TTL settings according to the configuration.
|
|
289
289
|
If omitted, no TTL behavior is configured.
|
|
290
290
|
"""
|
langgraph_api/cron_scheduler.py
CHANGED
|
@@ -25,6 +25,7 @@ else:
|
|
|
25
25
|
logger = structlog.stdlib.get_logger(__name__)
|
|
26
26
|
|
|
27
27
|
SLEEP_TIME = config.CRON_SCHEDULER_SLEEP_TIME
|
|
28
|
+
BATCH_SIZE = config.CRON_SCHEDULER_BATCH_SIZE
|
|
28
29
|
|
|
29
30
|
|
|
30
31
|
def get_metadata_from_payload(
|
|
@@ -47,13 +48,19 @@ def get_metadata_from_payload(
|
|
|
47
48
|
return {**cron_metadata, **existing_metadata}
|
|
48
49
|
|
|
49
50
|
|
|
51
|
+
def batch_was_full(claimed: int) -> bool:
|
|
52
|
+
return BATCH_SIZE > 0 and claimed >= BATCH_SIZE
|
|
53
|
+
|
|
54
|
+
|
|
50
55
|
@retry_db
|
|
51
56
|
async def cron_scheduler():
|
|
52
57
|
logger.info("Starting cron scheduler")
|
|
53
58
|
while True:
|
|
54
59
|
try:
|
|
60
|
+
claimed = 0
|
|
55
61
|
async with connect() as conn:
|
|
56
62
|
async for cron, enc_ctx in Crons.next(conn):
|
|
63
|
+
claimed += 1
|
|
57
64
|
on_run_completed = cron.get("on_run_completed")
|
|
58
65
|
|
|
59
66
|
run_payload = cron["payload"]
|
|
@@ -75,6 +82,12 @@ async def cron_scheduler():
|
|
|
75
82
|
):
|
|
76
83
|
logger.debug(f"Scheduling cron run {cron}")
|
|
77
84
|
try:
|
|
85
|
+
cron_id = None
|
|
86
|
+
if run_payload.get("context"):
|
|
87
|
+
# Cron persistence and set_auth_ctx_for_run write these into configurable, which create_valid_run rejects next to context.
|
|
88
|
+
configurable = run_payload["config"]["configurable"]
|
|
89
|
+
cron_id = configurable.pop("cron_id", None)
|
|
90
|
+
configurable.pop("langgraph_auth_user", None)
|
|
78
91
|
run = await create_valid_run(
|
|
79
92
|
conn,
|
|
80
93
|
thread_id=(
|
|
@@ -84,6 +97,7 @@ async def cron_scheduler():
|
|
|
84
97
|
),
|
|
85
98
|
payload=run_payload,
|
|
86
99
|
headers={},
|
|
100
|
+
cron_id=cron_id,
|
|
87
101
|
)
|
|
88
102
|
if not run:
|
|
89
103
|
logger.error(
|
|
@@ -112,7 +126,8 @@ async def cron_scheduler():
|
|
|
112
126
|
conn, cron["cron_id"], next_run_date
|
|
113
127
|
)
|
|
114
128
|
|
|
115
|
-
|
|
129
|
+
if not batch_was_full(claimed):
|
|
130
|
+
await asyncio.sleep(SLEEP_TIME)
|
|
116
131
|
except asyncio.CancelledError:
|
|
117
132
|
raise
|
|
118
133
|
except Exception:
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
|
|
@@ -194,11 +194,13 @@ class ThreadRunManager:
|
|
|
194
194
|
self._seq: int = 0
|
|
195
195
|
self._thread_source_task: asyncio.Task[None] | None = None
|
|
196
196
|
# Set by ``_consume_thread_stream`` when the background task
|
|
197
|
-
# exits
|
|
198
|
-
#
|
|
199
|
-
#
|
|
200
|
-
#
|
|
197
|
+
# exits for any reason, including cancellation. Route handlers
|
|
198
|
+
# (SSE body_iter, WS watchdog) await this so a transient
|
|
199
|
+
# ``join_event_streaming`` failure can no longer leave the
|
|
200
|
+
# connection wedged with no events flowing.
|
|
201
201
|
self._thread_stream_done: asyncio.Event = asyncio.Event()
|
|
202
|
+
# Set when the consumer stops for any reason other than cancellation.
|
|
203
|
+
self._thread_stream_failed: bool = False
|
|
202
204
|
# WebSocket clients typically call ``subscription.subscribe``
|
|
203
205
|
# before ``run.start`` — natural ordering since they want to
|
|
204
206
|
# observe before firing. Buffer those subscriptions here and
|
|
@@ -224,6 +226,15 @@ class ThreadRunManager:
|
|
|
224
226
|
def seq(self) -> int:
|
|
225
227
|
return self._seq
|
|
226
228
|
|
|
229
|
+
@property
|
|
230
|
+
def thread_stream_failed(self) -> bool:
|
|
231
|
+
"""Whether the thread-stream consumer stopped without being cancelled.
|
|
232
|
+
|
|
233
|
+
A thread stream has no natural end, so the consumer stopping on its
|
|
234
|
+
own, by raising or by its source finishing, means the read failed.
|
|
235
|
+
"""
|
|
236
|
+
return self._thread_stream_failed
|
|
237
|
+
|
|
227
238
|
async def attach_event_sink(self, send_event: EventSink) -> None:
|
|
228
239
|
"""Attach a live transport consumer and flush buffered events."""
|
|
229
240
|
self._send_event = send_event
|
|
@@ -307,12 +318,19 @@ class ThreadRunManager:
|
|
|
307
318
|
except asyncio.CancelledError:
|
|
308
319
|
raise
|
|
309
320
|
except Exception as exc:
|
|
321
|
+
self._thread_stream_failed = True
|
|
310
322
|
await logger.aexception(
|
|
311
323
|
"protocol thread stream failed",
|
|
312
324
|
thread_id=str(self._thread_id),
|
|
313
325
|
error=str(exc),
|
|
314
326
|
error_type=type(exc).__name__,
|
|
315
327
|
)
|
|
328
|
+
else:
|
|
329
|
+
self._thread_stream_failed = True
|
|
330
|
+
await logger.awarning(
|
|
331
|
+
"protocol thread stream ended unexpectedly",
|
|
332
|
+
thread_id=str(self._thread_id),
|
|
333
|
+
)
|
|
316
334
|
finally:
|
|
317
335
|
# Wake up SSE body_iter / WS watchdog so the connection can
|
|
318
336
|
# be torn down. Without this, a transient ``join_event_streaming``
|