lib/mongo/read_preference.ex

defmodule Mongo.ReadPreference do
  import Keywords

  @moduledoc ~S"""
  Determines which servers are considered suitable for read operations

  A read preference consists of a mode and optional `tag_sets`, max_staleness_ms, and `hedge`.
  The mode prioritizes between primaries and secondaries to produce either a single suitable server or a list of candidate servers.
  If tag_sets and maxStalenessSeconds are set, they determine which candidate servers are eligible for selection.
  If hedge is set, it configures how server hedged reads are used.

  The default mode is `:primary`.
  The default tag_sets is a list with an empty tag set: [{}].
  The default max_staleness_ms is unset.
  The default hedge is unset.

  ## mode

  * `:primary` Only an available primary is suitable.
  * `:secondary` All secondaries (and only secondaries) are candidates, but only eligible candidates (i.e. after applying tag_sets and maxStalenessSeconds) are suitable.
  * `:primary_preferred` If a primary is available, only the primary is suitable. Otherwise, all secondaries are candidates,
       but only eligible secondaries are suitable.
  * `:secondary_preferred` All secondaries are candidates. If there is at least one eligible secondary, only eligible secondaries are suitable.
       Otherwise, when there are no eligible secondaries, the primary is suitable.
  * `:nearest` The primary and all secondaries are candidates, but only eligible candidates are suitable.

  """
  @type t :: %{
          mode:
            :primary
            | :secondary
            | :primary_preferred
            | :secondary_preferred
            | :nearest,
          tag_sets: [%{String.t() => String.t()}],
          max_staleness_ms: non_neg_integer,
          hedge: BSON.document()
        }

  @primary %{
    mode: :primary,
    tag_sets: [],
    max_staleness_ms: 0
  }

  def primary(map \\ nil)

  def primary(map) when is_map(map) do
    Map.merge(@primary, map)
  end

  def primary(_), do: @primary

  @doc """
  Add read preference to the cmd
  """
  def add_read_preference(cmd, opts) do
    case Keyword.get(opts, :read_preference) do
      nil -> cmd
      pref -> cmd ++ ["$readPreference": pref]
    end
  end

  @doc """
  From the specs:

  Use of slaveOk

  There are two usages of slaveOK:

  * A driver query parameter that predated read preference modes and tag set lists.
  * A wire protocol flag on OP_QUERY operations

  """
  def slave_ok(%{:mode => :primary}) do
    %{:mode => :primary}
  end

  def slave_ok(config) do
    mode =
      case config[:mode] do
        :primary_preferred -> :primaryPreferred
        :secondary_preferred -> :secondaryPreferred
        other -> other
      end

    filter_nils(mode: mode, tag_sets: config[:tag_sets])
  end

  ##
  # Therefore, when sending queries to a mongos, the following rules apply:
  #
  # For mode 'primary', drivers MUST NOT set the slaveOK wire protocol flag and MUST NOT use $readPreference
  def mongos(%{mode: :primary}) do
    nil
  end

  # For mode 'secondary', drivers MUST set the slaveOK wire protocol flag and MUST also use $readPreference
  def mongos(%{mode: :secondary} = config) do
    transform(config)
  end

  # For mode 'primaryPreferred', drivers MUST set the slaveOK wire protocol flag and MUST also use $readPreference
  def mongos(%{mode: :primary_preferred} = config) do
    transform(config)
  end

  # For mode 'secondaryPreferred', drivers MUST set the slaveOK wire protocol flag. If the read preference contains a
  # non-empty tag_sets parameter, maxStalenessSeconds is a positive integer, or the hedge parameter is non-empty,
  # drivers MUST use $readPreference; otherwise, drivers MUST NOT use $readPreference
  def mongos(%{mode: :secondary_preferred} = config) do
    transform(config)
  end

  # For mode 'nearest', drivers MUST set the slaveOK wire protocol flag and MUST also use $readPreference
  def mongos(%{mode: :nearest} = config) do
    transform(config)
  end

  defp transform(config) do
    mode =
      case config[:mode] do
        :primary_preferred -> :primaryPreferred
        :secondary_preferred -> :secondaryPreferred
        other -> other
      end

    max_staleness_seconds =
      case config[:max_staleness_ms] do
        i when is_integer(i) -> div(i, 1000)
        nil -> nil
      end

    [mode: mode, tag_sets: config[:tag_sets], maxStalenessSeconds: max_staleness_seconds, hedge: config[:hedge]]
    |> filter_nils()
  end
end