Modeling a business process
In contrast to tracking state of some external thing, modeling a business process aims at enforcing the proper procedure within Actyx. Not all events or changes are possible at all times, there is a prescribed sequence of events and decision points that needs to be observed. The process is usually designed by a domain expert and implemented by IT personnel.
Consider for example the agreement on what to cook for dinner:
- someone poses the question, which comes at the price of having to list a few options
- for a limited time period everybody gets to cast their vote
- when it is time to make a decision, mom picks the winner
- this happens in time for dad to pay a visit to the grocery store
- once all ingredients are at home, the fun can begin
As a tribute to real life, we also include the possibility to abort this process at any point, which means that the family will dine out.
Solution strategy
Since following this process means limiting current choices based on what has already happened, we will use the Actyx Pond library, see the conceptual guide for an introduction. Again, we start by defining the event model for recording the progression of the workflow, but in order to ensure that only appropriate events are emitted we then use a fish to process them. This yields both a current state and a set of applicable commands.
Designing the event model
This time the event model does not record changes in some external thing; this time each event marks the progression of the desired workflow. We therefore start by drawing the workflow:
This diagram shows the five states our workflow can be in, together with the events that mark transitions between them. We include the data that will be needed and thus the event design is already finished.
type Started = { type: 'started'; options: string[] }
type Voted = { type: 'voted'; option: string }
type Decided = { type: 'decided'; choice: string; ingredients: string[] }
type Ready = { type: 'ready' }
type Aborted = { type: 'aborted' }
type DinnerEvent = Started | Voted | Decided | Ready | Aborted
Encoding the state machine
Once we have the events, we need to use them to compute a current state. This state is not just equal to the type of the most recently written event because the proper sequence of transitions needs to be observed. If we get a nonsensical event, we need to ignore it. We start out by defining the states:
type Initial = { type: 'initial' }
type Voting = { type: 'voting'; options: string[]; votes: string[] }
type Decided = { type: 'decided'; choice: string; ingredients: string[] }
type Cooking = { type: 'cooking'; choice: string }
type DineOut = { type: 'dineOut' }
type DinnerState = Initial | Voting | Decided | Cooking | DineOut
There is some overlap with the event definitions, but the states usually hold on to more information than a single event provided. For example, it is useful to know what we are cooking, or we need to keep track of submitted votes. The state machine is the code that takes an existing state and a new event and then decides what the following state shall be.
const onEvent: Reduce<DinnerState, DinnerEvent> = (state, event) => {
switch (state.type) {
case 'initial': {
switch (event.type) {
case 'started': {
const { options } = event
return { type: 'started', options }
}
}
break
}
case 'voting': {
switch (event.type) {
case 'voted': {
const { option } = event
const { votes } = state
votes.push(option)
return { ...state, votes }
}
case 'decided': {
const { choice, ingredients } = event
return { state: 'decided', choice, ingredients }
}
}
break
}
// and so on for the other states
}
// otherwise, if an unexpected event was encountered, stay in the same state
return state
}
If this state machine gets too big, it is a good idea to factor out the handling of each state into its own function.
Please note that states need to be plain objects in current versions of Actyx Pond — using classes is not yet supported.
Breathing life into the fish
In order to see the state machine in action, we will need to instantiate it in an Actyx Pond. This requires a small collection of metadata to describe what exactly should happen:
const DinnerTag = Tag<DinnerEvent>('Dinner')
const DinnerFish = (dinnerId: string): Fish<DinnerState, DinnerEvent> => {
fishId: FishId.of('Dinner', dinnerId, 1),
where: DinnerTag.withId(dinnerId),
initialState: { type: 'initial' },
onEvent
}
Now we could observe this fish with pond.observe(DinnerFish('abcd'))
, but it will always be in the initial
state because no events have been emitted.
Offering the appropriate commands
Each of the state transitions described above is triggered by the reception of an event, which may come from some other Actyx node or from the local one.
In any case it must be generated somewhere, based on the current state known at that Actyx node.
This is done using the Pond.run()
facility.
const commands = (dinnerId: string, pond: Pond) => (state: DinnerState) => {
start: state.type === 'initial'
? (options: string[]) =>
pond
.run(
(state2, enqueue) =>
state2.type === 'initial' &&
enqueue([
DinnerTag.withId(dinnerId).and(Tag('started')),
{ type: 'started', options },
]),
)
.toPromise()
: undefined
vote: state.type === 'voting'
? (option: string) =>
pond
.run(
(state2, enqueue) =>
state2.type === 'voting' &&
enqueue([DinnerTag.withId(dinnerId), { type: 'voted', option }]),
)
.toPromise()
: undefined
// and so on for the other events
}
This function needs to be primed with a dinnerId
and pond
, which results in a function that computes the available commands for a given state.
A (graphical) UI could inspect the returned object and render buttons according to command availability.
When such a button is pressed, the required inputs are passed into the corresponding command function, which will dispatch this request via the Pond to the DinnerFish.
If the target fish still is in the right state when it is this command’s turn to be executed, then the enqueue
function is invoked to effect the event emission.
Subsequently the fish will see the emitted event and publish a new state to all its observers.
The Promise
returned from each command invocation should be await
ed to see whether the event publication succeeded:
it can fail if Actyx is currently unavailable (for example because it is being restarted).
Tying the pieces together
With the above code implemented, we can create a skeleton “dinner voting app”:
import * as uuid from 'uuid'
const pond = await Pond.default(/* app manifest */)
const dinnerId = uuid.v4()
const dinnerCommands = commands(dinnerId, pond)
pond.observe(DinnerFish(dinnerId), (state) => renderButtons(dinnerCommands(state)))
This is not quite good enough yet because every time this app starts on some Actyx node it will show a fresh dinner workflow in its initial state.
Instead, we want to show a selection of dinner preparations to join.
The astute reader will have noticed that the extra started
tag has been added in the command processor defined above.
Using this tag we can efficiently find all preparations started within the last hours:
const cutOff = new Date(Date.now() - 6 * 60 * 60 * 1000) // six hours ago
const dinnerIdResult = await pond.events().queryAql({
query: `PRAGMA features := timeRange
FROM 'Dinner' & 'started' & from(${cutOff})`,
})
const dinnerIds = dinnerIdResult
.filter((r) => r.type === 'event')
.map((r) => r.meta.tags.find((t) => t.startsWith('Dinner:')?.slice(7)))
const dinnerStates = await Promise.all(
dinnerIds.map(
(id) =>
new Promise((res) => {
const cancel = pond.observe(
DinnerFish(id),
(state) => {
cancel()
res({ ...state, id })
},
() => cancel(),
)
}),
),
)
Now we have a set of dinner workflows, complete with their identifier and respective state.
We could render an overview page for the user to select one of these or start a new one (i.e. creating a new identifier and emitting a started
event with the corresponding fish).
How it will be executed
The process we described and encoded in a state machine relies on getting the events in the right order:
if we see a ready
event without having seen the decided
event then we have no clue what we should be cooking!
Actyx helps by giving you three core guarantees:
- every node will eventually get all the events it needs (assuming the system keeps running and communication works sometimes) [reliability]
- once a node gets all events about some process, it will apply them to the state machine in the same order as all other nodes [eventual consistency]
- an event that is emitted after seeing a state based on some other events will be ordered after those other events [causality]
In our above process, dad will have seen the decided
event (and the included list of ingredients) when he publishes the ready
event.
Therefore, everyone will eventually see these two in that same order.
One of the kids may first get dad’s event without having synced events from mom’s phone yet, in which case our onEvent
function will ignore the ready
event; the app will remain in the voting
state until the sync with mom occurs.
There is one piece of our event design that bears special mention:
we included the choice
in the decided
event instead of relying on always applying the same voting mechanism.
This is important because the decision recorded here is fixed, based on what mom knew at the time.
Once dad is shopping groceries there is no way to go back and revisit the voting process.
So if a vote arrives late at mom’s phone, subsequent review of the event history may indicate that the decision was “incorrect”, but that is only true in hindsight and irrelevant for the ongoing execution of the workflow.