Current section
Files
Jump to
Current section
Files
lib/clarity/server.ex
defmodule Clarity.Server do
@moduledoc false
use GenServer
alias Clarity.Vertex.Root
require Logger
defmodule State do
@moduledoc false
@enforce_keys [:graph, :introspectors, :name]
defstruct [
:graph,
:introspectors,
:name,
:future_queue,
:requeue_queue,
in_progress: %{},
custom_introspectors: nil
]
@type t() :: %__MODULE__{
future_queue: :queue.queue(Clarity.Server.Task.t()),
requeue_queue: :queue.queue(Clarity.Server.Task.t()),
in_progress: %{reference() => Clarity.Server.Task.t()},
graph: Clarity.Graph.t(),
introspectors: [module()],
custom_introspectors: [module()] | nil,
name: GenServer.name()
}
end
@doc false
@spec start_link(opts :: GenServer.options()) :: GenServer.on_start()
def start_link(opts \\ []) do
{introspectors, gen_server_opts} = Keyword.pop(opts, :introspectors)
init_opts = if introspectors, do: [introspectors: introspectors], else: []
gen_server_opts = Keyword.put_new(gen_server_opts, :name, __MODULE__)
GenServer.start_link(__MODULE__, init_opts ++ gen_server_opts, gen_server_opts)
end
@doc """
Pull a task from the work queue (used by workers).
"""
@spec pull_task(GenServer.server()) :: {:ok, Clarity.Server.Task.t()} | :empty
def pull_task(server) do
GenServer.call(server, :pull_task)
end
@doc """
Acknowledge task completion (used by workers).
"""
@spec ack_task(GenServer.server(), reference(), [Clarity.Introspector.entry()]) :: :ok
def ack_task(server, task_id, result) do
GenServer.cast(server, {:ack_task, task_id, result})
end
@doc """
Report task failure (used by workers).
"""
@spec nack_task(GenServer.server(), reference(), term()) :: :ok
def nack_task(server, task_id, error) do
GenServer.cast(server, {:nack_task, task_id, error})
end
@doc """
Re-queue a task due to unmet dependencies. Task will be pushed to the back of
the queue.
"""
@spec requeue_task(GenServer.server(), reference()) :: :ok
def requeue_task(server, task_id) do
GenServer.cast(server, {:requeue_task, task_id})
end
@impl GenServer
def init(opts) do
# Create new graph
graph = Clarity.Graph.new()
# Get introspectors from options or use defaults
custom_introspectors = Keyword.get(opts, :introspectors)
# Create initial state with empty graph
initial_state = %State{
graph: graph,
custom_introspectors: custom_introspectors,
introspectors: custom_introspectors || [],
name: Keyword.fetch!(opts, :name),
future_queue: :queue.new(),
requeue_queue: :queue.new()
}
{future_queue, introspectors} =
reset_queue_and_introspectors(initial_state.graph, custom_introspectors)
state = %{
initial_state
| future_queue: future_queue,
introspectors: introspectors
}
broadcast_event(state, :work_started)
{:ok, state}
end
@impl GenServer
def handle_cast({:introspect, :full}, state) do
:ok = Clarity.Graph.clear(state.graph)
{future_queue, introspectors} =
reset_queue_and_introspectors(state.graph, state.custom_introspectors)
state = %{
state
| future_queue: future_queue,
introspectors: introspectors
}
{:noreply, state}
end
def handle_cast({:introspect, {:incremental, app, modules_diff}}, state) do
if contains_introspector_modules?(modules_diff, state.introspectors) do
Logger.info("Introspector modules changed, falling back to full introspection")
# Fall back to full introspection - introspector logic may have changed
handle_cast({:introspect, :full}, state)
else
Logger.info(
"Performing incremental introspection for app #{app}: #{length(modules_diff.changed)} changed, #{length(modules_diff.added)} added, #{length(modules_diff.removed)} removed modules"
)
# Find the Application vertex for this app
app_vertex = find_application_vertex(state.graph, app)
if app_vertex do
# Process incrementally
modules_to_purge = modules_diff.changed ++ modules_diff.removed
modules_to_add = modules_diff.changed ++ modules_diff.added
# 1. Find and purge vertices for changed + removed modules
module_vertices = find_module_vertices(state.graph, modules_to_purge)
Enum.each(module_vertices, &Clarity.Graph.purge(state.graph, &1))
# 2. Create new vertices and edges for changed + added modules
introspection_results =
Clarity.Introspector.Module.introspect_modules(
app_vertex,
modules_to_add,
state.graph
)
# 3. Process the results directly by creating a task
task = %Clarity.Server.Task{
id: make_ref(),
vertex: app_vertex,
introspector: Clarity.Introspector.Module,
graph: state.graph
}
state = process_task_results(task, introspection_results, state)
{:noreply, state}
else
Logger.warning(
"Application #{app} not found in graph, skipping incremental introspection"
)
{:noreply, state}
end
end
end
def handle_cast({:ack_task, task_id, result}, state) do
case Map.pop(state.in_progress, task_id) do
{nil, _} ->
Logger.warning(
"Received nack for unknown task #{inspect(task_id)}. The task may have already been acknowledged or requeued."
)
{:noreply, state}
{task, remaining_in_progress} ->
new_state =
process_task_results(task, result, %{state | in_progress: remaining_in_progress})
{:noreply, new_state}
end
end
def handle_cast({:nack_task, task_id, error}, state) do
case Map.pop(state.in_progress, task_id) do
{nil, _} ->
Logger.warning(
"Received ack for unknown task #{inspect(task_id)}. The task may have already been acknowledged or requeued."
)
{:noreply, state}
{task, remaining_in_progress} ->
Logger.warning(
"Task failed: #{Clarity.Server.Task.describe(task)}, error: #{inspect(error)}"
)
{:noreply, %{state | in_progress: remaining_in_progress}}
end
end
def handle_cast({:requeue_task, task_id}, state) do
case Map.pop(state.in_progress, task_id) do
{nil, _} ->
Logger.warning(
"Received requeue for unknown task #{inspect(task_id)}. The task may have already been acknowledged or requeued."
)
{:noreply, state}
{%{requeue_count: count} = task, remaining_in_progress} when count >= 100 ->
Logger.warning(
"Dropping task after #{count + 1} requeue attempts: #{Clarity.Server.Task.describe(task)}"
)
{:noreply, %{state | in_progress: remaining_in_progress}}
{task, remaining_in_progress} ->
task = %{task | requeue_count: task.requeue_count + 1}
{:noreply,
%{
state
| in_progress: remaining_in_progress,
requeue_queue: :queue.in(task, state.requeue_queue)
}}
end
end
@impl GenServer
def handle_call(:get, _from, state) do
status = if queue_empty_and_no_progress?(state), do: :done, else: :working
clarity = %Clarity{
graph: state.graph,
status: status,
queue_info: queue_info(state)
}
{:reply, clarity, state}
end
def handle_call(:pull_task, from, state) do
case :queue.out(state.future_queue) do
{{:value, task}, remaining_queue} ->
new_in_progress = Map.put(state.in_progress, task.id, task)
new_state = %{state | future_queue: remaining_queue, in_progress: new_in_progress}
{:reply, {:ok, task}, new_state}
{:empty, _queue} ->
cond do
# Still have in-progress tasks - wait for them to complete
# If we didn't do this, we could end up in a busy loop
# if all tasks kept failing with unmet dependencies
# while a longer running task is still in progress producing those
# dependencies
state.in_progress != %{} ->
{:reply, :empty, state}
# No in-progress tasks and have requeued tasks - swap queues
not :queue.is_empty(state.requeue_queue) ->
state = %{state | future_queue: state.requeue_queue, requeue_queue: :queue.new()}
broadcast_event(state, :__restart_pulling__)
handle_call(:pull_task, from, state)
true ->
{:reply, :empty, state}
end
end
end
defoverridable handle_cast: 2, handle_call: 3
@impl GenServer
def handle_cast(msg, state) do
{:noreply, new_state} = super(msg, state)
handle_state_change(state, new_state)
{:noreply, new_state}
end
@impl GenServer
def handle_call(msg, from, state) do
{:reply, reply, new_state} = super(msg, from, state)
handle_state_change(state, new_state)
{:reply, reply, new_state}
end
@spec handle_state_change(old :: State.t(), new :: State.t()) :: :ok
def handle_state_change(old, new)
def handle_state_change(state, state), do: :ok
def handle_state_change(old, new) do
cond do
queue_empty_and_no_progress?(old) and not queue_empty_and_no_progress?(new) ->
broadcast_event(new, :work_started)
broadcast_event(new, {:work_progress, queue_info(new)})
not queue_empty_and_no_progress?(old) and queue_empty_and_no_progress?(new) ->
broadcast_event(new, :work_completed)
true ->
broadcast_event(new, {:work_progress, queue_info(new)})
end
end
@spec create_tasks_for_vertex(Clarity.Vertex.t(), [module()], Clarity.Graph.t()) :: [
Clarity.Server.Task.t()
]
defp create_tasks_for_vertex(vertex, introspectors, graph) do
vertex_type = vertex.__struct__
introspectors
|> Enum.filter(fn introspector ->
vertex_type in introspector.source_vertex_types()
end)
|> Enum.map(&Clarity.Server.Task.new_introspection(vertex, &1, graph))
end
@spec process_task_results(Clarity.Server.Task.t(), [Clarity.Introspector.entry()], State.t()) ::
State.t()
defp process_task_results(task, results, state) do
# First pass: collect all vertices created by this introspection
created_vertices =
results
|> Enum.filter(&match?({:vertex, _}, &1))
|> MapSet.new(fn {:vertex, vertex} -> vertex end)
# Add the causing vertex to allowed vertices for edge validation
allowed_edge_vertices = MapSet.put(created_vertices, task.vertex)
new_tasks =
Enum.flat_map(results, fn
{:vertex, vertex} ->
# Add vertex using Graph with provenance tracking
Clarity.Graph.add_vertex(state.graph, vertex, task.vertex)
# Create tasks for this new vertex
create_tasks_for_vertex(
vertex,
state.introspectors,
state.graph
)
{:edge, nil, _to_vertex, _label} ->
[]
{:edge, _from_vertex, nil, _label} ->
[]
{:edge, from_vertex, to_vertex, label} ->
# Validate edge provenance - either from_vertex OR to_vertex must be allowed
if MapSet.member?(allowed_edge_vertices, from_vertex) or
MapSet.member?(allowed_edge_vertices, to_vertex) do
# Add edge using Graph
Clarity.Graph.add_edge(state.graph, from_vertex, to_vertex, label)
else
Logger.warning(
"Discarding invalid edge: neither from_vertex #{inspect(from_vertex)} nor to_vertex #{inspect(to_vertex)} " <>
"were created by this introspection or are the causing vertex #{inspect(task.vertex)}. " <>
"Introspectors should only create edges that reference the causing vertex or vertices they create."
)
end
[]
end)
# Add new tasks to the queue
new_queue = Enum.reduce(new_tasks, state.future_queue, &:queue.in(&1, &2))
# Tree is maintained incrementally, no rebuild needed
state = %{state | future_queue: new_queue}
state
end
@spec queue_empty_and_no_progress?(State.t()) :: boolean()
defp queue_empty_and_no_progress?(state) do
:queue.is_empty(state.future_queue) && :queue.is_empty(state.requeue_queue) &&
map_size(state.in_progress) == 0
end
@spec broadcast_event(State.t(), Clarity.event()) :: :ok
defp broadcast_event(state, event) do
topic =
case event do
topic when is_atom(topic) -> topic
tuple when is_tuple(tuple) -> elem(tuple, 0)
end
for name <- [state.name, self()] do
Registry.dispatch(Clarity.PubSub, {name, topic}, fn entries ->
for {pid, ref} <- entries do
case ref do
nil -> send(pid, {:clarity, event})
_ -> send(pid, {:clarity, ref, event})
end
end
end)
end
:ok
end
@spec queue_info(State.t()) :: Clarity.queue_info()
defp queue_info(state) do
%{
future_queue: :queue.len(state.future_queue),
requeue_queue: :queue.len(state.requeue_queue),
in_progress: map_size(state.in_progress),
total_vertices: Clarity.Graph.vertex_count(state.graph)
}
end
@spec reset_queue_and_introspectors(Clarity.Graph.t(), [module()] | nil) ::
{:queue.queue(Clarity.Server.Task.t()), [module()]}
defp reset_queue_and_introspectors(graph, custom_introspectors) do
root_vertex = %Root{}
introspectors = custom_introspectors || Clarity.Config.list_introspectors()
initial_tasks =
create_tasks_for_vertex(root_vertex, introspectors, graph)
queue = Enum.reduce(initial_tasks, :queue.new(), &:queue.in(&1, &2))
{queue, introspectors}
end
@spec contains_introspector_modules?(Clarity.modules_diff(), [Clarity.Introspector.t()]) ::
boolean()
defp contains_introspector_modules?(modules_diff, introspectors) do
all_changed = modules_diff.changed ++ modules_diff.added ++ modules_diff.removed
# The introspectors list already contains module names
introspector_modules = introspectors
not MapSet.disjoint?(MapSet.new(all_changed), MapSet.new(introspector_modules))
end
@spec find_module_vertices(Clarity.Graph.t(), [module()]) :: [Clarity.Vertex.Module.t()]
defp find_module_vertices(graph, module_names) do
graph
|> Clarity.Graph.vertices()
|> Enum.filter(fn
%Clarity.Vertex.Module{module: mod} -> mod in module_names
_ -> false
end)
end
@spec find_application_vertex(Clarity.Graph.t(), Application.app()) ::
Clarity.Vertex.Application.t() | nil
defp find_application_vertex(graph, app) do
graph
|> Clarity.Graph.vertices()
|> Enum.find(fn
%Clarity.Vertex.Application{app: ^app} -> true
_ -> false
end)
end
end