Relationships
#4224 swamp serve exhausts its heap when a workflow's run history carries large inputs: every run save parses and rewrites the whole .runs-index.json
Opened by vcjdeboer · 10/31/2026
Summary. Serve's memory grows with the total size of a workflow's stored run inputs, not with current load. Each run save reads, parses and rewrites the whole per-workflow .runs-index.json, and that index holds every historical run's full inputs. In our deployment one workflow takes a ~0.6–0.8 MB base64 file as an input. After 709 runs its index is 433 MB, and serve hit the V8 heap limit and restarted 9 times across 4 sittings. We reproduced this off our deployment with a filesystem datastore. Only the stored input size changes between the two arms, and peak RSS goes from about 250 MB to 4.3–5.3 GB, with serve unresponsive.
Controlled comparison (swamp a2f815f3, filesystem datastore, no S3)
Setup:
- serve and one worker (capacity 1) in an 8 GB / 2 CPU container;
- a 9-step workflow of small steps;
- per arm-run: a fresh serve start, 1 min idle, then two concurrent runs, with a client polling
workflow run search --since 6h --limit 20every ~4 s andhistory getevery 2–10 s.
Run histories were cloned from real records under new run ids, dated 1–30 days back. We then deleted the index and let swamp rebuild it. The only difference between arms is the stored input size per run.
| arm | runs in history | .runs-index.json |
peak RSS: start / idle / two runs | client requests failed | runs |
|---|---|---|---|---|---|
| A, run 1 | 515 × 840 KB input | 432.8 MB | 2,236 / 2,649 / 5,331 MB | 15 / 15 | both errored |
| A, run 2 | 515 × 840 KB input | 432.8 MB | 2,190 / 2,601 / 4,277 MB | 13 / 13 | both errored |
| B, run 1 | 709 × 116 B input | 0.41 MB | 261 / 193 / 254 MB | 0 / 56 | both succeeded (~30 s) |
| B, run 2 | 709 × 116 B input | 0.41 MB | 263 / 194 / 253 MB | 0 / 56 | both succeeded (~33 s) |
Peak RSS is the largest 5-second peak (VmHWM reset after each sample).
- In arm A, serve stopped answering WebSocket pings (10 per run), so every client request failed.
- Most of the memory is taken before any run starts: 2.2 GB right after start and 2.6 GB idle, just from reading the index.
- Arm B has more runs than arm A (709 vs 515), so run count is not the driver.
A second, hard limit: Invalid string length
In a first attempt with 711 × 840 KB inputs, the index would have been ~600 MB:
- serve logged
Failed to write repaired index, will retry next read: RangeError: Invalid string length62 times. V8's maximum string length is ~2^29 characters, about 512 MB. - every read then re-parsed all 655 MB of run records and failed again;
- searches took 10–20 s, and run discovery never succeeded.
Past that size the index cannot be written at all, whatever the heap limit. At our average entry size (611 KB) a workflow reaches it after about 880 runs.
Mechanism, from the source at a2f815f3
- Where inputs are stored. The run record keeps the full inputs (
execution_service.ts:4093-4094, serialized atworkflow_run.ts:2434-2435). The run summary keepsinputs(workflow_run_summary.ts:49,91), and.runs-index.jsonstores each summary, inputs included (workflow_run_index.ts:25,42). - Every save rewrites everything. Each
save(run), i.e. every step status change, callsupdateIndexEntry(yaml_workflow_run_repository.ts:963-1004, 2155-2180). That reads the whole index,JSON.parses it, replaces one entry, thenJSON.stringifys and writes all of it (workflow_run_index.ts:215-223). - The next search rebuilds a snapshot. After any save, the next
run search/history searchrebuildsrunIndexSnapshot(yaml_workflow_run_repository.ts:1603-1643). That is another whole-index parse, and the snapshot keeps every run's inputs resident. - Search answers carry the inputs.
run searchcopies each listed run's fullinputsinto its answer (run_search.ts:662-682), with no CLI option to leave them out. - Inputs cannot be excluded. Workflow inputs have no
sensitiveflag or size bound (.claude/skills/swamp/references/workflow/reference.md:560). The per-run evaluated workflow (workflows-evaluated/runs/<runId>/,execution_service.ts:4144) holds them again.
A stand-alone micro-benchmark of just that JSON cycle (plain deno 2.9.7, not swamp's code; 3 repeats, identical heap figures):
| runs N (840 KB input each) | index file | transient heap per save | time |
|---|---|---|---|
| 25 | 21 MB | +65 MB | 59 ms |
| 50 | 42 MB | +128 MB | 152 ms |
| 100 | 84 MB | +254 MB | 228 ms |
| 200 | 168 MB | +506 MB | 1.1 s |
That is about 2.5 MB of transient heap per indexed run, per save. Extrapolating linearly to 709 runs gives ~1.7 GB per save (an extrapolation, not a measured serve peak).
What is not shown yet
- Which functions allocate. We have not profiled the allocations, because the compiled binary takes no V8 flags. The comparison shows that the heap follows stored input size; it does not prove which functions hold the memory.
- The fatal crash. We did not reproduce the fatal heap OOM in the ~10-minute arms: they ended unresponsive at 4–5 GB, not crashed. Our deployment crashed after 6.5–8 min of a heavier load.
What would help, in priority order
- Make one run's update independent of total history size. Updating one run should touch only that run's entry, not parse and rewrite every run (for example per-run index entries, a segmented index or database rows). This also removes the 512 MB wall. Splitting the file alone would fix the string limit but keep the repeated work.
- Keep large inputs out of the index, snapshots and listings. Load full inputs only for replay and detailed inspection. Input filtering (
run search --input) could use indexed fields or selective reads. - An opt-in heap diagnostic for the compiled binary, for example V8's sampling heap profiler dumped above a heapUsed threshold, or a heap snapshot on a signal. Then we could send you an allocation profile instead of RSS curves.
- Regression coverage: large historical inputs, repeated saves and searches, responsiveness during them, and the string-length limit, while keeping filtering, permissions and replay working.
On our side we are moving the file out of the workflow inputs. That doesn't shrink existing history, and the missing upload route for remote members is filed separately: #4233. Any workflow that takes a webhook body, config document or manifest as an input accumulates the same way.
Reproduction. The micro-benchmark is one dependency-free script (run-index-bench.js) and can be attached. The serve comparison harness (record-set builder, driver, sampler) can be shared on request; it needs one real run record per arm as a template.
First observations (original report)
Crashes on f96841b5 and a2f815f3, under one serve on an AWS t4g.large (8 GiB) with @swamp/s3-datastore 2026.10.24.2 and one remote worker: 6 fatal Ineffective mark-compacts near heap limit / Reached heap limit exits across 3 sittings, 2.3–60 min after start, then 3 more in a fourth sitting (2026-10-31, after 8.0 and 6.5 min of uptime). The kernel OOM killer never fired. The S3 sync path and catalog are not needed to reproduce this: the comparison above uses a filesystem datastore. Our earlier S3-emulator attempt (~9.9k objects, small run history) stayed at a 290 MB heap peak, consistent with this.
Related: #4102 (worker query deadline during the same sittings), #3361 (catalog rebuild), #3407 (history search parsed every run record), #3468 (warm-up skips very large indexes), #636 (heap not configurable, by design: we are not asking for a bigger heap).
Open
No activity in this phase yet.
vcjdeboer commented 11/1/2026, 9:10:49 AM
Benchmark script for the micro-benchmark table above (one dependency-free deno script, plus its raw output and a README): https://gist.github.com/vcjdeboer/9cf0051904487b0621466bbfec5520b1
Sign in to post a ripple.