# Rheo Backends: ETS, Mnesia, Mongo, SQLite, PostgreSQL, Redis

```elixir
Mix.install(
  [
    # From this repo (path). For HexDocs / standalone Livebook use {:rheo, "~> 1.0"} instead:
    {:rheo, path: Path.join(__DIR__, "..")},
    # {:rheo, "~> 1.0"},
    {:mongodb_driver, "~> 1.5"},
    {:redix, "~> 1.5"},
    {:ecto_sqlite3, "~> 0.17"},
    {:postgrex, "~> 0.19"},
    {:kino, "~> 0.14"}
  ],
  config: [
    rheo: [
      start_on_application: false,
      clock: Rheo.Clock.System,
      default_lease_ms: 5_000,
      default_max_attempts: 3
    ]
  ]
)
```

## Intro

Same `Rheo.append` / `fetch` / `ack` / `query` / `Consumer` API. Backends differ
in **durability**, **distribution**, and **mechanisms** — not in how you write
handlers.

**Multi-node** (several BEAM nodes, same group) needs a shared store: Redis or
PostgreSQL yes; ETS / SQLite / Mnesia v0.11 no. See the
[building your own backend](https://hexdocs.pm/rheo/building-your-own-backend.html#multi-node-several-beam-nodes)
guide.

This notebook:

1. Compares **capabilities** side by side
2. Runs a tiny happy path on **ETS**, **Mnesia**, **Mongo**, **SQLite**, **Postgres**, and
   **Redis** (optional where services are required)
3. Proves two Rheo instances in one BEAM (**ETS + SQLite**) do not bleed data
4. Runs a light **throughput comparison** across whichever backends are up

Sibling notebooks: [Quickstart](quickstart.livemd) · [Concepts](concepts.livemd) ·
[Pipelines](pipelines.livemd) · [Index](rheo_demo.livemd)

---

## Capability snapshot

**Guarantees** are what Rheo promises through the backend. **Mechanisms** are
how storage implements or optimizes those promises. Application code should
branch on guarantees sparingly — never to weaken fencing.

```elixir
cap_row = fn name, caps ->
  g = caps.guarantees
  m = caps.mechanisms

  %{
    backend: name,
    durable: g[:durable],
    distributed: g[:distributed],
    partitions: g[:partitions],
    contiguous_frontier: g[:contiguous_frontier],
    secondary_indexes: g[:secondary_indexes],
    notifications: m[:notifications],
    atomic_cas: m[:atomic_compare_and_set]
  }
end

rows =
  [{"ETS", Rheo.Backend.ETS.capabilities()}, {"Mnesia", Rheo.Backend.Mnesia.capabilities()}]
  |> then(fn acc ->
    if Code.ensure_loaded?(Rheo.Backend.Mongo) do
      acc ++ [{"Mongo", Rheo.Backend.Mongo.capabilities()}]
    else
      acc
    end
  end)
  |> then(fn acc ->
    if Code.ensure_loaded?(Rheo.Backend.Redis) do
      acc ++ [{"Redis", Rheo.Backend.Redis.capabilities()}]
    else
      acc
    end
  end)
  |> then(fn acc ->
    if Code.ensure_loaded?(Rheo.Backend.Ecto) do
      acc ++
        [
          {"Ecto SQLite", Rheo.Backend.Ecto.capabilities(:sqlite)},
          {"Ecto Postgres", Rheo.Backend.Ecto.capabilities(:postgres)}
        ]
    else
      acc
    end
  end)
  |> Enum.map(fn {name, caps} -> cap_row.(name, caps) end)

Kino.DataTable.new(rows)
```

| Backend        | Typical home                                       |
| -------------- | -------------------------------------------------- |
| **ETS**        | Tests, Livebook, ephemeral in-process apps         |
| **Mnesia**     | Single-node durable BEAM store (no Docker / DB)    |
| **Mongo**      | Searchable durable log; easy multi-document claims |
| **Redis**      | Native Streams consumer groups; Model C receipts   |
| **SQLite**     | Single-node durable file (appliances, local demos) |
| **PostgreSQL** | Multi-node SQL with `FOR UPDATE SKIP LOCKED`       |

---

## 1. ETS — always available

Named instance `:ets_demo` so we can run several Rheos later without fighting
over the default `Rheo` name.

```elixir
stop_rheo = fn name  ->
  case Process.whereis(name) do
    nil -> :ok
    pid -> GenServer.stop(pid)
  end
end

stop_rheo.(Rheo)
{:ok, _} = Rheo.start_link(name: :ets_demo, backend: Rheo.Backend.ETS)
:ok = Rheo.ensure_indexes(rheo: :ets_demo)

s = "ets-demo"
:ok = Rheo.create_stream(s, rheo: :ets_demo)
:ok = Rheo.create_group(s, "workers", rheo: :ets_demo)
{:ok, ev} = Rheo.append(s, %{type: "ping", n: 1}, rheo: :ets_demo)
{:ok, [lease]} = Rheo.fetch(s, "workers", limit: 1, rheo: :ets_demo)
:ok = Rheo.ack(lease, rheo: :ets_demo)

%{
  event_id: ev.id,
  receipt: lease.receipt,
  lag: elem(Rheo.lag(s, "workers", rheo: :ets_demo), 1).lag
}
```

Note `receipt`: on ETS it equals the fencing `lease_id`. Native-stream backends
will put a different opaque token there without changing your handler.

---

## 2. Mnesia — durable, no Docker

OTP `:mnesia` with single-node `disc_copies` (v0.11). Same ETS-shaped state
machine, but data survives GenServer restart when `:dir` is preserved. No
optional Hex dep — `:mnesia` ships with OTP.

```elixir
mnesia_dir =
  Path.join(System.tmp_dir!(), "rheo_livebook_mnesia_#{System.unique_integer([:positive])}")

stop_rheo.(:mnesia_demo)

{:ok, _} =
  Rheo.start_link(
    name: :mnesia_demo,
    backend: {Rheo.Backend.Mnesia, name: MnesiaLivebook, dir: mnesia_dir}
  )

:ok = Rheo.ensure_indexes(rheo: :mnesia_demo)

s = "mnesia-demo"
:ok = Rheo.create_stream(s, partition_count: 2, rheo: :mnesia_demo)
:ok = Rheo.create_group(s, "workers", rheo: :mnesia_demo)
{:ok, ev} = Rheo.append(s, %{type: "ping", venue: "mnesia"}, rheo: :mnesia_demo)
{:ok, [lease]} = Rheo.fetch(s, "workers", limit: 1, rheo: :mnesia_demo)
:ok = Rheo.ack(lease, rheo: :mnesia_demo)

# Restart the Rheo handle; keep the same Mnesia directory.
stop_rheo.(:mnesia_demo)

{:ok, _} =
  Rheo.start_link(
    name: :mnesia_demo,
    backend: {Rheo.Backend.Mnesia, name: MnesiaLivebook, dir: mnesia_dir}
  )

:ok = Rheo.ensure_indexes(rheo: :mnesia_demo)
{:ok, after_restart} = Rheo.query(s, type: "ping", limit: 5, rheo: :mnesia_demo)

%{
  durable?: Rheo.Backend.Mnesia.capabilities().guarantees.durable,
  distributed?: Rheo.Backend.Mnesia.capabilities().guarantees.distributed,
  event_id: ev.id,
  survived_restart?: ev.id in Enum.map(after_restart, & &1.id),
  dir: mnesia_dir
}
```

`survived_restart?` should be `true`. Prefer Redis / Postgres when several BEAM
nodes must claim the same group — Mnesia v0.11 is single-node only.

---

## 3. MongoDB — optional

Needs a running MongoDB. From the repo root:

```bash
docker compose up -d
```

Override the URL with `RHEO_MONGO_URL` if needed. If Mongo is down, this cell
returns a structured failure instead of crashing the notebook.

```elixir
mongo_url = System.get_env("RHEO_MONGO_URL", "mongodb://localhost:27017/rheo_livebook")

mongo =
  cond do
    not Code.ensure_loaded?(Rheo.Backend.Mongo) ->
      %{ok?: false, hint: "add {:mongodb_driver, \"~> 1.5\"} to Mix.install and re-evaluate"}

    true ->
      case Rheo.start_link(
             name: :mongo_demo,
             backend: {Rheo.Backend.Mongo, url: mongo_url, name: MongoLivebook}
           ) do
        {:ok, _} ->
          :ok = Rheo.ensure_indexes(rheo: :mongo_demo)
          s = "mongo-demo-#{System.unique_integer([:positive])}"
          :ok = Rheo.create_stream(s, partition_count: 2, rheo: :mongo_demo)
          :ok = Rheo.create_group(s, "workers", rheo: :mongo_demo)
          {:ok, ev} = Rheo.append(s, %{type: "ping", venue: "mongo"}, rheo: :mongo_demo)
          {:ok, [lease]} = Rheo.fetch(s, "workers", limit: 1, rheo: :mongo_demo)
          :ok = Rheo.ack(lease, rheo: :mongo_demo)
          {:ok, found} = Rheo.query(s, type: "ping", limit: 5, rheo: :mongo_demo)

          %{
            ok?: true,
            url: mongo_url,
            event_id: ev.id,
            queried: length(found),
            capabilities: Rheo.Backend.Mongo.capabilities().guarantees
          }

        {:error, reason} ->
          %{ok?: false, reason: inspect(reason), hint: "docker compose up -d"}
      end
  end
```

Mongo shines when you want secondary indexes and rich queries on the same log
you consume from.

---

## 4. Redis Streams — optional

Needs Redis **6.2+**. From the repo: `docker compose up -d redis`.

```elixir
redis_url = System.get_env("RHEO_REDIS_URL", "redis://localhost:6379")

redis =
  cond do
    not Code.ensure_loaded?(Rheo.Backend.Redis) ->
      %{ok?: false, hint: "add {:redix, \"~> 1.5\"} to Mix.install and re-evaluate"}

    true ->
      case Rheo.start_link(
             name: :redis_demo,
             backend: {Rheo.Backend.Redis, name: RedisLivebook, url: redis_url}
           ) do
        {:ok, _} ->
          :ok = Rheo.ensure_indexes(rheo: :redis_demo)
          s = "redis-demo-#{System.unique_integer([:positive])}"
          :ok = Rheo.create_stream(s, partition_count: 2, rheo: :redis_demo)
          :ok = Rheo.create_group(s, "workers", rheo: :redis_demo)
          {:ok, ev} = Rheo.append(s, %{type: "ping", venue: "redis"}, rheo: :redis_demo)
          {:ok, [lease]} = Rheo.fetch(s, "workers", limit: 1, rheo: :redis_demo)
          :ok = Rheo.ack(lease, rheo: :redis_demo)

          %{
            ok?: true,
            url: redis_url,
            event_id: ev.id,
            receipt: lease.receipt,
            receipt_differs_from_lease_id?: lease.receipt != lease.lease_id,
            capabilities: Rheo.Backend.Redis.capabilities().guarantees
          }

        {:error, reason} ->
          %{ok?: false, reason: inspect(reason), hint: "docker compose up -d redis"}
      end
  end
```

On Redis, `receipt` is the stream entry id — different from `lease_id` (fencing
token). Handlers still ignore receipt contents.

---

## 5. SQLite via Ecto — durable, no Docker

Rheo borrows a **host-owned** `Ecto.Repo`. Your app supervises the Repo; Rheo
does not own the connection pool. `Rheo.ensure_indexes/1` creates the Rheo
tables idempotently (or use `mix rheo.ecto.gen_migration` in real apps).

```elixir
defmodule Backends.SqliteRepo do
  use Ecto.Repo, otp_app: :rheo, adapter: Ecto.Adapters.SQLite3
end

db = Path.join(System.tmp_dir!(), "rheo_livebook_#{System.unique_integer([:positive])}.db")

case Process.whereis(Backends.SqliteRepo) do
  nil -> :ok
  pid -> GenServer.stop(pid)
end

stop_rheo.(Rheo)

{:ok, _} =
  Backends.SqliteRepo.start_link(
    database: db,
    pool_size: 5,
    name: Backends.SqliteRepo
  )

{:ok, _} =
  Rheo.start_link(
    name: :sqlite_demo,
    backend: {Rheo.Backend.Ecto, repo: Backends.SqliteRepo}
  )

:ok = Rheo.ensure_indexes(rheo: :sqlite_demo)

s = "sqlite-demo"
:ok = Rheo.create_stream(s, partition_count: 2, rheo: :sqlite_demo)
:ok = Rheo.create_group(s, "risk", rheo: :sqlite_demo)

{:ok, ev} =
  Rheo.append(s, %{type: "curve_update", key: "EUR-1", price: 1.0}, rheo: :sqlite_demo)

{:ok, [lease]} = Rheo.fetch(s, "risk", limit: 1, rheo: :sqlite_demo)
:ok = Rheo.ack(lease, rheo: :sqlite_demo)
{:ok, lag} = Rheo.lag(s, "risk", rheo: :sqlite_demo)

%{
  guarantees: Rheo.Backend.Ecto.capabilities(:sqlite).guarantees,
  event_partition: ev.partition,
  frontier: lag.partitions[ev.partition].frontier,
  db: db
}
```

SQLite declares `distributed: false` — one writer node. Ideal for appliances
and local durable demos; prefer Postgres when multiple BEAM nodes claim the
same group.

---

## 6. PostgreSQL via Ecto — optional

Set an Ecto URL, for example:

```bash
export RHEO_POSTGRES_URL=ecto://postgres:postgres@localhost:5432/rheo_livebook
```

Same `{Rheo.Backend.Ecto, repo: ...}` tuple as SQLite — only the Repo adapter
changes.

```elixir
defmodule Backends.PostgresRepo do
  use Ecto.Repo, otp_app: :rheo, adapter: Ecto.Adapters.Postgres
end

postgres =
  case System.get_env("RHEO_POSTGRES_URL") || System.get_env("DATABASE_URL") do
    nil ->
      %{
        ok?: false,
        hint: "export RHEO_POSTGRES_URL=ecto://postgres:postgres@localhost:5432/rheo_livebook"
      }

    url ->
      case Process.whereis(Backends.PostgresRepo) do
        nil -> :ok
        pid -> GenServer.stop(pid)
      end

      stop_rheo.(Rheo)

      {:ok, _} =
        Backends.PostgresRepo.start_link(
          url: url,
          pool_size: 5,
          name: Backends.PostgresRepo
        )

      {:ok, _} =
        Rheo.start_link(
          name: :pg_demo,
          backend: {Rheo.Backend.Ecto, repo: Backends.PostgresRepo}
        )

      :ok = Rheo.ensure_indexes(rheo: :pg_demo)

      s = "pg-demo-#{System.unique_integer([:positive])}"
      :ok = Rheo.create_stream(s, partition_count: 2, rheo: :pg_demo)
      :ok = Rheo.create_group(s, "risk", rheo: :pg_demo)
      {:ok, ev} = Rheo.append(s, %{type: "ping", venue: "postgres"}, rheo: :pg_demo)
      {:ok, [lease]} = Rheo.fetch(s, "risk", limit: 1, rheo: :pg_demo)
      :ok = Rheo.ack(lease, rheo: :pg_demo)

      %{
        ok?: true,
        event_id: ev.id,
        guarantees: Rheo.Backend.Ecto.capabilities(:postgres).guarantees,
        mechanisms:
          Rheo.Backend.Ecto.capabilities(:postgres).mechanisms
          |> Enum.filter(fn {_k, v} -> v end)
          |> Map.new()
      }
  end
```

Postgres is the multi-node SQL path. Wakeup via `NOTIFY` is an optional hint —
**polling remains the universal fallback** ([ADR 025](https://hexdocs.pm/rheo/025-backend-wakeup-contract.html)).

---

## 7. Multi-instance: ETS + SQLite side by side

Two Rheo names in one BEAM node is a supported pattern (e.g. hot market feed
in ETS, durable audit log in SQLite). Same stream **name** must not bleed
across handles.

```elixir
stop_rheo.(Rheo)
stop_rheo.(:ets_demo)
stop_rheo.(:mnesia_demo)
stop_rheo.(:sqlite_demo)
stop_rheo.(:mongo_demo)
stop_rheo.(:pg_demo)
stop_rheo.(:redis_demo)

case Process.whereis(Backends.SqliteRepo) do
  nil -> :ok
  pid -> GenServer.stop(pid)
end

db2 = Path.join(System.tmp_dir!(), "rheo_audit_#{System.unique_integer([:positive])}.db")

{:ok, _} =
  Backends.SqliteRepo.start_link(database: db2, pool_size: 5, name: Backends.SqliteRepo)

{:ok, _} = Rheo.start_link(name: :market, backend: Rheo.Backend.ETS)
{:ok, _} =
  Rheo.start_link(name: :audit, backend: {Rheo.Backend.Ecto, repo: Backends.SqliteRepo})

:ok = Rheo.ensure_indexes(rheo: :audit)

stream = "shared-name"
:ok = Rheo.create_stream(stream, rheo: :market)
:ok = Rheo.create_stream(stream, rheo: :audit)
:ok = Rheo.create_group(stream, "traders", rheo: :market)
:ok = Rheo.create_group(stream, "compliance", rheo: :audit)

{:ok, m} = Rheo.append(stream, %{type: "fill", venue: "ets"}, rheo: :market)
{:ok, a} = Rheo.append(stream, %{type: "fill", venue: "sqlite"}, rheo: :audit)

{:ok, only_m} = Rheo.query(stream, limit: 10, rheo: :market)
{:ok, only_a} = Rheo.query(stream, limit: 10, rheo: :audit)

%{
  market_ids: Enum.map(only_m, & &1.id),
  audit_ids: Enum.map(only_a, & &1.id),
  bleed?: m.id in Enum.map(only_a, & &1.id) or a.id in Enum.map(only_m, & &1.id)
}
```

`bleed?` must be `false`. Groups (`traders` vs `compliance`) are also isolated
per instance — creating a group on `:market` does not create it on `:audit`.

---

## 8. Throughput comparison (informal)

Same portable API, different storage cost. This is a **Livebook microbench**,
not a formal load test: single partition, sequential calls from one BEAM,
`N` events append then drain with `fetch`/`ack`. Results skip backends that
are not running or cannot connect.

Tune `bench_n` if you want a longer run. Re-evaluate after starting Docker
services for Mongo / Redis / Postgres. Mnesia needs no Docker.

```elixir
bench_n = 200
bench_payload = %{type: "bench", i: 0}

stop_rheo = fn name ->
  case Process.whereis(name) do
    nil -> :ok
    pid -> GenServer.stop(pid)
  end
end

# Repos may already exist from sections 5–6; define them if this cell is run alone.
unless Code.ensure_loaded?(Backends.SqliteRepo) do
  defmodule Backends.SqliteRepo do
    use Ecto.Repo, otp_app: :rheo, adapter: Ecto.Adapters.SQLite3
  end
end

unless Code.ensure_loaded?(Backends.PostgresRepo) do
  defmodule Backends.PostgresRepo do
    use Ecto.Repo, otp_app: :rheo, adapter: Ecto.Adapters.Postgres
  end
end

ensure_sqlite_repo = fn ->
  case Process.whereis(Backends.SqliteRepo) do
    nil ->
      db = Path.join(System.tmp_dir!(), "rheo_bench_#{System.unique_integer([:positive])}.db")

      {:ok, _} =
        Backends.SqliteRepo.start_link(
          database: db,
          pool_size: 5,
          name: Backends.SqliteRepo
        )

      :ok

    _pid ->
      :ok
  end
end

timed = fn label, fun ->
  t0 = System.monotonic_time(:microsecond)

  try do
    case fun.() do
      :ok ->
        us = System.monotonic_time(:microsecond) - t0
        ms = Float.round(us / 1000, 1)
        ops = Float.round(bench_n * 1_000_000 / us, 1)
        %{backend: label, ok?: true, n: bench_n, ms: ms, ops_per_sec: ops}

      {:error, reason} ->
        %{backend: label, ok?: false, reason: inspect(reason), n: bench_n}
    end
  rescue
    e ->
      %{backend: label, ok?: false, reason: Exception.message(e), n: bench_n}
  end
end

drain = fn rheo, stream, group ->
  drain_loop = fn drain_loop ->
    case Rheo.fetch(stream, group, limit: 50, rheo: rheo) do
      {:ok, []} ->
        :ok

      {:ok, leases} ->
        Enum.each(leases, fn lease -> :ok = Rheo.ack(lease, rheo: rheo) end)
        drain_loop.(drain_loop)

      {:error, _} = err ->
        err
    end
  end

  drain_loop.(drain_loop)
end

run_cycle = fn rheo, stream ->
  group = "bench"
  :ok = Rheo.create_stream(stream, partition_count: 1, rheo: rheo)
  :ok = Rheo.create_group(stream, group, rheo: rheo)

  Enum.each(1..bench_n, fn i ->
    {:ok, _} = Rheo.append(stream, Map.put(bench_payload, :i, i), rheo: rheo)
  end)

  drain.(rheo, stream, group)
end

candidates = [
  {"ETS",
   fn ->
     stop_rheo.(:ets_bench)
     {:ok, _} = Rheo.start_link(name: :ets_bench, backend: Rheo.Backend.ETS)
     :ok = Rheo.ensure_indexes(rheo: :ets_bench)
     run_cycle.(:ets_bench, "bench-ets")
   end},
  {"Mnesia",
   fn ->
     stop_rheo.(:mnesia_bench)
     dir = Path.join(System.tmp_dir!(), "rheo_bench_mnesia_#{System.unique_integer([:positive])}")

     {:ok, _} =
       Rheo.start_link(
         name: :mnesia_bench,
         backend: {Rheo.Backend.Mnesia, name: MnesiaBench, dir: dir}
       )

     :ok = Rheo.ensure_indexes(rheo: :mnesia_bench)
     run_cycle.(:mnesia_bench, "bench-mnesia-#{System.unique_integer([:positive])}")
   end},
  {"SQLite",
   fn ->
     stop_rheo.(:sqlite_bench)
     ensure_sqlite_repo.()

     {:ok, _} =
       Rheo.start_link(
         name: :sqlite_bench,
         backend: {Rheo.Backend.Ecto, repo: Backends.SqliteRepo}
       )

     :ok = Rheo.ensure_indexes(rheo: :sqlite_bench)
     run_cycle.(:sqlite_bench, "bench-sqlite-#{System.unique_integer([:positive])}")
   end},
  {"Mongo",
   fn ->
     if not Code.ensure_loaded?(Rheo.Backend.Mongo) do
       {:error, :mongo_not_loaded}
     else
       stop_rheo.(:mongo_bench)
       url = System.get_env("RHEO_MONGO_URL", "mongodb://localhost:27017/rheo_livebook")

       case Rheo.start_link(
              name: :mongo_bench,
              backend: {Rheo.Backend.Mongo, url: url, name: MongoBench}
            ) do
         {:ok, _} ->
           :ok = Rheo.ensure_indexes(rheo: :mongo_bench)
           run_cycle.(:mongo_bench, "bench-mongo-#{System.unique_integer([:positive])}")

         {:error, reason} ->
           {:error, reason}
       end
     end
   end},
  {"Redis",
   fn ->
     if not Code.ensure_loaded?(Rheo.Backend.Redis) do
       {:error, :redis_not_loaded}
     else
       stop_rheo.(:redis_bench)
       url = System.get_env("RHEO_REDIS_URL", "redis://localhost:6379")

       case Rheo.start_link(
              name: :redis_bench,
              backend: {Rheo.Backend.Redis, name: RedisBench, url: url}
            ) do
         {:ok, _} ->
           :ok = Rheo.ensure_indexes(rheo: :redis_bench)
           run_cycle.(:redis_bench, "bench-redis-#{System.unique_integer([:positive])}")

         {:error, reason} ->
           {:error, reason}
       end
     end
   end},
  {"Postgres",
   fn ->
     case System.get_env("RHEO_POSTGRES_URL") || System.get_env("DATABASE_URL") do
       nil ->
         {:error, :postgres_url_unset}

       url ->
         stop_rheo.(:pg_bench)

         case Process.whereis(Backends.PostgresRepo) do
           nil ->
             {:ok, _} =
               Backends.PostgresRepo.start_link(
                 url: url,
                 pool_size: 5,
                 name: Backends.PostgresRepo
               )

           _pid ->
             :ok
         end

         {:ok, _} =
           Rheo.start_link(
             name: :pg_bench,
             backend: {Rheo.Backend.Ecto, repo: Backends.PostgresRepo}
           )

         :ok = Rheo.ensure_indexes(rheo: :pg_bench)
         run_cycle.(:pg_bench, "bench-pg-#{System.unique_integer([:positive])}")
     end
   end}
]

bench_rows =
  Enum.map(candidates, fn {label, fun} ->
    timed.(label, fun)
  end)

Enum.each(
  [:ets_bench, :mnesia_bench, :sqlite_bench, :mongo_bench, :redis_bench, :pg_bench],
  stop_rheo
)

Kino.DataTable.new(bench_rows)
```

Expect **ETS** to lead (in-memory), then roughly Mnesia / Redis / SQLite /
Postgres / Mongo depending on your machine and Docker latency. Numbers are for
relative ordering in this notebook — not published SLOs.

---

## Takeaways

* Swap backends with `{Rheo, backend: …}` — keep Consumer / Query / Event meaning
* Read **capabilities** for ops decisions; do not weaken fencing in app code
* Optional packages / deps: Mongo driver, Redix, Ecto adapters, Broadway — core stays lean
* Mnesia is core (OTP); durable single-node without Docker
* Multiple named Rheo instances in one node are fine when handles stay distinct
* Throughput here is informal; durable / remote backends trade speed for guarantees

HexDocs: [ETS](https://hexdocs.pm/rheo/ets.html) ·
[Mnesia](https://hexdocs.pm/rheo/mnesia.html) ·
[Mongo](https://hexdocs.pm/rheo/mongo.html) ·
[Redis](https://hexdocs.pm/rheo/redis.html) ·
[Using Ecto](https://hexdocs.pm/rheo/using-ecto.html) ·
[Article 13](https://github.com/thanos/rheo/blob/main/docs/tutorials/13-one-consumer-api-postgresql-and-sqlite.md) ·
[Article 16](https://github.com/thanos/rheo/blob/main/docs/tutorials/16-rheo-on-redis-streams-portable-sequence-native-pel.md)
