DiPeN - Distributed Petri Net runner
This crate implements a distributed runner to use petri nets as a workflow engine, that is, driving real workflows, not just simulating them.
The petri net differs from the classical petri net in multiple areas:
- Tokens are colored: Tokens can have arbitrary data attached to them.
- Transitions are timed: You are expected to write asynchronous code to be executed when a transition is firing.
- Transition have arbitrary guard expressions: You can write any logic you want to decide whether a transition can be fired.
If the last paragraph sounded like gibberish to you, read the Petri Net section for an introduction on DiPeN's version of petri nets. Otherwise, you can skip this and continue with Getting Started.
Petri Net
A petri net is a graph that has two node types: places and transitions. A transition and a place can be connected with an arc, there are no connections between two places or between two transitions.
A simple example might look like this,
The current state is represented by tokens. In DiPeN, each token is different and you can attach arbitrary data to it. This is also known as a 'colored' Petri net.
Tokens are represented as small dots, e.g. the starting state for our example net could look like this,
Petri nets progress by firing transitions. The transition in the middle might have some logic to decide that it can fire, if there is at least one token on an input place. This logic is something you would implement for every transition when using this library. This is also called a 'guard expression'.
When the transition starts firing, it can decide to take tokens from its input place, lets assume it takes one token, then we end up with the following state,
The firing of a transition is represented as an async function in the code and is it up to you how to implement that. Transitions may take some time to complete, this is known as a 'timed transition'. At the end of the firing process, a transition can place the token it has taken from the input place and put it into anoutput place. It could also destroy the token or create new ones, but let us assume the token is moved, we end up with,
The transition may fire a second time and move the second token, resulting in a final state of
In DiPeN we have 5 different arc types you can use:
- In: An incoming arc, the transition can use the attached place and its tokens to decide whether it should fire. When starting to fire, it can take tokens from this place
- Out: An outgoing arc, the transition can place tokens here when it finishes firing. Those token can be from input places or they can be newly created ones.
- Cond: The transition can decide whether to fire based on the attached place and its tokens, but no tokens are moved from or to this place.
- InOut: A combination of the incoming and outgoing arc. Tokens can be taken and placed here.
- OutCond: A combination of an outgoing arc with a condition. The state of the attached place and its tokens can be used to decide whether to fire and tokens can be placed here, but no tokens can be taken.
Getting Started
The minimal example below implements the following net:
The transition fires only if the output place is empty. If it fires, it places a token on the output place.
# use ;
#
# use ;
# use CancellationToken;
# use EnvFilter;
async
// Implement the PlaceNew transition
Implementation
The current implementation does roughly the following:
- build a reduced petri net, only containing the region we want to run
- call [
exec::TransitionExecutor::validate] on all transitions, stopping with an error in case of a failure - connect to etcd
- try to become elected leader for the given region - this ensures only one runner is active for a region. It also allows to start multiple runners and a second one will immediatly start as soon as the first one revokes or looses its etcd lease.
- spawn a transition runner for each transition in the net, which does:
- cancel a running transition. We are not yet running. If a transition is running according
to etcd, it is cancelled and all the tokens are placed back on their input place.
In a node crash this results in a transition firing a second time, probably with exactly
the same data. It is a good idea to have this in mind when writing a
[
exec::TransitionExecutor::run] method. Ideally the method would be idempotent. - create a transition instance using [
exec::TransitionExecutor::new] - on every change of one of the condition or input arcs for this transition, run
[
exec::TransitionExecutor::check_start]. Note: As an optimization,check_startcan return a specific place to watch. In this case only changes on that place trigger a recheck. - if we can start, acquire a distributed lock to the places connected with conditional or
input arcs and run
check_startagain, possibly with a different state. - If we can still run, take the tokens indicated by the result of the
check_startfunction, using the fencing tokens from the acquired locks to ensure we are allowed to do so. - release the locks (at least locally, see note below)
- Call [
exec::TransitionExecutor::run] - Use the return value to move / create tokens on etcd, again by acquiring locks on the output places and using the fencing tokens to ensure we are allowed to place tokens.
- Loop: check for changes on the connected conditional or input places again.
- cancel a running transition. We are not yet running. If a transition is running according
to etcd, it is cancelled and all the tokens are placed back on their input place.
In a node crash this results in a transition firing a second time, probably with exactly
the same data. It is a good idea to have this in mind when writing a
[
Note that acquiring distributed locks takes a while. To optimize this, the acquired locks are not given back until some other runner requests them. For all places that are only used within one runner, the locks are only taken once. There still is local locking between the different transition tasks, but this is much faster than communicating with etcd.
Another optimization concerns places at boundaries between regions. Assume that the following part of a net,
Normally, everytime a token is passed from the left to the right region, the transition in the left region needs to acquire a lock before placing the token and afterwards the region on the right needs a lock to take the token.
However, the output locking is unnecessary, if this place were to be used like a queue, that is, the transitions on the right only ever take the oldest token from the place. In this case, the transitions on the right do exactly the same, even if another token were to be added to the place. Even if this token is added while the right region holds the place's lock.
To facilitate that, output locking can be disabled for individual places. Only do this if it
does not introduce any logic problems. Always check: For every transition taking tokens from
this place, running check_start should give the same
result, even if additional tokens were to be placed.
Benchmarks
Single node
Uses N identical copies of the following net:
The benchmark starts when all the 'I' transitions run for the first time (synchronised with a barrier). Benchmark ends when the 'I' transitions fire for the second time (synchronized with a barrier again).
etcd is running locally with a single node. Real deployments would have multiple etcd notes that need to communicate, so changes would need more time, i.e. a chain of transitions that need to happen one after the other (as in the example net) are likely much slower. Furthermore, real transitions would likely have side effects, which need time to be executed.
Locking
Note that the single-node benchmark above never measures acquiring a lock from etcd.
When running a single net as shown above, but with 'T1' and 'T2' in different regions, throughput drops to ~5 transitions/s on my machine. One transition requires two lock changes (input and output place) so it currently costs roughly 100 ms to change the lock owner between different runners.
CPU: Intel(R) Core(TM) i5-9600 CPU @ 3.10GHz (6 cores)
SSD: Corsair Force MP510