modified: 2024/10/23 14:52:14
Following the outline below, this article presents the modeling of entity behaviour, which builds on the structural entity design.
The - potentially semi-automated - behaviour of an entity is modeled by a behavioural expression composed of one or more steps.
A number of players take turn tossing a (virtual) die.
The first players to reach an accumulated target score win - yes, more than one winner is a possibility!
If your score is above the target, the face value of your next turn will be subtracted rather than added, and to win, the target must be reached exactly.
site RollDice
description: Implement a simple game of dice using behaviour and step
area Table
integer Players
default: 3
integer Target
default: 13
step NewGame
guard: Players > 1 and Players < 10 and Target > 6 and Target < 36
behaviour: add(Game, Players: Players, Target: Target)
autocommit # make Game available on other devices
entity Game
collection: Games
readonly
integer Players
integer Target
function IsDone
expression: Scores.any(Amount = Target)
behaviour: # each round, players toss concurrently
while not IsDone() do
with player in 1..Players || Toss(player)
step Toss
parameter Player
behaviour: # user triggered, lazy-create Score, random update
getoradd(Score: Player).set(Amount: NextAmount())
autocommit # persist making others see the toss
entity Score
collection: Scores
integer Player
integer Amount
identity: Player
function NextAmount
expression: if Amount < Game.Target then Amount + random(1,6)
else Amount - random(1,6)
The design comprises an area Table
(the playing table in your favourite saloon), at which you
may at any time enter a number of players and set a target and - if the number is in the 2 to 9
range and the target from 7 to 35 - start a game.
When a step
- in this case NewGame
- is activated by a behaviour, a runtime instance of
that step
is created. Once the guard
predicate evaluates to true, its behaviour
will either autoexecute
or cause a button to appear in the UI for the end-user to click.
Since Table.behaviour
keeps repeating NewGame
every time the previous instance
succeeds, it will always be possible to start a new game unless at some point a failure occurs.
The present example design should not cause failures, but in general, the behaviour will stop executing, when any step instance fails and leave it up to the end user to deal with the failure. In some cases simply rerunning the failed step will remedy the situation.
The entity Game
is designed with a child entity Score
per player and the
Game.behaviour
while not IsDone() do
with player in 1..Players || Toss(player)
will execute rounds of playing until at least one player reaches the target exactly, and in each round one toss for each player can be carried out concurrently: if several devices are used it is possible for several players to toss their virtual die at the same time.
Like Table.NewGame
the step Toss
declares no autoexecute
modifier so when each
round starts a button for each player will appear in the UI, and when that button is hit
Toss.behaviour
executes updating Score.Amount for its player.
The part of the behaviour expression that describes each round of playing
with player in 1..Players || Toss(player)
semantically expands to
Toss(1) || Toss(2) || .. || Toss(Players) # explanatory, but *not* supported
Meaning that the step instances for each player all execute at the same time.
As mentioned above, ||
runs the step instances in parallel whereas the |
operator in
with player in 1..Players | Toss(player)
would mean that in each round all steps would be ready and display a button, but only a single step instance would execute. In the absence of a guard, it would simply be the first player to hit the toss button who got to do a toss in each round. And maybe that same player keeps being the fastest - quite a different game...
Using the sequence operator ;
as in
with player in 1..Players ; Toss(player)
ensures that all players get to toss in each round, but in strict order: player 1 goes first, player 2 goes next, then player 3 and so on.
The playing table is always Active
, and the [NewGame]
step is ready to be executed by a
user clicking the button. In the mockup below, 2 games are active and 7 have been successfully
concluded.
Below, we show a newly started game, which is Active and all 5 indexed Toss
steps are
ready:
As players 5 and 2 hit their buttons, their scores are created, then updated and the buttons disappear until next round, when new incarnations of the steps become ready:
Entering what turns out to be the last round, player 1 has overshot the target, and everybody except player 5 could win in this round...
Players 3 and 5 are fastest - 3 is lucky:
After players 1, 2 and 4 have hit their buttons, no more rounds are played, because the Game
behaviour,
while not IsDone() do ...
exits the loop
leaving the game in its final state of Completed
:
This modeling of behaviour is introduced as an attempt to improve over an approach of exposing a number of event handlers that are each implemented using an imperative programming language.
The declarative behavior
expression and the declarative structure of each step
, which
explicitly separate logic over persistable data from entry and deadline points in time aim to
The syntax used in the behaviour
expression still contain imperative constructs like
assignment and entity creation, and the operators for iteration and parallel execution closely
resemble well known control constructs to achieve the same expressiveness.
This calls for a discussion of how well the present approach addresses the perceived issues with imperative code in practice, and once we - hopefully - have numerous designs utilizing the behaviour construct a thorough comparison with equivalent solutions based on imperative code in event handlers should be made.
The behaviour
expression is composed of step
s that can be combined sequentially,
concurrently, alternately and iteratively. An step may also invoke other steps, but recursion
is not supported.
The readiness of each step can be guarded by a predicate and/or an earliest possible time of entry.
Once ready, a step may execute its own behavior, which is a list of steps that may create new entities, set values on existing entities or call other steps then either automatically commit or propose the changes to an end-user, who may commit.
Finally, a step may specify a success predicate and/or an exit deadline, which determine whether the step concluded successfully or not.
Steps and behaviour are inherited and a step can be abstract and may define a base and be overridden like all members of entities.
Only non-abstract steps can be executed at runtime.
To reduce complexity and ease understanding in the beginning, we start the detailed description deliberately ignoring the temporal aspects of entry time and deadline, which are then treated in a later section.
TODO: Consider 4.3. IDEA: Introducing the notion of stage
.
step
member:step <name>
[abstract]
[base: <step>]
[override: <step>]
[parameter <name>]*
[guard: <predicate>]
[autoexecute]
[behaviour: <behaviour-expression>]
[autocommit]
[success: <predicate>]
where
predicates, expressions and behaviour may refer to
- the members of the declaring entity and any linked or linking entities
- the point in time, creation, when the step instance was created
- the point in time, ready, when the step became ready
- the current evaluation of the success predicate
- the parameter runtime value, if declared
behaviour
property:behaviour: b
where
b ::= imperative | s | while <pred> do b | with <p> in <seq> * b | s * b
s ::= <step>([<parameter-list>])
parameter-list
::= [<parameter>:] <expression> [, parameter-list]
imperative ::= assignment | creation | deletion
assignment ::= [entity-expr.]set(member-list)
entity-expr ::= [entity-expr.]<link> | collection.find-expr | creation | ^<entity>
collection ::= [entity-expr.]<collection-name> | [entity-expr.]<link>@<entity>
find-expr ::= single([predicate]) | first([predicate]) | last([predicate]) |
get(<entity>[: <identity>][, member-list])
member-list ::= <member>: <expression>[, member-list]
creation ::= [entity-expr.]add(<entity>[: <identity>][, member-list]) |
[entity-expr.]getoradd(<entity>[: <identity>][, member-list])
deletion ::= [entity-expr.]delete(<entity>[: <identity>][, member-list])
while <pred> do b repeats b while <pred> evaluates to true
with <p> in <seq> * b
seq expresses a sequence of elements that are passed as a parameter into
instances of b, which execute according to * representing one of
the three operators | || or ;
<p> is used in the parameter-list of one or more steps in b
* is one of the three combining operators in order of precedence:
|| concurrence execute a set of steps concurrently
| choice from a set of alternative steps, select and execute the
leftmost step whose guard is satisfied
; sequence execute a sequence of steps from left to right
^<entity> finds the nearest <entity> ancestor
set assigns values to a list of members of an entity or class
it is possible to set the deadline (see
[7. Temporal Constraints](#7-temporal-constraints)) of the step that declares the
behaviour
get gets an entity with a given identity and/or a certain set of values
add creates and adds a new instance of an entity then sets member values as
specified
getoradd gets an entity with a given identity or a certain set of values
if such an entity does not exist, it is added
delete deletes an entity with a given identity or a certain set of values, if it exists
A behavioural expression is evaluated left-to-right, and parentheses (
)
can be used to
group parts. Line breaks are collapsed to a single space when parsing.
The while and with constructs have highest precedence, which means that (p
is a predicate,
a
and b
are step expressions, n
is a name if an element of the sequence s
and i
and j
are step expressions, and *
is any of the operators ||
;
or |
)
while p do a * b
is equivalent to
(while p do a) * b
and
with n in s * i * j
is equivalent to
(with n in s * i) * j
TODO: Have tooling do syntax trees for behavioural expressions.
stage
Realising that behaviour
really captures the process that an entity goes through, consider introducing named stages into the syntax of behavioural expressions.
A stage
might be declared simply by including it's name in select places of a behavioural expression such as the while
predicate. For example:
while not Scores.any(Amount = Target) do
becomes
while InPlay::not Scores.any(Amount = Target) do
Also, a stage could be introduced - less elegantly, but with more facets possible - as an explicit model element. For example:
stage InPlay
caption: The game is in play # what we show to the user
predicate: not Scores.any(Amount = Target)
Then add that in expressions a stage
would evaluate to it's predicate - if it has one. This would allow - but not force - the behavioural expression to support a reduced syntax, using which the predicate is given by the stage
. In the above example:
while InPlay do
Perhaps, the model element notation can be seen as a possibility to extend or detail a stage
that is introduced in the behavioural expression. Perhaps some stages are not easily embeddable in the behavioural expression notation.
One question that begs answering is: what exactly are the select places of a behavioural expression where a stage
can be introduced?
Is it perhaps so, that every branching point of the expression introduces a stage
?
Further: is a stage
really always inherently introduced in these places, and all the modeler can/should do is name it?
And: should we allow the same stage
to occur in several places of the expression? If so, should we allow different predicates for each occurrence?
It seems unnecessarily restricting to answer "no" to either of these two questions.
At runtime, the individual state of a step is tracked like this:
digraph step {
label = "states of each step, where T is current time"
node [shape=point] born
node [shape=doublecircle] completed
node [shape=ellipse] executed
node [shape=circle]
born -> waiting
waiting -> notready [label = "no entry or T >= entry"]
notready -> ready [label = "guard = true"]
ready -> notready [label = "guard = false"]
ready -> executing [label = "guard = true, automatic or by user"]
ready -> completed [label = "guard = true\nno behaviour\nsuccess = true"]
executing -> executed [label = "behaviour\nsucceeds"]
executing -> failed [label = "behaviour\nfails"]
failed -> executing [label = "guard = true\nretry by user"]
executed -> executing
[label = "guard = true\nsuccess = false\nretry by user"]
executed -> completed
[label = "success = true, committal by user or automatically"]
}
Note, that before any end-user interaction, it is verified that the currently logged in user has
execute permission for the entity that owns the step - written as may execute
in the diagram.
Also note, that the execute permission is not required for the end-user to simply commit a change
that an automatically executed step has produced.
The net effect is, that the declared behaviour of an entity is followed regardless of what permissions users may or may not have, and the execute permission specifically controls access to executing steps that by declaration require user interaction.
An entity that declares behaviour stores child items that track the evaluation of the behaviour expression and keeps the status of all steps until they complete. When an individual step completes, it is not considered "live" any longer and the item that tracks it is deleted.
The behavioural status of the entity itself is computed from the set of live step states by evaluating the below expressions from top to bottom:
live-steps.all(completed) => completed
live-steps.any(failed) => failed
live-steps.all(waiting) => waiting
true => active
Suggestion: The states of the live steps are updated at the start and just before the committal of every transaction for all entities that are read or modified in that transactiom, and they are persisted as part of the committal.
||
stepsWhen two or more steps are declared by the behaviour
expression to execute concurrently,
they may be executed and committed successfully at overlapping times on different devices, but only
if no conflict arises when each transaction is committed.
Transaction committal is always serialized, even if the changes that are part of the concurrent transactions were executed truly in parallel across different devices. While committal does permit other transactions to have committed since the transaction was started, each committal insists that no entity that was read as part of the transaction has since been modified by another committed transaction. Such strict semantics is sometimes referred to as read-level consistency.
In practice, this means that when designing concurrent steps, care must be taken to ensure that none of the steps will modify any entity that is also read by another of the concurrent steps.
Note that even if an entity, A
, has a member defined by an expression that depends on a
value of another entity, B
, that value of B
may be modified in a transaction without
A
being considered modified.
For example, the RollDice
design can update the Score
entities of a Game
independently in parallel, because the ending logic of Game
is declared using an expression,
rather than as a data member being explicitly updated as part of updating each Score
.
Suggestion: Change the runtime to pick the optimal execution instead of leaving it up to the designer
Note that, if the UI displays (and therefore must read) more than a single score, a refresh is needed between any two tosses on different devices. The UI runtime is responsible for adopting a pragmatic approach that issues enough, but not too many, such refreshes to reduce the risk of concurrency collissions, which may cancel user requests such as activating a step button or throw away recently entered data, whilst maintaining efficiency.
Also, these update semantics mean that internally the dbquity runtime deploys a child item holding the behavioural status, such that this behaviour item can be updated and persisted without affecting the state of the entity itself - even in cases where the entity declares an expression that depends on the value of this built-in behaviour item.
As a dbquity designer, consider these two alternative behaviour expressions for the Toss
step:
getoradd(Score, Player: player).set(Amount: ...)
and
getoradd(Score: player).set(Amount: ...)
Because Toss
declares identity: Player
, they have the exact same semantics when executed
in isolation, but because
Score
instances of the game and then matches against the value of the Player
member, andScore
instance of matching identity,only the latter will not risk concurrency collision when two or more devices trigger Toss
for
different players at the same time.
When showing an entity, the UI reflects the current status of behavior and steps.
Steps with behaviour, but without autoexecute, may - after entering the Ready state - be invoked by an end-user with execute permission, provided any declared guard is satisfied.
If a step has failed or did not yield success, it may be retried by a user, provided the guard is still satisfied – even if it was first automaticcally executed.
TODO: add more detail and screen mockups...
In practice, the behaviour of a design is often constrained in time by certain agreements that the users may enter into such as a delivery deadline or a due date for payments, and as mentioned earlier, we now extend the model to address such temporal aspects.
Looking again at the RollDice example, say you want each player to have a maximum of 5 minutes in each round of play to toss the die, and if at any point those 5 minutes are exceeded the game should time out.
This is simply achieved by adding a one-liner declaring a deadline to the Toss step:
desing RollDice
...
step Toss
parameter Player
behaviour:
getoradd(Score: Player).
set(Amount:
if Amount < Game.Target then Amount + random(1,6)
else Amount - random(1,6))
autocommit
deadline: ready.addminutes(5) # waiting too long to toss, times out the game
...
The deadline will surface in the UI. Below, we return to the example of the final round of a game with 5 players and a target of 30:
Players 3 and 5 are fastest as before - 3 is a little less lucky:
Players 1 and 2 toss in time:
But player 4 does not make it, so the step and consequently the whole game expires:
The timed out game is counted in the area:
step
In addition to the capability of declaratively handling deadlines requiring a given step to be succesfully executed before or at that point in time, we also introduce the logical opposite namely constraining the entry into a step, so that it cannot occur before a certain point in time.
With these, we complete the notion of a step
by adding entry
and deadline
expressions to step declaration and consequently have the runtime deal with a state of
expired
.
step <name>
[abstract]
[base: <step>]
[override: <step>]
[parameter <name>]*
[guard: <predicate>]
[entry: <time-expression>] # adds another clause joining the guard predicate
[autoexecute]
[behaviour: <behaviour-expression>]
[autocommit]
[success: <predicate>]
[deadline: <time-expression>] # adds the notion of expiry
where
predicates, expressions and behaviour may refer to
- the members of the declaring entity and any linked or linking entities
- the point in time, creation, when the step instance was created
- the point in time, ready, when the step became ready
- the current evaluation of the entry and deadline expressions
- the current evaluation of success predicate and
- the index runtime value, if declared
step
digraph step {
label = "states of each step, where T is current time"
node [shape=point] born
node [shape=doublecircle] succeeded expired
node [shape=ellipse] executed
node [shape=circle]
born -> notready
notready -> ready [label = "T >= entry and T <= deadline\nguard = true"]
ready -> notready [label = "T <= deadline\nguard = false"]
ready -> executing [label = "T <= deadline\nguard = true\nautomatic or\n if may execute\nby user"]
ready -> succeeded [label = "T <= deadline\nguard = true\nno behaviour\nsuccess = true"]
{notready,ready,executing,executed} -> expired [label = "T > deadline"]
executing -> executed [label = "T <= deadline\nbehaviour\nsucceeds"]
executing -> failed [label = "T <= deadline\nbehaviour\nfails"]
failed -> executing [label = "T <= deadline\nguard = true\nmay execute\nretry by user"]
executed -> executing
[label = "T <= deadline\nguard = true\nsuccess = false\nmay execute\nretry by user"]
executed -> succeeded
[label = "T <= deadline\nsuccess = true\ncommittal by user\nor automatically"]
}
The expired
state introduced for steps above also adds the second case below to the full
scheme of assessing overall behavioural status of an entity
live-steps.all(completed) => completed
live-steps.any(expired) => expired
live-steps.any(failed) => failed
true => active
Further, in addition to the transactions initiated by user activity, necessary transactions scheduled according to the entry and deadline declared by live steps will run in the background provided a user is connected to the site of the entities in question at or after the scheduled time. This scheduling occurs by persisting a link to the entity and the relevant time, whenever an entity with live steps that declare entry and/or deadline are in scope of a transaction. These links are ordered timewise, such that the head of the list can be effectively queried yielding the next point in time, when a background transaction should take place.
TODO: Investigate if this mechanism is sufficiently robust to entry and deadline expressions that change what they evaluate to after the entry to the step. E.g., if they depend on an aggregating expression on the entity.
site RollDiceWithOutcomes
area Table
integer Players
default: 3
integer Target
default: 13
step NewGame
guard: Players > 1 and Players < 10 and Target > 6 and Target < 36
behaviour: add(Game, Players: Players, Target: Target)
autocommit
entity Game
collection: Games
readonly
integer Players
integer Target
behaviour:
while not Scores.any(Amount = Target) do
with p in 1..Players || Toss(p)
step Toss
parameter Player
behaviour: # add outcome, then update score
getoradd(Score: Player).add(Outcome);
Scores.find(Player).set(
Amount: if Amount < Target then Amount + Outcomes.last().Value
else Amount - Outcomes.last().Value)
autocommit
entity Score
collection: Scores
integer Player
integer Amount
identity: Player
entity Outcome # random face value of die
collection: Outcomes
integer Value
initialization: random(1,6)
site RollDiceWithChoice
area Table
integer Players
default: 3
integer Target
default: 13
step NewGame
guard: Players > 1 and Players < 10 and Target > 6 and Target < 36
behaviour: add(Game, Players: Players, Target: Target)
autocommit
entity Game
collection: Games
readonly
integer Players
integer Target
behaviour: # players choose how to toss
while not Scores.any(Amount = Target) do
with p in 1..Players || (Toss(p) | TossManually(p))
step Toss
parameter Player
behaviour:
getoradd(Score: Player).set(Amount:
if Amount < Target then Amount + random(1,6)
else Amount - random(1,6))
autocommit
entity Score
collection: Scores
integer Player
integer Amount
identity: Player
integer FaceValue
readonly: false
step TossManually
parameter Player
guard: Scores.find(Player).FaceValue in 1..6
behaviour: # update by entered face value
getoradd(Score: Player).set(
Amount:
if Amount < Target then Amount + FaceValue
else Amount - FaceValue,
FaceValue: nil) # reset to not autoexecute in next round
autoexecute # execute on entry of FaceValue
autocommit
site RollDiceWithUsers
area Table
step NewGame
guard:
"2 or more users with 'Player' profile required" ~
Player.usercount() > 1
behaviour: add(Game)
autocommit
permissions:
view by *
execute by index as *
entity Game
collection: Games
readonly
behaviour:
while not Players.any(Score = 30) do
with p in Players || Toss(p)
step Toss
parameter Player
behaviour:
getoradd(Player: Player).set(Score:
if Score < 30 then Score + random(1,6)
else Score - random(1,6))
autocommit
entity Player
derived
profile
collection: Players
integer Score
text Name # same identity as user
scope: Player.usernames()
identity: Name
site RollDiceWithDismiss
area Table
integer Players
default: 3
integer Target
default: 13
step NewGame # no autoexecute => user must trigger
guard: Players > 1 and Players < 10 and Target > 6 and Target < 36
behaviour: add(Game, Players: Players, Target: Target)
autocommit # make Game available on other devices
entity Game
collection: Games
readonly
integer Players
integer Target
behaviour: # each round, players toss concurrently
while Scores.count(Dismissed) < Players and not Scores.any(Amount = Target) do
with p in 1..Players || (Toss(p) | Dismiss(p))
step Toss
parameter Player
behaviour: # user triggered, lazy-create Score, random update
getoradd(Score: Player).set(Amount:
if Amount < Target then Amount + random(1,6)
else Amount - random(1,6))
autocommit # persist making others see the toss
step Dismiss
parameter Player
entry: creation.addseconds(if Scores.find(Player).Dismissed then 0 else 20)
behaviour: getoradd(Score: Player).set(Dismissed: true)
autoexecute
autocommit
entity Score
collection: Scores
boolean Dismissed
integer Player
integer Amount
identity: Player