Skip to content

TriggerFlow 4.1.4.3 snapshots multiply completed interrupt/resume payloads and provide no bounded retention API #331

Description

@Maplemx

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingsolvedFor those issues already solved but stay open for more user can see

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions