Current section
Files
Jump to
Current section
Files
lib/subaru/adapters/mnesia.ex
defmodule Subaru.Adapters.Mnesia do
@moduledoc """
Mnesia storage adapter for Subaru.
Uses two Mnesia tables:
- `vertices` table: stores vertex data with id as primary key
- `edges` table: stores edge data with composite key {from, type, to}
Table storage defaults to `disc_copies` for durability with RAM performance.
"""
@behaviour Subaru.Adapters.Behaviour
require Logger
# Record definitions for Mnesia tables
# Vertex: {id, type, data}
# Edge: {{from, type, to}, from, to, type, data}
@impl true
def init(repo, opts) do
dir = Keyword.get(opts, :dir, ".mnesia/#{node()}")
storage_type = Keyword.get(opts, :storage_type, :disc_copies)
nodes = Keyword.get(opts, :nodes, [node()])
# Set Mnesia directory
Application.put_env(:mnesia, :dir, String.to_charlist(dir))
# Ensure directory exists
File.mkdir_p!(dir)
# Check for node mismatch before proceeding
if storage_type == :disc_copies do
validate_node_compatibility!(dir)
end
# Table names are prefixed with repo module name for isolation
vertex_table = vertex_table_name(repo)
edge_table = edge_table_name(repo)
# Create schema if needed (for disc_copies)
case :mnesia.create_schema(nodes) do
:ok -> :ok
{:error, {_, {:already_exists, _}}} -> :ok
{:error, reason} -> Logger.warning("Schema creation: #{inspect(reason)}")
end
# Start Mnesia
:ok = :mnesia.start()
# Wait for tables if they exist, or create them
case :mnesia.wait_for_tables([vertex_table, edge_table], 100) do
:ok ->
:ok
{:timeout, _} ->
create_tables(repo, storage_type, nodes)
{:error, _} ->
create_tables(repo, storage_type, nodes)
end
:ok
end
defp create_tables(repo, storage_type, nodes) do
vertex_table = vertex_table_name(repo)
edge_table = edge_table_name(repo)
# Create vertices table
:mnesia.create_table(vertex_table,
attributes: [:id, :type, :data],
type: :set,
"#{storage_type}": nodes
)
# Create edges table with indexed fields
:mnesia.create_table(edge_table,
attributes: [:id, :from, :to, :type, :data],
type: :set,
"#{storage_type}": nodes,
index: [:from, :to, :type]
)
:mnesia.wait_for_tables([vertex_table, edge_table], 5_000)
end
@impl true
def stop(_repo) do
:mnesia.stop()
:ok
end
@impl true
def sync_to_disc(_repo) do
# Apply pending log entries to .DCD data files
# This is the correct call for disc_copies tables
:mnesia.dump_log()
:ok
end
# Vertex operations
@impl true
def put_vertex(repo, %{id: id} = vertex) do
vertex_table = vertex_table_name(repo)
type = get_vertex_type(vertex)
data = vertex_to_data(vertex)
:mnesia.transaction(fn ->
:mnesia.write({vertex_table, id, type, data})
end)
|> transaction_result()
end
@impl true
def get_vertex(repo, id) do
vertex_table = vertex_table_name(repo)
case :mnesia.transaction(fn -> :mnesia.read(vertex_table, id) end) do
{:atomic, [{^vertex_table, ^id, type, data}]} ->
{:ok, data_to_vertex(id, type, data)}
{:atomic, []} ->
:error
{:aborted, reason} ->
Logger.error("get_vertex failed: #{inspect(reason)}")
:error
end
end
@impl true
def get_vertices(repo, ids) when is_list(ids) do
vertex_table = vertex_table_name(repo)
case :mnesia.transaction(fn ->
Enum.flat_map(ids, fn id ->
case :mnesia.read(vertex_table, id) do
[{^vertex_table, ^id, type, data}] -> [{id, type, data}]
[] -> []
end
end)
end) do
{:atomic, records} ->
Enum.map(records, fn {id, type, data} ->
data_to_vertex(id, type, data)
end)
{:aborted, reason} ->
Logger.error("get_vertices failed: #{inspect(reason)}")
[]
end
end
@impl true
def delete_vertex(repo, id) do
vertex_table = vertex_table_name(repo)
edge_table = edge_table_name(repo)
:mnesia.transaction(fn ->
# Delete the vertex
:mnesia.delete({vertex_table, id})
# Delete all edges from this vertex
out_edges = :mnesia.index_read(edge_table, id, :from)
for {^edge_table, edge_id, _, _, _, _} <- out_edges do
:mnesia.delete({edge_table, edge_id})
end
# Delete all edges to this vertex
in_edges = :mnesia.index_read(edge_table, id, :to)
for {^edge_table, edge_id, _, _, _, _} <- in_edges do
:mnesia.delete({edge_table, edge_id})
end
end)
|> transaction_result()
end
@impl true
def get_vertices_by_type(repo, type) do
vertex_table = vertex_table_name(repo)
case :mnesia.transaction(fn ->
:mnesia.match_object({vertex_table, :_, type, :_})
end) do
{:atomic, records} ->
Enum.map(records, fn {^vertex_table, id, t, data} ->
data_to_vertex(id, t, data)
end)
{:aborted, _} ->
[]
end
end
@impl true
def get_vertices_by_props(repo, type, props) when map_size(props) == 0 do
if type, do: get_vertices_by_type(repo, type), else: []
end
def get_vertices_by_props(repo, type, props) do
vertex_table = vertex_table_name(repo)
match_pattern =
if type do
{vertex_table, :_, type, :_}
else
{vertex_table, :_, :_, :_}
end
case :mnesia.transaction(fn -> :mnesia.match_object(match_pattern) end) do
{:atomic, records} ->
records
|> Enum.filter(fn {^vertex_table, _id, _type, data} ->
Enum.all?(props, fn {k, v} -> Map.get(data, k) == v end)
end)
|> Enum.map(fn {^vertex_table, id, t, data} ->
data_to_vertex(id, t, data)
end)
{:aborted, _} ->
[]
end
end
# Edge operations
@impl true
def put_edge(repo, from_id, edge_type, to_id, props) do
edge_table = edge_table_name(repo)
# Composite key ensures uniqueness: one edge of type X from A to B
edge_id = {from_id, edge_type, to_id}
:mnesia.transaction(fn ->
:mnesia.write({edge_table, edge_id, from_id, to_id, edge_type, props})
end)
|> transaction_result()
end
@impl true
def delete_edge(repo, from_id, edge_type, to_id) do
edge_table = edge_table_name(repo)
edge_id = {from_id, edge_type, to_id}
:mnesia.transaction(fn ->
:mnesia.delete({edge_table, edge_id})
end)
|> transaction_result()
end
@impl true
def get_out_edges(repo, from_id, edge_type) do
edge_table = edge_table_name(repo)
case :mnesia.transaction(fn ->
edges = :mnesia.index_read(edge_table, from_id, :from)
if edge_type do
Enum.filter(edges, fn {_, _, _, _, type, _} -> type == edge_type end)
else
edges
end
end) do
{:atomic, records} ->
Enum.map(records, fn {^edge_table, _id, from, to, type, data} ->
Map.merge(data, %{from: from, to: to, type: type})
end)
{:aborted, _} ->
[]
end
end
@impl true
def get_in_edges(repo, to_id, edge_type) do
edge_table = edge_table_name(repo)
case :mnesia.transaction(fn ->
edges = :mnesia.index_read(edge_table, to_id, :to)
if edge_type do
Enum.filter(edges, fn {_, _, _, _, type, _} -> type == edge_type end)
else
edges
end
end) do
{:atomic, records} ->
Enum.map(records, fn {^edge_table, _id, from, to, type, data} ->
Map.merge(data, %{from: from, to: to, type: type})
end)
{:aborted, _} ->
[]
end
end
# Transaction support
@impl true
def transaction(_repo, fun) do
case :mnesia.transaction(fun) do
{:atomic, result} -> {:ok, result}
{:aborted, reason} -> {:error, reason}
end
end
# Private helpers
defp vertex_table_name(repo) do
:"#{repo}_vertices"
end
defp edge_table_name(repo) do
:"#{repo}_edges"
end
defp get_vertex_type(%{__struct__: module}), do: module
defp get_vertex_type(%{type: type}) when is_atom(type), do: type
defp get_vertex_type(_), do: :vertex
defp vertex_to_data(%{__struct__: _} = struct) do
struct
|> Map.from_struct()
|> Map.delete(:id)
end
defp vertex_to_data(map) when is_map(map) do
Map.delete(map, :id)
end
defp data_to_vertex(id, type, data) when is_atom(type) and type not in [:vertex, nil] do
# Try to reconstruct struct if type is a module
if Code.ensure_loaded?(type) and function_exported?(type, :__struct__, 0) do
struct(type, Map.put(data, :id, id))
else
Map.merge(data, %{id: id, type: type})
end
end
defp data_to_vertex(id, _type, data) do
Map.put(data, :id, id)
end
defp transaction_result({:atomic, _}), do: :ok
defp transaction_result({:aborted, reason}), do: {:error, reason}
defp validate_node_compatibility!(dir) do
schema_file = Path.join(dir, "schema.DAT")
if File.exists?(schema_file) do
# Temporarily start Mnesia to read schema info
:mnesia.start()
case :mnesia.table_info(:schema, :disc_copies) do
[] ->
:mnesia.stop()
:ok
schema_nodes when is_list(schema_nodes) ->
current = node()
:mnesia.stop()
unless current in schema_nodes do
raise """
Mnesia node mismatch detected!
Existing data was created by: #{inspect(schema_nodes)}
Current node is: #{inspect(current)}
Mnesia cannot read data created by different nodes.
Solutions:
1. Set RELEASE_DISTRIBUTION=none to run as nonode@nohost
2. Re-import data with the current node name
3. Delete #{dir} to start fresh (WARNING: data loss)
"""
end
end
end
end
end