>> 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.