Package: skick
SkickSkick is a library for building actor based back ends for web applications. It
allows clients to connect over a websocket and interact withactorsusing JSON encoded messages.A simple hello world program would look something like:fromskickimportSkickskick=Skick()@skick.session("hello")defhello_world(inst,message):""" A hello world type user session """@inst.on_startasyncdefon_start():awaitinst.socksend({"greeting":"Hello, use the action 'greet' to receive a greeting"})@inst.socket("greet",{"action":"greet"})asyncdefgreet(msg):awaitinst.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,
areceptionistand asession actorwill 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
interactionClient: {"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 ProjectSkick 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 ActorAt 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'sActors, A Model of Concurrent Computation in
Distributed Systemsfrom 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 ActorsActors in Skick are defined insynchronousdeclarative functions which are
registered by a specialshardactor (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 theactormethod in some Skick instance@skick.actor(actor_name)deffunction_name(actor_instance,init_message):...Here,actor_nameis a string used to refer to the actor type when requesting
it from the shard. Theactor_instanceis the instance of the Actor class that
the shard has prepared for us, and theinit_messageis somemessage(messages in skick are always dictionaries) that helps us set up the initial
state of the actor.Within the function, theactor_instancecontains 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_startwhich 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_startfunction.
If you do not do this, then you will hold up the shard actor,
freezing the entire shard.@inst.on_stopwhich 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")defaccumulator(inst,message):""" An actor that adds numbers to an internal tally. """tally=message.get("initial_tally",0)@inst.daemon("tally_reporter")asyncdefreport():""" Reports the current tally with regular intervals if asked to """while"report_to"inmessage:awaitasyncio.sleep(1)awaitinst.send(message["report_to"],{"action":"report_tally","tally":tally})@inst.action("add",schema={"action":"add","number":int})asyncdefadder(msg):""" Adds msg["number"] to the tally found in the closure """nonlocaltally# Necessary nonlocal declaration for immutable objectstally+=msg["number"]@inst.action("get_tally",schema={"action":"get_tally","sender":str})asyncdefget_tally(msg):"""Asks the actor to send the current tally to *sender*. This couldhave used the conversation mechanism which we will introduce later."""awaitinst.send(msg["sender"],{"action":"report_tally","tally":tally})Helper MethodsThere 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 addressaddress. 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 thepartneractor. More on this later.async inst.spawn(actor_type, message, ...)which asks the shard object to
spawn a new actor of the typeactor_typeand 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 servicenameunregister_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 ActorsActors 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 thespawnmethod. In order to spawn
actors when the shard starts up, the user can define anon_startmethod.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:fromskickimportSkickasyncdefon_start(shard):awaitshard.spawn("accumulator",{"initial_tally":420})skick=Skick(on_start=on_start)...skick.start()ConversationsOften, 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 istooinconvenient in many situations. For this reason, Skick provides
an alternative out of the box called aconversation. 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 theyieldkeyword. Conversations are set up by yielding
specialConversationobjects, subclassed in oneCalland oneRespondvariety
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....fromskickimportSkick,Respond...@skick.actor("accumulator")defaccumulator(inst,message):""" An actor that adds numbers to an internal tally. """tally=message.get("initial_tally",0)...@inst.action("add")asyncdefadder(msg):"""Here, because adder is responding in a conversation, the msg parameteris generated by the calling actor, and is not manually constructed bythe programmer. It contains the necessary parameters to respond to thecall."""nonlocaltally# Necessary nonlocal declaration for immutable objectsyieldRespond(msg)# We first "pick up the phone" as it weretally+=msg["number"]yield{"tally":tally}# Then we yield a responseIn the corresponding caller, another asynchronous generator is found:...fromskickimportSkick,Call...@skick.actor("caller")defcaller(inst,message):...@inst.action("call_adder")asyncdefcall_adder(msg):new_tally=yieldCall(adder_address,{"number":10})...These conversations can go on for many exchanges, each (with the exception of theyield Respond(...)call) taking the form:...response_to_our_message=yieldour_message...In the caller actor above, the conversation was initiated because we received
a message the handler of which was aconversation prototype. This is not always
appropriate. For example, we may wish to start conversations in theon_startmethod
or in some daemon. If we wish to do this, we can create a conversation using theinst.conversemethod.inst.conversecan 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")defcaller(inst,message):...@inst.on_startasyncdefinit():...asyncdefsend_number(msg):response=yieldCall(adder_address,{"number":720})awaitdo_something(response)awaitinst.converse({},send_number)...The Special ActorsThere are two special actors in Skick. Theshardactor and theWebSocketactor. 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 ActorSkick instances may run in clusters, but even if they are run in a single core,
there is a specialshardactor 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 ofserviceswhich 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 actorsMost 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 WebsocketActorIf a websocket server has been requested, a specialWebsocketActorwill be instantiated. It maintains its own
collection of websocket handlers calledsessionsandsubsessionsas well as so calledhandshake 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 theWebsocketActor. TheWebsocketActoralso 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 SubsessionIn 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 usewebsockets. 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 asubsession. If the client is able to directly request
the subsession type over the websocket, then it is called asession. All the other subsession types
are created through subsession replacement.Let us recall the Hello World program from earlier:...@skick.session("hello")defhello_world(inst,message):""" A hello world type user session """@inst.on_startasyncdefon_start():awaitinst.socksend({"greeting":"Hello, use the action 'greet' to receive a greeting"})@inst.socket("greet",{"action":"greet"})asyncdefgreet(msg):awaitinst.socksend({"greeting":"Hello good sir"})...We note that theskick.sessionfunction 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.socketandinst.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, oneFrontActorand oneBackActor.
They both provide the same sets of decorators and methods, but the methods and
decorators do not have the same effects on them.When thehello_worldfunction is run on aFrontActor, the@inst.on_startdecorator simply does nothing (there is a separate@inst.front_on_startmethod
for the exceptional case where you would want to run code on startup in theFrontActor). When the@inst.socketdecorator is used, theFrontActormerely
records the name of the method and the provided schema, but completely ignores
the function body. Likewise, the old@inst.actiondecorator has been
overridden and does nothing on theFrontActor.When thehello_worldfunction is run on aBackActor, the@inst.on_startand other standard decorators work as we would expect from a normalActor.
TheytargettheBackActorsince it is the actual main actor of the session.
As for the@inst.socketdecorator, it reverts to an@inst.actiondecorator
ignoring the schema entirely.Many methods have these dual interpretations. Theinst.socksendmethod,
which only exists on these special websocket actors, sends a message to the
websocket client when it is called on theFrontActor, but if it is called in
theBackActor, it encapsulates the message in an action that asks theFrontActorto
send the encapsulated message over the websocket.How Websocket Connections are ProcessedWhen a client requests a new websocket connection, the following steps are followedThe 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 theFrontActorclass and one using theBackActorclass.The samesessiondefinition 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 theFrontActor, 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 theBackActor, where
the schema has a correspondingactionwhich treats the message as any other
that has been sent over the messaging system.If theBackActorchooses to send a message back to the client the following
takes place:The message is encapsulated into a message which is sent to theFrontActorThe encapsulated message is received in theFrontActorand 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.WhyThis 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 associatingseveralconnections 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 sameBackActoreven if he maintains
severalFrontActors. 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 MethodsThe FrontActor inherits all methods and decorators from the Actor class, but
in order to access them we need to use different names, namelyThe@inst.front_actiondecorator creates an action on the front actorThe@inst.front_daemondecorator creates a daemon on the front actorThe@inst.front_on_startdecorator registers a startup method on the front actorThe@inst.front_on_stopdecorator registers a shutdown method on the front actorTheasync inst.socksendmethod sends a message to the websocket clientApart from these special methods, a number of ordinary methods are either irrelevant
or have had their functionalities alteredThe@inst.actiondecorator ignores the function it was fed.The@inst.daemondecorator ignores the function it was fed.The@inst.on_startdecorator ignores the function it was fed.The@inst.on_stopdecorator ignores the function it was fed.Theasync inst.replacemethod 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 theBackActorThe conversation mechanisms function as usual, but are largely irrelevant.The service registry functions are not disabled, but onlyget_serviceshould be used in practice.Specifics About the BackActor's MethodsLike in the case of theFrontActor, theBackActorinherits all decorators and
methods from theActorclass. Some of them function as usual, and some have
had their behaviors altered. In addition to this, all the methods defined on theFrontActoralso exist on theBackActorbut many of them do nothing.The@inst.front_...decorators all do nothing on theBackActor.The@inst.daemonand@inst.actiondecorators functions as they do on theActorclass.Theasync inst.socksendmethod sends a message to theFrontActorinstructing it to send a message to the client.Theasync inst.replacemethod replaces theBackActor, but also performs the additional step
of ordering theFrontActorto 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 onlyget_serviceshould be used in practice.Session ConversationsWhen conversing with a handler defined through an@inst.socketdecorator,
the conversation will be with theBackActor. TheBackActorhas the ability
to use its turn to interject a user query into the conversation. This is done
by yielding askick.SocketQueryobject. When this is done, theBackActorwill
set up a query in theFrontActor. 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 thesubschemathat was specified by theBackActorwhen the query was made.The conversation in theBackActorwill resume once the websocket client has sent its response.Because theFrontActoris 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 functioninst.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,strorbool"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 extraquery_schemadeclarations.In principle, this means we can see constructs like the one below:...@skick.session("curious")defcuriosity(inst,message):...@inst.socket("get_information")asyncdefinfo(msg):response=yieldCall(info_actor,{"action":"info","credentials":cred})ifresponse["ask_gdpr"]:consent=yieldSocketQuery({"gdpr_query":GDPR_STRING},"bool")ifconsent:awaitinst.socksend(response["info"])else:awaitinst.socksend({"error":"terms not accepted"})else:awaitinst.socksend(response["info"])......In the example above, theBackActorasks 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
theBackActor, 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 SequencesImagine 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 ahandshake sequence.We may declare that a function is ahandshakeby using the@skick.handshakedecorator. We begin with an example:...@skick.handshake("random_number")defrandom_session(inst,message)num=random()@inst.on_startasyncdefon_start():awaitinst.replace("double_number",{**message,"number":num})@skick.subsession("double_number")defdoubler(inst,message):num=2*message["number"]@inst.on_start()asyncdefon_start():awaitinst.replace(f"{message['session_type']}:final",{"number":num})@random_session("logarithm")deflogarithm(inst,message):base=message["number"]@inst.socket("log",{"action":"log","x":float})asyncdeflog(msg):awaitinst.socksend({"log_base(x)":math.log(msg[x],base=base),"x":msg["x"]})Here, therandom_numberanddouble_numbersubsessions are part of a handshake sequence.@skick.handshakedoes 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")deflogarithm(inst,message):...Here, one session and one subsession are created and registered. One calledlogarithmand the other calledlogarithm:final. The former is merely a copy ofrandom_session, and the latter is the one actually described inlogarithm. 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 thef"{message['session_type']}:final"actor, handing control over to the subsession in question.Session terminationSince sessions are not normal actors, they are terminated in a slightly different fashion than normal actors. If you invoke theinst.stopmethod on theBackActor,
theWebsocketActorwill make sure to close the connection and stop theFrontActor.This works because theFrontActor,BackActorand the consumer task in theFrontActorare collated in a specialSyzygyobject by theWebsocketActor.
If any of the three fail, theSyzygyprovides 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 theWebsocketActor, and while the stop
methods are invoked directly without involving messaging, theWebsocketActoris informed of the demise of the actors in theSyzygythrough the sentinel message itshouldemit. This may prove unreliable on some occasions, and wemightsee situations where only one of the actors is actually alive. We may also experience ill defined states when the sentinel messages are in transition.ClusteringSkick 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 ObjectIn 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.OptionsThe Skick object natively supports the following options:on_startwhich allows you to specify a startup function. This would typically be used to spawn actors on startup.message_systemwhich, if you supply it, presumes that you have given it an AMQP url to a RabbitMQ instance.websocket_hostwhich, if you supply it, is the address the websocket library will listen onwebsocket_portwhich, if you supply it, is the port the websocket library will listen onwebsocket_serverwhich, if you set it to any value, will disable the websocket serversslwhich tells the websocket library to use TLS. It can beA 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'screate_default_contextmethod.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.websocket_optionswhich should be a dictionary of options that will be passed directly to the websocket library.dict_typewhich, if you provide a string, will be presumed to be a redis url to a redis server that will manage yourhashobjects.MethodsTheSkickinstance 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 underlyingShardobject.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.SentinelsIn 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 bysentinels, 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'smonitors. Monitors are other actors that have told the supervised actor that they wish to be notified.Registering as a MonitorWhen an actor is spawned, we may supply theinst.spawnfunction with a list of addresses under the optionaladd_monitorskeyword argument. By default, the actor that spawns a subactor will be added as a monitor together with theShardspecial actor. If you do not wish to only add theShardactor, you may setadd_monitorstoFalse. If you opt to add extra monitors via theadd_monitorsargument, 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 NotificationsWhen 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 updateOptional("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 thenameargument.@inst.default_sentinelwhich receives sentinel messages for which no specific sentinel handler has been registerd.Alternatively, you can override the default behavior by defining an@inst.actionfor the"sentinel"messages.Out of the Box SentinelsThe 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 allFrontActors andBackActorsto ensure they get removed in a similar fashion.What Triggers the SentinelThere 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 theinst.stopmethod, will produce a"cancellation"message.Any exception arising inside a message handler will cause an"exception"message.Any daemon marked as adead_mans_handdaemon, will cancel the message processing task if it terminates, leading to a"cancellation"messageeven if it was stopped by an exception.If the websocket consumer task in aFrontActordies, theWebsocketActorwill 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 FeaturesHashesIn 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'shashsystem.In order to create a dictionary which will be eventually consistent across all
actors and shards, simply write@skick.actor("some_actor")defthe_actor(inst,msg):dictionary=skick.hash("table_name")...@inst.action("the_action")asyncdefthe_action(msg):...value=awaitdictionary["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 withawait hash.get(key)orawait hash[key],Setting, which can be done withawait 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 withawait 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 theinoperator, as theinoperator 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 parameterdict_typewhen 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.DirectoriesIn 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 aDirectory. They act as proxy objects for the mainSkickobject. We can attach actors, sessions and handshakes to aDirectory, import theDirectoryin the main module, and then
attach them to the mainSkickobject using theskick.add_directory(directory)method.For example, suppose there is a submodulea.py. We define an actor there and
use the Directory class to transfer it to themain.pyfile.#a.pyfromskickimportDirectoryA=Directory()@A.actor("a_actor")defa_actor(inst,messages):...We can then use the directory in the file main.py as follows.#main.pyfromskickimportSkickfromaimportA...skick=Skick()skick.add_directory(A)...The actor defined ina.pyis then transferred to theskickobject inmain.pyand the resulting actor can be used just as if it had been defined directly inmain.pywith the decorator@skick.actor.