scheddy 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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,5 @@
1
+ module Scheddy
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,4 @@
1
+ module Scheddy
2
+ class TaskHistory < ApplicationRecord
3
+ end
4
+ end
@@ -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
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'scheddy/cli'
4
+ Scheddy::CLI.start(ARGV)
@@ -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,13 @@
1
+ module Scheddy
2
+ class Context
3
+
4
+ def initialize(scheduler, finish_before)
5
+ @scheduler = scheduler
6
+ @finish_before = finish_before
7
+ end
8
+
9
+ delegate :stop?, to: :@scheduler
10
+ attr_reader :finish_before
11
+
12
+ end
13
+ end
@@ -0,0 +1,5 @@
1
+ module Scheddy
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace Scheddy
4
+ end
5
+ end
@@ -0,0 +1,11 @@
1
+ module Scheddy
2
+ class << self
3
+
4
+ attr_writer :logger
5
+
6
+ def logger
7
+ @logger ||= Rails.logger
8
+ end
9
+
10
+ end
11
+ 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
@@ -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
@@ -0,0 +1,3 @@
1
+ module Scheddy
2
+ VERSION = "0.1.0"
3
+ end
data/lib/scheddy.rb ADDED
@@ -0,0 +1,16 @@
1
+ require 'fugit'
2
+
3
+ module Scheddy
4
+ end
5
+
6
+ %w(
7
+ config
8
+ context
9
+ logger
10
+ scheduler
11
+ task
12
+ version
13
+ engine
14
+ ).each do |f|
15
+ require_relative "scheddy/#{f}"
16
+ end
@@ -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: []