postqueue 0.0.10 → 0.0.11
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 +95 -6
- data/lib/postqueue.rb +1 -2
- data/lib/postqueue/base.rb +32 -0
- data/lib/postqueue/{enqueue.rb → base/enqueue.rb} +3 -7
- data/lib/postqueue/base/processing.rb +127 -0
- data/lib/postqueue/item.rb +6 -6
- data/lib/postqueue/version.rb +1 -1
- data/spec/postqueue/enqueue_spec.rb +4 -2
- data/spec/postqueue/{process_spec.rb → idempotent_queue_spec.rb} +32 -26
- data/spec/postqueue/process_errors_spec.rb +56 -24
- data/spec/postqueue/process_one_spec.rb +13 -9
- data/spec/spec_helper.rb +8 -0
- metadata +5 -4
- data/lib/postqueue/processing.rb +0 -97
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 6cd82270501112669519b0a37ae116d9b5f2ae36
|
4
|
+
data.tar.gz: ae81665e57301d5998ff4493d5db54d393d2fe68
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 0d79ebc63c90da1834c3b867aabbcf05cb03d8d45e0a99e9c043620a61770958b186a0c98ea80f5a4786358581f7d4b5f0671ecd1fee87b2b1c893211477fd2e
|
7
|
+
data.tar.gz: 0366cf7aa5322d3becd0f9c5fa801fda0a8950cd433303b3a10265f41d3d93e480617a27aa8e8ea64f9f47212931977c8c2e210dbb227998f844639779cbb87d
|
data/README.md
CHANGED
@@ -1,8 +1,93 @@
|
|
1
1
|
# Postqueue
|
2
2
|
|
3
|
-
|
3
|
+
The postqueue gem implements a simple to use queue on top of postgresql. Since this implementation is using the SKIP LOCKED
|
4
|
+
syntax, it needs PostgresQL >= 9.5.
|
4
5
|
|
5
|
-
|
6
|
+
Why not using another queue implementation? postqueue comes with some extras:
|
7
|
+
|
8
|
+
- support for optimal handling of idempotent operations
|
9
|
+
- batch processing
|
10
|
+
- searchable via SQL
|
11
|
+
|
12
|
+
## Basic usage
|
13
|
+
|
14
|
+
```ruby
|
15
|
+
queue = PostgresQL::Base.new
|
16
|
+
queue.enqueue op: "myop", entity_type: "mytype", entity_id: 12
|
17
|
+
queue.process do |op, entity_type, entity_ids|
|
18
|
+
# note: entity_ids is always an Array of ids.
|
19
|
+
case "#{op}/#{entity_type}"
|
20
|
+
when "reindex/product"
|
21
|
+
Product.index_many(Product.where(id: entity_ids))
|
22
|
+
else
|
23
|
+
raise "Unsupported op/entity_type: #{op}/#{entity_type}"
|
24
|
+
end
|
25
|
+
end
|
26
|
+
```
|
27
|
+
|
28
|
+
The callback will receive the `op` and `entity_type` attributes and the `entity_ids` of all queue entries
|
29
|
+
selected for processing. If the block fails, by either returning `false` or by raising an exception the
|
30
|
+
queue entries are postponed a bit, up until `Postqueue::MAX_ATTEMPTS` times (which currently is defined as 5).
|
31
|
+
|
32
|
+
The method will return the return value of the block.
|
33
|
+
|
34
|
+
If no callback is given the return value will be the `[op, entity_type, entity_ids]` values that would have
|
35
|
+
been sent to the block. This is highly unrecommended though, since when using a block to do processing errors
|
36
|
+
and exceptions can properly be dealt with.
|
37
|
+
|
38
|
+
Postqueue.process also accepts the following arguments:
|
39
|
+
|
40
|
+
- `entity_type`: only process entries with this `entity_type`;
|
41
|
+
- `op`: only process entries with this `op` value;
|
42
|
+
- `batch_size`: when the first matching entry supports batch processing limit the size of the batch to the bassed in size.
|
43
|
+
|
44
|
+
Example:
|
45
|
+
|
46
|
+
Postqueue.process(entity_type: 'Product', batch_size: 10) do |op, entity_type, entity_ids|
|
47
|
+
# only handle Product entries
|
48
|
+
end
|
49
|
+
|
50
|
+
If the queue is empty or no matching queue entry could be found, both `Postqueue.process` and `Postqueue.process_one` will return nil.
|
51
|
+
|
52
|
+
### process a single entry
|
53
|
+
|
54
|
+
For the simple just-give-me-the-next use case there is a shortcut, which only processed the first matching entry. Under the hood this calls Postqueue.process with `batch_size` set to `1`.
|
55
|
+
|
56
|
+
Postqueue.process_one do |op, entity_type, entity_ids|
|
57
|
+
end
|
58
|
+
|
59
|
+
Note that even though `process_one` will only ever process a single entry the `entity_ids` parameter to the block is still an array (holding a single ID in that case).
|
60
|
+
|
61
|
+
## idempotent operations
|
62
|
+
|
63
|
+
If queue items represent idempotent operations they need not be run repeatedly, but only once. To implement idempotent
|
64
|
+
operations, subclass `Postqueue::Base` and reimplement the `idempotent?` method. The following marks all "reindex"
|
65
|
+
ops as idempotent:
|
66
|
+
|
67
|
+
class Testqueue < Postqueue::Base
|
68
|
+
def idempotent?(entity_type:,op:)
|
69
|
+
op == "reindex"
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
## batch processing
|
74
|
+
|
75
|
+
Often queue items can be processed in batches for a better performance of the entire system. To enable
|
76
|
+
batch processing for some items subclass `Postqueue::Base` and reimplement the `batch_size?` method
|
77
|
+
to return a suggested batch_size for a specific operation. The following implements a batch_size of 100
|
78
|
+
for all queue entries:
|
79
|
+
|
80
|
+
class Testqueue < Postqueue::Base
|
81
|
+
def batch_size(entity_type:,op:)
|
82
|
+
100
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
## Searchable via SQL
|
87
|
+
|
88
|
+
In contrast to other queue implementations available for Rubyists this queue formats entries in a way that
|
89
|
+
makes it possible to query the queue via SQL. On the other hand this queue also does not allow to
|
90
|
+
enqueue arbitrary entries as these others do.
|
6
91
|
|
7
92
|
## Installation
|
8
93
|
|
@@ -22,13 +107,17 @@ Or install it yourself as:
|
|
22
107
|
|
23
108
|
## Usage
|
24
109
|
|
25
|
-
TODO: Write usage instructions here
|
26
|
-
|
27
110
|
## Development
|
28
111
|
|
29
|
-
After checking out the repo, run `bin/setup` to install dependencies.
|
112
|
+
After checking out the repo, run `bin/setup` to install dependencies. Make sure you have a local postgresql implementation of
|
113
|
+
at least version 9.5. Add a `postqueue` user with a `postqueue` password, and create a `postqueue_test` database for it.
|
114
|
+
The script `./scripts/prepare_pg` can be helpful in establishing that.
|
115
|
+
|
116
|
+
Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
117
|
+
|
118
|
+
To install this gem onto your local machine, run `bundle exec rake install`.
|
30
119
|
|
31
|
-
To
|
120
|
+
To release a new version, run `./scripts/release`, which will bump the version number, create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
32
121
|
|
33
122
|
## Contributing
|
34
123
|
|
data/lib/postqueue.rb
CHANGED
@@ -0,0 +1,32 @@
|
|
1
|
+
module Postqueue
|
2
|
+
class Base
|
3
|
+
private
|
4
|
+
|
5
|
+
def item_class
|
6
|
+
Postqueue::Item
|
7
|
+
end
|
8
|
+
|
9
|
+
def logger
|
10
|
+
Postqueue.logger
|
11
|
+
end
|
12
|
+
|
13
|
+
def on_processing(op, entity_type, entity_ids, runtime, queue_time)
|
14
|
+
logger.info "processing '#{op}/#{entity_type}' for id(s) #{entity_ids.join(",")}: processing #{entity_ids.length} items took #{"%.3f msecs" % runtime}, queue_time: avg: #{"%.3f msecs" % queue_time.avg}/max: #{"%.3f msecs" % queue_time.max}"
|
15
|
+
end
|
16
|
+
|
17
|
+
def on_exception(exception, op, entity_type, entity_ids)
|
18
|
+
logger.warn "processing '#{op}/#{entity_type}' for id(s) #{entity_ids.join(",")}: caught #{exception}"
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.logger
|
23
|
+
Logger.new(STDERR)
|
24
|
+
end
|
25
|
+
|
26
|
+
def self.new
|
27
|
+
Base.new
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
require 'postqueue/base/enqueue'
|
32
|
+
require 'postqueue/base/processing'
|
@@ -1,12 +1,10 @@
|
|
1
1
|
module Postqueue
|
2
|
-
|
3
|
-
Item = ::Postqueue::Item
|
4
|
-
|
2
|
+
class Base
|
5
3
|
def enqueue(op:, entity_type:, entity_id:)
|
6
4
|
# An optimized code path, as laid out below, is 4 times as fast.
|
7
5
|
# However, exec_query changed from Rails 4 to Rails 5.
|
8
6
|
|
9
|
-
# sql = "INSERT INTO
|
7
|
+
# sql = "INSERT INTO #{item_class.table_name} (op, entity_type, entity_id) VALUES($1, $2, $3)"
|
10
8
|
# binds = [ ]
|
11
9
|
#
|
12
10
|
# binds << ActiveRecord::Attribute.from_user("name", op, ::ActiveRecord::Type::String.new)
|
@@ -15,9 +13,7 @@ module Postqueue
|
|
15
13
|
# # Note: Rails 4 does not understand prepare: true
|
16
14
|
# db.exec_query(sql, 'SQL', binds, prepare: true)
|
17
15
|
|
18
|
-
|
16
|
+
item_class.create!(op: op, entity_type: entity_type, entity_id: entity_id)
|
19
17
|
end
|
20
18
|
end
|
21
|
-
|
22
|
-
extend Enqueue
|
23
19
|
end
|
@@ -0,0 +1,127 @@
|
|
1
|
+
module Postqueue
|
2
|
+
MAX_ATTEMPTS = 5
|
3
|
+
|
4
|
+
class Base
|
5
|
+
|
6
|
+
# Processes many entries
|
7
|
+
#
|
8
|
+
# process batch_size: 100
|
9
|
+
def process(entity_type:nil, op:nil, batch_size:100, &block)
|
10
|
+
status, result = item_class.transaction do
|
11
|
+
process_inside_transaction(entity_type: entity_type, op: op, batch_size: batch_size, &block)
|
12
|
+
end
|
13
|
+
|
14
|
+
raise result if status == :err
|
15
|
+
result
|
16
|
+
end
|
17
|
+
|
18
|
+
def process_one(entity_type:nil, op:nil, &block)
|
19
|
+
process(entity_type: entity_type, op: op, batch_size: 1, &block)
|
20
|
+
end
|
21
|
+
|
22
|
+
def idempotent?(entity_type:, op:)
|
23
|
+
false
|
24
|
+
end
|
25
|
+
|
26
|
+
def batch_size(entity_type:, op:)
|
27
|
+
10
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
# Select and lock up to \a limit unlocked items in the queue.
|
33
|
+
def select_and_lock(relation, limit:)
|
34
|
+
relation = relation.where("failed_attempts < ? AND next_run_at < ?", MAX_ATTEMPTS, Time.now).order(:next_run_at, :id)
|
35
|
+
|
36
|
+
sql = relation.to_sql + " FOR UPDATE SKIP LOCKED"
|
37
|
+
sql += " LIMIT #{limit}" if limit
|
38
|
+
items = item_class.find_by_sql(sql)
|
39
|
+
|
40
|
+
items
|
41
|
+
end
|
42
|
+
|
43
|
+
def calculate_batch_size(op:, entity_type:, batch_size:)
|
44
|
+
processor_batch_size = self.batch_size(op: op, entity_type: entity_type)
|
45
|
+
if !processor_batch_size || processor_batch_size < 2
|
46
|
+
1
|
47
|
+
elsif(!batch_size)
|
48
|
+
processor_batch_size
|
49
|
+
else
|
50
|
+
[ processor_batch_size, batch_size ].min
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
# The actual processing. Returns [ :ok, number-of-items ] or [ :err, exception ]
|
55
|
+
def process_inside_transaction(entity_type:, op:, batch_size:, &block)
|
56
|
+
relation = item_class.all
|
57
|
+
relation = relation.where(entity_type: entity_type) if entity_type
|
58
|
+
relation = relation.where(op: op) if op
|
59
|
+
|
60
|
+
first_match = select_and_lock(relation, limit: 1).first
|
61
|
+
return [ :ok, nil ] unless first_match
|
62
|
+
op, entity_type = first_match.op, first_match.entity_type
|
63
|
+
|
64
|
+
# determine batch to process. Whether or not an operation can be batched is defined
|
65
|
+
# by the Base#batch_size method. If that signals batch processing by returning a
|
66
|
+
# number > 0, then the passed in batch_size provides an additional upper limit.
|
67
|
+
batch_size = calculate_batch_size(op: op, entity_type: entity_type, batch_size: batch_size)
|
68
|
+
if batch_size > 1
|
69
|
+
batch_relation = relation.where(entity_type: entity_type, op: op)
|
70
|
+
batch = select_and_lock(batch_relation, limit: batch_size)
|
71
|
+
else
|
72
|
+
batch = [ first_match ]
|
73
|
+
end
|
74
|
+
|
75
|
+
entity_ids = batch.map(&:entity_id)
|
76
|
+
|
77
|
+
# If the current operation is idempotent we will mark additional queue items as
|
78
|
+
# in process.
|
79
|
+
if idempotent?(op: op, entity_type: entity_type)
|
80
|
+
entity_ids.uniq!
|
81
|
+
process_relations = relation.where(entity_type: entity_type, op: op, entity_id: entity_ids)
|
82
|
+
items_in_processing = select_and_lock(process_relations, limit: nil)
|
83
|
+
else
|
84
|
+
items_in_processing = batch
|
85
|
+
end
|
86
|
+
|
87
|
+
items_in_processing_ids = items_in_processing.map(&:id)
|
88
|
+
|
89
|
+
queue_times = item_class.find_by_sql <<-SQL
|
90
|
+
SELECT extract('epoch' from AVG(now() - created_at)) AS avg,
|
91
|
+
extract('epoch' from MAX(now() - created_at)) AS max
|
92
|
+
FROM #{item_class.table_name} WHERE entity_id IN (#{entity_ids.join(",")})
|
93
|
+
SQL
|
94
|
+
queue_time = queue_times.first
|
95
|
+
|
96
|
+
# run callback.
|
97
|
+
result = [ op, entity_type, entity_ids ]
|
98
|
+
|
99
|
+
processing_time = Benchmark.realtime do
|
100
|
+
result = yield *result if block_given?
|
101
|
+
end
|
102
|
+
|
103
|
+
# Depending on the result either reprocess or delete all items
|
104
|
+
if result == false
|
105
|
+
postpone items_in_processing_ids
|
106
|
+
else
|
107
|
+
on_processing(op, entity_type, entity_ids, processing_time, queue_time)
|
108
|
+
item_class.where(id: items_in_processing_ids).delete_all
|
109
|
+
end
|
110
|
+
|
111
|
+
[ :ok, result ]
|
112
|
+
rescue => e
|
113
|
+
on_exception(e, op, entity_type, entity_ids)
|
114
|
+
postpone items_in_processing_ids
|
115
|
+
[ :err, e ]
|
116
|
+
end
|
117
|
+
|
118
|
+
def postpone(ids)
|
119
|
+
item_class.connection.exec_query <<-SQL
|
120
|
+
UPDATE #{item_class.table_name}
|
121
|
+
SET failed_attempts = failed_attempts+1,
|
122
|
+
next_run_at = next_run_at + power(failed_attempts + 1, 1.5) * interval '10 second'
|
123
|
+
WHERE id IN (#{ids.join(",")})
|
124
|
+
SQL
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
data/lib/postqueue/item.rb
CHANGED
@@ -5,15 +5,15 @@ module Postqueue
|
|
5
5
|
self.table_name = :postqueue
|
6
6
|
end
|
7
7
|
|
8
|
-
def self.unmigrate!
|
8
|
+
def self.unmigrate!(table_name = "postqueue")
|
9
9
|
Item.connection.execute <<-SQL
|
10
|
-
DROP TABLE IF EXISTS
|
10
|
+
DROP TABLE IF EXISTS #{table_name};
|
11
11
|
SQL
|
12
12
|
end
|
13
13
|
|
14
|
-
def self.migrate!
|
14
|
+
def self.migrate!(table_name = "postqueue")
|
15
15
|
Item.connection.execute <<-SQL
|
16
|
-
CREATE TABLE
|
16
|
+
CREATE TABLE #{table_name} (
|
17
17
|
id SERIAL PRIMARY KEY,
|
18
18
|
op VARCHAR,
|
19
19
|
entity_type VARCHAR,
|
@@ -23,8 +23,8 @@ module Postqueue
|
|
23
23
|
failed_attempts INTEGER NOT NULL DEFAULT 0
|
24
24
|
);
|
25
25
|
|
26
|
-
CREATE INDEX
|
27
|
-
CREATE INDEX
|
26
|
+
CREATE INDEX #{table_name}_idx1 ON #{table_name}(entity_id);
|
27
|
+
CREATE INDEX #{table_name}_idx2 ON #{table_name}(next_run_at);
|
28
28
|
SQL
|
29
29
|
end
|
30
30
|
end
|
data/lib/postqueue/version.rb
CHANGED
@@ -1,8 +1,10 @@
|
|
1
1
|
require 'spec_helper'
|
2
2
|
|
3
|
-
describe
|
3
|
+
describe 'enqueuing' do
|
4
|
+
let(:queue) { Postqueue.new }
|
5
|
+
|
4
6
|
before do
|
5
|
-
|
7
|
+
queue.enqueue op: "myop", entity_type: "mytype", entity_id: 12
|
6
8
|
end
|
7
9
|
|
8
10
|
let(:item) { Postqueue::Item.first }
|
@@ -1,27 +1,39 @@
|
|
1
1
|
require 'spec_helper'
|
2
2
|
|
3
|
-
describe "
|
3
|
+
describe "Idempotent queue" do
|
4
|
+
class Testqueue < Postqueue::Base
|
5
|
+
def idempotent?(entity_type:,op:)
|
6
|
+
true
|
7
|
+
end
|
8
|
+
|
9
|
+
def batch_size(entity_type:,op:)
|
10
|
+
100
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
let(:queue) { Testqueue.new }
|
15
|
+
|
4
16
|
context 'when having entries with the same entity_type and op' do
|
5
17
|
before do
|
6
|
-
|
7
|
-
|
8
|
-
|
18
|
+
queue.enqueue op: "myop", entity_type: "mytype", entity_id: 12
|
19
|
+
queue.enqueue op: "myop", entity_type: "mytype", entity_id: 13
|
20
|
+
queue.enqueue op: "myop", entity_type: "mytype", entity_id: 14
|
9
21
|
end
|
10
22
|
|
11
23
|
it "processes one entries" do
|
12
|
-
r =
|
24
|
+
r = queue.process batch_size: 1
|
13
25
|
expect(r).to eq(["myop", "mytype", [12]])
|
14
26
|
expect(items.map(&:entity_id)).to contain_exactly(13, 14)
|
15
27
|
end
|
16
28
|
|
17
29
|
it "processes two entries" do
|
18
|
-
r =
|
30
|
+
r = queue.process batch_size: 2
|
19
31
|
expect(r).to eq(["myop", "mytype", [12, 13]])
|
20
32
|
expect(items.map(&:entity_id)).to contain_exactly(14)
|
21
33
|
end
|
22
34
|
|
23
35
|
it "processes many entries" do
|
24
|
-
r =
|
36
|
+
r = queue.process
|
25
37
|
expect(r).to eq(["myop", "mytype", [12, 13, 14]])
|
26
38
|
expect(items.map(&:entity_id)).to contain_exactly()
|
27
39
|
end
|
@@ -29,33 +41,33 @@ describe "::Postqueue.process" do
|
|
29
41
|
|
30
42
|
context 'when having entries with different entity_type and op' do
|
31
43
|
before do
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
44
|
+
queue.enqueue op: "myop", entity_type: "mytype", entity_id: 12
|
45
|
+
queue.enqueue op: "myop", entity_type: "mytype", entity_id: 13
|
46
|
+
queue.enqueue op: "otherop", entity_type: "mytype", entity_id: 14
|
47
|
+
queue.enqueue op: "myop", entity_type: "othertype", entity_id: 15
|
48
|
+
queue.enqueue op: "otherop", entity_type: "othertype", entity_id: 16
|
37
49
|
end
|
38
50
|
|
39
51
|
it "processes one entries" do
|
40
|
-
r =
|
52
|
+
r = queue.process batch_size: 1
|
41
53
|
expect(r).to eq(["myop", "mytype", [12]])
|
42
54
|
expect(items.map(&:entity_id)).to contain_exactly(13, 14, 15, 16)
|
43
55
|
end
|
44
56
|
|
45
57
|
it "processes two entries" do
|
46
|
-
r =
|
58
|
+
r = queue.process batch_size: 2
|
47
59
|
expect(r).to eq(["myop", "mytype", [12, 13]])
|
48
60
|
expect(items.map(&:entity_id)).to contain_exactly(14, 15, 16)
|
49
61
|
end
|
50
62
|
|
51
63
|
it "processes only matching entries when asked for more" do
|
52
|
-
r =
|
64
|
+
r = queue.process
|
53
65
|
expect(r).to eq(["myop", "mytype", [12, 13]])
|
54
66
|
expect(items.map(&:entity_id)).to contain_exactly(14, 15, 16)
|
55
67
|
end
|
56
68
|
|
57
69
|
it "honors search conditions" do
|
58
|
-
r =
|
70
|
+
r = queue.process(op: "otherop")
|
59
71
|
expect(r).to eq(["otherop", "mytype", [14]])
|
60
72
|
expect(items.map(&:entity_id)).to contain_exactly(12, 13, 15, 16)
|
61
73
|
end
|
@@ -63,21 +75,15 @@ describe "::Postqueue.process" do
|
|
63
75
|
|
64
76
|
context 'when having duplicate entries' do
|
65
77
|
before do
|
66
|
-
|
67
|
-
|
68
|
-
|
78
|
+
queue.enqueue op: "myop", entity_type: "mytype", entity_id: 12
|
79
|
+
queue.enqueue op: "myop", entity_type: "mytype", entity_id: 13
|
80
|
+
queue.enqueue op: "myop", entity_type: "mytype", entity_id: 12
|
69
81
|
end
|
70
82
|
|
71
83
|
it "removes duplicates from the queue" do
|
72
|
-
r =
|
84
|
+
r = queue.process batch_size: 1
|
73
85
|
expect(r).to eq(["myop", "mytype", [12]])
|
74
86
|
expect(items.map(&:entity_id)).to contain_exactly(13)
|
75
87
|
end
|
76
|
-
|
77
|
-
it "does not remove duplicates when skip_duplicates is set to false" do
|
78
|
-
r = Postqueue.process limit: 1, skip_duplicates: false
|
79
|
-
expect(r).to eq(["myop", "mytype", [12]])
|
80
|
-
expect(items.map(&:entity_id)).to contain_exactly(13, 12)
|
81
|
-
end
|
82
88
|
end
|
83
89
|
end
|
@@ -1,41 +1,73 @@
|
|
1
1
|
require 'spec_helper'
|
2
2
|
|
3
|
-
describe "::
|
3
|
+
describe "::queue.process_one" do
|
4
|
+
let(:queue) { Postqueue.new }
|
5
|
+
|
4
6
|
class E < RuntimeError; end
|
5
7
|
|
6
8
|
before do
|
7
|
-
|
9
|
+
queue.enqueue op: "myop", entity_type: "mytype", entity_id: 12
|
8
10
|
end
|
9
11
|
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
12
|
+
context "block raises an exception" do
|
13
|
+
before do
|
14
|
+
expect { queue.process_one do |op, type, ids| raise E end }.to raise_error(E)
|
15
|
+
end
|
16
|
+
|
17
|
+
it "reraises the exception" do
|
18
|
+
# checked in before block
|
19
|
+
end
|
14
20
|
|
15
|
-
|
16
|
-
|
17
|
-
|
21
|
+
it "keeps the item in the queue" do
|
22
|
+
expect(items.map(&:entity_id)).to contain_exactly(12)
|
23
|
+
end
|
24
|
+
|
25
|
+
it "increments the failed_attempt count" do
|
26
|
+
expect(items.map(&:failed_attempts)).to contain_exactly(1)
|
27
|
+
end
|
18
28
|
end
|
19
29
|
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
30
|
+
context "block returns false" do
|
31
|
+
before do
|
32
|
+
@result = queue.process_one do |op, type, ids| false end
|
33
|
+
end
|
34
|
+
|
35
|
+
it "returns false" do
|
36
|
+
expect(@result).to be false
|
37
|
+
end
|
24
38
|
|
25
|
-
|
26
|
-
|
39
|
+
it "keeps the item in the queue" do
|
40
|
+
expect(items.map(&:entity_id)).to contain_exactly(12)
|
41
|
+
end
|
42
|
+
|
43
|
+
it "increments the failed_attempt count" do
|
44
|
+
expect(items.map(&:failed_attempts)).to contain_exactly(1)
|
45
|
+
end
|
27
46
|
end
|
28
47
|
|
29
|
-
|
30
|
-
|
31
|
-
|
48
|
+
context "failed_attempts reached MAX_ATTEMPTS" do
|
49
|
+
before do
|
50
|
+
expect(Postqueue::MAX_ATTEMPTS).to be >= 3
|
51
|
+
items.update_all(failed_attempts: Postqueue::MAX_ATTEMPTS)
|
52
|
+
|
53
|
+
@called_block = 0
|
54
|
+
@result = queue.process_one do called_block += 1; false end
|
55
|
+
end
|
56
|
+
|
57
|
+
it "does not call the block" do
|
58
|
+
expect(@called_block).to eq(0)
|
59
|
+
end
|
60
|
+
|
61
|
+
it "returns nil" do
|
62
|
+
expect(@result).to eq(nil)
|
63
|
+
end
|
32
64
|
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
expect(called_block).to eq(0)
|
65
|
+
it "does not remove the item" do
|
66
|
+
expect(items.map(&:entity_id)).to contain_exactly(12)
|
67
|
+
end
|
37
68
|
|
38
|
-
|
39
|
-
|
69
|
+
it "does not increment the failed_attempts count" do
|
70
|
+
expect(items.first.failed_attempts).to eq(Postqueue::MAX_ATTEMPTS)
|
71
|
+
end
|
40
72
|
end
|
41
73
|
end
|
@@ -1,29 +1,33 @@
|
|
1
1
|
require 'spec_helper'
|
2
2
|
|
3
|
-
describe "::
|
3
|
+
describe "::queue.process_one" do
|
4
|
+
let(:queue) { Postqueue.new }
|
5
|
+
|
4
6
|
before do
|
5
|
-
|
6
|
-
|
7
|
-
|
7
|
+
queue.enqueue op: "myop", entity_type: "mytype", entity_id: 12
|
8
|
+
queue.enqueue op: "myop", entity_type: "mytype", entity_id: 13
|
9
|
+
queue.enqueue op: "myop", entity_type: "mytype", entity_id: 14
|
8
10
|
end
|
9
11
|
|
12
|
+
let(:processor) { Postqueue::Processor.new }
|
13
|
+
|
10
14
|
it "processes one entry" do
|
11
|
-
r =
|
15
|
+
r = queue.process_one
|
12
16
|
expect(r).to eq(["myop", "mytype", [12]])
|
13
17
|
expect(items.map(&:entity_id)).to contain_exactly(13, 14)
|
14
18
|
end
|
15
19
|
|
16
20
|
it "honors search conditions" do
|
17
|
-
|
21
|
+
queue.enqueue op: "otherop", entity_type: "mytype", entity_id: 112
|
18
22
|
|
19
|
-
r =
|
23
|
+
r = queue.process_one(op: "otherop")
|
20
24
|
expect(r).to eq(["otherop", "mytype", [112]])
|
21
25
|
expect(items.map(&:entity_id)).to contain_exactly(12, 13, 14)
|
22
26
|
end
|
23
27
|
|
24
28
|
it "yields a block and returns it" do
|
25
|
-
|
26
|
-
r =
|
29
|
+
queue.enqueue op: "otherop", entity_type: "mytype", entity_id: 112
|
30
|
+
r = queue.process_one(op: "otherop") do |op, type, ids|
|
27
31
|
expect(op).to eq("otherop")
|
28
32
|
expect(type).to eq("mytype")
|
29
33
|
expect(ids).to eq([112])
|
data/spec/spec_helper.rb
CHANGED
@@ -14,6 +14,14 @@ end
|
|
14
14
|
require 'postqueue'
|
15
15
|
require './spec/support/configure_active_record'
|
16
16
|
|
17
|
+
$logger = Logger.new(File.open("log/test.log", "a"))
|
18
|
+
|
19
|
+
module Postqueue
|
20
|
+
def self.logger
|
21
|
+
$logger
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
17
25
|
def items
|
18
26
|
Postqueue::Item.all
|
19
27
|
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: postqueue
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0.
|
4
|
+
version: 0.0.11
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- radiospiel
|
@@ -131,15 +131,16 @@ extra_rdoc_files: []
|
|
131
131
|
files:
|
132
132
|
- README.md
|
133
133
|
- lib/postqueue.rb
|
134
|
-
- lib/postqueue/
|
134
|
+
- lib/postqueue/base.rb
|
135
|
+
- lib/postqueue/base/enqueue.rb
|
136
|
+
- lib/postqueue/base/processing.rb
|
135
137
|
- lib/postqueue/item.rb
|
136
|
-
- lib/postqueue/processing.rb
|
137
138
|
- lib/postqueue/version.rb
|
138
139
|
- spec/postqueue/enqueue_spec.rb
|
140
|
+
- spec/postqueue/idempotent_queue_spec.rb
|
139
141
|
- spec/postqueue/postqueue_spec.rb
|
140
142
|
- spec/postqueue/process_errors_spec.rb
|
141
143
|
- spec/postqueue/process_one_spec.rb
|
142
|
-
- spec/postqueue/process_spec.rb
|
143
144
|
- spec/spec_helper.rb
|
144
145
|
- spec/support/configure_active_record.rb
|
145
146
|
- spec/support/models.rb
|
data/lib/postqueue/processing.rb
DELETED
@@ -1,97 +0,0 @@
|
|
1
|
-
module Postqueue
|
2
|
-
MAX_ATTEMPTS = 3
|
3
|
-
|
4
|
-
module Processing
|
5
|
-
# Processes many entries
|
6
|
-
#
|
7
|
-
# process limit: 100, skip_duplicates: true
|
8
|
-
def process(options = {}, &block)
|
9
|
-
limit = options.fetch(:limit, 100)
|
10
|
-
skip_duplicates = options.fetch(:skip_duplicates, true)
|
11
|
-
options.delete :limit
|
12
|
-
options.delete :skip_duplicates
|
13
|
-
|
14
|
-
status, result = Item.transaction do
|
15
|
-
process_inside_transaction options, limit: limit, skip_duplicates: skip_duplicates, &block
|
16
|
-
end
|
17
|
-
|
18
|
-
raise result if status == :err
|
19
|
-
result
|
20
|
-
end
|
21
|
-
|
22
|
-
# Process a single entry from the queue
|
23
|
-
#
|
24
|
-
# Example:
|
25
|
-
#
|
26
|
-
# process_one do |op, entity_type
|
27
|
-
# end
|
28
|
-
def process_one(options = {}, &block)
|
29
|
-
options = options.merge(limit: 1, skip_duplicates: false)
|
30
|
-
process options, &block
|
31
|
-
end
|
32
|
-
|
33
|
-
private
|
34
|
-
|
35
|
-
def select_and_lock(relation, limit:)
|
36
|
-
relation = relation.where("failed_attempts < ? AND next_run_at < ?", MAX_ATTEMPTS, Time.now).order(:next_run_at, :id)
|
37
|
-
|
38
|
-
sql = relation.to_sql + " FOR UPDATE SKIP LOCKED"
|
39
|
-
sql += " LIMIT #{limit}" if limit
|
40
|
-
items = Item.find_by_sql(sql)
|
41
|
-
|
42
|
-
items
|
43
|
-
end
|
44
|
-
|
45
|
-
# The actual processing. Returns [ :ok, number-of-items ] or [ :err, exception ]
|
46
|
-
def process_inside_transaction(options, limit:, skip_duplicates:, &block)
|
47
|
-
relation = Item.all
|
48
|
-
relation = relation.where(options[:where]) if options[:where]
|
49
|
-
|
50
|
-
first_match = select_and_lock(relation, limit: 1).first
|
51
|
-
return [ :ok, nil ] unless first_match
|
52
|
-
|
53
|
-
# find all matching entries with the same entity_type/op value
|
54
|
-
if limit > 1
|
55
|
-
batch_relation = relation.where(entity_type: first_match.entity_type, op: first_match.op)
|
56
|
-
matches = select_and_lock(batch_relation, limit: limit)
|
57
|
-
else
|
58
|
-
matches = [ first_match ]
|
59
|
-
end
|
60
|
-
|
61
|
-
entity_ids = matches.map(&:entity_id)
|
62
|
-
|
63
|
-
# When skipping dupes we'll find and lock all entries that match entity_type,
|
64
|
-
# op, and one of the entity_ids in the first batch of matches
|
65
|
-
if skip_duplicates
|
66
|
-
entity_ids.uniq!
|
67
|
-
process_relations = relation.where(entity_type: first_match.entity_type, op: first_match.op, entity_id: entity_ids)
|
68
|
-
process_items = select_and_lock process_relations, limit: nil
|
69
|
-
else
|
70
|
-
process_items = matches
|
71
|
-
end
|
72
|
-
|
73
|
-
# Actually process the queue items
|
74
|
-
result = [ first_match.op, first_match.entity_type, entity_ids ]
|
75
|
-
result = yield *result if block_given?
|
76
|
-
|
77
|
-
if result == false
|
78
|
-
postpone process_items
|
79
|
-
else
|
80
|
-
Item.where(id: process_items.map(&:id)).delete_all
|
81
|
-
end
|
82
|
-
|
83
|
-
[ :ok, result ]
|
84
|
-
rescue => e
|
85
|
-
postpone process_items
|
86
|
-
[ :err, e ]
|
87
|
-
end
|
88
|
-
|
89
|
-
def postpone(items)
|
90
|
-
ids = items.map(&:id)
|
91
|
-
sql = "UPDATE postqueue SET failed_attempts = failed_attempts+1, next_run_at = next_run_at + interval '10 second' WHERE id IN (#{ids.join(",")})"
|
92
|
-
Item.connection.exec_query(sql)
|
93
|
-
end
|
94
|
-
end
|
95
|
-
|
96
|
-
extend Processing
|
97
|
-
end
|