How to Monitor DeltaCRDT with Vigilmon
DeltaCRDT is a pure Elixir implementation of delta-state Conflict-free Replicated Data Types. It lets you build eventually-consistent distributed data structures — counters, sets, maps, and registers — that merge correctly without coordination between nodes. Each replica maintains its own state and propagates compact deltas rather than full state, making it bandwidth-efficient for large distributed clusters.
When DeltaCRDT state fails to converge, your distributed application silently diverges. One node believes a value is 1,200; another believes it is 800. A key added on node A never appears on node B. These failures don't raise exceptions — your processes are alive, your HTTP endpoints return 200, and your supervisor trees are green. The data is just wrong.
Vigilmon heartbeat monitors let you verify that DeltaCRDT state is converging across your cluster at each sync cycle, not just that the processes are alive.
Why Monitor DeltaCRDT?
DeltaCRDT failures are almost always data consistency failures, not process failures:
- Delta sync stall — the periodic delta sync task (
:sync_interval) may be misconfigured or the underlying transport may drop messages; deltas accumulate without propagating - State divergence after network partition — each partition continues accepting mutations; when the partition heals, the merge must reconcile all diverged deltas; this merge backlog can cause temporary state bloat
- Garbage collection lag — DeltaCRDT uses causal contexts to track which deltas have been applied; if garbage collection falls behind, the causal context grows and serialization becomes expensive
- Process crash without state transfer — a node crash before state is replicated to a peer means the delta is lost; depending on your CRDT type, this may be unrecoverable without application-level replay
- Neighbor list staleness — DeltaCRDT requires explicit configuration of which neighbors to sync with; if your cluster membership changes (nodes join or leave) but the neighbor list is not updated, new nodes never receive deltas
Key Metrics to Track
| Metric | What it tells you | |--------|-------------------| | Delta sync call frequency | Whether the sync timer is firing at the expected rate | | CRDT state byte size | Growing size indicates GC lag or accumulation of unmerged deltas | | Sync neighbor count | Whether the configured neighbor set matches the live cluster | | Cross-node state hash | Whether replicas have converged to the same value | | Merge duration | Time spent processing incoming deltas; spikes indicate large state or CPU pressure | | GC runs per minute | Whether causal context cleanup is keeping up |
Step 1: Add DeltaCRDT to Your Application
# mix.exs
defp deps do
[
{:delta_crdt, "~> 0.6"}
]
end
# lib/my_app/distributed_store.ex
defmodule MyApp.DistributedStore do
use Supervisor
def start_link(opts), do: Supervisor.start_link(__MODULE__, opts, name: __MODULE__)
def init(_opts) do
neighbors = Application.get_env(:my_app, :crdt_neighbors, [])
children = [
{DeltaCrdt, [
crdt: DeltaCrdt.AWLWWMap,
name: MyApp.CrdtStore,
sync_interval: 200,
max_sync_size: :infinite,
neighbours: neighbors
]}
]
Supervisor.init(children, strategy: :one_for_one)
end
end
# lib/my_app/application.ex
children = [
MyApp.DistributedStore,
# ...
]
Step 2: Build a CRDT Health Check
# lib/my_app/crdt_health.ex
defmodule MyApp.CrdtHealth do
@probe_key "__health_probe__"
def check do
pid = Process.whereis(MyApp.CrdtStore)
if is_nil(pid) do
%{status: :error, reason: "CrdtStore process not found"}
else
memory = Process.info(pid, :memory) |> elem(1)
state = DeltaCrdt.to_map(MyApp.CrdtStore)
entry_count = map_size(state)
write_result = probe_write()
%{
status: if(write_result == :ok, do: :ok, else: :error),
pid: inspect(pid),
memory_bytes: memory,
entry_count: entry_count,
write_probe: write_result
}
end
rescue
e -> %{status: :error, reason: inspect(e)}
end
defp probe_write do
DeltaCrdt.put(MyApp.CrdtStore, @probe_key, :erlang.system_time(:millisecond))
Process.sleep(50)
result = DeltaCrdt.get(MyApp.CrdtStore, @probe_key)
if is_integer(result) do
DeltaCrdt.delete(MyApp.CrdtStore, @probe_key)
:ok
else
:write_not_readable
end
rescue
e -> {:error, inspect(e)}
end
end
Step 3: Check Cross-Node Convergence
For multi-node deployments, verify that all nodes have converged to the same state:
# lib/my_app/crdt_consistency.ex
defmodule MyApp.CrdtConsistency do
@convergence_key "__convergence_probe__"
@tolerance_ms 5_000
def check do
nodes = [Node.self() | Node.list()]
if length(nodes) < 2 do
{:ok, :single_node}
else
probe_value = :erlang.system_time(:millisecond)
DeltaCrdt.put(MyApp.CrdtStore, @convergence_key, probe_value)
Process.sleep(500)
remote_values =
Enum.map(Node.list(), fn node ->
case :rpc.call(node, DeltaCrdt, :get, [MyApp.CrdtStore, @convergence_key], 3_000) do
{:badrpc, reason} -> {:error, reason}
value -> {:ok, value}
end
end)
errors = Enum.filter(remote_values, fn {tag, _} -> tag == :error end)
values = Enum.filter(remote_values, fn {tag, _} -> tag == :ok end)
|> Enum.map(fn {:ok, v} -> v end)
all_match = Enum.all?(values, fn v -> v == probe_value end)
DeltaCrdt.delete(MyApp.CrdtStore, @convergence_key)
cond do
errors != [] -> {:degraded, %{errors: errors, replicated: length(values), total: length(nodes)}}
not all_match -> {:degraded, %{reason: :values_diverged, values: values}}
true -> {:ok, %{nodes: length(nodes), all_converged: true}}
end
end
end
end
Step 4: Expose a Health Endpoint
# lib/my_app_web/controllers/health_controller.ex
defmodule MyAppWeb.HealthController do
use MyAppWeb, :controller
def index(conn, _params) do
crdt = MyApp.CrdtHealth.check()
consistency = MyApp.CrdtConsistency.check()
crdt_ok = crdt.status == :ok
consistency_ok = elem(consistency, 0) == :ok
status = if crdt_ok and consistency_ok, do: 200, else: 503
conn
|> put_status(status)
|> json(%{
status: if(status == 200, do: "ok", else: "degraded"),
node: Node.self() |> to_string(),
crdt: crdt,
consistency: format_consistency(consistency)
})
end
defp format_consistency({:ok, :single_node}), do: %{status: "ok", mode: "single_node"}
defp format_consistency({:ok, data}), do: Map.merge(%{status: "ok"}, data)
defp format_consistency({:degraded, data}), do: Map.merge(%{status: "degraded"}, data)
end
# lib/my_app_web/router.ex
scope "/", MyAppWeb do
get "/health", HealthController, :index
end
Step 5: Emit Telemetry Metrics
# lib/my_app/crdt_poller.ex
defmodule MyApp.CrdtPoller do
use GenServer
require Logger
@interval :timer.seconds(30)
def start_link(_), do: GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
def init(state) do
schedule()
{:ok, state}
end
def handle_info(:poll, state) do
report()
schedule()
{:noreply, state}
end
defp report do
pid = Process.whereis(MyApp.CrdtStore)
if pid do
memory = Process.info(pid, :memory) |> elem(1)
entry_count = DeltaCrdt.to_map(MyApp.CrdtStore) |> map_size()
neighbor_count = length(Application.get_env(:my_app, :crdt_neighbors, []))
:telemetry.execute(
[:my_app, :crdt, :state],
%{memory_bytes: memory, entry_count: entry_count, neighbor_count: neighbor_count},
%{node: Node.self()}
)
else
Logger.warning("CrdtPoller: MyApp.CrdtStore not running")
end
rescue
e -> Logger.warning("CrdtPoller error: #{inspect(e)}")
end
defp schedule, do: Process.send_after(self(), :poll, @interval)
end
Step 6: Create Monitors in Vigilmon
HTTP monitor for the health endpoint:
- Sign in at vigilmon.online
- Click New Monitor → HTTP
- URL:
https://your-app.example.com/health - Interval: 60 seconds
- Alert condition: non-200 or body contains
"status":"degraded"
Heartbeat monitor for CRDT liveness:
# lib/my_app/crdt_heartbeat.ex
defmodule MyApp.CrdtHeartbeat do
use GenServer
@interval :timer.minutes(2)
def start_link(_), do: GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
def init(state) do
schedule()
{:ok, state}
end
def handle_info(:ping, state) do
check = MyApp.CrdtHealth.check()
if check.status == :ok do
url = System.get_env("VIGILMON_CRDT_HEARTBEAT_URL")
if url do
:httpc.request(:get, {String.to_charlist(url), []}, [{:timeout, 5_000}], [])
end
end
schedule()
{:noreply, state}
end
defp schedule, do: Process.send_after(self(), :ping, @interval)
end
In Vigilmon, create a Heartbeat monitor with a 5-minute expected interval. Set VIGILMON_CRDT_HEARTBEAT_URL in your release environment.
Step 7: Alerting
In Vigilmon, go to Notifications → New Channel and configure:
Slack for CRDT degradation:
🔴 DOWN: DeltaCRDT — MyApp
Monitor: /health returning crdt.status = "degraded"
Node: myapp@10.0.0.1
Action: Check CrdtStore process, neighbor list, and network partition status
PagerDuty for heartbeat miss — a missed heartbeat means the CRDT store process has crashed or write/read is failing; this warrants immediate attention as all distributed state updates are being dropped.
Common DeltaCRDT Issues and Fixes
Stale neighbor list after cluster resize:
# Update neighbors dynamically when cluster membership changes
def handle_info({:nodeup, node}, state) do
new_neighbors = [node | state.neighbors]
DeltaCrdt.set_neighbours(MyApp.CrdtStore, new_neighbors)
{:noreply, %{state | neighbors: new_neighbors}}
end
def handle_info({:nodedown, node}, state) do
new_neighbors = List.delete(state.neighbors, node)
DeltaCrdt.set_neighbours(MyApp.CrdtStore, new_neighbors)
{:noreply, %{state | neighbors: new_neighbors}}
end
Large state after partition reconciliation:
# From IEx, check current state size
iex> MyApp.CrdtStore |> DeltaCrdt.to_map() |> map_size()
# If far above expected, check for deleted-but-not-GC'd entries
# Reduce sync_interval temporarily to speed up delta propagation
Slow merge on large maps:
# Prefer AWLWWMap for mutable key/value data;
# use GSet for append-only sets to avoid tombstone accumulation
{DeltaCrdt, [crdt: DeltaCrdt.GSet, name: MyApp.AppendStore, sync_interval: 100]}
What You've Built
| What | How | |------|-----| | CRDT process health | Verify CrdtStore process alive and write/read cycle works | | Cross-node convergence check | RPC probe verifying all nodes converged on written value | | Structured health endpoint | JSON health response with CRDT and consistency status | | State size monitoring | Telemetry on entry count and memory per 30s | | Liveness heartbeat | GenServer pinging Vigilmon only when CRDT is healthy | | Real-time alerting | Vigilmon HTTP monitor + PagerDuty on heartbeat miss |
DeltaCRDT makes coordination-free distributed state possible. Vigilmon makes sure the eventual consistency is actually converging.
Next Steps
- Add per-CRDT-type monitors if your application uses multiple CRDT instances for different data domains
- Alert on neighbor count dropping below the expected cluster size to catch node siloing early
- Use Vigilmon response time history to track health endpoint latency as CRDT state grows
- Set up a status page combining CRDT health with your application's other distributed components
Get started free at vigilmon.online.