Task Flow: the Next Task by Condition or Probability
An entity rarely leaves after completing just one task. An order is checked, perhaps wrapped as a gift, packed, sometimes packed again, and shipped. This page shows how a finished task chooses the next one, and what the entity carries from task to task.
What It Is
A flow is the sequence of tasks one entity passes through. Each task names what follows it: always the same task, the first of several rules whose condition holds, or a task drawn at random by weight. A rule can send the entity back to a task it has passed, for rework. A task that names nothing ends the flow. The entity keeps its key and its attributes through the whole flow, and every task can read them.
A Flow of Tasks builds a pick, pack and ship flow step by step.
How to Use It
Choose the Next Task
next takes one of these forms:
- A task name,
next: pack_order: always that task. - Rules,
whenwith an optionaldefault: each rule is a condition and a task. Rules are tried in order. The first matching rule wins, anddefaultapplies if none matches. With no default and no matching rule, the flow ends. - Weights, below.
- In the builder, a function that takes the
Upstreamand returns a task name orNone.
| docs/snippets/yaml/task_flow.yaml:condition | |
|---|---|
A Router takes the same rules as next. When a task finishes, its process builds the Upstream, asks route.choose(upstream, env, levels=...) for the next task's name, and starts that task with the same entity. env lets a condition read registry[...] and t. levels, a mapping from store and container names to the objects, lets it read level.<name>.
The condition is evaluated when the task finishes, after its set. payload. reads the payload of the task that just finished, attr. the entity's attributes, level. a store's or container's level, and registry["..."] a live parameter. How It Works lists everything a condition can use.
Weights
weights maps task names to numbers, and the next task is drawn in proportion to them. The draw comes from a random stream of the routing task's own, route.<task>, so a seeded run repeats it and it changes no other draw:
| docs/snippets/pages/task_flow.py:builder-weights | |
|---|---|
| docs/snippets/pages/task_flow.py:low-level-weights | |
|---|---|
Attributes
An arrival gives each new entity its starting attributes, and a task's set changes them when the task finishes. Each entity gets its own copy. Attributes are not published: a condition reads them as attr.<name>, and a payload includes them only when it asks.
| docs/snippets/yaml/task_flow.yaml:set | |
|---|---|
A task's attributes list adds the named attributes to a mapping payload, as ship_order does in the full blueprint, task_flow.yaml.
An Entity(key, arrival, attributes) carries them. The process updates entity.attributes itself after a task, as the low-level tab of A Flow of Tasks shows.
Entity Key
Each arrival starts a new entity, keyed by the arrival's name and a counter from 1: orders-1, orders-2. Every event of every task in its flow carries that key as its record key, so a sink can follow one order through the whole flow. Kafka sends records with the same key to one partition, so it keeps one order's events in order. The builder can replace the key with a function of the counter:
A blueprint uses the default key.
The process that creates each Entity gives it its key.
Below are the finished events of gifts-5 in task_flow.yaml, the blueprint whose parts this page shows. The gift order is checked while the shelf is empty, backordered, checked again, wrapped, packed and shipped, all under one key:
Upstream
A task started by next receives what the task before it handed on, the Upstream: the finished task's instance id, the entity's key and attributes, and the finished task's payload. A builder function gets it by declaring a parameter called upstream, and gets the entity by declaring entity. A function that declares neither is called without them. The first task of a flow gets upstream=None.
A blueprint task has no function to declare upstream. Its payload names the attributes it carries, and its next conditions read the finished task's payload as payload.<field> and the entity's attributes as attr.<name>.
The low-level API has no Upstream: a process holds the order and its attributes itself, and decides the next step in Python.
How It Works
Conditions. A condition is a Python expression, parsed with Python's ast module and checked against a list of what is allowed before it is ever evaluated. Nothing goes through eval. It may use:
- numbers,
TrueandFalse, and the arithmetic+,-,*,/,//and%; - the comparisons
<,<=,>,>=,==and!=, strings compared with==or!=, andinandnot inwithitems(<store>)on the right; and,or,not, and the conditional forma if c else b;- names with their source as a prefix:
payload.<field>,attr.<name>andlevel.<name>;users.<name>, how many units of a resource are held, andqueue.<name>, how many requests wait for it (Resources);putters.<name>andgetters.<name>, how many puts and gets wait at a store or container;count(<store>)anditems(<store>), how many items a store holds and the list of them (Stores and Containers);item.<field>, the item the task's storegettook (Stores and Containers);wait.<branch>.done,.okand.value, the task's wait, andsignal.<name>.count,.okand.value(Signals and Waiting);process.<name>,.instanceand.target(Tasks and Processes);registry["<path>"];
t, the simulation time in seconds.
A name carries its source, so adding a payload field cannot change what an attribute of the same name means. The registry[...] form lets a registry path, which contains dots, be written whole. Anything else is refused with a message that names it:
A blueprint condition that uses anything else is refused when the blueprint is built, with the same message.
The low-level API has no conditions: a process decides in Python.
A blueprint is checked when it loads, and each error names its YAML line. Every condition must parse, and every task a rule names must exist. Every level.<name> must name a store or container, every users.<name> and queue.<name> a resource, every putters.<name> and getters.<name> a store or container, and every count(...) and items(...) a store. Every registry["..."] must be a path of the model. The builder makes the same checks when run() starts. A name that is missing when a condition is evaluated, such as a payload field, raises an error naming the expression.
Weights. The weights are turned into cumulative shares once. Each choice draws one uniform number from the routing task's stream and picks the task whose share it falls in.
Starting the Next Task. When a task finishes, it applies set, builds the Upstream and asks its rules for the next task. It starts that task as a new SimPy process, with the same entity and the next instance id from the task's counter. An arrival that spawns the task uses the same counter, so no two runs of a task share an id. In SimPy terms, one process starting the next is the whole mechanism. SimPy and desflow describes processes.
Stability and Limits
- Flow balance. The rate into each task follows from the rates into the tasks before it and the routing shares. With weights of 9 to ship and 1 back to packing, one packing in ten sends the order back to be packed again. Every order is packed once, one in ten a second time, one in a hundred a third time, and so on. An order is therefore packed 1 + 0.1 + 0.01 + ... = 1 รท 0.9 = 1.11 times on average, so the packers see 11% more work than there are orders.
- Rework raises utilisation. Work the load out with the rework included, as Queues and Capacity does, or a rework loop can push a resource past full use. Queueing Basics explains utilisation.
- Refused expressions.
**, function calls, indexing other thanregistry["..."], list and dictionary literals, and bare names other thantare refused. - One entity, one flow. A task leads to at most one next task. Splitting one order into several, or joining several into one, comes after 1.0.
decideis kept free. The task keydecideis kept for decisions sent from outside for one entity, planned after 1.0. Today it is refused like any unknown key.
Example: A Flow of Tasks
In the flow example, orders are picked, gift orders are wrapped, one parcel in ten is packed again, and every order is shipped. Wrapping and shipping are timed steps. It needs no Docker service and ends on its own.
The tutorial page A Flow of Tasks teaches these features step by step.
Quick Start
desflow examples get downloads the example at the release you install, into a folder of its own. Pick uv or pip:
The whole file is examples/yaml/flow.yaml.
Quick Start
desflow examples get downloads the example at the release you install, into a folder of its own. Pick uv or pip:
The whole file is examples/builder/flow_example.py.
Quick Start
desflow examples get downloads the example at the release you install, into a folder of its own. Pick uv or pip:
| examples/low-level/flow_example.py:routes | |
|---|---|
The whole file is examples/low-level/flow_example.py.