3. Event State Machine#

3.1. Introduction and Purpose#

Within palaestrAI, many processes are based on events and transitions between states. The Event State Machine (ESM) is a utility class that helps to handle all event-based state transitions within palaestrAI. It transparently handles the most common use cases:

  • dealing with ZMQ messages

  • monitoring processes

  • reacting to signals

  • handling exceptions that rise (from user code).

The ESM works by decorating the target class (called the monitored class). It also injects some methods/properties into the target class. However, the target class’ code does not need to interact with the ESM; it can remain as-is, without being subjected to a certain programming paradigm.

3.2. Class Documentation#

class palaestrai.core.EventStateMachine(monitored: Any)[source]#

An event-triggered state machine

The EventStateMachine (ESM) can be used to transparently handle events within palaestrAI. An ESM wraps another class and callbacks can be defined with method decorators for events. Events are:

  • A message received,

  • a signal received (SIGCHLD, SIGTERM, etc.)

  • setup

  • enter (the initial event)

  • teardown

The initial event enter is issued immediately after the main event/state loop commences in order to provide an entrypoint for operation. The enter event can be used to, e.g., send out the first request. For example:

@ESM.monitor()
class Foo:

    @ESM.enter
    async def _enter(self):
        _ = await self._request_initialization()

    @ESM.requests
    async def _request_initialization(self):
        # ...
        return InitRequest(
            # ...
        )

It is not strictly necessary to provide an enter event. If the monitored class is exclusively an MDP worker, then there is no need for the enter event, because the worker reacts on the first request it receives and not on its own volition.

In order to make a class use the ESM, you must decorate it with ::~.monitor. The monitor decorator can also inject all necessary code to handle ZMQ MDP workers.

If the monitored class does not have a run method, the ESM will also inject it. The run method then serves as an event/state loop that continues until it is stopped. At the start of the run method, the target objects setup method is called if it exists. Likewise, a teardown method will be called immediately after the loop ends.

The ESM also adds a stop method to the target object. It serves to terminate the event/state loop.

In order to react to a specific event, users of the ESM can decorate their methods with on(event). The ::~.on decorator takes as parameter the class of what is handled. E.g., the class of a particular message, or signal.SIGCHLD to react to a process that has ended. For example:

from palaestrai.core import EventStateMachine as ESM
import signal

@ESM.monitor()
class Foo:

    @ESM.on(SomeRequest)
    async def handle_some_request(self, request):
         # ...
         pass

    @ESM.on(signal.SIGCHLD)
    async def handle_process_termination(self, process):
        # ...
        pass

Spawning processes is also handled through a decorator: spawns. If a method decorated with spawns returns a ::Process object, this process will automatically be monitored. E.g.,:

# ...
@ESM.spawns
def start_some_fancy_process(self):
    p = multiprocessing.Process(target=somefunc)
    p.start()
    return p

The ESM also handles the sending of requests. ESM-monitored classes do not need to instantiate and monitor MDP client objects themselves. Instead, they simply need methods to be decorated with requests. The so decorated method must return a message object that has the receiver property, so that ::~.requests can handle sending. E.g.,:

# ...
@ESM.requests
def get_something_from_a_worker(self):
    req = SomeRequest()
    req.receiver = "Foo"
    return req

@ESM.on(SomeResponse)    # also handle the response!
def handle_response_from_worker(self, response):
    # ...
    pass

The ESM also supports classes that act as workers. For this, the ESM’s monitor decorator needs the flag is_mdp_worker=True. Then, the ESM injects the property mdp_service. Setting this property connects the MDP worker, and ESM.on can be used to handle requests from clients. For example:

@ESM.monitor(is_mdp_worker=True)
class Foo:
    async def setup(self):
        self.mdp_service = "Foo"

    @ESM.on(SomeRequest)
    def handle_request_from_client(self, req):
        do_something_with(request)
        rsp = SomeResponse()
        rsp.receiver = req.sender
        return rsp