`PostgresStore` and `AsyncPostgresStore` always ran a batch inside a
pipeline. For a read that costs two round trips: fetching the result
forces one, and leaving the pipeline sends a Sync and waits for a
second. `store.get()`, `store.search()` and `store.list_namespaces()`
are batches of one read, so each paid two round trips, which is why a
`get` took longer than a `put`.
A batch of exactly one read now runs outside the pipeline, in one round
trip. Batches with several ops or any write still use the pipeline, and
so does a store opened with `pipeline=True`. In the sync store the
non-pipeline cursor now takes the store's lock, as every batch already
did through the pipeline branch, so a lone read never uses a shared
connection while another thread has it in a pipeline. That also covers
`sweep_ttl`, which went through that branch without the lock.
Median of four alternating rounds on main and on this branch, Python
3.14, Postgres 15 in local Docker:
| workload | main | this PR | speedup |
|---|---|---|---|
| round trips for 200 `get` calls, counted with a TCP proxy | 400 | 200
| 2x |
| 200 `PostgresStore.get` | 85.2 ms | 47.8 ms | 1.78x |
| 50 `PostgresStore.search` | 25.7 ms | 16.6 ms | 1.55x |
| 200 `AsyncPostgresStore.aget`, one after another | 103 ms | 59.5 ms |
1.73x |
| the same 200 `get` with 0.5 ms added per round trip | 433 ms | 228 ms
| 1.90x |
| graph node doing one `get` and one `put` per step, 50 steps | 46.8 ms
| 37.2 ms | 1.26x |
| `put`, and many concurrent `aget` batched together | | | about 1x |
## Test plan
- [x] The existing store suites pass for the sync and async stores in
their default, `pipeline=True` and pool setups, which cover the
lone-read path and the pipeline path.
Fixes#7259
`AsyncPostgresSaver` takes its instance lock around every database call.
The lock is needed when the saver shares one connection, which serves
one call at a time, but the saver also took it with an
`AsyncConnectionPool`, which gives each call its own connection. So
every saver call in the process waited for the one before it: concurrent
runs, and within one run the background checkpoint saves and each task's
`put_writes`.
With a pool, `_cursor` no longer takes the lock. A single connection and
a shared `pipe` still do.
Python 3.14 on an Apple M5 Pro, Postgres 16,
`AsyncConnectionPool(max_size=10)`, median of 5 rounds that alternate
main and this branch (3 rounds with 1 ms round trips):
| workload | main | this PR | speedup |
|---|---|---|---|
| 8 concurrent one-turn agents on one saver | 87.1 ms | 49.8 ms | 1.75x
|
| 500-way `Send` fan-out | 246 ms | 144 ms | 1.71x |
| 200 sequential nodes | 265 ms | 190 ms | 1.39x |
| one agent turn (7 supersteps) | 12.3 ms | 10.4 ms | 1.18x |
| 8 concurrent agents, 1 ms round trips | 234 ms | 60 ms | 3.86x |
| 500-way fan-out, 1 ms round trips | 773 ms | 167 ms | 4.63x |
| 200 sequential nodes, 1 ms round trips | 696 ms | 346 ms | 2.01x |
Process CPU stays about the same. A burst of calls larger than the pool
now waits in `psycopg_pool` (30 s timeout by default) instead of on the
lock. Calls that must be ordered already are: the loop saves a
checkpoint after the previous one and after its DeltaChannel writes.
The sync `PostgresSaver` takes the same lock with a `ConnectionPool`.
Removing it there let up to 19 executor threads contend for the GIL and
the pool: 1.3x to 1.5x faster, but 1.2x to 2.4x more CPU, so this PR
leaves it. `AsyncShallowPostgresSaver` is deprecated and also keeps its
lock.
@glaziermag reported this in #7259 with a load test, and @jitokim
proposed the same fix in #7269.
## Test plan
- [x] New `test_pool_saver_reads_while_a_list_is_open`: with a pool,
`aget_tuple` completes while an `alist` iteration is open. On main it
waits for the lock and times out after 5 s.
- [x] `checkpoint-postgres` suite (427 passed, 3 skipped), the
checkpoint conformance suite on a pool, and langgraph's Postgres tests
against this saver (2,832 passed).
`add_messages` ran `convert_to_messages` and `message_chunk_to_message`
over every message already in the state on every update, plus a final
filter pass. Both conversions return a plain message unchanged, so on a
long history this is overhead: 0.18 ms per call at 1,000 messages. An
agent turn calls it once per update and once more for each conditional
edge that reads `messages`.
The list now passes through as is when every item is already a plain
message, checked on the few distinct types in the list rather than per
item, and the filter runs only when a message was removed. Anything else
(dicts, tuples, strings, chunks, or a mix) goes through the same
conversion as before.
Median of alternating rounds on main and on this branch, Python 3.14, an
agent turn being 3 tool calls on a thread with 1,000 messages:
| workload | main | this PR | speedup |
|---|---|---|---|
| `add_messages` on 1,000 messages plus 1 new one | 184 us | 69 us |
2.7x |
| agent turn, no checkpointer | 4.80 ms | 3.38 ms | 1.42x |
| agent turn, `InMemorySaver`, `durability="exit"` | 11.0 ms | 8.7 ms |
1.25x |
| agent turn, `InMemorySaver`, default durability | 22.4 ms | 21.1 ms |
1.06x |
With a checkpoint every step, saving the whole history at each step
dominates the turn, so the gain is small there.
@jkennedyvz trimmed the same per-message conversion loop in #6969.
## Test plan
- [x] Missing ids are still assigned and the result is still a new list.
Two new tests in `test_messages_state.py` cover message-likes and chunks
on both sides, and that the result is not the input list. They pass on
main too.
`InMemoryStore.search` built an `(item, vectors)` pair for every item in
every matching namespace, then kept `candidates[offset:offset + limit]`.
A `limit=10` search on a 10,000-item namespace took 1.9 ms, nearly all
of it building candidates the slice then dropped. Paging a namespace 100
items at a time, as deepagents' `StoreBackend` does for `ls`, `glob` and
`grep`, rebuilt the whole listing for every page.
Without a filter, and without a query the store can embed, the result is
exactly that slice, so `_filter_items` now stops once it has `offset +
limit` matches. The namespace prefix test also becomes one tuple
compare: a namespace shorter than the prefix slices to itself, so it can
never equal the prefix.
Median of four alternating rounds on main and on this branch, Python
3.14:
| workload | main | this PR | speedup |
|---|---|---|---|
| `search(ns, limit=10)` on a 1,000-item namespace | 70 µs | 4 µs | 17x
|
| same, 10,000 items | 1.92 ms | 4 µs | about 490x |
| graph node that searches 1,000 memories (`limit=5`) and adds one, 20
steps | 3.41 ms | 1.86 ms | 1.83x |
| same, 10,000 memories | 39.4 ms | 1.83 ms | 21.5x |
| paging 5,000 items 100 at a time | 47.1 ms | 30.1 ms | 1.57x |
| `search` over 10,000 one-item namespaces | 23.4 ms | 15.5 ms | 1.51x |
| `search` with a filter, `get`, `put` | | | about 1x |
A filter keeps the full scan, so a filter that raises on some item (a
range operator on a missing key) still raises the same way. Negative
`offset` or `limit` keep it too, since a slice counts those from the
end.
## Test plan
- [x] `test_search_pages_match_full_listing` pages plain and filtered
searches across nested and look-alike namespaces and compares each page
with the full listing. It passes on main too, since the results are
unchanged.
`AsyncPostgresSaver` deserializes a checkpoint's pending writes in
`asyncio.to_thread`. The hand-off costs about 50 us per checkpoint, and
it ran even when there was nothing to load: the usual case for the
latest checkpoint of a finished run, which `aget_state` and the start of
every run read.
It now returns an empty list without the hop when the row has no pending
writes.
Python 3.14 on an Apple M5 Pro, Postgres 16, median of 5 rounds that
alternate main and this branch:
| workload | main | this PR | speedup |
|---|---|---|---|
| `aget_tuple` of a checkpoint with no pending writes | 264 us | 214 us
| 1.24x |
| `aget_state`, 200-message thread | 2.46 ms | 2.13 ms | 1.16x |
| an agent turn on that thread | 23.8 ms | 23.7 ms | about 1x |
With 1 ms round trips the round trip dominates each call, so wall time
stays about 1x, while process CPU per `aget_tuple` is still 1.36x lower.
`_load_writes(None)` already returned an empty list, so the result is
the same.
## Test plan
- [x] `checkpoint-postgres` suite (426 passed, 3 skipped) and the
checkpoint conformance suite (`get_tuple`, `list`).
`AsyncBatchedBaseStore` collects the store calls made in the same event
loop tick into one batch and dedupes them first. `_dedupe_ops` found an
earlier equal read with `dedupped.index(op)`, a linear scan per read, so
a batch of n reads cost O(n²) comparisons: 1.1 ms for 500 concurrent
`aget` calls, 16 ms for 2,000. A fan-out where every task reads the
store fills exactly such a batch.
Reads are now looked up in a dict keyed by the op. An op that can't be
hashed (a `SearchOp` with a filter dict) falls back to the old scan, so
it dedupes exactly as before.
Median of four alternating rounds on main and on this branch, Python
3.14:
| workload | main | this PR | speedup |
|---|---|---|---|
| `_dedupe_ops` on 500 gets | 1.13 ms | 31 µs | 36x |
| 2,000 concurrent `aget`, `AsyncSqliteStore` | 17.6 ms | 8.0 ms | 2.2x
|
| 2,000 concurrent `aget`, `AsyncPostgresStore` | 20.2 ms | 11.3 ms |
1.78x |
| 500 concurrent `aget`, either store | | | 1.25x |
| 500-way `Send` fan-out where each task reads and writes the store | |
| about 1x |
## Test plan
- [x] `test_dedupe_ops_maps_repeated_reads_and_puts_to_one_op` covers
repeated gets, filtered searches, list ops and overwritten puts. It
passes on main too, since the result is unchanged.
Each functional API task call (`accept_push`) restores any output the
pushed task saved in an earlier run. It did that by running
`read_task_statuses` over every pending write, which builds the status
of every task that has written, and then looked up the one task. On
resume every earlier task's writes are pending, so an entrypoint that
calls n tasks paid for n full status reads, O(n²): 196 µs per task call
at 1,000 pending writes. A sync entrypoint running tasks in parallel
hits it too, since writes land while tasks are still being pushed.
`_reapply_writes_to_succeeded_nodes` now hands `read_task_statuses` only
the writes of the tasks it was given. Statuses are per task, so those
tasks get exactly the statuses they got before.
Median of four alternating rounds on main and on this branch, Python
3.14, `InMemorySaver`:
| workload | main | this PR | speedup |
|---|---|---|---|
| matching saved writes for one task call, 1,000 pending writes | 196 µs
| 9.8 µs | 20x |
| resume an entrypoint that ran 500 tasks and then called `interrupt()`
| 153 ms | 35.4 ms | 4.3x |
| same, 100 tasks | 9.8 ms | 6.1 ms | 1.6x |
| entrypoint running 500 tasks in parallel, sync | 134 ms | 90.4 ms |
1.48x |
| 100 tasks in parallel or one after another, sync or async | | | about
1x |
Async entrypoints push all their tasks before any write lands, so they
barely move. In the 500-task parallel run, the biggest remaining cost in
the profile is the runner's stop check, which #9630 addresses.
## Test plan
- [x] Existing coverage: `test_imp_task` resumes an entrypoint and
checks its tasks don't run again, and the full suite passes.
`_get_updates` (run on every node's return value) and
`map_output_updates` (run for every task in `stream_mode="updates"`)
checked each written key against a list of all state keys, and
`map_output_updates` also looped over every key to look for duplicate
writes. On a state with hundreds of keys that is a list scan for every
written key.
Both now check against a frozenset: `_get_updates` builds it once when
the node is attached, `map_output_updates` once per call. The updates
keep the order of the node's writes.
Median of four alternating rounds on main and on this branch, Python
3.14:
| workload | main | this PR | speedup |
|---|---|---|---|
| `map_output_updates`, 20 writes on a 300-key state | 23.3 us | 4.0 us
| 5.8x |
| 30 nodes writing 20 keys each on a 300-key state, sync | 10.7 ms | 9.6
ms | 1.12x |
| same, async | 12.9 ms | 11.7 ms | 1.10x |
| `bench` `wide_state_25x300` (17 keys), sync | 3.84 ms | 3.74 ms |
about 1x |
States with a handful of keys see no change. Compiling builds one extra
frozenset per node, about 0.5 us each (`pydantic_state_25x300` compiles
in 117 us instead of 114 us).
## Test plan
- [x] The same keys pass the check, so no new test. Existing tests cover
the update paths, including a node that writes the same key twice in
`updates` mode (`test_multiple_writes_same_channel_from_same_node`).
When a task in a superstep finished, `FuturesDict.on_done` ran the stop
check over every future that had finished so far. The check calls
`cancelled()` and `exception()` on each one, and both take the future's
lock for a `concurrent.futures.Future`. A step with n parallel tasks
paid about n²/2 of these calls, about 125,000 for a 500-task `Send`
fan-out.
Only a future that raised can meet the stop condition, and a finished
future never changes, so `FuturesDict` now keeps the failed futures in
their own set and checks only those. The single-task path, which adds a
failed future straight to `done`, adds it to the new set too, so the
check sees the same failures as before, including ones an error handler
takes over.
Median of four alternating rounds on main and on this branch, Python
3.14:
| workload | main | this PR | speedup |
|---|---|---|---|
| 500 futures finishing in one step, `FuturesDict` alone | 28.5 ms |
0.72 ms | 39x |
| 500-way `Send` fan-out, sync, `InMemorySaver` | 77.3 ms | 33.9 ms |
2.28x |
| 500-way `Send` fan-out, sync, no checkpointer | 65.2 ms | 28.1 ms |
2.32x |
| 500-way `Send` fan-out, async | 36.4 ms | 35.8 ms | about 1x |
| `bench` `fanout_to_subgraph_100x`, sync | 116 ms | 113 ms | about 1x |
Async barely moves because asyncio futures answer these calls without a
lock, and the 100-way benchmark spends its time inside the subgraphs.
@AaronG18 (#8860), @nankingjing (#8347) and @014-code (#8270) proposed
checking fewer futures here, and @HumphreySun98 reported the problem in
#8240.
## Test plan
- [x] `test_runner_futures.py` pins the stop decision: a failed task
wakes the waiter before the other tasks finish, and a failure an error
handler took over does not. Both tests pass on main too, since the
behavior is unchanged.
The Pregel loop looked up channel types in two hot places. `put_writes`
scanned every channel for an `UntrackedValue` on each task's writes, and
`_put_checkpoint` walked every channel twice per superstep to find the
`DeltaChannel`s. A graph has a channel per node plus one per state key,
and channels are ABCs, so each check goes through
`ABCMeta.__instancecheck__`: a 200-node graph paid about 630 of them per
step.
Channel types can't change during a run, so the loop now works out both
answers once from `specs` and reuses them.
Median of four alternating rounds on main and on this branch, Python
3.14:
| workload | main | this PR | speedup |
|---|---|---|---|
| channel-type checks per step, 211 channels | 38 us | under 0.1 us | |
| `bench` `sequential_1000`, sync | 219 ms | 93 ms | 2.36x |
| `bench` `sequential_1000`, async | 370 ms | 250 ms | 1.48x |
| 200 sequential nodes, `InMemorySaver` | 38.7 ms | 29.4 ms | 1.31x |
| 200 sequential nodes, no checkpointer | 19.4 ms | 12.9 ms | 1.50x |
| agent turn on a model node + `ToolNode` graph | 2.51 ms | 2.48 ms |
about 1x |
The gain grows with the number of nodes and state keys. Small graphs see
no change.
`self.channels` is built from the same `specs`, with the same keys in
the same order, so the same writes are dropped, the same `Send` packets
are sanitized, and the same delta counters and snapshots come out.
@AaronG18 proposed the `UntrackedValue` half of this in #8858, and
@jkennedyvz in #7121.
## Test plan
- [x] No behavior change, so no new test. `test_untracked_value`,
`test_send_with_untracked_value` and the `test_delta_channel_*` files
cover these paths.
Fixes#9328
`_get_model_input_state` in `create_react_agent` built its
missing-messages error string before checking whether it needed it. The
f-string includes `{state}`, so every model call ran `repr()` over the
whole message history and threw the result away. The cost grows with the
history, so a long tool loop pays it on every step.
The message is now built only when the error is raised, with the same
text.
Median of four alternating rounds on main and on this branch, Python
3.14:
| workload | main | this PR | speedup |
|---|---|---|---|
| building the model input, 1,000 messages | 7.6 ms | 0.08 ms | 95x |
| one agent turn (3 tool calls) on a 1,000-message thread,
`InMemorySaver` | 60.7 ms | 29.6 ms | 2.05x |
| same turn on a 200-message thread | 14.1 ms | 8.1 ms | 1.75x |
| `bench` `react_agent_100x`, sync | 319 ms | 53 ms | 6.0x |
| `bench` `react_agent_100x`, async | 390 ms | 126 ms | 3.1x |
The saving is the cost of that `repr()`, so it grows with the size of
the history.
Thanks @deadstrobe5, who reported this in #9328 and proposed the same
change in #9329.
## Test plan
- [x] Behavior is unchanged: the same `ValueError` with the same text
when the input has no messages. The `create_react_agent` tests in
`libs/prebuilt` cover the call path.
2026-11-12 18:53:53 -05:00
16 changed files with 321 additions and 56 deletions
error_msg=f"Expected input to call_model to have 'llm_input_messages' or 'messages' key, but got {state}"
expected_keys="'llm_input_messages' or 'messages' key"
else:
messages=_get_state_value(state,"messages")
error_msg=(
f"Expected input to call_model to have 'messages' key, but got {state}"
)
expected_keys="'messages' key"
ifmessagesisNone:
raiseValueError(error_msg)
raiseValueError(
f"Expected input to call_model to have {expected_keys}, but got {state}"
)
_validate_chat_history(messages)
# we're passing messages under `messages` key, as this is expected by the prompt
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.