crono_trigger 0.0.1
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/.gitignore +20 -0
- data/.rspec +2 -0
- data/.travis.yml +10 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +146 -0
- data/Rakefile +47 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/crono_trigger.gemspec +36 -0
- data/exe/crono_trigger +3 -0
- data/gemfiles/activerecord-42.gemfile +5 -0
- data/gemfiles/activerecord-50.gemfile +5 -0
- data/gemfiles/activerecord-51.gemfile +5 -0
- data/lib/crono_trigger.rb +36 -0
- data/lib/crono_trigger/cli.rb +80 -0
- data/lib/crono_trigger/polling_thread.rb +60 -0
- data/lib/crono_trigger/railtie.rb +4 -0
- data/lib/crono_trigger/schedulable.rb +158 -0
- data/lib/crono_trigger/version.rb +3 -0
- data/lib/crono_trigger/worker.rb +32 -0
- data/lib/generators/crono_trigger/migration/migration_generator.rb +11 -0
- data/lib/generators/crono_trigger/migration/templates/create_table_migration.rb +38 -0
- data/lib/generators/crono_trigger/migration/templates/migration.rb +15 -0
- data/lib/generators/crono_trigger/model/model_generator.rb +11 -0
- data/lib/generators/crono_trigger/model/templates/model.rb +17 -0
- metadata +212 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: f8410e0b2dd3dc398df839baba03aaec050d3e31
|
4
|
+
data.tar.gz: 945ece057b38511d03324505c4fe960420b9f581
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 76e9b4270e9d9ea439106e88e5e031a2489212f4caaf8dedd0e39c0ba3d64189b0a3c7ecc6e67d9dfbdaffcfcfb27cbe8839a4860268c00fcede1f8c5a4f5fd5
|
7
|
+
data.tar.gz: 5adc3071703de67b4d9a894b32e4ede1677dcc119fdb8fdc021fdae4f232e56d297984363eaf5d4d1eec586226841666bef9ffa6d2713e166ff524fc4c583a74
|
data/.gitignore
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
/.bundle/
|
2
|
+
/.yardoc
|
3
|
+
/Gemfile.lock
|
4
|
+
/_yardoc/
|
5
|
+
/coverage/
|
6
|
+
/doc/
|
7
|
+
/pkg/
|
8
|
+
/spec/reports/
|
9
|
+
/tmp/
|
10
|
+
/gemfiles/.bundle
|
11
|
+
*.gemfile.lock
|
12
|
+
|
13
|
+
# rspec failure tracking
|
14
|
+
.rspec_status
|
15
|
+
|
16
|
+
log/*.log
|
17
|
+
spec/dummy/db/*.sqlite3
|
18
|
+
spec/dummy/db/*.sqlite3-journal
|
19
|
+
spec/dummy/log/*.log
|
20
|
+
spec/dummy/tmp/
|
data/.rspec
ADDED
data/.travis.yml
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2017 joker1007
|
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
|
13
|
+
all 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
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,146 @@
|
|
1
|
+
# CronoTrigger
|
2
|
+
|
3
|
+
Asynchronous Job Scheduler for Rails.
|
4
|
+
|
5
|
+
The purpose of this gem is to integrate job schedule into Service Domain.
|
6
|
+
|
7
|
+
Because of it, this gem uses ActiveRecord model as definition of job schedule.
|
8
|
+
|
9
|
+
## Installation
|
10
|
+
|
11
|
+
Add this line to your application's Gemfile:
|
12
|
+
|
13
|
+
```ruby
|
14
|
+
gem 'crono_trigger'
|
15
|
+
```
|
16
|
+
|
17
|
+
And then execute:
|
18
|
+
|
19
|
+
$ bundle
|
20
|
+
|
21
|
+
Or install it yourself as:
|
22
|
+
|
23
|
+
$ gem install crono_trigger
|
24
|
+
|
25
|
+
## Usage
|
26
|
+
|
27
|
+
#### Execute `crono_trigger:model` generator.
|
28
|
+
|
29
|
+
```
|
30
|
+
$ rails g crono_trigger:model mail_notification
|
31
|
+
create db/migrate/20170619064928_create_mail_notifications.rb
|
32
|
+
create app/models/mail_notification.rb
|
33
|
+
# ...
|
34
|
+
```
|
35
|
+
|
36
|
+
#### Migration sample
|
37
|
+
|
38
|
+
```ruby
|
39
|
+
class CreateMailNotifications < ActiveRecord::Migration
|
40
|
+
def change
|
41
|
+
create_table :mail_notifications do |t|
|
42
|
+
|
43
|
+
# columns for CronoTrigger::Schedulable
|
44
|
+
t.string :cron
|
45
|
+
t.datetime :next_execute_at
|
46
|
+
t.datetime :last_executed_at
|
47
|
+
t.integer :execute_lock, limit: 8, default: 0, null: false
|
48
|
+
t.datetime :started_at, null: false
|
49
|
+
t.datetime :finished_at
|
50
|
+
t.string :last_error_name
|
51
|
+
t.string :last_error_reason
|
52
|
+
t.datetime :last_error_time
|
53
|
+
t.integer :retry_count, default: 0, null: false
|
54
|
+
|
55
|
+
|
56
|
+
t.timestamps
|
57
|
+
end
|
58
|
+
add_index :mail_notifications, [:next_execute_at, :execute_lock, :started_at, :finished_at], name: "crono_trigger_index_on_mail_notifications"
|
59
|
+
end
|
60
|
+
end
|
61
|
+
```
|
62
|
+
|
63
|
+
#### Implement `#execute` method
|
64
|
+
|
65
|
+
```ruby
|
66
|
+
class MailNotification < ActiveRecord::Base
|
67
|
+
include CronoTrigger::Schedulable
|
68
|
+
|
69
|
+
self.crono_trigger_options = {
|
70
|
+
retry_limit: 5,
|
71
|
+
retry_interval: 10,
|
72
|
+
exponential_backoff: true,
|
73
|
+
execute_lock_timeout: 300,
|
74
|
+
}
|
75
|
+
|
76
|
+
# `execute` callback is defined
|
77
|
+
# can use `before_execute`, `after_execute`, `around_execute`
|
78
|
+
|
79
|
+
# If execute method raise Exception, worker retry task until reach `retry_limit`
|
80
|
+
# If `retry_count` reaches `retry_limit`, task schedule is reset.
|
81
|
+
#
|
82
|
+
# If record has cron value, reset process set next execution time by cron definition
|
83
|
+
# If record has no cron value, reset process clear next execution time
|
84
|
+
def execute
|
85
|
+
send_mail
|
86
|
+
|
87
|
+
throw :retry # break execution and retry task
|
88
|
+
throw :abort # break execution and raise AbortExecution. AbortExecution is not retried
|
89
|
+
throw :ok # break execution and handle task as success
|
90
|
+
end
|
91
|
+
end
|
92
|
+
```
|
93
|
+
|
94
|
+
#### Run Worker
|
95
|
+
|
96
|
+
```
|
97
|
+
$ crono_trigger MailNotification
|
98
|
+
```
|
99
|
+
|
100
|
+
```
|
101
|
+
$ crono_trigger --help
|
102
|
+
Usage: crono_trigger [options] MODEL [MODEL..]
|
103
|
+
-f, --config-file=CONFIG Config file (ex. ./crono_trigger.rb)
|
104
|
+
-e, --envornment=ENV Set environment name (ex. development, production)
|
105
|
+
-p, --polling-thread=SIZE Polling thread size (Default: 1)
|
106
|
+
-i, --polling-interval=SECOND Polling interval seconds (Default: 5)
|
107
|
+
-c, --concurrency=SIZE Execute thread size (Default: 25)
|
108
|
+
-l, --log=LOGFILE Set log output destination (Default: STDOUT or ./crono_trigger.log if daemonize is true)
|
109
|
+
--log-level=LEVEL Set log level (Default: info)
|
110
|
+
-d, --daemonize Daemon mode
|
111
|
+
--pid=PIDFILE Set pid file
|
112
|
+
-h, --help Prints this help
|
113
|
+
```
|
114
|
+
|
115
|
+
## Specification
|
116
|
+
|
117
|
+
### Columns
|
118
|
+
|
119
|
+
|name |type |required|description |
|
120
|
+
|-----------------|--------|--------|-------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
121
|
+
|cron |string |no |Recurring schedule formatted by cron style |
|
122
|
+
|next_execute_at |datetime|yes |Timestamp of next execution. Worker executes task if this column <= now |
|
123
|
+
|last_executed_at |datetime|no |Timestamp of last execution |
|
124
|
+
|execute_lock |integer |yes |Timestamp of fetching record in order to hide record from other transaction during execute lock timeout. <br> when execution complete this column is reset to 0|
|
125
|
+
|started_at |datetime|no |Timestamp of schedule activated |
|
126
|
+
|finished_at |datetime|no |Timestamp of schedule deactivated |
|
127
|
+
|last_error_name |string |no |Class name of last error |
|
128
|
+
|last_error_reason|string |no |Error message of last error |
|
129
|
+
|last_error_time |datetime|no |Timestamp of last error occured |
|
130
|
+
|retry_count |integer |no |Retry count. <br> If execution succeed retry_count is reset to 0 |
|
131
|
+
|
132
|
+
## Development
|
133
|
+
|
134
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
135
|
+
|
136
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
137
|
+
|
138
|
+
## Contributing
|
139
|
+
|
140
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/joker1007/crono_trigger.
|
141
|
+
|
142
|
+
|
143
|
+
## License
|
144
|
+
|
145
|
+
The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
|
146
|
+
|
data/Rakefile
ADDED
@@ -0,0 +1,47 @@
|
|
1
|
+
require "bundler/gem_tasks"
|
2
|
+
require "rspec/core/rake_task"
|
3
|
+
|
4
|
+
RSpec::Core::RakeTask.new(:spec)
|
5
|
+
|
6
|
+
task :default => :spec
|
7
|
+
|
8
|
+
pwd = File.expand_path('../', __FILE__)
|
9
|
+
|
10
|
+
gemfiles = Dir.glob(File.join(pwd, "gemfiles", "*.gemfile")).map { |f| File.basename(f, ".*") }
|
11
|
+
|
12
|
+
namespace :spec do
|
13
|
+
gemfiles.each do |gemfile|
|
14
|
+
desc "Run Tests by #{gemfile}.gemfile"
|
15
|
+
task gemfile do
|
16
|
+
Bundler.with_clean_env do
|
17
|
+
sh "BUNDLE_GEMFILE='#{pwd}/gemfiles/#{gemfile}.gemfile' bundle install --path #{pwd}/.bundle"
|
18
|
+
sh "BUNDLE_GEMFILE='#{pwd}/gemfiles/#{gemfile}.gemfile' bundle exec rake -t spec"
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
desc "Run All Tests"
|
24
|
+
task :all do
|
25
|
+
gemfiles.each do |gemfile|
|
26
|
+
Rake::Task["spec:#{gemfile}"].invoke
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
namespace :bundle_update do
|
32
|
+
gemfiles.each do |gemfile|
|
33
|
+
desc "Run Tests by #{gemfile}.gemfile"
|
34
|
+
task gemfile do
|
35
|
+
Bundler.with_clean_env do
|
36
|
+
sh "BUNDLE_GEMFILE='#{pwd}/gemfiles/#{gemfile}.gemfile' bundle update"
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
desc "Run All Tests"
|
42
|
+
task :all do
|
43
|
+
gemfiles.each do |gemfile|
|
44
|
+
Rake::Task["bundle_update:#{gemfile}"].invoke
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "crono_trigger"
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
# require "pry"
|
11
|
+
# Pry.start
|
12
|
+
|
13
|
+
require "irb"
|
14
|
+
IRB.start(__FILE__)
|
data/bin/setup
ADDED
@@ -0,0 +1,36 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'crono_trigger/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "crono_trigger"
|
8
|
+
spec.version = CronoTrigger::VERSION
|
9
|
+
spec.authors = ["joker1007"]
|
10
|
+
spec.email = ["kakyoin.hierophant@gmail.com"]
|
11
|
+
|
12
|
+
spec.summary = %q{In Service Asynchronous Job Scheduler for Rails}
|
13
|
+
spec.description = %q{In Service Asynchronous Job Scheduler for Rails. This gem handles ActiveRecord model as schedule definition.}
|
14
|
+
spec.homepage = "https://github.com/joker1007/crono_trigger"
|
15
|
+
spec.license = "MIT"
|
16
|
+
|
17
|
+
spec.files = `git ls-files -z`.split("\x0").reject do |f|
|
18
|
+
f.match(%r{^(test|spec|features)/})
|
19
|
+
end
|
20
|
+
spec.bindir = "exe"
|
21
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
22
|
+
spec.require_paths = ["lib"]
|
23
|
+
|
24
|
+
|
25
|
+
spec.add_dependency "chrono"
|
26
|
+
spec.add_dependency "serverengine"
|
27
|
+
spec.add_dependency "concurrent-ruby"
|
28
|
+
spec.add_dependency "activerecord", ">= 4.2"
|
29
|
+
|
30
|
+
spec.add_development_dependency "sqlite3"
|
31
|
+
spec.add_development_dependency "database_rewinder"
|
32
|
+
spec.add_development_dependency "timecop"
|
33
|
+
spec.add_development_dependency "bundler", "~> 1.14"
|
34
|
+
spec.add_development_dependency "rake", "~> 10.0"
|
35
|
+
spec.add_development_dependency "rspec", "~> 3.0"
|
36
|
+
end
|
data/exe/crono_trigger
ADDED
@@ -0,0 +1,36 @@
|
|
1
|
+
require "crono_trigger/version"
|
2
|
+
|
3
|
+
require "ostruct"
|
4
|
+
require "active_record"
|
5
|
+
require "concurrent"
|
6
|
+
require "crono_trigger/worker"
|
7
|
+
require "crono_trigger/polling_thread"
|
8
|
+
require "crono_trigger/schedulable"
|
9
|
+
|
10
|
+
module CronoTrigger
|
11
|
+
@config = OpenStruct.new(
|
12
|
+
polling_thread: 1,
|
13
|
+
polling_interval: 5,
|
14
|
+
executor_thread: 25,
|
15
|
+
model_names: [],
|
16
|
+
)
|
17
|
+
|
18
|
+
def self.config
|
19
|
+
@config
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.configure
|
23
|
+
yield config
|
24
|
+
end
|
25
|
+
|
26
|
+
def self.load_config(yml, environment = nil)
|
27
|
+
config = YAML.load_file(yml)[environment || "default"]
|
28
|
+
config.each do |k, v|
|
29
|
+
@config[k] = v
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
if defined?(Rails)
|
35
|
+
require "crono_trigger/railtie"
|
36
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
require "optparse"
|
2
|
+
require "crono_trigger"
|
3
|
+
require "serverengine"
|
4
|
+
|
5
|
+
options = {
|
6
|
+
daemonize: false,
|
7
|
+
pid_path: "./crono_trigger.pid",
|
8
|
+
}
|
9
|
+
|
10
|
+
opt_parser = OptionParser.new do |opts|
|
11
|
+
opts.banner = "Usage: crono_trigger [options] MODEL [MODEL..]"
|
12
|
+
|
13
|
+
opts.on("-f", "--config-file=CONFIG", "Config file (ex. ./crono_trigger.rb)") do |cfg|
|
14
|
+
options[:config] = cfg
|
15
|
+
end
|
16
|
+
|
17
|
+
opts.on("-e", "--envornment=ENV", "Set environment name (ex. development, production)") do |env|
|
18
|
+
options[:env] = env
|
19
|
+
end
|
20
|
+
|
21
|
+
opts.on("-p", "--polling-thread=SIZE", Integer, "Polling thread size (Default: 1)") do |i|
|
22
|
+
options[:polling_thread] = i
|
23
|
+
end
|
24
|
+
|
25
|
+
opts.on("-i", "--polling-interval=SECOND", Integer, "Polling interval seconds (Default: 5)") do |i|
|
26
|
+
options[:polling_interval] = i
|
27
|
+
end
|
28
|
+
|
29
|
+
opts.on("-c", "--concurrency=SIZE", Integer, "Execute thread size (Default: 25)") do |i|
|
30
|
+
options[:execute_thread] = i
|
31
|
+
end
|
32
|
+
|
33
|
+
opts.on("-l", "--log=LOGFILE", "Set log output destination (Default: STDOUT or ./crono_trigger.log if daemonize is true)") do |log|
|
34
|
+
options[:log] = log
|
35
|
+
end
|
36
|
+
|
37
|
+
opts.on("--log-level=LEVEL", "Set log level (Default: info)") do |log_level|
|
38
|
+
options[:log_level] = log_level
|
39
|
+
end
|
40
|
+
|
41
|
+
opts.on("-d", "--daemonize", "Daemon mode") do
|
42
|
+
options[:daemonize] = true
|
43
|
+
end
|
44
|
+
|
45
|
+
opts.on("--pid=PIDFILE", "Set pid file") do |pid|
|
46
|
+
options[:pid_path] = pid
|
47
|
+
end
|
48
|
+
|
49
|
+
opts.on("-h", "--help", "Prints this help") do
|
50
|
+
puts opts
|
51
|
+
exit
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
opt_parser.parse!
|
56
|
+
|
57
|
+
begin
|
58
|
+
require "rails"
|
59
|
+
require File.expand_path("./config/environment", Rails.root)
|
60
|
+
rescue LoadError
|
61
|
+
end
|
62
|
+
|
63
|
+
CronoTrigger.load_config(options[:config], options[:env]) if options[:config]
|
64
|
+
|
65
|
+
%i(polling_thread polling_interval execute_thread).each do |name|
|
66
|
+
CronoTrigger.config[name] = options[name] if options[name]
|
67
|
+
end
|
68
|
+
|
69
|
+
CronoTrigger.config.model_names.concat(ARGV)
|
70
|
+
|
71
|
+
se = ServerEngine.create(nil, CronoTrigger::Worker, {
|
72
|
+
daemonize: options[:daemonize],
|
73
|
+
log: options[:log] || (options[:daemonize] ? "./crono_trigger.log" : "-"),
|
74
|
+
log_level: options[:log_level] || "info",
|
75
|
+
pid_path: options[:pid_path] || (options[:daemonize] ? "./crono_trigger.pid" : nil),
|
76
|
+
supervisor: true,
|
77
|
+
server_process_name: "crono_trigger[worker]",
|
78
|
+
restart_server_process: true,
|
79
|
+
})
|
80
|
+
se.run
|
@@ -0,0 +1,60 @@
|
|
1
|
+
module CronoTrigger
|
2
|
+
class PollingThread
|
3
|
+
def initialize(model_queue, stop_flag, logger, executor)
|
4
|
+
@model_queue = model_queue
|
5
|
+
@stop_flag = stop_flag
|
6
|
+
@logger = logger
|
7
|
+
@executor = executor
|
8
|
+
end
|
9
|
+
|
10
|
+
def run
|
11
|
+
@thread = Thread.start do
|
12
|
+
@logger.info "(polling-thread-#{Thread.current.object_id}) Start polling thread"
|
13
|
+
until @stop_flag.wait_for_set(CronoTrigger.config.polling_interval)
|
14
|
+
begin
|
15
|
+
model = @model_queue.pop(true)
|
16
|
+
poll(model)
|
17
|
+
rescue ThreadError => e
|
18
|
+
logger.error(e) unless e.message == "queue empty"
|
19
|
+
rescue => e
|
20
|
+
logger.error(e)
|
21
|
+
ensure
|
22
|
+
@model_queue << model if model
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def join
|
29
|
+
@thread.join
|
30
|
+
end
|
31
|
+
|
32
|
+
def poll(model)
|
33
|
+
@logger.debug "(polling-thread-#{Thread.current.object_id}) Poll #{model}"
|
34
|
+
records = []
|
35
|
+
primary_key_offset = nil
|
36
|
+
begin
|
37
|
+
begin
|
38
|
+
conn = model.connection_pool.checkout
|
39
|
+
records = model.executables_with_lock(primary_key_offset: primary_key_offset)
|
40
|
+
primary_key_offset = records.last && records.last.id
|
41
|
+
ensure
|
42
|
+
model.connection_pool.checkin(conn)
|
43
|
+
end
|
44
|
+
|
45
|
+
records.each do |record|
|
46
|
+
@executor.post do
|
47
|
+
model.connection_pool.with_connection do
|
48
|
+
@logger.info "(executor-thread-#{Thread.current.object_id}) Execute #{record.class}-#{record.id}"
|
49
|
+
begin
|
50
|
+
record.do_execute
|
51
|
+
rescue Exception => e
|
52
|
+
@logger.error(e)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end while records.any?
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,158 @@
|
|
1
|
+
require "active_support/concern"
|
2
|
+
require "chrono"
|
3
|
+
|
4
|
+
module CronoTrigger
|
5
|
+
module Schedulable
|
6
|
+
DEFAULT_RETRY_LIMIT = 10
|
7
|
+
DEFAULT_RETRY_INTERVAL = 4
|
8
|
+
DEFAULT_EXECUTE_LOCK_TIMEOUT = 600
|
9
|
+
|
10
|
+
class AbortExecution < StandardError; end
|
11
|
+
|
12
|
+
extend ActiveSupport::Concern
|
13
|
+
include ActiveSupport::Callbacks
|
14
|
+
|
15
|
+
included do
|
16
|
+
class_attribute :crono_trigger_options
|
17
|
+
self.crono_trigger_options = {}
|
18
|
+
|
19
|
+
define_model_callbacks :execute
|
20
|
+
|
21
|
+
scope :executables, ->(from: Time.current, primary_key_offset: nil, limit: 1000) do
|
22
|
+
t = arel_table
|
23
|
+
|
24
|
+
rel = where(t[:next_execute_at].lteq(from))
|
25
|
+
.where(t[:execute_lock].lteq(from.to_i - (crono_trigger_options[:execute_lock_timeout] || DEFAULT_EXECUTE_LOCK_TIMEOUT)))
|
26
|
+
|
27
|
+
rel = rel.where(t[:started_at].lteq(from)) if column_names.include?("started_at")
|
28
|
+
rel = rel.where(t[:finished_at].gt(from).or(t[:finished_at].eq(nil))) if column_names.include?("finished_at")
|
29
|
+
rel = rel.where(t[primary_key].gt(primary_key_offset)) if primary_key_offset
|
30
|
+
|
31
|
+
rel = rel.order("#{quoted_table_name}.#{quoted_primary_key} ASC").limit(limit)
|
32
|
+
|
33
|
+
rel
|
34
|
+
end
|
35
|
+
|
36
|
+
before_create :ensure_next_execute_at
|
37
|
+
end
|
38
|
+
|
39
|
+
module ClassMethods
|
40
|
+
def executables_with_lock(primary_key_offset: nil, limit: 1000)
|
41
|
+
records = nil
|
42
|
+
transaction do
|
43
|
+
records = executables(primary_key_offset: primary_key_offset, limit: limit).lock.to_a
|
44
|
+
unless records.empty?
|
45
|
+
where(id: records).update_all(execute_lock: Time.current.to_i)
|
46
|
+
end
|
47
|
+
records
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def do_execute
|
53
|
+
run_callbacks :execute do
|
54
|
+
catch(:ok) do
|
55
|
+
catch(:retry) do
|
56
|
+
catch(:abort) do
|
57
|
+
execute
|
58
|
+
throw :ok
|
59
|
+
end
|
60
|
+
raise AbortExecution
|
61
|
+
end
|
62
|
+
retry!
|
63
|
+
return
|
64
|
+
end
|
65
|
+
reset!(true)
|
66
|
+
end
|
67
|
+
rescue AbortExecution => ex
|
68
|
+
save_last_error_info(ex)
|
69
|
+
reset!
|
70
|
+
|
71
|
+
raise
|
72
|
+
rescue Exception => ex
|
73
|
+
save_last_error_info(ex)
|
74
|
+
retry_or_reset!
|
75
|
+
|
76
|
+
raise
|
77
|
+
end
|
78
|
+
|
79
|
+
def retry!
|
80
|
+
logger.info "Retry #{self.class}-#{id}" if logger
|
81
|
+
|
82
|
+
now = Time.current
|
83
|
+
wait = crono_trigger_options[:exponential_backoff] ? retry_interval * [2 * (retry_count - 1), 1].max : retry_interval
|
84
|
+
attributes = {next_execute_at: now + wait, execute_lock: 0}
|
85
|
+
|
86
|
+
if self.class.column_names.include?("retry_count")
|
87
|
+
attributes.merge!(retry_count: retry_count.to_i + 1)
|
88
|
+
end
|
89
|
+
|
90
|
+
update_columns(attributes)
|
91
|
+
end
|
92
|
+
|
93
|
+
def reset!(update_last_executed_at = false)
|
94
|
+
logger.info "Reset execution schedule #{self.class}-#{id}" if logger
|
95
|
+
|
96
|
+
attributes = {next_execute_at: calculate_next_execute_at, execute_lock: 0}
|
97
|
+
|
98
|
+
if update_last_executed_at && self.class.column_names.include?("last_executed_at")
|
99
|
+
attributes.merge!(last_executed_at: Time.current)
|
100
|
+
end
|
101
|
+
|
102
|
+
if self.class.column_names.include?("retry_count")
|
103
|
+
attributes.merge!(retry_count: 0)
|
104
|
+
end
|
105
|
+
|
106
|
+
update_columns(attributes)
|
107
|
+
end
|
108
|
+
|
109
|
+
private
|
110
|
+
|
111
|
+
def retry_or_reset!
|
112
|
+
if respond_to?(:retry_count) && retry_count.to_i <= retry_limit
|
113
|
+
retry!
|
114
|
+
else
|
115
|
+
reset!
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
def calculate_next_execute_at
|
120
|
+
if respond_to?(:cron) && cron
|
121
|
+
it = Chrono::Iterator.new(cron)
|
122
|
+
it.next
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
def ensure_next_execute_at
|
127
|
+
self.next_execute_at ||= calculate_next_execute_at || Time.current
|
128
|
+
end
|
129
|
+
|
130
|
+
def retry_limit
|
131
|
+
crono_trigger_options[:retry_limit] || DEFAULT_RETRY_LIMIT
|
132
|
+
end
|
133
|
+
|
134
|
+
def retry_interval
|
135
|
+
crono_trigger_options[:retry_interval] || DEFAULT_RETRY_INTERVAL
|
136
|
+
end
|
137
|
+
|
138
|
+
def save_last_error_info(ex)
|
139
|
+
columns = self.class.column_names
|
140
|
+
attributes = {}
|
141
|
+
now = Time.current
|
142
|
+
|
143
|
+
if columns.include?("last_error_name")
|
144
|
+
attributes.merge!(last_error_name: ex.class.to_s)
|
145
|
+
end
|
146
|
+
|
147
|
+
if columns.include?("last_error_reason")
|
148
|
+
attributes.merge!(last_error_reason: ex.message)
|
149
|
+
end
|
150
|
+
|
151
|
+
if columns.include?("last_error_time")
|
152
|
+
attributes.merge!(last_error_time: now)
|
153
|
+
end
|
154
|
+
|
155
|
+
update_columns(attributes)
|
156
|
+
end
|
157
|
+
end
|
158
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
require "active_support/core_ext/string"
|
2
|
+
|
3
|
+
module CronoTrigger
|
4
|
+
module Worker
|
5
|
+
def initialize
|
6
|
+
@stop_flag = ServerEngine::BlockingFlag.new
|
7
|
+
@model_queue = Queue.new
|
8
|
+
CronoTrigger.config.model_names.each do |model_name|
|
9
|
+
model = model_name.classify.constantize
|
10
|
+
@model_queue << model
|
11
|
+
end
|
12
|
+
@executor = Concurrent::ThreadPoolExecutor.new(
|
13
|
+
min_threads: 1,
|
14
|
+
max_threads: CronoTrigger.config.executor_thread,
|
15
|
+
)
|
16
|
+
ActiveRecord::Base.logger = logger
|
17
|
+
end
|
18
|
+
|
19
|
+
def run
|
20
|
+
polling_threads = CronoTrigger.config.polling_thread.times.map { PollingThread.new(@model_queue, @stop_flag, logger, @executor) }
|
21
|
+
polling_threads.each(&:run)
|
22
|
+
polling_threads.each(&:join)
|
23
|
+
|
24
|
+
@executor.shutdown
|
25
|
+
@executor.wait_for_termination
|
26
|
+
end
|
27
|
+
|
28
|
+
def stop
|
29
|
+
@stop_flag.set!
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
require "rails/generators/active_record/migration/migration_generator"
|
2
|
+
|
3
|
+
module CronoTrigger
|
4
|
+
module Generators
|
5
|
+
class MigrationGenerator < ActiveRecord::Generators::MigrationGenerator
|
6
|
+
source_root File.expand_path("../templates", __FILE__)
|
7
|
+
|
8
|
+
desc "Create migration for Scheduled Job"
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
class <%= migration_class_name %> < ActiveRecord::Migration<%= Rails::VERSION::MAJOR >= 5 ? "[#{ActiveRecord::Migration.current_version}]" : "" %>
|
2
|
+
def change
|
3
|
+
create_table :<%= table_name %><%= respond_to?(:primary_key_type) ? primary_key_type : "" %> do |t|
|
4
|
+
<% attributes.each do |attribute| -%>
|
5
|
+
<% if attribute.password_digest? -%>
|
6
|
+
t.string :password_digest<%= attribute.inject_options %>
|
7
|
+
<% elsif attribute.respond_to?(:token?) && attribute.token? -%>
|
8
|
+
t.string :<%= attribute.name %><%= attribute.inject_options %>
|
9
|
+
<% else -%>
|
10
|
+
t.<%= attribute.type %> :<%= attribute.name %><%= attribute.inject_options %>
|
11
|
+
<% end -%>
|
12
|
+
<% end -%>
|
13
|
+
|
14
|
+
# columns for CronoTrigger::Schedulable
|
15
|
+
t.string :cron
|
16
|
+
t.datetime :next_execute_at
|
17
|
+
t.datetime :last_executed_at
|
18
|
+
t.integer :execute_lock, limit: 8, default: 0, null: false
|
19
|
+
t.datetime :started_at, null: false
|
20
|
+
t.datetime :finished_at
|
21
|
+
t.string :last_error_name
|
22
|
+
t.string :last_error_reason
|
23
|
+
t.datetime :last_error_time
|
24
|
+
t.integer :retry_count, default: 0, null: false
|
25
|
+
|
26
|
+
<% if options[:timestamps] %>
|
27
|
+
t.timestamps<%= Rails::VERSION::MAJOR >=5 ? " null: false" : "" %>
|
28
|
+
<% end -%>
|
29
|
+
end
|
30
|
+
<% attributes.select { |attribute| attribute.respond_to?(:token?) && attribute.token? }.each do |attribute| -%>
|
31
|
+
add_index :<%= table_name %>, :<%= attribute.index_name %><%= attribute.inject_index_options %>, unique: true
|
32
|
+
<% end -%>
|
33
|
+
<% attributes_with_index.each do |attribute| -%>
|
34
|
+
add_index :<%= table_name %>, :<%= attribute.index_name %><%= attribute.inject_index_options %>
|
35
|
+
<% end -%>
|
36
|
+
add_index :<%= table_name %>, [:next_execute_at, :execute_lock, :started_at, :finished_at], name: "crono_trigger_index_on_<%= table_name %>"
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
class <%= migration_class_name %> < ActiveRecord::Migration<%= Rails::VERSION::MAJOR >= 5 ? "[#{ActiveRecord::Migration.current_version}]" : "" %>
|
2
|
+
def change
|
3
|
+
# columns for CronoTrigger::Schedulable
|
4
|
+
add_column :<%= table_name %>, :cron, :string
|
5
|
+
add_column :<%= table_name %>, :next_execute_at, :datetime
|
6
|
+
add_column :<%= table_name %>, :last_executed_at, :datetime
|
7
|
+
add_column :<%= table_name %>, :execute_lock, :integer, limit: 8, default: 0, null: false
|
8
|
+
add_column :<%= table_name %>, :started_at, :datetime, null: false
|
9
|
+
add_column :<%= table_name %>, :finished_at, :datetime
|
10
|
+
add_column :<%= table_name %>, :last_error_name, :string
|
11
|
+
add_column :<%= table_name %>, :last_error_reason, :string
|
12
|
+
add_column :<%= table_name %>, :last_error_time, :string
|
13
|
+
add_column :<%= table_name %>, :retry_count, :integer, default: 0, null: false
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
require "rails/generators/active_record/model/model_generator"
|
2
|
+
|
3
|
+
module CronoTrigger
|
4
|
+
module Generators
|
5
|
+
class ModelGenerator < ActiveRecord::Generators::ModelGenerator
|
6
|
+
source_root File.expand_path("../templates", __FILE__)
|
7
|
+
|
8
|
+
desc "Create model for Scheduled Job"
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
<% module_namespacing do -%>
|
2
|
+
class <%= class_name %> < <%= parent_class_name.classify %>
|
3
|
+
<% attributes.select(&:reference?).each do |attribute| -%>
|
4
|
+
belongs_to :<%= attribute.name %><%= ', polymorphic: true' if attribute.polymorphic? %><%= ', required: true' if attribute.required? %>
|
5
|
+
<% end -%>
|
6
|
+
<% attributes.select { |attribute| attribute.respond_to?(:token?) && attribute.token? }.each do |attribute| -%>
|
7
|
+
has_secure_token<% if attribute.name != "token" %> :<%= attribute.name %><% end %>
|
8
|
+
<% end -%>
|
9
|
+
<% if attributes.any?(&:password_digest?) -%>
|
10
|
+
has_secure_password
|
11
|
+
<% end -%>
|
12
|
+
|
13
|
+
def execute
|
14
|
+
# Implement this
|
15
|
+
end
|
16
|
+
end
|
17
|
+
<% end -%>
|
metadata
ADDED
@@ -0,0 +1,212 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: crono_trigger
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- joker1007
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2017-06-19 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: chrono
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: serverengine
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: concurrent-ruby
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: activerecord
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '4.2'
|
62
|
+
type: :runtime
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '4.2'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: sqlite3
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ">="
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - ">="
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: database_rewinder
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - ">="
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '0'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - ">="
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '0'
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: timecop
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - ">="
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: '0'
|
104
|
+
type: :development
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - ">="
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '0'
|
111
|
+
- !ruby/object:Gem::Dependency
|
112
|
+
name: bundler
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
114
|
+
requirements:
|
115
|
+
- - "~>"
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '1.14'
|
118
|
+
type: :development
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
requirements:
|
122
|
+
- - "~>"
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: '1.14'
|
125
|
+
- !ruby/object:Gem::Dependency
|
126
|
+
name: rake
|
127
|
+
requirement: !ruby/object:Gem::Requirement
|
128
|
+
requirements:
|
129
|
+
- - "~>"
|
130
|
+
- !ruby/object:Gem::Version
|
131
|
+
version: '10.0'
|
132
|
+
type: :development
|
133
|
+
prerelease: false
|
134
|
+
version_requirements: !ruby/object:Gem::Requirement
|
135
|
+
requirements:
|
136
|
+
- - "~>"
|
137
|
+
- !ruby/object:Gem::Version
|
138
|
+
version: '10.0'
|
139
|
+
- !ruby/object:Gem::Dependency
|
140
|
+
name: rspec
|
141
|
+
requirement: !ruby/object:Gem::Requirement
|
142
|
+
requirements:
|
143
|
+
- - "~>"
|
144
|
+
- !ruby/object:Gem::Version
|
145
|
+
version: '3.0'
|
146
|
+
type: :development
|
147
|
+
prerelease: false
|
148
|
+
version_requirements: !ruby/object:Gem::Requirement
|
149
|
+
requirements:
|
150
|
+
- - "~>"
|
151
|
+
- !ruby/object:Gem::Version
|
152
|
+
version: '3.0'
|
153
|
+
description: In Service Asynchronous Job Scheduler for Rails. This gem handles ActiveRecord
|
154
|
+
model as schedule definition.
|
155
|
+
email:
|
156
|
+
- kakyoin.hierophant@gmail.com
|
157
|
+
executables:
|
158
|
+
- crono_trigger
|
159
|
+
extensions: []
|
160
|
+
extra_rdoc_files: []
|
161
|
+
files:
|
162
|
+
- ".gitignore"
|
163
|
+
- ".rspec"
|
164
|
+
- ".travis.yml"
|
165
|
+
- Gemfile
|
166
|
+
- LICENSE.txt
|
167
|
+
- README.md
|
168
|
+
- Rakefile
|
169
|
+
- bin/console
|
170
|
+
- bin/setup
|
171
|
+
- crono_trigger.gemspec
|
172
|
+
- exe/crono_trigger
|
173
|
+
- gemfiles/activerecord-42.gemfile
|
174
|
+
- gemfiles/activerecord-50.gemfile
|
175
|
+
- gemfiles/activerecord-51.gemfile
|
176
|
+
- lib/crono_trigger.rb
|
177
|
+
- lib/crono_trigger/cli.rb
|
178
|
+
- lib/crono_trigger/polling_thread.rb
|
179
|
+
- lib/crono_trigger/railtie.rb
|
180
|
+
- lib/crono_trigger/schedulable.rb
|
181
|
+
- lib/crono_trigger/version.rb
|
182
|
+
- lib/crono_trigger/worker.rb
|
183
|
+
- lib/generators/crono_trigger/migration/migration_generator.rb
|
184
|
+
- lib/generators/crono_trigger/migration/templates/create_table_migration.rb
|
185
|
+
- lib/generators/crono_trigger/migration/templates/migration.rb
|
186
|
+
- lib/generators/crono_trigger/model/model_generator.rb
|
187
|
+
- lib/generators/crono_trigger/model/templates/model.rb
|
188
|
+
homepage: https://github.com/joker1007/crono_trigger
|
189
|
+
licenses:
|
190
|
+
- MIT
|
191
|
+
metadata: {}
|
192
|
+
post_install_message:
|
193
|
+
rdoc_options: []
|
194
|
+
require_paths:
|
195
|
+
- lib
|
196
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
197
|
+
requirements:
|
198
|
+
- - ">="
|
199
|
+
- !ruby/object:Gem::Version
|
200
|
+
version: '0'
|
201
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
202
|
+
requirements:
|
203
|
+
- - ">="
|
204
|
+
- !ruby/object:Gem::Version
|
205
|
+
version: '0'
|
206
|
+
requirements: []
|
207
|
+
rubyforge_project:
|
208
|
+
rubygems_version: 2.6.11
|
209
|
+
signing_key:
|
210
|
+
specification_version: 4
|
211
|
+
summary: In Service Asynchronous Job Scheduler for Rails
|
212
|
+
test_files: []
|