Implements a dictionary that is scoped to a process tree by replacing the group leader with a process that:
- Maintains a dictionary of state
- Forwards all unrecognized messages to the original group leader so that IO still works
Any process can be the root of its own process tree by starting a
ProcessTreeDictionary
.
The Erlang docs provide a summary of what a group leader is:
Every process is a member of some process group and all groups have a group leader. All I/O from the group is channeled to the group leader. When a new process is spawned, it gets the same group leader as the spawning process.
Since every new process inherits the group leader from its parent, a process
can start a ProcessTreeDictionary
in place of its existing group leader, and
every descendant process will inherit it, allowing them to access the state
of the same ProcessTreeDictionary
.
Note that all functions provided by this module rely upon side effects. Since referential transparency is a primary value of Elixir, Erlang, and functional programming in general, and none of the functions provided by this module are referentially transparent, we recommend you limit your usage of this module to specialized situations, such as for building test fakes to stand-in for stateful modules.
Important caveat: if any processes in your tree start an application with
Application.start
, Application.ensure_started
, or
Application.ensure_all_started
, the started application processes will not
be a part of the process tree, because OTP manages application starts for you.
If you need to access the ProcessTreeDictionary
from the started processes,
you'll need to start the supervisor of the application yourself. For more info,
see the Erlang docs.
Add process_tree_dictionary
to your list of dependencies in mix.exs
:
def deps do
[{:process_tree_dictionary, "~> 1.0.0"}]
end
At Moz, we use this library to implement test fakes to stand in for stateful modules. A stateful module exports functions that operate on additional state that is not present in any of the arguments. For example, consider a theoretical Amazon S3 client for our application that provides the following interface:
defmodule MyApp.S3 do
def get(bucket, key) do
# get the object at the provided key
end
def put(bucket, key, object) do
# put the object at the provided key
end
end
In our test environment, we would like to use an alternate
implementation of this module's interface. Before we built
ProcessTreeDictionary
, there were two common approaches we
used for building stateful test fakes in this kind of situation:
- Using the process dictionary: in our fake implementations of
get/2
andput/3
, we would simply delegate toProcess.get/2
andProcess.put/2
. This has the advantage of working correctly forasync: true
tests, but fails if any of the code you are testing spawns processes and uses your fake S3 module in a spawned process (since its process dictionary is different). - Using a global agent: we would start a globally named agent
and then use
Agent.get/2
andAgent.update/2
to manage the state. This has the advantage of working correctly for tests that use the fake S3 module in a spawned process, but is not compatible withasync: true
tests. Even worse, if you forget to changeasync: true
toasync: false
, it can lead to flickering tests.
ProcessTreeDictionary
provides an alternate approach that does not
suffer from these problems:
- Each test defines its own isolated process tree, which allows you
to safely use
ProcessTreeDictionary
inasync: true
tests. - Since spawned processes belong to the same process tree as their parent process, tests that spawn processes are supported.
Here's what a fake implementation of our S3 client looks like using
ProcessTreeDictionary
:
defmodule MyApp.S3.TestFake do
def get(bucket, key) do
key_path(bucket, key)
|> ProcessTreeDictionary.get(:not_found)
|> case do
:not_found -> {:error, :not_found}
object -> {:ok, object}
end
end
def put(bucket, key, object) do
# Start the ProcessTreeDictionary if it's not already started
# so we can write to it.
ProcessTreeDictionary.ensure_started()
key_path(bucket, key)
|> ProcessTreeDictionary.put(object)
end
defp key_path(bucket, key) do
# Scope our dictionary keys using our module name to prevent
# key conflicts with other uses of ProcessTreeDictionary.
[__MODULE__, bucket, key]
end
end