/SchedEx

Simple scheduling for Elixir

Primary LanguageElixirMIT LicenseMIT

SchedEx

Build Status Module Version Hex Docs Total Download License Last Updated

SchedEx is a simple yet deceptively powerful scheduling library for Elixir. Though it is almost trivially simple by design, it enables a number of very powerful use cases to be accomplished with very little effort.

SchedEx is written by Mat Trudel, and development is generously supported by the fine folks at FunnelCloud.

For usage details, please refer to the documentation.

Basic Usage

In most contexts SchedEx.run_every is the function most commonly used. There are two typical use cases:

Static Configuration

This approach is useful when you want SchedEx to manage jobs whose configuration is static. At FunnelCloud, we use this approach to run things like our hourly reports, cleanup tasks and such. Typically, you will start jobs inside your application.ex file:

defmodule Example.Application do
  use Application

  def start(_type, _args) do
    children = [
      # Call Runner.do_frequent/0 every five minutes
      %{ id: "frequent", start: {SchedEx, :run_every, [Example.Runner, :do_frequent, [], "*/5 * * * *"]} },

      # Call Runner.do_daily/0 at 1:01 UTC every day
      %{ id: "daily", start: {SchedEx, :run_every, [Example.Runner, :do_daily, [], "1 1 * * *"]} },

      # You can also pass a function instead of an m,f,a:
      %{ id: "hourly", start: {SchedEx, :run_every, [fn -> IO.puts "It is the top of the hour" end, "0 * * * *"]} }
    ]

    opts = [strategy: :one_for_one, name: Example.Supervisor]
    Supervisor.start_link(children, opts)
  end
end

This will cause the corresponding methods to be run according to the specified crontab entries. If the jobs crash they also take down the SchedEx process which ran them (SchedEx does not provide supervision by design). Your application's Supervisor will then restart the relevant SchedEx process, which will continue to run according to its crontab entry.

Dynamically Scheduled Tasks

SchedEx is especially suited to running tasks which run on a schedule and may be dynamically configured by the user. For example, at FunnelCloud we have a ScheduledTask Ecto schema with a string field called crontab. At startup our scheduled_task application reads entries from this table, determines the module, function, argument which should be invoked when the task comes due, and adds a SchedEx job to a supervisor:

def start_scheduled_tasks(sup, scheduled_tasks) do
  scheduled_tasks
  |> Enum.map(&child_spec_for_scheduled_task/1)
  |> Enum.map(&(DynamicSupervisor.start_child(sup, &1)))
end

defp child_spec_for_scheduled_task(%ScheduledTask{id: id, crontab: crontab} = task) do
  %{id: "scheduled-task-#{id}", start: {SchedEx, :run_every, mfa_for_task(task) ++ [crontab]}}
end

defp mfa_for_task(task) do
  # Logic that returns the [m, f, a] that should be invoked when task comes due
  [IO, :puts, ["Hello, scheduled task: #{inspect task}"]]
end

This will start one SchedEx process per ScheduledTask, all supervised within a DynamicSupervisor. If either SchedEx or the invoked function crashes DynamicSupervisor will restart it, making this approach robust to failures anywhere in the application. Note that the above is somewhat simplified - in production we have some additional logic to handle starting / stopping / reloading tasks on user change.

You can optionally pass a name to the task that would allow you to lookup the task later with Registry or gproc and remove it like so:

child_spec = %{
  id: "scheduled-task-#{id}",
  start:
    {SchedEx, :run_every,
     mfa_for_task(task) ++
       [crontab, [name: {:via, Registry, {RegistryName, "scheduled-task-#{id}"}}]]}
}

def get_scheduled_item(id) do
  #ie. "scheduled-task-1"
  list = Registry.lookup(RegistryName, id)

  if length(list) > 0 do
    {pid, _} = hd(list)
    {:ok, pid}
  else
    {:error, "does not exist"}
  end
end

def cancel_scheduled_item(id) do
  with {:ok, pid} <- get_scheduled_item(id) do
    DynamicSupervisor.terminate_child(DSName, pid)
  end
end

Then in your children in application.ex

{Registry, keys: :unique, name: RegistryName},
{DynamicSupervisor, strategy: :one_for_one, name: DSName},

Other Functions

In addition to SchedEx.run_every, SchedEx provides two other methods which serve to schedule jobs; SchedEx.run_at, and SchedEx.run_in. As the names suggest, SchedEx.run_at takes a DateTime struct which indicates the time at which the job should be executed, and SchedEx.run_in takes a duration in integer milliseconds from the time the function is called at which to execute the job. Similarly to SchedEx.run_every, these functions both come in module, function, argument and fn form.

The above functions have the same return values as standard start_link functions ({:ok, pid} on success, {:error, error} on error). The returned pid can be passed to SchedEx.cancel to cancel any further invocations of the job.

Crontab details

SchedEx uses the crontab library to parse crontab strings. If it is unable to parse the given crontab string, an error is returned from the SchedEx.run_every call and no jobs are scheduled.

Building on the support provided by the crontab library, SchedEx supports extended crontabs. Such crontabs have 7 segments instead of the usual 5; one is added to the beginning of the crontab and expresses a seconds value, and one added to the end expresses a year value. As such, it's possible to specify a unique instant down to the second, for example:

50 59 23 31 12 * 1999     # You'd better be getting ready to party

Jobs scheduled via SchedEx.run_every are implicitly recurring; they continue to to execute according to the crontab until SchedEx.cancel/1 is called or the original calling process terminates. If job execution takes longer than the scheduling interval, the job is requeued at the next matching interval (for example, if a job set to run every minute (crontab * * * * *) takes 61 seconds to run at minute x it will not run at minute x+1 and will next run at minute x+2).

Testing

SchedEx has a feature called TimeScales which help provide a performant and high parity environment for testing scheduled code. When invoking SchedEx.run_every or SchedEx.run_in, you can pass an optional time_scale parameter which allows you to change the speed at which time runs within SchedEx. This allows you to run an entire day (or longer) worth of scheduling time in a much shorter amount of real time. For example:

defmodule ExampleTest do
  use ExUnit.Case

  defmodule AgentHelper do
    def set(agent, value) do
      Agent.update(agent, fn _ -> value end)
    end

    def get(agent) do
      Agent.get(agent, & &1)
    end
  end

  defmodule TestTimeScale do
    def now(_) do
      DateTime.utc_now()
    end

    def speedup do
      86400
    end
  end

  test "updates the agent at 10am every morning" do
    {:ok, agent} = start_supervised({Agent, fn -> nil end})

    SchedEx.run_every(AgentHelper, :set, [agent, :sched_ex_scheduled_time], "* 10 * * *", time_scale: TestTimeScale)

    # Let SchedEx run through a day's worth of scheduling time
    Process.sleep(1000)

    expected_time = Timex.now() |> Timex.beginning_of_day() |> Timex.shift(hours: 34)
    assert DateTime.diff(AgentHelper.get(agent), expected_time) == 0
  end
end

will run through an entire day's worth of scheduling time in one second, and allows us to test against the expectations of the called function quickly, while maintaining near-perfect parity with development. The only thing that changes in the test environment is the passing of a time_scale; all other code is exactly as it is in production.

Note that in the above test, the atom :sched_ex_scheduled_time is passed as a value in the argument array. This atom is treated specially by SchedEx, and is replaced by the scheduled invocation time for which the function is being called.

Installation

SchedEx can be installed by adding :sched_ex to your list of dependencies in mix.exs:

def deps do
  [
    {:sched_ex, "~> 1.0"}
  ]
end

Copyright and License

Copyright (c) 2018 Mat Trudel on behalf of FunnelCloud Inc.

This work is free. You can redistribute it and/or modify it under the terms of the MIT License. See the LICENSE.md file for more details.