postqueue 0.2.1 → 0.4.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 +4 -4
- data/README.md +103 -49
- data/lib/postqueue/item/enqueue.rb +33 -0
- data/lib/postqueue/item/inserter.rb +29 -0
- data/lib/postqueue/item.rb +14 -0
- data/lib/postqueue/logger.rb +9 -0
- data/lib/postqueue/queue/callback.rb +60 -0
- data/lib/postqueue/queue/logging.rb +22 -0
- data/lib/postqueue/queue/processing.rb +57 -0
- data/lib/postqueue/{base → queue}/select_and_lock.rb +24 -9
- data/lib/postqueue/queue.rb +61 -0
- data/lib/postqueue/version.rb +1 -1
- data/lib/postqueue.rb +18 -4
- data/lib/tracker/advisory_lock.rb +39 -0
- data/lib/tracker/migration.rb +23 -0
- data/lib/tracker/registry.rb +45 -0
- data/lib/tracker/tracker.sql +231 -0
- data/lib/tracker.rb +125 -0
- data/spec/postqueue/concurrency_spec.rb +77 -0
- data/spec/postqueue/enqueue_spec.rb +3 -37
- data/spec/postqueue/idempotent_ops_spec.rb +66 -0
- data/spec/postqueue/process_errors_spec.rb +27 -40
- data/spec/postqueue/process_spec.rb +37 -28
- data/spec/postqueue/syncmode_spec.rb +38 -0
- data/spec/postqueue/wildcard_spec.rb +31 -0
- data/spec/spec_helper.rb +1 -12
- data/spec/support/configure_active_record.rb +7 -13
- data/spec/support/connect_active_record.rb +5 -0
- metadata +20 -7
- data/lib/postqueue/base/callback.rb +0 -23
- data/lib/postqueue/base/enqueue.rb +0 -31
- data/lib/postqueue/base/processing.rb +0 -57
- data/lib/postqueue/base.rb +0 -41
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 5fd15294351f2f6e2be7d4c15d7ac509e097f3c5
|
4
|
+
data.tar.gz: cbe1f9ca4e5bf7bbfa6027794e2c0ff26ad14c03
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 5192d32ebb44a46f60df9303edb8cf2065edb42194e77e28576d2deba16dfbcc703c2c4f384f895a190cec68a327426936d1bfe48c157e8763e275a641945820
|
7
|
+
data.tar.gz: c27d77120587844546d644d5bdfa38897a771080fd89654242b39c00ca95125c8916b5dbb751ff47ed8def82ba50cded1daf2090dc6424854c2bac5343f366f3
|
data/README.md
CHANGED
@@ -1,18 +1,37 @@
|
|
1
1
|
# Postqueue
|
2
2
|
|
3
|
-
|
4
|
-
syntax, it needs PostgresQL >= 9.5.
|
3
|
+
## Intro
|
5
4
|
|
6
|
-
|
5
|
+
The postqueue gem implements a simple to use queue on top of postgresql. Note that while
|
6
|
+
a queue like this is typically used in a job queueing scenario, this document does not
|
7
|
+
talk about jobs, it talks about **queue items**; it also does not schedule a job,
|
8
|
+
it **enqueues** an item, and it does not executes a job, it **processes** queue items.
|
7
9
|
|
8
|
-
|
9
|
-
|
10
|
-
|
10
|
+
Why building an additional queue implementation? Compared to delayed_job or the other
|
11
|
+
usual suspects postqueue implements these features:
|
12
|
+
|
13
|
+
- The item structure is intentionally kept super simple: an item is described by an
|
14
|
+
`op` field - a string - and an `id` field, an integer. In a typical usecase a
|
15
|
+
queue item would describe an operation on a specific entity, where `op` names
|
16
|
+
both the operation and the entity type and the `id` field would describe the
|
17
|
+
individual entity.
|
18
|
+
|
19
|
+
- With such a simplistic item structure the queue itself can be searched or
|
20
|
+
otherwise evaluated using SQL. This also allows for **skipping duplicate entries**
|
21
|
+
when enqueuing items (managed via a duplicate: argument when enqueuing) and for
|
22
|
+
**batch processing** multple items in one go.
|
23
|
+
|
24
|
+
- With data being kept in a Postgresql database processing provides **transactional semantics**:
|
25
|
+
an item failing to process stays in the queue. Error handling is kept simpe to a
|
26
|
+
strategy of rescheduling items up to a specific maximum number of processing attemps.
|
27
|
+
|
28
|
+
Please be aware that postqueue is using the SELECT .. FOR UPDATE SKIP LOCKED Postgresql syntax,
|
29
|
+
and therefore needs at least PostgresQL >= 9.5.
|
11
30
|
|
12
31
|
## Basic usage
|
13
32
|
|
14
33
|
```ruby
|
15
|
-
queue =
|
34
|
+
queue = Postqueue.new
|
16
35
|
queue.enqueue op: "product/reindex", entity_id: [12,13,14,15]
|
17
36
|
queue.process do |op, entity_ids|
|
18
37
|
# note: entity_ids is always an Array of ids.
|
@@ -27,12 +46,11 @@ end
|
|
27
46
|
|
28
47
|
The process call will select a number of queue items for processing. They will all have
|
29
48
|
the same `op` attribute. The callback will receive the `op` attribute and the `entity_ids`
|
30
|
-
of all queue entries selected for processing. The `processing` method will return the
|
31
|
-
|
49
|
+
of all queue entries selected for processing. The `processing` method will return the number
|
50
|
+
of processed items.
|
32
51
|
|
33
|
-
If no callback is given the
|
34
|
-
|
35
|
-
when using a block to do processing errors and exceptions can properly be dealt with.
|
52
|
+
If no callback is given the matching items are only removed from the queue without
|
53
|
+
any processing.
|
36
54
|
|
37
55
|
Postqueue.process also accepts the following arguments:
|
38
56
|
|
@@ -45,56 +63,90 @@ Example:
|
|
45
63
|
# only handle up to 10 "product/reindex" entries
|
46
64
|
end
|
47
65
|
|
48
|
-
If the block
|
49
|
-
|
50
|
-
|
51
|
-
leads to a maximum postpone interval (currently up to 190 seconds).
|
66
|
+
If the block raises an exception the queue will postpone processing these entries
|
67
|
+
by an increasing amount of time, up until `queue.max_attempts` failed attempts.
|
68
|
+
That value defaults to 5.
|
52
69
|
|
53
70
|
If the queue is empty or no matching queue entry could be found, `Postqueue.process`
|
54
|
-
returns
|
71
|
+
returns 0.
|
72
|
+
|
73
|
+
## Advanced usage
|
74
|
+
|
75
|
+
### Concurrency
|
76
|
+
|
77
|
+
Postqueue implements the following concurrency guarantees:
|
78
|
+
|
79
|
+
- catastrophic DB failure and communication breakdown aside a queue item which is enqueued will eventually be processed successfully exactly once;
|
80
|
+
- multiple consumers can work in parallel.
|
81
|
+
|
82
|
+
Note that you should not share a Postqueue ruby object across threads - instead you should create
|
83
|
+
process objects with the identical configuration.
|
84
|
+
|
85
|
+
### Idempotent operations
|
86
|
+
|
87
|
+
When enqueueing items duplicate idempotent operations are not enqueued. Whether or not an operation
|
88
|
+
should be considered idempotent is defined when configuring the queue:
|
89
|
+
|
90
|
+
Postqueue.new do |queue|
|
91
|
+
queue.idempotent_operation "idempotent"
|
92
|
+
end
|
55
93
|
|
56
|
-
###
|
94
|
+
### Processing a single entry
|
57
95
|
|
58
96
|
Postqueue implements a shortcut to process only a single entry. Under the hood this
|
59
97
|
calls `Postqueue.process` with `batch_size` set to `1`:
|
60
98
|
|
61
|
-
|
62
|
-
end
|
99
|
+
queue.process_one
|
63
100
|
|
64
101
|
Note that even though `process_one` will only ever process a single entry the
|
65
|
-
`entity_ids` parameter to the
|
102
|
+
`entity_ids` parameter to the callback is still an array (with a single ID entry
|
66
103
|
in that case).
|
67
104
|
|
68
|
-
|
105
|
+
### Migrating
|
106
|
+
|
107
|
+
Postqueue comes with migration helpers:
|
108
|
+
|
109
|
+
# set up a table for use with postqueue.
|
110
|
+
Postqueue.migrate!(table_name = "postqueue")
|
69
111
|
|
70
|
-
|
71
|
-
|
72
|
-
a queue item will be created if another item is currently being processed.
|
112
|
+
# set up a table for use with postqueue.
|
113
|
+
Postqueue.unmigrate!(table_name = "postqueue")
|
73
114
|
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
115
|
+
You can also set up your own table, as long as it is compatible.
|
116
|
+
|
117
|
+
To use a non-default table or a non-default database, change the `item_class`
|
118
|
+
attribute of the queue:
|
119
|
+
|
120
|
+
Postqueue.new do |queue|
|
121
|
+
queue.item_class = MyItemClass
|
78
122
|
end
|
79
123
|
|
80
|
-
|
124
|
+
`MyItemClass` should inherit from Postqueue::Item and use the same or a compatible database
|
125
|
+
structure.
|
126
|
+
|
127
|
+
## Batch processing
|
81
128
|
|
82
|
-
Often queue items can be
|
83
|
-
|
84
|
-
|
85
|
-
The following implements a batch_size of 100 for all queue entries:
|
129
|
+
Often queue items can be batched together for a performant operation. To allow batch
|
130
|
+
processing for some items, configure the Postqueue to either set a `default_batch_size`
|
131
|
+
or an operation-specific batch_size:
|
86
132
|
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
end
|
133
|
+
Postqueue.new do |queue|
|
134
|
+
queue.default_batch_size = 100
|
135
|
+
queue.batch_sizes["batchable"] = 10
|
91
136
|
end
|
92
137
|
|
93
|
-
##
|
138
|
+
## Test mode
|
94
139
|
|
95
|
-
|
96
|
-
|
97
|
-
|
140
|
+
During unit tests it is likely preferrable to process queue items in synchronous fashion (i.e. as they come in).
|
141
|
+
You can enable this mode via:
|
142
|
+
|
143
|
+
Postqueue.async_processing = false
|
144
|
+
|
145
|
+
You can also enable this on a queue-by-queue base via:
|
146
|
+
|
147
|
+
Postqueue.new do |queue|
|
148
|
+
queue.async_processing = false
|
149
|
+
end
|
98
150
|
|
99
151
|
## Installation
|
100
152
|
|
@@ -112,19 +164,21 @@ Or install it yourself as:
|
|
112
164
|
|
113
165
|
$ gem install postqueue
|
114
166
|
|
115
|
-
## Usage
|
116
|
-
|
117
167
|
## Development
|
118
168
|
|
119
|
-
After checking out the repo, run `bin/setup` to install dependencies. Make sure you have
|
120
|
-
at least version 9.5. Add a `postqueue` user with
|
121
|
-
|
169
|
+
After checking out the repo, run `bin/setup` to install dependencies. Make sure you have
|
170
|
+
a local postgresql implementation of at least version 9.5. Add a `postqueue` user with
|
171
|
+
a `postqueue` password, and create a `postqueue_test` database for it. The script
|
172
|
+
`./scripts/prepare_pg` can be somewhat helpful in establishing that.
|
122
173
|
|
123
|
-
Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive
|
174
|
+
Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive
|
175
|
+
prompt that will allow you to experiment.
|
124
176
|
|
125
177
|
To install this gem onto your local machine, run `bundle exec rake install`.
|
126
178
|
|
127
|
-
To release a new version, run `./scripts/release`, which will bump the version number,
|
179
|
+
To release a new version, run `./scripts/release`, which will bump the version number,
|
180
|
+
create a git tag for the version, push git commits and tags, and push the `.gem` file
|
181
|
+
to [rubygems.org](https://rubygems.org).
|
128
182
|
|
129
183
|
## Contributing
|
130
184
|
|
@@ -0,0 +1,33 @@
|
|
1
|
+
module Postqueue
|
2
|
+
class Item
|
3
|
+
# Enqueues an queue item. If the operation is duplicate, and an entry with
|
4
|
+
# the same combination of op and entity_id exists already, no new entry will
|
5
|
+
# be added to the queue.
|
6
|
+
#
|
7
|
+
# Returns the number of items that have been enqueued.
|
8
|
+
def self.enqueue(op:, entity_id:, ignore_duplicates: false)
|
9
|
+
if entity_id.is_a?(Enumerable)
|
10
|
+
return enqueue_many(op: op, entity_ids: entity_id, ignore_duplicates: ignore_duplicates)
|
11
|
+
end
|
12
|
+
|
13
|
+
if ignore_duplicates && where(op: op, entity_id: entity_id).present?
|
14
|
+
return 0
|
15
|
+
end
|
16
|
+
|
17
|
+
insert_item op: op, entity_id: entity_id
|
18
|
+
return 1
|
19
|
+
end
|
20
|
+
|
21
|
+
def self.enqueue_many(op:, entity_ids:, ignore_duplicates:) #:nodoc:
|
22
|
+
entity_ids.uniq! if ignore_duplicates
|
23
|
+
|
24
|
+
transaction do
|
25
|
+
entity_ids.each do |entity_id|
|
26
|
+
enqueue(op: op, entity_id: entity_id, ignore_duplicates: ignore_duplicates)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
entity_ids.count
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
require "active_record"
|
2
|
+
|
3
|
+
module Postqueue
|
4
|
+
#
|
5
|
+
# An item class.
|
6
|
+
class Item < ActiveRecord::Base
|
7
|
+
module ActiveRecordInserter
|
8
|
+
def insert_item(op:, entity_id:)
|
9
|
+
create!(op: op, entity_id: entity_id)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
module RawInserter
|
14
|
+
def prepared_inserter_statement
|
15
|
+
@prepared_inserter_statement ||= begin
|
16
|
+
name = "postqueue-insert-{table_name}-#{Thread.current.object_id}"
|
17
|
+
connection.raw_connection.prepare(name, "INSERT INTO #{table_name}(op, entity_id) VALUES($1, $2)")
|
18
|
+
name
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def insert_item(op:, entity_id:)
|
23
|
+
connection.raw_connection.exec_prepared(prepared_inserter_statement, [op, entity_id])
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
extend RawInserter
|
28
|
+
end
|
29
|
+
end
|
data/lib/postqueue/item.rb
CHANGED
@@ -1,8 +1,19 @@
|
|
1
1
|
require "active_record"
|
2
2
|
|
3
3
|
module Postqueue
|
4
|
+
#
|
5
|
+
# An item class.
|
4
6
|
class Item < ActiveRecord::Base
|
5
7
|
self.table_name = :postqueue
|
8
|
+
|
9
|
+
def self.postpone(ids)
|
10
|
+
connection.exec_query <<-SQL
|
11
|
+
UPDATE #{table_name}
|
12
|
+
SET failed_attempts = failed_attempts+1,
|
13
|
+
next_run_at = next_run_at + power(failed_attempts + 1, 1.5) * interval '10 second'
|
14
|
+
WHERE id IN (#{ids.join(',')})
|
15
|
+
SQL
|
16
|
+
end
|
6
17
|
end
|
7
18
|
|
8
19
|
def self.unmigrate!(table_name = "postqueue")
|
@@ -33,3 +44,6 @@ module Postqueue
|
|
33
44
|
SQL
|
34
45
|
end
|
35
46
|
end
|
47
|
+
|
48
|
+
require_relative "item/inserter"
|
49
|
+
require_relative "item/enqueue"
|
@@ -0,0 +1,60 @@
|
|
1
|
+
module Postqueue
|
2
|
+
class MissingHandler < RuntimeError
|
3
|
+
attr_reader :queue, :op, :entity_ids
|
4
|
+
|
5
|
+
def initialize(queue:, op:, entity_ids:)
|
6
|
+
@queue = queue
|
7
|
+
@op = op
|
8
|
+
@entity_ids = entity_ids
|
9
|
+
end
|
10
|
+
|
11
|
+
def to_s
|
12
|
+
"#{queue.item_class.table_name}: Unknown operation #{op} with #{entity_ids.count} entities"
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
class Queue
|
17
|
+
Timing = Struct.new(:avg_queue_time, :max_queue_time, :total_processing_time, :processing_time)
|
18
|
+
|
19
|
+
def on(op, &block)
|
20
|
+
raise ArgumentError, "Invalid op #{op.inspect}, must be a string" unless op.is_a?(String)
|
21
|
+
callbacks[op] = block
|
22
|
+
self
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
def callbacks
|
28
|
+
@callbacks ||= {}
|
29
|
+
end
|
30
|
+
|
31
|
+
def callback_for(op:)
|
32
|
+
callbacks[op] || callbacks['*']
|
33
|
+
end
|
34
|
+
|
35
|
+
def on_missing_handler(op:, entity_ids:)
|
36
|
+
raise MissingHandler.new(queue: self, op: op, entity_ids: entity_ids)
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
def run_callback(op:, entity_ids:)
|
42
|
+
queue_times = item_class.find_by_sql <<-SQL
|
43
|
+
SELECT extract('epoch' from AVG(now() - created_at)) AS avg,
|
44
|
+
extract('epoch' from MAX(now() - created_at)) AS max
|
45
|
+
FROM #{item_class.table_name} WHERE entity_id IN (#{entity_ids.join(',')})
|
46
|
+
SQL
|
47
|
+
queue_time = queue_times.first
|
48
|
+
|
49
|
+
total_processing_time = Benchmark.realtime do
|
50
|
+
if callback = callback_for(op: op)
|
51
|
+
callback.call(op, entity_ids)
|
52
|
+
else
|
53
|
+
on_missing_handler(op: op, entity_ids: entity_ids)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
Timing.new(queue_time.avg, queue_time.max, total_processing_time, total_processing_time / entity_ids.length)
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module Postqueue
|
2
|
+
# The Postqueue processor processes items in a single Postqueue table.
|
3
|
+
class Queue
|
4
|
+
private
|
5
|
+
|
6
|
+
def on_processing(op, entity_ids, timing)
|
7
|
+
msg = "processing '#{op}' for id(s) #{entity_ids.join(',')}: "
|
8
|
+
msg += "processing #{entity_ids.length} items took #{'%.3f secs' % timing.total_processing_time}"
|
9
|
+
|
10
|
+
msg += ", queue_time: avg: #{'%.3f secs' % timing.avg_queue_time}/max: #{'%.3f secs' % timing.max_queue_time}"
|
11
|
+
logger.info msg
|
12
|
+
end
|
13
|
+
|
14
|
+
def on_exception(exception, op, entity_ids)
|
15
|
+
logger.warn "processing '#{op}' for id(s) #{entity_ids.inspect}: caught #{exception}"
|
16
|
+
end
|
17
|
+
|
18
|
+
def logger
|
19
|
+
Postqueue.logger
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
module Postqueue
|
2
|
+
# The Postqueue processor processes items in a single Postqueue table.
|
3
|
+
class Queue
|
4
|
+
# Processes up to batch_size entries
|
5
|
+
#
|
6
|
+
# process batch_size: 100
|
7
|
+
def process(op: nil, batch_size: 100)
|
8
|
+
item_class.transaction do
|
9
|
+
process_inside_transaction(op: op, batch_size: batch_size)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
# processes a single entry
|
14
|
+
def process_one(op: nil, &block)
|
15
|
+
process(op: op, batch_size: 1)
|
16
|
+
end
|
17
|
+
|
18
|
+
def process_until_empty(op: nil, batch_size: 100)
|
19
|
+
count = 0
|
20
|
+
loop do
|
21
|
+
processed_items = process(op: op, batch_size: batch_size)
|
22
|
+
break if processed_items == 0
|
23
|
+
count += processed_items
|
24
|
+
end
|
25
|
+
count
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
# The actual processing. Returns [ op, [ ids-of-processed-items ] ] or nil
|
31
|
+
def process_inside_transaction(op:, batch_size:)
|
32
|
+
items = select_and_lock_batch(op: op, max_batch_size: batch_size)
|
33
|
+
match = items.first
|
34
|
+
return 0 unless match
|
35
|
+
|
36
|
+
entity_ids = items.map(&:entity_id)
|
37
|
+
timing = run_callback(op: match.op, entity_ids: entity_ids)
|
38
|
+
|
39
|
+
on_processing(match.op, entity_ids, timing)
|
40
|
+
item_class.where(id: items.map(&:id)).delete_all
|
41
|
+
|
42
|
+
# even though we try not to enqueue duplicates we cannot guarantee that,
|
43
|
+
# since concurrent enqueue transactions might still insert duplicates.
|
44
|
+
# That's why we explicitely remove all non-failed duplicates here.
|
45
|
+
if idempotent_operation?(match.op)
|
46
|
+
duplicates = select_and_lock_duplicates(op: match.op, entity_ids: entity_ids)
|
47
|
+
item_class.where(id: duplicates.map(&:id)).delete_all unless duplicates.empty?
|
48
|
+
end
|
49
|
+
|
50
|
+
entity_ids.length
|
51
|
+
rescue => e
|
52
|
+
on_exception(e, match.op, entity_ids)
|
53
|
+
item_class.postpone items.map(&:id)
|
54
|
+
raise
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -1,15 +1,20 @@
|
|
1
1
|
module Postqueue
|
2
|
-
class
|
3
|
-
# Select and lock up to \a limit unlocked items in the queue.
|
2
|
+
class Queue
|
3
|
+
# Select and lock up to \a limit unlocked items in the queue. Used by
|
4
|
+
# select_and_lock_batch.
|
4
5
|
def select_and_lock(relation, limit:)
|
5
6
|
# Ordering by next_run_at and id should not strictly be necessary, but helps
|
6
7
|
# processing entries in the passed in order when enqueued at the same time.
|
7
|
-
relation = relation
|
8
|
+
relation = relation
|
9
|
+
.select(:id, :entity_id, :op)
|
10
|
+
.where("failed_attempts < ? AND next_run_at < ?", max_attemps, Time.now)
|
11
|
+
.order(:next_run_at, :id)
|
8
12
|
|
9
13
|
# FOR UPDATE SKIP LOCKED selects and locks entries, but skips those that
|
10
14
|
# are already locked - preventing this transaction from being locked.
|
11
15
|
sql = relation.to_sql + " FOR UPDATE SKIP LOCKED"
|
12
16
|
sql += " LIMIT #{limit}" if limit
|
17
|
+
|
13
18
|
item_class.find_by_sql(sql)
|
14
19
|
end
|
15
20
|
|
@@ -20,25 +25,35 @@ module Postqueue
|
|
20
25
|
# passed in, that one is chosen as a filter condition, otherwise the op value
|
21
26
|
# of the first queue entry is used insteatd.
|
22
27
|
#
|
23
|
-
# This method will at maximum select and lock batch_size items.
|
24
|
-
#
|
25
|
-
# that one is used instead.
|
26
|
-
|
28
|
+
# This method will at maximum select and lock \a batch_size items.
|
29
|
+
# If the \a batch_size configured in the queue is smaller than the value
|
30
|
+
# passed in here that one is used instead.
|
31
|
+
#
|
32
|
+
# Returns an array of item objects.
|
33
|
+
def select_and_lock_batch(op:, max_batch_size:)
|
27
34
|
relation = item_class.all
|
28
35
|
relation = relation.where(op: op) if op
|
29
36
|
|
30
37
|
match = select_and_lock(relation, limit: 1).first
|
31
38
|
return [] unless match
|
32
39
|
|
33
|
-
batch_size = calculate_batch_size(op: match.op, max_batch_size:
|
40
|
+
batch_size = calculate_batch_size(op: match.op, max_batch_size: max_batch_size)
|
34
41
|
return [ match ] if batch_size <= 1
|
35
42
|
|
36
43
|
batch_relation = relation.where(op: match.op)
|
37
44
|
select_and_lock(batch_relation, limit: batch_size)
|
38
45
|
end
|
39
46
|
|
47
|
+
def select_and_lock_duplicates(op:, entity_ids:)
|
48
|
+
raise ArgumentError, "Missing op argument" unless op
|
49
|
+
return [] if entity_ids.empty?
|
50
|
+
|
51
|
+
relation = item_class.where(op: op, entity_id: entity_ids)
|
52
|
+
select_and_lock(relation, limit: nil)
|
53
|
+
end
|
54
|
+
|
40
55
|
def calculate_batch_size(op:, max_batch_size:)
|
41
|
-
recommended_batch_size = batch_size(op: op)
|
56
|
+
recommended_batch_size = batch_size(op: op)
|
42
57
|
return 1 if recommended_batch_size < 2
|
43
58
|
return recommended_batch_size unless max_batch_size
|
44
59
|
max_batch_size < recommended_batch_size ? max_batch_size : recommended_batch_size
|
@@ -0,0 +1,61 @@
|
|
1
|
+
module Postqueue
|
2
|
+
class Queue
|
3
|
+
# The AR::Base class to use. You would only change this if you want to run
|
4
|
+
# the queue in a different database or in a different table.
|
5
|
+
attr_accessor :item_class
|
6
|
+
|
7
|
+
# The default batch size. Will be used if no specific batch size is defined
|
8
|
+
# for an operation.
|
9
|
+
attr_accessor :default_batch_size
|
10
|
+
|
11
|
+
# batch size for a given op
|
12
|
+
attr_reader :batch_sizes
|
13
|
+
|
14
|
+
# maximum number of processing attempts.
|
15
|
+
attr_reader :max_attemps
|
16
|
+
|
17
|
+
def async_processing?
|
18
|
+
@async_processing
|
19
|
+
end
|
20
|
+
|
21
|
+
attr_writer :async_processing
|
22
|
+
|
23
|
+
def initialize(&block)
|
24
|
+
@batch_sizes = {}
|
25
|
+
@item_class = ::Postqueue::Item
|
26
|
+
@default_batch_size = 1
|
27
|
+
@max_attemps = 5
|
28
|
+
@async_processing = Postqueue.async_processing?
|
29
|
+
|
30
|
+
yield self if block
|
31
|
+
end
|
32
|
+
|
33
|
+
def batch_size(op:)
|
34
|
+
batch_sizes[op] || default_batch_size || 1
|
35
|
+
end
|
36
|
+
|
37
|
+
def idempotent_operations
|
38
|
+
@idempotent_operations ||= {}
|
39
|
+
end
|
40
|
+
|
41
|
+
def idempotent_operation?(op)
|
42
|
+
idempotent_operations.fetch(op) { idempotent_operations.fetch('*', false) }
|
43
|
+
end
|
44
|
+
|
45
|
+
def idempotent_operation(op, flag = true)
|
46
|
+
idempotent_operations[op] = flag
|
47
|
+
end
|
48
|
+
|
49
|
+
def enqueue(op:, entity_id:)
|
50
|
+
enqueued_items = item_class.enqueue op: op, entity_id: entity_id, ignore_duplicates: idempotent_operation?(op)
|
51
|
+
return unless enqueued_items > 0
|
52
|
+
|
53
|
+
process_until_empty(op: op) unless async_processing?
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
require_relative "queue/select_and_lock"
|
59
|
+
require_relative "queue/processing"
|
60
|
+
require_relative "queue/callback"
|
61
|
+
require_relative "queue/logging"
|
data/lib/postqueue/version.rb
CHANGED
data/lib/postqueue.rb
CHANGED
@@ -1,8 +1,22 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
|
1
|
+
require_relative "postqueue/logger"
|
2
|
+
require_relative "postqueue/item"
|
3
|
+
require_relative "postqueue/version"
|
4
|
+
require_relative "postqueue/queue"
|
4
5
|
|
5
6
|
module Postqueue
|
7
|
+
def self.new(*args, &block)
|
8
|
+
::Postqueue::Queue.new(*args, &block)
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.async_processing=(async_processing)
|
12
|
+
@async_processing = async_processing
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.async_processing?
|
16
|
+
@async_processing
|
17
|
+
end
|
18
|
+
|
19
|
+
self.async_processing = true
|
6
20
|
end
|
7
21
|
|
8
|
-
#
|
22
|
+
# require_relative 'postqueue/railtie' if defined?(Rails)
|
@@ -0,0 +1,39 @@
|
|
1
|
+
module AdvisoryLock
|
2
|
+
ADVISORY_LOCK = self
|
3
|
+
|
4
|
+
def self.included(base)
|
5
|
+
base.extend ClassMethods
|
6
|
+
end
|
7
|
+
|
8
|
+
module ClassMethods
|
9
|
+
def exclusive(lock_identifier = self.class.name.hash, &block)
|
10
|
+
ADVISORY_LOCK.exclusive(lock_identifier, connection, &block)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
module Implementation
|
15
|
+
def exclusive(lock_identifier, connection = ActiveRecord::Base.connection, &_block)
|
16
|
+
if obtained_lock?(lock_identifier, connection)
|
17
|
+
begin
|
18
|
+
yield
|
19
|
+
ensure
|
20
|
+
release_lock(lock_identifier, connection)
|
21
|
+
end
|
22
|
+
else
|
23
|
+
raise "Cannot get lock #{lock_identifier.inspect}"
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def obtained_lock?(lock_identifier, connection)
|
30
|
+
connection.select_value("select pg_try_advisory_lock(#{lock_identifier})")
|
31
|
+
end
|
32
|
+
|
33
|
+
def release_lock(lock_identifier, connection)
|
34
|
+
connection.execute "select pg_advisory_unlock(#{lock_identifier})"
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
extend Implementation
|
39
|
+
end
|