langgraph-api 0.17.0.dev3__py3-none-any.whl → 0.17.0.dev4__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 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.dev4"
@@ -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
-
@@ -1,8 +1,11 @@
1
1
  import os
2
2
  import re
3
3
 
4
+ import structlog
4
5
  from langgraph.version import __version__
5
6
 
7
+ logger = structlog.stdlib.get_logger(__name__)
8
+
6
9
  # Only gate features on the major.minor version; Lets you ignore the rc/alpha/etc. releases anyway
7
10
  LANGGRAPH_PY_MINOR = tuple(map(int, __version__.split(".")[:2]))
8
11
 
@@ -29,14 +32,42 @@ FF_V2_EVENT_STREAMING = os.getenv("FF_V2_EVENT_STREAMING", "true").lower() in (
29
32
  "yes",
30
33
  )
31
34
 
35
+
36
+ def _resolve_prefer_grpc_checkpointer(
37
+ env_value: str | None, *, langgraph_supported: bool, is_postgres: bool
38
+ ) -> bool:
39
+ enabled = ("true" if env_value is None else env_value).lower() in (
40
+ "true",
41
+ "1",
42
+ "yes",
43
+ )
44
+ if langgraph_supported:
45
+ return enabled
46
+ if is_postgres:
47
+ ignored = (
48
+ "Ignoring PREFER_GRPC_CHECKPOINTER=true. " if env_value and enabled else ""
49
+ )
50
+ logger.warning(
51
+ f"{ignored}Using the Python checkpointer because the Go checkpointer "
52
+ "requires langgraph>=0.5. Please upgrade langgraph: support for "
53
+ "langgraph<0.5 will be removed in a future release.",
54
+ langgraph_version=__version__,
55
+ )
56
+ return False
57
+
58
+
32
59
  # When true on the postgres runtime, use ``GrpcCheckpointer``.
33
60
  # Inmem edition is unaffected (``IS_POSTGRES_BACKEND`` is false there).
34
61
  # Custom checkpointer (``backend=custom`` in ``LANGGRAPH_CHECKPOINTER``) and
35
62
  # Mongo (``backend=mongo``) is also unaffected.
36
- PREFER_GRPC_CHECKPOINTER = os.getenv("PREFER_GRPC_CHECKPOINTER", "false").lower() in (
37
- "true",
38
- "1",
39
- "yes",
63
+ # langgraph < 0.5 keeps Send() packets in checkpoint["pending_sends"], which only
64
+ # the Python checkpointer rebuilds on load; the Go checkpointer drops them. Keep
65
+ # < 0.5 on the Python checkpointer. Before removing it, raise the minimum
66
+ # langgraph to >= 0.5.
67
+ PREFER_GRPC_CHECKPOINTER = _resolve_prefer_grpc_checkpointer(
68
+ os.getenv("PREFER_GRPC_CHECKPOINTER"),
69
+ langgraph_supported=OMIT_PENDING_SENDS,
70
+ is_postgres=IS_POSTGRES_BACKEND,
40
71
  )
41
72
 
42
73
  # In langgraph <= 1.0.3, we automatically subscribed to updates stream events to surface interrupts. In langgraph 1.0.4 we include interrupts in values events (which we are automatically subscribed to), so we no longer need to implicitly subscribe to updates stream events
@@ -5,7 +5,6 @@ from datetime import UTC, datetime
5
5
  from typing import TYPE_CHECKING, Any, Literal
6
6
  from uuid import UUID
7
7
 
8
- from google.protobuf.empty_pb2 import Empty # ty: ignore[unresolved-import]
9
8
  from langgraph_grpc_common.conversion.config import config_from_proto, config_to_proto
10
9
  from langgraph_grpc_common.proto import core_api_pb2 as pb
11
10
  from langgraph_grpc_common.proto import (
@@ -16,6 +15,7 @@ from langgraph_grpc_common.proto import (
16
15
  )
17
16
  from langgraph_sdk import Auth
18
17
 
18
+ from langgraph_api import config
19
19
  from langgraph_api.graph import SYSTEM_ASSISTANT_IDS, get_assistant_id
20
20
  from langgraph_api.grpc.client import get_shared_client
21
21
  from langgraph_api.grpc.ops import (
@@ -502,6 +502,7 @@ class Crons(Authenticated):
502
502
  @staticmethod
503
503
  async def next(
504
504
  conn, # Not used in gRPC implementation
505
+ limit: int = config.CRON_SCHEDULER_BATCH_SIZE,
505
506
  ) -> AsyncIterator[tuple[Cron, dict | None]]:
506
507
  """Get next crons ready to run via gRPC (internal API, no auth filters).
507
508
 
@@ -512,7 +513,7 @@ class Crons(Authenticated):
512
513
  """
513
514
 
514
515
  client = await get_shared_client()
515
- response = await client.crons.Next(Empty())
516
+ response = await client.crons.Next(pb.NextCronsRequest(limit=limit))
516
517
 
517
518
  for cron_with_now in response.crons:
518
519
  cron = proto_to_cron(cron_with_now.cron)
@@ -386,6 +386,9 @@ def proto_to_run(proto_run: pb.Run) -> Run:
386
386
  "langsmith_session_name": proto_run.langsmith_session_name
387
387
  if proto_run.HasField("langsmith_session_name")
388
388
  else None,
389
+ "cancel_reason": proto_run.cancel_reason
390
+ if proto_run.HasField("cancel_reason")
391
+ else None,
389
392
  }
390
393
 
391
394