crono_trigger 0.3.2 → 0.3.4

Sign up to get free protection for your applications and to get access to all the features.
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