diff --git a/README.md b/README.md new file mode 100644 index 0000000..56331b2 --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +# Quantum + +[Cron](https://en.wikipedia.org/wiki/Cron)-like job scheduler for [Elixir](http://elixir-lang.org/) applications. + +## Setup + +To use this plug in your projects, edit your mix.exs file and add the project as a dependency: + +```elixir +defp deps do + [ + { :quantum, ">= 1.0.0" } + ] +end +``` + +## Usage + +```elixir +Quantum.cron("0 18-6/2 * * *", fn -> IO.puts("it's late") end) +Quantum.cron("@daily", &backup/0) +``` + +## License + +[Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0) diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..6dfa82f --- /dev/null +++ b/config/config.exs @@ -0,0 +1,24 @@ +# This file is responsible for configuring your application +# and its dependencies with the aid of the Mix.Config module. +use Mix.Config + +# This configuration is loaded before any dependency and is restricted +# to this project. If another project depends on this project, this +# file won't be loaded nor affect the parent project. For this reason, +# if you want to provide default values for your application for third- +# party users, it should be done in your mix.exs file. + +# Sample configuration: +# +# config :logger, :console, +# level: :info, +# format: "$date $time [$level] $metadata$message\n", +# metadata: [:user_id] + +# It is also possible to import configuration files, relative to this +# directory. For example, you can emulate configuration per environment +# by uncommenting the line below and defining dev.exs, test.exs and such. +# Configuration from the imported file will override the ones defined +# here (which is why it is important to import them last). +# +# import_config "#{Mix.env}.exs" diff --git a/lib/quantum.ex b/lib/quantum.ex new file mode 100644 index 0000000..d5291c2 --- /dev/null +++ b/lib/quantum.ex @@ -0,0 +1,109 @@ +defmodule Quantum do + use GenServer + import Process, only: [send_after: 3] + + @days ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] + @months ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"] + + def start_link(options \\ []) do + GenServer.start_link(__MODULE__, %{}, [name: __MODULE__] ++ options) + end + + # Public functions ------------------------------------------------------------------------------ + + def cron("@yearly", fun), do: GenServer.cast(__MODULE__, {"0 0 1 1 *", fun}) + def cron("@monthly", fun), do: GenServer.cast(__MODULE__, {"0 0 1 * *", fun}) + def cron("@weekly", fun), do: GenServer.cast(__MODULE__, {"0 0 * * 0", fun}) + def cron("@daily", fun), do: GenServer.cast(__MODULE__, {"0 0 * * *", fun}) + def cron("@hourly", fun), do: GenServer.cast(__MODULE__, {"0 * * * *", fun}) + def cron(expression, fun), do: GenServer.cast(__MODULE__, {expression, fun}) + def reset, do: GenServer.cast(__MODULE__, :reset) + + # Private functions ----------------------------------------------------------------------------- + + def init(_) do + send_after(self, :tick, 1000) + {:ok, %{jobs: [], d: nil, h: nil, m: nil, w: nil}} + end + + def handle_info(:tick, state) do + send_after(self, :tick, 1000) + {d, {h, m, _}} = :calendar.now_to_universal_time(:os.timestamp) + if state.d != d do + state = %{state | w: rem(:calendar.day_of_the_week(d), 7)} + end + if state.m != m do + state = %{state | d: d, h: h, m: m} + Enum.each(state.jobs, fn({e, fun}) -> Task.start(__MODULE__, :execute, [e, fun, state]) end) + end + {:noreply, state} + end + def handle_info(_, state), do: {:noreply, state} + + def handle_cast(:reset, state) do + {:noreply, %{state | jobs: []}} + end + def handle_cast({ e, fun }, state) do + {:noreply, %{state | jobs: ["#{e |> String.downcase |> translate}": fun] ++ state.jobs}} + end + + def execute("* * * * *", fun, _), do: fun.() + def execute("0 * * * *", fun, %{m: 0}), do: fun.() + def execute("0 0 * * *", fun, %{m: 0, h: 0}), do: fun.() + def execute("0 0 1 * *", fun, %{m: 0, h: 0, d: {_, _, 1}}), do: fun.() + def execute("0 0 1 1 *", fun, %{m: 0, h: 0, d: {_, 1, 1}}), do: fun.() + def execute(e, fun, state) do + [m, h, d, n, w] = e |> String.split(" ") + {_, cur_mon, cur_day} = state.d + cond do + !match(m, state.m, 0, 59) -> false + !match(h, state.h, 0, 59) -> false + !match(d, cur_day, 1, 31) -> false + !match(n, cur_mon, 1, 12) -> false + !match(w, state.w, 0, 6) -> false + true -> fun.() + end + end + + defp translate(e) do + {e,_} = List.foldl(@days, {e,0}, fn(x, acc) -> translate(acc, x) end) + {e,_} = List.foldl(@months, {e,1}, fn(x, acc) -> translate(acc, x) end) + e + end + defp translate({e, i}, term), do: {String.replace(e, term, "#{i}"), i+1} + + defp match("*", _, _, _), do: true + defp match([], _, _, _), do: false + defp match([e|t], v, min, max), do: Enum.any?(parse(e, min, max), &(&1 == v)) or match(t, v, min, max) + defp match(e, v, min, max), do: match(e |> String.split(","), v, min, max) + + defp parse("*/" <> _ = e, min, max) do + [_,i] = e |> String.split("/") + {x,_} = i |> Integer.parse + Enum.reject(min..max, &(rem(&1, x) != 0)) + end + defp parse(e, min, max) do + [r|i] = e |> String.split("/") + [x|y] = r |> String.split("-") + {v,_} = x |> Integer.parse + parse(v, y, i, min, max) |> Enum.reject(&((&1 < min) or (&1 > max))) + end + defp parse(v, [], [], _, _), do: [v] + defp parse(v, [], i, _, _) do + {x,_} = i |> Integer.parse + [rem(v,i)] + end + defp parse(v, y, [], min, max) do + {t,_} = y |> Integer.parse + if v < t do + Enum.to_list(v..t) + else + Enum.to_list(v..max) ++ Enum.to_list(min..t) + end + end + defp parse(v, y, i, min, max) do + {x, _} = i |> Integer.parse + parse(v, y, [], min, max) |> Enum.reject(&(rem(&1, x) != 0)) + end + +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..09686cb --- /dev/null +++ b/mix.exs @@ -0,0 +1,45 @@ +defmodule Quantum.Mixfile do + use Mix.Project + + def project do + [ + app: :quantum, + version: "1.0.0", + elixir: "~> 1.0", + build_embedded: Mix.env == :prod, + start_permanent: Mix.env == :prod, + deps: deps, + description: "Cronjob scheduler for Elixir applications.", + package: package + ] + end + + # Configuration for the OTP application + # + # Type `mix help compile.app` for more information + def application do + [applications: [:logger]] + end + + # Dependencies can be Hex packages: + # + # {:mydep, "~> 0.3.0"} + # + # Or git/path repositories: + # + # {:mydep, git: "https://github.com/elixir-lang/mydep.git", tag: "0.1.0"} + # + # Type `mix help deps` for more examples and options + defp deps do + [] + end + + defp package do + %{ + contributors: ["Constantin Rack"], + licenses: ["Apache License 2.0"], + links: %{"Github" => "https://github.com/c-rack/quantum-elixir"} + } + end + +end diff --git a/test/quantum_test.exs b/test/quantum_test.exs new file mode 100644 index 0000000..4c73636 --- /dev/null +++ b/test/quantum_test.exs @@ -0,0 +1,20 @@ +defmodule QuantumTest do + use ExUnit.Case + + test "check hourly" do + Quantum.execute("0 * * * *", fn -> IO.puts("OK") end, %{ d: { 2015, 12, 31 }, h: 12, m: 0, w: 1 } ) + end + + test "parse */5" do + Quantum.execute("*/5 * * * *", fn -> IO.puts("OK") end, %{ d: { 2015, 12, 31 }, h: 12, m: 0, w: 1 } ) + end + + test "parse 5" do + Quantum.execute("5 * * * *", fn -> IO.puts("OK") end, %{ d: { 2015, 12, 31 }, h: 12, m: 5, w: 1 } ) + end + + test "counter example" do + Quantum.execute("5 * * * *", fn -> IO.puts("FAIL") end, %{ d: { 2015, 12, 31 }, h: 12, m: 0, w: 1 } ) + end + +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start()