postqueue 0.0.10 → 0.0.11

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 5ec904d462a89e0a99db3738cdeaac46cda9364d
4
- data.tar.gz: b45a746b40cfe5521aea1ce3d96dab1b746e3972
3
+ metadata.gz: 6cd82270501112669519b0a37ae116d9b5f2ae36
4
+ data.tar.gz: ae81665e57301d5998ff4493d5db54d393d2fe68
5
5
  SHA512:
6
- metadata.gz: d623d3d9bc87f4d17231b5954cb518d78f395171baa41ed4260edd757560f44b6aea98bf707f1db29708c85da95d9036476d06fc7acd72300e6fae465c5f6175
7
- data.tar.gz: e3ffd8e1933f5de86446314e69f15540cb99a045c3a783db241fb5e699a58fbd13f4e86193445b4e79803699a7eca2ad711aa1f8b834293fcb1407429aa61685
6
+ metadata.gz: 0d79ebc63c90da1834c3b867aabbcf05cb03d8d45e0a99e9c043620a61770958b186a0c98ea80f5a4786358581f7d4b5f0671ecd1fee87b2b1c893211477fd2e
7
+ data.tar.gz: 0366cf7aa5322d3becd0f9c5fa801fda0a8950cd433303b3a10265f41d3d93e480617a27aa8e8ea64f9f47212931977c8c2e210dbb227998f844639779cbb87d
data/README.md CHANGED
@@ -1,8 +1,93 @@
1
1
  # Postqueue
2
2
 
3
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/postqueue`. To experiment with that code, run `bin/console` for an interactive prompt.
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
- TODO: Delete this and the text above, and describe your gem
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. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
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 install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
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
@@ -1,6 +1,5 @@
1
1
  require 'postqueue/item'
2
- require 'postqueue/enqueue'
3
- require 'postqueue/processing'
2
+ require 'postqueue/base'
4
3
  require 'postqueue/version'
5
4
 
6
5
  module Postqueue
@@ -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
- module Enqueue
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 postqueue (op, entity_type, entity_id) VALUES($1, $2, $3)"
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
- Item.create!(op: op, entity_type: entity_type, entity_id: entity_id)
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
@@ -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 postqueue;
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 postqueue (
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 postqueue_idx1 ON postqueue(entity_id);
27
- CREATE INDEX postqueue_idx2 ON postqueue(next_run_at);
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
@@ -1,3 +1,3 @@
1
1
  module Postqueue
2
- VERSION = '0.0.10'
2
+ VERSION = '0.0.11'
3
3
  end
@@ -1,8 +1,10 @@
1
1
  require 'spec_helper'
2
2
 
3
- describe ::Postqueue::Enqueue do
3
+ describe 'enqueuing' do
4
+ let(:queue) { Postqueue.new }
5
+
4
6
  before do
5
- Postqueue.enqueue op: "myop", entity_type: "mytype", entity_id: 12
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 "::Postqueue.process" do
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
- Postqueue.enqueue op: "myop", entity_type: "mytype", entity_id: 12
7
- Postqueue.enqueue op: "myop", entity_type: "mytype", entity_id: 13
8
- Postqueue.enqueue op: "myop", entity_type: "mytype", entity_id: 14
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 = Postqueue.process limit: 1
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 = Postqueue.process limit: 2
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 = Postqueue.process
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
- Postqueue.enqueue op: "myop", entity_type: "mytype", entity_id: 12
33
- Postqueue.enqueue op: "myop", entity_type: "mytype", entity_id: 13
34
- Postqueue.enqueue op: "otherop", entity_type: "mytype", entity_id: 14
35
- Postqueue.enqueue op: "myop", entity_type: "othertype", entity_id: 15
36
- Postqueue.enqueue op: "otherop", entity_type: "othertype", entity_id: 16
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 = Postqueue.process limit: 1
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 = Postqueue.process limit: 2
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 = Postqueue.process
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 = Postqueue.process(where: { op: "otherop" })
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
- Postqueue.enqueue op: "myop", entity_type: "mytype", entity_id: 12
67
- Postqueue.enqueue op: "myop", entity_type: "mytype", entity_id: 13
68
- Postqueue.enqueue op: "myop", entity_type: "mytype", entity_id: 12
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 = Postqueue.process limit: 1
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 "::Postqueue.process_one" do
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
- Postqueue.enqueue op: "myop", entity_type: "mytype", entity_id: 12
9
+ queue.enqueue op: "myop", entity_type: "mytype", entity_id: 12
8
10
  end
9
11
 
10
- it "fails when block raises an exception and reraises the exception" do
11
- expect { Postqueue.process_one do |op, type, ids| raise E end }.to raise_error(E)
12
- expect(items.map(&:entity_id)).to contain_exactly(12)
13
- end
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
- it "fails when block returns false" do
16
- Postqueue.process_one do |op, type, ids| false end
17
- expect(items.map(&:entity_id)).to contain_exactly(12)
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
- it "keeps item in the queue after failure, with an increased failed_attempt count" do
21
- called_block = 0
22
- Postqueue.process_one do called_block += 1; false end
23
- expect(called_block).to eq(1)
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
- expect(items.map(&:entity_id)).to contain_exactly(12)
26
- expect(items.first.failed_attempts).to eq(1)
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
- it "ignores items with a failed_attempt count > MAX_ATTEMPTS" do
30
- expect(Postqueue::MAX_ATTEMPTS).to be >= 3
31
- items.update_all(failed_attempts: 3)
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
- called_block = 0
34
- r = Postqueue.process_one do called_block += 1; false end
35
- expect(r).to eq(nil)
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
- expect(items.map(&:entity_id)).to contain_exactly(12)
39
- expect(items.first.failed_attempts).to eq(3)
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 "::Postqueue.process_one" do
3
+ describe "::queue.process_one" do
4
+ let(:queue) { Postqueue.new }
5
+
4
6
  before do
5
- Postqueue.enqueue op: "myop", entity_type: "mytype", entity_id: 12
6
- Postqueue.enqueue op: "myop", entity_type: "mytype", entity_id: 13
7
- Postqueue.enqueue op: "myop", entity_type: "mytype", entity_id: 14
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 = Postqueue.process_one
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
- Postqueue.enqueue op: "otherop", entity_type: "mytype", entity_id: 112
21
+ queue.enqueue op: "otherop", entity_type: "mytype", entity_id: 112
18
22
 
19
- r = Postqueue.process_one(where: { op: "otherop" })
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
- Postqueue.enqueue op: "otherop", entity_type: "mytype", entity_id: 112
26
- r = Postqueue.process_one(where: { op: "otherop" }) do |op, type, ids|
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.10
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/enqueue.rb
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
@@ -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