Model the workflow payloads instead of storing them as documents
Table of Contents
This page is a capture in the inbox bucket of the product backlog — a pre-sprint idea, not yet pulled into a sprint as a story.
1. What
Decide, for each of the workflow schema's serialised-message columns, whether it is a model that was flattened into a document or a payload the engine must not interpret — and give the first kind the relation it should have had. The end state is that nothing in this schema holds a relation disguised as a document, and nothing that is genuinely opaque claims to be understood.
The six columns are:
| Column | Table | Holds |
|---|---|---|
request_json |
instance | the start message the run was created from |
result_json |
instance | the last step's response, relayed |
request_json |
step | the command built for the step |
command_json |
step | the command built for the step |
response_json |
step | what the downstream service answered |
compensation_json |
step | the compensation command |
step_log_json |
step | the entries a step emitted while it ran |
2. Why
The same argument that settled a run's chain as relations rather than a document applies here, and it is worth being precise about which side each column falls on, because the answer is not the same for all of them.
A payload is an opaque wire message: the service that carries and stores it must not interpret it, and serialising it verbatim is the correct design. A model is a relation: it has named fields, each with a type, and code that reads them by name. Storing a model as a document means the schema cannot constrain it, cannot index it, cannot join it, and cannot say what it holds — which is exactly the position the chain was in before it became two relations.
Three things fall out of reading the columns, before any decision is taken.
step_log_jsonholds a model that the model already declares. It is astd::vector<ores::workflow::messaging::step_log_entry>, andstep_log_entryis declared inores.workflow.workflow_messageswith a level, a message and a context. The entries are typed on the wire, typed in the model, and flattened into a document only at the point of storage. This one is a child relation whatever else is decided.- Two of the columns hold the same thing.
workflow_step.request_jsonandworkflow_step.command_jsonare assigned the same value at every site that writes either of them, so one of them is redundant. They read as "what the step was asked" and "what was published", which is a distinction nothing makes. - One of them is never written. The engine builds a compensation command on
the fly through the definition's
build_compensationand publishes it, and where it records that command it records it on the compensation step's owncommand_json. Nothing writesworkflow_step.compensation_json, so it holds the empty default on every row the engine produces.
That leaves the interesting one. request_json, response_json and
result_json carry messages whose types the engine does not know: the
definition's builder parses the request by name, the step's own
require_step_result<T> parses a response as the type it expects, and the
engine relays the bytes between them without ever naming what they are. The
engine is a generic saga runner, so that erasure is deliberate. The question is
whether it is at the right layer: the schema stores a document whose type is
known to a definition the schema has never heard of, so nothing can validate a
payload, version one, or reject a step that answers with the wrong shape.
3. Questions to answer
- For
request_json,response_jsonandresult_json: is the type of each statically known per workflow type and per step, and if so where does that knowledge live? A definition states its start message and each step states its command type; if the engine could name them, a payload could carry the message type it is an instance of, and the column could stop being typeless. - Is a typed payload a relation or a column? A message type and a body is still a payload; what changes is that the schema can say which message it is, validate it at the boundary, and refuse a body that does not parse as the type it claims.
result_jsonon the instance: is it the last step's response, a message of its own, or a value the definition builds? The answer decides whether it is a relayed payload or a modelled one.step_log_jsonas a child relation: what is the entry's identity, is order significant, and does an entry survive a step retry?- Does the engine ever need to interpret a payload beyond relaying it? It
already extracts the target from the start message into
target_kindandtarget_id, which is the precedent: what the engine acts on belongs in a column, and what it merely carries does not. - What is the migration path for messages already stored as documents, and does anything outside this component read them?
4. Out of scope
The payloads of other components. This capture is about the workflow schema's own serialised columns; a component that stores one of its own messages as a document is the same argument in a different place, and is worth a pass of its own once the shape is settled here.
5. References
- Hold a workflow as a dependency graph — where a run's chain stopped being a document and became two relations, and the reasoning this capture applies.
projects/ores.workflow/modeling/ores.workflow.workflow_step.organdores.workflow.workflow_instance.org— the columns and what they claim.projects/ores.workflow/modeling/ores.workflow.workflow_messages.org— thestep_log_entrythestep_log_jsoncolumn flattens.projects/ores.workflow/core/src/service/workflow_engine.cpp— every site that writes any of the six.
6. See also
- Gather a run's inputs by declared dependency — the story whose graph work raised the question.