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 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