Summary
A durable TriggerFlow execution snapshot can grow by several multiples of the application payload because completed pause/resume data is retained in several internal structures at once. The public runtime-event compaction policy does not compact these snapshot sections, and I could not find a public retention/projector API for completed interrupts, resume requests, or signal attempts.
This is not only an application-side “large payload” issue: one request/response value is copied into multiple framework-owned history structures after the interrupt has already reached a terminal state.
Environment
- Agently:
4.1.4.3
- Python:
3.10
- TriggerFlow durable execution with a snapshot store
Production observation
One closed/cancelled execution with no pending interrupts produced a 6,605,964-byte JSON snapshot.
| Snapshot section |
Bytes |
Share |
signal_net |
2,754,161 |
41.7% |
interrupts |
2,171,383 |
32.9% |
runtime_data |
942,980 |
14.3% |
resume_ledger |
520,036 |
7.9% |
sub_flow_frames |
213,276 |
3.2% |
The snapshot contained 13 interrupts (12 resumed, 1 cancelled) and 61 completed signal attempts. A large resume value was present in several paths, including:
interrupt.response
interrupt.resume_value
interrupt.resume_requests.*.value
resume_ledger.*.*.value
- completed signal-attempt metadata embedding the interrupt/resume fields
signal.meta.resume.value
The application does pass non-trivial evidence/probe data through pause/resume, which amplifies the symptom, but the repeated terminal-history copies are framework-owned.
Minimal reproduction
import asyncio
import json
from agently import TriggerFlow
class MemoryStore:
def __init__(self):
self.state = None
async def put_snapshot(self, run_id, state, **kwargs):
self.state = state
return {"ref": "memory"}
async def get_snapshot(self, run_id):
return self.state
async def main():
async def pause(data):
return await data.async_pause_for(
payload={"request": "x" * 100_000},
resume_to="next",
)
async def done(data):
return data.input
flow = TriggerFlow(name="snapshot-bloat-repro")
flow.to(pause).to(done)
store = MemoryStore()
execution = flow.create_execution(
auto_close=False,
record_store=False,
runtime_resources={"snapshot_store": store},
)
await execution.async_start(None)
interrupt_id = next(iter(execution.get_pending_interrupts()))
await execution.async_continue_with(
interrupt_id,
{"response": "y" * 100_000},
resume_request_id="req-1",
)
snapshot = execution.save(require_idle=True)
def size(value):
return len(json.dumps(
value,
ensure_ascii=False,
separators=(",", ":"),
).encode())
print("snapshot", size(snapshot))
print("interrupts", size(snapshot["interrupts"]))
print("resume_ledger", size(snapshot["resume_ledger"]))
print("signal_net", size(snapshot["signal_net"]))
asyncio.run(main())
Observed output:
snapshot 1306777
interrupts 501548
resume_ledger 100247
signal_net 602860
The same flow with tiny payloads is about 6,725 bytes. With the reproduction above, 200 KB of application request/response text becomes about 1.31 MB of serialized snapshot data.
Additional isolation showed approximately:
- 100 KB request alone -> 406 KB snapshot
- 100 KB response alone -> 907 KB snapshot
Compaction does not cover this state
I also tested execution.set_compaction_policy(...) with a 10 KB request and 10 KB response.
Before compaction: 136,916 bytes
After compaction: 137,555 bytes (the increase is compaction metadata)
The relevant sections were unchanged:
interrupts 51541 -> 51541
resume_ledger 10244 -> 10244
signal_net 62855 -> 62855
This appears consistent with the current implementation: runtime-event compaction applies to runtime_event_store events, while the snapshot serializer still includes full completed signal attempts, interrupt records, request values, response values, and the resume ledger.
Expected behavior
Full payloads are necessary for pending interrupts and active recovery boundaries. Once an interrupt/resume/signal attempt is terminal and a durable successor checkpoint exists, the serialized snapshot should be able to retain a bounded summary instead of repeated full values.
For example:
- pending interrupts retain the complete payload;
- resumed/cancelled interrupts retain IDs, status, timestamps, related signal/request IDs, and optionally a digest/artifact reference;
- completed signal attempts retain bounded metadata rather than embedding full interrupt/resume values;
- the idempotency ledger stores a digest/result reference instead of another full response copy;
- applications can configure latest-N history, a byte budget, or a snapshot projector/retention policy;
- load/resume validates summaries and does not prune anything needed by the active recovery boundary.
Suggested API / compatibility direction
A public snapshot_retention_policy or snapshot projector could be separate from runtime-event compaction, with defaults preserving current behavior for compatibility. Useful controls could include:
- retain full values for pending/active records;
- retain latest N terminal records;
- terminal-history byte budget;
- replace terminal values with digest + external artifact reference;
- load-time migration/projection for older snapshots.
It would also help if the documentation explicitly stated that set_compaction_policy(...) compacts runtime events but not TriggerFlow snapshot internals.
At-rest gzip is an effective temporary storage mitigation (the 6.61 MB sample compressed to about 1.02 MB at gzip level 1), but it does not reduce save-time object duplication, serialization CPU/memory, or in-process workspace size, so it does not address the underlying retention behavior.
Summary
A durable TriggerFlow execution snapshot can grow by several multiples of the application payload because completed pause/resume data is retained in several internal structures at once. The public runtime-event compaction policy does not compact these snapshot sections, and I could not find a public retention/projector API for completed interrupts, resume requests, or signal attempts.
This is not only an application-side “large payload” issue: one request/response value is copied into multiple framework-owned history structures after the interrupt has already reached a terminal state.
Environment
4.1.4.33.10Production observation
One closed/cancelled execution with no pending interrupts produced a 6,605,964-byte JSON snapshot.
signal_netinterruptsruntime_dataresume_ledgersub_flow_framesThe snapshot contained 13 interrupts (12 resumed, 1 cancelled) and 61 completed signal attempts. A large resume value was present in several paths, including:
interrupt.responseinterrupt.resume_valueinterrupt.resume_requests.*.valueresume_ledger.*.*.valuesignal.meta.resume.valueThe application does pass non-trivial evidence/probe data through pause/resume, which amplifies the symptom, but the repeated terminal-history copies are framework-owned.
Minimal reproduction
Observed output:
The same flow with tiny payloads is about 6,725 bytes. With the reproduction above, 200 KB of application request/response text becomes about 1.31 MB of serialized snapshot data.
Additional isolation showed approximately:
Compaction does not cover this state
I also tested
execution.set_compaction_policy(...)with a 10 KB request and 10 KB response.Before compaction: 136,916 bytes
After compaction: 137,555 bytes (the increase is compaction metadata)
The relevant sections were unchanged:
This appears consistent with the current implementation: runtime-event compaction applies to
runtime_event_storeevents, while the snapshot serializer still includes full completed signal attempts, interrupt records, request values, response values, and the resume ledger.Expected behavior
Full payloads are necessary for pending interrupts and active recovery boundaries. Once an interrupt/resume/signal attempt is terminal and a durable successor checkpoint exists, the serialized snapshot should be able to retain a bounded summary instead of repeated full values.
For example:
Suggested API / compatibility direction
A public
snapshot_retention_policyor snapshot projector could be separate from runtime-event compaction, with defaults preserving current behavior for compatibility. Useful controls could include:It would also help if the documentation explicitly stated that
set_compaction_policy(...)compacts runtime events but not TriggerFlow snapshot internals.At-rest gzip is an effective temporary storage mitigation (the 6.61 MB sample compressed to about 1.02 MB at gzip level 1), but it does not reduce save-time object duplication, serialization CPU/memory, or in-process workspace size, so it does not address the underlying retention behavior.