Skip to content
This repository was archived by the owner on Feb 15, 2025. It is now read-only.

Commit ae55627

Browse files
committed
Tweaks and documentation
1 parent fc3162a commit ae55627

File tree

5 files changed

+407
-21
lines changed

5 files changed

+407
-21
lines changed

distributed-process-fsm.cabal

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,7 @@ Stability: experimental
1212
Homepage: http://github.com/haskell-distributed/distributed-process-fsm
1313
Bug-Reports: http://github.com/haskell-distributed/distributed-process-fsm/issues
1414
synopsis: The Cloud Haskell implementation of Erlang/OTP gen_statem
15-
description: Modelled after Erlang OTP's gen_statem, this framework provides similar
16-
facilities for Cloud Haskell.
15+
description: Cloud Haskell framework for building finite state machines around CSPs
1716
category: Control
1817
Tested-With: GHC==7.10.3 GHC==8.0.1 GHC==8.0.2
1918
data-dir: ""
@@ -27,7 +26,6 @@ library
2726
base >= 4.8.2.0 && < 5,
2827
distributed-process >= 0.6.6 && < 0.7,
2928
distributed-process-extras >= 0.3.1 && < 0.4,
30-
distributed-process-async >= 0.2.4 && < 0.3,
3129
distributed-process-client-server >= 0.2.1 && < 0.3,
3230
binary >= 0.6.3.0 && < 0.9,
3331
deepseq >= 1.3.0.1 && < 1.6,

src/Control/Distributed/Process/FSM.hs

Lines changed: 347 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,349 @@
1010
-- Stability : experimental
1111
-- Portability : non-portable (requires concurrency)
1212
--
13-
-- A /Managed Process/ API for building finite state machines. Losely based
13+
-- A /Managed Process/ API for building state machines. Losely based
1414
-- on http://erlang.org/doc/man/gen_statem.html, but with a Haskell-ish
1515
-- 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+
--
16356
-----------------------------------------------------------------------------
17357
module Control.Distributed.Process.FSM
18358
( -- * Starting / Running an FSM Process
@@ -44,6 +384,7 @@ module Control.Distributed.Process.FSM
44384
, allState
45385
, matching
46386
, set
387+
, set_
47388
, put
48389
-- * DSL-style API (operator sugar)
49390
, (.|)
@@ -192,10 +533,11 @@ stop = return . Stop
192533
-- This expression functions as a "Transition" and is not applied immediately.
193534
-- To /see/ state data changes in subsequent expressions during a single pass,
194535
-- 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
199541

200542
-- | Set the current state data.
201543
--

0 commit comments

Comments
 (0)