Skick is a library for building actor based web application backends that communicate with javascript clients over websockets. It is in an early state of development.
Project description
Skick
Skick is a library for building actor based back ends for web applications. It allows clients to connect over a websocket and interact with actors using JSON encoded messages.
A simple hello world program would look something like:
from skick import Skick
skick = Skick()
@skick.session("hello")
def hello_world(inst, message):
""" A hello world type user session """
@inst.on_start
async def on_start():
await inst.socksend({"greeting": "Hello, use the action 'greet' to receive a greeting"})
@inst.socket("greet", {"action": "greet"})
async def greet(msg):
await inst.socksend({"greeting": "Hello good sir"})
skick.start()
In the example above, a single core instance of Skick is instantiated and a websocket server is opened on
port 8000. A so called "session" is declared and registered with the system.
This allows users to connect over a websocket and request a "hello" session
using the command {"session_type": "hello"}
. If they do, a pair of actors,
a receptionist and a session actor will be instantiated on the server and
associated with the connection. The receptionist receives and sends messages
over the websocket and filters them to ensure they conform to predefined
schemas. The session actor receives the accepted messages and processes them
within the actor model.
In this particular case, the session actor will begin by sending the message
{"greeting": "Hello, use the action 'greet' to receive a greeting"}
to the client, and will then proceed to await further communications from the client. This particular session type has only one registered schema, namely the one associated with the "greet" action. If the client sends a conforming message, the method greet will be scheduled and run resulting in the following interaction
Client: {"session_type": "hello"}
Server: {"greeting": "Hello, use the action 'greet' to receive a greeting"}
Client: {"action": "greet"}
Server: {"greeting": "Hello good sir"}
This demonstrates some of the fundamental ideas behind Skick. Actors should be defined in much the same way as endpoints in the most popular HTTP frameworks like Flask and Sanic. There should be minimal boilerplate requirements, and the library should interface seamlessly with a JavaScript client in a browser. In order to see the full picture, we will need to take a deeper dive into the features and general architecture of the system.
The Current State of the Project
Skick is in an experimental state. It is essentially a proof of concept. It has not been battle tested on the internet, and has not been extensively analyzed for vulnerabilities. There are certain weak points.
The Actor
At the heart of the Skick system lies the lone actor. Actors are program units which have a mailbox attached. They receive messages in this mailbox and in response to these messages, they can do any combination of the following:
- Send messages to other actors.
- Spawn new actors.
- Replace their current set of responses with a different set of responses.
While this leads to a nice and tidy theoretical model, such as the one exhibited in Gul Agha's Actors, A Model of Concurrent Computation in Distributed Systems from 1986, it does so at the expense of certain practical realities of software development. For this reason, additional capabilities have been added to Skick. In particular, Skick actors also have abilities like:
- Maintaining state.
- Running daemons.
- Running functions on startup and on stopping.
- Maintaining stateful conversations with other actors.
- Receiving notices when actors cease to run.
Defining Actors
Actors in Skick are defined in synchronous declarative functions which are registered by a special shard actor (conveniently hidden away in the Skick object). The actor is instantiated by this shard actor when it is needed. The creation process proceeds in the following steps:
- The shard actor creates an Actor object.
- The shard actor runs the actor's declarative function on the actor object.
- The function stores the actor's state in the closures of the functions defined within, and registers message handlers (behaviors in actor terminology), daemons and other necessary methods.
- The actor runs the on_start method if one has been registered.
- The actor starts separate tasks for the daemons (be careful of race conditions).
- The actor starts the message processing loop in the main task.
In practice, defining actors is done through the actor
method in some Skick instance
@skick.actor(actor_name)
def function_name(actor_instance, init_message):
...
Here, actor_name
is a string used to refer to the actor type when requesting
it from the shard. The actor_instance
is the instance of the Actor class that
the shard has prepared for us, and the init_message
is some message
(messages in skick are always dictionaries) that helps us set up the initial
state of the actor.
Within the function, the actor_instance
contains methods which decorate
asynchronous functions in various ways. These are
@inst.action(action_name, [optional schema])
which defines a response to a particular message type@inst.on_start
which registers an asynchronous method to run on startup. N.B: It is very important to run time consuming initiation steps such as fetching information from an API asynchronously in thison_start
function. If you do not do this, then you will hold up the shard actor, freezing the entire shard.@inst.on_stop
which acts as the inverse ofon_start
, being run during the ordinary shutdown procedure.@inst.daemon(name)
which registers an asynchronous function that will run in a separate task under the namename
.@inst.conversation(name)
which will be discussed later, but which formerly decorated asynchronous generator functions to produce the stateful conversation mechanism. More on this later.
To illustrate how these parts function together, an example is in order.
@skick.actor("accumulator")
def accumulator(inst, message):
""" An actor that adds numbers to an internal tally. """
tally = message.get("initial_tally", 0)
@inst.daemon("tally_reporter")
async def report():
""" Reports the current tally with regular intervals if asked to """
while "report_to" in message:
await asyncio.sleep(1)
await inst.send(message["report_to"],
{"action": "report_tally", "tally": tally})
@inst.action("add", schema={"action": "add", "number": int})
async def adder(msg):
""" Adds msg["number"] to the tally found in the closure """
nonlocal tally # Necessary nonlocal declaration for immutable objects
tally += msg["number"]
@inst.action("get_tally", schema={"action": "get_tally", "sender": str})
async def get_tally(msg):
"""
Asks the actor to send the current tally to *sender*. This could
have used the conversation mechanism which we will introduce later.
"""
await inst.send(msg["sender"], {"action": "report_tally", "tally": tally})
Helper Methods
There are a number of helpful methods in the actor instance.
async inst.send(address, message)
which sends the message (a dictionary) to to the actor that has the address address. In general, this address must be known in advance.async inst.replace(new_type, message)
which replaces the current actor's behaviors with a new set of behaviors defined in some actor definition. This function strips the actor of all but its address, purging all daemons, all message handlers, conversations, all its closures etc.async inst.converse(partner, initial_message, prorotype)
which initiates a conversation with thepartner
actor. More on this later.async inst.spawn(actor_type, message, ...)
which asks the shard object to spawn a new actor of the typeactor_type
and send it the initial messagemessage
. By default, this is spawned on the same shard in clusters, and the requesting actor is added as a monitor so that it will be notified when the new actor dies.register_service(name)
which registers the actor in the service registry as probiding the servicename
unregister_service()
which removes all mentions of the actor from the service registryget_service(name, local="mixed")
, which retrieves a random service provider for the service, either fromlocal="local"
only shard local providers;local="mixed"
local or remote shards, preferring local shards if available; orlocal="remote"
only remote providers.
How to Run Actors
Actors that have been defined in the manner described above are not
automatically instantiated. They have to be requested somewhere in the program.
Apart from being spawned as a response to user connections in the session system,
shards may be spawned by any actor using the spawn
method. In order to spawn
actors when the shard starts up, the user can define an on_start
method.
This method is scheduled to run when the shard starts up and is supplied with a direct reference to the shard actor, so that we may use it to spawn more actors as shown in the example below:
from skick import Skick
async def on_start(shard):
await shard.spawn("accumulator", {"initial_tally": 420})
skick = Skick(on_start=on_start)
...
skick.start()
Conversations
Often, actors have to communicate back and forth in specific sequences. This may be as simple as actor A asking actor B what the value of some variable is. With the basic messaging functionality we have to solve this problem through a combination of complicated state management, replacement and defining receiving and sending actions in separate methods.
This is too inconvenient in many situations. For this reason, Skick provides an alternative out of the box called a conversation. A conversation takes place between two actors, one caller and one responder. These take turns in passing messages to eachother in a call and response fashion.
In a conversation in Skick, the actions involved are not normal asynchronous
functions, but asynchronous generator functions. Messages are sent and received
through the use of the yield
keyword. Conversations are set up by yielding
special Conversation
objects, subclassed in one Call
and one Respond
variety
for convenience, and are managed by the actor objects out of the view
of the programmer. Suppose the Accumulator actor from before were to return the
new tally whenever it received an "add" request. We could rewrite the action as
a conversation in the following manner.
...
from skick import Skick, Respond
...
@skick.actor("accumulator")
def accumulator(inst, message):
""" An actor that adds numbers to an internal tally. """
tally = message.get("initial_tally", 0)
...
@inst.action("add")
async def adder(msg):
"""
Here, because adder is responding in a conversation, the msg parameter
is generated by the calling actor, and is not manually constructed by
the programmer. It contains the necessary parameters to respond to the
call.
"""
nonlocal tally # Necessary nonlocal declaration for immutable objects
yield Respond(msg) # We first "pick up the phone" as it were
tally += msg["number"]
yield {"tally": tally} # Then we yield a response
In the corresponding caller, another asynchronous generator is found:
...
from skick import Skick, Call
...
@skick.actor("caller")
def caller(inst, message):
...
@inst.action("call_adder")
async def call_adder(msg):
new_tally = yield Call(adder_address, {"number": 10})
...
These conversations can go on for many exchanges, each (with the exception of the yield Respond(...)
call) taking the form:
...
response_to_our_message = yield our_message
...
In the caller actor above, the conversation was initiated because we received
a message the handler of which was a conversation prototype. This is not always
appropriate. For example, we may wish to start conversations in the on_start
method
or in some daemon. If we wish to do this, we can create a conversation using the
inst.converse
method. inst.converse
can be called in two ways. Either,
it can be used to invoke a registered prototype by passing its string identifier,
or we can pass some asynchronous generator function. In other words, we could
imagine a scenario like the following.
@skick.actor("caller")
def caller(inst, message):
...
@inst.on_start
async def init():
...
async def send_number(msg):
response = yield Call(adder_address, {"number": 720})
await do_something(response)
await inst.converse({}, send_number)
...
The Special Actors
There are two special actors in Skick. The shard actor and the WebSocket actor. Both are automatically instantiated by the Skick object, but the latter can be disabled if you do not wish to enable websocket functionality.
These actors help the other actors in various ways.
The Shard Actor
Skick instances may run in clusters, but even if they are run in a single core, there is a special shard actor in the background. This actor performs a few helpful jobs for the actors.
- It maintains a record of which actors are alive so they don't get garbage collected.
- It maintains a registry of services which works like an internal DNS to which actors can add themselves if they provide a service to other actors and from which actors can request addresses to service providers.
- If there are several shards in the cluster, it will communicate with the other shards to ensure it has up to date information about which services and actor types are available on the other shards, as well as telling the other shards which services and actor types it provides.
- It helps spawn actors
Most of these functions are not exposed to the user through the actual shard actor object. Instead, the shard injects helpers into all actors so that they can be accessed through any Actor instance. Actors can also be spawned by sending a message to the shard, but this is primarily meant for spawning shards remotely.
The WebsocketActor
If a websocket server has been requested, a special WebsocketActor
will be instantiated. It maintains its own
collection of websocket handlers called sessions and subsessions as well as so called handshake sequences.
These are abstractions of lower level actors, to which websocket connections have been affixed.
The exact details are complicated enough to warrant their own chapter, but these special actors live in their own
universe, which is managed by the WebsocketActor
. The WebsocketActor
also ensures
that the actors associated with the connection live and die together, and that they are both stopped when the connection terminates.
The Session and the Subsession
In the previous sections, we discussed features that live, so to speak, inside the clean room that your backend should be. However, for our backend to be useful for anything, it has to be able to communicate with the outside world. Most importantly, it has to be able to communicate with clients over the internet. Skick's main method for this is to use websockets. Websockets allow bidirectional communication over persistent TCP connections with clients like web browsers and phone apps.
The way Skick handles these connections is to create a pair of actors for each incoming connection. This syzygy of actors is called a subsession. If the client is able to directly request the subsession type over the websocket, then it is called a session. All the other subsession types are created through subsession replacement.
Let us recall the Hello World program from earlier:
...
@skick.session("hello")
def hello_world(inst, message):
""" A hello world type user session """
@inst.on_start
async def on_start():
await inst.socksend({"greeting": "Hello, use the action 'greet' to receive a greeting"})
@inst.socket("greet", {"action": "greet"})
async def greet(msg):
await inst.socksend({"greeting": "Hello good sir"})
...
We note that the skick.session
function is very similar to an actor declaration.
It uses similar methods, like @inst.on_start
. On top of this there are new methods,
such as @inst.socket
and inst.socksend
. These serve to give us the illusion
that the session is a special type of actor. In reality, there are two different
subclasses for these special websocket actors, one FrontActor
and one BackActor
.
They both provide the same sets of decorators and methods, but the methods and
decorators do not have the same effects on them.
When the hello_world
function is run on a FrontActor
, the @inst.on_start
decorator simply does nothing (there is a separate @inst.front_on_start
method
for the exceptional case where you would want to run code on startup in the
FrontActor
). When the @inst.socket
decorator is used, the FrontActor
merely
records the name of the method and the provided schema, but completely ignores
the function body. Likewise, the old @inst.action
decorator has been
overridden and does nothing on the FrontActor
.
When the hello_world
function is run on a BackActor
, the @inst.on_start
and other standard decorators work as we would expect from a normal Actor
.
They target the BackActor
since it is the actual main actor of the session.
As for the @inst.socket
decorator, it reverts to an @inst.action
decorator
ignoring the schema entirely.
Many methods have these dual interpretations. The inst.socksend
method,
which only exists on these special websocket actors, sends a message to the
websocket client when it is called on the FrontActor
, but if it is called in
the BackActor
, it encapsulates the message in an action that asks the FrontActor
to
send the encapsulated message over the websocket.
How Websocket Connections are Processed
When a client requests a new websocket connection, the following steps are followed
- The underlying websocket library (currently only Websockets is available) creates a new task.
The task awaits an initial handshake message from the client, which must be
a dictionary and which must contain the field
"session_type"
. - If the session type has been registered with the WebsocketActor, then it creates
two new actors. One using the
FrontActor
class and one using theBackActor
class. - The same
session
definition function is run twice. Once on the FrontActor and once on the BackActor function. - The websocket actor coordinates these two actors by directly injecting references to things like the websocket connection and the other actor in the pair into the actor objects.
- The actors are both started in their own separate tasks.
Once this process has completed, the client can send messages to the server. If it does, they will be processed in the following order:
- The request arrives over the websocket connection.
- It is received by a separate task in the
FrontActor
, where it is deserialized and compared with locally available schemas. - If there is a corresponding schema, and the deserialized message conforms to it,
the message is sent over the messaging system to the
BackActor
, where the schema has a correspondingaction
which treats the message as any other that has been sent over the messaging system.
If the BackActor
chooses to send a message back to the client the following
takes place:
- The message is encapsulated into a message which is sent to the
FrontActor
- The encapsulated message is received in the
FrontActor
and is processed as any other action. The specific action,"socksend"
, merely extracts the message and sends it through a method in theFrontActor
's self._websocket field.
Why
This dual actor setup may seem like an unnecessarily complicated procedure. Surely, it must
introduce a plethora of bugs and cause all manner of performance penalties.
There is a reason why we want this type of division. Originally, it was
conceived that we should separate the actors so that they could live on different shards.
This would separate the shards into those that only dealt with the outside world
and those that dealt only with the innards of the actor system. In such a scenario,
we could scale the these systems independently, and we might be able to prevent
excess user requests from halting the entire system. Additionally, this type of clean
separation does not preclude associating several connections to the same session.
That is, a user who is connected on both his smartphone and his laptop could
interact with the system through the same BackActor
even if he maintains
several FrontActor
s. These features are not
present in the system for the time being, and we may ultimately shift towards a
single actor setup, where one actor performs all tasks required to maintain websocket
connections. Indeed, we may even settle for a solution where these mechanisms
coexist and can be used interchangeably depending on the context.
Specifics About the FrontActor's Methods
The FrontActor inherits all methods and decorators from the Actor class, but in order to access them we need to use different names, namely
- The
@inst.front_action
decorator creates an action on the front actor - The
@inst.front_daemon
decorator creates a daemon on the front actor - The
@inst.front_on_start
decorator registers a startup method on the front actor - The
@inst.front_on_stop
decorator registers a shutdown method on the front actor - The
async inst.socksend
method sends a message to the websocket client
Apart from these special methods, a number of ordinary methods are either irrelevant or have had their functionalities altered
- The
@inst.action
decorator ignores the function it was fed. - The
@inst.daemon
decorator ignores the function it was fed. - The
@inst.on_start
decorator ignores the function it was fed. - The
@inst.on_stop
decorator ignores the function it was fed. - The
async inst.replace
method works as usual, but performs additional steps to accommodate for the websocket. It is not invoked directly by the user, but is invoked in response to a message sent by theBackActor
- The conversation mechanisms function as usual, but are largely irrelevant.
- The service registry functions are not disabled, but only
get_service
should be used in practice.
Specifics About the BackActor's Methods
Like in the case of the FrontActor
, the BackActor
inherits all decorators and
methods from the Actor
class. Some of them function as usual, and some have
had their behaviors altered. In addition to this, all the methods defined on the
FrontActor
also exist on the BackActor
but many of them do nothing.
- The
@inst.front_...
decorators all do nothing on theBackActor
. - The
@inst.daemon
and@inst.action
decorators functions as they do on theActor
class. - The
async inst.socksend
method sends a message to theFrontActor
instructing it to send a message to the client. - The
async inst.replace
method replaces theBackActor
, but also performs the additional step of ordering theFrontActor
to perform a corresponding replacement action on its end. N.B: This operates over the messaging system, and may therefore be somewhat brittle in some situations. - The conversation mechanisms function as usual, with the exception of the addition of a special method to query the client for input. More on this later.
- The service registry functions are not disabled but only
get_service
should be used in practice.
Session Conversations
When conversing with a handler defined through an @inst.socket
decorator,
the conversation will be with the BackActor
. The BackActor
has the ability
to use its turn to interject a user query into the conversation. This is done
by yielding a skick.SocketQuery
object. When this is done, the BackActor
will
set up a query in the FrontActor
. They work by siphoning off client requests where the action field is "query_reply"
, and comparing the message to registered queries. For this command there is a two tiered schema verification procedure:
- The message as a whole is evaluated against the schema
{"action": "query_reply", "query_id":str, "message": object}
. - It is verified that such a query has been registered.
- The "message" field in the dictionary is then evaluated against the
subschema that was specified by the
BackActor
when the query was made.
The conversation in the BackActor
will resume once the websocket client has sent its response.
Because the FrontActor
is unable to read function bodies, the subschemas
can not be specified within the handler requesting the conversation.
Instead, subschemas must created in the session declaration's direct scope
using the function inst.query_schema
. This is highly inconvenient, so
a number of simple schemas have been predefined for the programmers's convenience, namely:
"default"
which accepts anint
,float
,str
orbool
"int"
which accepts anint
,"float"
which accepts afloat
,"str"
which accepts astr
,"bool"
which accepts abool
,"list"
which accepts a list consisting of only those types included in the"default"
subschema.
For this reason, many simple queries require no extra query_schema
declarations.
In principle, this means we can see constructs like the one below:
...
@skick.session("curious")
def curiosity(inst, message):
...
@inst.socket("get_information")
async def info(msg):
response = yield Call(info_actor, {"action": "info", "credentials": cred})
if response["ask_gdpr"]:
consent = yield SocketQuery({"gdpr_query": GDPR_STRING}, "bool")
if consent:
await inst.socksend(response["info"])
else:
await inst.socksend({"error": "terms not accepted"})
else:
await inst.socksend(response["info"])
...
...
In the example above, the BackActor
asks a different actor for some piece of
information. The other actor discovers that the user with the supplied credentials
has not accepted certain important GDPR related terms and conditions. It informs
the BackActor
, which sends a query to the client. If the client responds approvingly, the information is sent to the client, otherwise an error message
is sent.
Handshake Sequences
Imagine for a moment that your Skick application has several classes of user sessions. Maybe they represent different user tiers or perhaps one skick instance runs several different services. In many such situations there are substantial benefits to be gained from allowing us to reuse sequences of sessions and subsessions that the system proceeds through before a fully authenticated and initiated user session is in place. Skick has a mechanism for this called a handshake sequence.
We may declare that a function is a handshake
by using the @skick.handshake
decorator. We begin with an example:
...
@skick.handshake("random_number")
def random_session(inst, message)
num = random()
@inst.on_start
async def on_start():
await inst.replace("double_number", {**message, "number": num})
@skick.subsession("double_number")
def doubler(inst, message):
num = 2*message["number"]
@inst.on_start()
async def on_start():
await inst.replace(f"{message['session_type']}:final", {"number": num})
@random_session("logarithm")
def logarithm(inst, message):
base = message["number"]
@inst.socket("log", {"action": "log", "x": float})
async def log(msg):
await inst.socksend({"log_base(x)": math.log(msg[x], base=base),
"x": msg["x"]})
Here, the random_number
and double_number
subsessions are part of a handshake sequence. @skick.handshake
does not actually create a session. Instead, it
replaces the function it decorates with a new decorator. This new decorator can be used to create subsessions which are preceded by some standardized succession of
subsessions.
Let us take a closer look at the last decorated function.
@random_session("logarithm")
def logarithm(inst, message):
...
Here, one session and one subsession are created and registered. One called
logarithm
and the other called logarithm:final
. The former is merely a copy of
random_session
, and the latter is the one actually described in logarithm
. Skick expects
a subsession that is part of a handshake sequence to forward the "session_type"
field of the original message throughout the sequence. Ultimately, the final actor
in the sequence should replace itself with the f"{message['session_type']}:final"
actor, handing control over to the subsession in question.
Session termination
Since sessions are not normal actors, they are terminated in a slightly different fashion than normal actors. If you invoke the inst.stop
method on the BackActor
,
the WebsocketActor
will make sure to close the connection and stop the FrontActor
.
This works because the FrontActor
, BackActor
and the consumer task in the FrontActor
are collated in a special Syzygy
object by the WebsocketActor
.
If any of the three fail, the Syzygy
provides methods for also shutting down the other two.
A word of caution is appropriate when it comes to these syzygies. While the
consumer task is directly monitored by the WebsocketActor
, and while the stop
methods are invoked directly without involving messaging, the WebsocketActor
is informed of the demise of the actors in the Syzygy
through the sentinel message it
should emit. This may prove unreliable on some occasions, and we might see situations where only one of the actors is actually alive. We may also experience ill defined states when the sentinel messages are in transition.
Clustering
Skick has the ability to run on many shards in a cluster. If this is required in your application, then you must provide some supported messaging system in your backend, since Skick does not supply its own distributed messaging system.
At present, Skick relies on RabbitMQ for its inter-shard communication. If you have an unencrypted RabbitMQ service hidden behind a firewall, you may swap Skick's default messaging system (which relies on a dictionary of active mail queues) for a RabbitMQ based system by adding a parameter when instantiating your Skick class.
...
skick = Skick(on_start = on_start,
message_system = "amqp://user:pwd@rabbit_url:rabbitport")
...
If you choose to run skick in a clustered configuration, there is no requirement that the shards be homogeneous. You may opt to have some that do not support websockets, some that use different websocket drivers, some that provide interactions with some external service local to its docker container or whichever architecture you think is best for your application. No matter how you organize the system, it will work as long as the shards all use the same messaging system.
The Skick Object
In the examples above, we interact with the library through an imported class called Skick. The skick class is responsible for setting up the special actors, launching all needed tasks and connections, as well as coordinating all the disparate components for the user.
Options
The Skick object natively supports the following options:
on_start
which allows you to specify a startup function. This would typically be used to spawn actors on startup.message_system
which, if you supply it, presumes that you have given it an AMQP url to a RabbitMQ instance.websocket_host
which, if you supply it, is the address the websocket library will listen onwebsocket_port
which, if you supply it, is the port the websocket library will listen onwebsocket_server
which, if you set it to any value, will disable the websocket serverssl
which tells the websocket library to use TLS. It can be- A string, in which case skick will assume it is a PEM file and will use it to generate an SSLContext object for you using python's
create_default_context
method. - A tuple, in which case it will act as above, but it will presume the first element is the PEM file and the second is the password.
- An SSLContext instance, in which case it will be used by the websocket server.
- A string, in which case skick will assume it is a PEM file and will use it to generate an SSLContext object for you using python's
websocket_options
which should be a dictionary of options that will be passed directly to the websocket library.dict_type
which, if you provide a string, will be presumed to be a redis url to a redis server that will manage yourhash
objects.
Methods
The Skick
instance also supplies a number of methods. Some of these are
actual methods from the class defintion and some are merely proxies for methods
in the underlying Shard
object.
skick.start()
, which starts the event loop and the rest of the system.skick.stop()
, which initiates the shutdown procedure.skick.add_directory(directory)
which adds the actors, subsessions and handshakes in the directory to the skick instance.@skick.actor(name)
which directly registers an actor type with the shard.@skick.subsession(name)
which directly registers a subsession type with the websocket actor.@skick.session(name)
which directly registers a session type with the websocket actor.@skick.handshake(name)
which directly registers a handshake with the websocket actor.skick.hash(name)
which provides a hash table as described below using the requested backend.
Sentinels
In an actor model, we frequently end up having a large number, perhaps tens of thousands of concurrent actors. In the absence of some tool to keep track of these, it is very easy to see how actors could be lost and forgotten, leading to memory leaks, or how errors from crashing actors might go unknown, or how any number of other things might go wrong.
Most actor systems use some sort of system for supervising actors for this reason. In Skick, the supervision is carried out by sentinels, tasks that monitor other tasks. If they detect a cancellation, exception or a task being marked as done, they will report this to the actor's monitors. Monitors are other actors that have told the supervised actor that they wish to be notified.
Registering as a Monitor
When an actor is spawned, we may supply the inst.spawn
function with a list of addresses under the optional add_monitors
keyword argument. By default, the actor that spawns a subactor will be added as a monitor together with the Shard
special actor. If you do not wish to only add the Shard
actor, you may set add_monitors
to False
. If you opt to add extra monitors via the add_monitors
argument, you will have to also include the spawning actor manually.
If we supply a list of monitors, then the shard will automatically assign the listed actors as monitors and have the sentinel task report to them. We may also request to monitor a running actor by sending it a request over the message system. The message must follow the format
{"action": "monitor", "address": address_to_report_to}
and will receive updates just the same as if it had been added when the actor was spawned.
Receiving Notifications
When an actor ceases to operate (stops processing messages), the sentinel task will produce a message the contents of which depends on how this came to be. The message will follow the schema
{
"action": "sentinel",
"address": str, # The address of the actor that stopped
"type": Or(Const("done"), Const("cancellation"), Const("exception")), # The reason for the update
Optional("exception"): str, # The name of the exception if one occurred
"tag": str, # A callsign of the sentinel that was triggered. Usually "main_task".
}
The user can catch these in a few different ways, but the easiest is to register sentinel handlers. The actor instance provides two functions for this:
inst.sentinel_handler(name, awt)
which is receives the sentinel message when the address field matches thename
argument.@inst.default_sentinel
which receives sentinel messages for which no specific sentinel handler has been registerd.
Alternatively, you can override the default behavior by defining an @inst.action
for the "sentinel"
messages.
Out of the Box Sentinels
The shard actor will always keep a lookout for any actor it has spawned so that it can remove the instance from its internal registries and have it be garbage collected.
The websocket actor will always supervise all FrontActor
s and BackActors
to ensure they get removed in a similar fashion.
What Triggers the Sentinel
There are a few ways to trigger the sentinel. Under the hood, it awaits the message processing task. This means:
- Any action that cancels the actor, such as invoking the
inst.stop
method, will produce a"cancellation"
message. - Any exception arising inside a message handler will cause an
"exception"
message. - Any daemon marked as a
dead_mans_hand
daemon, will cancel the message processing task if it terminates, leading to a"cancellation"
message even if it was stopped by an exception. - If the websocket consumer task in a
FrontActor
dies, theWebsocketActor
will sense that this has happened. It will directly invoke the stop methods of both the actors associated with the connection, and both of them will send cancellation messages.
Miscellaneous Features
Hashes
In many applications we need some method of keeping track of information like
active session keys across shards. While it is possible to directly use your
favorite in memory database for this purpose, or to implement your own
distributed hash table, the simplest use cases may benefit from Skick's hash
system.
In order to create a dictionary which will be eventually consistent across all actors and shards, simply write
@skick.actor("some_actor")
def the_actor(inst, msg):
dictionary = skick.hash("table_name")
...
@inst.action("the_action")
async def the_action(msg):
...
value = await dictionary["our_key"]
...
...
These hash objects emulate dictionaries to some extent, but are asynchronous. They support getting, setting, deleting and checking whether certain keys are in the table. The supported operations are:
- Getting, which can be done either with
await hash.get(key)
orawait hash[key]
, - Setting, which can be done with
await hash.set(key, value)
orhash[key] = value
. N.B: Here the former allows you to await completion before proceeding, but the latter simply creates a task that will complete at some unknown time in the future. - Deletion, which can be done with
await hash.delete(key)
ordel hash[key]
. As in the setting operation, the former method guarantees not proceeding before the key has been deleted, while the latter only promises to delete the key some time in the future. - Checking for membership. Here there is no convenient asynchronous equivalent to the
in
operator, as thein
operator will convert any future we try to return from the dunder method into a boolean. Therefore only the methodawait hash.contains(key)
is available.
These as objects are, as is evident from this description, rather basic. Among other things, they do not support iteration, can not be copied and can not be compared with one another. What they do support is both a single core dictionary based implementation and a clustered Redis based implementation.
In order to use the Redis version, ensure you have a Redis instance running,
and then add the keyword parameter dict_type
when you instantiate the Skick
instance, setting it to a redis url such as in the following example.
...
skick = Skick(dict_type="redis://user:pwd@localhost:port")
...
This may seem like an odd choice of a feature. But it is perfectly in line with the underlying philosophy of Skick. We do not want the Java experience. We want minimal boilerplate requirements and we want simple use cases to be available at the developer's fingertips. If there were no facilities such as the hash object in Skick, then the developer would have to make his own version of it for nearly every project. While this would not be altogether difficult, and while it would allow him to expand its capabilities to encompass more of Redis' (if he choses Redis) feature set, it would be a major annoyance.
For those applications that require more sophisticated interactions with Redis, other in memory databases or fully featured SQL databases, the developer will have to bring his own libraries and implementations.
Directories
In frameworks like Flask and Sanic, endpoints do not have to be attached directly to the main application object. This is convenient, because in nontrivial applications, we want to distribute the code over multiple semi independent modules.
In Skick, there is a similar mechanism called a Directory
. They act as proxy objects for the main Skick
object. We can attach actors, sessions and handshakes to a Directory
, import the Directory
in the main module, and then
attach them to the main Skick
object using the skick.add_directory(directory)
method.
For example, suppose there is a submodule a.py
. We define an actor there and
use the Directory class to transfer it to the main.py
file.
#a.py
from skick import Directory
A = Directory()
@A.actor("a_actor")
def a_actor(inst, messages):
...
We can then use the directory in the file main.py as follows.
#main.py
from skick import Skick
from a import A
...
skick = Skick()
skick.add_directory(A)
...
The actor defined in a.py
is then transferred to the skick
object in main.py
and the resulting actor can be used just as if it had been defined directly in main.py
with the decorator @skick.actor
.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
File details
Details for the file skick-0.0.1a0.dev1.tar.gz
.
File metadata
- Download URL: skick-0.0.1a0.dev1.tar.gz
- Upload date:
- Size: 63.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/4.0.1 CPython/3.10.6
File hashes
Algorithm | Hash digest | |
---|---|---|
SHA256 | d996ad23657401b187203dfb8f966e46d8139514deef2132276d0b693a076d3a |
|
MD5 | 003c36b1ea4221c7dbd8a52d210d6115 |
|
BLAKE2b-256 | d90f38ccd97721c2d6cdfbe4c1ea1ade96272f3f0dcac60ea770772ad087014b |
File details
Details for the file skick-0.0.1a0.dev1-py3-none-any.whl
.
File metadata
- Download URL: skick-0.0.1a0.dev1-py3-none-any.whl
- Upload date:
- Size: 77.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/4.0.1 CPython/3.10.6
File hashes
Algorithm | Hash digest | |
---|---|---|
SHA256 | 04efa4ba76bfb8b5d32691c4b60834c918bfb7f9eb4748a1ac84b80250c6d959 |
|
MD5 | d291feff6b2a90cffca7b13cc66bcd91 |
|
BLAKE2b-256 | 98308b6c5e6aa6ade775beac5afd5956752216135a818baee9b2e66dfa43789c |