Skip to content

perf(agent-server): index conversation execution status for search/count - #4201

Merged
VascoSch92 merged 5 commits into
mainfrom
vasco/perf-conversation-search-index
Jul 23, 2026
Merged

VascoSch92 merged 5 commits into
mainfrom
vasco/perf-conversation-search-index

Conversation

@VascoSch92

@VascoSch92 VascoSch92 commented Jul 23, 2026 •

Copy link
Copy Markdown
Member

HUMAN:

Addressing issue #3142


AGENT:

Why

Fixes #3142. Status-filtered conversation search and count were O(total conversations) in full state loads, not just in iteration.

_search_conversations and _count_conversations both called _execution_status() once per catalog entry. For any conversation without a live EventService, that ran ConversationState.model_validate_json() over base_state.json — validating the agent, LLM config, workspace, secret registry and stats blob — purely to reach one enum field, and paid a separate asyncio.to_thread hop per conversation on top.

The unfiltered paths were already fixed by #4100 (the catalog is metadata-only), so this was the last remaining full scan: with 2000 conversations, one GET /conversations?execution_status=... call blocked for ~244 ms.

Summary

  • Cache execution_status on _ConversationRecord next to a (st_mtime_ns, st_size) signature of base_state.json, and refresh the whole catalog in one batched worker-thread pass instead of one thread hop per conversation.
  • Live conversations answer from in-memory state; persisted ones cost a stat() each and are only re-parsed — for the execution_status scalar alone, never a full ConversationState — when the file actually changed. Full state is loaded only for the conversations a page returns.
  • Filtered search refreshes the index before snapshotting the catalog, so a conversation going live mid-refresh is filtered on its current record rather than a superseded one.
  • Add TestConversationSearchScaling (5 tests) pinning both the scaling property and the correctness properties it must not trade away.

Out-of-process status writes are still observed: the signature is re-checked against the file on every query, so another server instance finishing a conversation is picked up on the next call. The signature is captured before the content read, so a write racing the read leaves a signature that no longer matches and forces a re-read rather than caching a torn value.

Issue Number

Closes #3142

How to Test

1. Run the benchmark (builds a real conversation, clones its on-disk layout N times, times the read APIs behind GET /conversations and GET /conversations/count):

uv run python .pr/bench_conversation_search.py --sizes 100,500,1000,2000 --repeat 5

2. Run the regression tests:

uv run pytest tests/agent_server/test_conversation_service.py -k TestConversationSearchScaling

To confirm they actually catch the regression, check out conversation_service.py from main and re-run — test_filtered_search_loads_state_only_for_the_page and test_filtered_count_loads_no_state both fail.

3. Full agent-server suite:

uv run pytest tests/agent_server/ -q

Video/Screenshots

End-to-end benchmark output, median of 5 calls after one warmup, macOS 15 (arm64, APFS SSD), Python 3.13.11. Full write-up in .pr/RESULTS.md.

Before — main @ c248ec0ae:

     N |    count() |  count(status) |   page(20) |  page(20,status)
--------------------------------------------------------------------
   100 |       0.00 |          11.49 |       3.50 |            14.96
   500 |       0.00 |          58.75 |       3.96 |            62.64
  1000 |       0.00 |         115.60 |       4.46 |           118.14
  2000 |       0.00 |         234.16 |       5.93 |           243.58

Scaling from N=100 to N=2000:
  count()            x    0.8
  count(status)      x   20.4     <-- dead-linear in total conversations
  page(20)           x    1.7
  page(20,status)    x   16.3     <-- dead-linear in total conversations

After — this branch:

     N |    count() |  count(status) |   page(20) |  page(20,status)
--------------------------------------------------------------------
   100 |       0.00 |           0.19 |       3.61 |             3.73
   500 |       0.00 |           0.75 |       4.02 |             4.71
  1000 |       0.00 |           1.49 |       4.50 |             6.04
  2000 |       0.00 |           2.99 |       5.76 |             9.93

Scaling from N=100 to N=2000:
  count()            x    0.9
  count(status)      x   15.8
  page(20)           x    1.6
  page(20,status)    x    2.7
Operation N=100 N=500 N=1000 N=2000
count(status) 60x 78x 78x 78x
page(20, status) 4.0x 13x 20x 25x

Test evidence. On main, the two scaling tests fail:

FAILED test_filtered_search_loads_state_only_for_the_page
  AssertionError: expected 5 full state loads (the page), got 45
FAILED test_filtered_count_loads_no_state
  AssertionError            # _load_persisted_state_sync was called at all

On this branch all four pass, and the full agent-server suite is green:

$ uv run pytest tests/agent_server/ -q
1764 passed, 13 deselected, 41 warnings in 124.45s (0:02:04)

pre-commit run on the changed files passes (ruff format, ruff lint, pycodestyle, pyright, import rules, tool registration).

Type

  • Bug fix
  • Feature
  • Refactor
  • Breaking change
  • Docs / chore

Notes

Why page(20, status) doesn't go fully flat, and shouldn't. It still loads full state for the 20 conversations it actually returns — that's the ~5 ms floor visible in the unchanged page(20) column. The remaining slope on count(status) is the stat() pass that keeps the index honest against out-of-process writers, ~1.5 µs per conversation. Both are deliberate: dropping them would mean serving statuses this process cannot vouch for.

Micro-optimisations worth flagging, since they look odd out of context — both are on a path that runs once per conversation per filtered query:

  • _state_signature takes a str and calls os.stat directly rather than Path.stat(). Measured over 1000 conversations: Path construction + Path.stat() = 4.62 ms, cached str + os.stat = 1.26 ms. The path is memoised on the record via _base_state_path.
  • _refresh_persisted_statuses_sync is a @staticmethod taking plain tuples rather than reading self._conversation_records, so the worker thread never touches the catalog the event loop may be mutating.

Follow-up not taken here. _search_conversations still sorts the whole catalog and linear-scans for page_id on every call. Both are pure in-memory work (~0.5 ms at N=2000) and were not the bottleneck, so I left them alone to keep the diff reviewable. A genuinely sorted index would be the next step if conversation counts grow another order of magnitude.

Review fixes since the first push (0dbc78fa4, 19114eb12):

  • Startup catalog parsing is strict again. Routing it through the lenient scalar helper had made a corrupt or unknown-status base_state.json land in the catalog as IDLE instead of being dropped and logged. Verified against main: catalog size 0 and two error_loading_conversation_catalog entries on both.
  • _refresh_execution_statuses no longer stamps a stale disk status onto a conversation that went live during its worker-thread hop, and filtered search now refreshes before it snapshots the catalog. Without the reorder, search(RUNNING) returned [] while the catalog record said RUNNING; there is now a deterministic (sleep-free) regression test for it.

Behaviour was checked with a differential harness: 37 conversations across 5 statuses, fixed UUIDs and timestamps, every sort order × 4 filters walked to the last page (16 walks, 88 pages, 240 items), plus on-disk mutation and a live in-memory status flip. Output is byte-identical to main.

Related. There is an existing draft, #3905, opened by an OpenHands agent against the same issue on branch openhands/conversation-search-index. It predates #4100 and targets the state of the code before the catalog existed. This PR is an independent fix against current main; whichever lands, the other should be closed.


Agent Server images for this PR

• GHCR package: https://github.com/OpenHands/agent-sdk/pkgs/container/agent-server

Variants & Base Images

Variant Architectures Base Image Docs / Tags
java amd64, arm64 eclipse-temurin:17-jdk Link
python amd64, arm64 nikolaik/python-nodejs:python3.13-nodejs22-slim Link
golang amd64, arm64 golang:1.21-bookworm Link

Pull (multi-arch manifest)

# Each variant is a multi-arch manifest supporting both amd64 and arm64
docker pull ghcr.io/openhands/agent-server:e17c390-python

Run

docker run -it --rm \
  -p 8000:8000 \
  --name agent-server-e17c390-python \
  ghcr.io/openhands/agent-server:e17c390-python

All tags pushed for this build

ghcr.io/openhands/agent-server:e17c390-golang-amd64
ghcr.io/openhands/agent-server:e17c390b3506f3fde4f938f383d6a93140e0d1bd-golang-amd64
ghcr.io/openhands/agent-server:vasco-perf-conversation-search-index-golang-amd64
ghcr.io/openhands/agent-server:e17c390-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:e17c390-golang-arm64
ghcr.io/openhands/agent-server:e17c390b3506f3fde4f938f383d6a93140e0d1bd-golang-arm64
ghcr.io/openhands/agent-server:vasco-perf-conversation-search-index-golang-arm64
ghcr.io/openhands/agent-server:e17c390-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:e17c390-java-amd64
ghcr.io/openhands/agent-server:e17c390b3506f3fde4f938f383d6a93140e0d1bd-java-amd64
ghcr.io/openhands/agent-server:vasco-perf-conversation-search-index-java-amd64
ghcr.io/openhands/agent-server:e17c390-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:e17c390-java-arm64
ghcr.io/openhands/agent-server:e17c390b3506f3fde4f938f383d6a93140e0d1bd-java-arm64
ghcr.io/openhands/agent-server:vasco-perf-conversation-search-index-java-arm64
ghcr.io/openhands/agent-server:e17c390-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:e17c390-python-amd64
ghcr.io/openhands/agent-server:e17c390b3506f3fde4f938f383d6a93140e0d1bd-python-amd64
ghcr.io/openhands/agent-server:vasco-perf-conversation-search-index-python-amd64
ghcr.io/openhands/agent-server:e17c390-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-amd64
ghcr.io/openhands/agent-server:e17c390-python-arm64
ghcr.io/openhands/agent-server:e17c390b3506f3fde4f938f383d6a93140e0d1bd-python-arm64
ghcr.io/openhands/agent-server:vasco-perf-conversation-search-index-python-arm64
ghcr.io/openhands/agent-server:e17c390-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-arm64
ghcr.io/openhands/agent-server:e17c390-golang
ghcr.io/openhands/agent-server:e17c390b3506f3fde4f938f383d6a93140e0d1bd-golang
ghcr.io/openhands/agent-server:vasco-perf-conversation-search-index-golang
ghcr.io/openhands/agent-server:e17c390-golang_tag_1.21-bookworm
ghcr.io/openhands/agent-server:e17c390-java
ghcr.io/openhands/agent-server:e17c390b3506f3fde4f938f383d6a93140e0d1bd-java
ghcr.io/openhands/agent-server:vasco-perf-conversation-search-index-java
ghcr.io/openhands/agent-server:e17c390-eclipse-temurin_tag_17-jdk
ghcr.io/openhands/agent-server:e17c390-python
ghcr.io/openhands/agent-server:e17c390b3506f3fde4f938f383d6a93140e0d1bd-python
ghcr.io/openhands/agent-server:vasco-perf-conversation-search-index-python
ghcr.io/openhands/agent-server:e17c390-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim

About Multi-Architecture Support

  • Each variant tag (e.g., e17c390-python) is a multi-arch manifest supporting both amd64 and arm64
  • Docker automatically pulls the correct architecture for your platform
  • Individual architecture tags (e.g., e17c390-python-amd64) are also available if needed

Status-filtered `search_conversations` and `count_conversations` called
`get_state()` once per catalog entry, validating a full `ConversationState`
(agent, LLM config, workspace, secret registry, stats) just to read one enum
field -- and paid a separate thread hop for each one. Every filtered list or
count call was therefore O(total conversations) in full state loads.

Cache `execution_status` on `_ConversationRecord` alongside a
(mtime_ns, size) signature of `base_state.json`, and refresh the whole
catalog in one batched worker-thread pass. Live conversations answer from
memory; persisted ones cost a `stat()` each and are only re-parsed -- for the
`execution_status` scalar alone -- when the file actually changed. Full state
is now loaded only for the conversations a page returns.

Out-of-process status writes are still observed, since the signature check
runs against the file on every query.

At N=2000 conversations: filtered count 234ms -> 3ms, filtered page(20)
244ms -> 10ms. Benchmark and results in .pr/.

Closes #3142
@github-actions

github-actions Bot commented Jul 23, 2026 •

Copy link
Copy Markdown
Contributor

✅ PR Artifacts Cleaned Up

The .pr/ directory has been automatically removed.

@github-actions

github-actions Bot commented Jul 23, 2026 •

Copy link
Copy Markdown
Contributor

Python API breakage checks — ✅ PASSED

Result: ✅ PASSED

Action log

@github-actions

github-actions Bot commented Jul 23, 2026 •

Copy link
Copy Markdown
Contributor

REST API breakage checks (OpenAPI) — ✅ PASSED

Result: ✅ PASSED

Action log

Comment thread openhands-agent-server/openhands/agent_server/conversation_service.py Outdated
Comment thread openhands-agent-server/openhands/agent_server/conversation_service.py Outdated
@github-actions

github-actions Bot commented Jul 23, 2026 •

Copy link
Copy Markdown
Contributor

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-agent-server/openhands/agent_server
   conversation_service.py91711587%174–175, 184, 210–211, 215–216, 221, 306–307, 310–311, 323–324, 338, 490–491, 509, 526, 575, 597, 604–605, 627, 695, 709, 736–737, 753, 783, 787, 801, 813–816, 822–823, 832, 834, 874, 877–881, 885–886, 892, 919, 925, 1016, 1022, 1027, 1033, 1041–1042, 1051–1054, 1063, 1075, 1083, 1106, 1112–1113, 1116–1118, 1143, 1185, 1253–1254, 1314, 1358–1360, 1362–1363, 1366–1367, 1387, 1498–1500, 1503–1504, 1508–1510, 1513–1514, 1518–1520, 1523–1524, 1553, 1562, 1602, 1612–1614, 1670, 1673, 1696, 1712, 1800, 1849, 1907, 1918–1919, 2219, 2222
TOTAL384691636857% 

Address review: replace the try/except blocks in _state_signature and
_read_execution_status_sync with contextlib.suppress. Condense docstrings
and inline comments across the change.
Self-review turned up two defects in the previous commits.

_load_catalog_sync had silently become lenient: routing the startup parse
through _read_execution_status_sync meant a corrupt or unknown-status
base_state.json left the conversation in the catalog as IDLE instead of
dropping it and logging error_loading_conversation_catalog. Restore the
strict inline parse and keep the lenient helper for the refresh path, where
"keep the cached status, unverified" is the wanted behaviour.

_refresh_execution_statuses could also stamp a stale disk status onto a
conversation that went live during its worker-thread hop, since the record
is rebuilt by _start_event_service while the read is in flight. Skip any
conversation with an open event service when applying results.
Review catch: guarding the refresh's apply loop was not enough. Filtered
search snapshotted _conversation_records before awaiting the refresh, so a
conversation that went live during the hop had its record replaced while the
filter still tested the superseded object -- search(RUNNING) returned nothing
while the catalog record said RUNNING.

Refresh first, then snapshot and filter the current catalog, then sort.
Filtering before sorting is also cheaper and yields the same order, since the
sort is stable and both orderings start from catalog order.

Adds a deterministic regression test (no sleeps) that replaces a record
during the refresh and asserts the filter still sees it.
@VascoSch92 VascoSch92 closed this Jul 23, 2026
@VascoSch92 VascoSch92 reopened this Jul 23, 2026
@VascoSch92
VascoSch92 requested a review from neubig July 23, 2026 08:45
@VascoSch92
VascoSch92 marked this pull request as ready for review July 23, 2026 08:45

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ QA Report: PASS

Status-filtered conversation count/search now behave correctly and are much faster under real persisted-conversation load.

Does this PR achieve its stated goal?

Yes. The PR set out to remove the expensive full-state scan from status-filtered conversation search/count while preserving correctness. I exercised the real ConversationService path before/after and the agent-server HTTP endpoints on the PR: at 1000 persisted conversations, filtered count dropped from 292.8 ms to 3.5 ms and filtered page search dropped from 322.7 ms to 18.5 ms, while an out-of-process base_state.json status change was picked up on the next query.

Phase Result
Environment Setup ✅ make build completed successfully.
CI Status 🟡 Most checks are green; PR is still BLOCKED only because QA / PR artifacts / image build checks were still in progress when checked.
Functional Verification ✅ Real service calls and HTTP requests confirmed the performance improvement and status correctness.
Functional Verification

Test 1: Reproduce baseline status-filtered scan cost, then verify PR improvement

Step 1 — Reproduce / establish baseline without the fix:

Checked out origin/main at c248ec0a and ran a temporary harness that creates one real conversation with ConversationService.start_conversation(), clones its persisted on-disk layout to 100 and 1000 conversations, then calls count_conversations(execution_status=IDLE) and search_conversations(limit=20, execution_status=IDLE) with real persisted data.

BASE_COMMIT=c248ec0a
{
  "sizes": [
    [100, {"count_all": 100.0, "count_filtered_ms": 33.9300540000238, "page_filtered_ms": 42.19226299997558, "page_items": 20.0}],
    [1000, {"count_all": 1000.0, "count_filtered_ms": 292.7906220000125, "page_filtered_ms": 322.6791700000149, "page_items": 20.0}]
  ]
}

This confirms the reported problem: status-filtered count/search become hundreds of milliseconds at 1000 persisted conversations.

Step 2 — Apply the PR's changes:

Checked out PR commit 19114eb1.

Step 3 — Re-run with the fix in place:

PR_COMMIT=19114eb1
{
  "sizes": [
    [100, {"count_all": 100.0, "count_filtered_ms": 0.47147200001518286, "page_filtered_ms": 12.877546999987999, "page_items": 20.0}],
    [1000, {"count_all": 1000.0, "count_filtered_ms": 3.475533999989011, "page_filtered_ms": 18.53672499998993, "page_items": 20.0}]
  ]
}

This shows the fix works in the changed path: at 1000 conversations, filtered count improved by about 84x and filtered page search by about 17x in this environment.

Test 2: Verify status correctness after out-of-process persisted writes

The same harness changed one conversation's base_state.json from idle to finished while the service was running, then queried again.

Base/main mutation result:
{"before_finished": 0, "after_finished": 1, "after_idle": 999, "finished_page_ids": ["36f2b514-6307-4348-81db-a9b5661a218f"]}

PR mutation result:
{"before_finished": 0, "after_finished": 1, "after_idle": 999, "finished_page_ids": ["8b7c8ad9-7944-4684-a879-fde869923408"]}

This confirms the PR preserves the correctness property described in the PR: out-of-process status writes are observed on the next filtered count/search.

Test 3: Exercise the real agent-server HTTP API on the PR

Seeded 1000 persisted conversations, started the local agent server with OH_CONVERSATIONS_PATH=/tmp/qa-http-conversations, and called the OpenAPI-documented status filter parameter (status).

COUNT status=idle
1000 time=0.005852 http=200
COUNT status=running
0 time=0.005272 http=200
SEARCH limit=20&status=idle items=20 next_page_id=46ccefd232454e608085e971476899ea
SEARCH limit=20&status=running items=0 next_page_id=None

Then mutated one persisted base_state.json to finished without restarting the server:

COUNT status=finished
1 time=0.005800 http=200
COUNT status=idle
999 time=0.005093 http=200
FINISHED_SEARCH items=1 ids=['d43f20a5-0b11-4eb5-a0cd-4f9df13d9a27']

This verifies the user-facing HTTP endpoints return the expected filtered counts/pages and pick up persisted status changes.

Issues Found

None.

This review was created by an AI agent (OpenHands) on behalf of the user.

@neubig neubig left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you!

@VascoSch92
VascoSch92 merged commit 60ca502 into main Jul 23, 2026
29 checks passed
@VascoSch92
VascoSch92 deleted the vasco/perf-conversation-search-index branch July 23, 2026 09:25
@github-actions github-actions Bot mentioned this pull request Jul 24, 2026
1 of 8 tasks
@VascoSch92 VascoSch92 mentioned this pull request Aug 22, 2026
1 of 5 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf: O(N) full scan for conversation search and count

3 participants