langgraph-runtime-inmem 0.28.0__tar.gz → 0.29.0.dev1__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.
Files changed (18) hide show
  1. {langgraph_runtime_inmem-0.28.0 → langgraph_runtime_inmem-0.29.0.dev1}/.gitignore +1 -0
  2. {langgraph_runtime_inmem-0.28.0 → langgraph_runtime_inmem-0.29.0.dev1}/PKG-INFO +1 -1
  3. {langgraph_runtime_inmem-0.28.0 → langgraph_runtime_inmem-0.29.0.dev1}/langgraph_runtime_inmem/__init__.py +1 -1
  4. {langgraph_runtime_inmem-0.28.0 → langgraph_runtime_inmem-0.29.0.dev1}/langgraph_runtime_inmem/checkpoint.py +40 -1
  5. {langgraph_runtime_inmem-0.28.0 → langgraph_runtime_inmem-0.29.0.dev1}/langgraph_runtime_inmem/ops.py +119 -42
  6. {langgraph_runtime_inmem-0.28.0 → langgraph_runtime_inmem-0.29.0.dev1}/langgraph_runtime_inmem/queue.py +7 -9
  7. {langgraph_runtime_inmem-0.28.0 → langgraph_runtime_inmem-0.29.0.dev1}/langgraph_runtime_inmem/routes.py +186 -53
  8. {langgraph_runtime_inmem-0.28.0 → langgraph_runtime_inmem-0.29.0.dev1}/uv.lock +302 -249
  9. {langgraph_runtime_inmem-0.28.0 → langgraph_runtime_inmem-0.29.0.dev1}/Makefile +0 -0
  10. {langgraph_runtime_inmem-0.28.0 → langgraph_runtime_inmem-0.29.0.dev1}/README.md +0 -0
  11. {langgraph_runtime_inmem-0.28.0 → langgraph_runtime_inmem-0.29.0.dev1}/langgraph_runtime_inmem/_persistence.py +0 -0
  12. {langgraph_runtime_inmem-0.28.0 → langgraph_runtime_inmem-0.29.0.dev1}/langgraph_runtime_inmem/database.py +0 -0
  13. {langgraph_runtime_inmem-0.28.0 → langgraph_runtime_inmem-0.29.0.dev1}/langgraph_runtime_inmem/inmem_stream.py +0 -0
  14. {langgraph_runtime_inmem-0.28.0 → langgraph_runtime_inmem-0.29.0.dev1}/langgraph_runtime_inmem/lifespan.py +0 -0
  15. {langgraph_runtime_inmem-0.28.0 → langgraph_runtime_inmem-0.29.0.dev1}/langgraph_runtime_inmem/metrics.py +0 -0
  16. {langgraph_runtime_inmem-0.28.0 → langgraph_runtime_inmem-0.29.0.dev1}/langgraph_runtime_inmem/retry.py +0 -0
  17. {langgraph_runtime_inmem-0.28.0 → langgraph_runtime_inmem-0.29.0.dev1}/langgraph_runtime_inmem/store.py +0 -0
  18. {langgraph_runtime_inmem-0.28.0 → langgraph_runtime_inmem-0.29.0.dev1}/pyproject.toml +0 -0
@@ -1,5 +1,6 @@
1
1
  *.env
2
2
  .env.gcp.yaml
3
+ .worktrees/
3
4
  postgres-volume/
4
5
  redis-volume/
5
6
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langgraph-runtime-inmem
3
- Version: 0.28.0
3
+ Version: 0.29.0.dev1
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
@@ -10,7 +10,7 @@ from langgraph_runtime_inmem import (
10
10
  store,
11
11
  )
12
12
 
13
- __version__ = "0.28.0"
13
+ __version__ = "0.29.0.dev1"
14
14
  __all__ = [
15
15
  "ops",
16
16
  "database",
@@ -241,8 +241,47 @@ def Checkpointer(*args, unpack_hook=None, **kwargs):
241
241
  if unpack_hook is not None:
242
242
  from langgraph_api.serde import Serializer # noqa: PLC0415
243
243
 
244
+ # Prefer the API-level feature flag when available; older
245
+ # langgraph-api versions may not define it yet.
246
+ try:
247
+ from langgraph_api.feature_flags import ( # noqa: PLC0415
248
+ DELTA_CHANNEL_SUPPORT,
249
+ )
250
+ except ImportError:
251
+ DELTA_CHANNEL_SUPPORT = False
252
+
253
+ # DeltaChannel snapshots only exist on langgraph >= 1.2; on older
254
+ # installs the ``EXT_DELTA_SNAPSHOT`` codepoint can never appear in
255
+ # serialized payloads, so the bare ``unpack_hook`` is sufficient.
256
+ if DELTA_CHANNEL_SUPPORT:
257
+ from langgraph.checkpoint.serde.jsonplus import ( # noqa: PLC0415
258
+ EXT_DELTA_SNAPSHOT, # ty: ignore[unresolved-import]
259
+ )
260
+ from langgraph.checkpoint.serde.types import ( # noqa: PLC0415
261
+ _DeltaSnapshot, # ty: ignore[unresolved-import]
262
+ )
263
+
264
+ _inner_hook = unpack_hook
265
+
266
+ def _delta_aware_hook(code: int, data: bytes) -> Any:
267
+ if code == EXT_DELTA_SNAPSHOT:
268
+ import ormsgpack # noqa: PLC0415
269
+
270
+ return _DeltaSnapshot(
271
+ ormsgpack.unpackb(
272
+ data,
273
+ ext_hook=_delta_aware_hook,
274
+ option=ormsgpack.OPT_NON_STR_KEYS,
275
+ )
276
+ )
277
+ return _inner_hook(code, data)
278
+
279
+ ext_hook = _delta_aware_hook
280
+ else:
281
+ ext_hook = unpack_hook
282
+
244
283
  saver = InMemorySaver(
245
- serde=Serializer(__unpack_ext_hook__=unpack_hook),
284
+ serde=Serializer(__unpack_ext_hook__=ext_hook),
246
285
  __persistence_hook__=register_persistent_dict,
247
286
  **kwargs,
248
287
  )
@@ -1949,6 +1949,27 @@ class Threads(Authenticated):
1949
1949
  stream_modes: list[ThreadStreamMode],
1950
1950
  ctx: Auth.types.BaseAuthContext | None = None,
1951
1951
  ) -> AsyncIterator[tuple[bytes, bytes, bytes | None]]:
1952
+ async for (
1953
+ event,
1954
+ payload,
1955
+ stream_id,
1956
+ _run_id,
1957
+ ) in Threads.Stream.join_event_streaming(
1958
+ thread_id,
1959
+ last_event_id=last_event_id,
1960
+ stream_modes=stream_modes,
1961
+ ctx=ctx,
1962
+ ):
1963
+ yield event, payload, stream_id
1964
+
1965
+ @staticmethod
1966
+ async def join_event_streaming(
1967
+ thread_id: UUID,
1968
+ *,
1969
+ last_event_id: str | None = None,
1970
+ stream_modes: list[ThreadStreamMode],
1971
+ ctx: Auth.types.BaseAuthContext | None = None,
1972
+ ) -> AsyncIterator[tuple[bytes, bytes, bytes | None, str | None]]:
1952
1973
  """Stream the thread output."""
1953
1974
  await Threads.Stream.check_thread_stream_auth(thread_id, ctx)
1954
1975
 
@@ -1986,11 +2007,14 @@ class Threads(Authenticated):
1986
2007
 
1987
2008
  # Restore messages if resuming from a specific event
1988
2009
  if last_event_id is not None:
1989
- # Collect all events from all message stores for this thread
2010
+ # ``message_stores`` is keyed by ``UUID`` (see
2011
+ # :meth:`StreamManager.put`). Callers can hand us
2012
+ # ``thread_id`` as either ``str`` or ``UUID``, so
2013
+ # normalize before the lookup — otherwise replay
2014
+ # always misses and yields nothing.
2015
+ store_key = _ensure_uuid(thread_id)
1990
2016
  all_events = []
1991
- for run_id in stream_manager.message_stores.get(
1992
- str(thread_id), []
1993
- ):
2017
+ for run_id in stream_manager.message_stores.get(store_key, []):
1994
2018
  for message in stream_manager.restore_messages(
1995
2019
  run_id, thread_id, last_event_id
1996
2020
  ):
@@ -2020,9 +2044,20 @@ class Threads(Authenticated):
2020
2044
  event_bytes,
2021
2045
  message_bytes,
2022
2046
  message.id,
2047
+ str(run_id),
2023
2048
  )
2024
2049
 
2025
- # Listen for live messages from all queues
2050
+ # Listen for live messages from all queues.
2051
+ #
2052
+ # Hot loop is non-blocking: a burst of N events drains in
2053
+ # one outer-loop iteration via ``get_nowait``, instead of
2054
+ # the previous "one event per queue per 200ms timeout"
2055
+ # pattern that throttled fast-publishing runs (the empty
2056
+ # thread-stream queue alone forced every iteration to
2057
+ # wait the full timeout). When everything is idle we fall
2058
+ # back to a short ``asyncio.sleep`` so new runs joining
2059
+ # the thread get picked up by the next ``subscribe``
2060
+ # without burning CPU.
2026
2061
  while True:
2027
2062
  # Refresh queues to pick up any new runs that joined this thread
2028
2063
  new_queue_tuples = await Threads.Stream.subscribe(
@@ -2032,40 +2067,69 @@ class Threads(Authenticated):
2032
2067
  for run_id, queue in new_queue_tuples:
2033
2068
  created_queues.append((run_id, queue))
2034
2069
 
2070
+ drained_any = False
2035
2071
  for run_id, queue in created_queues:
2036
- try:
2037
- message = await asyncio.wait_for(
2038
- queue.get(), timeout=0.2
2039
- )
2040
- decoded = decode_stream_message(
2041
- message.data, channel=message.topic
2042
- )
2072
+ while True:
2073
+ try:
2074
+ message = queue.get_nowait()
2075
+ except asyncio.QueueEmpty:
2076
+ break
2077
+ try:
2078
+ decoded = decode_stream_message(
2079
+ message.data, channel=message.topic
2080
+ )
2081
+ except (ValueError, KeyError):
2082
+ continue
2043
2083
  event = decoded.event_bytes
2044
2084
  event_name = event.decode("utf-8")
2045
2085
  payload = decoded.message_bytes
2046
2086
 
2047
2087
  if event == b"control" and payload == b"done":
2088
+ # Don't shadow the queue-iteration
2089
+ # ``run_id`` with the topic-extracted
2090
+ # string — non-control events later in
2091
+ # this drain pass would yield the
2092
+ # rebound value. Wire output is
2093
+ # identical (``str(UUID)`` matches the
2094
+ # topic suffix), but the rebinding is
2095
+ # fragile if the topic format moves.
2048
2096
  topic = message.topic.decode()
2049
- run_id = topic.split("run:")[1].split(":")[0]
2097
+ done_run_id = topic.split("run:")[1].split(":")[0]
2050
2098
  meta_event = b"metadata"
2051
2099
  meta_payload = orjson.dumps(
2052
- {"status": "run_done", "run_id": run_id}
2100
+ {"status": "run_done", "run_id": done_run_id}
2053
2101
  )
2054
2102
  if not should_filter_event(
2055
2103
  "metadata", meta_payload
2056
2104
  ):
2057
- yield (meta_event, meta_payload, message.id)
2105
+ yield (
2106
+ meta_event,
2107
+ meta_payload,
2108
+ message.id,
2109
+ done_run_id,
2110
+ )
2111
+ drained_any = True
2058
2112
  else:
2059
2113
  if not should_filter_event(event_name, payload):
2060
- yield (event, payload, message.id)
2061
-
2062
- except TimeoutError:
2063
- continue
2064
- except (ValueError, KeyError):
2065
- continue
2066
-
2067
- # Yield execution to other tasks to prevent event loop starvation
2068
- await asyncio.sleep(0)
2114
+ yield (
2115
+ event,
2116
+ payload,
2117
+ message.id,
2118
+ str(run_id),
2119
+ )
2120
+ drained_any = True
2121
+
2122
+ if drained_any:
2123
+ # Yield once so other tasks (worker, send) can
2124
+ # advance, then loop immediately to drain any
2125
+ # follow-up burst.
2126
+ await asyncio.sleep(0)
2127
+ else:
2128
+ # All queues empty — short poll interval keeps
2129
+ # ``subscribe`` rechecking for newly-spawned
2130
+ # runs and lets the worker emit without a
2131
+ # >5-events-per-second delivery cap.
2132
+ await asyncio.sleep(0.02)
2069
2133
 
2070
2134
  except WrappedHTTPException as e:
2071
2135
  raise e.http_exception from None
@@ -2913,12 +2977,18 @@ class Runs(Authenticated):
2913
2977
  run_id
2914
2978
  ):
2915
2979
  for control_queue in control_queues:
2916
- try:
2917
- while True:
2918
- control_msg = control_queue.get()
2919
- await queue.put(control_msg)
2920
- except asyncio.QueueEmpty:
2921
- pass
2980
+ # NOTE: must use ``get_nowait``. ``asyncio.Queue.get`` is a
2981
+ # coroutine — calling it without ``await`` returns a
2982
+ # coroutine object and never raises ``QueueEmpty``, which
2983
+ # turns this drain into an infinite loop that blocks the
2984
+ # event loop (the coroutine objects get pushed straight
2985
+ # into ``queue`` via ``put_nowait`` with no yield point).
2986
+ while True:
2987
+ try:
2988
+ control_msg = control_queue.get_nowait()
2989
+ except asyncio.QueueEmpty:
2990
+ break
2991
+ await queue.put(control_msg)
2922
2992
  return queue
2923
2993
 
2924
2994
  @staticmethod
@@ -3502,16 +3572,18 @@ class Crons(Authenticated):
3502
3572
  ctx: Auth.types.BaseAuthContext | None = None,
3503
3573
  sort_by: str | None = None,
3504
3574
  sort_order: Literal["asc", "desc"] | None = None,
3575
+ metadata: dict | None = None,
3505
3576
  ) -> tuple[AsyncIterator[Cron], int | None]:
3506
3577
  filters = await Crons.handle_event(
3507
3578
  ctx,
3508
3579
  "search",
3509
- Auth.types.CronsSearch(
3510
- assistant_id=assistant_id,
3511
- thread_id=thread_id,
3512
- limit=limit,
3513
- offset=offset,
3514
- ),
3580
+ {
3581
+ "assistant_id": assistant_id,
3582
+ "thread_id": thread_id,
3583
+ "limit": limit,
3584
+ "offset": offset,
3585
+ "metadata": metadata or {},
3586
+ },
3515
3587
  )
3516
3588
 
3517
3589
  if thread_id:
@@ -3535,6 +3607,7 @@ class Crons(Authenticated):
3535
3607
  if (assistant_id is None or str(c["assistant_id"]) == str(assistant_id))
3536
3608
  and (thread_id is None or str(c.get("thread_id")) == str(thread_id))
3537
3609
  and (enabled is None or c.get("enabled") == enabled)
3610
+ and (not metadata or is_jsonb_contained(c.get("metadata", {}), metadata))
3538
3611
  and (not filters or _check_filter_match(c.get("metadata", {}), filters))
3539
3612
  ]
3540
3613
 
@@ -3616,17 +3689,19 @@ class Crons(Authenticated):
3616
3689
  assistant_id: UUID | None = None,
3617
3690
  thread_id: UUID | None = None,
3618
3691
  ctx: Auth.types.BaseAuthContext | None = None,
3692
+ metadata: dict | None = None,
3619
3693
  ) -> int:
3620
3694
  """Get count of crons."""
3621
3695
  filters = await Crons.handle_event(
3622
3696
  ctx,
3623
3697
  "search",
3624
- Auth.types.CronsSearch(
3625
- assistant_id=assistant_id,
3626
- thread_id=thread_id,
3627
- limit=0,
3628
- offset=0,
3629
- ),
3698
+ {
3699
+ "assistant_id": assistant_id,
3700
+ "thread_id": thread_id,
3701
+ "limit": 0,
3702
+ "offset": 0,
3703
+ "metadata": metadata or {},
3704
+ },
3630
3705
  )
3631
3706
 
3632
3707
  if thread_id:
@@ -3649,6 +3724,8 @@ class Crons(Authenticated):
3649
3724
  continue
3650
3725
  if thread_id is not None and str(c.get("thread_id")) != str(thread_id):
3651
3726
  continue
3727
+ if metadata and not is_jsonb_contained(c.get("metadata", {}), metadata):
3728
+ continue
3652
3729
  if filters and not _check_filter_match(c.get("metadata", {}), filters):
3653
3730
  continue
3654
3731
  filtered_crons.append(c)
@@ -102,9 +102,9 @@ async def queue():
102
102
  await logger.awarning(
103
103
  "Heads up: You've set --allow-blocking, which allows synchronous blocking I/O operations."
104
104
  " Be aware that blocking code in one run may tie up the shared event loop"
105
- " and slow down ALL other server operations. For best performance, either convert blocking"
106
- " code to async patterns or set BG_JOB_ISOLATED_LOOPS=true in production"
107
- " to isolate each run in its own event loop."
105
+ " and slow down ALL other server operations. For best performance, use async drivers"
106
+ " (e.g., aiohttp instead of requests, asyncpg instead of psycopg2). If switching to an"
107
+ " async driver isn't possible, wrap the blocking call in asyncio.to_thread()."
108
108
  )
109
109
  else:
110
110
  bb = _enable_blockbuster()
@@ -369,13 +369,11 @@ def _patch_blocking_error():
369
369
  "Heads up! LangGraph dev identified a synchronous blocking call in your code. "
370
370
  "When running in an ASGI web server, blocking calls can degrade performance for everyone since they tie up the event loop.\n\n"
371
371
  "Here are your options to fix this:\n\n"
372
- "1. Best approach: Convert any blocking code to use async/await patterns\n"
373
- " For example, use 'await aiohttp.get()' instead of 'requests.get()'\n\n"
374
- "2. Quick fix: Move blocking operations to a separate thread\n"
372
+ "1. Best approach: Use an async driver so the call is non-blocking\n"
373
+ " For example, use 'await aiohttp.get()' instead of 'requests.get()', or asyncpg instead of psycopg2.\n\n"
374
+ "2. If an async driver isn't available: wrap the blocking call in a thread\n"
375
375
  " Example: 'await asyncio.to_thread(your_blocking_function)'\n\n"
376
- "3. Override (if you can't change the code):\n"
377
- " - For development: Run 'langgraph dev --allow-blocking'\n"
378
- " - For deployment: Set 'BG_JOB_ISOLATED_LOOPS=true' environment variable\n\n"
376
+ "3. Dev-only override: run 'langgraph dev --allow-blocking'\n\n"
379
377
  "These blocking operations can prevent health checks and slow down other runs in your deployment. "
380
378
  "Following these recommendations will help keep your LangGraph application running smoothly!"
381
379
  )