Current section
Files
Jump to
Current section
Files
lib/ragex/graph/persistence.ex
defmodule Ragex.Graph.Persistence do
@moduledoc """
Persistence layer for the knowledge graph (nodes and edges).
Saves and loads graph structure to/from disk using ETS serialization,
complementing the embedding persistence in `Ragex.Embeddings.Persistence`.
Graph data is stored at `~/.cache/ragex/<project_hash>/graph.ets`.
## Usage
# Save graph (called automatically on shutdown)
{:ok, path} = Graph.Persistence.save()
# Load graph (called automatically on startup)
{:ok, %{nodes: 500, edges: 2000}} = Graph.Persistence.load()
# Check if cache exists and is valid
Graph.Persistence.cache_valid?()
"""
require Logger
alias Ragex.Embeddings.Persistence, as: EmbeddingsPersistence
@version 1
@cache_file_name "graph.ets"
@nodes_table :ragex_nodes
@edges_table :ragex_edges
@doc """
Saves graph nodes and edges to disk.
## Returns
- `{:ok, path}` - Cache file path
- `{:error, reason}` - Failure
"""
@spec save(String.t() | nil) :: {:ok, Path.t()} | {:error, term()}
def save(project_path \\ nil) do
cache_path = get_cache_path(project_path)
cache_dir = Path.dirname(cache_path)
File.mkdir_p!(cache_dir)
nodes = :ets.tab2list(@nodes_table)
edges = :ets.tab2list(@edges_table)
metadata = %{
version: @version,
timestamp: System.system_time(:second),
node_count: length(nodes),
edge_count: length(edges)
}
data = %{
metadata: metadata,
nodes: nodes,
edges: edges
}
binary = :erlang.term_to_binary(data, [:compressed])
File.write!(cache_path, binary)
Logger.info(
"Saved graph to cache: #{length(nodes)} nodes, #{length(edges)} edges (#{cache_path})"
)
{:ok, cache_path}
rescue
e ->
Logger.error("Failed to save graph cache: #{Exception.message(e)}")
{:error, Exception.message(e)}
end
@doc """
Loads graph nodes and edges from disk.
## Returns
- `{:ok, %{nodes: count, edges: count}}` - Loaded successfully
- `{:error, :not_found}` - No cache file
- `{:error, reason}` - Failure
"""
@spec load(String.t() | nil) :: {:ok, map()} | {:error, term()}
def load(project_path \\ nil) do
cache_path = get_cache_path(project_path)
if File.exists?(cache_path) do
do_load(cache_path)
else
{:error, :not_found}
end
end
@doc """
Checks if a valid graph cache exists.
"""
@spec cache_valid?(String.t() | nil) :: boolean()
def cache_valid?(project_path \\ nil) do
cache_path = get_cache_path(project_path)
if File.exists?(cache_path) do
case read_metadata(cache_path) do
{:ok, %{version: @version}} -> true
_ -> false
end
else
false
end
end
@doc """
Returns statistics about the graph cache.
"""
@spec stats(String.t() | nil) :: {:ok, map()} | {:error, term()}
def stats(project_path \\ nil) do
cache_path = get_cache_path(project_path)
if File.exists?(cache_path) do
stat = File.stat!(cache_path)
case read_metadata(cache_path) do
{:ok, metadata} ->
{:ok,
%{
cache_path: cache_path,
file_size: stat.size,
metadata: metadata
}}
error ->
error
end
else
{:error, :not_found}
end
end
@doc """
Clears the graph cache for the current project.
"""
@spec clear(String.t() | nil) :: :ok
def clear(project_path \\ nil) do
cache_path = get_cache_path(project_path)
if File.exists?(cache_path) do
File.rm!(cache_path)
Logger.info("Cleared graph cache: #{cache_path}")
end
:ok
end
# Private functions
defp do_load(cache_path) do
binary = File.read!(cache_path)
data = :erlang.binary_to_term(binary)
case data do
%{metadata: %{version: @version}, nodes: nodes, edges: edges} ->
# Restore nodes
Enum.each(nodes, fn entry ->
:ets.insert(@nodes_table, entry)
end)
# Restore edges
Enum.each(edges, fn entry ->
:ets.insert(@edges_table, entry)
end)
node_count = length(nodes)
edge_count = length(edges)
Logger.info("Loaded graph from cache: #{node_count} nodes, #{edge_count} edges")
{:ok, %{nodes: node_count, edges: edge_count}}
%{metadata: %{version: version}} ->
Logger.warning("Graph cache version mismatch: expected #{@version}, got #{version}")
{:error, :version_mismatch}
_ ->
Logger.warning("Invalid graph cache format")
{:error, :invalid_format}
end
rescue
e ->
Logger.error("Failed to load graph cache: #{Exception.message(e)}")
{:error, Exception.message(e)}
end
defp read_metadata(cache_path) do
binary = File.read!(cache_path)
case :erlang.binary_to_term(binary) do
%{metadata: metadata} -> {:ok, metadata}
_ -> {:error, :no_metadata}
end
rescue
e -> {:error, Exception.message(e)}
end
defp get_cache_path(project_path) do
cache_dir =
Application.get_env(:ragex, :cache_root, EmbeddingsPersistence.default_cache_root())
project_hash = EmbeddingsPersistence.generate_project_hash(project_path)
Path.join([cache_dir, project_hash, @cache_file_name])
end
end