crono_trigger 0.8.4 → 1.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ec668a8a6dfa0a60daf0d2e7b80b9054b2776584343594622e37163cb4c5474b
4
- data.tar.gz: 3fc0dc5a53a9e8286ce96002b27b205d3d56594abb20b28e8a81f0bf339833b3
3
+ metadata.gz: 855216016010a5117316cfcdf1554d8873e91605d09bbdb226d69ccfaafec918
4
+ data.tar.gz: ffd42586870d68941253bad9adcf3cee6da83853bba0d2584ba79941104e0775
5
5
  SHA512:
6
- metadata.gz: d9267dcfa7df4b786b26dc8bf16e37d420972dba451ade978fcc099c0682e56b0eaddc11417081738c7d133bd4e22b572db24361b697b1d36d4cec830ca668dc
7
- data.tar.gz: b14ab3d6ba391ef873e680ab5adb00c763639e5b38ee79b7dc4aaa7edbc40be0d60a3e5653b8097d108a48dd089c671ca86306f84c1735e254dfb1d306c6a8ac
6
+ metadata.gz: bfecebefd3fc5c422ddc19db8e61014ee47d87e05ff2c0944504ea9fb0196b5352d1d91a72462778a26f5c8a0ada80dd7a6391a79fc3bfaf5c598488ebc4df11
7
+ data.tar.gz: 01a7b05d5150b3749520b4d29a50377aada67532c14a34eee64c9067b7b571589750169c0045b50a9f9202a52aff1bfb27ab6892366f97c801334e9544cd0698
@@ -12,22 +12,16 @@ jobs:
12
12
  runs-on: ubuntu-latest
13
13
  strategy:
14
14
  matrix:
15
- ruby-version: ['3.2', '3.3', '3.4']
15
+ ruby-version: ['3.4', '4.0']
16
16
  gemfile:
17
17
  - Gemfile
18
- - gemfiles/activerecord-70.gemfile
19
18
  - gemfiles/activerecord-71.gemfile
20
19
  - gemfiles/activerecord-80.gemfile
21
- exclude:
22
- - ruby-version: '3.2'
23
- gemfile: "gemfiles/activerecord-61.gemfile"
24
- - ruby-version: '3.3'
25
- gemfile: "gemfiles/activerecord-61.gemfile"
26
20
  env:
27
21
  BUNDLE_GEMFILE: ${{ matrix.gemfile }}
28
22
 
29
23
  steps:
30
- - uses: actions/checkout@v4
24
+ - uses: actions/checkout@v7
31
25
  - name: Set up Ruby
32
26
  # To automatically get bug fixes and new Ruby versions for ruby/setup-ruby,
33
27
  # change this to (see https://github.com/ruby/setup-ruby#versioning):
@@ -37,3 +31,89 @@ jobs:
37
31
  bundler-cache: true # runs 'bundle install' and caches installed gems automatically
38
32
  - name: Run tests
39
33
  run: bundle exec rake
34
+
35
+ test-mysql:
36
+
37
+ runs-on: ubuntu-latest
38
+ strategy:
39
+ matrix:
40
+ ruby-version: ['3.4', '4.0']
41
+ gemfile:
42
+ - Gemfile
43
+ - gemfiles/activerecord-71.gemfile
44
+ - gemfiles/activerecord-80.gemfile
45
+ services:
46
+ mysql:
47
+ image: mysql:8.4
48
+ env:
49
+ MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
50
+ MYSQL_DATABASE: test
51
+ ports:
52
+ - 3306:3306
53
+ options: >-
54
+ --health-cmd "mysqladmin ping -h 127.0.0.1"
55
+ --health-interval 5s
56
+ --health-timeout 5s
57
+ --health-retries 20
58
+ env:
59
+ BUNDLE_GEMFILE: ${{ matrix.gemfile }}
60
+ DB: mysql
61
+ DB_HOST: 127.0.0.1
62
+ DB_PORT: 3306
63
+ DB_USERNAME: root
64
+
65
+ steps:
66
+ - uses: actions/checkout@v7
67
+ - name: Set up Ruby
68
+ uses: ruby/setup-ruby@v1
69
+ with:
70
+ ruby-version: ${{ matrix.ruby-version }}
71
+ bundler-cache: true
72
+ - name: Run tests
73
+ run: bundle exec rake
74
+ env:
75
+ # `job` context is only available in step level
76
+ MYSQL_RESTART_COMMAND: >-
77
+ docker restart ${{ job.services.mysql.id }} &&
78
+ until docker exec ${{ job.services.mysql.id }} mysqladmin ping -h 127.0.0.1 --silent; do sleep 1; done
79
+
80
+ test-postgresql:
81
+
82
+ runs-on: ubuntu-latest
83
+ strategy:
84
+ matrix:
85
+ ruby-version: ['3.4', '4.0']
86
+ gemfile:
87
+ - Gemfile
88
+ - gemfiles/activerecord-71.gemfile
89
+ - gemfiles/activerecord-80.gemfile
90
+ services:
91
+ postgres:
92
+ image: postgres:18
93
+ env:
94
+ POSTGRES_PASSWORD: postgres
95
+ POSTGRES_DB: test
96
+ ports:
97
+ - 5432:5432
98
+ options: >-
99
+ --health-cmd "pg_isready -h 127.0.0.1 -U postgres"
100
+ --health-interval 5s
101
+ --health-timeout 5s
102
+ --health-retries 20
103
+ env:
104
+ BUNDLE_GEMFILE: ${{ matrix.gemfile }}
105
+ DB: postgresql
106
+ DB_HOST: 127.0.0.1
107
+ DB_PORT: 5432
108
+ DB_USERNAME: postgres
109
+ DB_PASSWORD: postgres
110
+
111
+ steps:
112
+ - uses: actions/checkout@v7
113
+ - name: Set up Ruby
114
+ uses: ruby/setup-ruby@v1
115
+ with:
116
+ ruby-version: ${{ matrix.ruby-version }}
117
+ bundler-cache: true
118
+ - name: Run tests
119
+ run: bundle exec rake
data/.gitignore CHANGED
@@ -21,3 +21,6 @@ spec/dummy/tmp/
21
21
  spec/testdb.sqlite3*
22
22
 
23
23
  .gem_rbs_collection
24
+
25
+ .envrc
26
+ .nvim.lua
data/README.md CHANGED
@@ -25,8 +25,38 @@ Or install it yourself as:
25
25
 
26
26
  $ gem install crono_trigger
27
27
 
28
+ ## Requirements
29
+
30
+ CronoTrigger fetches executable records with `SELECT ... FOR UPDATE SKIP LOCKED`.
31
+ So the database must support `SKIP LOCKED` (MySQL 8.0+ or PostgreSQL 9.5+).
32
+
33
+ CronoTrigger also requires ActiveRecord 7.1+.
34
+
35
+ If your database does not support `SKIP LOCKED` or you use ActiveRecord < 7.1, use crono_trigger < 1.0.0.
36
+
37
+ ```ruby
38
+ gem 'crono_trigger', '< 1.0.0'
39
+ ```
40
+
28
41
  ## Breaking Changes
29
42
 
43
+ ### Update from v0.8.x
44
+
45
+ - The database must support `SKIP LOCKED` (MySQL 8.0+ or PostgreSQL 9.5+).
46
+ Records locked by other workers are skipped instead of waited for.
47
+ If your database does not support `SKIP LOCKED`, keep using crono_trigger < 1.0.0.
48
+ - ActiveRecord < 7.1 is no longer supported. If you use it, keep using crono_trigger < 1.0.0.
49
+ - The default value of `fetch_records` is changed from `executor_thread * 3` to `1`.
50
+ See [Tuning `fetch_records`](#tuning-fetch_records).
51
+ - The `worker_count` argument of `executables_with_lock` and `CronoTrigger::PollingThread#worker_count=` are removed.
52
+ - `exponential_backoff` actually doubles the retry interval on each retry (`retry_interval * 2 ** retry_count`).
53
+ Previously, it increased the interval linearly after the third retry.
54
+ - `crono_trigger` command without MODEL arguments polls all models including `CronoTrigger::Schedulable` as described in its help.
55
+ Previously, it polled no models.
56
+
57
+ Because a locking read locks the index records which it scans, make sure that the index starting with `next_execute_at` (See [Migration sample](#migration-sample)) exists.
58
+ Without the index, a polling query might lock much more records than it fetches.
59
+
30
60
  ### Update from v0.6.x
31
61
 
32
62
  In previous version, now is `2023-06-07T18:00:00+00:00`, a cron definition is `0 1 * * * *`, and `started_at` is `2023-06-08T1:00:00+00:00`.
@@ -170,7 +200,7 @@ Usage: crono_trigger [options] MODEL [MODEL..]
170
200
  -p, --polling-thread=SIZE Polling thread size (Default: 1)
171
201
  -i, --polling-interval=SECOND Polling interval seconds (Default: 5)
172
202
  -c, --concurrency=SIZE Execute thread size (Default: 25)
173
- -r, --fetch-records=SIZE Record count fetched by polling thread (Default: concurrency * 3)
203
+ -r, --fetch-records=SIZE Record count fetched by polling thread at once (Default: 1)
174
204
  -l, --log=LOGFILE Set log output destination (Default: STDOUT or ./crono_trigger.log if daemonize is true)
175
205
  --log-level=LEVEL Set log level (Default: info)
176
206
  -d, --daemonize Daemon mode
@@ -178,6 +208,18 @@ Usage: crono_trigger [options] MODEL [MODEL..]
178
208
  -h, --help Prints this help
179
209
  ```
180
210
 
211
+ #### Tuning `fetch_records`
212
+
213
+ A polling thread fetches and locks `fetch_records` records in a transaction, and repeats it while it can fetch `fetch_records` records.
214
+ By default, it fetches records one by one. It is enough in most cases, and it keeps locking duration short and distributes records evenly to workers.
215
+
216
+ If there are many jobs and each job finishes quickly, fetching one by one requires a few round trips to database per record and it can be a bottleneck.
217
+ In such case, increase `fetch_records` (for example, the same value as `--concurrency`).
218
+
219
+ ```
220
+ $ crono_trigger --concurrency=25 --fetch-records=25 MailNotification
221
+ ```
222
+
181
223
  #### Handle errors
182
224
 
183
225
  This gem provides the following options to handle errors:
@@ -228,6 +270,10 @@ CronoTrigger.configure do |config|
228
270
  end
229
271
  ```
230
272
 
273
+ NOTE: If the connection is lost right after the transaction that fetches and locks records is committed, the polling thread cannot know that the records have been locked.
274
+ The retry fetches other records, and the locked records are not executed until `execute_lock_timeout` passes.
275
+ They are executed after that as usual, so they are not lost but delayed.
276
+
231
277
 
232
278
  ## Specification
233
279
 
@@ -28,15 +28,16 @@ Gem::Specification.new do |spec|
28
28
  spec.add_dependency "tzinfo"
29
29
  spec.add_dependency "sinatra"
30
30
  spec.add_dependency "rack-contrib"
31
- spec.add_dependency "oj"
32
- spec.add_dependency "activerecord", ">= 4.2"
31
+ spec.add_dependency "activerecord", ">= 7.1"
33
32
  spec.add_dependency "retriable"
33
+ spec.add_dependency "ostruct"
34
34
 
35
35
  spec.add_development_dependency "sqlite3", ">= 1.3"
36
36
  spec.add_development_dependency "mysql2"
37
+ spec.add_development_dependency "pg"
37
38
  spec.add_development_dependency "timecop"
38
39
  spec.add_development_dependency "rollbar"
39
- spec.add_development_dependency "bundler", "~> 2.0"
40
+ spec.add_development_dependency "bundler", ">= 2.0"
40
41
  spec.add_development_dependency "rake"
41
42
  spec.add_development_dependency "rspec"
42
43
  spec.add_development_dependency "simplecov"
@@ -34,7 +34,7 @@ opt_parser = OptionParser.new do |opts|
34
34
  options[:executor_thread] = i
35
35
  end
36
36
 
37
- opts.on("-r", "--fetch-records=SIZE", Integer, "Record count fetched by polling thread (Default: concurrency * 3)") do |i|
37
+ opts.on("-r", "--fetch-records=SIZE", Integer, "Record count fetched by polling thread at once (Default: 1)") do |i|
38
38
  options[:fetch_records] = i
39
39
  end
40
40
 
@@ -75,7 +75,7 @@ CronoTrigger.load_config(options[:config], options[:env]) if options[:config]
75
75
  CronoTrigger.config[name] = options[name] if options[name]
76
76
  end
77
77
 
78
- CronoTrigger.config.model_names = ARGV
78
+ CronoTrigger.config.model_names = ARGV unless ARGV.empty?
79
79
 
80
80
  se = ServerEngine.create(nil, CronoTrigger::Worker, {
81
81
  daemonize: options[:daemonize],
@@ -10,15 +10,16 @@ module CronoTrigger
10
10
 
11
11
  def handle_exception(ex)
12
12
  handlers = CronoTrigger.config.error_handlers + Array(@record.crono_trigger_options[:error_handlers])
13
- handlers.each do |callable|
14
- callable, arity = ensure_callable(callable)
13
+ handlers.each do |handler|
14
+ callable, arity = ensure_callable(handler)
15
15
  args = [ex, @record]
16
16
  args = arity < 0 ? args : args.take(arity)
17
17
  callable.call(*args)
18
+ # Rescue each handler so that an error in one handler does not prevent the following handlers
19
+ rescue Exception => e
20
+ @record.logger&.error("CronoTrigger error handler raises error")
21
+ @record.logger&.error(e)
18
22
  end
19
- rescue Exception => e
20
- @record.logger.error("CronoTrigger error handler raises error")
21
- @record.logger.error(e)
22
23
  end
23
24
 
24
25
  private
@@ -28,6 +29,8 @@ module CronoTrigger
28
29
  return callable, callable.arity
29
30
  elsif callable.is_a?(Symbol)
30
31
  return @record.method(callable), 1
32
+ else
33
+ raise ArgumentError, "error handler must respond to `call` or be a Symbol: #{callable.inspect}"
31
34
  end
32
35
  end
33
36
  end
@@ -23,7 +23,7 @@ module CronoTrigger
23
23
  else
24
24
  execution.complete!
25
25
  end
26
- rescue => e
26
+ rescue Exception => e
27
27
  execution.error!(e)
28
28
  raise
29
29
  end
@@ -6,16 +6,17 @@ module CronoTrigger
6
6
 
7
7
  def handle_global_exception(ex)
8
8
  handlers = CronoTrigger.config.global_error_handlers
9
- handlers.each do |callable|
10
- callable, arity = ensure_callable(callable)
9
+ handlers.each do |handler|
10
+ callable, arity = ensure_callable(handler)
11
11
 
12
12
  args = [ex]
13
13
  args = arity < 0 ? args : args.take(arity)
14
14
  callable.call(*args)
15
+ # Rescue each handler so that an error in one handler does not prevent the following handlers
16
+ rescue Exception => e
17
+ ActiveRecord::Base.logger&.error("CronoTrigger error handler raises error")
18
+ ActiveRecord::Base.logger&.error(e)
15
19
  end
16
- rescue Exception => e
17
- ActiveRecord::Base.logger.error("CronoTrigger error handler raises error")
18
- ActiveRecord::Base.logger.error(e)
19
20
  end
20
21
 
21
22
  private
@@ -23,6 +24,8 @@ module CronoTrigger
23
24
  def ensure_callable(callable)
24
25
  if callable.respond_to?(:call)
25
26
  return callable, callable.arity
27
+ else
28
+ raise ArgumentError, "global error handler must respond to `call`: #{callable.inspect}"
26
29
  end
27
30
  end
28
31
  end
@@ -7,23 +7,13 @@ module CronoTrigger
7
7
 
8
8
  scope :recently, ->(schedule_type:) { where(schedule_type: schedule_type).order(executed_at: :desc) }
9
9
 
10
- if ActiveRecord.version >= Gem::Version.new("7.0")
11
- enum :status, {
12
- executing: "executing",
13
- completed: "completed",
14
- failed: "failed",
15
- retrying: "retrying",
16
- aborted: "aborted",
17
- }
18
- else
19
- enum status: {
20
- executing: "executing",
21
- completed: "completed",
22
- failed: "failed",
23
- retrying: "retrying",
24
- aborted: "aborted",
25
- }
26
- end
10
+ enum :status, {
11
+ executing: "executing",
12
+ completed: "completed",
13
+ failed: "failed",
14
+ retrying: "retrying",
15
+ aborted: "aborted",
16
+ }
27
17
 
28
18
  def self.create_with_timestamp!
29
19
  create!(executed_at: Time.current, status: :executing, worker_id: CronoTrigger.config.worker_id)
@@ -5,11 +5,7 @@ module CronoTrigger
5
5
 
6
6
  IGNORE_THRESHOLD = 300
7
7
 
8
- if ActiveRecord.version >= Gem::Version.new("7.0")
9
- enum :signal, {TERM: "TERM", USR1: "USR1", CONT: "CONT", TSTP: "TSTP"}
10
- else
11
- enum signal: {TERM: "TERM", USR1: "USR1", CONT: "CONT", TSTP: "TSTP"}
12
- end
8
+ enum :signal, {TERM: "TERM", USR1: "USR1", CONT: "CONT", TSTP: "TSTP"}
13
9
 
14
10
  scope :sent_to_me, proc {
15
11
  raise "Must set worker_id" unless CronoTrigger.config.worker_id
@@ -7,17 +7,9 @@ module CronoTrigger
7
7
 
8
8
  ALIVE_THRESHOLD = CronoTrigger::Worker::HEARTBEAT_INTERVAL * 5
9
9
 
10
- if ActiveRecord.version >= Gem::Version.new("7.0")
11
- enum :executor_status, {running: "running", quiet: "quiet", shuttingdown: "shuttingdown", shutdown: "shutdown"}
12
- else
13
- enum executor_status: {running: "running", quiet: "quiet", shuttingdown: "shuttingdown", shutdown: "shutdown"}
14
- end
10
+ enum :executor_status, {running: "running", quiet: "quiet", shuttingdown: "shuttingdown", shutdown: "shutdown"}
15
11
 
16
- if ActiveRecord.version >= Gem::Version.new("7.1.0")
17
- serialize :polling_model_names, coder: JSON
18
- else
19
- serialize :polling_model_names, JSON
20
- end
12
+ serialize :polling_model_names, coder: JSON
21
13
 
22
14
  scope :alive_workers, proc { where(arel_table[:last_heartbeated_at].gteq(Time.current - ALIVE_THRESHOLD)) }
23
15
  end
@@ -10,7 +10,6 @@ module CronoTrigger
10
10
  end
11
11
  @execution_counter = execution_counter
12
12
  @quiet = Concurrent::AtomicBoolean.new(false)
13
- @worker_count = 1
14
13
  end
15
14
 
16
15
  def run
@@ -60,7 +59,7 @@ module CronoTrigger
60
59
  while maybe_has_next && !@stop_flag.set?
61
60
  records, maybe_has_next = CronoTrigger.retry_on_db_errors do
62
61
  model.connection_pool.with_connection do
63
- model.executables_with_lock(limit: CronoTrigger.config.fetch_records || CronoTrigger.config.executor_thread * 3, worker_count: @worker_count)
62
+ model.executables_with_lock(limit: CronoTrigger.config.fetch_records)
64
63
  end
65
64
  end
66
65
 
@@ -77,11 +76,6 @@ module CronoTrigger
77
76
  end
78
77
  end
79
78
 
80
- def worker_count=(n)
81
- raise ArgumentError, "worker_count must be greater than 0" if n <= 0
82
- @worker_count = n
83
- end
84
-
85
79
  private
86
80
 
87
81
  def process_record(record)
@@ -63,31 +63,28 @@ module CronoTrigger
63
63
  end
64
64
 
65
65
  module ClassMethods
66
- def executables_with_lock(limit: CronoTrigger.config.executor_thread * 3, worker_count: 1)
67
- records = []
68
- # Fetch more than `limit` records because other workers might have acquired the lock
69
- # and this method might not be able to return enough records for the executor to
70
- # make the best use of the CPU.
71
- ids = executables(limit: limit * worker_count).pluck(:id)
72
- maybe_has_next = !ids.empty?
73
- ids.each do |id|
74
- transaction do
75
- r = all.lock.find(id)
76
- unless r.locking?
77
- r.crono_trigger_lock!
78
- records << r
79
- end
80
- end
66
+ # Requires a database that supports `FOR UPDATE SKIP LOCKED` (MySQL 8.0+, PostgreSQL 9.5+).
67
+ # Rows locked by other workers' transactions are skipped instead of waited for.
68
+ def executables_with_lock(limit: CronoTrigger.config.fetch_records)
69
+ now = Time.current
70
+ attributes = {
71
+ crono_trigger_column_name(:execute_lock) => now.to_i,
72
+ crono_trigger_column_name(:locked_by) => CronoTrigger.config.worker_id,
73
+ }
74
+ attributes["updated_at"] = now if column_names.include?("updated_at")
81
75
 
82
- return [records, maybe_has_next] if records.size == limit
76
+ records = transaction do
77
+ rs = executables(from: now, limit: limit).lock("FOR UPDATE SKIP LOCKED").to_a
78
+ unscoped.where(primary_key => rs.map(&:id)).update_all(attributes) unless rs.empty?
79
+ rs
83
80
  end
84
81
 
85
- [records, maybe_has_next]
86
- rescue => e
87
- raise if records.empty?
82
+ records.each do |r|
83
+ r.assign_attributes(attributes)
84
+ r.clear_changes_information
85
+ end
88
86
 
89
- logger&.warn("Failed to fetching some records but continue processing records: #{e} (#{e.class})")
90
- [records, maybe_has_next]
87
+ [records, records.size >= limit]
91
88
  end
92
89
 
93
90
  def crono_trigger_column_name(name)
@@ -112,16 +109,27 @@ module CronoTrigger
112
109
 
113
110
  private
114
111
 
112
+ # Reassign instead of mutating because class_attribute shares the same object with subclasses
115
113
  def add_executable_conditions(pr)
116
- self.executable_conditions << pr
114
+ self.executable_conditions = executable_conditions + [pr]
117
115
  end
118
116
 
119
117
  def clear_executable_conditions
120
- self.executable_conditions.clear
118
+ self.executable_conditions = []
121
119
  end
122
120
  end
123
121
 
122
+ # A copy does not hold the lock, but `dup` would share the lock state of the original during an execution
123
+ private def initialize_dup(other)
124
+ super
125
+ @crono_trigger_holding_lock = nil
126
+ end
127
+
124
128
  def do_execute
129
+ @crono_trigger_holding_lock = {
130
+ crono_trigger_column_name(:execute_lock) => self[crono_trigger_column_name(:execute_lock)],
131
+ crono_trigger_column_name(:locked_by) => self[crono_trigger_column_name(:locked_by)],
132
+ }
125
133
  ExecutionTracker.track(self) do
126
134
  do_execute_with_catch
127
135
  end
@@ -129,6 +137,8 @@ module CronoTrigger
129
137
  logger.error(ex) if logger
130
138
  save_last_error_info(ex)
131
139
  retry_or_reset!(ex)
140
+ ensure
141
+ @crono_trigger_holding_lock = nil
132
142
  end
133
143
 
134
144
  private def do_execute_with_catch
@@ -181,10 +191,12 @@ module CronoTrigger
181
191
  logger.info "Retry #{self.class}-#{id}" if logger
182
192
 
183
193
  now = Time.current
194
+ has_retry_count = self.class.column_names.include?("retry_count")
195
+ current_retry_count = has_retry_count ? retry_count.to_i : 0
184
196
  if immediately
185
197
  wait = 0
186
198
  else
187
- wait = crono_trigger_options[:exponential_backoff] ? retry_interval * [2 * (retry_count - 1), 1].max : retry_interval
199
+ wait = crono_trigger_options[:exponential_backoff] ? retry_interval * (2 ** current_retry_count) : retry_interval
188
200
  end
189
201
  attributes = {
190
202
  crono_trigger_column_name(:next_execute_at) => now + wait,
@@ -192,12 +204,12 @@ module CronoTrigger
192
204
  crono_trigger_column_name(:locked_by) => nil,
193
205
  }
194
206
 
195
- if self.class.column_names.include?("retry_count")
196
- attributes.merge!(retry_count: retry_count.to_i + 1)
207
+ if has_retry_count
208
+ attributes.merge!(retry_count: current_retry_count + 1)
197
209
  end
198
210
 
199
211
  merge_updated_at_for_crono_trigger!(attributes, now)
200
- update_columns(attributes)
212
+ update_crono_trigger_columns(attributes)
201
213
  end
202
214
  end
203
215
 
@@ -225,7 +237,7 @@ module CronoTrigger
225
237
  end
226
238
 
227
239
  merge_updated_at_for_crono_trigger!(attributes, now)
228
- update_columns(attributes)
240
+ update_crono_trigger_columns(attributes)
229
241
  end
230
242
 
231
243
  def abort_execution!
@@ -392,7 +404,31 @@ module CronoTrigger
392
404
  end
393
405
 
394
406
  merge_updated_at_for_crono_trigger!(attributes)
395
- update_columns(attributes) unless attributes.empty?
407
+ update_crono_trigger_columns(attributes) unless attributes.empty?
408
+ end
409
+
410
+ # While executing, update only if this record still holds the lock acquired before the execution.
411
+ # Otherwise, the record has been locked by another worker after execute_lock_timeout, and updating it would
412
+ # overwrite the schedule which another worker is executing.
413
+ # Outside of executions (e.g. operations from the admin web), update unconditionally as before.
414
+ def update_crono_trigger_columns(attributes)
415
+ return update_columns(attributes) unless @crono_trigger_holding_lock
416
+
417
+ updated_count = self.class.unscoped
418
+ .where(self.class.primary_key => id)
419
+ .where(@crono_trigger_holding_lock)
420
+ .update_all(attributes)
421
+ if updated_count.zero?
422
+ logger&.warn("Skip updating #{self.class}-#{id} because the record has been deleted or locked by another worker")
423
+ return false
424
+ end
425
+
426
+ attributes.each { |name, value| self[name] = value }
427
+ clear_attribute_changes(attributes.keys.map(&:to_s))
428
+ @crono_trigger_holding_lock.each_key do |name|
429
+ @crono_trigger_holding_lock[name] = attributes[name] if attributes.key?(name)
430
+ end
431
+ true
396
432
  end
397
433
 
398
434
  def merge_updated_at_for_crono_trigger!(attributes, time = Time.current)
@@ -1,3 +1,3 @@
1
1
  module CronoTrigger
2
- VERSION = "0.8.4"
2
+ VERSION = "1.0.0"
3
3
  end
@@ -1,7 +1,7 @@
1
1
  require "crono_trigger"
2
2
  require "sinatra/base"
3
3
  require "rack/contrib/post_body_content_type_parser"
4
- require "oj"
4
+ require "active_support/json"
5
5
 
6
6
  module CronoTrigger
7
7
  class Web < Sinatra::Application
@@ -19,9 +19,9 @@ module CronoTrigger
19
19
  if params[:format] == "json"
20
20
  content_type :json
21
21
  @workers = CronoTrigger::Models::Worker.alive_workers
22
- Oj.dump({
22
+ {
23
23
  records: @workers,
24
- }, mode: :compat)
24
+ }.to_json
25
25
  else
26
26
  raise "unknown format"
27
27
  end
@@ -35,16 +35,18 @@ module CronoTrigger
35
35
  worker_id = params[:worker_id]
36
36
  sig = params[:signal]
37
37
  if worker_id && sig
38
- if CronoTrigger::Models::Signal.send_signal(sig, worker_id)
38
+ # Check before creating because assigning an undefined enum value raises ArgumentError
39
+ if CronoTrigger::Models::Signal.signals.key?(sig)
40
+ CronoTrigger::Models::Signal.send_signal(sig, worker_id)
39
41
  status 200
40
42
  body ""
41
43
  else
42
44
  status 422
43
- Oj.dump({error: "#{sig} signal is not supported"}, mode: :compat)
45
+ {error: "#{sig} signal is not supported"}.to_json
44
46
  end
45
47
  else
46
48
  status 422
47
- Oj.dump({error: "Must set worker_id and signal"}, mode: :compat)
49
+ {error: "Must set worker_id and signal"}.to_json
48
50
  end
49
51
  end
50
52
 
@@ -52,9 +54,9 @@ module CronoTrigger
52
54
  if params[:format] == "json"
53
55
  content_type :json
54
56
  @signals = CronoTrigger::Models::Signal.order(sent_at: :desc).limit(30)
55
- Oj.dump({
57
+ {
56
58
  records: @signals,
57
- }, mode: :compat)
59
+ }.to_json
58
60
  else
59
61
  raise "unknown format"
60
62
  end
@@ -136,9 +138,9 @@ module CronoTrigger
136
138
  -"delay_sec" => r.locking?(at: now) ? 0 : (now - r[r.crono_trigger_column_name(:next_execute_at)]).to_i,
137
139
  }
138
140
  end
139
- Oj.dump({
141
+ {
140
142
  records: records,
141
- }, mode: :compat)
143
+ }.to_json
142
144
  else
143
145
  status 404
144
146
  "Model Class is not found"
@@ -156,9 +158,9 @@ module CronoTrigger
156
158
  if params[:format] == "json"
157
159
  content_type :json
158
160
  @models = CronoTrigger::Schedulable.included_by.map(&:name).sort
159
- Oj.dump({
161
+ {
160
162
  models: @models,
161
- }, mode: :compat)
163
+ }.to_json
162
164
  else
163
165
  raise "unknown format"
164
166
  end
@@ -189,9 +191,9 @@ module CronoTrigger
189
191
  -"error_reason" => r.error_reason,
190
192
  }
191
193
  end
192
- Oj.dump({
194
+ {
193
195
  records: records,
194
- }, mode: :compat)
196
+ }.to_json
195
197
  else
196
198
  status 404
197
199
  "Model Class is not found"
@@ -7,7 +7,6 @@ module CronoTrigger
7
7
  HEARTBEAT_INTERVAL = 60
8
8
  SIGNAL_FETCH_INTERVAL = 10
9
9
  MONITOR_INTERVAL = 20
10
- WORKER_COUNT_UPDATE_INTERVAL = 60
11
10
  EXECUTOR_SHUTDOWN_TIMELIMIT = 300
12
11
  OTHER_THREAD_SHUTDOWN_TIMELIMIT = 120
13
12
  attr_reader :polling_threads
@@ -19,7 +18,9 @@ module CronoTrigger
19
18
  @signal_fetch_stop_flag = ServerEngine::BlockingFlag.new
20
19
  @monitor_stop_flag = ServerEngine::BlockingFlag.new
21
20
  @model_queue = Queue.new
22
- @model_names = CronoTrigger.config.model_names || CronoTrigger::Schedulable.included_by
21
+ # Model names are used as String in PollingThread (`classify`) and serialized to the workers table,
22
+ # but `included_by` holds Class objects.
23
+ @model_names = (CronoTrigger.config.model_names.presence || CronoTrigger::Schedulable.included_by).map(&:to_s)
23
24
  @model_names.each do |model_name|
24
25
  @model_queue << model_name
25
26
  end
@@ -45,8 +46,6 @@ module CronoTrigger
45
46
  @polling_threads = polling_threads
46
47
  @polling_threads.each(&:run)
47
48
 
48
- @worker_count_updater_thread = run_worker_count_updater_thread
49
-
50
49
  ServerEngine::SignalThread.new do |st|
51
50
  st.trap(:TSTP) do
52
51
  @logger.info("[worker_id:#{@crono_trigger_worker_id}] Transit to quiet mode")
@@ -61,7 +60,6 @@ module CronoTrigger
61
60
  @executor.wait_for_termination(EXECUTOR_SHUTDOWN_TIMELIMIT)
62
61
  @heartbeat_thread.join(OTHER_THREAD_SHUTDOWN_TIMELIMIT)
63
62
  @signal_fetcn_thread.join(OTHER_THREAD_SHUTDOWN_TIMELIMIT)
64
- @worker_count_updater_thread.join(OTHER_THREAD_SHUTDOWN_TIMELIMIT)
65
63
 
66
64
  unregister
67
65
  end
@@ -108,15 +106,6 @@ module CronoTrigger
108
106
  end
109
107
  end
110
108
 
111
- def run_worker_count_updater_thread
112
- update_worker_count
113
- Thread.start do
114
- until @stop_flag.wait_for_set(WORKER_COUNT_UPDATE_INTERVAL)
115
- update_worker_count
116
- end
117
- end
118
- end
119
-
120
109
  def heartbeat
121
110
  CronoTrigger.retry_on_db_errors do
122
111
  CronoTrigger::Models::Worker.connection_pool.with_connection do
@@ -209,18 +198,6 @@ module CronoTrigger
209
198
  CronoTrigger::GlobalExceptionHandler.handle_global_exception(ex)
210
199
  end
211
200
 
212
- def update_worker_count
213
- CronoTrigger.retry_on_db_errors do
214
- CronoTrigger::Models::Worker.connection_pool.with_connection do
215
- worker_count = workers_processing_same_models.count
216
- return if worker_count.zero?
217
- @polling_threads.each { |th| th.worker_count = worker_count }
218
- end
219
- end
220
- rescue => ex
221
- CronoTrigger::GlobalExceptionHandler.handle_global_exception(ex)
222
- end
223
-
224
201
  def workers_processing_same_models
225
202
  CronoTrigger.workers.where("polling_model_names = ?", @model_names.to_json)
226
203
  end
data/lib/crono_trigger.rb CHANGED
@@ -19,7 +19,7 @@ module CronoTrigger
19
19
  polling_thread: nil,
20
20
  polling_interval: 5,
21
21
  executor_thread: 25,
22
- fetch_records: nil, # default is executor_thread * 3
22
+ fetch_records: 1,
23
23
  model_names: nil,
24
24
  error_handlers: [],
25
25
  global_error_handlers: [],
@@ -1,4 +1,4 @@
1
- class CreateCronoTriggerSystemTables < ActiveRecord::Migration<%= Rails::VERSION::MAJOR >= 5 ? "[#{ActiveRecord::Migration.current_version}]" : "" %>
1
+ class CreateCronoTriggerSystemTables < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
2
  def change
3
3
  create_table :crono_trigger_workers, id: :string, primary_key: :worker_id do |t|
4
4
  t.integer :max_thread_size, null: false
@@ -1,4 +1,4 @@
1
- class <%= migration_class_name %> < ActiveRecord::Migration<%= Rails::VERSION::MAJOR >= 5 ? "[#{ActiveRecord::Migration.current_version}]" : "" %>
1
+ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
2
  def change
3
3
  create_table :<%= table_name %><%= respond_to?(:primary_key_type) ? primary_key_type : "" %> do |t|
4
4
  <% attributes.each do |attribute| -%>
@@ -26,7 +26,7 @@ class <%= migration_class_name %> < ActiveRecord::Migration<%= Rails::VERSION::M
26
26
  t.integer :retry_count, default: 0, null: false
27
27
 
28
28
  <% if options[:timestamps] %>
29
- t.timestamps<%= Rails::VERSION::MAJOR >=5 ? " null: false" : "" %>
29
+ t.timestamps null: false
30
30
  <% end -%>
31
31
  end
32
32
  <% attributes.select { |attribute| attribute.respond_to?(:token?) && attribute.token? }.each do |attribute| -%>
@@ -1,4 +1,4 @@
1
- class <%= migration_class_name %> < ActiveRecord::Migration<%= Rails::VERSION::MAJOR >= 5 ? "[#{ActiveRecord::Migration.current_version}]" : "" %>
1
+ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
2
  def change
3
3
  # columns for CronoTrigger::Schedulable
4
4
  add_column :<%= table_name %>, :cron, :string
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: crono_trigger
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.4
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - joker1007
@@ -94,35 +94,35 @@ dependencies:
94
94
  - !ruby/object:Gem::Version
95
95
  version: '0'
96
96
  - !ruby/object:Gem::Dependency
97
- name: oj
97
+ name: activerecord
98
98
  requirement: !ruby/object:Gem::Requirement
99
99
  requirements:
100
100
  - - ">="
101
101
  - !ruby/object:Gem::Version
102
- version: '0'
102
+ version: '7.1'
103
103
  type: :runtime
104
104
  prerelease: false
105
105
  version_requirements: !ruby/object:Gem::Requirement
106
106
  requirements:
107
107
  - - ">="
108
108
  - !ruby/object:Gem::Version
109
- version: '0'
109
+ version: '7.1'
110
110
  - !ruby/object:Gem::Dependency
111
- name: activerecord
111
+ name: retriable
112
112
  requirement: !ruby/object:Gem::Requirement
113
113
  requirements:
114
114
  - - ">="
115
115
  - !ruby/object:Gem::Version
116
- version: '4.2'
116
+ version: '0'
117
117
  type: :runtime
118
118
  prerelease: false
119
119
  version_requirements: !ruby/object:Gem::Requirement
120
120
  requirements:
121
121
  - - ">="
122
122
  - !ruby/object:Gem::Version
123
- version: '4.2'
123
+ version: '0'
124
124
  - !ruby/object:Gem::Dependency
125
- name: retriable
125
+ name: ostruct
126
126
  requirement: !ruby/object:Gem::Requirement
127
127
  requirements:
128
128
  - - ">="
@@ -163,6 +163,20 @@ dependencies:
163
163
  - - ">="
164
164
  - !ruby/object:Gem::Version
165
165
  version: '0'
166
+ - !ruby/object:Gem::Dependency
167
+ name: pg
168
+ requirement: !ruby/object:Gem::Requirement
169
+ requirements:
170
+ - - ">="
171
+ - !ruby/object:Gem::Version
172
+ version: '0'
173
+ type: :development
174
+ prerelease: false
175
+ version_requirements: !ruby/object:Gem::Requirement
176
+ requirements:
177
+ - - ">="
178
+ - !ruby/object:Gem::Version
179
+ version: '0'
166
180
  - !ruby/object:Gem::Dependency
167
181
  name: timecop
168
182
  requirement: !ruby/object:Gem::Requirement
@@ -195,14 +209,14 @@ dependencies:
195
209
  name: bundler
196
210
  requirement: !ruby/object:Gem::Requirement
197
211
  requirements:
198
- - - "~>"
212
+ - - ">="
199
213
  - !ruby/object:Gem::Version
200
214
  version: '2.0'
201
215
  type: :development
202
216
  prerelease: false
203
217
  version_requirements: !ruby/object:Gem::Requirement
204
218
  requirements:
205
- - - "~>"
219
+ - - ">="
206
220
  - !ruby/object:Gem::Version
207
221
  version: '2.0'
208
222
  - !ruby/object:Gem::Dependency
@@ -283,7 +297,6 @@ files:
283
297
  - crono_trigger.gemspec
284
298
  - exe/crono_trigger
285
299
  - exe/crono_trigger-web
286
- - gemfiles/activerecord-70.gemfile
287
300
  - gemfiles/activerecord-71.gemfile
288
301
  - gemfiles/activerecord-80.gemfile
289
302
  - lib/crono_trigger.rb
@@ -366,7 +379,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
366
379
  - !ruby/object:Gem::Version
367
380
  version: '0'
368
381
  requirements: []
369
- rubygems_version: 4.0.16
382
+ rubygems_version: 4.0.20
370
383
  specification_version: 4
371
384
  summary: In Service Asynchronous Job Scheduler for Rails
372
385
  test_files: []
@@ -1,7 +0,0 @@
1
- source "https://rubygems.org"
2
-
3
- gem "activerecord", "~> 7.0.0"
4
- gem "mutex_m"
5
- gem "sqlite3", "~> 1.7"
6
-
7
- gemspec :path => "../"