-
-
Notifications
You must be signed in to change notification settings - Fork 293
/
cron.ex
286 lines (215 loc) · 8.16 KB
/
cron.ex
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
defmodule Oban.Plugins.Cron do
@moduledoc """
Periodically enqueue jobs through CRON based scheduling.
This plugin registers workers a cron-like schedule and enqueues jobs automatically. Periodic
jobs are declared as a list of `{cron, worker}` or `{cron, worker, options}` tuples.
> #### 🌟 DynamicCron {: .info}
>
> This plugin only loads the crontab statically, at boot time. To configure cron schedules
> dynamically at runtime, across your entire cluster, see the `DynamicCron` plugin in [Oban
> Pro](https://getoban.pro/docs/pro/Oban.Pro.Plugins.DynamicCron.html).
## Using the Plugin
Schedule various jobs using `{expr, worker}` and `{expr, worker, opts}` syntaxes:
config :my_app, Oban,
plugins: [
{Oban.Plugins.Cron,
crontab: [
{"* * * * *", MyApp.MinuteWorker},
{"0 * * * *", MyApp.HourlyWorker, args: %{custom: "arg"}},
{"0 0 * * *", MyApp.DailyWorker, max_attempts: 1},
{"0 12 * * MON", MyApp.MondayWorker, queue: :scheduled, tags: ["mondays"]},
{"@daily", MyApp.AnotherDailyWorker}
]}
]
## Options
* `:crontab` — a list of cron expressions that enqueue jobs on a periodic basis. See [Periodic
Jobs][perjob] in the Oban module docs for syntax and details.
* `:timezone` — which timezone to use when scheduling cron jobs. To use a timezone other than
the default of "Etc/UTC" you *must* have a timezone database like [tz][tz] installed and
configured.
[tz]: https://hexdocs.pm/tz
[perjob]: Oban.html#module-periodic-jobs
## Instrumenting with Telemetry
The `Oban.Plugins.Cron` plugin adds the following metadata to the `[:oban, :plugin, :stop]` event:
* :jobs - a list of jobs that were inserted into the database
"""
@behaviour Oban.Plugin
use GenServer
alias Oban.Cron.Expression
alias Oban.{Job, Peer, Plugin, Repo, Validation, Worker}
alias __MODULE__, as: State
@opaque expression :: Expression.t()
@type cron_input :: {binary(), module()} | {binary(), module(), [Job.option()]}
@type option ::
Plugin.option()
| {:crontab, [cron_input()]}
| {:timezone, Calendar.time_zone()}
defstruct [
:conf,
:timer,
crontab: [],
timezone: "Etc/UTC"
]
@doc false
@spec child_spec(Keyword.t()) :: Supervisor.child_spec()
def child_spec(opts), do: super(opts)
@impl Plugin
@spec start_link([option()]) :: GenServer.on_start()
def start_link(opts) do
{name, opts} = Keyword.pop(opts, :name)
state =
State
|> struct!(opts)
|> parse_crontab()
GenServer.start_link(__MODULE__, state, name: name)
end
@impl Plugin
def validate(opts) do
Validation.validate_schema(opts,
conf: :any,
crontab: {:custom, &validate_crontab/1},
name: :any,
timezone: :timezone
)
end
@doc """
Parse a crontab expression into a cron struct.
This is provided as a convenience for validating and testing cron expressions. As such, the cron
struct itself is opaque and the internals may change at any time.
The parser can handle common expressions that use minutes, hours, days, months and weekdays,
along with ranges and steps. It also supports common extensions, also called nicknames.
Returns `{:error, %ArgumentError{}}` with a detailed error if the expression cannot be parsed.
## Nicknames
The following special nicknames are supported in addition to standard cron expressions:
* `@yearly`—Run once a year, "0 0 1 1 *"
* `@annually`—Same as `@yearly`
* `@monthly`—Run once a month, "0 0 1 * *"
* `@weekly`—Run once a week, "0 0 * * 0"
* `@daily`—Run once a day, "0 0 * * *"
* `@midnight`—Same as `@daily`
* `@hourly`—Run once an hour, "0 * * * *"
* `@reboot`—Run once at boot
## Examples
iex> Oban.Plugins.Cron.parse("@hourly")
{:ok, #Oban.Cron.Expression<...>}
iex> Oban.Plugins.Cron.parse("0 * * * *")
{:ok, #Oban.Cron.Expression<...>}
iex> Oban.Plugins.Cron.parse("60 * * * *")
{:error, %ArgumentError{message: "expression field 60 is out of range 0..59"}}
"""
@spec parse(input :: binary()) :: {:ok, expression()} | {:error, Exception.t()}
defdelegate parse(input), to: Expression
@doc false
@spec interval_to_next_minute(Time.t()) :: pos_integer()
def interval_to_next_minute(time \\ Time.utc_now()) do
time
|> Time.add(60)
|> Map.put(:second, 0)
|> Time.diff(time)
|> Integer.mod(86_400)
|> :timer.seconds()
end
@impl GenServer
def init(state) do
Process.flag(:trap_exit, true)
:telemetry.execute([:oban, :plugin, :init], %{}, %{conf: state.conf, plugin: __MODULE__})
{:ok, schedule_evaluate(state)}
end
@impl GenServer
def terminate(_reason, %State{timer: timer}) do
if is_reference(timer), do: Process.cancel_timer(timer)
:ok
end
@impl GenServer
def handle_info(:evaluate, %State{} = state) do
meta = %{conf: state.conf, plugin: __MODULE__}
:telemetry.span([:oban, :plugin], meta, fn ->
case check_leadership_and_insert_jobs(state) do
{:ok, inserted_jobs} when is_list(inserted_jobs) ->
{:ok, Map.put(meta, :jobs, inserted_jobs)}
error ->
{:error, Map.put(meta, :error, error)}
end
end)
state =
state
|> discard_reboots()
|> schedule_evaluate()
{:noreply, state}
end
# Scheduling Helpers
defp schedule_evaluate(state) do
timer = Process.send_after(self(), :evaluate, interval_to_next_minute())
%{state | timer: timer}
end
defp discard_reboots(state) do
crontab = Enum.reject(state.crontab, fn {expr, _worker, _opts} -> expr.reboot? end)
%{state | crontab: crontab}
end
# Parsing & Validation Helpers
defp parse_crontab(%State{crontab: crontab} = state) do
parsed =
Enum.map(crontab, fn
{expression, worker} -> {Expression.parse!(expression), worker, []}
{expression, worker, opts} -> {Expression.parse!(expression), worker, opts}
end)
%{state | crontab: parsed}
end
defp validate_crontab(crontab) when is_list(crontab) do
Validation.validate(:crontab, crontab, &validate_crontab/1)
end
defp validate_crontab({expression, worker, opts}) do
with {:ok, _} <- parse(expression) do
cond do
not Code.ensure_loaded?(worker) ->
{:error, "#{inspect(worker)} not found or can't be loaded"}
not function_exported?(worker, :perform, 1) ->
{:error, "#{inspect(worker)} does not implement `perform/1` callback"}
not Keyword.keyword?(opts) ->
{:error, "options must be a keyword list, got: #{inspect(opts)}"}
not build_changeset(worker, opts).valid? ->
{:error, "expected valid job options, got: #{inspect(opts)}"}
true ->
:ok
end
end
end
defp validate_crontab({expression, worker}) do
validate_crontab({expression, worker, []})
end
defp validate_crontab(invalid) do
{:error,
"expected crontab entry to be an {expression, worker} or " <>
"{expression, worker, options} tuple, got: #{inspect(invalid)}"}
end
# Inserting Helpers
defp check_leadership_and_insert_jobs(state) do
if Peer.leader?(state.conf) do
Repo.transaction(state.conf, fn ->
insert_jobs(state.conf, state.crontab, state.timezone)
end)
else
{:ok, []}
end
end
defp insert_jobs(conf, crontab, timezone) do
{:ok, datetime} = DateTime.now(timezone)
for {expr, worker, opts} <- crontab, Expression.now?(expr, datetime) do
{:ok, job} = Oban.insert(conf.name, build_changeset(worker, opts))
job
end
end
defp build_changeset(worker, opts) do
{args, opts} = Keyword.pop(opts, :args, %{})
opts = unique_opts(worker.__opts__(), opts)
worker.new(args, opts)
end
# Make each job unique for 59 seconds to prevent double-enqueue if the node or scheduler
# crashes. The minimum resolution for our cron jobs is 1 minute, so there is potentially
# a one second window where a double enqueue can happen.
defp unique_opts(worker_opts, crontab_opts) do
[unique: [period: 59]]
|> Worker.merge_opts(worker_opts)
|> Worker.merge_opts(crontab_opts)
end
end