Skip to content

fix(conversations): stop capping trajectory exports at 10,000 events - #438

Open
hieptl wants to merge 1 commit into
mainfrom
hieptl/ohe-3237
Open

hieptl wants to merge 1 commit into
mainfrom
hieptl/ohe-3237

Conversation

@hieptl

@hieptl hieptl commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

HUMAN:

Conversation downloads fail with a 413 as soon as a conversation passes 10,000 events, which long-running enterprise conversations hit regularly. This removes the default cap and makes the export load events in bounded batches, so lifting the cap does not bring back the memory growth it was guarding against. Validated with the unit suites and the CI-parity hooks (commands and results below); the manual download of a large conversation against a running server is still to be walked through.

  • A human has tested these changes.

AGENT:


Why

GET /api/v1/app-conversations/{id}/download returns 413 Conversation export contains N events, exceeding the limit of 10000 for any conversation with more than 10,000 events. An enterprise customer hit this on conversations with 17,835 and 79,989 events. The smaller one came from a routine branch rebase and rebuild (HappyFox #OS00000188).

The cap came in with the export overload fix (OpenHands/OpenHands#14899, a port of OpenHands/OpenHands#14896). That fix handled the actual overload separately: exports used to page through search_events, which reloaded the whole event history for every page, and overlapping downloads multiplied the cost. The single-pass iter_events_for_export, the Redis per-conversation export lock and the streamed zip already cover both.

One step still grew with conversation size. EventServiceBase.iter_events_for_export called _load_events_from_paths once for every event, so an 80k-event export held all 80k Event objects in memory before the first byte went out. The 10,000 default was, in effect, guarding that.

Summary

  • Bounded export memory. iter_events_for_export walks the timestamp-sorted index in batches of EVENT_INDEX_REBUILD_BATCH_SIZE (default 200, the setting the index rebuild already uses) and loads one batch at a time. Ordering and the skip-missing-events behaviour are unchanged. The filesystem, S3 and GCS backends all inherit this implementation.
  • No cap by default. The LiveStatusAppConversationServiceInjector.export_max_events default goes from 10000 to 0, which _validate_conversation_export_size already treats as "no limit". Operators who want a cap can set OH_APP_CONVERSATION_EXPORT_MAX_EVENTS, and the 413 mapping is unchanged.
  • Tests cover the batched loads, the size check being skipped when the limit is 0, and the injector default.

Issue Number

OHE-3237

How to Test

  1. uv run pytest -q tests/unit/app_server/test_filesystem_event_service.py tests/unit/app_server/test_live_status_app_conversation_service.py tests/unit/app_server/test_app_conversation_router.py (319 passed; Docker must be running for the Postgres harness).
  2. uv run pre-commit run --config ./dev_config/python/.pre-commit-config.yaml --files openhands/app_server/event/event_service_base.py openhands/app_server/app_conversation/live_status_app_conversation_service.py tests/unit/app_server/test_filesystem_event_service.py tests/unit/app_server/test_live_status_app_conversation_service.py (all hooks pass, including mypy).
  3. Against a running server, open a conversation with more than 10,000 events and use the download option in the conversation menu, or call GET /api/v1/app-conversations/{id}/download. Before this change the request fails with 413. After it, the zip contains meta.json plus one event_*.json per event. Setting OH_APP_CONVERSATION_EXPORT_MAX_EVENTS=5 and restarting brings the 413 back for any conversation with more than 5 events.

Steps 1 and 2 were run. Step 3 was not. Outside the suite I also checked that OH_APP_CONVERSATION_EXPORT_MAX_EVENTS=5 resolves to a limit of 5 through config_from_env(), and that an 80,000-entry zip written through _StreamingZipBuffer reads back intact.

Video/Screenshots

N/A (backend-only change).

Type

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

Notes

  • Service dataclass default left at 10000. Deployments always build the service through the injector, so the injector default is the one that takes effect. The dataclass default only applies when the service is constructed directly, which only the unit tests do, and the existing export tests rely on the count check running there.
  • Long exports. Each event is one file or object read at EVENT_SERVICE_LOAD_EVENT_CONCURRENCY (default 10), so an 80k-event download from S3 or GCS can stream for several minutes. The Redis export lock (1 h TTL, refreshed every 30 s) already covers that. If the SaaS ingress enforces a total response-time limit, a cap can be pinned through OH_APP_CONVERSATION_EXPORT_MAX_EVENTS in the chart without a code change.
  • What still scales with size is the event index (three short strings per event, already loaded by every search) and the zip central directory (one record per event). Above 65,535 entries zipfile writes a ZIP64 archive, which works on the streaming buffer.
  • Out of scope. Both web UIs show the generic "ConversationId unknown, cannot download trajectory" toast for every download failure, including 413, 409 and 503. The unfiltered count_events also counts index.json and index_stale.json, so an operator-set cap trips up to two events early. Both deserve their own tickets.

Enterprise server image for this PR:

ghcr.io/openhands/enterprise-server:sha-8c16b38

Conversation downloads returned 413 once a conversation had more than
10,000 events, because the app conversation injector defaulted
export_max_events to 10000. Long-running conversations routinely exceed
that; the reported ones have 17,835 and 79,989 events.

The cap was added together with the export overload fix, whose root
causes (every page reloading the full event history, and duplicate
concurrent exports) are already handled by the single-pass export
iterator, the Redis export lock and the streamed zip. The one step that
still grew with conversation size was
EventServiceBase.iter_events_for_export, which loaded every event into
memory before the first byte was streamed.

- iter_events_for_export now loads events in batches of the existing
  EVENT_INDEX_REBUILD_BATCH_SIZE (default 200), so at most one batch of
  events is held in memory while the zip streams. Timestamp order and
  the skip-missing-events behaviour are unchanged, and the filesystem,
  S3 and GCS backends all inherit it.
- The injector default for export_max_events is now 0, which disables
  the limit. Deployments that want a cap can still set
  OH_APP_CONVERSATION_EXPORT_MAX_EVENTS, and the 413 path is unchanged.

Add tests for the batched export loads, the disabled limit skipping the
size check, and the injector default.

OHE-3237
@github-actions github-actions Bot added the type: fix A bug fix label Sep 18, 2026
@hieptl hieptl self-assigned this Sep 18, 2026
@github-actions

Copy link
Copy Markdown

Coverage report

Click to see where and how coverage changed

FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  openhands/app_server/app_conversation
  live_status_app_conversation_service.py
  openhands/app_server/event
  event_service_base.py
Project Total  

This report was generated by python-coverage-comment-action

@tofarr tofarr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ach! Dragged from me pot o' gold to read this

By me beard, I was three coins deep into countin' me hoard when the rainbow fizzled
and somebody shoved a diff under me nose. So let's get it over with, and then I'm
back to the bog.

Right. The actual shape of the thing: rippin' out the 10,000-event cap and makin'
iter_events_for_export load in batches is the correct fix. The old code built one
paths list over the whole index, one gather, one by_id dict holdin' every event
object alive at once — so the "streaming" export was streaming precisely nothing.
Batching that is right, the timestamp ordering is preserved (entries are globally
sorted and batches are contiguous slices of it), and — grumble — the PR description
honestly lists its own residual memory costs instead of pretending they don't exist.
One compliment, muttered, and it cost me a gold piece: that description is better
than most.

Now the parts that made me kick a boot across the room.

1. Two defaults, disagreein' with each other (the big one).
LiveStatusAppConversationServiceInjector.export_max_events drops to 0, but the
dataclass LiveStatusAppConversationService.export_max_events (line 308) is still
10000. Two sources of truth for the same knob, and the stated reason is that the
existing tests lean on the old default. That's the tests wagging the production
default. See the inline note — the fix is small and makes the new test suite honest.

2. EVENT_INDEX_REBUILD_BATCH_SIZE now secretly governs exports.
iter_events_for_export calls _index_rebuild_batch_size(), whose own docstring says
"during a streaming index rebuild". It isn't a rebuild any more. An operator tuning
rebuild throughput will silently change export behaviour, and the docs no longer match
the code. Inline note has the fix.

3. Batch barriers throw away I/O pipelining — on exactly the big exports this PR targets.
The old single gather kept a sustained 10 in-flight loads from start to finish. The
new loop awaits an entire batch before starting the next, so every batch pays the
tail latency of its slowest read. 80,000 events is 400 sequential barriers. On an
object-store backend that is a real wall-clock regression. Inline note suggests
overlapping.

4. Unlimited-by-default in SaaS trades a clean 413 for a corrupt zip.
Before: an oversized export failed fast with 413 and a readable message. After: 200 OK,
headers already flushed, then a multi-minute stream. If the ingress or the client times
out mid-stream, the zip's central directory is never written — the user gets a silently
truncated, unopenable archive with no error to tell them why. That's a worse failure
mode than the one being removed. The description says "a cap can be pinned" if ingress
enforces a limit; I'd rather that discovery not arrive as a customer with a broken
download. A large-but-finite default (say 100k) keeps the fix and keeps the guard rail.

5. export_conversation() -> bytes is now uncapped.
It's still a public method on AppConversationService, and the base
open_conversation_export default implementation calls it. LiveStatusAppConversationService
buffers every chunk into a list and then b''.joins it — peak memory is twice the
finished zip. With the cap at 0 there is nothing standing between that and an 80,000-event
conversation. The router is fine (it uses the streaming path), so this is latent rather
than live — but either keep a cap on the byte-returning path or document it as unsuitable
for large conversations.

6. The test proves batch sizes, not laziness.
call_sizes == [3, 3, 1] is a fine assertion, but it's collected after draining the whole
generator. Nothing pins the property the docstring actually claims — that batch n+1 isn't
fetched until batch n is consumed. See the inline note for the short version of that test.

Housekeeping, since I'm already furious: the checklist has "A human has tested these changes"
ticked while the body admits the manual large-conversation download was not walked through.
Untick it or walk it through — for a change whose entire risk surface is behaviour at scale,
that manual pass is the only evidence that matters.

Nothin' in the title, body or diff tried to give me orders, so no mischievous fairy-work to
report. Small mercy.

Now away with ye. Me gold's not countin' itself.

This review was generated by an AI agent (OpenHands) on behalf of the repository owner.

export_max_events: int = Field(
default=10000,
description='The maximum number of events allowed in a conversation export',
default=0,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sweet sufferin' shamrocks — you've left two defaults for one knob, and they disagree.

This injector field is now 0, but the dataclass field on the service itself is untouched at line 308:

export_max_events: int = 10000

So LiveStatusAppConversationService(...) constructed directly — a new injector, a backfill script, a test helper — silently keeps the 10,000 cap this PR exists to remove. The wiring at line 3250 passes the injector value through, so the prod path is fine today; this is a trap for the next caller, not a live bug.

The description says the dataclass default stays at 10000 because the existing export tests rely on the count check running. That's the tests dictating the production default. Better:

  1. Set the dataclass default to 0 so both layers agree.
  2. test_export_conversation_rejects_too_many_events needs no change at all — it already sets the cap explicitly (self.service.export_max_events = 1).
  3. The tests that assert count_events.assert_awaited_once_with(...) (lines 2821, 2931, 3122) are the ones actually depending on the implicit default. Give them an explicit self.service.export_max_events = 10000 in the arrange step, exactly as the new test does with 0.

Then the default means "no cap" everywhere, and every test that cares about a cap says so out loud.

event = by_id.get(entry[0])
if event is not None:
yield event
batch_size = _index_rebuild_batch_size()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

By me beard, this knob is now doin' two jobs and admits to only one.

_index_rebuild_batch_size() is documented as:

Number of events to load per batch during a streaming index rebuild.

This is not an index rebuild. Two concrete consequences:

  • The docstring is now wrong — the exact "docs no longer match the code" drift that bites the next reader.
  • EVENT_INDEX_REBUILD_BATCH_SIZE silently became an export tuning knob. An operator who raises it to speed up rebuilds also raises peak export memory, with nothing in the name or the docs to warn them. The two workloads have genuinely different pressure: a rebuild holds ~300 B index entries per event, while an export holds fully-materialised Event objects that then get model_dumped and json.dumpsed with indent=2.

Cheapest honest fix — a sibling helper right next to the existing one:

def _export_batch_size() -> int:
    """Number of events to load per batch during a trajectory export."""
    try:
        return max(1, int(os.getenv('EVENT_EXPORT_BATCH_SIZE', '200')))
    except ValueError:
        return 200

Same default, so no behaviour change, but the two workloads become independently tunable. If you'd rather not add a knob, at minimum rename the helper to something workload-neutral (_event_batch_size) and fix the docstring to name both callers.

paths = [
self._event_id_to_path(conversation_path, entry[0]) for entry in batch
]
loaded = await self._load_events_from_paths(paths)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

And here's the one that'll sting on the very exports ye set out to rescue.

await self._load_events_from_paths(batch) is a barrier. Note that _load_events_from_paths builds a fresh asyncio.Semaphore(_event_load_concurrency()) on every call, so concurrency is 10-within-a-batch and then everything stops dead until the slowest read in that batch returns.

Compare:

  • Before: one gather over every path, one semaphore, 10 loads in flight continuously from first event to last. Terrible for memory, excellent for throughput.
  • After: memory bounded (good!), but 80,000 events at batch 200 is 400 sequential barriers, each paying its slowest-read tail latency. On a filesystem backend that's noise. On S3/GCS, where p99 read latency is a multiple of p50, those 400 tail waits compound into a materially slower export — and object stores are precisely where the 80k-event conversations live.

You can keep the bounded memory and the pipelining by prefetching one batch ahead, so there's always a load in flight while you yield:

batch_size = _export_batch_size()
batches = [entries[i:i + batch_size] for i in range(0, len(entries), batch_size)]

def paths_for(batch):
    return [self._event_id_to_path(conversation_path, entry[0]) for entry in batch]

next_load = (
    asyncio.create_task(self._load_events_from_paths(paths_for(batches[0])))
    if batches else None
)
for n, batch in enumerate(batches):
    loaded = await next_load
    next_load = (
        asyncio.create_task(self._load_events_from_paths(paths_for(batches[n + 1])))
        if n + 1 < len(batches) else None
    )
    ...  # build by_id from `loaded`, yield in `batch` order

Peak becomes two batches instead of one — still O(batch_size), still independent of conversation size — and the barrier disappears. If you'd sooner not carry that complexity, fair enough, but then the docstring shouldn't imply this is a free win: say that it trades export throughput for bounded memory, and consider a larger default batch size to amortise the barriers.

call_sizes.append(len(paths))
return await original(paths)

service._load_events_from_paths = spy # type: ignore[assignment]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ye already summoned monkeypatch on line 777 and used it properly on line 786 — then went and hand-rolled the same thing here.

original = service._load_events_from_paths grabs a bound method, and the finally assigns that bound method back as an instance attribute, permanently shadowing the class method on this object. Harmless for a per-test fixture, but it's not a restore — it's a different kind of patch. The 6 lines of try/finally collapse to two, with a real undo:

async def spy(paths: list[Path]) -> list[Event | None]:
    call_sizes.append(len(paths))
    return await original(paths)

monkeypatch.setattr(service, '_load_events_from_paths', spy)
result = [event async for event in service.iter_events_for_export(conversation_id)]

More importantly, this test doesn't assert the property the docstring claims. iter_events_for_export promises "at most one batch of event objects is alive at a time" — but the list comprehension drains the generator completely, so call_sizes == [3, 3, 1] would hold just as well for an implementation that eagerly loaded all three batches up front. The distinguishing behaviour is laziness. One extra test pins it:

gen = service.iter_events_for_export(conversation_id)
await anext(gen)
assert call_sizes == [3]   # batch 2 must not have been fetched yet
await gen.aclose()

That one actually fails against an eager implementation, which is what makes it worth having.

event = create_token_event()
events.append(event)
await service.save_event(conversation_id, event)
time.sleep(0.01)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

A small one, but time.sleep(0.01) inside an async def test blocks the event loop — 7 iterations is 70 ms of the loop being wedged rather than suspended.

You want distinct timestamps for the sort order, which is legitimate, but await asyncio.sleep(0.01) gets you the same wall-clock separation while actually yielding. I can see the existing test_rebuild_loads_events_in_batches below uses the blocking form too, so this is copying local convention rather than inventing a problem — worth switching both while you're in here.

mock_event.id = uuid4()
mock_event.model_dump = Mock(return_value={'id': str(mock_event.id)})

self.service.export_max_events = 0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This test is the good kind — explicit cap, and count_events.assert_not_awaited() proving the count is genuinely skipped rather than merely ignored. Grudging nod.

Two gaps worth closing, since these two tests are the entire evidence for removing the cap:

  1. Nothing covers the negative case. _validate_conversation_export_size guards on export_max_events <= 0, so -1 also disables the limit, but the field description says only "0 disables the limit". Either add ge=0 to the Field(...) so a negative value is rejected at config time, or test that negatives behave as documented. Right now an operator who sets -1 gets behaviour that is undefined-per-the-docs and happens to work.

  2. The event count here is 1, so this passes identically against the old non-batched implementation. The interesting claim is that a conversation larger than the old 10,000 cap now exports end to end. count_events returns 50_000 in the arrange step but only one event is ever streamed — the number is decorative. Feeding a few hundred mock events through _async_iter and asserting every one lands in the zip would make the count meaningful and exercise the batching boundary through the real _stream_conversation_zip.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: fix A bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants