Skip to content

Add a scope option to share a store between sessions - #14

Open
drozdzynski wants to merge 1 commit into
masterfrom
feat/shared-store-scope
Open

drozdzynski wants to merge 1 commit into
masterfrom
feat/shared-store-scope

Conversation

@drozdzynski

Copy link
Copy Markdown
Member

Storex has always started one store process per {session, store} pair, so two
connections to the same store module hold two independent states. Anything where
the state is the shared thing — a chat room, a collaborative document, a
dashboard several people watch — could not be expressed. The workaround was an
external source of truth plus Storex.mutate/3, which fans out over :pg and
re-runs mutation/5 once per session, so the callback had to be written to
survive running N times against N independent copies of the same state.

use Storex.Store now takes a :scope:

scope: :session         # the default, and what it has always done
scope: :global          # one process for the whole node
scope: {:key, "room"}   # one process per params["room"] value

A scope decides the identity a store process registers under.
Storex.Store.scope_id/3 derives it and Storex.Supervisor.name/2 takes that
scope id where it used to take a session. For :session the scope id is the
session, so stores that do not pass the option resolve to exactly the processes
and names they did before.

Under a shared scope several sessions call into one process. A mutation is
applied once, the diff is computed once, and it reaches every attached session:
the one that issued it as the reply to its own request, the rest as a push. The
push is a new {:storex_diff, ...} message, encoded by
Storex.Socket.diff_handle/3 and sent by both handlers. The fan-out is
generated away for :session scope rather than branched on at runtime, so the
existing path pays nothing for it.

What sharing a process changes about the callbacks

  • init/2 runs once, for the first session to attach. The params of every
    session that attaches later are ignored.
  • mutation/5 receives the session that issued the mutation rather than the
    one init/2 ran with, so a shared store can tell its clients apart. Under
    :session scope those are the same value.
  • terminate/3 runs when the last session detaches.
    Storex.Supervisor.remove_store/2 reference counts to make that true: the
    registry row is deleted first, then the process is stopped only if no session
    is left.

Attaching instead of starting

Storex.Supervisor.add_store/4 looks the scope up in Storex.StoreRegistry
before starting anything. Relying on {:error, {:already_started, _}} alone
would run the user's init/2, side effects included, and then throw the result
away. The lookup narrows that to a genuine race between two sessions attaching
at the same moment, which the :already_started branch still covers. The key an
attaching session reports comes from the process itself, through a new
:get_key call, because the registry row of the session that started it may not
be written yet.

Broadcast dispatch

Storex.PG now dispatches a broadcast once per store process instead of once
per registry row. Without that a shared store would run a Storex.mutate/3
mutation once per attached session against the state they all share. Nothing
changes for :session scoped stores, where every row already has a process of
its own.

The registry table is unchanged

A shared store is several rows pointing at one store_pid, so none of the match
patterns in registry.ex, pg.ex and the handlers had to move.
Storex.Registry.store_sessions/1 is the one new read, and it backs both the
fan-out and the reference count.

The frontend needs no change

The client already falls through to its mutation listeners for a mutation
frame whose request matches no pending promise, and filters on store and
session; the pushed frame carries the receiving session's own id, so it matches.
No rebuilt bundles in priv/static.

Limitations

:global is per node, not per cluster. Storex.StoreRegistry is node-local and
Storex.mutate/3 broadcasts to every node, so a :global store takes a mutation
once per node, against that node's own copy of the state. Cluster-wide
singletons need :global, Horde or an owning-node rule, and are not attempted
here.

Storex.Store.scope/1 checks Code.ensure_loaded?/1 before
function_exported?/3. Asking the latter alone answers false for a module that
is not loaded yet — the same trap __terminate__/4 fell into — and every store
then silently degrades to :session scope depending on what the code server
happens to hold.

Tests

test/storex/scope_test.exs covers scope resolution, sharing, isolation between
keys, the diff fan-out, the reference count and the Storex.mutate/3 dedupe.
Two end-to-end tests in test/storex/handler/plug_test.exs drive two real
sockets through a shared store. 136 non-browser tests pass on three seeds;
mix compile --warnings-as-errors and mix format --check-formatted are clean.
The 21 browser tests still need chromedriver and did not run here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant