|
10 | 10 | -- Stability : experimental
|
11 | 11 | -- Portability : non-portable (requires concurrency)
|
12 | 12 | --
|
13 |
| --- A /Managed Process/ API for building finite state machines. Losely based |
| 13 | +-- A /Managed Process/ API for building state machines. Losely based |
14 | 14 | -- on http://erlang.org/doc/man/gen_statem.html, but with a Haskell-ish
|
15 | 15 | -- flavour.
|
| 16 | +-- |
| 17 | +-- A state machine is defined by a "Step" that is executed whenever an input |
| 18 | +-- arrives in the process mailbox. This "Step" will usually be produced by |
| 19 | +-- evaluating the combinator-style API provided by this module, to link |
| 20 | +-- events with actions and transitions that communicate responses back to |
| 21 | +-- client processes, alter the state (or state data), and so on. |
| 22 | +-- |
| 23 | +-- [Overview and Examples] |
| 24 | +-- |
| 25 | +-- The "Step" that defines our state machines is parameterised by two types, |
| 26 | +-- @s@ and @d@, which represent the state identity (e.g. state name) and |
| 27 | +-- state data. These types are fixed, such that if you wish to alternate the |
| 28 | +-- type or form state data for different state identities, you will need to |
| 29 | +-- encode the relevant storage facilities into the state type @s@ (or choose |
| 30 | +-- to align the two types and ensure they are adjusted in tandem yourself). |
| 31 | +-- |
| 32 | +-- The following example shows a simple pushbutton model for a toggling |
| 33 | +-- push-button. You can push the button and it replies if it went on or off, |
| 34 | +-- and you can ask for a count of how many times the switch has been pushed. |
| 35 | +-- |
| 36 | +-- We begin by defining the types we'll be working with. An algebraic data |
| 37 | +-- type for the state (identity), an "Int" as the state data (for holding the |
| 38 | +-- counter), and a request datum for issuing a /check/ on the number of times |
| 39 | +-- the button has been set to @On@. Pushing the button will be represented |
| 40 | +-- by unit. |
| 41 | +-- |
| 42 | +-- > data State = On | Off deriving (Eq, Show, Typeable, Generic) |
| 43 | +-- > instance Binary State where |
| 44 | +-- > |
| 45 | +-- > data Check = Check deriving (Eq, Show, Typeable, Generic) |
| 46 | +-- > instance Binary Check where |
| 47 | +-- > |
| 48 | +-- > type StateData = Integer |
| 49 | +-- > type ButtonPush = () |
| 50 | +-- |
| 51 | +-- We define our starting state as a "Step", which yields the @Off@ state id |
| 52 | +-- and an initial counter set to zero. |
| 53 | +-- |
| 54 | +-- > startState :: Step State Integer |
| 55 | +-- > startState = yield Off initCount |
| 56 | +-- > |
| 57 | +-- > initCount :: StateData |
| 58 | +-- > initCount = 0 |
| 59 | +-- |
| 60 | +-- To capture what happens when a specific event arrives in the mailbox, we |
| 61 | +-- evaluate the 'event' function and specify the type it accepts. When combined |
| 62 | +-- with 'await', this maps input messages of a specific type to actions and |
| 63 | +-- state transitions. |
| 64 | +-- |
| 65 | +-- > await (event :: Event ButtonPush) actions |
| 66 | +-- |
| 67 | +-- Since our @startState@ is going to yield a specific state and data, we don't |
| 68 | +-- want it to be evaluated each time we handle a message. The 'begin' function |
| 69 | +-- ensures that its first argument is only evaluated once, on our first pass of |
| 70 | +-- the state machine's instruction set (i.e. the "Step" structure that determines |
| 71 | +-- its runtime execution). Thus we have: |
| 72 | +-- |
| 73 | +-- > begin startState $ await (event :: Event ButtonPush) actions |
| 74 | +-- |
| 75 | +-- In response to a button push, we want to set the state to it's opposite |
| 76 | +-- setting. We behave differently in the two states, so we can't simply write |
| 77 | +-- @if state == On then enter Off else enter On@, therefore we will use 'atState' |
| 78 | +-- to execute an action only if the current state matches our input. |
| 79 | +-- |
| 80 | +-- > actions = atState On enter Off |
| 81 | +-- |
| 82 | +-- Now of course we need to choose between two states. We could use 'allState' |
| 83 | +-- and switch on the state id in our code, but 'pick' provides an API that lets |
| 84 | +-- us stay in the combinatorial pattern instead: |
| 85 | +-- |
| 86 | +-- > actions = ((pick (atState On enter Off)) |
| 87 | +-- > (atState Off (set_ (+1) >> enter On))) |
| 88 | +-- |
| 89 | +-- The 'set_' function alters our state data (used to track the button click to |
| 90 | +-- @On@ count), and the 'enter' function produces a "Transition". Transitions |
| 91 | +-- can alter the sate id, state data, and/or determine what the server process |
| 92 | +-- will do next (e.g. stop, timeout, etc).. |
| 93 | +-- |
| 94 | +-- > atState :: forall s d . (Eq s) => s -> FSM s d (Transition s d) -> Step s d |
| 95 | +-- |
| 96 | +-- Looking at the signature of 'atState', we can see that it takes a state id for |
| 97 | +-- comparison, and an action in the "FSM" monad which evaluates to a "Transition". |
| 98 | +-- Notice that the 'set_' action does not yield a transition. In fact, 'set' does, |
| 99 | +-- but we need to throw it away by using monadic @>>@, so we can introduce the |
| 100 | +-- 'enter' transition. |
| 101 | +-- |
| 102 | +-- Essentially this is all syntactic sugar. What happens here is that 'set_' |
| 103 | +-- evaluates 'addTransition', which can be used to queue up multiple transitions. |
| 104 | +-- So what we really want is @addTransition set (+1) >> addTransition enter On@, |
| 105 | +-- however 'atState' wants an action that produces a "Transition", and finishing |
| 106 | +-- our /sentence/ with @enter newState@ reads rather nicely, so we've opted for |
| 107 | +-- @set (+1) >> enter On@ in the end. |
| 108 | +-- |
| 109 | +-- Only one options presents itself for replying to clients, and that is 'reply'. |
| 110 | +-- Since an FSM process deals with control flow quite differently to an ordinary |
| 111 | +-- managed process - taking input messages and passing them through the "Step" |
| 112 | +-- definitions that operate as a simple state machine - we do not leverage the |
| 113 | +-- usual @call@ APIs and instead utilise rpc channels to handle synchronous |
| 114 | +-- client/server style interactions with a state machine process. |
| 115 | +-- |
| 116 | +-- Thus we treat replying as a separate "Step", and use 'join' to combine the |
| 117 | +-- 'await' "Step" with 'reply' such that we have something akin to |
| 118 | +-- |
| 119 | +-- > (await (event :: Event ButtonPush) actions) `join` (reply currentState)) |
| 120 | +-- |
| 121 | +-- Putting this all together, we will now replace 'await', 'atState', 'join' and |
| 122 | +-- so on, with their equivalent synonyms, provided as operators to make the |
| 123 | +-- combinator pattern style look a bit more like an internal DSL. We end up with: |
| 124 | +-- |
| 125 | +-- > switchFsm :: Step State StateData |
| 126 | +-- > switchFsm = startState |
| 127 | +-- > ^. ((event :: Event ButtonPush) |
| 128 | +-- > ~> ( (On ~@ enter Off)) |
| 129 | +-- > .| (Off ~@ (set_ (+1) >> enter On)) |
| 130 | +-- > ) |> (reply currentState)) |
| 131 | +-- |
| 132 | +-- Our client code will need to use the @call@ function from the Client module, |
| 133 | +-- although it /is/ possible to interact synchronously with an FSM process (e.g. |
| 134 | +-- in client/server mode) by hand, the implementation is very likely to change |
| 135 | +-- in a future release and this isn't advised. |
| 136 | +-- |
| 137 | +-- To wire a synchronous @call@ up, we need to supply information about the |
| 138 | +-- input and expected response types at the call site. These are used to determine |
| 139 | +-- the type of channels used to communicate with the server. |
| 140 | +-- |
| 141 | +-- > pushButton :: ProcessId -> Process State |
| 142 | +-- > pushButton pid = call pid (() :: ButtonPush) |
| 143 | +-- |
| 144 | +-- Starting a new switch server process is fairly simple: |
| 145 | +-- |
| 146 | +-- > pid <- start Off initCount switchFsm |
| 147 | +-- |
| 148 | +-- And we can interact with it using our defined function. |
| 149 | +-- |
| 150 | +-- > mSt <- pushButton pid |
| 151 | +-- > mSt `shouldBe` equalTo On |
| 152 | +-- > |
| 153 | +-- > mSt' <- pushButton pid |
| 154 | +-- > mSt' `shouldBe` equalTo Off |
| 155 | +-- |
| 156 | +-- However we haven't got a way to query the switched-on count yet. Let's add |
| 157 | +-- that now. We will send our @Check@ datum to the server process, and expect |
| 158 | +-- an @Int@ reply. |
| 159 | +-- |
| 160 | +-- > switchFsm :: Step State StateData |
| 161 | +-- > switchFsm = startState |
| 162 | +-- > ^. ((event :: Event ButtonPush) |
| 163 | +-- > ~> ( (On ~@ (set_ (+1) >> enter Off)) -- on => off => on is possible with |> here... |
| 164 | +-- > .| (Off ~@ (set_ (+1) >> enter On)) |
| 165 | +-- > ) |> (reply currentState)) |
| 166 | +-- > .| ((event :: Event Check) ~> reply stateData) |
| 167 | +-- > |
| 168 | +-- |
| 169 | +-- Notice that we can still use the @(.|)@ operator - a synonym for 'pick' - |
| 170 | +-- here, since we're picking between two branches based on the type of the event |
| 171 | +-- received. The 'reply' function takes an action in the "FSM" monad, which must |
| 172 | +-- evaluate to a "Serializable" type @r@, which is sent back to the client. |
| 173 | +-- |
| 174 | +-- We can now write our check function... |
| 175 | +-- |
| 176 | +-- > check :: ProcessId -> Process StateData |
| 177 | +-- > check pid = call pid Check |
| 178 | +-- |
| 179 | +-- This is exactly the same approach that we took with @pushButton@. We can |
| 180 | +-- leverage this in our code too, so after we've evaluated the @pushButton@ |
| 181 | +-- twice (as above), we will see |
| 182 | +-- |
| 183 | +-- > mCk <- check pid |
| 184 | +-- > mCk `shouldBe` equalTo (1 :: StateData) |
| 185 | +-- |
| 186 | +-- How do we terminate our FSM process when we're done with it? A process |
| 187 | +-- built using this API will respond to exit signals in a matter befitting a |
| 188 | +-- managed process, e.g. suitable for use in a supervision tree. |
| 189 | +-- |
| 190 | +-- We can handle exit signals by registering listeners for them, as though they |
| 191 | +-- were incoming events. The type we match on must be the type of the /exit reason/ |
| 192 | +-- (whatever that may be, whether it is "ExitReason" or some other type), not |
| 193 | +-- the exception type being thrown. |
| 194 | +-- |
| 195 | +-- Let's play around with this in our button state machine. We will /catch/ an |
| 196 | +-- exit where the reason is @ExitNormal@ and instead of stopping, we'll timeout |
| 197 | +-- after three seconds and publish a @Reset@ event to ourselves. |
| 198 | +-- |
| 199 | +-- > data Reset = Reset deriving (Eq, Show, Typeable, Generic) |
| 200 | +-- > instance Binary Reset where |
| 201 | +-- > |
| 202 | +-- > switchFsm = startState |
| 203 | +-- > ^. ((event :: Event ButtonPush) |
| 204 | +-- > ~> ( (On ~@ (set_ (+1) >> enter Off)) -- on => off => on is possible with |> here... |
| 205 | +-- > .| (Off ~@ (set_ (+1) >> enter On)) |
| 206 | +-- > ) |> (reply currentState)) |
| 207 | +-- > .| ((event :: Event ExitReason) |
| 208 | +-- > ~> ((== ExitNormal) ~? (\_ -> timeout (seconds 3) Reset))) |
| 209 | +-- > .| ((event :: Event Check) ~> reply stateData) |
| 210 | +-- > .| (event :: Event Reset) |
| 211 | +-- > ~> (allState $ \Reset -> put initCount >> enter Off) |
| 212 | +-- |
| 213 | +-- Here 'put' works similarly to 'set_' and 'allState' applies the action/transition |
| 214 | +-- regardless of the current state. The @condition ~? action@ operator, a synonym |
| 215 | +-- for 'matching', will only match if the conditional expression evaluates to |
| 216 | +-- @True@. Obviously if the "ExitReason" is something other than @ExitNormal@ |
| 217 | +-- we will not timeout, and in fact we will not handle the exit signal at all. |
| 218 | +-- |
| 219 | +-- In order to participate properly in a supervision tree, a process should |
| 220 | +-- respond to the @ExitShutdown@ "ExitReason" by executing a clean shutdown and |
| 221 | +-- stopping normally. What happens if we try to handle this "ExitReason" |
| 222 | +-- ourselves? |
| 223 | +-- |
| 224 | +-- > .| ((event :: Event Stop) |
| 225 | +-- > ~> ( ((== ExitNormal) ~? (\_ -> timeout (seconds 3) Reset)) |
| 226 | +-- > .| ((== ExitShutdown) ~? (\_ -> timeout (seconds 3) Reset)) |
| 227 | +-- > .| ((const True) ~? stop) |
| 228 | +-- > )) |
| 229 | +-- |
| 230 | +-- We've added an expression to always stop when the previous two branches fail, |
| 231 | +-- so that even @ExitOther@ will lead to a normal shutdown. Let's test this... |
| 232 | +-- |
| 233 | +-- > exit pid ExitNormal |
| 234 | +-- > sleep $ seconds 6 |
| 235 | +-- > alive <- isProcessAlive pid |
| 236 | +-- > alive `shouldBe` equalTo True |
| 237 | +-- > |
| 238 | +-- > exit pid ExitShutdown |
| 239 | +-- > monitor pid >>= waitForDown |
| 240 | +-- > alive' <- isProcessAlive pid |
| 241 | +-- > alive' `shouldBe` equalTo False |
| 242 | +-- |
| 243 | +-- So we can see that our override of @ExitShutdown@ has failed, and this is |
| 244 | +-- because any process implemented with the /managed process/ API will respond |
| 245 | +-- to @ExitShutdown@ by executing its termination handlers and stopping normally. |
| 246 | +-- |
| 247 | +-- We can add a shutdown handler quite easily, by dealing with the @Stopping@ |
| 248 | +-- event type, like so: |
| 249 | +-- |
| 250 | +-- > (event :: Event Stopping) ~> actions |
| 251 | +-- |
| 252 | +-- While we're discussing exit signals, let's briefly cover the /safe/ API we |
| 253 | +-- have available to us for ensuring that if an exit signal interrupts one of |
| 254 | +-- our actions/transitions before it completes, but we handle that exit signal |
| 255 | +-- without terminating, that we can re-try handling the event again. |
| 256 | +-- |
| 257 | +-- The 'safeWait' function, and its operator synonym @(*>)@ do precisely this. |
| 258 | +-- Let's write up an example and test it. |
| 259 | +-- |
| 260 | +-- > blockingFsm :: SendPort () -> Step State () |
| 261 | +-- > blockingFsm sp = initState Off () |
| 262 | +-- > ^. ((event :: Event ()) |
| 263 | +-- > *> (allState $ \() -> (lift $ sleep (seconds 10) >> sendChan sp ()) >> resume)) |
| 264 | +-- > .| ((event :: Event Stop) |
| 265 | +-- > ~> ( ((== ExitNormal) ~? (\_ -> resume) ) |
| 266 | +-- > .| ((== ExitShutdown) ~? const resume) |
| 267 | +-- > )) |
| 268 | +-- > |
| 269 | +-- > verifyMailboxHandling :: Process () |
| 270 | +-- > verifyMailboxHandling = do |
| 271 | +-- > (sp, rp) <- newChan :: Process (SendPort (), ReceivePort ()) |
| 272 | +-- > pid <- start Off () (blockingFsm sp) |
| 273 | +-- > |
| 274 | +-- > send pid () |
| 275 | +-- > exit pid ExitNormal |
| 276 | +-- > |
| 277 | +-- > sleep $ seconds 5 |
| 278 | +-- > alive <- isProcessAlive pid |
| 279 | +-- > alive `shouldBe` equalTo True |
| 280 | +-- > |
| 281 | +-- > -- we should resume after the ExitNormal handler runs, and get back into the () |
| 282 | +-- > -- handler due to safeWait (*>) which adds a `safe` filter check for the given type |
| 283 | +-- > () <- receiveChan rp |
| 284 | +-- > |
| 285 | +-- > exit pid ExitShutdown |
| 286 | +-- > monitor pid >>= waitForDown |
| 287 | +-- > alive' <- isProcessAlive pid |
| 288 | +-- > alive' `shouldBe` equalTo False |
| 289 | +-- > |
| 290 | +-- |
| 291 | +-- [Prioritising Events and Manipulating the Event Queue] |
| 292 | +-- |
| 293 | +-- We will review these capabilities by example. Our state machine will respond |
| 294 | +-- to button clicks by postponing the events when its state id is @Off@. In the |
| 295 | +-- other state (i.e. @On@), it will prioritise events passing a new state, and |
| 296 | +-- respond to button clicks by pushing them onto a typed channel. In addition, |
| 297 | +-- we handle @Event String@ by either putting the event at the back of the total |
| 298 | +-- event queue, or putting a @()@ at the front/head of the queue. |
| 299 | +-- |
| 300 | +-- > genFSM :: SendPort () -> Step State () |
| 301 | +-- > genFSM sp = initState Off () |
| 302 | +-- > ^. ( (whenStateIs Off) |
| 303 | +-- > |> ((event :: Event ()) ~> (always $ \() -> postpone)) |
| 304 | +-- > ) |
| 305 | +-- > .| ( (((pevent 100) :: Event State) ~> (always $ \state -> enter state)) |
| 306 | +-- > .| ((event :: Event ()) ~> (always $ \() -> (lift $ sendChan sp ()) >> resume)) |
| 307 | +-- > ) |
| 308 | +-- > .| ( (event :: Event String) |
| 309 | +-- > ~> ( (Off ~@ putBack) |
| 310 | +-- > .| (On ~@ (nextEvent ())) |
| 311 | +-- > ) |
| 312 | +-- > ) |
| 313 | +-- |
| 314 | +-- Notice that we're able to apply filters/conditions on both state and event |
| 315 | +-- types at the /top level/ of our DSL. |
| 316 | +-- |
| 317 | +-- Our test case will be a bit racy, since we'll be relying on having loaded up |
| 318 | +-- a backlog of messages and using priorities to jump the queue. |
| 319 | +-- |
| 320 | +-- > republicationOfEvents :: Process () |
| 321 | +-- > republicationOfEvents = do |
| 322 | +-- > (sp, rp) <- newChan |
| 323 | +-- > |
| 324 | +-- > pid <- start Off () $ genFSM sp |
| 325 | +-- > |
| 326 | +-- > replicateM_ 15 $ send pid () |
| 327 | +-- > |
| 328 | +-- > Nothing <- receiveChanTimeout (asTimeout $ seconds 5) rp |
| 329 | +-- > |
| 330 | +-- > send pid On |
| 331 | +-- > |
| 332 | +-- > replicateM_ 15 $ receiveChan rp |
| 333 | +-- > |
| 334 | +-- > send pid "hello" -- triggers `nextEvent ()` |
| 335 | +-- > |
| 336 | +-- > res <- receiveChanTimeout (asTimeout $ seconds 5) rp :: Process (Maybe ()) |
| 337 | +-- > res `shouldBe` equalTo (Just ()) |
| 338 | +-- > |
| 339 | +-- > send pid Off |
| 340 | +-- > |
| 341 | +-- > forM_ ([1..50] :: [Int]) $ \i -> send pid i |
| 342 | +-- > send pid "yo" |
| 343 | +-- > send pid On |
| 344 | +-- > |
| 345 | +-- > res' <- receiveChanTimeout (asTimeout $ seconds 20) rp :: Process (Maybe ()) |
| 346 | +-- > res' `shouldBe` equalTo (Just ()) |
| 347 | +-- > |
| 348 | +-- > kill pid "thankyou byebye" |
| 349 | +-- |
| 350 | +-- Here, the difference between 'postpone' and 'putBack' is that 'postpone' will |
| 351 | +-- ensure that the events given to it aren't re-processed until the state id |
| 352 | +-- changes. Once the state change is detected, those postponed events are |
| 353 | +-- set to be added to the front of the queue (ahead of other events) as soon |
| 354 | +-- as the pass completes. |
| 355 | +-- |
16 | 356 | -----------------------------------------------------------------------------
|
17 | 357 | module Control.Distributed.Process.FSM
|
18 | 358 | ( -- * Starting / Running an FSM Process
|
@@ -44,6 +384,7 @@ module Control.Distributed.Process.FSM
|
44 | 384 | , allState
|
45 | 385 | , matching
|
46 | 386 | , set
|
| 387 | + , set_ |
47 | 388 | , put
|
48 | 389 | -- * DSL-style API (operator sugar)
|
49 | 390 | , (.|)
|
@@ -192,10 +533,11 @@ stop = return . Stop
|
192 | 533 | -- This expression functions as a "Transition" and is not applied immediately.
|
193 | 534 | -- To /see/ state data changes in subsequent expressions during a single pass,
|
194 | 535 | -- use "yield" instead.
|
195 |
| -set :: forall s d . (d -> d) -> FSM s d () |
196 |
| -set f = addTransition $ Eval $ do |
197 |
| - -- MP.liftIO $ putStrLn "setting state" |
198 |
| - processState >>= \s -> setProcessState $ s { stData = (f $ stData s) } |
| 536 | +set :: forall s d . (d -> d) -> FSM s d (Transition s d) |
| 537 | +set f = return $ Eval (processState >>= \s -> setProcessState $ s { stData = (f $ stData s) }) |
| 538 | + |
| 539 | +set_ :: forall s d . (d -> d) -> FSM s d () |
| 540 | +set_ f = set f >>= addTransition |
199 | 541 |
|
200 | 542 | -- | Set the current state data.
|
201 | 543 | --
|
|
0 commit comments