Skip to content

Commit 683f098

Browse files
authored
Implement unique job insertions (#10)
Implement unique job insertion in a way that's compatible with the main Go library. Unique opts live on insert opts just like in Go, and we copy as much of its API as possible including return result. insert_res = client.insert(args, insert_opts: River::InsertOpts.new( unique_opts: River::UniqueOpts.new( by_args: true, by_period: 15 * 60, by_queue: true, by_state: [River::JOB_STATE_AVAILABLE] ) ) # contains either a newly inserted job, or an existing one if insertion was skipped insert_res.job # true if insertion was skipped insert_res.unique_skipped_as_duplicated
1 parent 6b55d91 commit 683f098

File tree

19 files changed

+874
-69
lines changed

19 files changed

+874
-69
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Implement unique job insertion. [PR #10](https://github.com/riverqueue/riverqueue-ruby/pull/10).
13+
1014
## [0.2.0] - 2024-04-27
1115

1216
### Added

Gemfile.lock

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ PATH
22
remote: .
33
specs:
44
riverqueue (0.2.0)
5+
fnv-hash
56

67
GEM
78
remote: https://rubygems.org/
@@ -31,6 +32,7 @@ GEM
3132
drb (2.2.1)
3233
ffi (1.16.3)
3334
fileutils (1.7.2)
35+
fnv-hash (0.2.0)
3436
i18n (1.14.4)
3537
concurrent-ruby (~> 1.0)
3638
io-console (0.7.2)

Steepfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ target :lib do
44
check "lib"
55

66
library "json"
7+
library "time"
78

89
signature "sig"
10+
signature "sig_gem"
911

1012
configure_code_diagnostics(D::Ruby.strict)
1113
end

docs/README.md

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,27 @@ insert_res = client.insert(
6060
)
6161
```
6262

63+
## Inserting unique jobs
64+
65+
[Unique jobs](https://riverqueue.com/docs/unique-jobs) are supported through `InsertOpts#unique_opts`, and can be made unique by args, period, queue, and state. If a job matching unique properties is found on insert, the insert is skipped and the existing job returned.
66+
67+
```ruby
68+
insert_res = client.insert(args, insert_opts: River::InsertOpts.new(
69+
unique_opts: River::UniqueOpts.new(
70+
by_args: true,
71+
by_period: 15 * 60,
72+
by_queue: true,
73+
by_state: [River::JOB_STATE_AVAILABLE]
74+
)
75+
)
76+
77+
# contains either a newly inserted job, or an existing one if insertion was skipped
78+
insert_res.job
79+
80+
# true if insertion was skipped
81+
insert_res.unique_skipped_as_duplicated
82+
```
83+
6384
## Inserting jobs in bulk
6485

6586
Use `#insert_many` to bulk insert jobs as a single operation for improved efficiency:
@@ -75,8 +96,8 @@ Or with `InsertManyParams`, which may include insertion options:
7596

7697
```ruby
7798
num_inserted = client.insert_many([
78-
River::InsertManyParams.new(SimpleArgs.new(job_num: 1), insert_opts: InsertOpts.new(max_attempts: 5)),
79-
River::InsertManyParams.new(SimpleArgs.new(job_num: 2), insert_opts: InsertOpts.new(queue: "high_priority"))
99+
River::InsertManyParams.new(SimpleArgs.new(job_num: 1), insert_opts: River::InsertOpts.new(max_attempts: 5)),
100+
River::InsertManyParams.new(SimpleArgs.new(job_num: 2), insert_opts: River::InsertOpts.new(queue: "high_priority"))
80101
])
81102
```
82103

drivers/riverqueue-activerecord/Gemfile.lock

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ PATH
22
remote: ../..
33
specs:
44
riverqueue (0.2.0)
5+
fnv-hash
56

67
PATH
78
remote: .
@@ -41,6 +42,7 @@ GEM
4142
diff-lcs (1.5.1)
4243
docile (1.4.0)
4344
drb (2.2.1)
45+
fnv-hash (0.2.0)
4446
i18n (1.14.4)
4547
concurrent-ruby (~> 1.0)
4648
io-console (0.7.2)

drivers/riverqueue-activerecord/lib/driver.rb

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,32 @@ def errors = {}
3131
end
3232
end
3333

34-
def insert(insert_params)
34+
def advisory_lock(key)
35+
::ActiveRecord::Base.connection.execute("SELECT pg_advisory_xact_lock(#{key})")
36+
end
37+
38+
def job_get_by_kind_and_unique_properties(get_params)
39+
data_set = RiverJob.where(kind: get_params.kind)
40+
data_set = data_set.where("tstzrange(?, ?, '[)') @> created_at", get_params.created_at[0], get_params.created_at[1]) if get_params.created_at
41+
data_set = data_set.where(args: get_params.encoded_args) if get_params.encoded_args
42+
data_set = data_set.where(queue: get_params.queue) if get_params.queue
43+
data_set = data_set.where(state: get_params.state) if get_params.state
44+
data_set.take
45+
end
46+
47+
def job_insert(insert_params)
3548
to_job_row(RiverJob.create(insert_params_to_hash(insert_params)))
3649
end
3750

38-
def insert_many(insert_params_many)
51+
def job_insert_many(insert_params_many)
3952
RiverJob.insert_all(insert_params_many.map { |p| insert_params_to_hash(p) })
4053
insert_params_many.count
4154
end
4255

56+
def transaction(&)
57+
::ActiveRecord::Base.transaction(requires_new: true, &)
58+
end
59+
4360
private def insert_params_to_hash(insert_params)
4461
# the call to `#compact` is important so that we remove nils and table
4562
# default values get picked up instead

drivers/riverqueue-activerecord/spec/driver_spec.rb

Lines changed: 169 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,155 @@ class SimpleArgsWithInsertOpts < SimpleArgs
2525
let!(:driver) { River::Driver::ActiveRecord.new }
2626
let(:client) { River::Client.new(driver) }
2727

28-
describe "#insert" do
28+
describe "unique insertion" do
29+
it "inserts a unique job once" do
30+
args = SimpleArgsWithInsertOpts.new(job_num: 1)
31+
args.insert_opts = River::InsertOpts.new(
32+
unique_opts: River::UniqueOpts.new(
33+
by_queue: true
34+
)
35+
)
36+
37+
insert_res = client.insert(args)
38+
expect(insert_res.job).to_not be_nil
39+
expect(insert_res.unique_skipped_as_duplicated).to be false
40+
original_job = insert_res.job
41+
42+
insert_res = client.insert(args)
43+
expect(insert_res.job.id).to eq(original_job.id)
44+
expect(insert_res.unique_skipped_as_duplicated).to be true
45+
end
46+
47+
it "inserts a unique job with an advisory lock prefix" do
48+
client = River::Client.new(driver, advisory_lock_prefix: 123456)
49+
50+
args = SimpleArgsWithInsertOpts.new(job_num: 1)
51+
args.insert_opts = River::InsertOpts.new(
52+
unique_opts: River::UniqueOpts.new(
53+
by_queue: true
54+
)
55+
)
56+
57+
insert_res = client.insert(args)
58+
expect(insert_res.job).to_not be_nil
59+
expect(insert_res.unique_skipped_as_duplicated).to be false
60+
original_job = insert_res.job
61+
62+
insert_res = client.insert(args)
63+
expect(insert_res.job.id).to eq(original_job.id)
64+
expect(insert_res.unique_skipped_as_duplicated).to be true
65+
end
66+
end
67+
68+
describe "#advisory_lock" do
69+
it "takes an advisory lock" do
70+
driver.advisory_lock(123)
71+
end
72+
end
73+
74+
describe "#job_get_by_kind_and_unique_properties" do
75+
let(:job_args) { SimpleArgs.new(job_num: 1) }
76+
77+
it "gets a job by kind" do
78+
insert_res = client.insert(job_args)
79+
80+
job = driver.send(
81+
:to_job_row,
82+
driver.job_get_by_kind_and_unique_properties(River::Driver::JobGetByKindAndUniquePropertiesParam.new(
83+
kind: job_args.kind
84+
))
85+
)
86+
expect(job.id).to eq(insert_res.job.id)
87+
88+
expect(
89+
driver.job_get_by_kind_and_unique_properties(River::Driver::JobGetByKindAndUniquePropertiesParam.new(
90+
kind: "does_not_exist"
91+
))
92+
).to be_nil
93+
end
94+
95+
it "gets a job by created at period" do
96+
insert_res = client.insert(job_args)
97+
98+
job = driver.send(
99+
:to_job_row,
100+
driver.job_get_by_kind_and_unique_properties(River::Driver::JobGetByKindAndUniquePropertiesParam.new(
101+
kind: job_args.kind,
102+
created_at: [insert_res.job.created_at - 1, insert_res.job.created_at + 1]
103+
))
104+
)
105+
expect(job.id).to eq(insert_res.job.id)
106+
107+
expect(
108+
driver.job_get_by_kind_and_unique_properties(River::Driver::JobGetByKindAndUniquePropertiesParam.new(
109+
kind: job_args.kind,
110+
created_at: [insert_res.job.created_at + 1, insert_res.job.created_at + 3]
111+
))
112+
).to be_nil
113+
end
114+
115+
it "gets a job by encoded args" do
116+
insert_res = client.insert(job_args)
117+
118+
job = driver.send(
119+
:to_job_row,
120+
driver.job_get_by_kind_and_unique_properties(River::Driver::JobGetByKindAndUniquePropertiesParam.new(
121+
kind: job_args.kind,
122+
encoded_args: JSON.dump(insert_res.job.args)
123+
))
124+
)
125+
expect(job.id).to eq(insert_res.job.id)
126+
127+
expect(
128+
driver.job_get_by_kind_and_unique_properties(River::Driver::JobGetByKindAndUniquePropertiesParam.new(
129+
kind: job_args.kind,
130+
encoded_args: JSON.dump({"job_num" => 2})
131+
))
132+
).to be_nil
133+
end
134+
135+
it "gets a job by queue" do
136+
insert_res = client.insert(job_args)
137+
138+
job = driver.send(
139+
:to_job_row,
140+
driver.job_get_by_kind_and_unique_properties(River::Driver::JobGetByKindAndUniquePropertiesParam.new(
141+
kind: job_args.kind,
142+
queue: insert_res.job.queue
143+
))
144+
)
145+
expect(job.id).to eq(insert_res.job.id)
146+
147+
expect(
148+
driver.job_get_by_kind_and_unique_properties(River::Driver::JobGetByKindAndUniquePropertiesParam.new(
149+
kind: job_args.kind,
150+
queue: "other_queue"
151+
))
152+
).to be_nil
153+
end
154+
155+
it "gets a job by state" do
156+
insert_res = client.insert(job_args)
157+
158+
job = driver.send(
159+
:to_job_row,
160+
driver.job_get_by_kind_and_unique_properties(River::Driver::JobGetByKindAndUniquePropertiesParam.new(
161+
kind: job_args.kind,
162+
state: [River::JOB_STATE_AVAILABLE, River::JOB_STATE_COMPLETED]
163+
))
164+
)
165+
expect(job.id).to eq(insert_res.job.id)
166+
167+
expect(
168+
driver.job_get_by_kind_and_unique_properties(River::Driver::JobGetByKindAndUniquePropertiesParam.new(
169+
kind: job_args.kind,
170+
state: [River::JOB_STATE_RUNNING, River::JOB_STATE_SCHEDULED]
171+
))
172+
).to be_nil
173+
end
174+
end
175+
176+
describe "#job_insert" do
29177
it "inserts a job" do
30178
insert_res = client.insert(SimpleArgs.new(job_num: 1))
31179
expect(insert_res.job).to have_attributes(
@@ -133,7 +281,7 @@ class SimpleArgsWithInsertOpts < SimpleArgs
133281
end
134282
end
135283

136-
describe "#insert_many" do
284+
describe "#job_insert_many" do
137285
it "inserts multiple jobs" do
138286
num_inserted = client.insert_many([
139287
SimpleArgs.new(job_num: 1),
@@ -197,6 +345,25 @@ class SimpleArgsWithInsertOpts < SimpleArgs
197345
end
198346
end
199347

348+
describe "#transaction" do
349+
it "runs block in a transaction" do
350+
insert_res = nil
351+
352+
driver.transaction do
353+
insert_res = client.insert(SimpleArgs.new(job_num: 1))
354+
355+
river_job = River::Driver::ActiveRecord::RiverJob.find_by(id: insert_res.job.id)
356+
expect(river_job).to_not be_nil
357+
358+
raise ActiveRecord::Rollback
359+
end
360+
361+
# Not present because the job was rolled back.
362+
river_job = River::Driver::ActiveRecord::RiverJob.find_by(id: insert_res.job.id)
363+
expect(river_job).to be_nil
364+
end
365+
end
366+
200367
describe "#to_job_row" do
201368
it "converts a database record to `River::JobRow`" do
202369
now = Time.now.utc

drivers/riverqueue-sequel/Gemfile.lock

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ PATH
22
remote: ../..
33
specs:
44
riverqueue (0.2.0)
5+
fnv-hash
56

67
PATH
78
remote: .
@@ -17,6 +18,7 @@ GEM
1718
bigdecimal (3.1.7)
1819
diff-lcs (1.5.1)
1920
docile (1.4.0)
21+
fnv-hash (0.2.0)
2022
json (2.7.2)
2123
language_server-protocol (3.17.0.3)
2224
lint_roller (1.1.0)

drivers/riverqueue-sequel/lib/driver.rb

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,32 @@ def initialize(db)
2121
end
2222
end
2323

24-
def insert(insert_params)
24+
def advisory_lock(key)
25+
@db.fetch("SELECT pg_advisory_xact_lock(?)", key).first
26+
end
27+
28+
def job_get_by_kind_and_unique_properties(get_params)
29+
data_set = RiverJob.where(kind: get_params.kind)
30+
data_set = data_set.where(::Sequel.lit("tstzrange(?, ?, '[)') @> created_at", get_params.created_at[0], get_params.created_at[1])) if get_params.created_at
31+
data_set = data_set.where(args: get_params.encoded_args) if get_params.encoded_args
32+
data_set = data_set.where(queue: get_params.queue) if get_params.queue
33+
data_set = data_set.where(state: get_params.state) if get_params.state
34+
data_set.first
35+
end
36+
37+
def job_insert(insert_params)
2538
to_job_row(RiverJob.create(insert_params_to_hash(insert_params)))
2639
end
2740

28-
def insert_many(insert_params_many)
41+
def job_insert_many(insert_params_many)
2942
RiverJob.multi_insert(insert_params_many.map { |p| insert_params_to_hash(p) })
3043
insert_params_many.count
3144
end
3245

46+
def transaction(&)
47+
@db.transaction(savepoint: true, &)
48+
end
49+
3350
private def insert_params_to_hash(insert_params)
3451
# the call to `#compact` is important so that we remove nils and table
3552
# default values get picked up instead

0 commit comments

Comments
 (0)