jstimps
DGen - A distributed GenServer
I love GenServer. There are only 2 things stopping me from writing an entire app with them:
- Durability: The state is lost when the process goes down.
- High availability: The functionality is unavailable when the process goes down.
What if we could guarantee the GenServer never went down? Could we build a stateful application without a database?
Many Erlang and Elixir developers have this fantasy at some point in their journey. But how close can we actually get to the dream? I’d like to find out with DGen. This is v0.1.0 stuff, early days.
What DGen does
DGen provides a “distributed GenServer” (DGenServer). It’s meant to work just like a GenServer, but the message queue and the module state are durably stored in FoundationDB, with other backends possible.
Quick example
Our simplest example looks almost exactly like a GenServer.
defmodule Counter do
use DGenServer
def start(tenant), do: DGenServer.start(__MODULE__, [], tenant: tenant)
def increment(pid), do: DGenServer.cast(pid, :increment)
def value(pid), do: DGenServer.call(pid, :value)
@impl true
def init([]), do: {:ok, 0}
@impl true
def handle_call(:value, _from, state), do: {:reply, state, state}
@impl true
def handle_cast(:increment, state), do: {:noreply, state + 1}
end
However, the state lives on beyond the lifetime of the original Elixir process.
{:ok, pid} = Counter.start(tenant)
Counter.increment(pid)
Counter.increment(pid)
2 = Counter.value(pid)
# Restart the process
Process.exit(pid, :kill)
{:ok, pid2} = Counter.start(tenant)
2 = Counter.value(pid2) # State persisted!
The tenant argument is the only unique piece here. This tells DGenServer where to persist the queue and state in the datastore.
Beyond the basics
The simple example demonstrates the durable state. But the benefits inherited by a serializable distributed system are all here:
- Start one DGenServer per node, with state mutations processed exactly once, without rpc coordination or process registration.
- Separate where messages are pushed from where they are processed. Producers can run anywhere in the cluster, while consumers — the processes that mutate state — can be pinned to specific nodes or hardware.
- Perform side effects such as sending emails or performing network requests, with similar transactional guarantees.
Embracing side effects
The simplest side effect is one that happens after a state mutation. For example, a log message is a side effect! Your callback can optionally return a function to be executed after the state change is committed.
def handle_cast(:increment, state) do
action = &Logger.info("Counter is now #{&1}") # runs after commit
{:noreply, state + 1, [action]}
end
On the other hand, when a side effect needs to update the state, then we must lock out the queue from processing messages while our side effect executes outside of the transaction.
def handle_cast(:send_email, state) do
# executes inside the transaction
{:lock, state}
end
def handle_locked(:cast, :send_email, state) do
# executes outside of a transaction
Req.post(...)
{:noreply, %{state | sent: state.sent + 1}}
end
Under the hood - performance characteristics
Message Queue: The critical piece of DGenServer is the message queue. A caller must be able to push new messages onto the queue with serializability guarantees and high concurrency. This is achieved by using versionstamped keys, which exactly tie the underlying commit order with the key order.
Writes: The module state could be stored as a single term_to_binary blob. However, doing so would amplify the number of writes necessary for incremental changes. Instead, DGenServer adopts the design decisions of LiveView’s assigns and component lists. A module state consisting of a map with atom-keys or a list with string-id’d elements are optimized for incremental diffs on write. This means that standard Elixir structs are the preferred terms for the DGenServer module state.
Reads: And finally, DGenServer will cache the module state in memory to improve performance, with perfect cache invalidation. A single hot consumer will never have to read the full state, unless it restarts.
These components together allow for adequate performance. Still, you shouldn’t replace all your GenServers tomorrow. DGenServer should be reserved for stateful mutations that require durability and high availability guarantees, such that the performance tradeoff is acceptable.
Let it crash?
A DGenServer consumer can crash, just like any other Elixir process. But a key difference here is that the message queue is durable. If the crash is due to a poison message, a supervisor restart of a DGenServer consumer will simply try to process the same message again. DGen has yet to learn what this means in a production setting. Crash semantics themselves are well-defined, but system recovery is not automatic, like it is with GenServer. An operator may have to manually delete a poison message from the queue - an operation that is not possible with a standard GenServer.
Other backends?
DGen requires a strictly serializable key-value datastore with transactions, like FoundationDB, to provide the consistency guarantees to match the semantics of a GenServer. The first backend implementation available in DGen is FoundationDB, via erlfdb. But, we hope that other datastores providing a similar featureset can be implemented as alternative backends (such as Hobbes, Bedrock, etc.). I’m very open to flexing the current backend behaviour (:dgen_backend) to support other projects.
Links
- Hex
- HexDocs
- :dgen_server docs
- GitHub:
https://github.com/foundationdb-beam/dgen
Community input
So I’m continuing my obsession with exploring different kinds of state engines on the BEAM. I know there are like-minded folks around, so I’d love to hear thoughts and feedback about the approach. This project isn’t meant to be integrated into your production app today, but hoping it can evolve into something useful.
Trending in Announcing
Other Trending Topics
Categories:
Sub Categories:
Forums
Popular Tags
- #ecto
- #liveview
- #troubleshooting
- #learning-elixir
- #library
- #deployment
- #erlang
- #testing
- #genserver
- #mix
- #absinthe
- #remote-other
- #otp
- #plug
- #how-to-question
- #macros
- #postgres
- #elixirconf
- #channels
- #exunit
- #discussion
- #code-sync
- #podcasts
- #javascript
- #onsite
- #dialyzer
- #docker
- #authentication
- #umbrella
- #full-time-contract
- #podcasts-by-brainlid
- #ecto-query
- #ai
- #elixirconf-us
- #blog-post
- #elixir-ls
- #phoenix_html
- #iex
- #graphql
- #genstage
- #websockets
- #supervisor
- #advent-of-code
- #distillery
- #processes
- #api
- #forms
- #elixirconf-eu
- #metaprogramming
- #hex











Showing Posts 41 to 32- Show Best Posts
- Show All (oldest first)
- Show All (newest first)
Lucassifoni
I remembered this topic while stumbling on durable_server | Hex on Hex. This package might be of interest for some of you, even if just for discussion
mudasobwa
I don’t actually think that abusing the process lifetime to depend on some external action is a good idea in the first place, so LV is likely out of scope here.
Might be, but that would somewhat violate fail-fast paradigm, because we would need to carefully handle all the wrong paths instead of just implementing happy paths and allow it to fail on any incoming garbage, knowing that we are to resurrect with the correct state. The most vivid example is probably handling 3rd party calls.
derek-zhou
If I knew that, wouldn’t it be simpler to reject the wrong message and keep the GenServer humming?
You library might be useful for cases like Liveview, where the life span of the process is tied to the health of the socket. However, in the case of LV, the restart of the process is triggered by async user action, not a supervisor, so there could be race conditions between the serialization and de-serialization, thus corrupt the state for good?
mudasobwa
I think that not all the people whose opinion differs from yours are missing the point
If there was a rock solid solution allowing the developer to properly recover from anything, preserving a proper good state, it’d been incorporated into OTP, I’m 102% positive. Obsiously, there is not such a silver bullet.
It does not mean the developer cannot narrow their usecases to some less general surface. For some cases, like aforementioned “wrong message, correct state,” Peeper just does everything right. If I can ensure that my code does not corrupt the state under any circumstances, Peeper would have a lot of hassle prevented. Does it work for everyone under any circumstances?—Of course not. Small libraries are not usually cover each and every need of the depeloper all across the world, standard lib (OTP) does.
Asd
I think that you’re missing the point. Some time ago, I’ve seen your Peeper library which stores the state of some GenServer in ets and loads it back when the GenServer restarts. It got me thinking about it and I decided that this approach is just reinventing the bicycle
You are completely right that restoring the latest correct state after the crash is the best option. However, it is not the best assumption that this latest correct state is the state which the GenServer was in right before the crash or before it received the message which crashed the GenServer. And even more, there is no generic answer about how to decide which state (the GenServer is in at some moment) is correct and which is not.
That’s why GenServer has callbacks. Namely
init/1is the callback which executes some code which has to recreate some state which is correct for sure. This approach is generic, because it imposes no expectations and lets the developer decide which state is correct and which is not. If you have a bug ininit/1which returns the incorrect state, then your server will restart with an incorrect state, but that’s just a one callback, and its a callback, a function, which may return different results. That means, that GenServer will recover ifinit/1returns a correct state at least once.Rolling back to some of the previous states will impose the hard requirement onto the developer, who now needs to write the code in a way, that no
handle_*callback ever returns incorrect state. If it returns incorrect state once, you’re forever stuck with it. Otherwise every crash will restart the server with incorrect state, thus indefinitely persisting the incorrect state without any chance for automatic recovery. That means, that GenServer will recover only if all callbacks return correct state all the time.hauleth
Some stuff may be acceptable in some cases. The perfect example there will be telephone switcher (what a coincidence):
In case of telephone call if there is a bug in software, we want to reduce impact on the overall system. If that was one-off issue, then the callers will call again and “something broke” and everyone will go back to their lives. But if bug in single process (call) can cascade to other calls, then it is highly undesirable.
Similar thing with HTTP services, if there will be some issue on the line, then user will simply hit “refresh”. If that issue isn’t common, and was one-off, then no-one will notice that (browser may even refresh on its own in some cases).
There is a lot of systems (especially related to network), where simply restarting process (often even not needed to be done automatically) will be enough for a lot of error handling in case of one-off errors.
derek-zhou
Of course you need correctness testing. However, it is a chicken and egg problem in the real world: what do you do before you reached absolute correctness? Nothing? With OTP you can at least limp on and monitor the log file, find out what went wrong, add a test case and fix the bug for good. It gives you a path to correctness but not the correctness itself.
garrison
All I’m saying is that “turn it off and on again” is inadequate for maintaining availability in a persistent system because you will either persist the bugs or lose data, neither of which is acceptable. You need correctness testing.
hauleth
Of course, but that isn’t something that OTP provides for
gen_*modules for you. If you need such behaviour, then it is up to you to decide what “committed data” is and how tell user it was committed at all.garrison
Some programs have pesky correctness conditions like “do not lose committed data ever”.