Tutorial

Here you will learn how to start Circo, create actors, send messages and react to lifecycle events. We will program a distributed backend for Twitter clones from scratch.

Don't worry, it is not complicated, just a simplified prototype! But it works, and it scales to any size...

Architecture

We build the system out of three building blocks: Posts, Feeds and Profiles.

  • Posts are simple structs with some text and the name of the author.
  • Feeds are actors holding a list of posts. When someone opens the frontend, a new feed will be created for that session, and populated with recent posts. While the feed is alive, it also receives pushed updates from its sources.
  • Profile actors can create posts and follow other profiles.

Post

We start with Post, which is a simple struct, as it has no behavior currently. We will store posts in feeds and profiles, and pass them as messages.

using Circo

struct Post
    authorname::String
    text::String
end

Feed

A Feed, our first actor, contains a growing list of posts from different authors.

Actors in Circo are mutable structs[encapsulation], subtypes of Actor:

mutable struct Feed <: Actor{Any}
    sources::Vector{Addr} # Post sources that this feed watches
    posts::Vector{Post}
    core::Any # A tiny boilerplate is needed
    Feed(sources) = new(sources, [])
end

core is a required field to store system info, e.g. the id of the actor. You may sometimes use the information in core, but you should never access it directly, as its content is not fixed: Its type is assembled by the activated plugins.


When the feed receives a Post, it just prints and stores it:

function Circo.onmessage(me::Feed, post::Post, service)
    println("Feed $(box(me)) received post: $post")
    push!(me.posts, post)
end

By adding a method to Circo.onmessage we have defined how Feed actors react, when they receive a Post as a message.[behaviors] The here unused service argument is for sending out messages, spawning actors or communicating with plugins.


Try out what we have

feed = Feed([])

ctx = CircoContext()
s = Scheduler(ctx, [feed])
run!(s) # Start the scheduler in the background
Task (runnable) @0x00007fe956c3dcd0

The CircoContext manages the configuration and helps building a tailored system: it loads the plugins, generates types, etc. The Scheduler then executes our actors in that context.


The feed is scheduled and waiting for posts. We can send one from the outside:

send(s, feed, Post("Me", "My first post"))
Feed 17168398928580964680 received post: Main.Post("Me", "My first post")
Output
Feed 15794352489972218257 received post: Main.Post("Me", "My first post")

Great, the post arrived at the feed and got processed!

Profile

Now we will create a Profile actor that can create posts and follow other profiles.

mutable struct Profile <: Actor{Any}
    name::String
    posts::Vector{Post}
    following::Vector{Addr} # Adresses of the profiles we follow
    watchers::Vector{Addr} # Feeds to notify about our new posts
    core::Any
    Profile(name) = new(name, [], [], [])
end

The Profile will start following another one if it receives the Follow message:

struct Follow
    whom::Addr
end

function Circo.onmessage(me::Profile, msg::Follow, service)
    println("$(me.name) ($(box(me))): Starting to follow $(box(msg.whom))")
    push!(me.following, msg.whom)
end

Now we can create a few profiles and connect them. But first the running scheduler has to be paused and restarted for the new onmessage method to take effect.

pause!(s); run!(s)
alice = spawn(s, Profile("Alice"))
bela = spawn(s, Profile("Béla"))
cecile = spawn(s, Profile("Cécile"))

send(s, alice, Follow(bela))
send(s, alice, Follow(cecile))
send(s, bela, Follow(cecile))
Output
Alice (2519498415121108185): Starting to follow 3749599043616972853
Alice (2519498415121108185): Starting to follow 5769659525869689442
Bela (3749599043616972853): Starting to follow 5769659525869689442

Creating Posts, notifying watchers

Profiles will create posts when they receive a CreatePost message:

struct CreatePost
    text::String
end

function Circo.onmessage(me::Profile, msg::CreatePost, service)
    post = Post(me.name, msg.text)
    println("Posting: $post")
    push!(me.posts, post)
    notify_watchers(me, post, service) # Send out the post to the feeds of our live followers (if any)
end

function notify_watchers(me::Profile, post, service)
    for watcher in me.watchers
        send(service, me, watcher, post)
    end
end
notify_watchers (generic function with 1 method)

Let our users create a few interesting posts:

pause!(s); run!(s)
send(s, alice, CreatePost("Through the Looking-Glass"))
send(s, bela, CreatePost("I lost my handkerchief"))
send(s, cecile, CreatePost("My first post"))
send(s, cecile, CreatePost("At the zoo"))
Output
Posting: Main.Post("Alice", "Through the Looking-Glass")
Posting: Main.Post("Bela", "I lost my handkerchief")
Posting: Main.Post("Cécile", "My first post")
Posting: Main.Post("Cécile", "At the zoo")

As there isn't any feed watching the profiles at the time, no notifications were sent out.

Creating feeds

So, time to create a live feed! The CreateFeed message asks a profile to create a feed that is sourced from the profiles that this one follows:

struct CreateFeed end
function Circo.onmessage(me::Profile, msg::CreateFeed, service)
    feed = spawn(service, Feed(copy(me.following)))
    println("Created Feed: $(feed)")
end

When the feed actor is spawned, it starts watching the profiles by sending them an AddWatcher message:

struct AddWatcher
    watcher::Addr
end

function Circo.onmessage(me::Feed, ::OnSpawn, service)
    for source in me.sources
        send(service, me, source, AddWatcher(addr(me)))
    end
end

The profile reacts with immediately sending back its last 3 posts, and starting to send notifications about future posts:

function Circo.onmessage(me::Profile, msg::AddWatcher, service)
    for post in me.posts[max(end - 2, 1):end]
        send(service, me, msg.watcher, post)
    end
    push!(me.watchers, msg.watcher)
end

Ta-da

We are ready! We do not want to create the frontend, so let's just say that when someone opens the frontend app on their device, a Circo plugin or an external system will call:

send(s, alice, CreateFeed())
Created Feed: 10.1.0.119:24721/e7af2351d50e511e
Feed 16694601178059526430 received post: Main.Post("Béla", "I lost my handkerchief")
Feed 16694601178059526430 received post: Main.Post("Cécile", "My first post")
Feed 16694601178059526430 received post: Main.Post("Cécile", "At the zoo")
Output
Created Feed: 192.168.193.99:24721/898192691fd68c14
Feed 9908361635395177492 received post: Main.Post("Cécile", "My first post")
Feed 9908361635395177492 received post: Main.Post("Cécile", "At the zoo")
Feed 9908361635395177492 received post: Main.Post("Béla", "I lost my handkerchief")

That's it! Just a final check that when Béla creates a new post, it will arrive on the feed of Alice:

send(s, bela, CreatePost("Have you ever seen a llama wearing pajamas?"))
Posting: Main.Post("Béla", "Have you ever seen a llama wearing pajamas?")
Feed 16694601178059526430 received post: Main.Post("Béla", "Have you ever seen a llama wearing pajamas?")
Output
Posting: Main.Post("Bela", "Have you ever seen a llama wearing pajamas?")
Feed 9908361635395177492 received post: Main.Post("Bela", "Have you ever seen a llama wearing pajamas?")

Where to go

Nothing more is needed to start coding in Circo. The best way to learn is to make something yourself.

For closer-to-life Circo programs look into the examples folder of the repo.

Have fun!


This page was generated using Literate.jl.

  • encapsulationActors encapsulate their state: They are to be accessed only through message passing. This strict separation enables the scalability of the actor model, and I also believe that it is very natural, meaning that it is aligned with how nature works. It seems that shared state is not common in nature, which explains why systems that provide shared state scale poorly.
  • behaviorsUnlike other actor systems, Circo does not complicate things with replaceable actor behaviors. When we need an actor to change its behavior dynamically, we can dispatch further in onmessage, or spawn another actor. As always, performance was the main driver behind this design decision, but the API is also definitely simpler. Actors are like objects in OOP, and objects does not have replaceable behaviors.