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 (WorkflowModelConverter → MarshallingUtils.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 precedent — the `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)
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.
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 (WorkflowModelConverter→MarshallingUtils.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):
There is currently no claim-check / pointer mechanism for state exchanged between steps. Note this is distinct from
ExternalResourceinsdk-java, which only references definition artifacts (schemas, OpenAPI/gRPC descriptors, function defs) — not the runtime payload flowing between steps.Proposed solution / API
Alternatives considered
{ "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.Area(s)
Impact & scope
core(model factory + lazy model + config),persistence/*(converter/serializer interception across JPA, Redis, MVStore — the JPAWorkflowModelConverterusesautoApply=true, so it must be replaced rather than added), plus a newBlobStoreSPI withfilesystem/s3/azure-blobimplementations.completed/failed/aborted) via aWorkflowExecutionListener/persistence hook; define retention for durable/replayable runs.ClaimCheckModelmust also marshal as a pointer throughMarshallingUtils(replay), not just at the JPA boundary.claim-check/with per-backend submodules) mirroring thelangchain4jmodule layout?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.