scheddy 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE.txt +20 -0
- data/README.md +266 -0
- data/Rakefile +16 -0
- data/app/models/scheddy/application_record.rb +5 -0
- data/app/models/scheddy/task_history.rb +4 -0
- data/db/migrate/20230607201527_create_scheddy_task_histories.rb +10 -0
- data/exe/scheddy +4 -0
- data/lib/scheddy/cli.rb +49 -0
- data/lib/scheddy/config.rb +142 -0
- data/lib/scheddy/context.rb +13 -0
- data/lib/scheddy/engine.rb +5 -0
- data/lib/scheddy/logger.rb +11 -0
- data/lib/scheddy/scheduler.rb +85 -0
- data/lib/scheddy/task.rb +173 -0
- data/lib/scheddy/version.rb +3 -0
- data/lib/scheddy.rb +16 -0
- data/lib/tasks/scheddy_tasks.rake +16 -0
- metadata +108 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 7f3ff142fb2059d543a17e05efa0e607e1a762419a1e14bd6c79c82eb4806872
|
4
|
+
data.tar.gz: a3a1a9a1975148811f85bdc36a92df7d83bada860e2ca9ad7a1f06eabacac40e
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 95d9325b4fa7985dc777b0d722a0a4b5a81ac13a382e0d73e1547c16182442d6f5a073bd17f16d81332876f85c344e2a4180108546fee39f383fcd3dd8d3b03b
|
7
|
+
data.tar.gz: 16cff8b03fc9d4c608f61d41db7d624e532e4e77d92d1e9bb6233c0603b627f38ad2964a5fee39dd4a136679f0b7f38bf1ba34708f1f70925f872ca5596fc54f
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright 2023 thomas morgan
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,266 @@
|
|
1
|
+
# Scheddy
|
2
|
+
|
3
|
+
Scheddy is a batteries-included task scheduler for Rails. It is intended as a replacement for cron and cron-like functionality (including job queue specific schedulers), with some useful differences.
|
4
|
+
|
5
|
+
* Flexible scheduling. Handles fixed times (Monday at 9am), intervals (every 15 minutes), and tiny intervals (every 5 seconds).
|
6
|
+
* Tiny intervals are great for scheduling workload specific jobs (database field `next_run_at`).
|
7
|
+
* Catch up missed tasks. Designed for environments with frequent deploys. Also useful in dev where the scheduler isn't always running.
|
8
|
+
* Job-queue agnostic. Works great with various ActiveJob adapters and non-ActiveJob queues too.
|
9
|
+
* Minimal dependencies. Uses your existing database; doesn't require Redis.
|
10
|
+
* Tasks and their schedules are versioned as part of your code.
|
11
|
+
|
12
|
+
|
13
|
+
|
14
|
+
## Installation
|
15
|
+
Add to your application's `Gemfile`:
|
16
|
+
|
17
|
+
```ruby
|
18
|
+
gem "scheddy"
|
19
|
+
```
|
20
|
+
|
21
|
+
After running `bundle install`, add the migration to your app:
|
22
|
+
```bash
|
23
|
+
bin/rails scheddy:install:migrations
|
24
|
+
bin/rails db:migrate
|
25
|
+
```
|
26
|
+
|
27
|
+
FYI, if all tasks set `track_runs false`, the migration can be skipped.
|
28
|
+
|
29
|
+
|
30
|
+
|
31
|
+
## Usage
|
32
|
+
|
33
|
+
Scheddy is configured with a straightforward DSL.
|
34
|
+
|
35
|
+
For clarity, Scheddy's units of work are referred to as Tasks. This is to differentiate them from background queue Jobs, like those run via ActiveJob. Scheddy's tasks have no relation to rake tasks.
|
36
|
+
|
37
|
+
|
38
|
+
Start by creating `config/initializers/scheddy.rb`:
|
39
|
+
|
40
|
+
```ruby
|
41
|
+
Scheddy.config do
|
42
|
+
|
43
|
+
## Fixed times
|
44
|
+
task 'monday reports' do
|
45
|
+
run_at '0 9 * * mon' # cron syntax
|
46
|
+
# run_at 'monday 9am' # use fugit's natural language parsing
|
47
|
+
# track_runs false # defaults to true for run_at() jobs
|
48
|
+
perform do
|
49
|
+
ReportJob.perform_later
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
task 'tuesday reports' do
|
54
|
+
run_when day: :tue, hour: 9..16, minute: [0,30]
|
55
|
+
# a native ruby syntax is also supported
|
56
|
+
# :day - day of week
|
57
|
+
# :month
|
58
|
+
# :date - day of month
|
59
|
+
# :hour
|
60
|
+
# :minute
|
61
|
+
# :second
|
62
|
+
# all values default to '*' (except second, which defaults to 0)
|
63
|
+
# track_runs false # defaults to true for run_when() jobs
|
64
|
+
perform do
|
65
|
+
AnotherReportJob.perform_later
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
## Intervals
|
70
|
+
task 'send welcome emails' do
|
71
|
+
run_every 30.minutes
|
72
|
+
# track_runs false # when run_every is >= 15.minutes, defaults to true; else to false
|
73
|
+
perform do
|
74
|
+
User.where(welcome_email_at: nil).find_each(batch_size: 100) do |user|
|
75
|
+
WelcomeMailer.welcome_email.with(user: user).deliver_later
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
task 'heartbeat' do
|
81
|
+
run_every 300 # seconds may be used instead
|
82
|
+
perform 'HeartbeatJob.perform_later' # a string to eval may be used too
|
83
|
+
end
|
84
|
+
|
85
|
+
# Use tiny intervals for lightweight scanning for ready-to-work records
|
86
|
+
task 'disable expired accounts' do
|
87
|
+
run_every 15.seconds
|
88
|
+
logger_tag 'expired-scan' # tag log lines with an alternate value; nil disables tagging
|
89
|
+
perform do
|
90
|
+
Subscription.expired.pluck(:id).each do |id|
|
91
|
+
DisableAccountJob.perform_later id
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
end
|
97
|
+
```
|
98
|
+
|
99
|
+
|
100
|
+
#### Fixed times: `run_at` and `run_when`
|
101
|
+
|
102
|
+
Fixed time tasks are comparable to cron-style scheduling. Times will be interpreted according to the Rails default TZ.
|
103
|
+
|
104
|
+
By default `run_at` and `run_when` will automatically catch up missed tasks. Scheddy does this by maintaining a record of the last run. If one or more runs was missed, it will run _once_ immediately. Multiple misses will still only be run once. Set `track_runs false` to disable catch-ups.
|
105
|
+
|
106
|
+
|
107
|
+
#### Intervals: `run_every`
|
108
|
+
|
109
|
+
Intervals are similar to cron style `*/5` syntax, but one key difference is the cycle is calculated based on Scheddy's startup time.
|
110
|
+
|
111
|
+
To avoid all tasks running at once, interval tasks are given an initial random delay of no more than the interval length itself. For example, a task running at 15 second intervals will be randomly delayed 0-14 seconds for first run. It will then continue running every 15 seconds.
|
112
|
+
|
113
|
+
By default, tiny interval tasks (those under 15 minutes) do not track last run or perform catch-ups, but longer interval tasks (>= 15 minutes) do. This is because tiny intervals will re-run soon anyway and it reduces database activity. Set `track_runs true|false` to override.
|
114
|
+
|
115
|
+
|
116
|
+
|
117
|
+
### Additional notes
|
118
|
+
|
119
|
+
#### Units of work
|
120
|
+
|
121
|
+
Notice that all these examples delegate the actual work to an external job. This is the recommended approach, but is not strictly required.
|
122
|
+
|
123
|
+
In general, bite-sized bits of work are fine in Scheddy, but bigger chunks of work usually belong in a background queue. In general, when timeliness is key (running right on time) or scheduling a background job is more costly than doing the work directly, then performing work inside the Scheddy task may be appropriate.
|
124
|
+
|
125
|
+
Database transactions are valid. These can increase use of database connections from the pool. Ensure Rails is configured appropriately.
|
126
|
+
|
127
|
+
|
128
|
+
#### Threading and execution
|
129
|
+
|
130
|
+
Each task runs in its own thread which helps ensure all tasks perform on time. However, Scheddy is not intended as a job executor and doesn't have a robust mechanism for retrying failed jobs--that belongs to your background job queue.
|
131
|
+
|
132
|
+
A given task will only ever be executed once at a time. Mostly relevant when using tiny intervals, if a prior execution is still going when the next execution is scheduled, Scheddy will skip the next execution and log an error message to that effect.
|
133
|
+
|
134
|
+
|
135
|
+
#### Task context
|
136
|
+
|
137
|
+
Tasks may receive an optional context to check if they need to stop for pending shutdown or to know the deadline for completing work before the next cycle would begin.
|
138
|
+
|
139
|
+
Deadlines (`finish_before`) are mostly useful if there is occasionally a large block of work combined with tiny intervals. The deadline is calculated with a near 2 second buffer. Only if that's inadequate do you need to adjust further. As already mentioned, Scheddy is smart enough to skip the next cycle if the prior cycle is still running, so handling deadlines is entirely optional.
|
140
|
+
|
141
|
+
```ruby
|
142
|
+
task 'iterating task' do
|
143
|
+
run_every 15.seconds
|
144
|
+
perform do |context|
|
145
|
+
Model.where(...).find_each do |model|
|
146
|
+
SomeJob.perform_later model.id if model.run_job?
|
147
|
+
break if context.stop? # the scheduler has requested to shutdown
|
148
|
+
break if context.finish_before < Time.now # the next cycle is imminent
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
152
|
+
```
|
153
|
+
|
154
|
+
|
155
|
+
#### Rails reloader
|
156
|
+
|
157
|
+
Each task's block is run inside the Rails reloader. In development mode, any classes referenced inside the block will be reloaded automatically to your latest code, just like the Rails dev-server itself.
|
158
|
+
|
159
|
+
It's possible to also make the task work reloadable by using a proxy class for the task itself. If your tasks are a bit bigger, organizing them into `app/tasks/` might be worthwhile anyway.
|
160
|
+
|
161
|
+
```ruby
|
162
|
+
# config/initializers/scheddy.rb
|
163
|
+
Scheddy.config do
|
164
|
+
task 'weekly report' do
|
165
|
+
run_at 'friday 9am'
|
166
|
+
perform 'WeeklyReportTask.perform'
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
# app/tasks/weekly_report_task.rb
|
171
|
+
class WeeklyReportTask
|
172
|
+
def self.perform
|
173
|
+
ReportJob.perform_later
|
174
|
+
end
|
175
|
+
end
|
176
|
+
```
|
177
|
+
|
178
|
+
|
179
|
+
|
180
|
+
## Running Scheddy
|
181
|
+
|
182
|
+
Depending on your ruby setup, one of the following should do:
|
183
|
+
```bash
|
184
|
+
scheddy start
|
185
|
+
# OR
|
186
|
+
bundle exec scheddy start
|
187
|
+
```
|
188
|
+
|
189
|
+
You can also check your tasks configuration with:
|
190
|
+
```bash
|
191
|
+
scheddy tasks
|
192
|
+
# OR
|
193
|
+
bundle exec scheddy tasks
|
194
|
+
```
|
195
|
+
|
196
|
+
|
197
|
+
### In production
|
198
|
+
|
199
|
+
Scheddy runs as its own process. It is intended to be run only once. Because Scheddy has the ability to catch up missed tasks, redundancy should be achieved through automatic restarts via `systemd`, `dockerd`, Kubernetes, or whatever supervisory system you use.
|
200
|
+
|
201
|
+
During deployment, shutdown the old instance before starting the new one. In Kubernetes this might look like:
|
202
|
+
```yaml
|
203
|
+
kind: Deployment
|
204
|
+
spec:
|
205
|
+
replicas: 1
|
206
|
+
strategy:
|
207
|
+
rollingUpdate:
|
208
|
+
maxSurge: 0
|
209
|
+
maxUnavailable: 1
|
210
|
+
template:
|
211
|
+
spec:
|
212
|
+
terminationGracePeriodSeconds: 60
|
213
|
+
```
|
214
|
+
|
215
|
+
|
216
|
+
### In development (and `Procfile` in production)
|
217
|
+
|
218
|
+
Assuming you're using `Procfile.dev` or `Procfile` for development, add:
|
219
|
+
```bash
|
220
|
+
scheddy: bundle exec scheddy start
|
221
|
+
```
|
222
|
+
|
223
|
+
|
224
|
+
### Signals and shutdown
|
225
|
+
|
226
|
+
Scheddy will shutdown upon receiving an `INT`, `QUIT`, or `TERM` signal.
|
227
|
+
|
228
|
+
There is a default 45 second wait for tasks to complete, which should be more than enough for the tiny types of tasks at hand. Tasks may also check for when to stop work part way through. This may be useful in iterators processing large numbers of items. See Task Context above.
|
229
|
+
|
230
|
+
|
231
|
+
### Error handling
|
232
|
+
|
233
|
+
Scheddy's default error handler uses the Rails Errors API introduced in Rails 7. If your exception tracker of choice doesn't implement this API, or if using Rails 6.x, set your own error handler.
|
234
|
+
|
235
|
+
Note that the default handler is responsible for exception logging, so you must perform your own logging if wanted. If the handler is set to `nil`, exceptions will be silenced.
|
236
|
+
|
237
|
+
```ruby
|
238
|
+
Scheddy.config do
|
239
|
+
error_handler do |exception, task|
|
240
|
+
# displaying task.name is the most likely use of task
|
241
|
+
name = "task '#{task.name}'" if task # task might be nil
|
242
|
+
logger.error "Exception in Scheddy #{name}: #{e.inspect}"
|
243
|
+
# report the exception here
|
244
|
+
end
|
245
|
+
|
246
|
+
error_handler ->(exception){
|
247
|
+
# passing a proc instead of a block is also allowed
|
248
|
+
# the task arg can be left out if it won't be used
|
249
|
+
}
|
250
|
+
|
251
|
+
error_handler nil # silence & don't report
|
252
|
+
end
|
253
|
+
```
|
254
|
+
|
255
|
+
|
256
|
+
|
257
|
+
## Compatibility
|
258
|
+
Used in production on Rails 7.0+. Gemspec is set to Rails 6.0+, but such is not well tested.
|
259
|
+
|
260
|
+
|
261
|
+
## Contributing
|
262
|
+
Pull requests are welcomed.
|
263
|
+
|
264
|
+
|
265
|
+
## License
|
266
|
+
MIT licensed.
|
data/Rakefile
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
require "bundler/setup"
|
2
|
+
|
3
|
+
APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
|
4
|
+
load "rails/tasks/engine.rake"
|
5
|
+
|
6
|
+
load "rails/tasks/statistics.rake"
|
7
|
+
|
8
|
+
require "bundler/gem_tasks"
|
9
|
+
|
10
|
+
require "rake/testtask"
|
11
|
+
Rake::TestTask.new(:test) do |t|
|
12
|
+
t.libs << 'test'
|
13
|
+
t.pattern = 'test/**/*_test.rb'
|
14
|
+
t.verbose = false
|
15
|
+
end
|
16
|
+
task default: :test
|
@@ -0,0 +1,10 @@
|
|
1
|
+
class CreateScheddyTaskHistories < ActiveRecord::Migration[6.0]
|
2
|
+
def change
|
3
|
+
# feel free to modify to id: :uuid or another :id format if you prefer
|
4
|
+
create_table :scheddy_task_histories do |t|
|
5
|
+
t.string :name, null: false, index: {unique: true}
|
6
|
+
t.datetime :last_run_at
|
7
|
+
t.timestamps
|
8
|
+
end
|
9
|
+
end
|
10
|
+
end
|
data/exe/scheddy
ADDED
data/lib/scheddy/cli.rb
ADDED
@@ -0,0 +1,49 @@
|
|
1
|
+
require 'thor'
|
2
|
+
|
3
|
+
module Scheddy
|
4
|
+
class CLI < Thor
|
5
|
+
|
6
|
+
class << self
|
7
|
+
def exit_on_failure?
|
8
|
+
true
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
|
13
|
+
desc :start, "Run Scheddy's scheduler"
|
14
|
+
def start
|
15
|
+
load_app!
|
16
|
+
Scheddy.run
|
17
|
+
end
|
18
|
+
|
19
|
+
|
20
|
+
desc :tasks, 'Show configured tasks'
|
21
|
+
def tasks
|
22
|
+
load_app!
|
23
|
+
|
24
|
+
Scheddy.tasks.map do |t|
|
25
|
+
OpenStruct.new t.to_h
|
26
|
+
end.each do |t|
|
27
|
+
puts <<~TASK.gsub(/$\s+$/m,'')
|
28
|
+
#{t.type.to_s.humanize} task: #{t.name}
|
29
|
+
#{"Interval: #{t.interval&.inspect}" if t.interval}
|
30
|
+
#{"Initial delay: #{t.initial_delay&.inspect}" if t.initial_delay}
|
31
|
+
#{"Cron rule: #{t.cron}" if t.cron}
|
32
|
+
Track runs? #{t.track_runs}
|
33
|
+
Next cycle: #{t.next_cycle} (if run now)
|
34
|
+
Tag: #{t.tag.present? ? "[#{t.tag}]" : 'nil'}
|
35
|
+
TASK
|
36
|
+
puts ''
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
|
41
|
+
no_commands do
|
42
|
+
|
43
|
+
def load_app!
|
44
|
+
require File.expand_path('config/environment.rb')
|
45
|
+
end
|
46
|
+
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,142 @@
|
|
1
|
+
module Scheddy
|
2
|
+
# default task list for when running standalone
|
3
|
+
mattr_accessor :tasks, default: []
|
4
|
+
|
5
|
+
# called from within task's execution thread; must be multi-thread safe
|
6
|
+
# task is allowed to be nil
|
7
|
+
mattr_accessor :error_handler, default: lambda {|e, task|
|
8
|
+
logger.error "Exception in Scheddy task '#{task&.name}': #{e.inspect}\n #{e.backtrace.join("\n ")}"
|
9
|
+
Rails.error.report(e, handled: true, severity: :error)
|
10
|
+
}
|
11
|
+
|
12
|
+
def self.config(&block)
|
13
|
+
Config.new(tasks, &block)
|
14
|
+
end
|
15
|
+
|
16
|
+
|
17
|
+
class Config
|
18
|
+
attr_reader :tasks
|
19
|
+
|
20
|
+
delegate :logger, to: :Scheddy
|
21
|
+
|
22
|
+
def initialize(tasks, &block)
|
23
|
+
@tasks = tasks
|
24
|
+
instance_eval(&block)
|
25
|
+
end
|
26
|
+
|
27
|
+
def error_handler(block1=nil, &block2)
|
28
|
+
Scheddy.error_handler = block1 || block2
|
29
|
+
end
|
30
|
+
|
31
|
+
def task(name, &block)
|
32
|
+
raise ArgumentError, "Duplicate task name '#{name}'" if tasks.any?{|t| t.name == name}
|
33
|
+
tasks.push TaskDefinition.new(name, &block).to_task
|
34
|
+
end
|
35
|
+
|
36
|
+
# shortcut syntax
|
37
|
+
def run_at(cron, name:, tag: :auto, track: :auto, &task)
|
38
|
+
task(name) do
|
39
|
+
run_at cron
|
40
|
+
logger_tag tag if tag!=:auto
|
41
|
+
track_runs track if track!=:auto
|
42
|
+
perform(&task)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
# shortcut syntax
|
47
|
+
def run_every(interval, name:, delay: nil, tag: :auto, track: :auto, &task)
|
48
|
+
task(name) do
|
49
|
+
run_every interval
|
50
|
+
initial_delay delay if delay
|
51
|
+
logger_tag tag if tag!=:auto
|
52
|
+
track_runs track if track!=:auto
|
53
|
+
perform(&task)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
end
|
58
|
+
|
59
|
+
|
60
|
+
class TaskDefinition
|
61
|
+
delegate :logger, to: :Scheddy
|
62
|
+
|
63
|
+
# block - task to perform
|
64
|
+
# string - task to perform as evalable code, eg: 'SomeJob.perform_later'
|
65
|
+
def perform(string=nil, &block)
|
66
|
+
raise ArgumentError, 'Must provide string or block to perform' unless string.is_a?(String) ^ block
|
67
|
+
block ||= lambda { eval(string) }
|
68
|
+
task[:task] = block
|
69
|
+
end
|
70
|
+
|
71
|
+
# cron - String("min hour dom mon dow"), eg "0 4 * * *"
|
72
|
+
def run_at(cron)
|
73
|
+
task[:cron] =
|
74
|
+
Fugit.parse_cronish(cron) ||
|
75
|
+
Fugit.parse_cronish("every #{cron}") ||
|
76
|
+
raise(ArgumentError, "Unable to parse '#{cron}'")
|
77
|
+
end
|
78
|
+
|
79
|
+
# duration - Duration or Integer
|
80
|
+
def run_every(duration)
|
81
|
+
task[:interval] = duration.to_i
|
82
|
+
end
|
83
|
+
|
84
|
+
# day - day of week as Symbol (:monday, :mon) or Integer (both 0 and 7 are sunday)
|
85
|
+
# month - month as Symbol (:january, :jan) or Integer 1-12
|
86
|
+
# date - day of month, 1-31
|
87
|
+
# hour - 0-23
|
88
|
+
# minute - 0-59
|
89
|
+
# second - 0-59
|
90
|
+
def run_when(day: '*', month: '*', date: '*', hour: '*', minute: '*', second: '0')
|
91
|
+
day = day.to_s[0,3] if day.to_s =~ /[a-z]/
|
92
|
+
month = month.to_s[0,3] if month.to_s =~ /[a-z]/
|
93
|
+
run_at [second, minute, hour, date, month, day].map{normalize_val _1}.join(' ')
|
94
|
+
end
|
95
|
+
|
96
|
+
# duration - Duration or Integer (nil = random delay)
|
97
|
+
def initial_delay(duration)
|
98
|
+
task[:initial_delay] = duration&.to_i
|
99
|
+
end
|
100
|
+
|
101
|
+
# tag - String or false/nil; defaults to :name
|
102
|
+
def logger_tag(tag)
|
103
|
+
task[:tag] = tag
|
104
|
+
end
|
105
|
+
|
106
|
+
def track_runs(bool)
|
107
|
+
task[:track_runs] = bool
|
108
|
+
end
|
109
|
+
|
110
|
+
|
111
|
+
# private api
|
112
|
+
def to_task
|
113
|
+
Task.new(**as_args)
|
114
|
+
end
|
115
|
+
|
116
|
+
private
|
117
|
+
|
118
|
+
attr_accessor :task
|
119
|
+
|
120
|
+
def initialize(name, &block)
|
121
|
+
self.task = {name: name}
|
122
|
+
instance_eval(&block)
|
123
|
+
end
|
124
|
+
|
125
|
+
def as_args
|
126
|
+
raise ArgumentError, 'Must call run_at, run_every, or run_when' unless task[:cron] || task[:interval]
|
127
|
+
task
|
128
|
+
end
|
129
|
+
|
130
|
+
def normalize_val(val)
|
131
|
+
case val
|
132
|
+
when Array
|
133
|
+
val.join(',')
|
134
|
+
when Range
|
135
|
+
"#{val.min}-#{val.max}"
|
136
|
+
else
|
137
|
+
val
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
end
|
142
|
+
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
module Scheddy
|
2
|
+
|
3
|
+
def self.run
|
4
|
+
Scheduler.new(tasks).run
|
5
|
+
end
|
6
|
+
|
7
|
+
class Scheduler
|
8
|
+
|
9
|
+
def run
|
10
|
+
puts "[Scheddy] Starting scheduler with #{tasks.size} #{'task'.pluralize tasks.size}"
|
11
|
+
trap_signals!
|
12
|
+
cleanup_task_history
|
13
|
+
|
14
|
+
until stop?
|
15
|
+
next_cycle = run_once
|
16
|
+
wait_until next_cycle unless stop?
|
17
|
+
end
|
18
|
+
|
19
|
+
running = tasks.select(&:running?).count
|
20
|
+
if running > 0
|
21
|
+
puts "[Scheddy] Waiting for #{running} tasks to complete"
|
22
|
+
wait_until(45.seconds.from_now) do
|
23
|
+
tasks.none?(&:running?)
|
24
|
+
end
|
25
|
+
tasks.select(&:running?).each do |task|
|
26
|
+
$stderr.puts "[Scheddy] Killing task #{task.name}"
|
27
|
+
task.kill
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
puts '[Scheddy] Done'
|
32
|
+
end
|
33
|
+
|
34
|
+
# return : Time of next cycle
|
35
|
+
def run_once
|
36
|
+
tasks.flat_map do |task|
|
37
|
+
task.perform(self) unless stop?
|
38
|
+
end.min
|
39
|
+
end
|
40
|
+
|
41
|
+
def stop? ; @stop ; end
|
42
|
+
|
43
|
+
|
44
|
+
private
|
45
|
+
|
46
|
+
attr_reader :tasks
|
47
|
+
attr_writer :stop
|
48
|
+
|
49
|
+
def initialize(tasks)
|
50
|
+
@tasks = tasks
|
51
|
+
end
|
52
|
+
|
53
|
+
def cleanup_task_history
|
54
|
+
known_tasks = tasks.select(&:track_runs).map(&:name)
|
55
|
+
return if known_tasks.empty? # table doesn't have to exist if track_runs always disabled
|
56
|
+
Scheddy::TaskHistory.find_each do |r|
|
57
|
+
r.destroy if known_tasks.exclude? r.name
|
58
|
+
end
|
59
|
+
rescue ActiveRecord::StatementInvalid => e
|
60
|
+
return if e.message =~ /relation "scheddy_task_histories" does not exist/
|
61
|
+
raise
|
62
|
+
end
|
63
|
+
|
64
|
+
def stop!(sig=nil)
|
65
|
+
puts '[Scheddy] Stopping'
|
66
|
+
self.stop = true
|
67
|
+
end
|
68
|
+
|
69
|
+
def trap_signals!
|
70
|
+
trap 'INT', &method(:stop!)
|
71
|
+
trap 'QUIT', &method(:stop!)
|
72
|
+
trap 'TERM', &method(:stop!)
|
73
|
+
end
|
74
|
+
|
75
|
+
# &block - optional block - return truthy to end prematurely
|
76
|
+
def wait_until(time)
|
77
|
+
while (now = Time.current) < time
|
78
|
+
return if stop?
|
79
|
+
return if block_given? && yield
|
80
|
+
sleep [time-now, 1].min
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
end
|
85
|
+
end
|
data/lib/scheddy/task.rb
ADDED
@@ -0,0 +1,173 @@
|
|
1
|
+
module Scheddy
|
2
|
+
class Task
|
3
|
+
attr_reader :cron, :delay, :interval, :name, :task, :tag, :track_runs, :type
|
4
|
+
|
5
|
+
delegate :logger, to: :Scheddy
|
6
|
+
|
7
|
+
|
8
|
+
def perform(scheduler, now: false)
|
9
|
+
return next_cycle if Time.current < next_cycle && !now
|
10
|
+
record_this_run
|
11
|
+
if running?
|
12
|
+
logger.error "Scheddy task '#{name}' already running; skipping this cycle"
|
13
|
+
return next_cycle!
|
14
|
+
end
|
15
|
+
context = Context.new(scheduler, finish_before)
|
16
|
+
self.thread =
|
17
|
+
Thread.new do
|
18
|
+
logger.tagged tag do
|
19
|
+
Rails.application.reloader.wrap do
|
20
|
+
task.call(*[context].take(task.arity.abs))
|
21
|
+
rescue Exception => e
|
22
|
+
if h = Scheddy.error_handler
|
23
|
+
h.call(*[e, self].take(h.arity.abs))
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
ensure
|
28
|
+
self.thread = nil
|
29
|
+
end
|
30
|
+
next_cycle!
|
31
|
+
end
|
32
|
+
|
33
|
+
def kill
|
34
|
+
thread&.kill
|
35
|
+
end
|
36
|
+
|
37
|
+
def running?
|
38
|
+
!!thread
|
39
|
+
end
|
40
|
+
|
41
|
+
def next_cycle
|
42
|
+
initial_cycle! if @next_cycle == :initial
|
43
|
+
@next_cycle
|
44
|
+
end
|
45
|
+
|
46
|
+
|
47
|
+
def to_h
|
48
|
+
attrs = {
|
49
|
+
name: name,
|
50
|
+
next_cycle: next_cycle.utc,
|
51
|
+
type: type,
|
52
|
+
tag: tag,
|
53
|
+
task: task,
|
54
|
+
track_runs: track_runs,
|
55
|
+
}
|
56
|
+
case type
|
57
|
+
when :interval
|
58
|
+
attrs[:initial_delay] = ActiveSupport::Duration.build(delay) unless track_runs
|
59
|
+
attrs[:interval] = ActiveSupport::Duration.build(interval)
|
60
|
+
when :cron
|
61
|
+
attrs[:cron] = cron.original
|
62
|
+
end
|
63
|
+
attrs.to_a.sort_by!{_1.to_s}.to_h
|
64
|
+
end
|
65
|
+
|
66
|
+
def inspect
|
67
|
+
attrs = to_h.map{|k,v| "#{k}: #{v.inspect}"}
|
68
|
+
%Q{#<#{self.class} #{attrs.join(', ')}>}
|
69
|
+
end
|
70
|
+
|
71
|
+
|
72
|
+
private
|
73
|
+
|
74
|
+
attr_accessor :thread
|
75
|
+
attr_writer :next_cycle, :track_runs
|
76
|
+
|
77
|
+
# :cron - cron definition
|
78
|
+
# :interval - interval period
|
79
|
+
# :initial_delay - delay of first run; nil = randomize; ignored if track_runs
|
80
|
+
# :name - task name
|
81
|
+
# :tag - logger tag; defaults to :name; false = no tag
|
82
|
+
# :task - proc/lambda to execute on each cycle
|
83
|
+
# :track_runs - whether to track last runs for catchup; defaults true except intervals < 15min
|
84
|
+
def initialize(**args)
|
85
|
+
@task = args[:task]
|
86
|
+
@name = args[:name]
|
87
|
+
@tag = args.key?(:tag) ? args[:tag] : self.name
|
88
|
+
if args[:interval]
|
89
|
+
@type = :interval
|
90
|
+
@interval = args[:interval]
|
91
|
+
@delay = args[:initial_delay] || rand(self.interval)
|
92
|
+
@track_runs = args.key?(:track_runs) ? args[:track_runs] : self.interval >= 15.minutes
|
93
|
+
else
|
94
|
+
@type = :cron
|
95
|
+
@cron = args[:cron]
|
96
|
+
@track_runs = args.key?(:track_runs) ? args[:track_runs] : true
|
97
|
+
end
|
98
|
+
|
99
|
+
self.next_cycle = :initial
|
100
|
+
end
|
101
|
+
|
102
|
+
|
103
|
+
def initial_cycle!
|
104
|
+
self.next_cycle =
|
105
|
+
case type
|
106
|
+
when :interval
|
107
|
+
if last_run
|
108
|
+
last_run + interval
|
109
|
+
else
|
110
|
+
Time.current + delay
|
111
|
+
end
|
112
|
+
when :cron
|
113
|
+
prev_t = cron.previous_time.to_utc_time
|
114
|
+
if last_run && last_run < prev_t
|
115
|
+
prev_t
|
116
|
+
else
|
117
|
+
cron.next_time.to_utc_time
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
def next_cycle!
|
123
|
+
self.next_cycle =
|
124
|
+
case type
|
125
|
+
when :interval
|
126
|
+
Time.current + interval
|
127
|
+
when :cron
|
128
|
+
cron.next_time.to_utc_time
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
def finish_before
|
133
|
+
case type
|
134
|
+
when :interval
|
135
|
+
Time.current + interval - 2.seconds
|
136
|
+
when :cron
|
137
|
+
cron.next_time.to_utc_time - 2.seconds
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
|
142
|
+
def last_run
|
143
|
+
track_runs && task_history.last_run_at
|
144
|
+
rescue ActiveRecord::StatementInvalid => e
|
145
|
+
if e.message =~ /relation "scheddy_task_histories" does not exist/
|
146
|
+
logger.error <<~MSG
|
147
|
+
[Scheddy] ERROR in task '#{name}': Missing DB table for Scheddy::TaskHistory.
|
148
|
+
Either set track_runs(false) or run:
|
149
|
+
bin/rails scheddy:install:migrations
|
150
|
+
bin/rails db:migrate
|
151
|
+
For now, disabling track_runs and continuing.
|
152
|
+
MSG
|
153
|
+
self.track_runs = false
|
154
|
+
else
|
155
|
+
raise
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
def record_this_run
|
160
|
+
return unless track_runs
|
161
|
+
Scheddy::TaskHistory.logger.silence(Logger::INFO) do
|
162
|
+
task_history.update last_run_at: Time.current
|
163
|
+
end
|
164
|
+
rescue ActiveRecord::ActiveRecordError => e
|
165
|
+
logger.error "Error updating task history for Scheddy task '#{name}': #{e.inspect}"
|
166
|
+
end
|
167
|
+
|
168
|
+
def task_history
|
169
|
+
@task_history ||= Scheddy::TaskHistory.find_or_create_by! name: name
|
170
|
+
end
|
171
|
+
|
172
|
+
end
|
173
|
+
end
|
data/lib/scheddy.rb
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
namespace :scheddy do
|
2
|
+
|
3
|
+
desc 'Run Scheddy'
|
4
|
+
task run: :environment do
|
5
|
+
Scheddy.run
|
6
|
+
end
|
7
|
+
|
8
|
+
task :migrate do
|
9
|
+
`bin/rails db:migrate SCOPE=scheddy`
|
10
|
+
end
|
11
|
+
|
12
|
+
task :rollback do
|
13
|
+
`bin/rails db:migrate SCOPE=scheddy VERSION=0`
|
14
|
+
end
|
15
|
+
|
16
|
+
end
|
metadata
ADDED
@@ -0,0 +1,108 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: scheddy
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- thomas morgan
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2023-06-08 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: fugit
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '1.8'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '1.8'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: rails
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '6'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '6'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: thor
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '1.0'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '1.0'
|
55
|
+
description: Scheddy is a batteries-included task scheduler for Rails. It is intended
|
56
|
+
as a replacement for cron and cron-like functionality (including job queue specific
|
57
|
+
schedulers). It is job-queue agnostic and can catch up missed tasks.
|
58
|
+
email:
|
59
|
+
- tm@iprog.com
|
60
|
+
executables:
|
61
|
+
- scheddy
|
62
|
+
extensions: []
|
63
|
+
extra_rdoc_files: []
|
64
|
+
files:
|
65
|
+
- LICENSE.txt
|
66
|
+
- README.md
|
67
|
+
- Rakefile
|
68
|
+
- app/models/scheddy/application_record.rb
|
69
|
+
- app/models/scheddy/task_history.rb
|
70
|
+
- db/migrate/20230607201527_create_scheddy_task_histories.rb
|
71
|
+
- exe/scheddy
|
72
|
+
- lib/scheddy.rb
|
73
|
+
- lib/scheddy/cli.rb
|
74
|
+
- lib/scheddy/config.rb
|
75
|
+
- lib/scheddy/context.rb
|
76
|
+
- lib/scheddy/engine.rb
|
77
|
+
- lib/scheddy/logger.rb
|
78
|
+
- lib/scheddy/scheduler.rb
|
79
|
+
- lib/scheddy/task.rb
|
80
|
+
- lib/scheddy/version.rb
|
81
|
+
- lib/tasks/scheddy_tasks.rake
|
82
|
+
homepage: https://github.com/zarqman/scheddy
|
83
|
+
licenses:
|
84
|
+
- MIT
|
85
|
+
metadata:
|
86
|
+
homepage_uri: https://github.com/zarqman/scheddy
|
87
|
+
source_code_uri: https://github.com/zarqman/scheddy
|
88
|
+
post_install_message:
|
89
|
+
rdoc_options: []
|
90
|
+
require_paths:
|
91
|
+
- lib
|
92
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - ">="
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '2.7'
|
97
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
98
|
+
requirements:
|
99
|
+
- - ">="
|
100
|
+
- !ruby/object:Gem::Version
|
101
|
+
version: '0'
|
102
|
+
requirements: []
|
103
|
+
rubygems_version: 3.4.10
|
104
|
+
signing_key:
|
105
|
+
specification_version: 4
|
106
|
+
summary: Job-queue agnostic, cron-like task scheduler for Rails apps, with missed
|
107
|
+
task catch-ups and other features.
|
108
|
+
test_files: []
|