Normandy.Coordination.StatefulContext
(normandy v2.2.0)
View Source
GenServer-backed shared context for multi-agent systems.
StatefulContext provides a concurrent, process-based key-value store using GenServer with ETS backing.
Features
- Concurrent access from multiple processes
- Reads from ETS: each read makes one short
GenServer.callfor the table, then looks the key up in ETS. The context process does only short work (an insert, a delete, a notification), so a read waits behind at most that;update/4runs its function in the caller, never in the context process - Atomic updates: the table is
:protected, so only the context process writes it;update/4commits with a compare-and-set (see there) - Optional pub/sub notifications for changes
- Process supervision compatible
Example
# Start context process
{:ok, pid} = StatefulContext.start_link(name: :my_context)
# Store and retrieve data
:ok = StatefulContext.put(pid, "key", "value")
{:ok, "value"} = StatefulContext.fetch(pid, "key")
# Use namespaced keys
:ok = StatefulContext.put(pid, {"agent_1", "status"}, "active")
# Subscribe to changes
:ok = StatefulContext.subscribe(pid, self())
Summary
Functions
Returns a specification to start this module under a supervisor.
Deletes a key from the context.
Looks up key: {:ok, value} or :error.
Retrieves a value with a default if not found.
Returns the ETS table, for reads (:ets.lookup/2, :ets.select/2, ...).
Checks if a key exists in the context.
Returns all keys in the context.
Stores a value in the context.
Starts a StatefulContext GenServer.
Subscribes a process to change notifications.
Returns all data in the context as a map.
Unsubscribes a process from change notifications.
Updates a value using a function.
Types
Functions
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec delete(GenServer.server(), key()) :: :ok
Deletes a key from the context.
Example
:ok = StatefulContext.delete(pid, "key")
@spec fetch(GenServer.server(), key()) :: {:ok, term()} | :error
Looks up key: {:ok, value} or :error.
Asks the context process for its table (one short GenServer.call), then reads
the key from ETS.
Example
{:ok, value} = StatefulContext.fetch(pid, "key")
:error = StatefulContext.fetch(pid, "missing")
@spec get(GenServer.server(), key(), term()) :: term()
Retrieves a value with a default if not found.
Example
value = StatefulContext.get(pid, "key", "default")
@spec get_table(GenServer.server()) :: :ets.tid()
Returns the ETS table, for reads (:ets.lookup/2, :ets.select/2, ...).
The table is :protected: only the context process writes it, so a write from
any other process raises ArgumentError. Write through put/3, update/4 and
delete/2.
Example
table = StatefulContext.get_table(pid)
:ets.lookup(table, "key")
@spec has_key?(GenServer.server(), key()) :: boolean()
Checks if a key exists in the context.
Example
true = StatefulContext.has_key?(pid, "key")
@spec keys(GenServer.server()) :: [String.t()]
Returns all keys in the context.
Example
keys = StatefulContext.keys(pid)
#=> ["key1", "agent_1:status"]
@spec put(GenServer.server(), key(), term()) :: :ok
Stores a value in the context.
Writes go through the context process for consistency; reads look the key up in ETS (after one short call for the table).
Example
:ok = StatefulContext.put(pid, "key", "value")
:ok = StatefulContext.put(pid, {"agent_1", "data"}, %{result: 42})
@spec start_link(keyword()) :: GenServer.on_start()
Starts a StatefulContext GenServer.
Options
:name(term/0) - AGenServer.name/0for the context.:notify_on_change(boolean/0) - Send subscribers a message on every change. The default value istrue.
Example
{:ok, pid} = StatefulContext.start_link(name: :shared_context)
@spec subscribe(GenServer.server(), subscriber()) :: :ok
Subscribes a process to change notifications.
The subscriber will receive messages of the form:
{:context_changed, key, old_value, new_value}
Example
:ok = StatefulContext.subscribe(pid, self())
@spec to_map(GenServer.server()) :: map()
Returns all data in the context as a map.
Example
data = StatefulContext.to_map(pid)
#=> %{"key1" => "value1", "agent_1:status" => "active"}
@spec unsubscribe(GenServer.server(), subscriber()) :: :ok
Unsubscribes a process from change notifications.
Example
:ok = StatefulContext.unsubscribe(pid, self())
@spec update(GenServer.server(), key(), term(), (term() -> term())) :: :ok
Updates a value using a function.
If the key doesn't exist, uses the initial value.
fun runs in the calling process, not in the context process, so a slow fun
blocks no other caller and a fun that raises raises in the caller. The result
is committed only if the key still holds the value fun was given; otherwise
fun runs again on the new value. So fun can run more than once and must have
no side effects. After 10 lost races (a key written more often than
fun takes) fun runs once inside the context process, serialized with every other
call, so every update finishes. There a fun that raises crashes the context, and a fun
that calls this same context (put/3, fetch/2, update/4, get_table/1, ...) exits
with :calling_self and crashes it too.
The caller reads the key from ETS directly (get_table/1), so update/4 works only
against a context on the caller's node.
Example
:ok = StatefulContext.update(pid, "counter", 0, fn count -> count + 1 end)