lib/openagents/release_state.ex

58e6347eeb72 · 3 KB

defmodule OpenAgents.ReleaseState do
  @moduledoc """
  Holds bounded runtime observations across supported release upgrades.

  The process uses an explicitly versioned state struct whose schema is
  compiled into each release from `OPENAGENTS_RELUP_STATE_VERSION`. A two-way
  relup calls `code_change/3`, which preserves the PID and observations while
  adding or removing schema 2's integrity field.

  The target schema is explicit, not positional: `OpenAgents.Release.Appup`
  puts the installing release's schema in the appup's `extra` term for each
  direction. A pair whose schemas match on both sides therefore keeps its
  schema through a downgrade instead of being forced back to schema 1.
  """

  use GenServer

  alias OpenAgents.ReleaseState.State

  @current_schema System.get_env("OPENAGENTS_RELUP_STATE_VERSION", "2")
                  |> String.to_integer()
  @maximum_observations 100

  if @current_schema not in [1, 2] do
    raise "OPENAGENTS_RELUP_STATE_VERSION must be 1 or 2"
  end

  def start_link(opts \\ []) do
    case Keyword.get(opts, :name, __MODULE__) do
      nil -> GenServer.start_link(__MODULE__, opts)
      name -> GenServer.start_link(__MODULE__, opts, name: name)
    end
  end

  @doc "Return the process's current state."
  def snapshot(server \\ __MODULE__), do: GenServer.call(server, :snapshot)

  @doc "Store one bounded observation for relup continuity checks."
  def observe(value, server \\ __MODULE__), do: GenServer.call(server, {:observe, value})

  @doc "Return the schema version compiled into this release."
  def current_schema, do: @current_schema

  @doc false
  def install_barrier do
    with path when is_binary(path) <- System.get_env("OPENAGENTS_RELUP_INSTALL_BARRIER_PATH"),
         delay when is_binary(delay) <- System.get_env("OPENAGENTS_RELUP_INSTALL_BARRIER_MS"),
         {milliseconds, ""} <- Integer.parse(delay),
         true <- milliseconds > 0,
         false <- File.exists?(path) do
      File.write!(path, "entered\n", [:exclusive])

      receive do
      after
        milliseconds -> :ok
      end
    else
      _not_enabled -> :ok
    end
  end

  @impl true
  def init(_opts), do: {:ok, state_for(@current_schema, [])}

  @impl true
  def handle_call(:snapshot, _from, state), do: {:reply, state, state}

  def handle_call({:observe, value}, _from, %State{} = state) do
    observations = Enum.take([value | state.observations], @maximum_observations)
    next = state_for(state.schema_version, observations)
    {:reply, :ok, next}
  end

  @impl true
  def code_change({:down, _from_version}, %State{} = state, extra) do
    # A downgrade runs this clause in the new module before the old code is
    # loaded, so the target schema belongs to the release being installed and
    # this module cannot infer it. The appup carries it in `extra`; without it
    # the migration refuses rather than guessing a schema.
    case target_schema(extra) do
      {:ok, schema} -> {:ok, state_for(schema, state.observations)}
      :error -> {:error, :missing_downgrade_schema_version}
    end
  end

  def code_change(_from_version, %State{} = state, extra) do
    # An upgrade runs in the release being installed, so this release's own
    # compiled schema is the target when the appup names none.
    case target_schema(extra) do
      {:ok, schema} -> {:ok, state_for(schema, state.observations)}
      :error -> {:ok, state_for(@current_schema, state.observations)}
    end
  end

  defp target_schema(extra) when is_list(extra) do
    Enum.find_value(extra, :error, fn
      {:schema_version, schema} when schema in [1, 2] -> {:ok, schema}
      _other -> nil
    end)
  end

  defp target_schema(_extra), do: :error

  defp state_for(1, observations) do
    %State{schema_version: 1, observations: observations, integrity: nil}
  end

  defp state_for(2, observations) do
    integrity =
      :crypto.hash(:sha256, :erlang.term_to_binary(observations)) |> Base.encode16(case: :lower)

    %State{schema_version: 2, observations: observations, integrity: integrity}
  end
end