crono_trigger 0.3.2 → 0.3.4

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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/.travis.yml +1 -2
  3. data/README.md +40 -0
  4. data/Rakefile +17 -0
  5. data/crono_trigger.gemspec +4 -1
  6. data/exe/crono_trigger-web +33 -0
  7. data/lib/crono_trigger.rb +20 -2
  8. data/lib/crono_trigger/cli.rb +8 -3
  9. data/lib/crono_trigger/models/signal.rb +52 -0
  10. data/lib/crono_trigger/models/worker.rb +16 -0
  11. data/lib/crono_trigger/polling_thread.rb +58 -20
  12. data/lib/crono_trigger/railtie.rb +15 -0
  13. data/lib/crono_trigger/schedulable.rb +69 -17
  14. data/lib/crono_trigger/version.rb +1 -1
  15. data/lib/crono_trigger/web.rb +163 -0
  16. data/lib/crono_trigger/worker.rb +118 -8
  17. data/lib/generators/crono_trigger/install/install_generator.rb +16 -0
  18. data/lib/generators/crono_trigger/install/templates/install.rb +23 -0
  19. data/lib/generators/crono_trigger/migration/templates/create_table_migration.rb +1 -0
  20. data/lib/generators/crono_trigger/migration/templates/migration.rb +1 -0
  21. data/web/app/.gitignore +21 -0
  22. data/web/app/README.md +2448 -0
  23. data/web/app/images.d.ts +3 -0
  24. data/web/app/package-lock.json +12439 -0
  25. data/web/app/package.json +36 -0
  26. data/web/app/public/favicon.ico +0 -0
  27. data/web/app/public/index.html +45 -0
  28. data/web/app/public/manifest.json +8 -0
  29. data/web/app/src/App.css +5 -0
  30. data/web/app/src/App.test.tsx +9 -0
  31. data/web/app/src/App.tsx +91 -0
  32. data/web/app/src/Models.tsx +61 -0
  33. data/web/app/src/SchedulableRecord.tsx +208 -0
  34. data/web/app/src/SchedulableRecordTableCell.tsx +19 -0
  35. data/web/app/src/SchedulableRecords.tsx +110 -0
  36. data/web/app/src/Signal.tsx +21 -0
  37. data/web/app/src/Signals.tsx +74 -0
  38. data/web/app/src/Worker.tsx +106 -0
  39. data/web/app/src/Workers.tsx +78 -0
  40. data/web/app/src/index.css +5 -0
  41. data/web/app/src/index.tsx +15 -0
  42. data/web/app/src/interfaces.ts +77 -0
  43. data/web/app/tsconfig.json +30 -0
  44. data/web/app/tsconfig.prod.json +3 -0
  45. data/web/app/tsconfig.test.json +6 -0
  46. data/web/app/tslint.json +13 -0
  47. data/web/public/asset-manifest.json +6 -0
  48. data/web/public/favicon.ico +0 -0
  49. data/web/public/manifest.json +8 -0
  50. data/web/public/service-worker.js +1 -0
  51. data/web/public/static/css/main.0f826673.css +2 -0
  52. data/web/public/static/css/main.0f826673.css.map +1 -0
  53. data/web/public/static/js/main.1413dc51.js +2 -0
  54. data/web/public/static/js/main.1413dc51.js.map +1 -0
  55. data/web/views/index.erb +1 -0
  56. data/web/views/signals.erb +9 -0
  57. data/web/views/workers.erb +9 -0
  58. metadata +89 -3
@@ -11,29 +11,37 @@ module CronoTrigger
11
11
  DEFAULT_RETRY_INTERVAL = 4
12
12
  DEFAULT_EXECUTE_LOCK_TIMEOUT = 600
13
13
 
14
+ class NoRestrictedUnlockError < StandardError; end
15
+
16
+ @included_by = []
17
+
18
+ def self.included_by
19
+ @included_by
20
+ end
21
+
14
22
  class AbortExecution < StandardError; end
15
23
 
16
24
  extend ActiveSupport::Concern
17
25
  include ActiveSupport::Callbacks
18
26
 
19
27
  included do
28
+ CronoTrigger::Schedulable.included_by << self
20
29
  class_attribute :crono_trigger_options, :executable_conditions
21
30
  self.crono_trigger_options ||= {}
22
31
  self.executable_conditions ||= []
23
32
 
24
33
  define_model_callbacks :execute
25
34
 
26
- scope :executables, ->(from: Time.current, primary_key_offset: nil, limit: 1000) do
35
+ scope :executables, ->(from: Time.current, limit: CronoTrigger.config.executor_thread * 3 || 100, including_locked: false) do
27
36
  t = arel_table
28
37
 
29
38
  rel = where(t[crono_trigger_column_name(:next_execute_at)].lteq(from))
30
- .where(t[crono_trigger_column_name(:execute_lock)].lteq(from.to_i - execute_lock_timeout))
39
+ rel = rel.where(t[crono_trigger_column_name(:execute_lock)].lteq(from.to_i - execute_lock_timeout)) unless including_locked
31
40
 
32
41
  rel = rel.where(t[crono_trigger_column_name(:started_at)].lteq(from)) if column_names.include?(crono_trigger_column_name(:started_at))
33
42
  rel = rel.where(t[crono_trigger_column_name(:finished_at)].gt(from).or(t[crono_trigger_column_name(:finished_at)].eq(nil))) if column_names.include?(crono_trigger_column_name(:finished_at))
34
- rel = rel.where(t[primary_key].gt(primary_key_offset)) if primary_key_offset
35
43
 
36
- rel = rel.order(Arel.sql("#{quoted_table_name}.#{quoted_primary_key} ASC")).limit(limit)
44
+ rel = rel.order(crono_trigger_column_name(:next_execute_at) => :asc).limit(limit)
37
45
 
38
46
  rel = executable_conditions.reduce(rel) do |merged, pr|
39
47
  if pr.arity == 0
@@ -52,12 +60,15 @@ module CronoTrigger
52
60
  end
53
61
 
54
62
  module ClassMethods
55
- def executables_with_lock(primary_key_offset: nil, limit: 1000)
63
+ def executables_with_lock(limit: CronoTrigger.config.executor_thread * 3 || 100)
56
64
  records = nil
57
65
  transaction do
58
- records = executables(primary_key_offset: primary_key_offset, limit: limit).lock.to_a
66
+ records = executables(limit: limit).lock.to_a
59
67
  unless records.empty?
60
- where(id: records).update_all(crono_trigger_column_name(:execute_lock) => Time.current.to_i)
68
+ where(id: records).update_all(
69
+ crono_trigger_column_name(:execute_lock) => Time.current.to_i,
70
+ crono_trigger_column_name(:locked_by) => CronoTrigger.config.worker_id
71
+ )
61
72
  end
62
73
  records
63
74
  end
@@ -71,6 +82,18 @@ module CronoTrigger
71
82
  (crono_trigger_options[:execute_lock_timeout] || DEFAULT_EXECUTE_LOCK_TIMEOUT)
72
83
  end
73
84
 
85
+ def crono_trigger_unlock_all!
86
+ wheres = all.where_values_hash
87
+ if wheres.empty?
88
+ raise NoRestrictedUnlockError, "Need `where` filter at least one, because this method is danger"
89
+ else
90
+ update_all(
91
+ crono_trigger_column_name(:execute_lock) => 0,
92
+ crono_trigger_column_name(:locked_by) => nil,
93
+ )
94
+ end
95
+ end
96
+
74
97
  private
75
98
 
76
99
  def add_executable_conditions(pr)
@@ -137,7 +160,11 @@ module CronoTrigger
137
160
 
138
161
  now = Time.current
139
162
  wait = crono_trigger_options[:exponential_backoff] ? retry_interval * [2 * (retry_count - 1), 1].max : retry_interval
140
- attributes = {crono_trigger_column_name(:next_execute_at) => now + wait, crono_trigger_column_name(:execute_lock) => 0}
163
+ attributes = {
164
+ crono_trigger_column_name(:next_execute_at) => now + wait,
165
+ crono_trigger_column_name(:execute_lock) => 0,
166
+ crono_trigger_column_name(:locked_by) => nil,
167
+ }
141
168
 
142
169
  if self.class.column_names.include?("retry_count")
143
170
  attributes.merge!(retry_count: retry_count.to_i + 1)
@@ -149,7 +176,11 @@ module CronoTrigger
149
176
  def reset!(update_last_executed_at = true)
150
177
  logger.info "Reset execution schedule #{self.class}-#{id}" if logger
151
178
 
152
- attributes = {crono_trigger_column_name(:next_execute_at) => calculate_next_execute_at, crono_trigger_column_name(:execute_lock) => 0}
179
+ attributes = {
180
+ crono_trigger_column_name(:next_execute_at) => calculate_next_execute_at,
181
+ crono_trigger_column_name(:execute_lock) => 0,
182
+ crono_trigger_column_name(:locked_by) => nil,
183
+ }
153
184
 
154
185
  if update_last_executed_at && self.class.column_names.include?(crono_trigger_column_name(:last_executed_at))
155
186
  attributes.merge!(crono_trigger_column_name(:last_executed_at) => Time.current)
@@ -162,18 +193,39 @@ module CronoTrigger
162
193
  update_columns(attributes)
163
194
  end
164
195
 
165
- def assume_executing?
166
- execute_lock_timeout = self.class.execute_lock_timeout
167
- locking? &&
168
- self[crono_trigger_column_name(:execute_lock)] + execute_lock_timeout >= Time.now.to_i
196
+ def crono_trigger_unlock!
197
+ update_columns(
198
+ crono_trigger_column_name(:execute_lock) => 0,
199
+ crono_trigger_column_name(:locked_by) => nil,
200
+ )
169
201
  end
170
202
 
171
- def locking?
172
- self[crono_trigger_column_name(:execute_lock)] > 0
203
+ def crono_trigger_status
204
+ case
205
+ when locking?
206
+ :locked
207
+ when waiting?
208
+ :waiting
209
+ when not_scheduled?
210
+ :not_scheduled
211
+ end
212
+ end
213
+
214
+ def waiting?
215
+ !!self[crono_trigger_column_name(:next_execute_at)]
216
+ end
217
+
218
+ def not_scheduled?
219
+ self[crono_trigger_column_name(:next_execute_at)].nil? && last_executed_at.nil?
220
+ end
221
+
222
+ def locking?(at: Time.now)
223
+ self[crono_trigger_column_name(:execute_lock)] > 0 &&
224
+ self[crono_trigger_column_name(:execute_lock)] > at.to_i - self.class.execute_lock_timeout
173
225
  end
174
226
 
175
- def idling?
176
- !locking?
227
+ def assume_executing?
228
+ locking?
177
229
  end
178
230
 
179
231
  def crono_trigger_column_name(name)
@@ -1,3 +1,3 @@
1
1
  module CronoTrigger
2
- VERSION = "0.3.2"
2
+ VERSION = "0.3.4"
3
3
  end
@@ -0,0 +1,163 @@
1
+ require "crono_trigger"
2
+ require "sinatra/base"
3
+ require "rack/contrib/post_body_content_type_parser"
4
+
5
+ module CronoTrigger
6
+ class Web < Sinatra::Application
7
+ use Rack::PostBodyContentTypeParser
8
+
9
+ set :root, File.expand_path("../../../web", __FILE__)
10
+ set :public_folder, Proc.new { File.join(root, "public") }
11
+ set :views, proc { File.join(root, "views") }
12
+
13
+ get "/" do
14
+ redirect to("/workers")
15
+ end
16
+
17
+ get "/workers.:format" do
18
+ if params[:format] == "json"
19
+ content_type :json
20
+ @workers = CronoTrigger::Models::Worker.alive_workers
21
+ Oj.dump({
22
+ records: @workers,
23
+ }, mode: :compat)
24
+ else
25
+ raise "unknown format"
26
+ end
27
+ end
28
+
29
+ get "/workers" do
30
+ erb :index
31
+ end
32
+
33
+ post "/signals" do
34
+ worker_id = params[:worker_id]
35
+ sig = params[:signal]
36
+ if worker_id && sig
37
+ if CronoTrigger::Models::Signal.send_signal(sig, worker_id)
38
+ status 200
39
+ body ""
40
+ else
41
+ status 422
42
+ Oj.dump({error: "#{sig} signal is not supported"}, mode: :compat)
43
+ end
44
+ else
45
+ status 422
46
+ Oj.dump({error: "Must set worker_id and signal"}, mode: :compat)
47
+ end
48
+ end
49
+
50
+ get "/signals.:format" do
51
+ if params[:format] == "json"
52
+ content_type :json
53
+ @signals = CronoTrigger::Models::Signal.order(sent_at: :desc).limit(30)
54
+ Oj.dump({
55
+ records: @signals,
56
+ }, mode: :compat)
57
+ else
58
+ raise "unknown format"
59
+ end
60
+ end
61
+
62
+ get "/signals" do
63
+ erb :index
64
+ end
65
+
66
+ post "/models/:name/:id/unlock" do
67
+ model_class = CronoTrigger::Schedulable.included_by.find { |c| c.name == params[:name] }
68
+ if model_class
69
+ model_class.find(params[:id]).crono_trigger_unlock!
70
+ status 200
71
+ body ""
72
+ else
73
+ status 404
74
+ "Model Class is not found"
75
+ end
76
+ end
77
+
78
+ post "/models/:name/:id/reset" do
79
+ model_class = CronoTrigger::Schedulable.included_by.find { |c| c.name == params[:name] }
80
+ if model_class
81
+ model_class.find(params[:id]).reset!
82
+ status 200
83
+ body ""
84
+ else
85
+ status 404
86
+ "Model Class is not found"
87
+ end
88
+ end
89
+
90
+ post "/models/:name/:id/retry" do
91
+ model_class = CronoTrigger::Schedulable.included_by.find { |c| c.name == params[:name] }
92
+ if model_class
93
+ model_class.find(params[:id]).retry!
94
+ status 200
95
+ body ""
96
+ else
97
+ status 404
98
+ "Model Class is not found"
99
+ end
100
+ end
101
+
102
+ get "/models/:name.:format" do
103
+ if params[:format] == "json"
104
+ content_type :json
105
+ model_class = CronoTrigger::Schedulable.included_by.find { |c| c.name == params[:name] }
106
+ if model_class
107
+ after_minute = params[:after] ? Integer(params[:after]) : 10
108
+ @scheduled_records = model_class.executables(from: Time.now.since(after_minute.minutes), limit: 100, including_locked: true).reorder(next_execute_at: :desc)
109
+ @scheduled_records.where!(locked_by: params[:worker_id]) if params[:worker_id]
110
+ now = Time.now
111
+ records = @scheduled_records.map do |r|
112
+ {
113
+ "crono_trigger_status" => r.crono_trigger_status,
114
+ "id" => r.id,
115
+ "cron" => r[r.crono_trigger_column_name(:cron)],
116
+ "next_execute_at" => r[r.crono_trigger_column_name(:next_execute_at)],
117
+ "last_executed_at" => r[r.crono_trigger_column_name(:last_executed_at)],
118
+ "timezone" => r[r.crono_trigger_column_name(:timezone)],
119
+ "execute_lock" => r[r.crono_trigger_column_name(:execute_lock)],
120
+ "locked_by" => r[r.crono_trigger_column_name(:locked_by)],
121
+ "started_at" => r[r.crono_trigger_column_name(:started_at)],
122
+ "finished_at" => r[r.crono_trigger_column_name(:finished_at)],
123
+ "last_error_name" => r[r.crono_trigger_column_name(:last_error_name)],
124
+ "last_error_reason" => r[r.crono_trigger_column_name(:last_error_reason)],
125
+ "last_error_time" => r[r.crono_trigger_column_name(:last_error_time)],
126
+ "retry_count" => r[r.crono_trigger_column_name(:retry_count)],
127
+ "time_to_unlock" => [(r.class.execute_lock_timeout + r[r.crono_trigger_column_name(:execute_lock)]) - now.to_i, 0].max,
128
+ "delay_sec" => r.locking?(at: now) ? 0 : (now - r[r.crono_trigger_column_name(:next_execute_at)]).to_i,
129
+ }
130
+ end
131
+ Oj.dump({
132
+ records: records,
133
+ }, mode: :compat)
134
+ else
135
+ status 404
136
+ "Model Class is not found"
137
+ end
138
+ else
139
+ raise "unknown format"
140
+ end
141
+ end
142
+
143
+ get "/models/:name" do
144
+ erb :index
145
+ end
146
+
147
+ get "/models.:format" do
148
+ if params[:format] == "json"
149
+ content_type :json
150
+ @models = CronoTrigger::Schedulable.included_by.map(&:name).sort
151
+ Oj.dump({
152
+ models: @models,
153
+ }, mode: :compat)
154
+ else
155
+ raise "unknown format"
156
+ end
157
+ end
158
+
159
+ get "/models" do
160
+ erb :index
161
+ end
162
+ end
163
+ end
@@ -2,31 +2,141 @@ require "active_support/core_ext/string"
2
2
 
3
3
  module CronoTrigger
4
4
  module Worker
5
+ HEARTBEAT_INTERVAL = 60
6
+ SIGNAL_FETCH_INTERVAL = 10
7
+ EXECUTOR_SHUTDOWN_TIMELIMIT = 300
8
+ OTHER_THREAD_SHUTDOWN_TIMELIMIT = 120
9
+ attr_reader :polling_threads
10
+
5
11
  def initialize
12
+ @crono_trigger_worker_id = CronoTrigger.config.worker_id
6
13
  @stop_flag = ServerEngine::BlockingFlag.new
14
+ @heartbeat_stop_flag = ServerEngine::BlockingFlag.new
15
+ @signal_fetch_stop_flag = ServerEngine::BlockingFlag.new
7
16
  @model_queue = Queue.new
8
- CronoTrigger.config.model_names.each do |model_name|
9
- model = model_name.classify.constantize
10
- @model_queue << model
17
+ @model_names = CronoTrigger.config.model_names || CronoTrigger::Schedulable.included_by
18
+ @model_names.each do |model_name|
19
+ @model_queue << model_name
11
20
  end
12
21
  @executor = Concurrent::ThreadPoolExecutor.new(
13
22
  min_threads: 1,
14
23
  max_threads: CronoTrigger.config.executor_thread,
24
+ max_queue: CronoTrigger.config.executor_thread * 2,
15
25
  )
16
- ActiveRecord::Base.logger = logger
26
+ @execution_counter = Concurrent::AtomicFixnum.new
27
+ @logger = Logger.new(STDOUT) unless @logger
28
+ ActiveRecord::Base.logger = @logger
17
29
  end
18
30
 
19
31
  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)
32
+ @heartbeat_thread = run_heartbeat_thread
33
+ @signal_fetcn_thread = run_signal_fetch_thread
34
+
35
+ polling_thread_count = CronoTrigger.config.polling_thread || [@model_names.size, Concurrent.processor_count].min
36
+ # Assign local variable for Signal handling
37
+ polling_threads = polling_thread_count.times.map { PollingThread.new(@model_queue, @stop_flag, @logger, @executor, @execution_counter) }
38
+ @polling_threads = polling_threads
39
+ @polling_threads.each(&:run)
40
+
41
+ ServerEngine::SignalThread.new do |st|
42
+ st.trap(:TSTP) do
43
+ @logger.info("[worker_id:#{@crono_trigger_worker_id}] Transit to quiet mode")
44
+ polling_threads.each(&:quiet)
45
+ heartbeat
46
+ end
47
+ end
48
+
49
+ @polling_threads.each(&:join)
23
50
 
24
51
  @executor.shutdown
25
- @executor.wait_for_termination
52
+ @executor.wait_for_termination(EXECUTOR_SHUTDOWN_TIMELIMIT)
53
+ @heartbeat_thread.join(OTHER_THREAD_SHUTDOWN_TIMELIMIT)
54
+ @signal_fetcn_thread.join(OTHER_THREAD_SHUTDOWN_TIMELIMIT)
55
+
56
+ unregister
26
57
  end
27
58
 
28
59
  def stop
29
60
  @stop_flag.set!
61
+ @heartbeat_stop_flag.set!
62
+ @signal_fetch_stop_flag.set!
63
+ end
64
+
65
+ def stopped?
66
+ @stop_flag.set?
67
+ end
68
+
69
+ def quiet?
70
+ @polling_threads&.all?(&:quiet?)
71
+ end
72
+
73
+ private
74
+
75
+ def run_heartbeat_thread
76
+ heartbeat
77
+ Thread.start do
78
+ until @heartbeat_stop_flag.wait_for_set(HEARTBEAT_INTERVAL)
79
+ heartbeat
80
+ end
81
+ end
82
+ end
83
+
84
+ def run_signal_fetch_thread
85
+ Thread.start do
86
+ until @signal_fetch_stop_flag.wait_for_set(SIGNAL_FETCH_INTERVAL)
87
+ handle_signal_from_rdb
88
+ end
89
+ end
90
+ end
91
+
92
+ def heartbeat
93
+ CronoTrigger::Models::Worker.connection_pool.with_connection do
94
+ begin
95
+ worker_record = CronoTrigger::Models::Worker.find_or_initialize_by(worker_id: @crono_trigger_worker_id)
96
+ worker_record.max_thread_size = @executor.max_length
97
+ worker_record.current_executing_size = @executor.scheduled_task_count
98
+ worker_record.current_queue_size = @execution_counter.value
99
+ worker_record.executor_status = executor_status
100
+ worker_record.polling_model_names = @model_names
101
+ worker_record.last_heartbeated_at = Time.current
102
+ @logger.info("[worker_id:#{@crono_trigger_worker_id}] Send heartbeat to database")
103
+ worker_record.save
104
+ rescue => ex
105
+ p ex
106
+ stop
107
+ end
108
+ end
109
+ end
110
+
111
+ def executor_status
112
+ case
113
+ when @executor.shutdown?
114
+ "shutdown"
115
+ when @executor.shuttingdown?
116
+ "shuttingdown"
117
+ when @executor.running?
118
+ if quiet?
119
+ "quiet"
120
+ else
121
+ "running"
122
+ end
123
+ end
124
+ end
125
+
126
+ def unregister
127
+ @logger.info("[worker_id:#{@crono_trigger_worker_id}] Unregister worker from database")
128
+ CronoTrigger::Models::Worker.connection_pool.with_connection do
129
+ CronoTrigger::Models::Worker.find_by(worker_id: @crono_trigger_worker_id)&.destroy
130
+ end
131
+ end
132
+
133
+ def handle_signal_from_rdb
134
+ CronoTrigger::Models::Signal.connection_pool.with_connection do
135
+ CronoTrigger::Models::Signal.sent_to_me.take(1)[0]&.tap do |s|
136
+ @logger.info("[worker_id:#{@crono_trigger_worker_id}] Receive Signal #{s.signal} from database")
137
+ s.kill_me(to_supervisor: s.signal != "TSTP")
138
+ end
139
+ end
30
140
  end
31
141
  end
32
142
  end