Skip to content

perf(core): avoid hydrating discarded compacted session parts #35570

Description

@dsbrgg

Large/long-running sessions can cause high memory usage during prompt execution because the compacted-history path hydrates the full session, including old tool outputs that are later discarded.

The relevant path appears to be:

// packages/opencode/src/session/message-v2.ts
filterCompactedEffect(sessionID) {
  return filterCompacted(yield* stream(sessionID))
}

stream(sessionID) pages through all messages and hydrates all parts before filterCompacted(...) reduces the history. For sessions with large persisted tool outputs, this loads much more data than the model turn ultimately needs.

I reproduced this locally using a copied database and this sanitized profiler script:

https://gist.github.com/dsbrgg/b5229cccb1c816e204cb7e10e54d5f93

The script only reports memory usage, message/part counts, part type totals, and approximate serialized part size. It does not print session contents, tool output contents, heap snapshots, logs, or database data.

Observed on one large compacted session:

full stream:
  messages: 3963
  parts: 13932
  approx part JSON: 197 MB

filterCompactedEffect result:
  messages: 134
  parts: 450
  approx part JSON: ~1 MB

Despite returning a small compacted result, filterCompactedEffect still paid the full-session hydration cost first.

Profiler output:

stream mode:
  RSS: 249 MB -> 547 MB, then 1162 MB after stats
  heapUsed: 36 MB -> 532 MB
  elapsed: 1291ms
  messages: 3963
  parts: 13932
  approxPartJsonMB: 197

filter mode:
  RSS: 254 MB -> 627 MB
  heapUsed: 36 MB -> 531 MB
  elapsed: 1290ms
  returned messages: 134
  returned parts: 450
  approxPartJsonMB: ~1

page100 mode:
  RSS: 247 MB -> 364 MB
  elapsed: 61ms
  messages: 100
  parts: 335

This shows up as RSS spikes in active long sessions, especially where historical tool parts are large.

A possible fix is to make filterCompactedEffect two-phase:

  1. Load message rows and only small control parts needed for compaction decisions, such as compaction/subtask parts.
  2. Run compaction filtering.
  3. Hydrate full parts only for retained message IDs.

This keeps stream(sessionID) behavior unchanged for callers that really need full history, but avoids hydrating discarded large tool outputs during prompt execution.

Plugins

rtk-ai, ponytail

OpenCode version

1.17.10 via Homebrew; reproduced against local source 1.17.13

Steps to reproduce

  1. Use or create a long session with many historical tool parts, including large tool outputs.
  2. Continue the session and send another prompt.
  3. Observe prompt execution memory while MessageV2.filterCompactedEffect(sessionID) runs.
  4. Compare current behavior to a bounded page load.

I used the sanitized profiler script here:

https://gist.github.com/dsbrgg/b5229cccb1c816e204cb7e10e54d5f93

Example commands:

OPENCODE_DB=/path/to/copied.db \
bun --expose-gc run profile-opencode-session-hydration.ts <sessionID> stream

OPENCODE_DB=/path/to/copied.db \
bun --expose-gc run profile-opencode-session-hydration.ts <sessionID> filter

OPENCODE_DB=/path/to/copied.db \
bun --expose-gc run profile-opencode-session-hydration.ts <sessionID> page100

Expected signal:

  • stream hydrates all historical messages/parts.
  • filter returns a compacted subset but currently still pays the full hydration cost first.
  • page100 shows bounded loading is much cheaper.

Screenshot and/or share link

N/A

Operating System

macOS

Terminal

Alacritty

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions