solid_queue 1.7.1 → 1.7.2

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: d55976c5a8d2a3e411cfb10099c0bbbeedc4652fcb02edfb8474b31b793c164c
4
- data.tar.gz: 5a4a9ddffa329ef4ff6feb7fb642f18baaae637d8248f55bfa739d56a7f16824
3
+ metadata.gz: 0d1bbdf59a147fbbf0dbb46fe048353340c9f78ee31a5285318b244d3dc4fa7c
4
+ data.tar.gz: 804fe9402e26203920158d705ef2d0353c5d99dba05ae156c3a4ee148b155cdd
5
5
  SHA512:
6
- metadata.gz: 8301a9c5150cc8f0a99b47755fd2e22e41832a913618fffe3280bd61be27c7f84a570ba2ade570a8228b6d07b4587b914fa61b626b24e87b4d57f8cd702e9949
7
- data.tar.gz: af764dc8c6749f5a88a50993693b36450a0dfe158ea04a6dd267fd23476208c858e96a17cf767152be75eb6bbc0bfb82d1cdfb3863091b5cfc4d8430eec72997
6
+ metadata.gz: a6d1a1426fd0be06daf3c0b7ac0b508cc7eaa75a32024d89088ac19d5abc2373c8b75ad6795a248e007bd376ebcf2c6ef10a30649625c6f3555703090a75d9fb
7
+ data.tar.gz: 5f57c878e6876d91ec56fd4492f9db5b190071db5efd12ff618ab566c618fc03720c4f76bb522758a9f4f58b258313fc0255020d05aa4cb5815fa041f1fec9bd
data/UPGRADING.md CHANGED
@@ -1,3 +1,10 @@
1
+ # Upgrading to version 1.7.2
2
+ Jobs with [concurrency controls](README.md#concurrency-controls) now remember which semaphore they got their slot from, in their serialized job data, and give the slot back only to that semaphore. Before, a job that ran past its `duration` gave its slot back to the semaphore a later job had created after the job's own one expired, so one more job than the limit allowed could run at the same time, and keep doing so until the jobs waiting for that key ran out.
3
+
4
+ There's nothing to change or migrate. Jobs that got their slot before you upgrade have nothing recorded and give it back as before, so old and new versions can run side by side while you deploy.
5
+
6
+ Solid Queue 2.0 will stop giving back slots that weren't recorded. Before upgrading to 2.0, make sure the concurrency-limited jobs that were ready or running when you upgraded to 1.7.2 have finished. Otherwise their slots won't be given back: a slot like that only comes back when its semaphore expires, and every job that takes or gives back a slot for the same key pushes that expiry out by another `duration`, so on a busy key with a limit above 1 it can stay lost for as long as the key stays busy.
7
+
1
8
  # Upgrading to version 1.7.x
2
9
  This version introduces support for grouping jobs into batches, which needs new tables. Fresh installs get them with the base schema; existing installations need to copy the migration that adds them and run it:
3
10
 
@@ -51,7 +51,7 @@ module SolidQueue
51
51
  destroy!
52
52
 
53
53
  payload[:failed] = true
54
- elsif acquire_concurrency_lock
54
+ elsif job.acquire_concurrency_lock
55
55
  promote_to_ready
56
56
  destroy!
57
57
 
@@ -63,12 +63,7 @@ module SolidQueue
63
63
 
64
64
  private
65
65
  def set_expires_at
66
- duration = job.job_class ? job.concurrency_duration : SolidQueue.default_concurrency_control_period
67
- self.expires_at = duration.from_now
68
- end
69
-
70
- def acquire_concurrency_lock
71
- Semaphore.wait(job)
66
+ self.expires_at = job.concurrency_duration.from_now
72
67
  end
73
68
 
74
69
  def promote_to_ready
@@ -5,11 +5,12 @@ module SolidQueue
5
5
  module ConcurrencyControls
6
6
  extend ActiveSupport::Concern
7
7
 
8
+ # Stored in the job's serialized Active Job data, which Active Job ignores when running the job
9
+ CONCURRENCY_LOCK_KEY = "solid_queue_concurrency_lock"
10
+
8
11
  included do
9
12
  has_one :blocked_execution
10
13
 
11
- delegate :concurrency_limit, :concurrency_duration, to: :job_class
12
-
13
14
  before_destroy :unblock_next_blocked_job, if: -> { concurrency_limited? && ready? }
14
15
  end
15
16
 
@@ -25,8 +26,39 @@ module SolidQueue
25
26
  end
26
27
  end
27
28
 
29
+ def acquire_concurrency_lock
30
+ return true unless concurrency_limited?
31
+
32
+ if job_class.nil?
33
+ # A job whose class doesn't load skips its limits. Record that it holds no slot,
34
+ # so it doesn't give one back if it runs somewhere its class does load.
35
+ record_concurrency_lock(semaphore_id: nil)
36
+ elsif semaphore_id = Semaphore.wait(self)
37
+ record_concurrency_lock(semaphore_id:, limit: concurrency_limit)
38
+ end
39
+ end
40
+
28
41
  def concurrency_limited?
29
- concurrency_key.present? && job_class.present?
42
+ concurrency_key.present?
43
+ end
44
+
45
+ # A job that kept its concurrency key after its class dropped limits_concurrency gets a limit of 1
46
+ def concurrency_limit
47
+ job_class.concurrency_limit || 1 if concurrency_limited?
48
+ end
49
+
50
+ def concurrency_duration
51
+ job_class ? job_class.concurrency_duration : SolidQueue.default_concurrency_control_period
52
+ end
53
+
54
+ # The semaphore the job got its concurrency slot from
55
+ def acquired_semaphore_id
56
+ concurrency_lock&.dig("semaphore_id")
57
+ end
58
+
59
+ # The concurrency limit the job had when it got its slot
60
+ def acquired_concurrency_limit
61
+ concurrency_lock&.dig("limit")
30
62
  end
31
63
 
32
64
  def blocked?
@@ -38,20 +70,35 @@ module SolidQueue
38
70
  end
39
71
 
40
72
  private
41
- def concurrency_on_conflict
42
- job_class.concurrency_on_conflict.to_s.inquiry
73
+ def release_concurrency_lock
74
+ holds_concurrency_slot? && Semaphore.signal(self)
43
75
  end
44
76
 
45
- def acquire_concurrency_lock
46
- return true unless concurrency_limited?
77
+ def holds_concurrency_slot?
78
+ if concurrency_lock_recorded?
79
+ acquired_semaphore_id.present?
80
+ else
81
+ # Jobs dispatched before Solid Queue recorded their semaphore, which need their class to know their limit
82
+ concurrency_limited? && job_class.present?
83
+ end
84
+ end
47
85
 
48
- Semaphore.wait(self)
86
+ def concurrency_lock_recorded?
87
+ arguments.key?(CONCURRENCY_LOCK_KEY)
49
88
  end
50
89
 
51
- def release_concurrency_lock
52
- return false unless concurrency_limited?
90
+ def release_next_blocked_job
91
+ BlockedExecution.release_one(concurrency_key)
92
+ end
93
+
94
+ def record_concurrency_lock(semaphore_id:, limit: nil)
95
+ arguments[CONCURRENCY_LOCK_KEY] = { "semaphore_id" => semaphore_id, "limit" => limit }
96
+ update_column :arguments, arguments
97
+ true
98
+ end
53
99
 
54
- Semaphore.signal(self)
100
+ def concurrency_lock
101
+ arguments[CONCURRENCY_LOCK_KEY]
55
102
  end
56
103
 
57
104
  def handle_concurrency_conflict
@@ -62,12 +109,12 @@ module SolidQueue
62
109
  end
63
110
  end
64
111
 
65
- def block
66
- BlockedExecution.create_or_find_by!(job: self)
112
+ def concurrency_on_conflict
113
+ job_class.concurrency_on_conflict.to_s.inquiry
67
114
  end
68
115
 
69
- def release_next_blocked_job
70
- BlockedExecution.release_one(concurrency_key)
116
+ def block
117
+ BlockedExecution.create_or_find_by!(job: self)
71
118
  end
72
119
 
73
120
  def execution
@@ -31,9 +31,10 @@ module SolidQueue
31
31
  @job = job
32
32
  end
33
33
 
34
+ # Returns the id of the semaphore the job got a slot on, or nil if there was none free
34
35
  def wait
35
36
  if semaphore = Semaphore.lock.find_by(key: key)
36
- semaphore.value > 0 && attempt_decrement
37
+ semaphore.id if semaphore.value > 0 && attempt_decrement
37
38
  else
38
39
  attempt_creation
39
40
  end
@@ -46,24 +47,46 @@ module SolidQueue
46
47
  private
47
48
  attr_accessor :job
48
49
 
50
+ def attempt_decrement
51
+ Semaphore.available.where(key: key).update_all([ "value = value - 1, expires_at = ?", expires_at ]) > 0
52
+ end
53
+
49
54
  def attempt_creation
50
55
  if Semaphore.create_unique_by(key: key, value: limit - 1, expires_at: expires_at)
51
- true
56
+ acquired_semaphore_id
52
57
  else
53
58
  check_limit_or_decrement
54
59
  end
55
60
  end
56
61
 
57
- def check_limit_or_decrement
58
- limit == 1 ? false : attempt_decrement
62
+ # A locking read, so it sees the latest committed rows rather than the transaction's
63
+ # snapshot, which on MySQL can still include a semaphore that expired and was deleted
64
+ def acquired_semaphore_id
65
+ Semaphore.lock.where(key: key).pick(:id)
59
66
  end
60
67
 
61
- def attempt_decrement
62
- Semaphore.available.where(key: key).update_all([ "value = value - 1, expires_at = ?", expires_at ]) > 0
68
+ def check_limit_or_decrement
69
+ acquired_semaphore_id if limit > 1 && attempt_decrement
63
70
  end
64
71
 
65
72
  def attempt_increment
66
- Semaphore.where(key: key, value: ...limit).update_all([ "value = value + 1, expires_at = ?", expires_at ]) > 0
73
+ semaphore_to_release.where(value: ...limit_to_release).update_all([ "value = value + 1, expires_at = ?", expires_at ]) > 0
74
+ end
75
+
76
+ # A job only gives its slot back to the semaphore it got it from. If that one expired
77
+ # and was deleted, a semaphore for the same key now belongs to other jobs.
78
+ # Jobs dispatched before Solid Queue recorded their semaphore give it back by key.
79
+ def semaphore_to_release
80
+ if job.acquired_semaphore_id
81
+ Semaphore.where(id: job.acquired_semaphore_id)
82
+ else
83
+ Semaphore.where(key: key)
84
+ end
85
+ end
86
+
87
+ # Recorded when the job got its slot, so it's known even if the job's class no longer loads
88
+ def limit_to_release
89
+ job.acquired_concurrency_limit || limit
67
90
  end
68
91
 
69
92
  def key
@@ -75,7 +98,7 @@ module SolidQueue
75
98
  end
76
99
 
77
100
  def limit
78
- job.concurrency_limit || 1
101
+ job.concurrency_limit
79
102
  end
80
103
  end
81
104
  end
@@ -1,5 +1,5 @@
1
1
  module SolidQueue
2
- VERSION = "1.7.1"
2
+ VERSION = "1.7.2"
3
3
 
4
4
  def self.next_major_version
5
5
  Gem::Version.new(VERSION).segments.first + 1
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solid_queue
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.7.1
4
+ version: 1.7.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rosa Gutierrez