lib/runbox/state_store/savepoint.ex

defmodule Runbox.StateStore.Savepoint do
  @moduledoc group: :internal
  @moduledoc """
  Savepoint represents state of runtime run valid to scheduled timestamp.
  """

  alias __MODULE__, as: Savepoint
  alias Runbox.Runtime.Stage.UnitRegistry
  alias Runbox.StateStore.Entity
  alias Runbox.StateStore.ScheduleUtils

  defstruct timestamp: 0,
            stats: %{registered: 0, saved: 0},
            entity_registry: %{}

  @type t :: %Savepoint{
          timestamp: ScheduleUtils.epoch_ms(),
          stats: %{registered: integer, saved: integer},
          entity_registry: %{Entity.id() => :not_set_yet | Entity.state()}
        }

  @doc """
  Creates new savepoint with given timestamp and register entities.
  """
  @spec new([Entity.id()], ScheduleUtils.epoch_ms()) :: t
  def new(entity_ids, timestamp) do
    %Savepoint{
      timestamp: timestamp,
      entity_registry: Map.new(entity_ids, fn entity_id -> {entity_id, :not_set_yet} end),
      stats: %{registered: Enum.count(entity_ids), saved: 0}
    }
  end

  @doc """
  Returns savepoint timestamp.
  """
  @spec timestamp(t) :: ScheduleUtils.epoch_ms()
  def timestamp(%Savepoint{} = savepoint) do
    savepoint.timestamp
  end

  @doc """
  Returns all registered entity ids.
  """
  @spec registered_entities(t) :: [Entity.id()]
  def registered_entities(%Savepoint{} = savepoint) do
    Map.keys(savepoint.entity_registry)
  end

  @doc """
  Saves entity state in given savepoint.
  """
  @spec save(t, Entity.id(), Entity.state()) :: t
  def save(%Savepoint{entity_registry: entity_registry} = savepoint, entity_id, entity_state) do
    case Map.fetch(entity_registry, entity_id) do
      {:ok, :not_set_yet} ->
        state = erase_nonpersisted_values(entity_state)
        entity_registry = Map.put(entity_registry, entity_id, state)
        stats = %{savepoint.stats | saved: savepoint.stats.saved + 1}
        %Savepoint{savepoint | entity_registry: entity_registry, stats: stats}

      {:ok, _} ->
        # entity state has been already set = ignore
        savepoint

      :error ->
        # entity is not registered = ignore
        savepoint
    end
  end

  @doc """
  Returns entities with saved states.
  """
  @spec entity_states(t) :: [{Entity.id(), Entity.state()}]
  def entity_states(%Savepoint{entity_registry: entity_registry}) do
    Map.to_list(entity_registry)
  end

  @doc """
  Predicate returning true when all registered states has been saved.
  """
  @spec all_saved?(t) :: boolean
  def all_saved?(%Savepoint{stats: %{registered: r, saved: s}}) do
    r == s
  end

  @doc """
  Returns registered entities count of given savepoint.
  """
  @spec registered_entities_count(t) :: integer
  def registered_entities_count(%Savepoint{stats: %{registered: r}}) do
    r
  end

  # public only for tests
  @doc false
  @spec fetch_entity_state(t, Entity.id()) :: {:ok, :not_set_yet | Entity.state()} | :error
  def fetch_entity_state(%Savepoint{} = savepoint, entity_id) do
    Map.fetch(savepoint.entity_registry, entity_id)
  end

  # public only for tests
  @doc false
  @spec saved_entities_count(t) :: integer
  def saved_entities_count(%Savepoint{stats: %{saved: s}}) do
    s
  end

  defp erase_nonpersisted_values(%UnitRegistry{} = registry) do
    %UnitRegistry{registry | parse_msg_fns: nil, register_unit_fns: nil}
  end

  defp erase_nonpersisted_values(other), do: other
end