Sagents.ViewerPresence behaviour (Sagents v0.14.0)
Copy MarkdownBookkeeping for the viewer-presence entries a subscriber process holds.
Sagents.Presence.track/4 tracks self(), and Phoenix.Presence reaps an
entry when the tracked process dies. A LiveView (or any other subscriber
process) outlives the conversations it is showing, so process exit is a
backstop rather than the mechanism: a process that stops viewing a
conversation has to hand that entry back explicitly.
That matters because Sagents.AgentServer reads the viewer list to decide an
idle agent may not shut down yet. An entry left on a conversation nobody is
looking at any more pins that agent for the full inactivity timeout.
A process holds a set, not a slot
One process can view any number of conversations at once: a split view, a
dashboard with a row per running agent, a panel of note threads each hosting
its own forked conversation. So what this module threads through is a map of
everything Phoenix.Presence holds for the calling process, and hosts keep it
beside their other session state:
state = %{tracked_viewers: %{}}
held = ViewerPresence.track(state.tracked_viewers, MyApp.Coordinator, 42, user_id)
held = ViewerPresence.track(held, MyApp.Coordinator, 43, user_id)
# Done with one of them.
held = ViewerPresence.untrack(held, MyApp.Coordinator, 42)
# Done with all of them: a reset, a closed panel, a navigation away.
held = ViewerPresence.untrack_all(held, MyApp.Coordinator)The viewer id is recorded per conversation rather than once for the process. A re-auth partway through a session changes what the host would compute now, but each entry has to be released under the id it was actually taken with.
sync/3 is the shape that cannot leak
track/4 and untrack/3 are incremental: a host that opens a conversation
and forgets to close it accumulates an entry, and no library can know when one
of its panels went away.
sync/3 inverts that. The host declares the set it is currently viewing and
this module diffs it against what is held, releasing what is gone and taking
what is new. Re-declaring the same set does nothing at all, so it is both
idempotent and safe to call from a render path:
# Whatever this socket was viewing, it is viewing exactly these now.
held = ViewerPresence.sync(held, MyApp.Coordinator, %{42 => user_id, 43 => user_id})Prefer it wherever the viewed set is derivable from state the host already keeps. Idempotence is not just tidiness here: an untrack followed by a track of the same conversation is a leave broadcast, and an idle agent that acts on it schedules the very shutdown the entry exists to prevent.
The record means what Presence holds
Never what the host intended. A track that fails records nothing, because a
release aimed at an entry this process does not hold reports :ok while
leaving the real entry in place. Phoenix.Tracker answers :ok to an untrack
of a key it never tracked, so a record that drifts from reality fails silently
here and surfaces as an agent that would not shut down somewhere else
entirely.
Summary
Types
A conversation being viewed, in the host's terms.
Every entry Phoenix.Presence holds for the calling process, by conversation.
Who is viewing, in the host's terms: a user id, a session token, a guest id.
Callbacks
Track viewer_id as a viewer of conversation_id.
Stop tracking viewer_id as a viewer of conversation_id.
Functions
Make what this process holds match desired exactly.
Add conversation_id to what this process is viewing.
Remove conversation_id from what this process is viewing.
Release every entry this process holds.
Types
@type conversation_id() :: term()
A conversation being viewed, in the host's terms.
@type held() :: %{optional(conversation_id()) => viewer_id()}
Every entry Phoenix.Presence holds for the calling process, by conversation.
@type viewer_id() :: term()
Who is viewing, in the host's terms: a user id, a session token, a guest id.
Callbacks
@callback track_conversation_viewer(conversation_id(), viewer_id(), metadata :: map()) :: {:ok, reference() | binary()} | {:error, term()}
Track viewer_id as a viewer of conversation_id.
Implemented by every generated coordinator over its own Phoenix.Presence
module and conversation topic.
@callback untrack_conversation_viewer(conversation_id(), viewer_id()) :: :ok
Stop tracking viewer_id as a viewer of conversation_id.
Functions
Make what this process holds match desired exactly.
Releases the conversations that are no longer in the set, takes the ones that are new, and leaves the unchanged ones untouched. Declaring the same set twice does nothing, which is what makes this the shape that cannot leak: the host states what it is viewing rather than remembering to undo what it did.
@spec track(held(), module(), conversation_id(), viewer_id()) :: held()
Add conversation_id to what this process is viewing.
Holding it already under the same viewer is a no-op, so this is safe to call on a path that re-enters a conversation already open. Holding it under a different viewer releases that entry first, since Presence keys by viewer and the old key would otherwise be left behind.
A nil viewer_id tracks nothing and leaves any existing entry alone: a host
that cannot name its viewer right now has not stopped viewing.
@spec untrack(held(), module(), conversation_id()) :: held()
Remove conversation_id from what this process is viewing.
Releases the entry under the viewer id it was taken with, which is why that id
is recorded rather than recomputed. Holding nothing for conversation_id is a
no-op.
Release every entry this process holds.
For a host that stops viewing everything at once: a reset, a closed panel, a navigation away. Always returns an empty map.