skiplock 1.0.22 → 1.1.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: 3b99466d7f1848f9dcd750a4dceb6a39d7124707e941c74b6c75f0b3a57944ff
4
- data.tar.gz: 3f3ed1c9327d41e675d5c170590b52fe32f1facc5b57667dba513e5324e6ee20
3
+ metadata.gz: 71cf3158d71dfe424c218da13a55d785365bee9027f827bb1498ba5e0110fda9
4
+ data.tar.gz: 1512863ab863b392f84d67a137959227a823ac33dd9ca8e1ab8e87c63fe04adf
5
5
  SHA512:
6
- metadata.gz: '0129296507b0479ba28a6e4e0bce810340f0ff2559004da1385cf02b6d6023f7be5cb1543f3fb24cebdeb579d6be6ed1e5d7d72d43245de5374926607b23d7df'
7
- data.tar.gz: 163faf212d06b6d7f8045eb347ccab77de5b16bfe77fe8780a5d73176ce3c5804cc25cbca8f962d92bc5dfccc1ada223d03fc0b443d0df48125a12bf4ec7ddeb
6
+ metadata.gz: 79ef005a284b8aabfff1e1435bba2019a3b3698f714803f6521562f143b3d5f4f6536991a040bbd44574a52fa3be2df7ac565c37cccf4da40da95fe4b150b1c6
7
+ data.tar.gz: 1f7fd443df703c98133b66838c6d6d49a9cfa3b54d33cafaff15d59fd3a538591400fdb917ccfb5ec4b8a2831d88b6a1f85ef622ecb7566842f2f761e97bba9f
data/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  It only uses the `LISTEN/NOTIFY/SKIP LOCKED` features provided natively on PostgreSQL 9.5+ to efficiently and reliably dispatch jobs to worker processes and threads ensuring that each job can be completed successfully **only once**. No other polling or timer is needed.
6
6
 
7
- The library is quite small compared to other PostgreSQL job queues (eg. *delay_job*, *queue_classic*, *que*, *good_job*) with less than 500 lines of codes; and it still provides similar set of features and more...
7
+ The library is quite small compared to other PostgreSQL job queues (eg. *delay_job*, *queue_classic*, *que*, *good_job*) with less than 600 lines of codes; and it still provides similar set of features and more...
8
8
 
9
9
  #### Compatibility:
10
10
 
@@ -50,11 +50,13 @@ The library is quite small compared to other PostgreSQL job queues (eg. *delay_j
50
50
  ```yaml
51
51
  # config/skiplock.yml (default settings)
52
52
  ---
53
+ graceful_shutdown: 15
53
54
  min_threads: 1
54
55
  max_threads: 10
55
56
  max_retries: 20
56
57
  logfile: skiplock.log
57
58
  loglevel: info
59
+ namespace:
58
60
  notification: custom
59
61
  extensions: false
60
62
  purge_completion: true
@@ -64,11 +66,13 @@ The library is quite small compared to other PostgreSQL job queues (eg. *delay_j
64
66
  workers: 0
65
67
  ```
66
68
  Available configuration options are:
69
+ - **graceful_shutdown** (*integer*): sets the number of seconds to wait for jobs to finish before being killed during shutdown
67
70
  - **min_threads** (*integer*): sets minimum number of threads staying idle
68
71
  - **max_threads** (*integer*): sets the maximum number of threads allowed to run jobs
69
72
  - **max_retries** (*integer*): sets the maximum attempt a job will be retrying before it is marked expired. See `Retry system` for more details
70
73
  - **logfile** (*string*): filename for skiplock logs; empty logfile will disable logging
71
74
  - **loglevel** (*string*): sets logging level (`debug, info, warn, error, fatal, unknown`)
75
+ - **namespace** (*string*): sets namespace for jobs (workers will only process jobs of specified namespace)
72
76
  - **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
73
77
  - **extensions** (*multi*): enable or disable the class method extension. See `ClassMethod extension` for more details
74
78
  - **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
@@ -85,6 +89,8 @@ The library is quite small compared to other PostgreSQL job queues (eg. *delay_j
85
89
  Usage: skiplock [options]
86
90
  -e, --environment STRING Rails environment
87
91
  -l, --logfile STRING Log filename
92
+ -L, --loglevel STRING Log level (debug, info, warn, error, fatal, unknown)
93
+ -n, --namespace STRING Job namespace
88
94
  -s, --graceful-shutdown NUM Number of seconds to wait for graceful shutdown
89
95
  -r, --max-retries NUM Number of maxixum retries
90
96
  -t, --max-threads NUM Number of maximum threads
@@ -237,6 +243,10 @@ Code examples of gathering counters information:
237
243
  ```ruby
238
244
  Skiplock::Counter.sum(:expiries)
239
245
  ```
246
+ - get all information in one query
247
+ ```ruby
248
+ Skiplock::Counter.pluck("sum(completions), sum(dispatches), sum(expiries), sum(failures), sum(retries)").first
249
+ ```
240
250
 
241
251
  ## Contributing
242
252
 
data/bin/skiplock CHANGED
@@ -8,6 +8,7 @@ begin
8
8
  opts.on('-e', '--environment STRING', String, 'Rails environment')
9
9
  opts.on('-l', '--logfile STRING', String, 'Log filename')
10
10
  opts.on('-L', '--loglevel STRING', String, 'Log level (debug, info, warn, error, fatal, unknown)')
11
+ opts.on('-n', '--namespace STRING', String, 'Job namespace')
11
12
  opts.on('-s', '--graceful-shutdown NUM', Integer, 'Number of seconds to wait for graceful shutdown')
12
13
  opts.on('-r', '--max-retries NUM', Integer, 'Number of maxixum retries')
13
14
  opts.on('-t', '--max-threads NUM', Integer, 'Number of maximum threads')
@@ -7,11 +7,14 @@ class CreateSkiplockSchema < ActiveRecord::Migration<%= "[#{ActiveRecord::VERSIO
7
7
  t.integer :expiries, null: false, default: 0
8
8
  t.integer :failures, null: false, default: 0
9
9
  t.integer :retries, null: false, default: 0
10
- t.date :day, null: false, index: { unique: true }
10
+ t.string :namespace, null: false, default: '', index: true
11
+ t.date :day, null: false
12
+ t.index [ :namespace, :day ], unique: true
11
13
  end
12
14
  create_table 'skiplock.jobs', id: :uuid do |t|
13
15
  t.uuid :worker_id, index: true
14
16
  t.string :job_class, null: false
17
+ t.string :namespace, null: false, default: '', index: true
15
18
  t.string :queue_name, index: true
16
19
  t.string :locale
17
20
  t.string :timezone
@@ -31,6 +34,7 @@ class CreateSkiplockSchema < ActiveRecord::Migration<%= "[#{ActiveRecord::VERSIO
31
34
  t.integer :sid, null: false
32
35
  t.integer :capacity, null: false
33
36
  t.string :hostname, null: false, index: true
37
+ t.string :namespace, null: false, default: '', index: true
34
38
  t.boolean :master, null: false, default: false, index: true
35
39
  t.jsonb :data
36
40
  t.timestamps null: false, index: true, default: -> { 'now()' }
@@ -41,27 +45,29 @@ class CreateSkiplockSchema < ActiveRecord::Migration<%= "[#{ActiveRecord::VERSIO
41
45
  record RECORD;
42
46
  BEGIN
43
47
  record = NEW;
44
- IF (TG_OP = 'DELETE') THEN
48
+ IF TG_OP = 'DELETE' THEN
45
49
  record = OLD;
46
- IF (record.running = TRUE) THEN
47
- INSERT INTO skiplock.counters (day,completions) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET completions = skiplock.counters.completions + 1;
50
+ IF record.running IS TRUE THEN
51
+ INSERT INTO skiplock.counters (namespace,day,completions) VALUES (record.namespace,NOW(),1) ON CONFLICT (namespace,day) DO UPDATE SET completions = skiplock.counters.completions + 1;
48
52
  END IF;
49
- ELSIF (TG_OP = 'UPDATE') THEN
50
- IF (OLD.running = FALSE AND record.running = TRUE) THEN
51
- IF (record.executions > 0) THEN
52
- INSERT INTO skiplock.counters (day,retries) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET retries = skiplock.counters.retries + 1;
53
+ ELSIF TG_OP = 'UPDATE' THEN
54
+ IF OLD.running IS FALSE AND record.running IS TRUE THEN
55
+ IF record.executions > 0 THEN
56
+ INSERT INTO skiplock.counters (namespace,day,retries) VALUES (record.namespace,NOW(),1) ON CONFLICT (namespace,day) DO UPDATE SET retries = skiplock.counters.retries + 1;
53
57
  ELSE
54
- INSERT INTO skiplock.counters (day,dispatches) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET dispatches = skiplock.counters.dispatches + 1;
58
+ INSERT INTO skiplock.counters (namespace,day,dispatches) VALUES (record.namespace,NOW(),1) ON CONFLICT (namespace,day) DO UPDATE SET dispatches = skiplock.counters.dispatches + 1;
59
+ END IF;
60
+ ELSIF OLD.finished_at IS NULL AND record.finished_at IS NOT NULL THEN
61
+ INSERT INTO skiplock.counters (namespace,day,completions) VALUES (record.namespace,NOW(),1) ON CONFLICT (namespace,day) DO UPDATE SET completions = skiplock.counters.completions + 1;
62
+ ELSIF OLD.running IS TRUE AND record.running IS FALSE THEN
63
+ IF record.expired_at IS NOT NULL THEN
64
+ INSERT INTO skiplock.counters (namespace,day,expiries) VALUES (record.namespace,NOW(),1) ON CONFLICT (namespace,day) DO UPDATE SET expiries = skiplock.counters.expiries + 1;
65
+ ELSE
66
+ INSERT INTO skiplock.counters (namespace,day,failures) VALUES (record.namespace,NOW(),1) ON CONFLICT (namespace,day) DO UPDATE SET failures = skiplock.counters.failures + 1;
55
67
  END IF;
56
- ELSIF (OLD.finished_at IS NULL AND record.finished_at IS NOT NULL) THEN
57
- INSERT INTO skiplock.counters (day,completions) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET completions = skiplock.counters.completions + 1;
58
- ELSIF (OLD.running = TRUE AND record.running = FALSE AND record.expired_at IS NOT NULL) THEN
59
- INSERT INTO skiplock.counters (day,expiries) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET expiries = skiplock.counters.expiries + 1;
60
- ELSIF (OLD.running = TRUE AND record.running = FALSE AND record.expired_at IS NULL AND record.finished_at IS NULL) THEN
61
- INSERT INTO skiplock.counters (day,failures) VALUES (NOW(),1) ON CONFLICT (day) DO UPDATE SET failures = skiplock.counters.failures + 1;
62
68
  END IF;
63
69
  END IF;
64
- PERFORM pg_notify('skiplock::jobs', CONCAT(TG_OP,',',record.id::TEXT,',',record.worker_id::TEXT,',',record.job_class,',',record.queue_name,',',record.running::TEXT,',',CAST(EXTRACT(EPOCH FROM record.expired_at) AS FLOAT)::TEXT,',',CAST(EXTRACT(EPOCH FROM record.finished_at) AS FLOAT)::TEXT,',',CAST(EXTRACT(EPOCH FROM CASE WHEN record.scheduled_at IS NULL THEN record.updated_at ELSE record.scheduled_at END) AS FLOAT)::TEXT));
70
+ PERFORM pg_notify('skiplock::jobs', CONCAT(TG_OP,',',record.id::TEXT,',',record.worker_id::TEXT,',',record.namespace,',',record.job_class,',',record.queue_name,',',record.running::TEXT,',',CAST(EXTRACT(EPOCH FROM record.expired_at) AS FLOAT)::TEXT,',',CAST(EXTRACT(EPOCH FROM record.finished_at) AS FLOAT)::TEXT,',',CAST(EXTRACT(EPOCH FROM CASE WHEN record.scheduled_at IS NULL THEN record.updated_at ELSE record.scheduled_at END) AS FLOAT)::TEXT));
65
71
  RETURN NULL;
66
72
  END;
67
73
  $$ LANGUAGE plpgsql
@@ -71,12 +77,12 @@ class CreateSkiplockSchema < ActiveRecord::Migration<%= "[#{ActiveRecord::VERSIO
71
77
  DECLARE
72
78
  record RECORD;
73
79
  BEGIN
74
- IF (TG_OP = 'DELETE') THEN
80
+ IF TG_OP = 'DELETE' THEN
75
81
  record = OLD;
76
82
  ELSE
77
83
  record = NEW;
78
84
  END IF;
79
- PERFORM pg_notify('skiplock::workers', CONCAT(TG_OP,',',record.id::TEXT,',',record.hostname,',',record.master::TEXT,',',record.capacity,',',record.pid,',',record.sid,',',CAST(EXTRACT(EPOCH FROM record.created_at) AS FLOAT)::TEXT,',',CAST(EXTRACT(EPOCH FROM record.updated_at) AS FLOAT)::TEXT));
85
+ PERFORM pg_notify('skiplock::workers', CONCAT(TG_OP,',',record.id::TEXT,',',record.namespace,',',record.hostname,',',record.master::TEXT,',',record.capacity,',',record.pid,',',record.sid,',',CAST(EXTRACT(EPOCH FROM record.created_at) AS FLOAT)::TEXT,',',CAST(EXTRACT(EPOCH FROM record.updated_at) AS FLOAT)::TEXT));
80
86
  RETURN NULL;
81
87
  END;
82
88
  $$ LANGUAGE plpgsql;
@@ -87,7 +93,7 @@ class CreateSkiplockSchema < ActiveRecord::Migration<%= "[#{ActiveRecord::VERSIO
87
93
  execute "CREATE INDEX jobs_retry_index ON skiplock.jobs(scheduled_at) WHERE running = FALSE AND executions IS NOT NULL AND expired_at IS NULL AND finished_at IS NULL"
88
94
  execute "CREATE INDEX jobs_cron_index ON skiplock.jobs(scheduled_at ASC NULLS FIRST, priority ASC NULLS LAST, created_at ASC) WHERE cron IS NOT NULL AND finished_at IS NULL"
89
95
  execute "CREATE UNIQUE INDEX jobs_unique_cron_index ON skiplock.jobs (job_class) WHERE cron IS NOT NULL"
90
- execute "CREATE UNIQUE INDEX workers_unique_master_index ON skiplock.workers(hostname) WHERE master = TRUE"
96
+ execute "CREATE UNIQUE INDEX workers_unique_master_index ON skiplock.workers(hostname,namespace) WHERE master = TRUE"
91
97
  end
92
98
 
93
99
  def down
data/lib/skiplock/cron.rb CHANGED
@@ -19,6 +19,7 @@ module Skiplock
19
19
  query = Job.where('cron IS NOT NULL')
20
20
  query = query.where('job_class NOT IN (?)', cronjobs) if cronjobs.count > 0
21
21
  query.delete_all
22
+ rescue
22
23
  end
23
24
 
24
25
  def self.next_schedule_at(cron)
data/lib/skiplock/job.rb CHANGED
@@ -3,14 +3,15 @@ module Skiplock
3
3
  self.implicit_order_column = 'updated_at'
4
4
  attribute :activejob_error
5
5
  attribute :exception
6
- attribute :max_retries, :integer
7
- attribute :purge, :boolean
6
+ attribute :max_retries
7
+ attribute :purge
8
8
  belongs_to :worker, inverse_of: :jobs, required: false
9
9
 
10
10
  def self.dispatch(purge_completion: true, max_retries: 20)
11
+ namespace_query = Skiplock.namespace.nil? ? "namespace IS NULL" : "namespace = '#{Skiplock.namespace}'"
11
12
  job = nil
12
13
  self.connection.transaction do
13
- job = self.find_by_sql("SELECT id, scheduled_at FROM skiplock.jobs WHERE running = FALSE AND expired_at IS NULL AND finished_at IS NULL ORDER BY scheduled_at ASC NULLS FIRST, priority ASC NULLS LAST, created_at ASC FOR UPDATE SKIP LOCKED LIMIT 1").first
14
+ job = self.find_by_sql("SELECT id, 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, priority ASC NULLS LAST, created_at ASC FOR UPDATE SKIP LOCKED LIMIT 1").first
14
15
  return if job.nil? || job.scheduled_at.to_f > Time.now.to_f
15
16
  job = self.find_by_sql("UPDATE skiplock.jobs SET running = TRUE, updated_at = NOW() WHERE id = '#{job.id}' RETURNING *").first
16
17
  end
@@ -33,7 +34,7 @@ module Skiplock
33
34
  Thread.current[:skiplock_job]
34
35
  else
35
36
  serialize = activejob.serialize
36
- self.create!(serialize.slice(*self.column_names).merge('id' => serialize['job_id'], 'data' => { 'arguments' => serialize['arguments'], 'options' => options }, 'scheduled_at' => timestamp))
37
+ self.create!(serialize.slice(*self.column_names).merge('id' => serialize['job_id'], 'data' => { 'arguments' => serialize['arguments'], 'options' => options }, 'namespace' => Skiplock.namespace, 'scheduled_at' => timestamp))
37
38
  end
38
39
  end
39
40
 
@@ -10,7 +10,8 @@ module Skiplock
10
10
  elsif @config[:extensions].is_a?(Array)
11
11
  @config[:extensions].each { |n| n.constantize.__send__(:extend, Skiplock::Extension) if n.safe_constantize }
12
12
  end
13
- async if (caller.any?{ |l| l =~ %r{/rack/} } && @config[:workers] == 0)
13
+ ActiveJob::Base.__send__(:include, Skiplock::Patch)
14
+ (caller.any?{ |l| l =~ %r{/rack/} } && @config[:workers] == 0) ? async : Cron.setup
14
15
  end
15
16
 
16
17
  def async
@@ -18,6 +19,7 @@ module Skiplock
18
19
  configure
19
20
  Worker.cleanup(@hostname)
20
21
  @worker = Worker.generate(capacity: @config[:max_threads], hostname: @hostname)
22
+ Cron.setup if @worker.master
21
23
  @worker.start(**@config)
22
24
  at_exit { @worker.shutdown }
23
25
  rescue Exception => ex
@@ -42,7 +44,8 @@ module Skiplock
42
44
  ActiveRecord::Base.connection.disconnect! if @config[:workers] > 1
43
45
  (@config[:workers] - 1).times do |n|
44
46
  fork do
45
- sleep 1
47
+ sleep(0.25*n + 1)
48
+ ActiveRecord::Base.establish_connection
46
49
  worker = Worker.generate(capacity: @config[:max_threads], hostname: @hostname, master: false)
47
50
  worker.start(worker_num: n + 1, **@config)
48
51
  loop do
@@ -73,24 +76,25 @@ module Skiplock
73
76
  @logger.info "-"*(title.length)
74
77
  @logger.info title
75
78
  @logger.info "-"*(title.length)
76
- @logger.info "ClassMethod extensions: #{@config[:extensions]}"
77
- @logger.info " Purge completion: #{@config[:purge_completion]}"
78
- @logger.info " Notification: #{@config[:notification]}"
79
- @logger.info " Max retries: #{@config[:max_retries]}"
80
- @logger.info " Min threads: #{@config[:min_threads]}"
81
- @logger.info " Max threads: #{@config[:max_threads]}"
82
- @logger.info " Environment: #{Rails.env}"
83
- @logger.info " Loglevel: #{@config[:loglevel]}"
84
- @logger.info " Logfile: #{@config[:logfile] || '(disabled)'}"
85
- @logger.info " Workers: #{@config[:workers]}"
86
- @logger.info " Queues: #{@config[:queues].map {|k,v| k + '(' + v.to_s + ')'}.join(', ')}" if @config[:queues].is_a?(Hash)
87
- @logger.info " PID: #{Process.pid}"
79
+ @logger.info " ClassMethod extensions: #{@config[:extensions]}"
80
+ @logger.info " Purge completion: #{@config[:purge_completion]}"
81
+ @logger.info " Notification: #{@config[:notification]}"
82
+ @logger.info " Max retries: #{@config[:max_retries]}"
83
+ @logger.info " Min threads: #{@config[:min_threads]}"
84
+ @logger.info " Max threads: #{@config[:max_threads]}"
85
+ @logger.info " Environment: #{Rails.env}"
86
+ @logger.info " Namespace: #{@config[:namespace] || '(nil)'}"
87
+ @logger.info " Loglevel: #{@config[:loglevel]}"
88
+ @logger.info " Logfile: #{@config[:logfile] || '(disabled)'}"
89
+ @logger.info " Workers: #{@config[:workers]}"
90
+ @logger.info " Queues: #{@config[:queues].map {|k,v| k + '(' + v.to_s + ')'}.join(', ')}" if @config[:queues].is_a?(Hash)
91
+ @logger.info " PID: #{Process.pid}"
88
92
  @logger.info "-"*(title.length)
89
93
  @logger.warn "[Skiplock] Custom notification has no registered 'on_error' callback" if Skiplock.on_errors.count == 0
90
94
  end
91
95
 
92
96
  def configure
93
- @hostname = Socket.gethostname
97
+ @hostname = "#{`hostname -f`.strip}|#{Socket.ip_address_list.reject(&:ipv4_loopback?).reject(&:ipv6?).map(&:ip_address).join('|')}"
94
98
  @config.transform_values! {|v| v.is_a?(String) ? v.downcase : v}
95
99
  @config[:graceful_shutdown] = 300 if @config[:graceful_shutdown] > 300
96
100
  @config[:graceful_shutdown] = nil if @config[:graceful_shutdown] < 0
@@ -131,6 +135,7 @@ module Skiplock
131
135
  else
132
136
  @config[:notification] = 'custom'
133
137
  end
138
+ Skiplock.namespace = @config[:namespace]
134
139
  Skiplock.on_errors.freeze
135
140
  end
136
141
 
@@ -145,6 +150,7 @@ module Skiplock
145
150
  end
146
151
  if @config[:standalone]
147
152
  Rails.logger.reopen('/dev/null') rescue Rails.logger.reopen('NUL') # supports Windows NUL device
153
+ Rails.logger.level = @logger.level
148
154
  Rails.logger.extend(ActiveSupport::Logger.broadcast(@logger))
149
155
  end
150
156
  rescue Exception => ex
@@ -153,4 +159,4 @@ module Skiplock
153
159
  Skiplock.on_errors.each { |p| p.call(ex) }
154
160
  end
155
161
  end
156
- end
162
+ end
@@ -1,4 +1,4 @@
1
1
  module Skiplock
2
- VERSION = Version = '1.0.22'
2
+ VERSION = Version = '1.1.0'
3
3
  end
4
4
 
@@ -5,7 +5,7 @@ module Skiplock
5
5
 
6
6
  def self.cleanup(hostname = nil)
7
7
  delete_ids = []
8
- self.where(hostname: hostname || Socket.gethostname).each do |worker|
8
+ self.where(namespace: Skiplock.namespace, hostname: hostname || Socket.gethostname).each do |worker|
9
9
  sid = Process.getsid(worker.pid) rescue nil
10
10
  delete_ids << worker.id if worker.sid != sid || worker.updated_at < 10.minutes.ago
11
11
  end
@@ -13,115 +13,119 @@ module Skiplock
13
13
  end
14
14
 
15
15
  def self.generate(capacity:, hostname:, master: true)
16
- self.create!(pid: Process.pid, sid: Process.getsid(), master: master, hostname: hostname, capacity: capacity)
16
+ worker = self.create!(pid: Process.pid, sid: Process.getsid(), master: master, hostname: hostname, capacity: capacity, namespace: Skiplock.namespace)
17
17
  rescue
18
- self.create!(pid: Process.pid, sid: Process.getsid(), master: false, hostname: hostname, capacity: capacity)
18
+ worker = self.create!(pid: Process.pid, sid: Process.getsid(), master: false, hostname: hostname, capacity: capacity, namespace: Skiplock.namespace)
19
19
  end
20
20
 
21
21
  def shutdown
22
22
  @running = false
23
23
  @executor.shutdown
24
24
  @executor.kill unless @executor.wait_for_termination(@config[:graceful_shutdown])
25
- self.delete
26
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."
27
26
  end
28
27
 
29
28
  def start(worker_num: 0, **config)
30
29
  @num = worker_num
31
30
  @config = config
31
+ @pg_config = ActiveRecord::Base.connection.raw_connection.conninfo_hash.compact
32
+ @namespace_query = Skiplock.namespace.nil? ? "namespace IS NULL" : "namespace = '#{Skiplock.namespace}'"
32
33
  @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
33
34
  @running = true
34
- @executor = Concurrent::ThreadPoolExecutor.new(min_threads: @config[:min_threads] + 1, max_threads: @config[:max_threads] + 1, max_queue: @config[:max_threads] + 1, idletime: 60, auto_terminate: true, fallback_policy: :discard)
35
+ @map = ::PG::TypeMapByOid.new
36
+ @map.add_coder(::PG::TextDecoder::Boolean.new(oid: 16, name: 'bool'))
37
+ @map.add_coder(::PG::TextDecoder::Integer.new(oid: 20, name: 'int8'))
38
+ @map.add_coder(::PG::TextDecoder::Integer.new(oid: 21, name: 'int2'))
39
+ @map.add_coder(::PG::TextDecoder::Integer.new(oid: 23, name: 'int4'))
40
+ @map.add_coder(::PG::TextDecoder::TimestampUtc.new(oid: 1114, name: 'timestamp'))
41
+ @map.add_coder(::PG::TextDecoder::String.new(oid: 2950, name: 'uuid'))
42
+ @map.add_coder(::PG::TextDecoder::JSON.new(oid: 3802, name: 'jsonb'))
43
+ @executor = Concurrent::ThreadPoolExecutor.new(min_threads: @config[:min_threads] + 1, max_threads: @config[:max_threads] + 1, max_queue: @config[:max_threads] + 1, idletime: 60, auto_terminate: false, fallback_policy: :abort)
35
44
  @executor.post { run }
36
- Process.setproctitle("skiplock: #{self.master ? 'master' : 'cluster'} worker#{(' ' + @num.to_s) if @num > 0 && @config[:workers] > 2} [#{Rails.application.class.name.deconstantize.downcase}:#{Rails.env}]") if @config[:standalone]
45
+ if @config[:standalone]
46
+ Process.setproctitle("skiplock: #{self.master ? 'master' : 'cluster'} worker#{(' ' + @num.to_s) if @num > 0 && @config[:workers] > 2} [#{Rails.application.class.name.deconstantize.downcase}:#{Rails.env}]")
47
+ ActiveRecord::Base.connection.throw_away!
48
+ end
37
49
  end
38
50
 
39
51
  private
40
52
 
53
+ def establish_connection
54
+ @connection = ::PG.connect(@pg_config)
55
+ @connection.type_map_for_results = @map
56
+ @connection.exec('LISTEN "skiplock::jobs"').clear
57
+ @connection.exec('LISTEN "skiplock::workers"').clear
58
+ end
59
+
41
60
  def run
42
61
  sleep 3
43
62
  Skiplock.logger.info "[Skiplock] Starting in #{@config[:standalone] ? 'standalone' : 'async'} mode (PID: #{self.pid}) with #{@config[:max_threads]} max threads as #{self.master ? 'master' : 'cluster'} worker#{(' ' + @num.to_s) if @num > 0 && @config[:workers] > 2}..."
44
- connection = nil
45
- error = false
46
- listen = false
47
63
  next_schedule_at = Time.now.to_f
48
64
  pg_exception_timestamp = nil
49
65
  timestamp = Process.clock_gettime(Process::CLOCK_MONOTONIC)
50
66
  while @running
51
67
  Rails.application.reloader.wrap do
52
68
  begin
53
- unless listen
54
- connection = self.class.connection
55
- connection.exec_query('LISTEN "skiplock::jobs"')
56
- if self.master
57
- Job.flush
58
- Cron.setup
59
- end
60
- listen = true
61
- end
62
- if error
63
- unless connection.active?
64
- connection.reconnect!
65
- sleep(0.5)
66
- connection.exec_query('LISTEN "skiplock::jobs"')
67
- Job.flush if self.master
68
- pg_exception_timestamp = nil
69
- next_schedule_at = Time.now.to_f
70
- end
71
- error = false
69
+ if @connection.nil? || @connection.status != ::PG::CONNECTION_OK
70
+ establish_connection
71
+ @executor.post { Rails.application.executor.wrap { Job.flush } } if self.master
72
+ pg_exception_timestamp = nil
73
+ next_schedule_at = Time.now.to_f
72
74
  end
73
75
  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
74
76
  result = nil
75
- connection.transaction do
76
- result = connection.select_all("SELECT id, running, scheduled_at FROM skiplock.jobs WHERE running = FALSE AND expired_at IS NULL AND finished_at IS NULL 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").first
77
- result = connection.select_all("UPDATE skiplock.jobs SET running = TRUE, worker_id = '#{self.id}', updated_at = NOW() WHERE id = '#{result['id']}' RETURNING *").first if result && result['scheduled_at'].to_f <= Time.now.to_f
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
81
+ end
78
82
  end
79
83
  if result && result['running']
80
- @executor.post do
81
- Rails.application.executor.wrap { Job.instantiate(result).execute(purge_completion: @config[:purge_completion], max_retries: @config[:max_retries]) }
82
- end
84
+ @executor.post { Rails.application.executor.wrap { Job.instantiate(result).execute(purge_completion: @config[:purge_completion], max_retries: @config[:max_retries]) } }
83
85
  else
84
86
  next_schedule_at = (result ? result['scheduled_at'].to_f : Float::INFINITY)
85
87
  end
86
88
  end
87
- job_notifications = []
88
- connection.raw_connection.wait_for_notify(0.2) do |channel, pid, payload|
89
- job_notifications << payload if payload
89
+ notifications = { 'skiplock::jobs' => [], 'skiplock::workers' => [] }
90
+ @connection.wait_for_notify(0.2) do |channel, pid, payload|
91
+ notifications[channel] << payload if payload
90
92
  loop do
91
- payload = connection.raw_connection.notifies
93
+ payload = @connection.notifies
92
94
  break unless @running && payload
93
- job_notifications << payload[:extra]
95
+ notifications[payload[:relname]] << payload[:extra]
94
96
  end
95
- job_notifications.each do |n|
96
- op, id, worker_id, job_class, queue_name, running, expired_at, finished_at, scheduled_at = n.split(',')
97
- next if op == 'DELETE' || running == 'true' || expired_at.to_f > 0 || finished_at.to_f > 0
97
+ notifications['skiplock::jobs'].each do |n|
98
+ op, id, worker_id, namespace, job_class, queue_name, running, expired_at, finished_at, scheduled_at = n.split(',')
99
+ next if op == 'DELETE' || running == 'true' || namespace != Skiplock.namespace || expired_at.to_f > 0 || finished_at.to_f > 0
98
100
  next_schedule_at = scheduled_at.to_f if scheduled_at.to_f < next_schedule_at
99
101
  end
100
102
  end
101
103
  if Process.clock_gettime(Process::CLOCK_MONOTONIC) - timestamp > 60
102
- connection.exec_query("UPDATE skiplock.workers SET updated_at = NOW() WHERE id = '#{self.id}'")
104
+ @connection.exec("UPDATE skiplock.workers SET updated_at = NOW() WHERE id = '#{self.id}'").clear
103
105
  timestamp = Process.clock_gettime(Process::CLOCK_MONOTONIC)
104
106
  end
105
107
  rescue Exception => ex
106
- Skiplock.logger.error(ex.to_s)
107
- Skiplock.logger.error(ex.backtrace.join("\n"))
108
108
  report_exception = true
109
109
  # if error is with database connection then only report if it persists longer than 1 minute
110
- if ex.is_a?(::PG::ConnectionBad) ||ex.is_a?(::PG::UnableToSend) || ex.message.include?('Bad file descriptor')
110
+ if @connection.nil? || @connection.status != ::PG::CONNECTION_OK
111
111
  report_exception = false if pg_exception_timestamp.nil? || Process.clock_gettime(Process::CLOCK_MONOTONIC) - pg_exception_timestamp <= 60
112
112
  pg_exception_timestamp ||= Process.clock_gettime(Process::CLOCK_MONOTONIC)
113
113
  end
114
- Skiplock.on_errors.each { |p| p.call(ex) } if report_exception
115
- error = true
114
+ if report_exception
115
+ Skiplock.logger.error(ex.to_s)
116
+ Skiplock.logger.error(ex.backtrace.join("\n"))
117
+ Skiplock.on_errors.each { |p| p.call(ex) }
118
+ end
116
119
  wait(5)
117
120
  end
118
121
  sleep(0.3)
119
122
  end
120
123
  end
121
- connection.exec_query('UNLISTEN *')
124
+ ensure
125
+ @connection.close if @connection && !@connection.finished?
122
126
  end
123
127
 
124
- def wait(timeout)
128
+ def wait(timeout = 1)
125
129
  t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
126
130
  while @running
127
131
  sleep(0.5)
@@ -129,4 +133,4 @@ module Skiplock
129
133
  end
130
134
  end
131
135
  end
132
- end
136
+ end
data/lib/skiplock.rb CHANGED
@@ -11,7 +11,7 @@ require 'skiplock/worker'
11
11
  require 'skiplock/version'
12
12
 
13
13
  module Skiplock
14
- DEFAULT_CONFIG = { 'extensions' => false, 'logfile' => 'skiplock.log', 'loglevel' => 'info', 'graceful_shutdown' => 15, 'min_threads' => 1, 'max_threads' => 10, 'max_retries' => 20, 'notification' => 'custom', 'purge_completion' => true, 'queues' => { 'default' => 100, 'mailers' => 999 }, 'workers' => 0 }.freeze
14
+ DEFAULT_CONFIG = { 'graceful_shutdown' => 15, 'min_threads' => 1, 'max_threads' => 10, 'max_retries' => 20, 'logfile' => 'skiplock.log', 'loglevel' => 'info', 'namespace' => nil, 'notification' => 'custom', 'extensions' => false, 'purge_completion' => true, 'queues' => { 'default' => 100, 'mailers' => 999 }, 'workers' => 0 }.freeze
15
15
 
16
16
  def self.logger=(l)
17
17
  @logger = l
@@ -21,6 +21,14 @@ module Skiplock
21
21
  @logger
22
22
  end
23
23
 
24
+ def self.namespace=(n)
25
+ @namespace = n
26
+ end
27
+
28
+ def self.namespace
29
+ @namespace || ''
30
+ end
31
+
24
32
  def self.on_error(&block)
25
33
  @on_errors ||= []
26
34
  @on_errors << block
@@ -34,5 +42,4 @@ module Skiplock
34
42
  def self.table_name_prefix
35
43
  'skiplock.'
36
44
  end
37
- end
38
- ActiveJob::Base.__send__(:include, Skiplock::Patch)
45
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: skiplock
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.22
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tin Vo
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-09-18 00:00:00.000000000 Z
11
+ date: 2022-01-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activejob