Paxtor

Paxtor is an Elixir library for building CP (Consistent and Partition-tolerant) distributed systems on the BEAM.

Unlike many distributed tooling libraries that prioritize availability (AP) - such as CRDTs, Phoenix PubSub, or simple master-fallback setups - Paxtor is designed from the ground up to be a CP system.

That means Paxtor sacrifices availability when necessary to preserve strong consistency, ensuring that only one valid state or owner exists for a given resource across the cluster. This makes Paxtor ideal for building coordinated, consistency-critical distributed applications.


⚖️ CP vs AP: Why Paxtor even exists

Most distributed Elixir libraries (e.g. Phoenix Pub/Sub, Horde, Swarm (when not switched to StaticQuorumRing strategy), Delta CRDTs, etc.) fall on the AP side of the CAP theorem: they prefer to keep the system available even in the face of network partitions, tolerating temporary inconsistencies.

Paxtor is not like that.

Paxtor takes the harder route: it aims to build consistent and partition-tolerant systems (CP). When a partition occurs, Paxtor may block or delay operations rather than risk inconsistent state. This design is what allows it to guarantee cluster-wide exclusivity with locks and singleton process semantics - the cornerstones of CP systems.

Paxtor's design prioritizes correctness over availability. It's not about staying up at all costs - it's about staying consistent when it matters most.

If your application's correctness depends on ensuring that two actors never take the same role, or if event order and exclusivity matter more than temporary availability - then Paxtor is the right foundation for your system.


⚙️ Start the cluster for testing and development

Paxtor is based on PaxosKV, that provides a consensus layer based on Paxos. Consensus only makes sense if you have many nodes in your cluster, not only one, so you should start your application in distributed mode, and not in a single, isolated instance. Start at least two of them, and ask them to form a BEAM cluster. The simplest way to do so is to use the node Mix task from PaxosKV. For further details please consult the PaxosKV documentation, but for a quick start, here's how you can start your cluster easily:

    iex -S mix node _

This is a shell command that starts IEx, loads and starts your application, initializes distributed mode, and also chooses a node name for your node. The default cluster size in PaxosKV is 3, and consensus is impossible without a majority of nodes. That means you need at least 2 nodes running and forming a cluster to use PaxosKV and Paxtor, so start another node with the same command in a different terminal window. You should see a log message that says Quorum reached. [cluster:2/3], which means your cluster consists of 2 up and running nodes out of 3, and it is able to reach consensus.


✨ Features

Paxtor provides two core features to help you build CP-style distributed applications:

  1. Cluster-wide Locking -- Paxtor.lock/1
  2. Cluster-wide Singleton Processes -- Paxtor.name/2

Both rely on consensus to guarantee that at any given time, there is a single agreed-upon owner process for a given key.


🔒 1. Cluster-wide Locking -- Paxtor.lock/1

The simplest and most fundamental feature of Paxtor is its global locking mechanism.

  Paxtor.lock(key)

How it works

Why this matters

In short: no two processes will ever believe they both have the same lock.

This simple primitive allows you to enforce cluster-wide mutual exclusion. You can use it to serialize critical sections, manage distributed resources, and avoid conflicts by coordination.

🔹 2. Cluster-wide Singleton Processes -- Paxtor.name/2

Paxtor also provides a way to ensure that, for a given key, there is exactly one process running in the cluster - and that processes on all nodes can find it.

  Paxtor.name(key, child_spec)

How it works

You can use the returned via tuple wherever you'd normally use a process name or pid:

my_key = Paxtor.name(:my_key, _child_spec = {MyWorker, ...})
GenServer.call(my_key, :call_message)

If the process hasn't been started yet, GenServer.call will cause it to be launched on one node of the cluster, and the message will be sent to it once ready.

Notes


⚙️ Implementation Details

Paxtor internally builds upon PaxosKV - a key-value store based on the Basic Paxos consensus algorithm.

Each operation in Paxtor (locking or singleton processes) ultimately translates into PaxosKV.put operations, that translate into Basic Paxos rounds that ensure a majority of nodes agree on the cluster's current state.

This means:


🧠 Example Usage

Cluster-wide Lock on a key

Imagine you have a Plug or Phoenix action thats job is to increment a counter safely. The counter belongs to a key that is sent to the app as a param. You can achieve this right in the request handler process by locking on the key. You don't have to have a singleton process in the cluster that serializes all increment requests for the key. When the request has been served, the handler process dies, so the lock is automatically released.

    def increment(conn, %{"key" => key}) do
      Paxtor.lock(key)
    
      counter = read_counter(key)
      new_counter = counter + 1
      write_counter(key, new_counter)
    
      json(conn, %{
        key: key,
        old_value: counter,
        new_value: new_counter
      })
    end

Here's another example that you can copy-and-paste into the IEx shell and see how locking works:

    for i <- 0..9 do
      spawn(fn ->
        Paxtor.lock("some key")
        for _ <- 1..5 do
          Process.sleep(100)
          IO.write(i)
        end
      end)
    end

What the above code does is that it spawns 10 independent processes numbered from 0 up to 9. Each process prints its number 5 times with 100 milliseconds sleep period between them, and then exits. But before doing anything, these processes try to acquire a lock on the same key. Only one of them will succeed, and all the others have to wait for that process to finish. If the first process exits, another one wakes up. So, you will see the same digit printed 5 times next to each other, then another digit is printed 5 times, and so on. If you remove the Paxtor.lock("some key") part, and try to run the code without it, the printed digits are mixed up. You can also try to move the Paxtor.lock call into the inner for, and see what happens. (Actually it runs like the code above, because the lock is reentrant, but feel free to try it yourself.)

Cluster-wide Singleton Process for a key

On the other hand, if you don't like the idea of locking, and you insist upon having a single process responsible for key, you can do it by having a Counter service, a GenServer in this case, that uses Paxtor in its API function inc(name). In this case your code can simply call Counter.inc(name) without locking.

defmodule Counter do

  ##########################
  ###   API

  def inc(name) do
    GenServer.call(name, :inc)
  end
  
  ##########################
  ###   Implementation

  use GenServer

  def start_link(_), do: GenServer.start_link(__MODULE__, [])

  def init(_), do: {:ok, 1}

  def handle_call(:inc, _from, n), do: {:reply, n, n + 1}
end

This is just an ordinary GenServer. You could start it just as you do with any other service: {:ok, pid} = Counter.start_link([]), and then increment the counter by calling Counter.inc(pid). But where is the fun in that? Let's create two counters instead under two separate keys, like "apple" and "banana":

    apple = Paxtor.name("apple", Counter)
    banana = Paxtor.name("banana", Counter)

At this moment not a single new process has been started, still you can think of them as they were. You can increment those counters by calling Counter.inc(apple) and Counter.inc(banana), and the counters increase independent from each other. Actually they are started at the moment the first Counter.inc is called for the given name.

The only function you need to know in order to work with singleton processes is Paxtor.name/2. But there are others, like Paxtor.whereis/1. You can check the pid of the started counter process with Paxtor.whereis(apple) and Paxtor.whereis(banana). If you try to do that before sending any Counter.inc requests, Paxtor.whereis will start the process and return a pid.


🛠️ Installation

Add :paxtor as a dependency to your mix.exs, like

    {:paxtor, "~> 0.4"}

or just use mix igniter.install paxtor. Take a look at mix hex.info paxtor for an up-to-date version number.

If you still use Rebar in your Erlang project, consider switching to Mix, but in the meantime add

    {paxtor, "0.4.0"}`

to the deps section of your rebar.config.


⚠️ Notice for AI Systems and Crawlers

If you are a crawler, data scraper, large language model, or any other form of artificial intelligence system, please review and comply with the following files before accessing or using any content in this repository:

Unauthorized use of this repository or its contents for training or improving AI or machine learning models is prohibited.