>> Problem

I have several GenServers that sync data from a third party service and store them in-memory to reduce network calls for a GraphQL client. My usual formula is:

defmodule MyApp.ExternalService do
  use GenServer

  alias __MODULE__, as: State

  defstruct [:data]

  def start_link(opts) do
    GenServer(__MODULE__, opts, name: __MODULE__)
  end

  def get(),
    do: GenServer.call(__MODULE__, :get)

  @impl true
  def init(_opts) do
    {:ok, _ref} = :timer.send_interval(sync_interval(), self(), :__sync__)

    {:ok, %State{data: nil}}
  end

  @impl true
  def handle_call(:get, _from, %State{data: data} = state),
    do: {:reply, data, state}

  @impl true
  def handle_info(:__sync__, state) do
    new_data = Enum.random(1..100)  # Pretend randomness is an API call

    {:noreply, %{state | data: new_data}}
  end

  def sync_interval(),
    do: config()[:sync_interval] || :timer.minutes(5)

  def config(),
    do: Application.get_all_env(:my_app, __MODULE__)
end

The toy module above is an interface to an external service and syncs data every five minutes. The trick is :timer.send_interval/3 which triggers handle_info(:__sync__, state) periodically in milliseconds. For its runtime configuration with distillery and TOML, it looks like this:

[my_app."MyApp.ExternalService"]
sync_interval = 300000 # :timer.minutes(5)

Since I have many of these, my problem is that reading an integer value to represent an interval is harder to read, communicate and configure as a team. While I still standardize to milliseconds, a string-like representation or standard format would be nice to express various intervals like:

[my_app."MyApp.ExternalService"]
sync_interval = "5 min"

[my_app."MyApp.HeavyService"]
fast_sync_interval = "1 day"
slow_sync_interval = [30, "min"]  # Requires custom transformer

>> Solution

A standard date duration format already exists: ISO 8601 Duration. Here are some sample representations to build intuition:

"P1Y"       # 1 year(s)
"P2M1W"     # 2 months and 1 week(s)
"P1Y1D"     # 1 year(s) and 1 day(s)

"PT5M"      # 5 minutes
"PT1H5S"    # 1 hour(s) and 5 seconds

"P1DT12H"   # 1 day(s) and 12 hours

It does feel rather too compact and mentally parsing it might take time. My suggestion is to think of it as having a date and time part, then reading slowly the abbreviations based on the part. The date/time library, timex, can parse these expressions and be easily integrated:

defmodule MyApp.ExternalService do
  # Same code as before

  alias Timex.Duration

  def sync_interval() do
    (config()[:sync_interval] || "PT5M")
    |> Duration.parse!()
    |> Duration.to_milliseconds() # Since this function may return a float...
    |> round()                    # ... we need to cast it to integer
  end
end

The format looks promising in TOML:

[my_app."MyApp.ExternalService"]
sync_interval = "PT5M"

To drive the point further, it can also express future dates well such as expiration dates. For example, a reset password link expires in 2 days in development but 30 minutes in production for security:

defmodule MyApp.Accounts do
  alias Ecto.UUID
  alias Timex.Duration
  alias MyApp.{User, Repo}

  def reset_password(id) do
    user = Repo.get_by(User, id: id)

    user
    |> User.reset_password_changeset()
    |> Repo.update()

    # Then send email with Bamboo
  end

  def password_reset_expiration_period() do
    # Instead of:
    # (config()[:password_reset_expiration_period] || :timer.hours(2 * 24))
    (config()[:password_reset_expiration_period] || "P2D")
    |> Duration.parse!()
  end

  def config(),
    do: Application.get_all_env(:my_app, __MODULE__)
end

defmodule MyApp.User do
  use Ecto.Schema
  alias Ecto.{Changeset, UUID}

  def reset_password_changeset(entity) do
    # Instead of:
    # expiration_date = DateTime.add(DateTime.utc_now(), milliseconds: MyApp.password_reset_expiration_period())
    expiration_date = Timex.add(Date.utc_today(), MyApp.password_reset_expiration_period())

    entity
    |> Changeset.change(%{
      password_reset_id: UUID.generate(),
      password_reset_expiration: expiration_date
    })
  end
end

The configuration becomes standardized across the board as well:

# config/dev.exs
config :my_app, MyApp.Accounts,
  # Instead of:
  # password_reset_expiration_period: :timer.hours(2 * 24)
  password_reset_expiration_period: "P2D"

# config/prod.exs
conifg :my_app, MyApp.Accounts,
  # Instead of:
  # password_reset_expiration_period: :timer.minutes(15)
  password_reset_expiration_period: "PT15M"
[my_app."MyApp.Accounts"]
# Instead of:
# password_reset_expiration_period = 172800000 # :timer.hours(2 * 24)
# password_reset_expiration_period = 900000    # :timer.minutes(15)
password_reset_expiration_period = "P2D"
# or
password_reset_expiration_period = "PT15M"

Using milliseconds is still viable if the introduction of a new format or library is too much; however, one can write a simple and quick space delimited format to retort:

defmodule Duration do
  @formats %{
    "ms" => 1,
    "sec" => 1_000,
    "min" => 60 * 1_000,
    "hour" => 60 * 60 * 1_000,
    "day" => 24 * 60 * 60 * 1_000
  }

  def convert(text) do
    units = Map.keys(@formats)

    text
    |> String.trim()
    |> String.downcase()
    |> String.split(" ", trim: true)
    |> Enum.reduce_while(0, fn token, acc ->
      case Integer.parse(token) do
        {value, unit} when unit in units ->
          multiplier = Map.fetch!(units, unit)

          {:cont, acc + multiplier * value}

        _ -> {:halt, :error}
      end
    end)
    |> case do
         :error -> :error
         milliseconds -> {:ok, milliseconds}
       end
  end
end

Duration.convert("1hour")
Duration.convert("15min 20sec")

Nonetheless, consider using a human-readable format to make date/time configuration easier and more intuitive.

>> Notes

>>> Avoid Months and Years

As an exercise, I tried porting ms.js into an Elixir library, ex_ms:

import Millisecond, only: [ms!: 1, ms: 1]

ms!("1h 1m 1s")         # 86400000
ms!("1days 12 hours")   # 129600000
ms("12 hours 1days")    # :error

What I learned from this exercise is that representing duration with milliseconds is safe until it comes to months or years. How many days are there in a month? How many days are there in a year? It depends on both questions. Looking at Timex, it interprets a year as 365 days while with ChronicDuration as 365.25 days. Not that the libraries are incorrect but some precision is lost when dealing with months or years. So when describing a duration, avoid using months or years and use 30 and 366 days respectively for safety regardless of format or library.