langgraph-runtime-inmem 0.37.0.dev5__tar.gz → 0.38.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 (19) hide show
  1. {langgraph_runtime_inmem-0.37.0.dev5 → langgraph_runtime_inmem-0.38.0.dev1}/PKG-INFO +1 -1
  2. {langgraph_runtime_inmem-0.37.0.dev5 → langgraph_runtime_inmem-0.38.0.dev1}/langgraph_runtime_inmem/__init__.py +1 -1
  3. {langgraph_runtime_inmem-0.37.0.dev5 → langgraph_runtime_inmem-0.38.0.dev1}/langgraph_runtime_inmem/database.py +8 -21
  4. {langgraph_runtime_inmem-0.37.0.dev5 → langgraph_runtime_inmem-0.38.0.dev1}/langgraph_runtime_inmem/ops.py +108 -34
  5. {langgraph_runtime_inmem-0.37.0.dev5 → langgraph_runtime_inmem-0.38.0.dev1}/.gitignore +0 -0
  6. {langgraph_runtime_inmem-0.37.0.dev5 → langgraph_runtime_inmem-0.38.0.dev1}/Makefile +0 -0
  7. {langgraph_runtime_inmem-0.37.0.dev5 → langgraph_runtime_inmem-0.38.0.dev1}/README.md +0 -0
  8. {langgraph_runtime_inmem-0.37.0.dev5 → langgraph_runtime_inmem-0.38.0.dev1}/langgraph_runtime_inmem/_persistence.py +0 -0
  9. {langgraph_runtime_inmem-0.37.0.dev5 → langgraph_runtime_inmem-0.38.0.dev1}/langgraph_runtime_inmem/checkpoint.py +0 -0
  10. {langgraph_runtime_inmem-0.37.0.dev5 → langgraph_runtime_inmem-0.38.0.dev1}/langgraph_runtime_inmem/encryption.py +0 -0
  11. {langgraph_runtime_inmem-0.37.0.dev5 → langgraph_runtime_inmem-0.38.0.dev1}/langgraph_runtime_inmem/inmem_stream.py +0 -0
  12. {langgraph_runtime_inmem-0.37.0.dev5 → langgraph_runtime_inmem-0.38.0.dev1}/langgraph_runtime_inmem/lifespan.py +0 -0
  13. {langgraph_runtime_inmem-0.37.0.dev5 → langgraph_runtime_inmem-0.38.0.dev1}/langgraph_runtime_inmem/metrics.py +0 -0
  14. {langgraph_runtime_inmem-0.37.0.dev5 → langgraph_runtime_inmem-0.38.0.dev1}/langgraph_runtime_inmem/queue.py +0 -0
  15. {langgraph_runtime_inmem-0.37.0.dev5 → langgraph_runtime_inmem-0.38.0.dev1}/langgraph_runtime_inmem/retry.py +0 -0
  16. {langgraph_runtime_inmem-0.37.0.dev5 → langgraph_runtime_inmem-0.38.0.dev1}/langgraph_runtime_inmem/routes.py +0 -0
  17. {langgraph_runtime_inmem-0.37.0.dev5 → langgraph_runtime_inmem-0.38.0.dev1}/langgraph_runtime_inmem/store.py +0 -0
  18. {langgraph_runtime_inmem-0.37.0.dev5 → langgraph_runtime_inmem-0.38.0.dev1}/pyproject.toml +0 -0
  19. {langgraph_runtime_inmem-0.37.0.dev5 → langgraph_runtime_inmem-0.38.0.dev1}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langgraph-runtime-inmem
3
- Version: 0.37.0.dev5
3
+ Version: 0.38.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
@@ -11,7 +11,7 @@ from langgraph_runtime_inmem import (
11
11
  store,
12
12
  )
13
13
 
14
- __version__ = "0.37.0.dev5"
14
+ __version__ = "0.38.0.dev1"
15
15
  __all__ = [
16
16
  "ops",
17
17
  "database",
@@ -195,28 +195,15 @@ def _load_ops_file() -> None:
195
195
 
196
196
 
197
197
  def upgrade_thread_status_facts(store: dict[str, Any]) -> None:
198
- """Bring threads from ops files saved by older versions in line with how
199
- status is derived now, as migration 000066 does for postgres.
200
-
201
- Interrupted threads are recorded as having next steps; other threads count as
202
- having none until their next snapshot. Threads whose status was `error` are
203
- recorded as having failed. Cancels, which older versions stored in the thread's
204
- error, are cleared when the error reads as exactly one; anything else, encrypted
205
- errors included, stays.
198
+ """Ensure fact maps exist for ops files saved by older versions.
199
+
200
+ Migration 000066 only adds nullable has_next / failed columns; it does not
201
+ backfill. Writers fill the facts on the next status write, and status
202
+ re-derivation keeps the stored status while either fact is still unknown.
203
+ Cancels that older versions left in ``error`` are left as-is.
206
204
  """
207
- has_next = store.setdefault("thread_has_next", {})
208
- failed = store.setdefault("thread_failed", {})
209
- for thread in store["threads"]:
210
- if thread.get("status") == "interrupted":
211
- has_next.setdefault(thread["thread_id"], True)
212
- failed.setdefault(thread["thread_id"], thread.get("status") == "error")
213
- error = thread.get("error")
214
- if (
215
- isinstance(error, dict)
216
- and "__encryption_context__" not in error
217
- and error.get("error") in ("UserInterrupt", "UserRollback")
218
- ):
219
- thread["error"] = None
205
+ store.setdefault("thread_has_next", {})
206
+ store.setdefault("thread_failed", {})
220
207
 
221
208
 
222
209
  def _set_aside_ops_file(reason: Exception) -> None:
@@ -122,6 +122,34 @@ def _thread_failure(exception: BaseException | None) -> BaseException | None:
122
122
  return None if isinstance(exception, UserInterrupt) else exception
123
123
 
124
124
 
125
+ def _derive_thread_status(
126
+ *,
127
+ has_active_runs: bool,
128
+ failed: bool | None,
129
+ has_next: bool | None,
130
+ existing_status: ThreadStatus,
131
+ ) -> ThreadStatus:
132
+ """The one rule for a thread's status from known facts.
133
+
134
+ busy if a run is active; otherwise error if the last run failed; otherwise
135
+ interrupted if the graph has next steps; otherwise keep ``existing_status``
136
+ when ``failed`` or ``has_next`` is still unknown (None); otherwise idle.
137
+
138
+ A leftover busy with no active runs becomes idle.
139
+ """
140
+ if has_active_runs:
141
+ return "busy"
142
+ if failed:
143
+ return "error"
144
+ if has_next:
145
+ return "interrupted"
146
+ if failed is None or has_next is None:
147
+ if existing_status == "busy":
148
+ return "idle"
149
+ return existing_status
150
+ return "idle"
151
+
152
+
125
153
  def _thread_status(
126
154
  conn: InMemConnectionProto,
127
155
  thread_id: UUID,
@@ -131,27 +159,29 @@ def _thread_status(
131
159
  ) -> ThreadStatus:
132
160
  """The one rule for a thread's status, used by every write that changes it.
133
161
 
134
- busy if a run on the thread is pending or running; otherwise error if the last
135
- run failed; otherwise interrupted if the graph has next steps (an interrupt or a
136
- breakpoint); otherwise idle.
137
-
138
162
  A nil ``failed`` or ``has_next`` means the caller doesn't know the fact, so it is
139
- read from what is stored on the thread; an unknown fact counts as no.
163
+ read from what is stored on the thread; a fact still missing from the store is
164
+ unknown and keeps the thread's current status (see ``_derive_thread_status``).
140
165
  """
141
- if any(
166
+ has_active_runs = any(
142
167
  r["thread_id"] == thread_id and r["status"] in ("pending", "running")
143
168
  for r in conn.store["runs"]
144
- ):
145
- return "busy"
146
- if failed is None:
147
- failed = _thread_failed(conn).get(thread_id, False)
148
- if failed:
149
- return "error"
150
- if has_next is None:
151
- has_next = _thread_has_next(conn).get(thread_id, False)
152
- if has_next:
153
- return "interrupted"
154
- return "idle"
169
+ )
170
+ if failed is None and thread_id in _thread_failed(conn):
171
+ failed = _thread_failed(conn)[thread_id]
172
+ if has_next is None and thread_id in _thread_has_next(conn):
173
+ has_next = _thread_has_next(conn)[thread_id]
174
+ existing_status: ThreadStatus = "idle"
175
+ for thread in conn.store["threads"]:
176
+ if thread["thread_id"] == thread_id:
177
+ existing_status = thread["status"]
178
+ break
179
+ return _derive_thread_status(
180
+ has_active_runs=has_active_runs,
181
+ failed=failed,
182
+ has_next=has_next,
183
+ existing_status=existing_status,
184
+ )
155
185
 
156
186
 
157
187
  async def _save_thread_status(
@@ -948,15 +978,24 @@ def is_jsonb_contained(superset: dict[str, Any], subset: dict[str, Any]) -> bool
948
978
  """
949
979
  if not isinstance(superset, dict):
950
980
  return False
951
- for key, value in subset.items():
952
- if key not in superset:
953
- return False
954
- if isinstance(value, dict) and isinstance(superset[key], dict):
955
- if not is_jsonb_contained(superset[key], value):
956
- return False
957
- elif superset[key] != value:
958
- return False
959
- return True
981
+ return all(
982
+ key in superset and _is_jsonb_value_contained(superset[key], value)
983
+ for key, value in subset.items()
984
+ )
985
+
986
+
987
+ def _is_jsonb_value_contained(superset: Any, subset: Any) -> bool:
988
+ """Implements Postgres' @> semantics for arbitrary JSON values."""
989
+ if isinstance(subset, dict):
990
+ return isinstance(superset, dict) and is_jsonb_contained(superset, subset)
991
+ if isinstance(subset, list):
992
+ return isinstance(superset, list) and all(
993
+ any(_is_jsonb_value_contained(item, sub_item) for item in superset)
994
+ for sub_item in subset
995
+ )
996
+ if isinstance(superset, bool) or isinstance(subset, bool):
997
+ return superset is subset
998
+ return superset == subset
960
999
 
961
1000
 
962
1001
  def bytes_decoder(obj):
@@ -1060,6 +1099,7 @@ class Threads(Authenticated):
1060
1099
  sort_order: str | None = None,
1061
1100
  select: list[ThreadSelectField] | None = None,
1062
1101
  extract: dict[str, str] | None = None,
1102
+ exclude_metadata: list[dict[str, Any]] | None = None,
1063
1103
  ctx: Auth.types.BaseAuthContext | None = None,
1064
1104
  ) -> tuple[AsyncIterator[Thread], int]:
1065
1105
  threads = conn.store["threads"]
@@ -1102,6 +1142,12 @@ class Threads(Authenticated):
1102
1142
  if metadata and not is_jsonb_contained(thread["metadata"], metadata):
1103
1143
  continue
1104
1144
 
1145
+ if exclude_metadata and any(
1146
+ is_jsonb_contained(thread["metadata"], excluded)
1147
+ for excluded in exclude_metadata
1148
+ ):
1149
+ continue
1150
+
1105
1151
  if (
1106
1152
  values
1107
1153
  and "values" in thread
@@ -1683,19 +1729,30 @@ class Threads(Authenticated):
1683
1729
  # The copy gets the original's checkpoints below, so it starts where the
1684
1730
  # original stands: same values, interrupts and error, and the same fact
1685
1731
  # about whether its graph has next steps. Its status comes from those,
1686
- # by the usual rule: the original's runs are not the copy's.
1732
+ # by the usual rule: the original's runs are not the copy's. Missing
1733
+ # facts keep the original's status (except a stale busy → idle).
1687
1734
  now = datetime.now(tz=UTC)
1735
+ src_failed = (
1736
+ _thread_failed(conn)[thread_id]
1737
+ if thread_id in _thread_failed(conn)
1738
+ else None
1739
+ )
1740
+ src_has_next = (
1741
+ _thread_has_next(conn)[thread_id]
1742
+ if thread_id in _thread_has_next(conn)
1743
+ else None
1744
+ )
1688
1745
  new_thread: Thread = {
1689
1746
  "thread_id": new_thread_id,
1690
1747
  "created_at": now,
1691
1748
  "updated_at": now,
1692
1749
  "state_updated_at": now,
1693
1750
  "metadata": copy.deepcopy(original_thread["metadata"]),
1694
- "status": _thread_status(
1695
- conn,
1696
- new_thread_id,
1697
- failed=_thread_failed(conn).get(thread_id, False),
1698
- has_next=_thread_has_next(conn).get(thread_id, False),
1751
+ "status": _derive_thread_status(
1752
+ has_active_runs=False,
1753
+ failed=src_failed,
1754
+ has_next=src_has_next,
1755
+ existing_status=original_thread["status"],
1699
1756
  ),
1700
1757
  "config": {},
1701
1758
  "values": copy.deepcopy(original_thread.get("values")),
@@ -2462,6 +2519,7 @@ class Threads(Authenticated):
2462
2519
  metadata: MetadataInput = None,
2463
2520
  values: MetadataInput = None,
2464
2521
  status: ThreadStatus | None = None,
2522
+ exclude_metadata: list[dict[str, Any]] | None = None,
2465
2523
  ctx: Auth.types.BaseAuthContext | None = None,
2466
2524
  ) -> int:
2467
2525
  """Get count of threads."""
@@ -2490,6 +2548,12 @@ class Threads(Authenticated):
2490
2548
  if metadata and not is_jsonb_contained(thread["metadata"], metadata):
2491
2549
  continue
2492
2550
 
2551
+ if exclude_metadata and any(
2552
+ is_jsonb_contained(thread["metadata"], excluded)
2553
+ for excluded in exclude_metadata
2554
+ ):
2555
+ continue
2556
+
2493
2557
  if (
2494
2558
  values
2495
2559
  and "values" in thread
@@ -2813,6 +2877,8 @@ class Runs(Authenticated):
2813
2877
  if not _check_filter_match(existing_thread["metadata"], filters):
2814
2878
  return _empty_generator()
2815
2879
 
2880
+ _reject_existing_run(conn.store["runs"], run_id)
2881
+
2816
2882
  if not existing_thread and (thread_id is None or if_not_exists == "create"):
2817
2883
  # Create new thread
2818
2884
  if thread_id is None:
@@ -2951,6 +3017,7 @@ class Runs(Authenticated):
2951
3017
  cancel_reason=None,
2952
3018
  )
2953
3019
  stored_run = new_run
3020
+ encrypted_thread = None
2954
3021
  if encryption_enabled:
2955
3022
  # Unlike Go core, in-memory workers read nested run kwargs and thread
2956
3023
  # config directly from the store, so those containers must stay readable.
@@ -2958,14 +3025,16 @@ class Runs(Authenticated):
2958
3025
  thread if not existing_thread else existing_thread,
2959
3026
  "thread",
2960
3027
  )
3028
+ stored_run = copy.deepcopy(new_run)
3029
+ stored_run = Run(**await _encrypt_for_storage(stored_run, "run"))
3030
+ _reject_existing_run(conn.store["runs"], run_id)
3031
+ if encrypted_thread is not None:
2961
3032
  stored_thread = next(
2962
3033
  t
2963
3034
  for t in conn.store["threads"]
2964
3035
  if t["thread_id"] == encrypted_thread["thread_id"]
2965
3036
  )
2966
3037
  stored_thread.update(encrypted_thread)
2967
- stored_run = copy.deepcopy(new_run)
2968
- stored_run = Run(**await _encrypt_for_storage(stored_run, "run"))
2969
3038
  conn.store["runs"].append(stored_run)
2970
3039
 
2971
3040
  async def _yield_new():
@@ -4433,6 +4502,11 @@ def _check_filter_match(
4433
4502
  return True
4434
4503
 
4435
4504
 
4505
+ def _reject_existing_run(runs: Sequence[Run], run_id: UUID | None) -> None:
4506
+ if any(run["run_id"] == run_id for run in runs):
4507
+ raise HTTPException(status_code=409, detail=f"Run {run_id} already exists")
4508
+
4509
+
4436
4510
  async def _empty_generator():
4437
4511
  if False:
4438
4512
  yield