skiplock 1.1.8 → 1.1.9

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: 2ff56b97e28d0f5b162dfb538e33461569af6aa19ba062a85d7efb4ad0d129f2
4
- data.tar.gz: 32472f59ee0ed8422fadfa52bb4cb59a15518c0211d58f562006748e30a1d72c
3
+ metadata.gz: 21372493169f916f319d425482e65f015777befaa5e4a65a9c29895a65f87719
4
+ data.tar.gz: c4ddc48122b091efb1bfc378e23c41e53f748251c914cc42b34f2bc6c0a51a97
5
5
  SHA512:
6
- metadata.gz: 3b1a4e1314d300ae26e93f77256c6d543d46a425a44ab12d20b07e6c14e5d8d070db8cab532e6fd0fcb0b636fdde7cbee78aff65651e2ee9694b2eab03bfc276
7
- data.tar.gz: e549e1db28766922325f8ef7b3b59e205df5bc6a6a5b3ca5b40899861c6baf732d973144aa0de2a09bc4d0fa42dc2fe3ec45958696fbbcb93fd60a8bc8b866cc
6
+ metadata.gz: 8d4bb1df0948887cc5ad7a70e6e586bb021040817848cf649030918102cfacb09ca4a70e051d259a17348133ed88a287ba20cbe8e68e3cff817c56b3bae068da
7
+ data.tar.gz: 2ea888f4e274f58e0617548857bcdc3936793e6e1270d70e0b51fcdfa3c06f1793fbf37f41cbe9deb9e83eed5bc8d5861924659cab95205433d48cb7fa42c955
data/LICENSE.txt CHANGED
File without changes
data/README.md CHANGED
@@ -76,7 +76,7 @@ The library is quite small compared to other PostgreSQL job queues (eg. *delay_j
76
76
  - **log_level** (*string*): sets logging level (`debug, info, warn, error, fatal, unknown`)
77
77
  - **log_count** (*integer*): number of log files to keep (ie: log rotation)
78
78
  - **log_size** (*integer*): maximum size per log file (in bytes)
79
- - **namespace** (*string*): sets namespace for jobs (workers will only process jobs of specified namespace)
79
+ - **namespace** (*string*): routes a category of jobs to workers configured with the same namespace; an empty value selects the default, unnamespaced category. Cron jobs always use the default category.
80
80
  - **notification** (*string*): sets the library to be used for notifying errors and exceptions (`auto, airbrake, bugsnag, exception_notification, custom`); using `auto` will detect library if available. See `Notification system` for more details
81
81
  - **extensions** (*multi*): enable or disable the class method extension. See `ClassMethod extension` for more details
82
82
  - **purge_completion** (*boolean*): when set to **true** will delete jobs after they were completed successfully; if set to **false** then the completed jobs should be purged periodically to maximize performance (eg. clean up old jobs after 3 months); queued jobs can manually override using `purge` option
@@ -86,8 +86,12 @@ The library is quite small compared to other PostgreSQL job queues (eg. *delay_j
86
86
  #### **Async mode**
87
87
  When **workers** is set to **0** then the jobs will be performed in the web server process using separate threads. If using multi-worker cluster mode web server like Puma, then all the Puma workers will also be able to perform `Skiplock` jobs.
88
88
 
89
+ In async mode the application server owns process signals. Skiplock does not install `INT` or `TERM` handlers; it shuts down its thread pool from a process-local, idempotent exit callback. This allows Puma workers to finish jobs for up to `graceful_shutdown` seconds without Skiplock interfering with Puma's signal handling. Puma's own worker shutdown timeout must allow at least the same amount of time. Async workers must be initialized after Puma forks; if `preload_app!` is enabled, use a Puma worker-boot hook or run Skiplock in standalone mode because worker threads are not inherited across a fork.
90
+
89
91
  #### **Standalone mode**
90
92
  `Skiplock` standalone mode can be launched by using the `skiplock` executable; command line options can be provided to override the `Skiplock` configuration file.
93
+
94
+ In standalone multi-process mode the Skiplock parent owns its child processes. On `INT` or `TERM`, it forwards `TERM` to every child, shuts down all worker thread pools against one shared `graceful_shutdown` deadline, and terminates any child that remains after the deadline.
91
95
  ```
92
96
  $ bundle exec skiplock -h
93
97
  Usage: skiplock [options]
@@ -140,6 +144,8 @@ Outside the Rails application:
140
144
 
141
145
  ## Cron system
142
146
  `Skiplock` provides the capability to setup cron jobs for running tasks periodically. It fully supports the cron syntax to specify the frequency of the jobs. To setup a cron job, simply assign a valid cron schedule to the constant `CRON` for the Job Class.
147
+
148
+ Cron schedules are global and are intentionally stored in the default, empty Skiplock namespace. Ruby module namespaces remain part of the complete job class name, so `Reports::CleanupJob` and `Mail::CleanupJob` are distinct schedules. At least one unnamespaced worker must be running to execute cron jobs; workers configured with a nonempty Skiplock namespace do not process them. Both async and standalone master workers reconcile cron definitions at startup.
143
149
  - setup `MyJob` to run as cron job every hour at 30 minutes past
144
150
 
145
151
  ```ruby
@@ -176,7 +182,7 @@ Outside the Rails application:
176
182
  ```
177
183
  If the retry attempt limit configured in ActiveJob has been reached, then the control will be passed back to `Skiplock` to be marked as an expired job.
178
184
 
179
- If the `retry_on` block is not defined, then the built-in retry system of `Skiplock` will kick in automatically. The retrying schedule is using an exponential formula (5 + 2**attempt). The `Skiplock` configuration `max_retries` determines the the limit of attempts before the failing job is marked as expired. The maximum retry limit can be set as high as 20; this allows up to 12 days of retrying before the job is marked as expired.
185
+ If the `retry_on` block is not defined, then the built-in retry system of `Skiplock` will kick in automatically. The delay before each retry uses the exponential formula `5 + 2**attempt` seconds. The first retry is delayed 7 seconds; at the maximum supported retry count of 20, the final individual delay is approximately 12.1 days. The cumulative delay across all 20 retries is approximately 24.3 days. The `Skiplock` configuration `max_retries` determines the limit of attempts before the failing job is marked as expired.
180
186
 
181
187
  ## Notification system
182
188
  `Skiplock` can use existing exception notification library to notify errors and exceptions. It supports `airbrake`, `bugsnag`, and `exception_notification`. Custom notification can also be called whenever an exception occurs; it can be configured in an initializer like below:
@@ -213,12 +219,16 @@ To enable extension for specific classes and modules only then set the configura
213
219
  ```
214
220
 
215
221
  ## Fault tolerant
216
- `Skiplock` ensures that jobs will be executed sucessfully only once even if database connection is lost during or after the job was dispatched. Successful jobs are marked as completed or removed (with `purge_completion` global configuration or `purge` job option); failed or interrupted jobs are marked for retry.
222
+ `Skiplock` ensures that jobs will be executed successfully only once even if database connection is lost during or after the job was dispatched. Successful jobs are marked as completed or removed (with `purge_completion` global configuration or `purge` job option); failed or interrupted jobs are marked for retry.
223
+
224
+ "Successfully only once" means that a job record can be recorded as successfully completed only once. Failed or interrupted attempts may execute again and do not count as a successful completion. Jobs that produce external, non-transactional side effects such as SMTP delivery or HTTP requests should therefore be idempotent, because a process can be interrupted after the external system accepts an operation but before Skiplock records the successful completion.
217
225
 
218
226
  However, when the database connection is dropped for any reasons and the commit is lost, `Skiplock` will then save the commit data to local disk (as `tmp/skiplock/<job_id>`) and synchronize with the database when the connection resumes.
219
227
 
220
228
  This also protects long running in-progress jobs that are terminated abruptly during a graceful shutdown with timeout; these will be queued for retry.
221
229
 
230
+ Worker ownership is host-affine by design. If a job is assigned to a worker on a particular host and that host becomes unavailable, another host does not take ownership of it. The job waits until the original host returns, removes its obsolete worker records, and releases the job for retry. Hosts running Skiplock should therefore be supervised and stale worker heartbeats should be monitored.
231
+
222
232
  ## Scalability
223
233
  `Skiplock` can scale both vertically and horizontally. To scale vertically, simply increase the number of `Skiplock` workers per host. To scale horizontally, simply deploy `Skiplock` to multiple hosts sharing the same PostgreSQL database.
224
234
 
File without changes
File without changes
File without changes
File without changes
data/lib/skiplock/cron.rb CHANGED
File without changes
File without changes
data/lib/skiplock/job.rb CHANGED
@@ -69,7 +69,7 @@ module Skiplock
69
69
  if (self.executions.to_i >= self.max_retries + 1) || self.exception_executions.key?('activejob_error') || self.exception.is_a?(Skiplock::Extension::ProxyError)
70
70
  self.expired_at = Time.now
71
71
  else
72
- self.scheduled_at = Time.now + (5 * 2**self.executions.to_i)
72
+ self.scheduled_at = Time.now + (5 + 2**self.executions.to_i)
73
73
  end
74
74
  elsif self.finished_at
75
75
  if self.cron
@@ -12,14 +12,28 @@ module Skiplock
12
12
  configure
13
13
  Worker.cleanup(@hostname)
14
14
  @worker = Worker.generate(capacity: @config[:max_threads], hostname: @hostname)
15
+ @worker_pid = Process.pid
15
16
  Cron.setup if @worker.master
16
17
  @worker.start(**@config)
17
- at_exit { @worker.shutdown }
18
+ at_exit { shutdown }
18
19
  rescue Exception => ex
19
20
  @logger.error(ex.to_s)
20
21
  @logger.error(ex.backtrace.join("\n"))
21
22
  end
22
23
 
24
+ # Puma owns process signals in async mode. Its worker exit invokes this through
25
+ # at_exit, so shutdown must be safe when callbacks run more than once and when
26
+ # an application was preloaded before Puma forked its workers.
27
+ def shutdown
28
+ return unless @worker && @worker_pid == Process.pid
29
+ @shutdown_mutex ||= Mutex.new
30
+ @shutdown_mutex.synchronize do
31
+ return if @shutdown_complete
32
+ @worker.shutdown
33
+ @shutdown_complete = true
34
+ end
35
+ end
36
+
23
37
  def standalone(**options)
24
38
  @config.merge!(options)
25
39
  @config[:standalone] = true
@@ -34,9 +48,12 @@ module Skiplock
34
48
  Signal.trap('HUP') { setup_logger }
35
49
  Worker.cleanup(@hostname)
36
50
  @worker = Worker.generate(capacity: @config[:max_threads], hostname: @hostname)
51
+ @worker_pid = Process.pid
52
+ Cron.setup if @worker.master
37
53
  ActiveRecord::Base.connection.disconnect! if @config[:workers] > 1
54
+ @child_pids = []
38
55
  (@config[:workers] - 1).times do |n|
39
- fork do
56
+ @child_pids << fork do
40
57
  sleep(0.25*n + 1)
41
58
  ActiveRecord::Base.establish_connection
42
59
  worker = Worker.generate(capacity: @config[:max_threads], hostname: @hostname, master: false)
@@ -55,8 +72,13 @@ module Skiplock
55
72
  break if @shutdown
56
73
  end
57
74
  @logger.info "[Skiplock] Terminating signal... Waiting for jobs to finish (up to #{@config[:graceful_shutdown]} seconds)..." if @config[:graceful_shutdown]
58
- Process.waitall
59
- @worker.shutdown
75
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @config[:graceful_shutdown] if @config[:graceful_shutdown]
76
+ signal_children(@child_pids, 'TERM')
77
+ begin
78
+ shutdown
79
+ ensure
80
+ reap_children(@child_pids, deadline)
81
+ end
60
82
  rescue Exception => ex
61
83
  @logger.error(ex.to_s)
62
84
  @logger.error(ex.backtrace.join("\n"))
@@ -64,6 +86,32 @@ module Skiplock
64
86
 
65
87
  private
66
88
 
89
+ def signal_children(child_pids, signal)
90
+ child_pids.each do |pid|
91
+ Process.kill(signal, pid)
92
+ rescue Errno::ESRCH
93
+ end
94
+ end
95
+
96
+ def reap_children(child_pids, deadline)
97
+ remaining = child_pids.dup
98
+ while remaining.any?
99
+ remaining.delete_if do |pid|
100
+ Process.waitpid(pid, Process::WNOHANG)
101
+ rescue Errno::ECHILD
102
+ true
103
+ end
104
+ break if remaining.empty?
105
+ break if deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
106
+ sleep 0.05
107
+ end
108
+ signal_children(remaining, 'KILL')
109
+ remaining.each do |pid|
110
+ Process.waitpid(pid)
111
+ rescue Errno::ECHILD
112
+ end
113
+ end
114
+
67
115
  def banner
68
116
  title = "Skiplock #{Skiplock::VERSION} (Rails #{Rails::VERSION::STRING} | Ruby #{RUBY_VERSION}-p#{RUBY_PATCHLEVEL})"
69
117
  @logger.info "-"*(title.length)
File without changes
@@ -1,3 +1,3 @@
1
1
  module Skiplock
2
- VERSION = Version = '1.1.8'
2
+ VERSION = Version = '1.1.9'
3
3
  end
@@ -19,10 +19,25 @@ module Skiplock
19
19
  end
20
20
 
21
21
  def shutdown
22
- @running = false
23
- @executor.shutdown
24
- @executor.kill unless @executor.wait_for_termination(@config[:graceful_shutdown])
25
- Skiplock.logger.info "[Skiplock] Shutdown of #{self.master ? 'master' : 'cluster'} worker#{(' ' + @num.to_s) if @num > 0 && @config[:workers] > 2} (PID: #{self.pid}) was completed."
22
+ @shutdown_mutex ||= Mutex.new
23
+ @shutdown_mutex.synchronize do
24
+ return if @shutdown_complete
25
+ @running = false
26
+ @dispatch_mutex ||= Mutex.new
27
+ if @executor
28
+ @dispatch_mutex.synchronize { @executor.shutdown }
29
+ @executor.kill unless @executor.wait_for_termination((@config || {})[:graceful_shutdown])
30
+ end
31
+ @shutdown_complete = true
32
+ begin
33
+ self.delete
34
+ rescue StandardError => ex
35
+ Skiplock.logger.error("[Skiplock] Unable to delete worker #{self.id} during shutdown: #{ex}") if Skiplock.logger
36
+ end
37
+ worker_num = @num.to_i
38
+ worker_count = (@config || {})[:workers].to_i
39
+ Skiplock.logger.info "[Skiplock] Shutdown of #{self.master ? 'master' : 'cluster'} worker#{(' ' + worker_num.to_s) if worker_num > 0 && worker_count > 2} (PID: #{self.pid}) was completed." if Skiplock.logger
40
+ end
26
41
  end
27
42
 
28
43
  def start(worker_num: 0, **config)
@@ -32,6 +47,7 @@ module Skiplock
32
47
  @namespace_query = Skiplock.namespace.nil? ? "namespace IS NULL" : "namespace = '#{Skiplock.namespace}'"
33
48
  @queues_order_query = @config[:queues].map { |q,v| "WHEN queue_name = '#{q}' THEN #{v}" }.join(' ') if @config[:queues].is_a?(Hash) && @config[:queues].count > 0
34
49
  @running = true
50
+ @dispatch_mutex = Mutex.new
35
51
  @map = ::PG::TypeMapByOid.new
36
52
  @map.add_coder(::PG::TextDecoder::Boolean.new(oid: 16, name: 'bool'))
37
53
  @map.add_coder(::PG::TextDecoder::Integer.new(oid: 20, name: 'int8'))
@@ -73,18 +89,22 @@ module Skiplock
73
89
  next_schedule_at = Time.now.to_f
74
90
  end
75
91
  if Time.now.to_f >= next_schedule_at && @executor.remaining_capacity > 1 # reserves 1 slot in queue for Job.flush in case of pg_connection error
76
- result = nil
77
- @connection.transaction do |conn|
78
- conn.exec("SELECT id, running, scheduled_at FROM skiplock.jobs WHERE running = FALSE AND expired_at IS NULL AND finished_at IS NULL AND #{@namespace_query} ORDER BY scheduled_at ASC NULLS FIRST,#{@queues_order_query ? ' CASE ' + @queues_order_query + ' ELSE NULL END ASC NULLS LAST,' : ''} priority ASC NULLS LAST, created_at ASC FOR UPDATE SKIP LOCKED LIMIT 1") do |r|
79
- result = r.first
80
- conn.exec("UPDATE skiplock.jobs SET running = TRUE, worker_id = '#{self.id}', updated_at = NOW() WHERE id = '#{result['id']}' RETURNING *") { |r| result = r.first } if result && result['scheduled_at'].to_f <= Time.now.to_f
92
+ @dispatch_mutex.synchronize do
93
+ if @running
94
+ result = nil
95
+ @connection.transaction do |conn|
96
+ conn.exec("SELECT id, running, scheduled_at FROM skiplock.jobs WHERE running = FALSE AND expired_at IS NULL AND finished_at IS NULL AND #{@namespace_query} ORDER BY scheduled_at ASC NULLS FIRST,#{@queues_order_query ? ' CASE ' + @queues_order_query + ' ELSE NULL END ASC NULLS LAST,' : ''} priority ASC NULLS LAST, created_at ASC FOR UPDATE SKIP LOCKED LIMIT 1") do |r|
97
+ result = r.first
98
+ conn.exec("UPDATE skiplock.jobs SET running = TRUE, worker_id = '#{self.id}', updated_at = NOW() WHERE id = '#{result['id']}' RETURNING *") { |r| result = r.first } if result && result['scheduled_at'].to_f <= Time.now.to_f
99
+ end
100
+ end
101
+ if result && result['running']
102
+ @executor.post { Rails.application.executor.wrap { Job.instantiate(result).execute(purge_completion: @config[:purge_completion], max_retries: @config[:max_retries]) } }
103
+ else
104
+ next_schedule_at = (result ? result['scheduled_at'].to_f : Float::INFINITY)
105
+ end
81
106
  end
82
107
  end
83
- if result && result['running']
84
- @executor.post { Rails.application.executor.wrap { Job.instantiate(result).execute(purge_completion: @config[:purge_completion], max_retries: @config[:max_retries]) } }
85
- else
86
- next_schedule_at = (result ? result['scheduled_at'].to_f : Float::INFINITY)
87
- end
88
108
  end
89
109
  notifications = { 'skiplock::jobs' => [], 'skiplock::workers' => [] }
90
110
  @connection.wait_for_notify(0.2) do |channel, pid, payload|
data/lib/skiplock.rb CHANGED
File without changes
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: skiplock
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.8
4
+ version: 1.1.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tin Vo
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2024-12-08 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: activejob
@@ -66,6 +65,48 @@ dependencies:
66
65
  - - "~>"
67
66
  - !ruby/object:Gem::Version
68
67
  version: '0.1'
68
+ - !ruby/object:Gem::Dependency
69
+ name: minitest
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '5.0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '5.0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: pg
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '1.0'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '1.0'
96
+ - !ruby/object:Gem::Dependency
97
+ name: rake
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '12.0'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '12.0'
69
110
  description: High performance ActiveJob Queue Adapter for PostgreSQL that provides
70
111
  maximum reliability and ACID compliance
71
112
  email:
@@ -94,7 +135,6 @@ homepage: https://github.com/vtt/skiplock
94
135
  licenses:
95
136
  - MIT
96
137
  metadata: {}
97
- post_install_message:
98
138
  rdoc_options: []
99
139
  require_paths:
100
140
  - lib
@@ -109,8 +149,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
109
149
  - !ruby/object:Gem::Version
110
150
  version: '0'
111
151
  requirements: []
112
- rubygems_version: 3.5.16
113
- signing_key:
152
+ rubygems_version: 4.0.19
114
153
  specification_version: 4
115
154
  summary: ActiveJob Queue Adapter for PostgreSQL
116
155
  test_files: []