Skip to content

Commit c817349

Browse files
committed
Fixes #224.
Postgres only Issue: Jobs that meet all of the following conditions will result in the database connection entering an invalid and unrecoverable state, requiring a rollback before any further database requests (reads or writes) can be made: 1. Use SolidQueue's 'limits_concurrency' macro 2. Are enqueued inside an application-level transaction 3. The limits_concurrency key already exists in the Semaphore table (i.e., this is job 2 - N for a given key) SolidQueue uses the following design pattern to implement the conflict detection of the limits_concurrency macro which works 100% correctly, 100% of the time, with MySQL and SQLite: begin Semaphore.create!(...) no_conflict_path() rescue ActiveRecord::RecordNotUnique handle_concurrency_conflict() end The problem is Postgres: It's not possible to rescue and recover from an insert failing due to an conflict on unique index (or any other database constraint). Until a rollback is executed, the database connection is in an invalid state and unusable. Possible Solutions: 1. Nested transactions + Easiest to implement + Portable across all three standard Rails databases - Has significant performance issues, especially for databases under load or replicated databases with long-running queries (Postgres specific) - Requires using Raise and Rescue for flow of control (performance, less than ideal coding practice) - Requires issuing a rollback (performance, additional command that must round trip from the client to database and back) 2. Insert into the Semaphore table using 'INSERT INTO table (..) VALUES (...) ON CONFLICT DO NOTHING' syntax + ANSI standard syntax (not a Postgres 'one off' solution) + Rails natively supports identifying database adaptors that support this syntax, making the implementation portable and maintainable + Supported by Postgres and allows this issue to be addressed + Does not require Raise and Rescue for flow of control + Performant (native, fast path database functionality) Solution: Leverage Rails connection adaptors allowing code to easily identifying supported database features/functionality to implement strategy #2 (INSERT ON CONFLICT) for those databases that support it (i.e., Postgres) and leave the original implementation of rescuing RecordNotUnique for those that do not. Note: Although I'd love to take credit for the "quality" of this implementation, all that credit belongs to @rosa who's excellent feedback on an earlier implementation resulted in this significantly better implementation.
1 parent 1d115b6 commit c817349

File tree

2 files changed

+31
-57
lines changed

2 files changed

+31
-57
lines changed

app/models/solid_queue/semaphore.rb

Lines changed: 18 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,17 @@ def signal(job)
1717
def signal_all(jobs)
1818
Proxy.signal_all(jobs)
1919
end
20+
21+
# Requires a unique index on key
22+
def create_unique_by(attributes)
23+
if connection.supports_insert_conflict_target?
24+
insert({ **attributes }, unique_by: :key).any?
25+
else
26+
create!(**attributes)
27+
end
28+
rescue ActiveRecord::RecordNotUnique
29+
false
30+
end
2031
end
2132

2233
class Proxy
@@ -44,35 +55,22 @@ def signal
4455

4556
attr_accessor :job
4657

47-
def attempt_creation_with_insert_on_conflict
48-
results = Semaphore.insert({ key: key, value: limit - 1, expires_at: expires_at }, unique_by: :key)
49-
50-
if results.length.zero?
51-
limit == 1 ? false : attempt_decrement
52-
else
58+
def attempt_creation
59+
if Semaphore.create_unique_by(key: key, value: limit - 1, expires_at: expires_at)
5360
true
61+
else
62+
check_limit_or_decrement
5463
end
5564
end
5665

57-
def attempt_creation_with_create_and_exception_handling
58-
Semaphore.create!(key: key, value: limit - 1, expires_at: expires_at)
59-
true
60-
rescue ActiveRecord::RecordNotUnique
61-
limit == 1 ? false : attempt_decrement
62-
end
63-
64-
if ActiveRecord::Base.connection.adapter_name == "PostgreSQL"
65-
alias attempt_creation attempt_creation_with_insert_on_conflict
66-
else
67-
alias attempt_creation attempt_creation_with_create_and_exception_handling
68-
end
66+
def check_limit_or_decrement = limit == 1 ? false : attempt_decrement
6967

7068
def attempt_decrement
71-
Semaphore.available.where(key: key).update_all([ "value = value - 1, expires_at = ?", expires_at ]) > 0
69+
Semaphore.available.where(key: key).update_all(["value = value - 1, expires_at = ?", expires_at]) > 0
7270
end
7371

7472
def attempt_increment
75-
Semaphore.where(key: key, value: ...limit).update_all([ "value = value + 1, expires_at = ?", expires_at ]) > 0
73+
Semaphore.where(key: key, value: ...limit).update_all(["value = value + 1, expires_at = ?", expires_at]) > 0
7674
end
7775

7876
def key

test/integration/concurrency_controls_test.rb

Lines changed: 13 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ class ConcurrencyControlsTest < ActiveSupport::TestCase
8383

8484
# C would have started in the beginning, seeing the status empty, and would finish after
8585
# all other jobs, so it'll do the last update with only itself
86-
assert_stored_sequence(@result, [ "C" ])
86+
assert_stored_sequence(@result, ["C"])
8787
end
8888

8989
test "run several jobs over the same record sequentially, with some of them failing" do
@@ -99,7 +99,7 @@ class ConcurrencyControlsTest < ActiveSupport::TestCase
9999
wait_for_jobs_to_finish_for(3.seconds)
100100
assert_equal 3, SolidQueue::FailedExecution.count
101101

102-
assert_stored_sequence @result, [ "B", "D", "F" ] + ("G".."K").to_a
102+
assert_stored_sequence @result, ["B", "D", "F"] + ("G".."K").to_a
103103
end
104104

105105
test "rely on dispatcher to unblock blocked executions with an available semaphore" do
@@ -133,7 +133,7 @@ class ConcurrencyControlsTest < ActiveSupport::TestCase
133133

134134
# We can't ensure the order between B and C, because it depends on which worker wins when
135135
# unblocking, as one will try to unblock B and another C
136-
assert_stored_sequence @result, ("A".."K").to_a, [ "A", "C", "B" ] + ("D".."K").to_a
136+
assert_stored_sequence @result, ("A".."K").to_a, ["A", "C", "B"] + ("D".."K").to_a
137137
end
138138

139139
test "rely on dispatcher to unblock blocked executions with an expired semaphore" do
@@ -165,7 +165,7 @@ class ConcurrencyControlsTest < ActiveSupport::TestCase
165165

166166
# We can't ensure the order between B and C, because it depends on which worker wins when
167167
# unblocking, as one will try to unblock B and another C
168-
assert_stored_sequence @result, ("A".."K").to_a, [ "A", "C", "B" ] + ("D".."K").to_a
168+
assert_stored_sequence @result, ("A".."K").to_a, ["A", "C", "B"] + ("D".."K").to_a
169169
end
170170

171171
test "don't block claimed executions that get released" do
@@ -183,44 +183,20 @@ class ConcurrencyControlsTest < ActiveSupport::TestCase
183183
assert job.reload.ready?
184184
end
185185

186-
if ActiveRecord::Base.connection.adapter_name == "PostgreSQL"
187-
test "insert_with_unique_by_has_same_database_results_as_create!_with_exception_handling" do
188-
key = "key", limit = 1, expires_at = 1.minute.from_now
186+
test "verify transactions remain valid after Job creation conflicts via limits_concurrency" do
187+
ActiveRecord::Base.transaction do
188+
SequentialUpdateResultJob.perform_later(@result, name: "A", pause: 0.2.seconds)
189+
SequentialUpdateResultJob.perform_later(@result, name: "B")
189190

190-
assert SolidQueue::Semaphore.count == 0
191-
192-
SolidQueue::Semaphore.insert({ key: key, value: limit - 1, expires_at: expires_at }, unique_by: :key)
193-
assert SolidQueue::Semaphore.count == 1
194-
195-
SolidQueue::Semaphore.insert({ key: key, value: limit - 1, expires_at: expires_at }, unique_by: :key)
196-
assert SolidQueue::Semaphore.count == 1
197-
198-
SolidQueue::Semaphore.delete_all
199-
assert SolidQueue::Semaphore.count == 0
200-
201-
SolidQueue::Semaphore.create!(key: key, value: limit - 1, expires_at: expires_at)
202-
assert SolidQueue::Semaphore.count == 1
203-
204-
assert_raises ActiveRecord::RecordNotUnique do
205-
SolidQueue::Semaphore.create!(key: key, value: limit - 1, expires_at: expires_at)
191+
begin
192+
assert_equal 2, SolidQueue::Job.count
193+
assert true, "Transaction state valid"
194+
rescue ActiveRecord::StatementInvalid
195+
assert false, "Transaction state unexpectedly invalid"
206196
end
207197
end
208198
end
209199

210-
test "confirm correct version of attempt_creation by database adaptor" do
211-
proxy = SolidQueue::Semaphore::Proxy.new(true)
212-
213-
aliased_method = proxy.method(:attempt_creation)
214-
215-
if ActiveRecord::Base.connection.adapter_name == "PostgreSQL"
216-
original_method = proxy.method(:attempt_creation_with_insert_on_conflict)
217-
else
218-
original_method = proxy.method(:attempt_creation_with_create_and_exception_handling)
219-
end
220-
221-
assert_equal original_method.name, aliased_method.original_name, "The alias maps to the correct original method"
222-
end
223-
224200
private
225201

226202
def assert_stored_sequence(result, *sequences)

0 commit comments

Comments
 (0)