Current section
Files
Jump to
Current section
Files
src/glubsub.gleam
//// This module implements a simple pubsub system using gleam actors.
////
import gleam/erlang/process.{type Monitor, type Selector, type Subject}
import gleam/list
import gleam/otp/actor
import gleam/result
pub type Topic(m) {
Topic(Subject(Message(m)))
}
pub opaque type Message(m) {
Subscribe(reply_with: Subject(Result(Nil, GlubsubError)), client: Subject(m))
Unsubscribe(
reply_with: Subject(Result(Nil, GlubsubError)),
client: Subject(m),
)
Broadcast(message: m)
GetSubscribers(reply_with: Subject(List(Subscriber(m))))
SubscriberDown(process.Down)
Shutdown
}
pub opaque type GlubsubError {
AlreadySubscribed
NotSubscribed
StartError(actor.StartError)
}
type State(m) {
State(subscribers: List(Subscriber(m)), selector: Selector(Message(m)))
}
pub type Subscriber(m) {
Subscriber(client: Subject(m), monitor: Monitor)
}
const timeout = 1000
/// Creates a new topic. Which is a pubsub channel that clients can subscribe to.
pub fn new_topic() -> Result(Topic(m), GlubsubError) {
actor.new_with_initialiser(timeout, fn(subject) {
let selector =
process.new_selector()
|> process.select(subject)
|> process.select_monitors(SubscriberDown)
actor.initialised(State(subscribers: [], selector: selector))
|> actor.selecting(selector)
|> actor.returning(subject)
|> Ok()
})
|> actor.on_message(handle_message)
|> actor.start()
|> result.map(fn(started) {
let actor.Started(data: subject, ..) = started
Topic(subject)
})
|> result.map_error(fn(err) { StartError(err) })
}
/// Destroys the given topic, unsubscribing all clients.
pub fn destroy_topic(topic: Topic(m)) -> Nil {
actor.send(topic_to_subject(topic), Shutdown)
}
/// Subscribes the given client to the given topic.
pub fn subscribe(
topic: Topic(m),
client: Subject(m),
) -> Result(Nil, GlubsubError) {
actor.call(topic_to_subject(topic), timeout, fn(self) {
Subscribe(self, client)
})
}
/// Unsubscribes the given client from the given topic.
pub fn unsubscribe(
topic: Topic(m),
client: Subject(m),
) -> Result(Nil, GlubsubError) {
actor.call(topic_to_subject(topic), timeout, fn(self) {
Unsubscribe(self, client)
})
}
/// Broadcasts a message to all subscribers of the given topic.
pub fn broadcast(topic: Topic(m), message: m) -> Result(Nil, Nil) {
actor.send(topic_to_subject(topic), Broadcast(message))
|> Ok
}
/// Returns a set of all subscribers to the given topic.
pub fn get_subscribers(topic: Topic(m)) -> List(Subscriber(m)) {
actor.call(topic_to_subject(topic), timeout, GetSubscribers)
}
fn handle_message(
state: State(m),
message: Message(m),
) -> actor.Next(State(m), Message(m)) {
case message {
Subscribe(reply, client) -> {
case list.find(state.subscribers, fn(sub) { sub.client == client }) {
Ok(_) -> {
actor.send(reply, Error(AlreadySubscribed))
actor.continue(state)
}
Error(Nil) -> {
case process.subject_owner(client) {
Ok(pid) -> {
let monitor = process.monitor(pid)
let new_subs = [
Subscriber(client: client, monitor: monitor),
..state.subscribers
]
actor.send(reply, Ok(Nil))
actor.continue(State(..state, subscribers: new_subs))
}
Error(_) -> {
// Subject was for a named process which has died between calling
// us and us trying to monitor them -> ignore
actor.continue(state)
}
}
}
}
}
Unsubscribe(reply, client) -> {
case list.find(state.subscribers, fn(sub) { sub.client == client }) {
Error(Nil) -> {
actor.send(reply, Error(NotSubscribed))
actor.continue(state)
}
Ok(unsub) -> {
let new_subs = remove_subscriber(state.subscribers, unsub)
actor.send(reply, Ok(Nil))
actor.continue(State(..state, subscribers: new_subs))
}
}
}
Broadcast(message) -> {
state.subscribers
|> list.each(fn(sub) { actor.send(sub.client, message) })
actor.continue(state)
}
GetSubscribers(reply) -> {
actor.send(reply, state.subscribers)
actor.continue(state)
}
SubscriberDown(process.ProcessDown(pid:, ..)) -> {
let ok_pid = Ok(pid)
case
state.subscribers
|> list.find(fn(sub) { process.subject_owner(sub.client) == ok_pid })
{
Error(Nil) -> {
actor.continue(state)
}
Ok(unsub) -> {
process.demonitor_process(unsub.monitor)
let new_subs = remove_subscriber(state.subscribers, unsub)
actor.continue(State(..state, subscribers: new_subs))
}
}
}
// A monitored port is down, which should never happen -> ignore
SubscriberDown(_) -> actor.continue(state)
Shutdown -> {
actor.stop()
}
}
}
fn topic_to_subject(topic: Topic(m)) -> Subject(Message(m)) {
let Topic(subject) = topic
subject
}
fn remove_subscriber(
subscribers: List(Subscriber(m)),
unsubscriber: Subscriber(m),
) -> List(Subscriber(m)) {
list.filter(subscribers, fn(sub) { sub != unsubscriber })
}