Skip to content

fix(execution): offload buffered event values under budget pressure - #6231

Merged
waleedlatif1 merged 4 commits into
stagingfrom
fix/execution-event-value-threshold
Aug 4, 2026
Merged

fix(execution): offload buffered event values under budget pressure#6231
waleedlatif1 merged 4 commits into
stagingfrom
fix/execution-event-value-threshold

Conversation

@waleedlatif1

@waleedlatif1 waleedlatif1 commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

An execution buffers EVENT_LIMIT events inside a per-execution byte budget, so a full ring only fits if events average under budget / EVENT_LIMIT. Values were only offloaded to object storage at the shared 8 MiB cap — far above that — so a run emitting large block outputs exhausted its budget within a few dozen events and stayed pinned at its ceiling for the rest of its life.

Rather than applying that ceiling to every run, engage it only once a run has buffered past half its budget.

Why not a flat lower threshold

A flat cutoff was the first attempt and was wrong on two counts:

  • It degrades the live stream, not just replay. execute/route.ts sends entry.event — the compacted event — to the active SSE client, and structured-output.tsx renders a LargeValueRef as ref.preview only (a truncated string, or { length: N } / { keys: [...] } for arrays and objects). There is no client-side rehydration. So a flat cutoff makes ordinary block outputs unreadable in the terminal, live and on replay.
  • It puts an object-storage write and an owner-metadata row on the hot path for every value above the cutoff, on runs that were never near their budget.

Pressure-driven avoids both: a short run keeps full-fidelity output and pays nothing extra, while a runaway one stops accumulating. Both bounds derive from the existing budget constants rather than being asserted.

Also in this PR

Stop a failed resume-path buffer write from failing the run. human-in-the-loop-manager.ts awaited the non-terminal write bare — while the terminal write directly above it already had a .catch — so a write failure propagated into the executor callback and failed work that had already completed. The buffer only backs reconnect replay, so it now degrades to live-only delivery the way the execute route does.

Exemption

Preserved UserFile base64 is exempt from pressure offloading: it is an explicit request for inline delivery, and is already bounded by its own cap and the strip-and-recompact fallback.

Type of Change

  • Bug fix

Testing

Two tests pinning both directions of the pressure gate — values stay inline below the mark, and offload above it — each verified to fail against the opposite mutant (gate never engages / gate always engages). Full lib/execution, lib/workflows and app/api/workflows suites green (98 files, 1645 tests).

Not exercised against a real Redis or a live run; the Lua is mocked throughout.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

@vercel

vercel Bot commented Aug 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Aug 3, 2026 11:57pm

Request Review

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@cursor

cursor Bot commented Aug 3, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes execution event compaction, Redis budget behavior, and terminal stream semantics for heavy runs; mistakes could strand replay events or mark runs complete prematurely, though coverage is strong in event-buffer tests.

Overview
Heavy runs were exhausting the per-execution Redis replay budget because large block outputs stayed inline until the shared 8 MiB cap. Pressure-driven offloading now kicks in only after buffered bytes cross half the execution budget, applying a tighter per-value threshold (budget / EVENT_LIMIT) so runaway runs offload to refs instead of pinning at the ceiling. Short runs keep full inline payloads for live SSE and terminal display.

Compaction tracks bufferedBytes at write time (not flush time) so bursts engage pressure before a scheduled flush. Failed pressure offloads fall back to inline buffering; preserved UserFile base64 stays exempt.

Terminal publish ordering is fixed when the budget forces chunking: queued events ahead of the terminal entry are drained before stamping terminal status or publishing the terminal alone, so reconnect readers are not told the run ended while lower event IDs are still pending. Transient drain failures retain the backlog and withhold terminal status.

On resume, non-terminal buffer write failures now degrade to live-only delivery (like the execute route) instead of failing executor callbacks after work completed; failed writes no longer assign eventId 0.

Reviewed by Cursor Bugbot for commit e2e4138. Configure here.

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@waleedlatif1
waleedlatif1 force-pushed the fix/execution-event-value-threshold branch from c268b9e to f169aff Compare August 3, 2026 22:55
Comment thread apps/sim/lib/execution/event-buffer.ts Outdated
@waleedlatif1
waleedlatif1 force-pushed the fix/execution-event-value-threshold branch from f169aff to 3219b47 Compare August 3, 2026 22:57
@greptile-apps

greptile-apps Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR makes large-value offloading pressure-sensitive, preserves inline delivery for short executions, and prevents resume-path replay-buffer failures from failing completed workflow work.

  • Tracks produced event bytes and lowers the per-value offload threshold after half of the execution budget is consumed.
  • Falls back to ordinary inline compaction when pressure-driven object-storage offloading fails.
  • Preserves predecessor ordering when retrying terminal publication after a budget rejection.
  • Degrades failed non-terminal resume writes to live-only delivery.
  • Adds regression coverage for pressure gating, burst accounting, terminal ordering, transient drain failures, and offload fallback.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; the prior resume-path propagation and transient terminal-drain data-loss issues are addressed, while the remaining replay-gap behavior above the shared large-value cap is the previously documented degradation tradeoff.

Important Files Changed

Filename Overview
apps/sim/lib/execution/event-buffer.ts Adds pressure-based compaction, inline fallback, produced-byte accounting, and ordered terminal retry behavior; the previously reported transient-drain loss is addressed.
apps/sim/lib/execution/event-buffer.test.ts Adds focused regression tests for both pressure-gate directions, burst behavior, terminal ordering, transient failures, and fallback persistence.
apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts Converts non-terminal resume buffer-write failures into live-only delivery while leaving the reconnect cursor unset.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  E[Execution event] --> P{Produced bytes exceed pressure mark?}
  P -- No --> D[Compact with shared large-value threshold]
  P -- Yes --> T[Compact with tighter pressure threshold]
  T --> O{Offload succeeds?}
  O -- Yes --> R[Queue compacted event for Redis replay]
  O -- No --> D
  D --> R
  R --> F{Terminal event with predecessor backlog?}
  F -- No --> W[Flush normally]
  F -- Yes --> B[Drain predecessors with terminal status disarmed]
  B --> C{Backlog empty?}
  C -- Yes --> S[Publish terminal event and status]
  C -- No --> X[Reject terminal write and degrade safely]
Loading

Reviews (7): Last reviewed commit: "fix(execution): keep an event in the buf..." | Re-trigger Greptile

Comment thread apps/sim/lib/execution/event-buffer.ts Outdated
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

1 issue from previous review remains unresolved.

Fix All in Cursor

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 3219b47. Configure here.

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/lib/execution/event-buffer.ts Outdated
Comment thread apps/sim/lib/execution/event-buffer.test.ts Outdated
@waleedlatif1
waleedlatif1 marked this pull request as draft August 3, 2026 23:22
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

Converting to draft. Verification before merge turned up problems that go past the two open findings, and I don't think this should land as written.

The compacted event goes to the live client, not just replay. execute/route.ts:1763 does eventToSend = entry.event, so the ref-substituted event is what the active SSE client receives. And there is no client-side rehydration — structured-output.tsx:88 renders a ref as ref.preview ?? '[Large value: N]', which is a 256-char prefix for strings and a shape summary ({length: N}, {keys: […]}) for arrays and objects. So every block output between the new threshold and 8 MiB stops being viewable in the terminal, live as well as on replay. I described this as a replay-side cost earlier; that was wrong.

Both open findings are correct, and the second is a mistake in my own fix.

  • The preserveUserFileBase64 override raises the threshold for the entire payload rather than the UserFile subtree, and resume sets that flag unconditionally (human-in-the-loop-manager.ts:1272, :1571) — so resume runs keep the old behaviour entirely and the change does not apply to them.
  • The test fixture is invalid. isUserFile requires id, key, url and name; the fixture omits id and url, so isUserFileWithMetadata never matches. It passed because of the coarse override, not because UserFile preservation works. It reverted red for the wrong reason.

Other blockers before this is worth reopening:

  • Adds an object-storage PUT plus an owner-metadata row per value on the hot SSE path, unmeasured.
  • The threshold is justified only by prose in a TSDoc. The distribution it came from cannot go in a public repo, so as written the number is unfalsifiable to a reviewer.
  • Better levers were not considered: raising the per-execution budget, lowering the ring size, or making offload pressure-driven (offload once an execution's accumulated buffer bytes cross a fraction of its budget) rather than a flat per-value cutoff that treats a three-block run like a pathological one.

The incident fix is #6229, which stands alone and does not depend on this. The one piece here worth keeping independently is the .catch on the resume-path buffer write, which fixes a genuine bug where a failed write aborted work that had already run — I'll move that to its own change.

An execution buffers EVENT_LIMIT events inside a per-execution byte budget, so
a full ring only fits if events average under budget/EVENT_LIMIT. Values were
only offloaded to object storage at the shared 8 MiB cap, far above that, so a
run emitting large block outputs exhausted its budget within a few dozen events
and stayed pinned at its ceiling for the rest of its life.

Applying that ceiling to every run would be worse than the problem: the SSE
stream carries the compacted event and the terminal renders a ref only as a
preview, so ordinary block outputs would stop being readable live, and every
value would cost an object-storage write on the hot path. Engage the tight
ceiling only once a run has actually buffered past half its budget. A short run
keeps full-fidelity output and pays nothing; a runaway one stops accumulating.

Both bounds derive from the existing budget rather than being asserted, and
preserved UserFile base64 is exempt — it is an explicit request for inline
delivery, already bounded by its own cap and the strip-and-recompact fallback.

Also stop a failed resume-path buffer write from failing the run: it was
awaited bare, so the rejection propagated into the executor callback and failed
work that had already completed. The buffer only backs reconnect replay, so
degrade to live-only delivery the way the execute route does.
@waleedlatif1
waleedlatif1 force-pushed the fix/execution-event-value-threshold branch from 684ce98 to 2e6bcd5 Compare August 3, 2026 23:31
@waleedlatif1 waleedlatif1 changed the title fix(execution): offload buffered event values at a budget-aware threshold fix(execution): offload buffered event values under budget pressure Aug 3, 2026
@waleedlatif1
waleedlatif1 marked this pull request as ready for review August 3, 2026 23:31
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

Rebuilt on top of #6229 (now in staging) and reworked in response to the pre-merge review.

The flat threshold is gone. The blocking finding was that lowering it degrades the live stream, not just replay: execute/route.ts sends entry.event — the compacted event — to the active SSE client, and structured-output.tsx:88 renders a ref as ref.preview only, with no client-side rehydration. A flat cutoff would have made every block output between the cutoff and 8 MiB unreadable in the terminal, and added an object-storage write plus an owner-metadata row to the hot path for runs that were never near their budget.

Replaced with pressure-driven offload. The tight ceiling engages only once a run has buffered past half its budget. Short runs — the overwhelming majority — behave exactly as they do on staging: full-fidelity output, no extra writes. Only a run actually accumulating toward exhaustion starts offloading. Both bounds derive from the existing budget constants (maxExecutionBytes / 2 and maxExecutionBytes / EVENT_LIMIT) rather than from a number asserted in prose.

The two findings from the previous round are addressed at the root rather than patched:

  • The coarse preserveUserFileBase64 override that raised the threshold for the whole payload is gone; the exemption is now a single explicit check in one place.
  • The invalid test fixture is gone with it. Worth flagging why it was invalid: isUserFile requires id, key, url and name, and the fixture omitted id and url, so isUserFileWithMetadata never matched — the test passed for the wrong reason and reverted red for the wrong reason too.

The resume-path fix is kept and is independently valuable: the non-terminal buffer write was awaited bare, so a failure propagated into the executor callback and failed work that had already run.

Both new tests are mutation-checked in both directions — gate-never-engages and gate-always-engages each turn one red.

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/lib/execution/event-buffer.ts
…us last

Pressure was read from bytes counted once a flush succeeded, but a burst is
compacted long before the scheduled flush runs — so the very batch that
exhausts the budget went through at the loose ceiling and was dropped instead
of offloaded. Count bytes as each event is compacted.

Separately, the terminal-alone retry stamped terminal status while entries
queued ahead of it were still unwritten. Terminal status is the reader's
end-of-run signal: a reconnecting client drains what is in Redis and closes, so
those entries were stranded behind a stream it had already finished with. Drain
the backlog first, then publish the terminal event.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/lib/execution/event-buffer.ts Outdated
Comment thread apps/sim/lib/execution/event-buffer.ts
Comment thread apps/sim/lib/execution/event-buffer.ts
Comment thread apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts
Draining the backlog ahead of the terminal event left the terminal status armed,
so whichever chunk emptied the queue stamped the run complete before its
terminal event was written — the inverse of the ordering the drain was added to
guarantee. Disarm the status for the drain and restore it afterwards.

The drain's result was also discarded: a transient Redis failure requeues its
batch, and the unconditional reassignment that followed dropped those events
even though the budget never rejected them. Keep whatever could not be
persisted, and publish the terminal event alone only once nothing earlier is
still queued — failing otherwise lets the caller degrade, which records the
status without claiming the missing events arrived.

Leave eventId unset on a failed resume-path write. Assigning 0 was persisted by
clients as a reconnect cursor and rewound them to the start of the run.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 84d9164. Configure here.

…ails

Durable compaction runs before an event is queued, so a storage or metadata
failure dropped it from replay entirely — a reconnecting client would never see
it, even though the live path carried on. Offloading under pressure is only an
optimization that keeps a heavy run from exhausting its budget, so when the
value cannot be persisted, fall back to buffering it inline: exactly what the
run would have done before pressure engaged.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

Addressed the remaining 4/5 concern in e2e4138 — the one about durable compaction running before an event is queued, so a storage failure omits it from replay.

That was right, and worth fixing rather than documenting. Offloading under pressure is only an optimization — it keeps a heavy run from exhausting its budget. Losing the event from replay is a worse outcome than carrying it inline, so a failed pressure offload now falls back to the shared cap: exactly what the run would have done before pressure engaged.

if (valueThresholdBytes === undefined) return compactEventForBuffer(event, options)
try {
  return await compactEventForBuffer(event, { ...options, valueThresholdBytes })
} catch (error) {
  logger.warn('Pressure offload failed; buffering the event inline instead', {})
  return compactEventForBuffer(event, options)
}

The fallback only applies when pressure was active, so a run below the mark behaves exactly as it does on staging.

Covered by buffers the event inline when a pressure offload cannot be persisted. It is a sharp test: with the fallback removed, 20 writes under a persistence failure yield only 8 buffered events — 12 silently lost from replay. With it, all 20 survive.

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit e2e4138. Configure here.

@waleedlatif1
waleedlatif1 merged commit f210a6e into staging Aug 4, 2026
28 checks passed
@waleedlatif1
waleedlatif1 deleted the fix/execution-event-value-threshold branch August 4, 2026 00:23
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