How would you implement Slack's notification flowchart in Elixir?

Slack has a crazy/intense flowchart to decide if they should send a push notification to a user for a specific message. Here it is for reference:

[source]

As a thought experiment if you were tasked with creating a system in Elixir to implement this decision tree how would you implement it? Ideally the approaches would take into consideration:

  • Maintainability of the code
  • Clarity/readability (bonus points if it can easily be shown to the business owner)
  • The understanding that answering some of the questions in the flowchart may require a non-negligible db query
5 Likes

I’d create a module with a single entry point and all the rules as private functions within it, pattern matching where possible. In a roughly top down order.

def send_notification(user, message)

#called from send_notifications
defp channel_muted(user, message)

#called from channel_muted
defp message_and_subscribed(user, message)

I don’t see any way to get something with that complex of a flow in a way that could easily be shown to a business owner in code so I wouldn’t put much energy into trying since a flow chart like this is a better route for that. I’d focus exclusively on trying to get it to be easy for developers to slot in new rules. So things like making sure comments show all calling functions.

3 Likes

There was a talk at this year’s ElixirConf about Behavior Trees that might be applicable here. They’re a concept mostly from the video game industry and NPC behavior. They were new to me, but might be worth watching the talk to see if you’d want to try them for this.

10 Likes

I might introduce a %NotificationWithContext{...} struct that contains all the data needed to check all those conditions. It would have keys such as channel_prefs, user_prefs, message_properties and so on. This way, testing whether or not to send a notification would be a pure function.

There would be a separate step to build that struct (fetch prefs, parse message to get mentions, etc).

So the high level code may look like

NotificationWithContext.build(user, message)
|> SlackNotificationStrategy.should_notify?
3 Likes

Thanks for the note! That video was definitely worth watching. I think that sort of setup does come quite close to how we want to make this type of decision. The only thing is that we don’t really have a concept of a behavior here, but it looks like it may even be possible (in the future) to export the behavior tree to be visualized with GraphViz which would be really neat (open bug)

2 Likes

ElixirConf 2018 - Behavior Trees and Battleship - Jeff Schoma

2 Likes

Without knowing the details of the remaining architecture, it’s always a bit difficult, but to play along and suggest a native one.
I think there is several valid approaches. The diagram kinda looks like it could be codified as a sort of state machine to me, and because I’m more familiar with the structure of a genserver than with a gen_statem, I would model a genserver to behave like one, specially now that we have {:continue, _} callbacks, since they produce basically a “handle switch” (although I would probably take the excuse to dig into gen_statem for scientific purposes).

Assuming “prefs” are stored in a database record for the user, I would probably have a genserver being spin up for a user on login, that loads those prefs as part of its state (probably timing out after N minutes of inactivity as well as when the user logs out), and has one callback for updating the prefs. Wrap the events that may trigger a notification on a function/task/process that either finds the running genserver for the user, or spins up one, and sends it the %Event to process.

This setup needs to account for the fact that a user might change their preferences while “their” genserver is already running, that’s why the callback to update those preferences would be necessary. This means that the specific action that updates a user preference would need to send a message to their genserver besides updating the db record.

I would then model the genserver callbacks as state transitions (with :continue, again a statem would probably be the technically correct choice)

So just to illustrate, perhaps after init you would have a state reflecting this:

{user, DND, user_prefs, device_prefs}

{user_id, true | false, %{"channel_1" => :everything, "channel_2" => :nothing}, %{"device_1" => :never}}
(and you could also prep up the prefs in specifically designed structures, instead of bare unstructured maps)

And the events themselves I would probably also wrap in their own dedicated structures, like %ChannelEvent{channel: "channel_1", type: :mention, payload: ...} | %PrivateEvent{...}, or a single %Event{} like struct if it worked fine for the majority of events

def handle_info({:maybe_notify, %ChannelEvent{channel: channel} = event}, {_, false, u_prefs, _} = state) do
     case u_prefs[channel] do
             :everything -> {:noreply, state, {:continue, {:device, event}}}
             :nothing -> {:noreply, state}
             :perhaps -> {:noreply, state, {:continue, {:channel_perhaps, event}}}
    end
end

# ideally you would get away with matching just the cases where a notification should be sent, and catching all others in a :noreply handle

def handle_continue({:device, event}, {user_id, false, _, d_prefs} = state) do
	Enum.each(d_prefs, fn
		({_, :never}, acc) -> :noop
	   	({device, :everything}) -> Task.start(fn -> Somemodule.persist_and_send_push_notification(user_id, event, device)  end)
	end)
	{:noreply, state}
end

# other specific :continues, bearing in mind that you can loop into any :continue if needed, as if it was a statem

Basically have a heavier init but afterwards (assuming when a user becomes active it will probably receive notifications in a continuous fashion, so this would be an advantage) a process already prepared to handle them, that goes through a sequence of handle_continues until it resolves.

There would be a non-trivial amount of details taking into account that flowchart (like, do you also need to decide if the notifications are persisted, if they need to be flagged as read/unread, etc), so in the end, I think, the choice would be the familiar “it depends”, but I would try first to see if I could fit it into something like this because in my mind it makes sense.

1 Like

I’ve just discovered a library named Opus which allows you to create pipelines using a declarative DSL. It even supports outputting a pipeline as a graph using GraphVix.

4 Likes

I wanted to circle back on this. For now I went with @venkatd’s suggestion of creating a single entry point to collect all the data into a struct, that we then check. Although in my case I ended up with several structs, but it’s been working pretty well. But in the future I’d really like to try something like the behavior trees!