crono_trigger 0.8.3 → 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: 05d21540f13b0418eb7a474a2efe964595c09c4a8ae433843cc33f8ea2861dc8
4
- data.tar.gz: 989e4b3eb6e55077de647bd4d8626d4a556537f8d4c92f974d8b7475f36d9e43
3
+ metadata.gz: 855216016010a5117316cfcdf1554d8873e91605d09bbdb226d69ccfaafec918
4
+ data.tar.gz: ffd42586870d68941253bad9adcf3cee6da83853bba0d2584ba79941104e0775
5
5
  SHA512:
6
- metadata.gz: 3176b9ccaa96a5676f6c28fb95968c4838a794a06ebe26b2a6ba4777b03487b0ce97d8b43f8a010bb9fb57336f5a2ada76cea9e21fbd642aad3d875fc1bed836
7
- data.tar.gz: 33f6cb3606ced81f3a2e09b46d34ba21c7832de5adb57e1d8f6b0f39961363d6477a6d9e35da3cf4a85ac90048f9f65e221a887b5dcc5a3d2461e19881a9eb02
6
+ metadata.gz: bfecebefd3fc5c422ddc19db8e61014ee47d87e05ff2c0944504ea9fb0196b5352d1d91a72462778a26f5c8a0ada80dd7a6391a79fc3bfaf5c598488ebc4df11
7
+ data.tar.gz: 01a7b05d5150b3749520b4d29a50377aada67532c14a34eee64c9067b7b571589750169c0045b50a9f9202a52aff1bfb27ab6892366f97c801334e9544cd0698
@@ -4,7 +4,6 @@ on:
4
4
  schedule:
5
5
  - cron: "0 9 * * 1"
6
6
  push:
7
- branches: [ master ]
8
7
  pull_request:
9
8
 
10
9
  jobs:
@@ -13,18 +12,16 @@ jobs:
13
12
  runs-on: ubuntu-latest
14
13
  strategy:
15
14
  matrix:
16
- ruby-version: ['3.1', '3.2', '3.3']
17
- gemfile: ["Gemfile", "gemfiles/activerecord-61.gemfile", "gemfiles/activerecord-70.gemfile", "gemfiles/activerecord-71.gemfile"]
18
- exclude:
19
- - ruby-version: '3.2'
20
- gemfile: "gemfiles/activerecord-61.gemfile"
21
- - ruby-version: '3.3'
22
- gemfile: "gemfiles/activerecord-61.gemfile"
15
+ ruby-version: ['3.4', '4.0']
16
+ gemfile:
17
+ - Gemfile
18
+ - gemfiles/activerecord-71.gemfile
19
+ - gemfiles/activerecord-80.gemfile
23
20
  env:
24
21
  BUNDLE_GEMFILE: ${{ matrix.gemfile }}
25
22
 
26
23
  steps:
27
- - uses: actions/checkout@v4
24
+ - uses: actions/checkout@v7
28
25
  - name: Set up Ruby
29
26
  # To automatically get bug fixes and new Ruby versions for ruby/setup-ruby,
30
27
  # change this to (see https://github.com/ruby/setup-ruby#versioning):
@@ -34,3 +31,89 @@ jobs:
34
31
  bundler-cache: true # runs 'bundle install' and caches installed gems automatically
35
32
  - name: Run tests
36
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/Gemfile CHANGED
@@ -2,3 +2,5 @@ source 'https://rubygems.org'
2
2
 
3
3
  # Specify your gem's dependencies in crono_trigger.gemspec
4
4
  gemspec
5
+
6
+ gem "mutex_m"
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
- spec.add_development_dependency "sqlite3", "~> 1.3"
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"
@@ -1,5 +1,6 @@
1
1
  source "https://rubygems.org"
2
2
 
3
3
  gem "activerecord", "~> 7.1.0"
4
+ gem "sqlite3", "~> 1.7"
4
5
 
5
6
  gemspec :path => "../"
@@ -1,5 +1,5 @@
1
1
  source "https://rubygems.org"
2
2
 
3
- gem "activerecord", "~> 6.1.0"
3
+ gem "activerecord", "~> 8.0.0"
4
4
 
5
5
  gemspec :path => "../"
@@ -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
- # Fetch more than `limit` records because other workers might have acquired the lock
68
- # and this method might not be able to return enough records for the executor to
69
- # make the best use of the CPU.
70
- ids = executables(limit: limit * worker_count).pluck(:id)
71
- maybe_has_next = !ids.empty?
72
- records = []
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.3"
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,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: crono_trigger
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.3
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - joker1007
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2024-10-25 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: chrono
@@ -95,35 +94,35 @@ dependencies:
95
94
  - !ruby/object:Gem::Version
96
95
  version: '0'
97
96
  - !ruby/object:Gem::Dependency
98
- name: oj
97
+ name: activerecord
99
98
  requirement: !ruby/object:Gem::Requirement
100
99
  requirements:
101
100
  - - ">="
102
101
  - !ruby/object:Gem::Version
103
- version: '0'
102
+ version: '7.1'
104
103
  type: :runtime
105
104
  prerelease: false
106
105
  version_requirements: !ruby/object:Gem::Requirement
107
106
  requirements:
108
107
  - - ">="
109
108
  - !ruby/object:Gem::Version
110
- version: '0'
109
+ version: '7.1'
111
110
  - !ruby/object:Gem::Dependency
112
- name: activerecord
111
+ name: retriable
113
112
  requirement: !ruby/object:Gem::Requirement
114
113
  requirements:
115
114
  - - ">="
116
115
  - !ruby/object:Gem::Version
117
- version: '4.2'
116
+ version: '0'
118
117
  type: :runtime
119
118
  prerelease: false
120
119
  version_requirements: !ruby/object:Gem::Requirement
121
120
  requirements:
122
121
  - - ">="
123
122
  - !ruby/object:Gem::Version
124
- version: '4.2'
123
+ version: '0'
125
124
  - !ruby/object:Gem::Dependency
126
- name: retriable
125
+ name: ostruct
127
126
  requirement: !ruby/object:Gem::Requirement
128
127
  requirements:
129
128
  - - ">="
@@ -140,14 +139,14 @@ dependencies:
140
139
  name: sqlite3
141
140
  requirement: !ruby/object:Gem::Requirement
142
141
  requirements:
143
- - - "~>"
142
+ - - ">="
144
143
  - !ruby/object:Gem::Version
145
144
  version: '1.3'
146
145
  type: :development
147
146
  prerelease: false
148
147
  version_requirements: !ruby/object:Gem::Requirement
149
148
  requirements:
150
- - - "~>"
149
+ - - ">="
151
150
  - !ruby/object:Gem::Version
152
151
  version: '1.3'
153
152
  - !ruby/object:Gem::Dependency
@@ -164,6 +163,20 @@ dependencies:
164
163
  - - ">="
165
164
  - !ruby/object:Gem::Version
166
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'
167
180
  - !ruby/object:Gem::Dependency
168
181
  name: timecop
169
182
  requirement: !ruby/object:Gem::Requirement
@@ -196,14 +209,14 @@ dependencies:
196
209
  name: bundler
197
210
  requirement: !ruby/object:Gem::Requirement
198
211
  requirements:
199
- - - "~>"
212
+ - - ">="
200
213
  - !ruby/object:Gem::Version
201
214
  version: '2.0'
202
215
  type: :development
203
216
  prerelease: false
204
217
  version_requirements: !ruby/object:Gem::Requirement
205
218
  requirements:
206
- - - "~>"
219
+ - - ">="
207
220
  - !ruby/object:Gem::Version
208
221
  version: '2.0'
209
222
  - !ruby/object:Gem::Dependency
@@ -284,9 +297,8 @@ files:
284
297
  - crono_trigger.gemspec
285
298
  - exe/crono_trigger
286
299
  - exe/crono_trigger-web
287
- - gemfiles/activerecord-61.gemfile
288
- - gemfiles/activerecord-70.gemfile
289
300
  - gemfiles/activerecord-71.gemfile
301
+ - gemfiles/activerecord-80.gemfile
290
302
  - lib/crono_trigger.rb
291
303
  - lib/crono_trigger/cli.rb
292
304
  - lib/crono_trigger/events.rb
@@ -353,7 +365,6 @@ homepage: https://github.com/joker1007/crono_trigger
353
365
  licenses:
354
366
  - MIT
355
367
  metadata: {}
356
- post_install_message:
357
368
  rdoc_options: []
358
369
  require_paths:
359
370
  - lib
@@ -368,8 +379,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
368
379
  - !ruby/object:Gem::Version
369
380
  version: '0'
370
381
  requirements: []
371
- rubygems_version: 3.5.11
372
- signing_key:
382
+ rubygems_version: 4.0.20
373
383
  specification_version: 4
374
384
  summary: In Service Asynchronous Job Scheduler for Rails
375
385
  test_files: []
@@ -1,5 +0,0 @@
1
- source "https://rubygems.org"
2
-
3
- gem "activerecord", "~> 7.0.0"
4
-
5
- gemspec :path => "../"