Slide 1

Slide 1 text

Sending Push Notifications An OTP Journey Derrick Reimer @derrickreimer level.app

Slide 2

Slide 2 text

The Process Check browser compatibility Register a service worker Ask permission and subscribe Store the subscription Send push payloads to the endpoint Receive the push in service worker

Slide 3

Slide 3 text

Compatibility

Slide 4

Slide 4 text

Generate VAPID keys ➜ ~ npm install web-push -g ➜ ~ web-push generate-vapid-keys ======================================= Public Key: BMz3R7GFA3j4TM4IJRKEO86qbhiACrntSS2LgD41PYo27Vs-dyJpVPr2BcU_YNt3-dJC6_i64PNLB1yvTrs2zf4 Private Key: mFXz3q23AMNsYfT_ZqkSqh9W79yeha20RjPpV1CH5B4 =======================================

Slide 5

Slide 5 text

Register the worker export function registerWorker() { return navigator.serviceWorker .register("/service-worker.js") .then(registration => { return registration; }) .catch(err => { error("Unable to register service worker.", err); }); } We’ll peek inside service-worker.js in a moment.

Slide 6

Slide 6 text

Subscribe the user export function subscribe() { return navigator.serviceWorker.ready.then(registration => { const convertedKey = urlBase64ToUint8Array(getPublicKey()); return registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: convertedKey }); }); } Your VAPID public key A special utility function

Slide 7

Slide 7 text

Send the subscription payload to your server { endpoint: '< Push Subscription URL >', keys: { p256dh: '< User Public Encryption Key >', auth: '< User Auth Secret >' } }

Slide 8

Slide 8 text

Create a table defmodule Level.Repo.Migrations.CreatePushSubscriptions do use Ecto.Migration def change do create table(:push_subscriptions, primary_key: false) do add :id, :binary_id, primary_key: true add :user_id, references(:users, on_delete: :nothing, type: :binary_id), null: false add :digest, :text, null: false add :data, :text, null: false timestamps() end create unique_index(:push_subscriptions, [:user_id, :digest]) end end A hashed version of the payload

Slide 9

Slide 9 text

Parse them defmodule Level.WebPush.Subscription do defstruct [:endpoint, :keys] @spec parse(String.t()) :: {:ok, t()} | {:error, :invalid_keys | :parse_error} def parse(data) do data |> Poison.decode() |> after_decode() end defp after_decode( {:ok, %{"endpoint" => endpoint, "keys" => %{"auth" => auth, "p256dh" => p256dh}}} ) do {:ok, %__MODULE__{endpoint: endpoint, keys: %{auth: auth, p256dh: p256dh}}} end defp after_decode({:ok, _}), do: {:error, :invalid_keys} defp after_decode(_), do: {:error, :parse_error} end

Slide 10

Slide 10 text

Persist them for later defmodule Level.WebPush do # ... defp persist(user_id, data) do %Schema{} |> Changeset.change(%{user_id: user_id, data: data}) |> Changeset.change(%{digest: compute_digest(data)}) |> Repo.insert(on_conflict: :nothing) end defp compute_digest(data) do :sha256 |> :crypto.hash(data) |> Base.encode16() end end If the particular subscription is 
 already persisted for the user, 
 do nothing.

Slide 11

Slide 11 text

Build payloads defmodule Level.WebPush.Payload do @enforce_keys [:body] defstruct [:body, :tag] @type t :: %__MODULE__{ body: String.t(), tag: String.t() | nil } def serialize(%__MODULE__{} = payload) do payload |> Map.from_struct() |> Poison.encode!() end end Include pretty much anything
 you may need in your service
 worker.

Slide 12

Slide 12 text

Post your payload to
 a subscription @spec make_request(Payload.t(), Subscription.t()) :: {:ok, HTTPoison.Response.t()} | {:error, atom()} | no_return() def make_request(payload, subscription) do payload |> Payload.serialize() |> WebPushEncryption.send_web_push(subscription) end

Slide 13

Slide 13 text

Listen for pushes
 and show notifications // Register event listener for the 'push' event. self.addEventListener('push', function (event) { const data = event.data ? event.data.json() : {}; const payload = { body: data.body }; // Keep the service worker alive until the notification is created. event.waitUntil(self.registration.showNotification('Level', payload)); });

Slide 14

Slide 14 text

Send pushes from
 your domain logic # After someone posts a message... defp send_push_notifications(notifiable_ids, payload) do Enum.each(notifiable_ids, fn user_id -> WebPush.send_web_push(user_id, payload) end) end

Slide 15

Slide 15 text

Proof of concept WebPush.send_web_push WebPush.fetch_subscriptions WebPush.make_request … WebPush.make_request WebPush.send_web_push WebPush.fetch_subscriptions WebPush.make_request … WebPush.make_request Enum.each(notifiable_users, …

Slide 16

Slide 16 text

Limitations with 
 the proof of concept • Queries and requests should not block the main process • Ordering of notifications should be controlled • Failed requests should be retried • Subscriptions should be deleted when they are no longer valid

Slide 17

Slide 17 text


 I think I need 
 a GenServer…

Slide 18

Slide 18 text

The Flow • Build the payload and a list of user IDs that should see it • For each user, fetch/cache their subscriptions (could be > 1) • For each subscription, send the payload and handle the response — retry, delete, etc. — independent of the other subscriptions

Slide 19

Slide 19 text

Servers User Responsible for fetching subscriptions out of the database, caching those subscriptions to reduce DB roundtrips, and dispatching messages to send payloads. Subscription Responsible for making network requests to the endpoint, handling responses, retrying the request as necessary, and deleting the subscription if it become invalid.

Slide 20

Slide 20 text

Supervision Strategy • There will be a potentially large number of users and subscriptions in the system • Users and subscriptions can be created and destroyed in realtime • (Static) Supervisors are not sufficient for our needs because we can’t know what processes we need upfront • Thus, we need to use DynamicSupervisors!

Slide 21

Slide 21 text

The Supervision Tree Level.WebPush Level.WebPush.UserSupervisor Level.WebPush.SubscriptionSupervisor Level.Supervisor Level.Registry Level.WebPush.UserWorker Level.WebPush.SubscriptionWorker

Slide 22

Slide 22 text

Level.Supervisor defmodule Level do use Application def start(_type, _args) do children = [ # ... {Registry, keys: :unique, name: Level.Registry}, Level.WebPush ] opts = [strategy: :one_for_one, name: Level.Supervisor] Supervisor.start_link(children, opts) end end Fire up the Registry process
 and WebPush supervisor
 on application boot
 
 More on why we need the
 Registry momentarily…

Slide 23

Slide 23 text

WebPush (Supervisor) defmodule Level.WebPush do use Supervisor def start_link(arg) do Supervisor.start_link(__MODULE__, arg, name: __MODULE__) end @impl true def init(_arg) do children = [ UserSupervisor, SubscriptionSupervisor ] Supervisor.init(children, strategy: :one_for_one) end end Responsible for keeping our dynamic
 supervisors alive and healthy This one is just a plain old supervisor

Slide 24

Slide 24 text

WebPush (API) defmodule Level.WebPush do def send_web_push(user_id, %Payload{} = payload) do user_id |> UserSupervisor.start_worker() |> handle_start_worker(user_id, payload) end defp handle_start_worker({:ok, _pid}, user_id, payload) do UserWorker.send_web_push(user_id, payload) end defp handle_start_worker({:error, {:already_started, _pid}}, user_id, payload) do UserWorker.send_web_push(user_id, payload) end defp handle_start_worker(err, _, _), do: err end Calls the public 
 API of the worker Dynamically starts a 
 worker process

Slide 25

Slide 25 text

UserSupervisor defmodule Level.WebPush.UserSupervisor do use DynamicSupervisor alias Level.WebPush.UserWorker def start_link(arg) do DynamicSupervisor.start_link(__MODULE__, arg, name: __MODULE__) end def start_worker(user_id) do spec = {UserWorker, user_id} DynamicSupervisor.start_child(__MODULE__, spec) end @impl true def init(_arg) do DynamicSupervisor.init(strategy: :one_for_one) end end No children need to 
 be specified on init!

Slide 26

Slide 26 text

UserWorker (API) defmodule Level.WebPush.UserWorker do use GenServer defstruct [:user_id] def start_link(user_id) do GenServer.start_link(__MODULE__, user_id, name: via_tuple(user_id)) end defp via_tuple(user_id) do {:via, Registry, {Level.Registry, {:web_push_user, user_id}}} end def send_web_push(user_id, %Payload{} = payload) do GenServer.cast(via_tuple(user_id), {:send_web_push, payload}) end end Instead of by static name,
 children are “addressed”
 via the Registry

Slide 27

Slide 27 text

UserWorker (Server) defmodule Level.WebPush.UserWorker do @impl true def init(user_id) do {:ok, %__MODULE__{user_id: user_id}} end @impl true def handle_cast({:send_web_push, payload}, state) do state.user_id |> fetch_subscriptions() |> Enum.each(fn {id, subscription} -> send_to_subscription(id, subscription, payload) end) {:noreply, state} end end Fetch subscriptions for 
 the user and dispatch to
 subscription workers

Slide 28

Slide 28 text

SubscriptionWorker (Happy Path) defmodule Level.WebPush.SubscriptionWorker do @impl true def handle_cast({:send_web_push, payload}, state) do make_request(state, payload, 0) end defp make_request(state, payload, attempts) do payload |> adapter().make_request(state.subscription) |> handle_push_response(state, payload, attempts) end defp handle_push_response({:ok, %_{status_code: 201}}, state, _, _) do {:noreply, state} end end Do nothing special
 when the request responds 
 with a 201 status code.

Slide 29

Slide 29 text

SubscriptionWorker (Unsubscribed Path) defmodule Level.WebPush.SubscriptionWorker do @impl true def handle_cast({:send_web_push, payload}, state) do make_request(state, payload, 0) end defp make_request(state, payload, attempts) do payload |> adapter().make_request(state.subscription) |> handle_push_response(state, payload, attempts) end defp handle_push_response({:ok, %_{status_code: 404}}, state, _, _) do delete_subscription(state.digest) {:stop, :normal, state} end end Delete the subscription
 and shutdown the worker
 process

Slide 30

Slide 30 text

SubscriptionWorker 
 (Retry Path) defmodule Level.WebPush.SubscriptionWorker do def handle_info({:retry_web_push, payload, attempts}, state) do make_request(state, payload, attempts) end defp handle_push_response(_, state, payload, attempts) do if attempts < max_attempts() - 1, do: schedule_retry(payload, attempts + 1) {:noreply, state} end defp schedule_retry(payload, attempts) do Process.send_after(self(), {:retry_web_push, payload, attempts}, retry_timeout()) end end Schedule a retry in a configured
 interval if we have not exceeded
 max attempts

Slide 31

Slide 31 text

Now, this operation is 100% async 
 and satisfies our requirements # After someone posts a message... defp send_push_notifications(notifiable_ids, payload) do Enum.each(notifiable_ids, fn user_id -> WebPush.send_web_push(user_id, payload) end) end

Slide 32

Slide 32 text

:observer.start

Slide 33

Slide 33 text

Thanks! Full source available on GitHub: github.com/levelhq/level Derrick Reimer @derrickreimer level.app