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.
Files changed (34) hide show
  1. LICENSE +1 -1
  2. langgraph_api/__init__.py +1 -1
  3. langgraph_api/api/event_streaming.py +31 -11
  4. langgraph_api/api/threads.py +2 -7
  5. langgraph_api/cache.py +86 -26
  6. langgraph_api/config/__init__.py +45 -5
  7. langgraph_api/config/schemas.py +12 -12
  8. langgraph_api/cron_scheduler.py +16 -1
  9. langgraph_api/event_streaming/__init__.py +0 -1
  10. langgraph_api/event_streaming/service.py +22 -4
  11. langgraph_api/feature_flags.py +35 -4
  12. langgraph_api/grpc/ops/crons.py +3 -2
  13. langgraph_api/grpc/ops/runs.py +3 -0
  14. langgraph_api/grpc/ops/threads.py +44 -0
  15. langgraph_api/js/package.json +1 -1
  16. langgraph_api/js/yarn.lock +7 -7
  17. langgraph_api/models/run.py +4 -0
  18. langgraph_api/schema.py +3 -0
  19. langgraph_api/store.py +7 -6
  20. langgraph_api/stream.py +1 -0
  21. langgraph_api/utils/cache.py +22 -10
  22. langgraph_api/worker.py +2 -0
  23. {langgraph_api-0.17.0.dev3.dist-info → langgraph_api-0.17.0.dev5.dist-info}/METADATA +2 -2
  24. {langgraph_api-0.17.0.dev3.dist-info → langgraph_api-0.17.0.dev5.dist-info}/RECORD +34 -34
  25. {langgraph_api-0.17.0.dev3.dist-info → langgraph_api-0.17.0.dev5.dist-info}/licenses/LICENSE +1 -1
  26. langgraph_grpc_common/conversion/checkpoint.py +19 -14
  27. langgraph_grpc_common/proto/checkpointer_pb2.pyi +1 -1
  28. langgraph_grpc_common/proto/core_api_pb2.py +135 -123
  29. langgraph_grpc_common/proto/core_api_pb2.pyi +163 -2
  30. langgraph_grpc_common/proto/core_api_pb2_grpc.py +47 -3
  31. langgraph_grpc_common/proto/core_api_pb2_grpc.pyi +15 -3
  32. openapi.json +9 -2
  33. {langgraph_api-0.17.0.dev3.dist-info → langgraph_api-0.17.0.dev5.dist-info}/WHEEL +0 -0
  34. {langgraph_api-0.17.0.dev3.dist-info → langgraph_api-0.17.0.dev5.dist-info}/entry_points.txt +0 -0
LICENSE CHANGED
@@ -90,4 +90,4 @@ these terms.
90
90
 
91
91
  **use** means anything you do with the software requiring one of your licenses.
92
92
 
93
- **trademark** means trademarks, service marks, and similar rights.
93
+ **trademark** means trademarks, service marks, and similar rights.
langgraph_api/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.17.0.dev3"
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 dies (transient join_event_streaming
249
- # error, or normal shutdown), we want this loop to exit
250
- # rather than wedge forever waiting for events that will
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 ends (transient
370
- # ``join_event_streaming`` failure or normal shutdown), close the socket so
371
- # the client sees a clean disconnect instead of an open connection
372
- # that will never deliver another event. ``receive_text`` below
373
- # raises ``WebSocketDisconnect`` once the close lands, exiting the
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
 
@@ -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
- thread_storage = getattr(runtime_checkpoint, "thread_storage", None)
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
- await fetchone(await Threads.get(conn, thread_id))
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. After this, the next access
162
- blocks on the loader. Defaults to :data:`MAX_CACHE_TTL` (24 h by
163
- default). Values above :data:`MAX_CACHE_TTL` are clamped to the
164
- backend maximum.
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, resolved_max_age = _validate_swr_windows(
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 _validate_swr_windows(
256
- fresh_for: timedelta, max_age: timedelta
257
- ) -> tuple[int, int, timedelta]:
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, ttl: timedelta) -> None:
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": _now_ms(),
374
+ "stored_at_ms": stored_at_ms,
323
375
  }
324
- await cache_set(cache_key, entry, ttl=ttl)
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, ttl: timedelta) -> None:
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, ttl)
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, ttl: timedelta, *, start_epoch: int
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, ttl)
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
- ttl: timedelta,
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(cache_key, value, ttl, start_epoch=start_epoch):
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
- ttl: timedelta,
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, ttl, start_epoch=state.write_epoch
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]], ttl: timedelta
470
+ cache_key: str, loader: Callable[[], Awaitable[T]], max_age: timedelta
411
471
  ) -> T:
412
- return await _ensure_swr_load_task(cache_key, loader, ttl, log_errors=False)
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]], ttl: timedelta
476
+ cache_key: str, loader: Callable[[], Awaitable[Any]], max_age: timedelta
417
477
  ) -> None:
418
- _ensure_swr_load_task(cache_key, loader, ttl, log_errors=True)
478
+ _ensure_swr_load_task(cache_key, loader, max_age, log_errors=True)
@@ -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",
@@ -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
  """
@@ -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
- await asyncio.sleep(SLEEP_TIME)
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 — success, cancellation, or unexpected failure all
198
- # transition this. Route handlers (SSE body_iter, WS watchdog)
199
- # await this so a transient ``join_event_streaming`` failure can no
200
- # longer leave the connection wedged with no events flowing.
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``