Skip to content

feat: claim-check support for large workflow state (filesystem, S3, Azure Blob pointers between steps) #666

Description

@KayandeSouzaPereira

The problem

Today the data that flows between workflow steps is the io.serverlessworkflow.impl.WorkflowModel, and it is always passed by value: the full payload is held in memory and, when persistence is enabled, the entire model is serialized into the DB column at every checkpoint (WorkflowModelConverterMarshallingUtils.writeModel(...)).

This is fine for small JSON, but becomes painful when a step produces a large artifact (generated documents, images, datasets, LLM/agentic context, file uploads):

  • the full payload is loaded in memory at every step that touches the model;
  • the full payload is serialized into the persistence store at every checkpoint (JPA/Redis/MVStore), bloating rows and I/O;
  • there is no first-class way to say "this step's output is a reference to a blob, not the blob itself".

There is currently no claim-check / pointer mechanism for state exchanged between steps. Note this is distinct from ExternalResource in sdk-java, which only references definition artifacts (schemas, OpenAPI/gRPC descriptors, function defs) — not the runtime payload flowing between steps.

Proposed solution / API

Add an **optional, pluggable claim-check layer**: when a step's model exceeds a configurable threshold, the engine offloads it to a blob store and keeps only a small pointer (URI + metadata) in the in-memory model and in persistence. The pointer is resolved lazily when a downstream step actually reads the data.

The hooks to do this already exist and there is precedentthe `langchain4j` module already swaps the model factory via `WorkflowApplicationBuilderCustomizer` (`builder.withModelFactory(new AgenticAwareModelFactory())`) and ships a custom `AbstractWorkflowModel` (`AgenticAwareWorkflowModel`). The same seams can host claim-check.

Proposed shape:


// 1. Pluggable backend SPI (one impl per storage type)
public interface BlobStore {
    BlobRef put(byte[] data, String contentType);   // returns a pointer
    byte[] get(BlobRef ref);                          // lazy resolve
    void delete(BlobRef ref);                         // GC on terminal states
    record BlobRef(URI uri, String contentType, long size) {}
}

// Built-in backends (selected by config):
//   - filesystem  ->  file:///var/flow/blobs/...
//   - s3          ->  s3://bucket/key        (quarkus-amazon-s3)
//   - azure-blob  ->  https://acct.blob.core.windows.net/container/blob

// 2. Claim-check model factory: offload above threshold, otherwise passthrough.
//    Registered via the existing WorkflowApplicationBuilderCustomizer seam.
public class ClaimCheckModelFactory extends JacksonModelFactory {
    @Override public WorkflowModel from(Map<String,Object> map) { return maybeOffload(super.from(map)); }
    @Override public WorkflowModel fromOther(Object o)          { return maybeOffload(super.fromOther(o)); }
    // maybeOffload(): serialize; if size > threshold -> blobStore.put(...) -> ClaimCheckModel(ref)
}

// 3. Lazy model holding only the pointer; resolves on first asX() access.
public class ClaimCheckModel extends AbstractWorkflowModel { /* delegates to lazily-loaded model */ }

// 4. Persistence: store only the pointer when the model is a ClaimCheckModel
//    (intercept the WorkflowModel AttributeConverter / backend serializers).


Configuration sketch:


quarkus.flow.claim-check.enabled=true
quarkus.flow.claim-check.threshold=256K          # offload models larger than this
quarkus.flow.claim-check.backend=s3              # filesystem | s3 | azure-blob
# filesystem
quarkus.flow.claim-check.filesystem.dir=/var/flow/blobs
# s3
quarkus.flow.claim-check.s3.bucket=my-flow-state
# azure
quarkus.flow.claim-check.azure.container=flow-state


It should be **opt-in** and transparent to step authors: steps keep returning plain objects; the engine decides offload/inline based on size. A user-facing escape hatch (e.g. a marker/annotation to force or skip offloading per step) would be a nice-to-have.

Alternatives considered

  • Manual claim-check in user code (steps call a blob service and pass only { "docRef": "s3://..." }, resolving it explicitly downstream). Works today with zero framework changes, but is verbose, error-prone, leaks storage concerns into every step, and lacks automatic GC. Good as a documented pattern, but not a substitute for first-class support.
  • Doing nothing / relying on bigger DB columns: doesn't address memory pressure or per-checkpoint serialization cost.

Area(s)

  • Runtime / Engine
  • DSL / API
  • Dev UI / Mermaid
  • Persistence
  • Messaging / CloudEvents
  • Observability (Micrometer / OTEL)
  • Docs & examples
  • Agentic AI

Impact & scope

  • Who benefits: anyone with large step payloads — document pipelines, file processing, and especially agentic AI flows where context/state can be large.
  • Modules touched: core (model factory + lazy model + config), persistence/* (converter/serializer interception across JPA, Redis, MVStore — the JPA WorkflowModelConverter uses autoApply=true, so it must be replaced rather than added), plus a new BlobStore SPI with filesystem / s3 / azure-blob implementations.
  • Open questions for triage:
    • Idempotency on retry/replay: offload must be deterministic or keyed so retried steps don't create orphan blobs.
    • Garbage collection: delete blobs on terminal workflow states (completed/failed/aborted) via a WorkflowExecutionListener/persistence hook; define retention for durable/replayable runs.
    • In-memory marshalling boundary: ClaimCheckModel must also marshal as a pointer through MarshallingUtils (replay), not just at the JPA boundary.
    • Packaging: dedicated module (e.g. claim-check/ with per-backend submodules) mirroring the langchain4j module layout?
  • Breaking changes: none — feature is opt-in and defaults to off.

I'm happy to work on a PR (starting with the SPI + filesystem backend, then S3/Azure) if the maintainers are open to the direction.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions