Skip to content

fix: cap in-memory journal to prevent heap OOM under sustained load#114

Merged
jpr5 merged 3 commits intomainfrom
fix/journal-unbounded-memory-leak
Apr 17, 2026
Merged

fix: cap in-memory journal to prevent heap OOM under sustained load#114
jpr5 merged 3 commits intomainfrom
fix/journal-unbounded-memory-leak

Conversation

@jpr5
Copy link
Copy Markdown
Contributor

@jpr5 jpr5 commented Apr 17, 2026

Summary

Journal.entries was unbounded — every request across 26 handlers (~179 call sites: chat completions, messages, responses, gemini, bedrock, embeddings, images, speech, transcription, video, ollama, cohere, search, rerank, moderation, a2a, mcp, agui, ws-*, etc.) pushed a full { method, path, headers, body, response } record and never evicted. At sustained prod traffic this grows heap ~3.8MB/sec → 4GB → OOM in ~18 minutes. Observed exactly that on showcase-aimock Railway service: deterministic 0→4GB heap growth then FATAL ERROR: Reached heap limit Allocation failed. Crash cascades to ~7 downstream showcase services that route through aimock via OPENAI_BASE_URL.

Fix

FIFO size cap on Journal.entries via new JournalOptions.maxEntries. Default:

  • Programmatic (new Journal() / createServer()): unbounded (backwards-compat; 100+ test/library callers depend on this).
  • CLI: 1000 (default for serve / the GHCR image). Override via --journal-max <N>; 0 or omitted = unbounded.

Eviction is a single shift() per over-cap add. At cap=1000 × ~5KB/entry ≈ 5MB steady-state — well under heap limits.

Test plan

  • 6 new red-green tests in src/__tests__/journal.test.ts (cap behavior, FIFO ordering, uncapped default, getLast/findByFixture post-eviction, 100k-add cap invariant)
  • Full suite: 2449 tests pass
  • Lint + build + prettier clean
  • Post-merge: GHCR image republishes with the fix; Railway picks up on next drift-rebuild or manual redeploy

Follow-ups (separate PR)

  • CLI: reject negative --journal-max values (currently silently treated as unbounded)
  • createServer() default: flip to finite cap so long-running embedders don't inherit the leak
  • fixtureMatchCountsByTestId Map cap (narrower but also unbounded)
  • Correct the "amortized O(1)" comment — Array.shift is O(n); true O(1) would need a ring buffer

…d load

The Journal class appended one entry per request (body + headers + fixture
reference) and never evicted, so long-running servers grew heap linearly.
Production showcase-aimock on Railway OOMs every ~18min (heap 0 -> 4GB)
because every LLM request flowing through proxy-only mode still appends to
Journal, even when fixture caching and on-disk recording are disabled.

Add a FIFO size cap:
  - new JournalOptions.maxEntries constructor arg (0 = unbounded, preserves
    prior behavior for existing callers, including 100+ test call sites)
  - MockServerOptions.journalMaxEntries plumbs through createServer
  - CLI --journal-max flag, defaulting to 1000 (sensible for long-running
    mock proxies; tiny memory footprint; large enough for most test-harness
    inspection use cases)

Eviction is single-shift-per-add (amortized O(1) under the never-overshoot-
by-more-than-one invariant).

Programmatic callers that want full unbounded history can pass 0 explicitly.
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 17, 2026

Open in StackBlitz

npm i https://pkg.pr.new/@copilotkit/aimock@114

commit: 8455e55

@jpr5 jpr5 merged commit 5fc7b89 into main Apr 17, 2026
22 checks passed
@jpr5 jpr5 deleted the fix/journal-unbounded-memory-leak branch April 17, 2026 16:57
jpr5 added a commit that referenced this pull request Apr 17, 2026
…erver defaults (#115)

## Summary
Follow-up polish to v1.14.1's journal OOM fix (#114). Three independent
improvements surfaced by CR:

1. **Read-path non-mutation bug fix.**
`Journal.getFixtureMatchCount(fixture, testId)` was a read method that
silently inserted an empty Map + triggered FIFO eviction for unknown
testIds. Reads could evict live testIds. Now split into a read-only
public `getFixtureMatchCountsForTest` (returns transient empty Map on
miss) and private `getOrCreateFixtureMatchCountsForTest` (insert+evict
write path used only by `incrementFixtureMatchCount`).

2. **CLI validation hardening.** `--journal-max -5` was silently treated
as unbounded; now rejected with a clear error. Same for the new
`--fixture-counts-max` flag.

3. **`createServer()` default flip.** `journalMaxEntries` (1000) and
`fixtureCountsMaxTestIds` (500) now default to finite caps for
programmatic callers — long-running embedders no longer inherit the
original leak. Tests using `new Journal()` directly remain unbounded by
default (back-compat). Opt in to unbounded via `journalMaxEntries: 0`.

## Test plan
- [x] 3 new tests: read-non-mutation, CLI negative rejection,
`fixtureCountsMaxTestIds` cap FIFO eviction
- [x] Full suite: 2461 tests pass
- [x] Lint + build + prettier clean
- [x] CR R2 on combined diff: 0 blocking bugs

## Breaking change note
The `createServer()` default flip is a behavioral change for
programmatic embedders that relied on unbounded journal retention. Opt
back in with `createServer({ journalMaxEntries: 0 })` if needed.
Documented in `types.ts` JSDoc.
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.

1 participant