perform_every 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +125 -0
- data/Rakefile +27 -0
- data/lib/generators/perform_every/active_record_generator.rb +31 -0
- data/lib/generators/perform_every/templates/migration.rb +16 -0
- data/lib/perform_every.rb +22 -0
- data/lib/perform_every/activejob.rb +46 -0
- data/lib/perform_every/helper.rb +9 -0
- data/lib/perform_every/job.rb +170 -0
- data/lib/perform_every/railtie.rb +7 -0
- data/lib/perform_every/reflection.rb +20 -0
- data/lib/perform_every/scheduler.rb +101 -0
- data/lib/perform_every/version.rb +3 -0
- data/lib/tasks/perform_every_tasks.rake +19 -0
- metadata +139 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 1e9c1509e5c84a94b1a0029dd2a71e5f62a81deb63f1673ac7532a2a43bfdbe4
|
4
|
+
data.tar.gz: 3c7f5acd581c44792a5dcb2577eaf2ec32d42dd36a2abbab333f8f20186ef79c
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 98636a9c1083dc8cd8ae302c1cb4c3ebd1f0b1e09cb3bd1cd0d6892d5f721f89d1e4d2ab579c74d39a6e19dc7bba9763fb8c225b2e675e1fcd3300738dd7645c
|
7
|
+
data.tar.gz: da526fc6fbb9a6029f317072953fe15df393c8238605ae5cff5b89ef1d393ed4ca8a721abf8ac883e99790c203e552e29fd7f21f6d367c00e3835e40fb681366
|
data/LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2020 Matthias Kadenbach
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,125 @@
|
|
1
|
+
# perform_every
|
2
|
+
|
3
|
+
Cron jobs for Rails. Just add `perform_every "5 minutes"` to any job.
|
4
|
+
|
5
|
+
Requires Postgres and a configured Rails' ActiveJob adapter,
|
6
|
+
like [delayed_job](https://github.com/collectiveidea/delayed_job) or
|
7
|
+
[sidekiq](https://github.com/mperham/sidekiq).
|
8
|
+
|
9
|
+
|
10
|
+
## Usage
|
11
|
+
|
12
|
+
Include `gem 'perform_every'` and then run:
|
13
|
+
|
14
|
+
```
|
15
|
+
bundle install
|
16
|
+
rails generate perform_every:active_record
|
17
|
+
rails db:migrate
|
18
|
+
```
|
19
|
+
|
20
|
+
Create a new job `app/jobs/example_job.rb`:
|
21
|
+
|
22
|
+
```ruby
|
23
|
+
class ExampleJob < ApplicationJob
|
24
|
+
queue_as :default
|
25
|
+
|
26
|
+
# multiple perform_every and perform_at are allowed
|
27
|
+
perform_every "10 minutes"
|
28
|
+
perform_at "October 1st, 2030"
|
29
|
+
perform_at "October 1st, 2050"
|
30
|
+
|
31
|
+
# This job runs every 10 minutes and on October 1st, 2030 and 2050.
|
32
|
+
# No `perform` parameters are allowed, because `perform_every` will
|
33
|
+
# just use the configured `Rails.config.active_job.queue_adapter` to
|
34
|
+
# queue this job.
|
35
|
+
def perform
|
36
|
+
User.all.each do |user|
|
37
|
+
send_cat_meme(user) #priceless
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
```
|
42
|
+
|
43
|
+
Finally start the worker which will enqueue jobs:
|
44
|
+
|
45
|
+
```
|
46
|
+
rails perform_every:run
|
47
|
+
```
|
48
|
+
|
49
|
+
---
|
50
|
+
|
51
|
+
### `perform_every`
|
52
|
+
|
53
|
+
```ruby
|
54
|
+
perform_every "interval", {:accuracy => 1.minute}
|
55
|
+
|
56
|
+
perform_every "day at five"
|
57
|
+
perform_every "weekday at five"
|
58
|
+
perform_every "day at 5 pm"
|
59
|
+
perform_every "tuesday at 5 pm"
|
60
|
+
perform_every "wed at 5 pm"
|
61
|
+
perform_every "day at 16:30"
|
62
|
+
perform_every "day at noon"
|
63
|
+
perform_every "day at midnight"
|
64
|
+
perform_every "tuesday"
|
65
|
+
perform_every "day at 5 pm on America/Los_Angeles"
|
66
|
+
perform_every "day at 6 pm in Asia/Tokyo"
|
67
|
+
perform_every "3 hours"
|
68
|
+
perform_every "4 months"
|
69
|
+
perform_every "5 minutes"
|
70
|
+
```
|
71
|
+
|
72
|
+
* `interval` should be >= 1.minute
|
73
|
+
* `interval` default timezone is UTC
|
74
|
+
* `accuracy` is set to `1.minute` by default (see notes below)
|
75
|
+
* multiple unique `perform_every` can be added
|
76
|
+
|
77
|
+
---
|
78
|
+
|
79
|
+
### `perform_at`
|
80
|
+
|
81
|
+
```ruby
|
82
|
+
perform_at "timestamp", {:accuracy => 1.minute}
|
83
|
+
|
84
|
+
perform_at "2017-12-12"
|
85
|
+
perform_at "2017-12-12 12:00:00 America/New_York"
|
86
|
+
perform_at "October 1st, 2050"
|
87
|
+
```
|
88
|
+
|
89
|
+
* `timestamp` default timezone is UTC
|
90
|
+
* `accuracy` is set to `1.minute` by default (see notes below)
|
91
|
+
* multiple unique `perform_at` an be added
|
92
|
+
|
93
|
+
---
|
94
|
+
|
95
|
+
## Commands
|
96
|
+
|
97
|
+
```
|
98
|
+
rails perform_every:run # Run scheduler
|
99
|
+
rails perform_every:cleanup # Remove deprecated jobs from database
|
100
|
+
rails perform_every:reset # Reset persisted jobs in database
|
101
|
+
```
|
102
|
+
|
103
|
+
|
104
|
+
## Notes
|
105
|
+
|
106
|
+
* Several workers (`rails perform_every:run`) can be started. During a leader election phase
|
107
|
+
one worker will become master. This is done via
|
108
|
+
[Postgres Advisory Locks](https://www.postgresql.org/docs/11/explicit-locking.html#ADVISORY-LOCKS)
|
109
|
+
and [with_advisory_lock gem](https://github.com/ClosureTree/with_advisory_lock).
|
110
|
+
An `exclusive session level advisory lock` is obtained. If the worker dies, another
|
111
|
+
worker will become master and take over.
|
112
|
+
* Workers will only enqueue jobs to your backend queue adapter.
|
113
|
+
* Workers will gracefully shutdown when SIGINT or SIGTERM is received.
|
114
|
+
* Job state is persited in Postgres in table `perform_every`.
|
115
|
+
* `perform_at` and `perform_every` statements can be added and removed between deploys,
|
116
|
+
the workers support rolling deploys. Obsolete jobs are marked as `deprecated` in table `perform_every`.
|
117
|
+
Run `rails perform_every:cleanup` after deploys to delete deprecated tasks.
|
118
|
+
* Enable `Rails.config.log_level = :debug` to output verbose logging to understand scheduling logic.
|
119
|
+
* Accuracy is set to 1 minute by default. If a job is scheduled to run at 4:00pm, the perform_every
|
120
|
+
worker has until 4:01pm to actually schedule the job.
|
121
|
+
Accuracy is important in case things go wrong.
|
122
|
+
Here is another example: Every day at 8am a job is supposed to send out email newsletters.
|
123
|
+
This can only happen between 8am and 9am. `perform_every "day at 8am", {:accuracy => 1.hour}`
|
124
|
+
ensures that if no workers are alive between 8am and 9am the newsletter job would not
|
125
|
+
be scheduled after 9:01am anymore.
|
data/Rakefile
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
begin
|
2
|
+
require 'bundler/setup'
|
3
|
+
rescue LoadError
|
4
|
+
puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
|
5
|
+
end
|
6
|
+
|
7
|
+
require 'rdoc/task'
|
8
|
+
|
9
|
+
RDoc::Task.new(:rdoc) do |rdoc|
|
10
|
+
rdoc.rdoc_dir = 'rdoc'
|
11
|
+
rdoc.title = 'PerformEvery'
|
12
|
+
rdoc.options << '--line-numbers'
|
13
|
+
rdoc.rdoc_files.include('README.md')
|
14
|
+
rdoc.rdoc_files.include('lib/**/*.rb')
|
15
|
+
end
|
16
|
+
|
17
|
+
require 'bundler/gem_tasks'
|
18
|
+
|
19
|
+
require 'rake/testtask'
|
20
|
+
|
21
|
+
Rake::TestTask.new(:test) do |t|
|
22
|
+
t.libs << 'test'
|
23
|
+
t.pattern = 'test/**/*_test.rb'
|
24
|
+
t.verbose = false
|
25
|
+
end
|
26
|
+
|
27
|
+
task default: :test
|
@@ -0,0 +1,31 @@
|
|
1
|
+
|
2
|
+
module PerformEvery
|
3
|
+
class ActiveRecordGenerator < Rails::Generators::Base
|
4
|
+
include Rails::Generators::Migration
|
5
|
+
source_root File.expand_path("../templates", __FILE__)
|
6
|
+
|
7
|
+
def copy_migration
|
8
|
+
migration_template "migration.rb", "db/migrate/create_perform_every.rb"
|
9
|
+
end
|
10
|
+
|
11
|
+
#def generate_model
|
12
|
+
#invoke "active_record:model", ["PerformEvery"], migration: false unless model_exists? && behavior == :invoke
|
13
|
+
#end
|
14
|
+
|
15
|
+
private
|
16
|
+
|
17
|
+
# see https://stackoverflow.com/questions/11079617/next-migration-number-notimplementederror-notimplementederror-using-wysihat
|
18
|
+
def self.next_migration_number(dirname)
|
19
|
+
next_migration_number = current_migration_number(dirname) + 1
|
20
|
+
ActiveRecord::Migration.next_migration_number(next_migration_number)
|
21
|
+
end
|
22
|
+
|
23
|
+
#def model_exists?
|
24
|
+
#File.exist?(File.join(destination_root, model_path))
|
25
|
+
#end
|
26
|
+
|
27
|
+
#def model_path
|
28
|
+
#@model_path ||= File.join("app", "models", "perform_every.rb")
|
29
|
+
#end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
class CreatePerformEvery < ActiveRecord::Migration[4.2]
|
2
|
+
def change
|
3
|
+
create_table :perform_every do |t|
|
4
|
+
t.string :job_name
|
5
|
+
t.string :typ # every|at
|
6
|
+
t.string :value
|
7
|
+
t.string :history, array: true
|
8
|
+
t.datetime :last_performed_at
|
9
|
+
t.datetime :perform_at
|
10
|
+
t.boolean :deprecated, null: false, default: false
|
11
|
+
end
|
12
|
+
|
13
|
+
add_index :perform_every, [:job_name, :typ, :value], unique: true, name: "perform_every_unique_job"
|
14
|
+
add_index :perform_every, :deprecated
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
require "perform_every/helper"
|
2
|
+
require "perform_every/job"
|
3
|
+
require "perform_every/reflection"
|
4
|
+
require "perform_every/scheduler"
|
5
|
+
require "perform_every/railtie"
|
6
|
+
|
7
|
+
require 'fugit'
|
8
|
+
|
9
|
+
ActiveSupport.on_load(:active_job) do
|
10
|
+
require "perform_every/activejob"
|
11
|
+
ActiveJob::Base.send(:include, ::PerformEvery::ActiveJobExtension)
|
12
|
+
end
|
13
|
+
|
14
|
+
module PerformEvery
|
15
|
+
DEFAULT_ACCURACY = 1.minute # must be >= 1 minute
|
16
|
+
|
17
|
+
ADVISORY_LOCK_NAME = "perform_every_scheduler"
|
18
|
+
SLEEP_INTERVAL = 30 # seconds (should be dividable by 2)
|
19
|
+
MAX_HISTORY = 10
|
20
|
+
|
21
|
+
mattr_accessor :dry_run, default: false
|
22
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
module PerformEvery
|
2
|
+
module ActiveJobExtension
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
# pattern from:
|
6
|
+
# https://guides.rubyonrails.org/plugins.html#add-an-acts-as-method-to-active-record
|
7
|
+
# and https://github.com/rails/rails/blob/master/activerecord/lib/active_record/associations.rb
|
8
|
+
|
9
|
+
class_methods do
|
10
|
+
def perform_every(interval, opts={})
|
11
|
+
j = Job.new
|
12
|
+
j.job_name = self.name
|
13
|
+
j.typ = "interval"
|
14
|
+
j.value = interval.strip
|
15
|
+
j.accuracy = opts[:accuracy]
|
16
|
+
|
17
|
+
if j.value.blank?
|
18
|
+
raise "#{self.name}#perform_every needs interval"
|
19
|
+
end
|
20
|
+
|
21
|
+
# TODO raise if perform method has parameters
|
22
|
+
# Object.const_get(self.name).instance_method(:perform).parameters.flatten.count
|
23
|
+
|
24
|
+
PerformEvery::Reflection.insert(j)
|
25
|
+
end
|
26
|
+
|
27
|
+
def perform_at(timestamp, opts={})
|
28
|
+
j = Job.new
|
29
|
+
j.job_name = self.name
|
30
|
+
j.typ = "timestamp"
|
31
|
+
j.value = timestamp.strip
|
32
|
+
j.accuracy = opts[:accuracy]
|
33
|
+
|
34
|
+
if j.value.blank?
|
35
|
+
raise "#{self.name}#perform_at needs timestamp"
|
36
|
+
end
|
37
|
+
|
38
|
+
# TODO raise if perform method has parameters
|
39
|
+
# Object.const_get(self.name).instance_method(:perform).parameters.flatten.count
|
40
|
+
|
41
|
+
PerformEvery::Reflection.insert(j)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
@@ -0,0 +1,170 @@
|
|
1
|
+
require 'action_view'
|
2
|
+
|
3
|
+
module PerformEvery
|
4
|
+
class Job < ActiveRecord::Base
|
5
|
+
include PerformEvery::Helper
|
6
|
+
|
7
|
+
self.table_name = "perform_every"
|
8
|
+
|
9
|
+
attr_accessor :accuracy
|
10
|
+
|
11
|
+
def title
|
12
|
+
return "#{self.job_name} every #{self.value}" if self.typ == "interval"
|
13
|
+
return "#{self.job_name} at #{self.value}" if self.typ == "timestamp"
|
14
|
+
raise "unknown typ"
|
15
|
+
end
|
16
|
+
|
17
|
+
def from_reflection_store(attr)
|
18
|
+
s = PerformEvery::Reflection.find(self)
|
19
|
+
return nil if s.nil?
|
20
|
+
s.send(attr.to_sym)
|
21
|
+
end
|
22
|
+
|
23
|
+
def perform?(now = Time.now.utc)
|
24
|
+
return false if self.deprecated
|
25
|
+
return false if self.perform_at.blank?
|
26
|
+
return false if self.too_old?(now)
|
27
|
+
|
28
|
+
now >= self.perform_at && (self.last_performed_at.blank? || self.last_performed_at < self.perform_at)
|
29
|
+
end
|
30
|
+
|
31
|
+
def too_old?(now = Time.now.utc)
|
32
|
+
raise "job is not scheduled to run because perform_at is nil" if self.perform_at.blank?
|
33
|
+
accuracy = self.from_reflection_store(:accuracy) || PerformEvery::DEFAULT_ACCURACY
|
34
|
+
now - self.perform_at >= accuracy
|
35
|
+
end
|
36
|
+
|
37
|
+
def perform!(now = Time.now.utc)
|
38
|
+
return :skip_deprecated if self.perform_at.blank? || self.deprecated
|
39
|
+
|
40
|
+
if self.too_old?(now)
|
41
|
+
rescue_perform_at = self.perform_at
|
42
|
+
self.perform_at = self.perform_once? ? nil : self.perform_next_at
|
43
|
+
self.deprecated = self.perform_once?
|
44
|
+
self.save!
|
45
|
+
|
46
|
+
# prepare debug log
|
47
|
+
log = []
|
48
|
+
log << "'#{self.title}' was skipped."
|
49
|
+
log << "It was scheduled for #{rescue_perform_at} but now it's #{distance(now, rescue_perform_at)} too late to still run the job."
|
50
|
+
if self.perform_multi?
|
51
|
+
log << "The job is scheduled to perform next in #{distance(now, self.perform_at)} at #{self.perform_at}."
|
52
|
+
else
|
53
|
+
log << "This one-time job will not be scheduled again."
|
54
|
+
end
|
55
|
+
Rails.logger.error log.join(" ")
|
56
|
+
|
57
|
+
return :skip_too_old
|
58
|
+
end
|
59
|
+
|
60
|
+
if !self.perform?(now)
|
61
|
+
# prepare debug log
|
62
|
+
log = []
|
63
|
+
log << "'#{self.title}' was skipped."
|
64
|
+
perform_next_str = ""
|
65
|
+
unless self.last_performed_at.blank?
|
66
|
+
log << "It ran #{distance(now, self.last_performed_at)} ago."
|
67
|
+
perform_next_str = "next"
|
68
|
+
else
|
69
|
+
perform_next_str = self.perform_once? ? "once" : "for the first time"
|
70
|
+
end
|
71
|
+
unless self.perform_at.blank?
|
72
|
+
log << "The job is scheduled to perform #{perform_next_str} in #{distance(now, self.perform_at)} at #{self.perform_at}."
|
73
|
+
end
|
74
|
+
Rails.logger.debug log.join(" ")
|
75
|
+
|
76
|
+
return :skip
|
77
|
+
end
|
78
|
+
|
79
|
+
# call the actual job
|
80
|
+
schedule_error = nil
|
81
|
+
unless PerformEvery.dry_run
|
82
|
+
begin
|
83
|
+
Object.const_get(self.job_name).send(:perform_now)
|
84
|
+
rescue => e
|
85
|
+
schedule_error = e
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
# prepare debug log
|
90
|
+
log = []
|
91
|
+
unless schedule_error
|
92
|
+
log << "'#{self.title}' was scheduled."
|
93
|
+
else
|
94
|
+
log << "'#{self.title}' failed with error: #{schedule_error}."
|
95
|
+
end
|
96
|
+
if self.perform_multi?
|
97
|
+
log << "The job is scheduled to perform next in #{distance(now, self.perform_next_at)} at #{self.perform_next_at}."
|
98
|
+
else
|
99
|
+
log << "This one-time job will not be scheduled again."
|
100
|
+
end
|
101
|
+
|
102
|
+
unless schedule_error
|
103
|
+
Rails.logger.debug log.join(" ")
|
104
|
+
else
|
105
|
+
Rails.logger.error log.join(" ")
|
106
|
+
end
|
107
|
+
|
108
|
+
# log warning if job is performed with more than 1 minute delay
|
109
|
+
if now - self.perform_at > 1.minute
|
110
|
+
Rails.logger.warn "'#{self.title}' was run with a delay of #{distance(now, self.perform_at)}."
|
111
|
+
end
|
112
|
+
|
113
|
+
self.last_performed_at = now.utc
|
114
|
+
self.add_history(self.last_performed_at)
|
115
|
+
self.perform_at = self.perform_once? ? nil : self.perform_next_at
|
116
|
+
self.deprecated = self.perform_once?
|
117
|
+
self.save!
|
118
|
+
|
119
|
+
return schedule_error.blank? ? :perform : :error
|
120
|
+
end
|
121
|
+
|
122
|
+
def add_history(t = Time.now.utc)
|
123
|
+
self.history ||= []
|
124
|
+
self.history << t.to_s
|
125
|
+
self.history.shift(self.history.count - MAX_HISTORY) if self.history.count > MAX_HISTORY
|
126
|
+
end
|
127
|
+
|
128
|
+
def perform_next_at
|
129
|
+
if self.typ == "interval"
|
130
|
+
self.parse_interval_value.next_time.utc
|
131
|
+
elsif self.typ == "timestamp"
|
132
|
+
self.parse_timestamp_value.utc
|
133
|
+
else
|
134
|
+
raise "unknown typ"
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
def parse_interval_value
|
139
|
+
raise "must be interval" if self.value.blank?
|
140
|
+
interval = ::Fugit::Nat.parse("every " + self.value, multi: :fail)
|
141
|
+
raise "must be interval" if interval.blank? || !interval.is_a?(::Fugit::Cron)
|
142
|
+
return interval
|
143
|
+
end
|
144
|
+
|
145
|
+
def parse_timestamp_value
|
146
|
+
raise "must be timestamp" if self.value.blank?
|
147
|
+
timestamp = ::Fugit::At.parse(self.value)
|
148
|
+
raise "must be timestamp" if timestamp.blank? || !timestamp.is_a?(::EtOrbi::EoTime)
|
149
|
+
return timestamp
|
150
|
+
end
|
151
|
+
|
152
|
+
def == j
|
153
|
+
self.job_name == j.job_name && self.typ == j.typ && self.value == j.value
|
154
|
+
end
|
155
|
+
|
156
|
+
def perform_once?
|
157
|
+
self.typ == "timestamp"
|
158
|
+
end
|
159
|
+
|
160
|
+
def perform_multi?
|
161
|
+
self.typ == "interval"
|
162
|
+
end
|
163
|
+
|
164
|
+
def mark_deprecated!
|
165
|
+
self.deprecated = true
|
166
|
+
self.save!
|
167
|
+
end
|
168
|
+
|
169
|
+
end
|
170
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module PerformEvery
|
2
|
+
|
3
|
+
module Reflection
|
4
|
+
mattr_reader :store, default: []
|
5
|
+
|
6
|
+
# insert into store, ignoring duplicates
|
7
|
+
def self.insert(job)
|
8
|
+
return false if @@store.include?(job)
|
9
|
+
@@store << job
|
10
|
+
true
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.find(job)
|
14
|
+
i = @@store.index(job)
|
15
|
+
return nil if i.nil?
|
16
|
+
@@store[i]
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
end
|
@@ -0,0 +1,101 @@
|
|
1
|
+
require 'with_advisory_lock'
|
2
|
+
|
3
|
+
module PerformEvery
|
4
|
+
class Scheduler
|
5
|
+
|
6
|
+
def run_forever
|
7
|
+
Zeitwerk::Loader.eager_load_all # make sure all jobs are loaded
|
8
|
+
|
9
|
+
# trap SIGINT and SIGTERM signals for clean shutdown
|
10
|
+
kill = false
|
11
|
+
Signal.trap("INT") {|s| kill = true }
|
12
|
+
Signal.trap("TERM") {|s| kill = true }
|
13
|
+
|
14
|
+
# try to continuously acquire advisory lock so that only one worker
|
15
|
+
# at a time will schedule jobs. wait 5 seconds for lock, then try again after 30 seconds.
|
16
|
+
loop do
|
17
|
+
Rails.logger.info "Leader election: waiting to become master ..."
|
18
|
+
ActiveRecord::Base.with_advisory_lock(ADVISORY_LOCK_NAME, timeout_seconds: 5) do
|
19
|
+
Rails.logger.info "Leader election: I'm the master!"
|
20
|
+
|
21
|
+
# persist new jobs in the database
|
22
|
+
local_jobs_count = Scheduler.persist_jobs
|
23
|
+
Rails.logger.info "Found #{local_jobs_count} job/s in local files"
|
24
|
+
|
25
|
+
metrics = {}
|
26
|
+
|
27
|
+
at_exit do
|
28
|
+
Rails.logger.info "#{metrics}" unless metrics.blank?
|
29
|
+
Rails.logger.info "Bye"
|
30
|
+
end
|
31
|
+
|
32
|
+
# start endless loop
|
33
|
+
loop do
|
34
|
+
Rails.logger.info "Running scheduler ..."
|
35
|
+
|
36
|
+
# handle all jobs and schedule job if it's about time
|
37
|
+
jobs = Job.where(:deprecated => false)
|
38
|
+
jobs.each do |job|
|
39
|
+
|
40
|
+
|
41
|
+
# check if job is still present in local job files
|
42
|
+
if Reflection.store.include?(job)
|
43
|
+
op = job.perform!
|
44
|
+
metrics[op] ||= 0
|
45
|
+
metrics[op] += 1
|
46
|
+
else
|
47
|
+
job.mark_deprecated!
|
48
|
+
end
|
49
|
+
|
50
|
+
return if kill
|
51
|
+
end
|
52
|
+
|
53
|
+
metrics[:total_jobs] = jobs.count
|
54
|
+
Rails.logger.info "#{metrics}"
|
55
|
+
metrics = {}
|
56
|
+
|
57
|
+
if local_jobs_count > jobs.count + Job.where(:deprecated => true).count
|
58
|
+
Rails.logger.warn "Unpersisted jobs found. Will retry to persist."
|
59
|
+
Scheduler.persist_jobs
|
60
|
+
end
|
61
|
+
|
62
|
+
# go sleeping for SLEEP_INTERVAL and keep watching for kill commands
|
63
|
+
(SLEEP_INTERVAL / 2).times do
|
64
|
+
return if kill
|
65
|
+
sleep 2 # seconds
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
# sleep for 30 seconds, keep watching for kill commands
|
71
|
+
15.times do
|
72
|
+
return if kill
|
73
|
+
sleep 2
|
74
|
+
end
|
75
|
+
|
76
|
+
end # /loop around with_advisory_lock
|
77
|
+
end
|
78
|
+
|
79
|
+
private
|
80
|
+
|
81
|
+
# insert new jobs to database
|
82
|
+
def self.persist_jobs
|
83
|
+
return 0 if Reflection.store.blank?
|
84
|
+
Job.insert_all(Reflection.store.map{|j| {
|
85
|
+
job_name: j.job_name,
|
86
|
+
typ: j.typ,
|
87
|
+
value: j.value,
|
88
|
+
perform_at: j.perform_next_at} })
|
89
|
+
Reflection.store.count
|
90
|
+
end
|
91
|
+
|
92
|
+
def self.cleanup_deprecated_jobs
|
93
|
+
Job.where(:deprecated => true).delete_all
|
94
|
+
end
|
95
|
+
|
96
|
+
def self.reset_jobs
|
97
|
+
Job.connection.truncate(Job.table_name)
|
98
|
+
end
|
99
|
+
|
100
|
+
end
|
101
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
namespace :perform_every do
|
2
|
+
|
3
|
+
desc "Run scheduler"
|
4
|
+
task run: :environment do
|
5
|
+
s = PerformEvery::Scheduler.new
|
6
|
+
s.run_forever
|
7
|
+
end
|
8
|
+
|
9
|
+
desc "Remove deprecated jobs"
|
10
|
+
task cleanup: :environment do
|
11
|
+
PerformEvery::Scheduler.cleanup_deprecated_jobs
|
12
|
+
end
|
13
|
+
|
14
|
+
desc "Reset jobs"
|
15
|
+
task reset: :environment do
|
16
|
+
PerformEvery::Scheduler.reset_jobs
|
17
|
+
end
|
18
|
+
|
19
|
+
end
|
metadata
ADDED
@@ -0,0 +1,139 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: perform_every
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Matthias Kadenbach
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2020-02-07 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rails
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 6.0.2
|
20
|
+
- - ">="
|
21
|
+
- !ruby/object:Gem::Version
|
22
|
+
version: 6.0.2.1
|
23
|
+
type: :runtime
|
24
|
+
prerelease: false
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
26
|
+
requirements:
|
27
|
+
- - "~>"
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: 6.0.2
|
30
|
+
- - ">="
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: 6.0.2.1
|
33
|
+
- !ruby/object:Gem::Dependency
|
34
|
+
name: fugit
|
35
|
+
requirement: !ruby/object:Gem::Requirement
|
36
|
+
requirements:
|
37
|
+
- - "~>"
|
38
|
+
- !ruby/object:Gem::Version
|
39
|
+
version: '1.1'
|
40
|
+
type: :runtime
|
41
|
+
prerelease: false
|
42
|
+
version_requirements: !ruby/object:Gem::Requirement
|
43
|
+
requirements:
|
44
|
+
- - "~>"
|
45
|
+
- !ruby/object:Gem::Version
|
46
|
+
version: '1.1'
|
47
|
+
- !ruby/object:Gem::Dependency
|
48
|
+
name: with_advisory_lock
|
49
|
+
requirement: !ruby/object:Gem::Requirement
|
50
|
+
requirements:
|
51
|
+
- - "~>"
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: '3.2'
|
54
|
+
type: :runtime
|
55
|
+
prerelease: false
|
56
|
+
version_requirements: !ruby/object:Gem::Requirement
|
57
|
+
requirements:
|
58
|
+
- - "~>"
|
59
|
+
- !ruby/object:Gem::Version
|
60
|
+
version: '3.2'
|
61
|
+
- !ruby/object:Gem::Dependency
|
62
|
+
name: pg
|
63
|
+
requirement: !ruby/object:Gem::Requirement
|
64
|
+
requirements:
|
65
|
+
- - "~>"
|
66
|
+
- !ruby/object:Gem::Version
|
67
|
+
version: 1.2.2
|
68
|
+
type: :development
|
69
|
+
prerelease: false
|
70
|
+
version_requirements: !ruby/object:Gem::Requirement
|
71
|
+
requirements:
|
72
|
+
- - "~>"
|
73
|
+
- !ruby/object:Gem::Version
|
74
|
+
version: 1.2.2
|
75
|
+
- !ruby/object:Gem::Dependency
|
76
|
+
name: byebug
|
77
|
+
requirement: !ruby/object:Gem::Requirement
|
78
|
+
requirements:
|
79
|
+
- - "~>"
|
80
|
+
- !ruby/object:Gem::Version
|
81
|
+
version: '11.1'
|
82
|
+
- - ">="
|
83
|
+
- !ruby/object:Gem::Version
|
84
|
+
version: 11.1.1
|
85
|
+
type: :development
|
86
|
+
prerelease: false
|
87
|
+
version_requirements: !ruby/object:Gem::Requirement
|
88
|
+
requirements:
|
89
|
+
- - "~>"
|
90
|
+
- !ruby/object:Gem::Version
|
91
|
+
version: '11.1'
|
92
|
+
- - ">="
|
93
|
+
- !ruby/object:Gem::Version
|
94
|
+
version: 11.1.1
|
95
|
+
description: Runs jobs at specified intervals.
|
96
|
+
email:
|
97
|
+
- matthias.kadenbach@gmail.com
|
98
|
+
executables: []
|
99
|
+
extensions: []
|
100
|
+
extra_rdoc_files: []
|
101
|
+
files:
|
102
|
+
- LICENSE
|
103
|
+
- README.md
|
104
|
+
- Rakefile
|
105
|
+
- lib/generators/perform_every/active_record_generator.rb
|
106
|
+
- lib/generators/perform_every/templates/migration.rb
|
107
|
+
- lib/perform_every.rb
|
108
|
+
- lib/perform_every/activejob.rb
|
109
|
+
- lib/perform_every/helper.rb
|
110
|
+
- lib/perform_every/job.rb
|
111
|
+
- lib/perform_every/railtie.rb
|
112
|
+
- lib/perform_every/reflection.rb
|
113
|
+
- lib/perform_every/scheduler.rb
|
114
|
+
- lib/perform_every/version.rb
|
115
|
+
- lib/tasks/perform_every_tasks.rake
|
116
|
+
homepage: https://github.com/mattes/perform_every
|
117
|
+
licenses:
|
118
|
+
- MIT
|
119
|
+
metadata: {}
|
120
|
+
post_install_message:
|
121
|
+
rdoc_options: []
|
122
|
+
require_paths:
|
123
|
+
- lib
|
124
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
125
|
+
requirements:
|
126
|
+
- - ">="
|
127
|
+
- !ruby/object:Gem::Version
|
128
|
+
version: '0'
|
129
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
130
|
+
requirements:
|
131
|
+
- - ">="
|
132
|
+
- !ruby/object:Gem::Version
|
133
|
+
version: '0'
|
134
|
+
requirements: []
|
135
|
+
rubygems_version: 3.1.2
|
136
|
+
signing_key:
|
137
|
+
specification_version: 4
|
138
|
+
summary: Cron for ActiveJob
|
139
|
+
test_files: []
|