├── .codeclimate.yml
├── .dockerignore
├── .gemrelease
├── .gitignore
├── .rspec
├── .rubocop.yml
├── .travis.yml
├── Appraisals
├── Dockerfile
├── Gemfile
├── LICENSE
├── README.md
├── Rakefile
├── app
├── Gemfile
├── Gemfile.lock
├── app.rb
├── config.ru
├── sidekiq.rb
├── views
│ └── index.erb
└── workers
│ ├── some_worker.rb
│ └── unique_worker.rb
├── docker-compose-common.yml
├── docker-compose.yml
├── gemfiles
├── sidekiq_4_0.gemfile
├── sidekiq_4_1.gemfile
├── sidekiq_4_2.gemfile
├── sidekiq_5_0.gemfile
└── sidekiq_5_1.gemfile
├── lib
├── sidekiq-merger.rb
└── sidekiq
│ ├── merger.rb
│ └── merger
│ ├── config.rb
│ ├── flusher.rb
│ ├── logging_observer.rb
│ ├── merge.rb
│ ├── middleware.rb
│ ├── redis.rb
│ ├── version.rb
│ ├── views
│ └── index.erb
│ └── web.rb
├── misc
├── bulk_notification_flow.png
├── cancel_task_flow.png
└── web_ui.png
├── sidekiq-merger.gemspec
└── spec
├── sidekiq
├── merger
│ ├── flusher_spec.rb
│ ├── logging_observer_spec.rb
│ ├── merge_spec.rb
│ ├── middleware_spec.rb
│ └── redis_spec.rb
└── merger_spec.rb
├── spec_helper.rb
└── support
├── matchers.rb
└── worker_class.rb
/.codeclimate.yml:
--------------------------------------------------------------------------------
1 | engines:
2 | brakeman:
3 | enabled: true
4 | duplication:
5 | enabled: true
6 | config:
7 | languages:
8 | - ruby
9 | fixme:
10 | enabled: true
11 | rubocop:
12 | enabled: true
13 | ratings:
14 | paths:
15 | - Gemfile.lock
16 | - "**.erb"
17 | - "**.rb"
18 | exclude_paths:
19 | - "app/"
20 | - "spec/"
21 | - "misc/"
22 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | .bundle/
2 | .yardoc
3 | _yardoc/
4 | coverage/
5 | doc/
6 | pkg/
7 | spec/reports/
8 | tmp/
9 | rdoc/
10 |
11 | /Gemfile.lock
12 |
13 | *.gem
14 | *.rbc
15 |
16 | .ruby-version
17 | .ruby-gemset
18 | .rvmrc
19 |
20 | .env
21 |
22 | .dockerignore
23 |
--------------------------------------------------------------------------------
/.gemrelease:
--------------------------------------------------------------------------------
1 | bump:
2 | tag: true
3 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .bundle/
2 | .yardoc
3 | _yardoc/
4 | coverage/
5 | doc/
6 | pkg/
7 | spec/reports/
8 | tmp/
9 | rdoc/
10 |
11 | /Gemfile.lock
12 | #Appraisal
13 | *.gemfile.lock
14 |
15 | *.gem
16 | *.rbc
17 |
18 | .ruby-version
19 | .ruby-gemset
20 | .rvmrc
21 |
22 | .env
23 |
--------------------------------------------------------------------------------
/.rspec:
--------------------------------------------------------------------------------
1 | --format documentation
2 | --color
3 |
--------------------------------------------------------------------------------
/.rubocop.yml:
--------------------------------------------------------------------------------
1 | AllCops:
2 | DisabledByDefault: true
3 | Exclude:
4 | - 'gemfiles/**/*'
5 |
6 | Style/StringLiterals:
7 | Enabled: true
8 | EnforcedStyle: double_quotes
9 |
10 | Style/StringLiteralsInInterpolation:
11 | Enabled: true
12 | EnforcedStyle: double_quotes
13 |
14 | Layout/SpaceBeforeBlockBraces:
15 | Enabled: true
16 | EnforcedStyle: 'space'
17 |
18 | Layout/SpaceInsideArrayLiteralBrackets:
19 | Enabled: true
20 |
21 | Layout/SpaceInsideReferenceBrackets:
22 | Enabled: true
23 |
24 | Layout/SpaceInsideHashLiteralBraces:
25 | Enabled: true
26 |
27 | Layout/SpaceInsideBlockBraces:
28 | Enabled: true
29 |
30 | Layout/SpaceAroundEqualsInParameterDefault:
31 | Enabled: true
32 |
33 | Layout/SpaceBeforeComma:
34 | Enabled: false
35 |
36 | Layout/SpaceAroundOperators:
37 | Enabled: true
38 |
39 | Layout/SpaceAfterComma:
40 | Enabled: true
41 |
42 | Layout/ExtraSpacing:
43 | Enabled: true
44 | AllowForAlignment: true
45 |
46 | Lint/DuplicateMethods:
47 | Enabled: true
48 |
49 | Layout/LineLength:
50 | Enabled: true
51 | Max: 200
52 | AllowHeredoc: true
53 | AllowURI: true
54 | URISchemes: http, https
55 |
56 | Metrics/MethodLength:
57 | Enabled: true
58 | Max: 25
59 |
60 | Metrics/ClassLength:
61 | Enabled: true
62 | Max: 160
63 |
64 | Metrics/ModuleLength:
65 | Enabled: true
66 | Max: 160
67 |
68 | Style/ClassAndModuleChildren:
69 | EnforcedStyle: compact
70 | Exclude:
71 | - lib/sidekiq/merger/version.rb
72 |
73 | Layout/ArrayAlignment:
74 | Enabled: true
75 |
76 | Layout/HashAlignment:
77 | Enabled: true
78 |
79 | Layout/BlockEndNewline:
80 | Enabled: false
81 |
82 | Style/DoubleNegation:
83 | Enabled: false
84 |
85 | Metrics/AbcSize:
86 | Max: 60
87 |
88 | Metrics/CyclomaticComplexity:
89 | Max: 12
90 |
91 | Metrics/PerceivedComplexity:
92 | Max: 12
93 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | sudo: false
2 | language: ruby
3 | rvm:
4 | - 2.5.9
5 | - 2.6.7
6 | - 2.7.3
7 | gemfile:
8 | - gemfiles/sidekiq_4_0.gemfile
9 | - gemfiles/sidekiq_4_1.gemfile
10 | - gemfiles/sidekiq_4_2.gemfile
11 | - gemfiles/sidekiq_5_0.gemfile
12 | - gemfiles/sidekiq_5_1.gemfile
13 | services:
14 | - redis-server
15 | cache: bundler
16 | script:
17 | - "bundle exec rake spec"
18 | - "bundle exec rubocop -D"
19 | notifications:
20 | email: false
21 |
--------------------------------------------------------------------------------
/Appraisals:
--------------------------------------------------------------------------------
1 | appraise "sidekiq-4-0" do
2 | gem "sidekiq", "~> 4.0.0"
3 | end
4 |
5 | appraise "sidekiq-4-1" do
6 | gem "sidekiq", "~> 4.1.0"
7 | end
8 |
9 | appraise "sidekiq-4-2" do
10 | gem "sidekiq", "~> 4.2.0"
11 | end
12 |
13 | appraise "sidekiq-5-0" do
14 | gem "sidekiq", "~> 5.0.0"
15 | end
16 |
17 | appraise "sidekiq-5-1" do
18 | gem "sidekiq", "~> 5.1.0"
19 | end
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM ruby:2.3.3
2 | MAINTAINER dtaniwaki
3 |
4 | ENV PORT 3000
5 | ENV REDIS_HOST 127.0.0.1
6 | ENV REDIS_PORT 6379
7 |
8 | RUN gem install bundler
9 | ADD . /gem
10 | WORKDIR /gem/app
11 | RUN bundle install -j4
12 |
13 | EXPOSE $PORT
14 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | source "https://rubygems.org"
2 |
3 | gemspec
4 |
5 | gem "gem-release"
6 | gem "pry"
7 |
8 | gem "sidekiq", "~> #{ENV["SIDEKIQ_VERSION"]}" unless ENV["SIDEKIQ_VERSION"].nil?
9 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2017 dtaniwaki
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of
6 | this software and associated documentation files (the "Software"), to deal in
7 | the Software without restriction, including without limitation the rights to
8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9 | the Software, and to permit persons to whom the Software is furnished to do so,
10 | subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # sidekiq-merger
2 |
3 | [![Gem Version][gem-image]][gem-link]
4 | [![Dependency Status][deps-image]][deps-link]
5 | [![Build Status][build-image]][build-link]
6 | [![Coverage Status][cov-image]][cov-link]
7 | [![Code Climate][gpa-image]][gpa-link]
8 |
9 | [![Docker][docker-hub-image]][docker-hub-link]
10 |
11 | Merge [sidekiq](http://sidekiq.org/) jobs occurring before the execution times. Inspired by [sidekiq-grouping](https://github.com/gzigzigzeo/sidekiq-grouping).
12 |
13 | [Demo](http://sidekiq-merger.dtaniwaki.com/)
14 |
15 | ## Use Case
16 |
17 | ### Cancel Task
18 |
19 | 
20 |
21 | ### Bulk Notification
22 |
23 | 
24 |
25 | ## Installation
26 |
27 | Add this line to your application's Gemfile:
28 |
29 | ```ruby
30 | gem 'sidekiq-merger'
31 | ```
32 |
33 | And then execute:
34 |
35 | $ bundle
36 |
37 | Or install it yourself as:
38 |
39 | $ gem install sidekiq-merger
40 |
41 | ## Usage
42 |
43 | Add merger option into your workers.
44 |
45 | ```ruby
46 | class SomeWorker
47 | include Sidekiq::Worker
48 |
49 | sidekiq_options merger: { key: -> (args) { args[0] } }
50 |
51 | def perform(*merged_args)
52 | merged_args.each do |args|
53 | # Do something
54 | end
55 | end
56 | end
57 | ```
58 |
59 | Then, enqueue jobs by `perform_in` or `perform_at`.
60 |
61 | ```ruby
62 | SomeWorker.perform_in 100, 4
63 | SomeWorker.perform_in 100, 3
64 | SomeWorker.perform_in 100, 5
65 | # Passed 100 seconds from the first enqueue.
66 | SomeWorker.perform_in 100, 6
67 | SomeWorker.perform_in 100, 1
68 | ```
69 |
70 | `SomeWorker` will be executed in 100 seconds with args of `[4], [3], [5]`, then with args of `[6], [1]`.
71 |
72 | `perform_async` works without merging args.
73 |
74 | ```ruby
75 | SomeWorker.perform_async 4
76 | SomeWorker.perform_async 3
77 | SomeWorker.perform_async 5
78 | ```
79 |
80 | In this case, `SomeWorker` will be executed 3 times with args of `[4]`, `[3]` and `[5]`.
81 |
82 | ### Quick Check
83 |
84 | Run docker containers to check the behavior of this gem.
85 |
86 | $ docker-compose up
87 |
88 | Then, open `http://localhost:3000/`. You can push jobs from the UI and see what happens in the sidekiq console.
89 |
90 | ## Options
91 |
92 | ### `key` (optional, default: `nil`)
93 |
94 | Defines merge key so different arguments can be merged.
95 |
96 | Format: `String` or `Proc`
97 |
98 | e.g. `sidekiq_options merger: { key: -> (args) { args[0..1] } }`
99 |
100 | ### `unique` (optional, default: `false`)
101 |
102 | Prevents enqueue of jobs with identical arguments.
103 |
104 | Format: `Boolean`
105 |
106 | e.g. `true`
107 |
108 | ### `batch_size` (optional, default: `nil`)
109 |
110 | Allow to specify how many jobs max to provide as arguments per aggregation
111 |
112 | Format: `Int`
113 |
114 | e.g. `50`
115 |
116 | ## Web UI
117 |
118 | 
119 |
120 | Add this line to your `config/routes.rb` to activate web UI:
121 |
122 | ```ruby
123 | require "sidekiq/merger/web"
124 | ```
125 |
126 | ## Test
127 |
128 | $ bundle exec appraisal rspec
129 |
130 | The test coverage is available at `./coverage/index.html`.
131 |
132 | ## Lint
133 |
134 | $ bundle exec appraisal rubocop
135 |
136 | ## Contributing
137 |
138 | 1. Fork it
139 | 2. Create your feature branch (`git checkout -b my-new-feature`)
140 | 3. Commit your changes (`git commit -am 'Add some feature'`)
141 | 4. Push to the branch (`git push origin my-new-feature`)
142 | 5. Create new [Pull Request](../../pull/new/master)
143 |
144 | ## Copyright
145 |
146 | Copyright (c) 2017 dtaniwaki. See [LICENSE](LICENSE) for details.
147 |
148 | [gem-image]: https://badge.fury.io/rb/sidekiq-merger.svg
149 | [gem-link]: http://badge.fury.io/rb/sidekiq-merger
150 | [build-image]: https://secure.travis-ci.org/dtaniwaki/sidekiq-merger.svg
151 | [build-link]: http://travis-ci.org/dtaniwaki/sidekiq-merger
152 | [deps-image]: https://gemnasium.com/dtaniwaki/sidekiq-merger.svg
153 | [deps-link]: https://gemnasium.com/dtaniwaki/sidekiq-merger
154 | [cov-image]: https://coveralls.io/repos/dtaniwaki/sidekiq-merger/badge.png
155 | [cov-link]: https://coveralls.io/r/dtaniwaki/sidekiq-merger
156 | [gpa-image]: https://codeclimate.com/github/dtaniwaki/sidekiq-merger.svg
157 | [gpa-link]: https://codeclimate.com/github/dtaniwaki/sidekiq-merger
158 | [docker-hub-image]: http://dockeri.co/image/dtaniwaki/sidekiq-merger
159 | [docker-hub-link]: https://hub.docker.com/r/dtaniwaki/sidekiq-merger/
160 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | require "bundler/gem_tasks"
2 | require "rspec/core/rake_task"
3 | require "rubocop/rake_task"
4 |
5 | RSpec::Core::RakeTask.new(:spec)
6 | RuboCop::RakeTask.new(:rubocop)
7 |
8 | task default: [:rubocop, :spec]
9 |
--------------------------------------------------------------------------------
/app/Gemfile:
--------------------------------------------------------------------------------
1 | source "https://rubygems.org"
2 |
3 | gem "rack-flash3"
4 | gem "sinatra"
5 | gem "sinatra-contrib"
6 | gem "sidekiq"
7 | gem "sidekiq-status"
8 |
9 | gem "sidekiq-merger", path: "../"
10 |
--------------------------------------------------------------------------------
/app/Gemfile.lock:
--------------------------------------------------------------------------------
1 | PATH
2 | remote: ../
3 | specs:
4 | sidekiq-merger (0.0.10)
5 | activesupport (>= 3.2, < 6)
6 | concurrent-ruby (~> 1.0)
7 | sidekiq (>= 3.4, < 5)
8 |
9 | GEM
10 | remote: https://rubygems.org/
11 | specs:
12 | activesupport (5.0.1)
13 | concurrent-ruby (~> 1.0, >= 1.0.2)
14 | i18n (~> 0.7)
15 | minitest (~> 5.1)
16 | tzinfo (~> 1.1)
17 | backports (3.6.8)
18 | concurrent-ruby (1.0.4)
19 | connection_pool (2.2.1)
20 | i18n (0.8.0)
21 | minitest (5.10.1)
22 | multi_json (1.12.1)
23 | rack (1.6.5)
24 | rack-flash3 (1.0.5)
25 | rack
26 | rack-protection (1.5.3)
27 | rack
28 | rack-test (0.6.3)
29 | rack (>= 1.0)
30 | redis (3.3.3)
31 | sidekiq (4.2.9)
32 | concurrent-ruby (~> 1.0)
33 | connection_pool (~> 2.2, >= 2.2.0)
34 | rack-protection (>= 1.5.0)
35 | redis (~> 3.2, >= 3.2.1)
36 | sidekiq-status (0.6.0)
37 | sidekiq (>= 2.7)
38 | sinatra (1.4.8)
39 | rack (~> 1.5)
40 | rack-protection (~> 1.4)
41 | tilt (>= 1.3, < 3)
42 | sinatra-contrib (1.4.7)
43 | backports (>= 2.0)
44 | multi_json
45 | rack-protection
46 | rack-test
47 | sinatra (~> 1.4.0)
48 | tilt (>= 1.3, < 3)
49 | thread_safe (0.3.5)
50 | tilt (2.0.6)
51 | tzinfo (1.2.2)
52 | thread_safe (~> 0.1)
53 |
54 | PLATFORMS
55 | ruby
56 |
57 | DEPENDENCIES
58 | rack-flash3
59 | sidekiq
60 | sidekiq-merger!
61 | sidekiq-status
62 | sinatra
63 | sinatra-contrib
64 |
65 | BUNDLED WITH
66 | 1.13.6
67 |
--------------------------------------------------------------------------------
/app/app.rb:
--------------------------------------------------------------------------------
1 | require_relative "./sidekiq"
2 | require "sinatra/base"
3 | require "sinatra/cookies"
4 | require "securerandom"
5 | require "rack/flash"
6 | require "sidekiq/web"
7 | require "sidekiq-status/web"
8 | require "sidekiq/merger/web"
9 |
10 | class App < Sinatra::Application
11 | enable :sessions
12 | use Rack::Flash
13 |
14 | before do
15 | @queue = cookies[:queue] ||= SecureRandom.urlsafe_base64(8)
16 | end
17 |
18 | get "/" do
19 | erb :index
20 | end
21 |
22 | post "/some_worker/perform_in" do
23 | n = rand(10)
24 | Sidekiq::Client.push(
25 | "queue" => @queue,
26 | "class" => SomeWorker,
27 | "args" => [n],
28 | "at" => Time.now + (params[:in] || 60)
29 | )
30 | flash[:notice] = "Added #{n} to SomeWorker to Queue #{@queue}"
31 | redirect "/"
32 | end
33 |
34 | post "/some_worker/perform_async" do
35 | n = rand(10)
36 | Sidekiq::Client.push(
37 | "queue" => @queue,
38 | "class" => SomeWorker,
39 | "args" => [n]
40 | )
41 | flash[:notice] = "Added #{n} to SomeWorker to Queue #{@queue}"
42 | redirect "/"
43 | end
44 |
45 | post "/unique_worker/perform_in" do
46 | n = rand(10)
47 | Sidekiq::Client.push(
48 | "queue" => @queue,
49 | "class" => UniqueWorker,
50 | "args" => [n],
51 | "at" => Time.now + (params[:in] || 60)
52 | )
53 | flash[:notice] = "Added #{n} to UniqueWorker to Queue #{@queue}"
54 | redirect "/"
55 | end
56 |
57 | post "/unique_worker/perform_async" do
58 | n = rand(10)
59 | Sidekiq::Client.push(
60 | "queue" => @queue,
61 | "class" => UniqueWorker,
62 | "args" => [n]
63 | )
64 | flash[:notice] = "Added #{n} to UniqueWorker to Queue #{@queue}"
65 | redirect "/"
66 | end
67 | end
68 |
--------------------------------------------------------------------------------
/app/config.ru:
--------------------------------------------------------------------------------
1 | require "./app"
2 |
3 | run Rack::URLMap.new("/" => App, "/sidekiq" => Sidekiq::Web)
4 |
--------------------------------------------------------------------------------
/app/sidekiq.rb:
--------------------------------------------------------------------------------
1 | require "active_support/core_ext/numeric/time"
2 | require "sidekiq"
3 | require "sidekiq-status"
4 | require "sidekiq-merger"
5 | require_relative "workers/some_worker"
6 | require_relative "workers/unique_worker"
7 |
8 | expiration = 30.minutes
9 |
10 | Sidekiq.configure_client do |config|
11 | config.redis = { url: "redis://#{ENV["REDIS_HOST"]}:#{ENV["REDIS_PORT"]}" }
12 | config.client_middleware do |chain|
13 | chain.add Sidekiq::Status::ClientMiddleware, expiration: expiration
14 | end
15 | end
16 |
17 | Sidekiq.configure_server do |config|
18 | config.redis = { url: "redis://#{ENV["REDIS_HOST"]}:#{ENV["REDIS_PORT"]}" }
19 | config.server_middleware do |chain|
20 | chain.add Sidekiq::Status::ServerMiddleware, expiration: expiration
21 | end
22 | config.client_middleware do |chain|
23 | chain.add Sidekiq::Status::ClientMiddleware, expiration: expiration
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/app/views/index.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Sidekiq Merger
7 |
8 |
9 |
10 |
16 |
17 | <% if flash[:notice] %>
18 |
19 |
×
20 |
<%= flash[:notice] %>
21 |
22 | <% end %>
23 |
24 | Click the `perform_in` buttons to create or merge tasks until the execution time (in 60s).
25 | Click the `perform_async` buttons to execute a single task.
26 | Open " target="_blank">sidekiq console to check what happens.
27 |
28 |
29 |
SomeWorker
30 |
31 | sidekiq_options merger: { unique: false }
32 |
33 |
34 | Tasks will be merged regardless of uniqueness.
35 |
36 |
37 |
40 |
43 |
44 |
UniqueWorker
45 |
46 | sidekiq_options merger: { unique: true }
47 |
48 |
49 | Tasks will be merged if they haven't added already.
50 |
51 |
52 |
55 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
--------------------------------------------------------------------------------
/app/workers/some_worker.rb:
--------------------------------------------------------------------------------
1 | class SomeWorker
2 | include Sidekiq::Worker
3 |
4 | sidekiq_options merger: { key: "foo" }
5 |
6 | def perform(*ids)
7 | puts "Get IDs: #{ids.inspect}"
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/app/workers/unique_worker.rb:
--------------------------------------------------------------------------------
1 | class UniqueWorker
2 | include Sidekiq::Worker
3 |
4 | sidekiq_options merger: { key: "foo", unique: true }
5 |
6 | def perform(*ids)
7 | puts "Get IDs: #{ids.inspect}"
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/docker-compose-common.yml:
--------------------------------------------------------------------------------
1 | version: '2.1'
2 | services:
3 | app:
4 | build: .
5 | environment:
6 | - REDIS_HOST=redis
7 | - REDIS_PORT=6379
8 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '2.1'
2 | services:
3 | worker:
4 | extends:
5 | file: 'docker-compose-common.yml'
6 | service: app
7 | command: bundle exec sidekiq -r ./sidekiq.rb
8 | links:
9 | - redis
10 | environment:
11 | - REDIS_HOST=redis
12 | - REDIS_PORT=6379
13 | web:
14 | extends:
15 | file: 'docker-compose-common.yml'
16 | service: app
17 | command: bundle exec rackup -p 3000 --host 0.0.0.0
18 | ports:
19 | - 3000:3000
20 | links:
21 | - redis
22 | environment:
23 | - PORT=3000
24 | - REDIS_HOST=redis
25 | - REDIS_PORT=6379
26 | redis:
27 | image: redis:3.2.7
28 |
--------------------------------------------------------------------------------
/gemfiles/sidekiq_4_0.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "https://rubygems.org"
4 |
5 | gem "gem-release"
6 | gem "pry"
7 | gem "sidekiq", "~> 4.0.0"
8 |
9 | gemspec path: "../"
10 |
--------------------------------------------------------------------------------
/gemfiles/sidekiq_4_1.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "https://rubygems.org"
4 |
5 | gem "gem-release"
6 | gem "pry"
7 | gem "sidekiq", "~> 4.1.0"
8 |
9 | gemspec path: "../"
10 |
--------------------------------------------------------------------------------
/gemfiles/sidekiq_4_2.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "https://rubygems.org"
4 |
5 | gem "gem-release"
6 | gem "pry"
7 | gem "sidekiq", "~> 4.2.0"
8 |
9 | gemspec path: "../"
10 |
--------------------------------------------------------------------------------
/gemfiles/sidekiq_5_0.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "https://rubygems.org"
4 |
5 | gem "gem-release"
6 | gem "pry"
7 | gem "sidekiq", "~> 5.0.0"
8 |
9 | gemspec path: "../"
10 |
--------------------------------------------------------------------------------
/gemfiles/sidekiq_5_1.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "https://rubygems.org"
4 |
5 | gem "gem-release"
6 | gem "pry"
7 | gem "sidekiq", "~> 5.1.0"
8 |
9 | gemspec path: "../"
10 |
--------------------------------------------------------------------------------
/lib/sidekiq-merger.rb:
--------------------------------------------------------------------------------
1 | require_relative "sidekiq/merger"
2 |
3 | Sidekiq.configure_client do |config|
4 | config.client_middleware do |chain|
5 | chain.add Sidekiq::Merger::Middleware
6 | end
7 | end
8 |
9 | Sidekiq.configure_server do |config|
10 | config.client_middleware do |chain|
11 | chain.add Sidekiq::Merger::Middleware
12 | end
13 | end
14 |
15 | if Sidekiq.server?
16 | task = Sidekiq::Merger.create_task
17 | task.execute
18 | end
19 |
--------------------------------------------------------------------------------
/lib/sidekiq/merger.rb:
--------------------------------------------------------------------------------
1 | require "sidekiq"
2 | require "concurrent"
3 | require_relative "merger/version"
4 | require_relative "merger/middleware"
5 | require_relative "merger/config"
6 | require_relative "merger/flusher"
7 | require_relative "merger/logging_observer"
8 |
9 | module Sidekiq::Merger
10 | LOGGER_TAG = self.name.freeze
11 |
12 | class << self
13 | attr_accessor :logger
14 |
15 | def create_task
16 | interval = Sidekiq::Merger::Config.poll_interval
17 | observer = Sidekiq::Merger::LoggingObserver.new(logger)
18 | flusher = Sidekiq::Merger::Flusher.new(logger)
19 | task = Concurrent::TimerTask.new(
20 | execution_interval: interval
21 | ) { flusher.flush }
22 | task.add_observer(observer)
23 | task
24 | end
25 |
26 | def configure(&block)
27 | yield config
28 | end
29 |
30 | def config
31 | @config ||= Config.new
32 | end
33 | end
34 |
35 | self.logger = Sidekiq.logger
36 | end
37 |
--------------------------------------------------------------------------------
/lib/sidekiq/merger/config.rb:
--------------------------------------------------------------------------------
1 | require "active_support/configurable"
2 |
3 | class Sidekiq::Merger::Config
4 | include ActiveSupport::Configurable
5 |
6 | def self.options
7 | Sidekiq.options["merger"] || {}
8 | end
9 |
10 | config_accessor :poll_interval do
11 | options[:poll_interval] || 5
12 | end
13 |
14 | config_accessor :lock_ttl do
15 | options[:lock_ttl] || 1
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/lib/sidekiq/merger/flusher.rb:
--------------------------------------------------------------------------------
1 | class Sidekiq::Merger::Flusher
2 | def initialize(logger)
3 | @logger = logger
4 | end
5 |
6 | def flush
7 | merges = Sidekiq::Merger::Merge.all.select(&:can_flush?)
8 | unless merges.empty?
9 | @logger.info(
10 | "[Sidekiq::Merger] Trying to flush merged queues: #{merges.map(&:full_merge_key).join(",")}"
11 | )
12 | merges.each(&:flush)
13 | end
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/lib/sidekiq/merger/logging_observer.rb:
--------------------------------------------------------------------------------
1 | class Sidekiq::Merger::LoggingObserver
2 | def initialize(logger)
3 | @logger = logger
4 | end
5 |
6 | def update(time, _result, ex)
7 | if ex.is_a?(Concurrent::TimeoutError)
8 | @logger.error(
9 | "[#{Sidekiq::Merger::LOGGER_TAG}] Execution timed out\n"
10 | )
11 | elsif ex.present?
12 | @logger.error(
13 | "[#{Sidekiq::Merger::LOGGER_TAG}] Execution failed with error #{ex}\n"
14 | )
15 | end
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/lib/sidekiq/merger/merge.rb:
--------------------------------------------------------------------------------
1 | require_relative "redis"
2 | require "active_support/core_ext/hash/indifferent_access"
3 |
4 | class Sidekiq::Merger::Merge
5 | class << self
6 | def all
7 | redis = Sidekiq::Merger::Redis.new
8 |
9 | redis.all_merges.map { |full_merge_key| initialize_with_full_merge_key(full_merge_key, redis: redis) }
10 | end
11 |
12 | def initialize_with_full_merge_key(full_merge_key, options = {})
13 | keys = full_merge_key.split(":")
14 | raise "Invalid merge key" if keys.size < 3
15 | worker_class = keys[0].camelize.constantize
16 | queue = keys[1]
17 | merge_key = keys[2]
18 | new(worker_class, queue, merge_key, options)
19 | end
20 |
21 | def initialize_with_args(worker_class, queue, args, options = {})
22 | new(worker_class, queue, merge_key(worker_class, args), options)
23 | end
24 |
25 | def merge_key(worker_class, args)
26 | options = get_options(worker_class)
27 | merge_key = options["key"]
28 | if merge_key.respond_to?(:call)
29 | merge_key = merge_key.call(args)
30 | end
31 | merge_key = "" if merge_key.nil?
32 | merge_key = merge_key.to_json unless merge_key.is_a?(String)
33 | merge_key
34 | end
35 |
36 | def get_options(worker_class)
37 | (worker_class.get_sidekiq_options["merger"] || {}).with_indifferent_access
38 | end
39 | end
40 |
41 | attr_reader :worker_class, :queue, :merge_key
42 |
43 | def initialize(worker_class, queue, merge_key, redis: Sidekiq::Merger::Redis.new)
44 | @worker_class = worker_class
45 | @queue = queue
46 | @merge_key = merge_key
47 | @redis = redis
48 | end
49 |
50 | def add(args, execution_time)
51 | if !options[:unique] || !@redis.merge_exists?(full_merge_key, args)
52 | @redis.push_message(full_merge_key, args, execution_time)
53 | end
54 | end
55 |
56 | def delete(args)
57 | @redis.delete_message(full_merge_key, args)
58 | end
59 |
60 | def delete_all
61 | @redis.delete_merge(full_merge_key)
62 | end
63 |
64 | def size
65 | @redis.merge_size(full_merge_key)
66 | end
67 |
68 | def flush
69 | msgs = []
70 |
71 | if @redis.lock_merge(full_merge_key, Sidekiq::Merger::Config.lock_ttl)
72 | msgs = @redis.pluck_merge(full_merge_key)
73 | end
74 |
75 | unless msgs.empty?
76 | batches = options[:batch_size].nil? ? [msgs] : msgs.each_slice(options[:batch_size].to_i).to_a
77 | batches.each do |batch_msgs|
78 | # preserve FIFO when enqueuing batches
79 | Sidekiq::Client.push(
80 | "class" => worker_class,
81 | "queue" => queue,
82 | "args" => batch_msgs,
83 | "merged" => true
84 | )
85 | end
86 | end
87 | end
88 |
89 | def can_flush?
90 | !execution_time.nil? && execution_time < Time.now
91 | end
92 |
93 | def full_merge_key
94 | @full_merge_key ||= [worker_class.name.to_s.underscore, queue, merge_key].join(":")
95 | end
96 |
97 | def all_args
98 | @redis.get_merge(full_merge_key)
99 | end
100 |
101 | def execution_time
102 | @execution_time ||= @redis.merge_execution_time(full_merge_key)
103 | end
104 |
105 | def ==(other)
106 | self.worker_class == other.worker_class &&
107 | self.queue == other.queue &&
108 | self.merge_key == other.merge_key
109 | end
110 |
111 | private
112 |
113 | def options
114 | @options ||= self.class.get_options(worker_class)
115 | rescue NameError
116 | {}
117 | end
118 | end
119 |
--------------------------------------------------------------------------------
/lib/sidekiq/merger/middleware.rb:
--------------------------------------------------------------------------------
1 | require_relative "merge"
2 |
3 | class Sidekiq::Merger::Middleware
4 | def call(worker_class, msg, queue, _ = nil)
5 | return yield if defined?(Sidekiq::Testing) && Sidekiq::Testing.inline?
6 |
7 | worker_class = worker_class.camelize.constantize if worker_class.is_a?(String)
8 | options = worker_class.get_sidekiq_options
9 |
10 | merger_enabled = options.key?("merger")
11 |
12 | return yield unless merger_enabled
13 |
14 | if !msg["at"].nil? && msg["at"].to_f > Time.now.to_f
15 | Sidekiq::Merger::Merge
16 | .initialize_with_args(worker_class, queue, msg["args"])
17 | .add(msg["args"], msg["at"])
18 | false
19 | else
20 | msg["args"] = [msg["args"].flatten] unless msg.delete("merged")
21 | yield
22 | end
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/lib/sidekiq/merger/redis.rb:
--------------------------------------------------------------------------------
1 | require "active_support/core_ext/module/delegation"
2 |
3 | class Sidekiq::Merger::Redis
4 | class << self
5 | KEY_PREFIX = "sidekiq-merger".freeze
6 |
7 | def purge!
8 | redis do |conn|
9 | conn.eval
10 | script = <<-SCRIPT
11 | for i=1, #ARGV do
12 | redis.call('del', unpack(redis.call('keys', ARGV[i])))
13 | end
14 | return true
15 | SCRIPT
16 | conn.eval(script, [], [merges_key, unique_msg_key("*"), msg_key("*"), lock_key("*")])
17 | end
18 | end
19 |
20 | def merges_key
21 | "#{KEY_PREFIX}:merges"
22 | end
23 |
24 | def unique_msg_key(key)
25 | "#{KEY_PREFIX}:unique_msg:#{key}"
26 | end
27 |
28 | def msg_key(key)
29 | "#{KEY_PREFIX}:msg:#{key}"
30 | end
31 |
32 | def time_key(key)
33 | "#{KEY_PREFIX}:time:#{key}"
34 | end
35 |
36 | def lock_key(key)
37 | "#{KEY_PREFIX}:lock:#{key}"
38 | end
39 |
40 | def redis(&block)
41 | Sidekiq.redis(&block)
42 | end
43 | end
44 |
45 | def push_message(key, msg, execution_time)
46 | msg_json = msg.to_json
47 | redis do |conn|
48 | conn.multi do
49 | conn.sadd(merges_key, key)
50 | conn.setnx(time_key(key), execution_time.to_i)
51 | conn.lpush(msg_key(key), msg_json)
52 | conn.sadd(unique_msg_key(key), msg_json)
53 | end
54 | end
55 | end
56 |
57 | def delete_message(key, msg)
58 | msg_json = msg.to_json
59 | redis do |conn|
60 | conn.multi do
61 | conn.srem(unique_msg_key(key), msg_json)
62 | conn.lrem(msg_key(key), 0, msg_json)
63 | end
64 | end
65 | end
66 |
67 | def merge_execution_time(key)
68 | redis do |conn|
69 | t = conn.get(time_key(key))
70 | Time.at(t.to_i) unless t.nil?
71 | end
72 | end
73 |
74 | def merge_size(key)
75 | redis { |conn| conn.llen(msg_key(key)) }
76 | end
77 |
78 | def merge_exists?(key, msg)
79 | msg_json = msg.to_json
80 | redis { |conn| conn.sismember(unique_msg_key(key), msg_json) }
81 | end
82 |
83 | def all_merges
84 | redis { |conn| conn.smembers(merges_key) }
85 | end
86 |
87 | def lock_merge(key, ttl)
88 | redis { |conn| conn.set(lock_key(key), true, nx: true, ex: ttl) }
89 | end
90 |
91 | def get_merge(key)
92 | msgs = []
93 | redis { |conn| msgs = conn.lrange(msg_key(key), 0, -1) }
94 | msgs.map { |msg| JSON.parse(msg) }
95 | end
96 |
97 | def pluck_merge(key)
98 | msgs = []
99 | redis do |conn|
100 | conn.multi do
101 | msgs = conn.lrange(msg_key(key), 0, -1)
102 | conn.del(unique_msg_key(key))
103 | conn.del(msg_key(key))
104 | conn.del(time_key(key))
105 | conn.srem(merges_key, key)
106 | end
107 | end
108 | extract_future_value(msgs).map { |msg| JSON.parse(msg) }
109 | end
110 |
111 | def delete_merge(key)
112 | redis do |conn|
113 | conn.multi do
114 | conn.del(unique_msg_key(key))
115 | conn.del(msg_key(key))
116 | conn.del(time_key(key))
117 | conn.del(lock_key(key))
118 | conn.srem(merges_key, key)
119 | end
120 | end
121 | end
122 |
123 | private
124 |
125 | delegate :merges_key, :msg_key, :unique_msg_key, :time_key, :lock_key, :redis, to: "self.class"
126 |
127 | def extract_future_value(future)
128 | while future.value.is_a?(Redis::FutureNotReady)
129 | sleep(0.001)
130 | end
131 | future.value
132 | end
133 | end
134 |
--------------------------------------------------------------------------------
/lib/sidekiq/merger/version.rb:
--------------------------------------------------------------------------------
1 | module Sidekiq
2 | module Merger
3 | VERSION = "0.1.0".freeze
4 | end
5 | end
6 |
--------------------------------------------------------------------------------
/lib/sidekiq/merger/views/index.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Merged jobs
4 |
5 |
6 |
7 |
8 |
9 |
10 | <% if true %>
11 |
12 |
13 | Worker
14 | Queue
15 | Count
16 | All Args
17 | Execution time
18 | Actions
19 |
20 | <% @merges.each do |merge| %>
21 |
22 | <%= merge.worker_class %>
23 | <%= merge.queue %>
24 | <%= merge.size %>
25 | <%= merge.all_args %>
26 | <%= merge.execution_time || "–"%>
27 |
28 |
32 |
33 |
34 | <% end %>
35 |
36 | <% else %>
37 |
No recurring jobs found.
38 | <% end %>
39 |
40 |
41 |
42 |
--------------------------------------------------------------------------------
/lib/sidekiq/merger/web.rb:
--------------------------------------------------------------------------------
1 | require "sidekiq/web"
2 |
3 | module Sidekiq::Merger::Web
4 | VIEWS = File.expand_path("views", File.dirname(__FILE__))
5 |
6 | def self.registered(app)
7 | app.get "/merges" do
8 | @merges = Sidekiq::Merger::Merge.all
9 | @merges.select! { |m| m.queue == params[:queue] } unless params[:queue].nil?
10 | erb File.read(File.join(VIEWS, "index.erb")), locals: { view_path: VIEWS }
11 | end
12 |
13 | app.post "/merges/:full_merge_key/delete" do
14 | full_merge_key = URI.decode_www_form_component params[:full_merge_key]
15 | merge = Sidekiq::Merger::Merge.initialize_with_full_merge_key(full_merge_key)
16 | merge.delete_all
17 | redirect "#{root_path}/merges"
18 | end
19 | end
20 | end
21 |
22 | Sidekiq::Web.register(Sidekiq::Merger::Web)
23 | Sidekiq::Web.tabs["Merges"] = "merges"
24 |
--------------------------------------------------------------------------------
/misc/bulk_notification_flow.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dtaniwaki/sidekiq-merger/b7f67d386dca9631bdc263652412c965b671150d/misc/bulk_notification_flow.png
--------------------------------------------------------------------------------
/misc/cancel_task_flow.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dtaniwaki/sidekiq-merger/b7f67d386dca9631bdc263652412c965b671150d/misc/cancel_task_flow.png
--------------------------------------------------------------------------------
/misc/web_ui.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dtaniwaki/sidekiq-merger/b7f67d386dca9631bdc263652412c965b671150d/misc/web_ui.png
--------------------------------------------------------------------------------
/sidekiq-merger.gemspec:
--------------------------------------------------------------------------------
1 | # coding: utf-8
2 | lib = File.expand_path("../lib", __FILE__)
3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4 | require "sidekiq/merger/version"
5 |
6 | Gem::Specification.new do |spec|
7 | spec.name = "sidekiq-merger"
8 | spec.version = Sidekiq::Merger::VERSION
9 | spec.platform = Gem::Platform::RUBY
10 | spec.authors = ["dtaniwaki"]
11 | spec.email = ["daisuketaniwaki@gmail.com"]
12 |
13 | spec.summary = "Sidekiq merger plugin"
14 | spec.description = "Merge sidekiq jobs."
15 | spec.homepage = "https://github.com/dtaniwaki/sidekiq-merger"
16 | spec.license = "MIT"
17 |
18 | spec.files = `git ls-files -z`.split("\x0").reject do |f|
19 | f.match(%r{^(test|spec|features)/})
20 | end
21 | spec.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
22 | spec.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) }
23 | spec.require_paths = ["lib"]
24 |
25 | spec.required_ruby_version = [">= 2.5.0"]
26 |
27 | spec.add_development_dependency "rake", ">= 10.0", "< 13"
28 | spec.add_development_dependency "rspec", ">= 3.0", "< 4"
29 | spec.add_development_dependency "simplecov", "~> 0.12"
30 | spec.add_development_dependency "timecop", "~> 0.8"
31 | spec.add_development_dependency "rubocop", "~> 0.93.1"
32 | spec.add_development_dependency "coveralls", "~> 0.8"
33 | spec.add_development_dependency "appraisal"
34 |
35 | spec.add_runtime_dependency "sidekiq", ">= 4.0", "< 6"
36 | spec.add_runtime_dependency "concurrent-ruby", "~> 1.0"
37 | spec.add_runtime_dependency "activesupport", ">= 3.2", "< 6"
38 | end
39 |
--------------------------------------------------------------------------------
/spec/sidekiq/merger/flusher_spec.rb:
--------------------------------------------------------------------------------
1 | require "spec_helper"
2 |
3 | describe Sidekiq::Merger::Flusher do
4 | subject { described_class.new(Sidekiq.logger) }
5 |
6 | describe "#call" do
7 | let(:active_merge) { double(full_merge_key: "active", can_flush?: true, flush: nil) }
8 | let(:inactive_merge) { double(full_merge_key: "inactive", can_flush?: false, flush: nil) }
9 | let(:merges) { [active_merge, inactive_merge] }
10 | it "adds the args to the merge" do
11 | allow(Sidekiq::Merger::Merge).to receive(:all).and_return merges
12 | expect(active_merge).to receive(:flush)
13 | expect(inactive_merge).not_to receive(:flush)
14 |
15 | subject.flush
16 | end
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/spec/sidekiq/merger/logging_observer_spec.rb:
--------------------------------------------------------------------------------
1 | require "spec_helper"
2 |
3 | describe Sidekiq::Merger::LoggingObserver do
4 | subject { described_class.new(logger) }
5 | let(:logger) { Logger.new("/dev/null") }
6 | let(:now) { Time.now }
7 | before { Timecop.freeze(now) }
8 |
9 | describe "#update" do
10 | it "logs a timeout" do
11 | expect(logger).to receive(:error).with("[Sidekiq::Merger] Execution timed out\n")
12 | subject.update(now, nil, Concurrent::TimeoutError.new)
13 | end
14 | it "logs an error" do
15 | expect(logger).to receive(:error).with("[Sidekiq::Merger] Execution failed with error foo\n")
16 | subject.update(now, nil, "foo")
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/spec/sidekiq/merger/merge_spec.rb:
--------------------------------------------------------------------------------
1 | require "spec_helper"
2 |
3 | describe Sidekiq::Merger::Merge, worker_class: true do
4 | subject { described_class.new(worker_class, queue, args, redis: redis) }
5 | let(:args) { "foo" }
6 | let(:redis) { Sidekiq::Merger::Redis.new }
7 | let(:queue) { "queue" }
8 | let(:now) { Time.now }
9 | let(:execution_time) { now + 10.seconds }
10 | let(:worker_options) { { key: -> (args) { args.to_json } } }
11 | before { Timecop.freeze(now) }
12 |
13 | describe ".all" do
14 | it "returns all the keys" do
15 | redis.redis do |conn|
16 | conn.sadd("sidekiq-merger:merges", "string:foo:xxx")
17 | conn.sadd("sidekiq-merger:merges", "numeric:bar:yyy")
18 | end
19 |
20 | expect(described_class.all).to contain_exactly(
21 | described_class.new(String, "foo", "xxx"),
22 | described_class.new(Numeric, "bar", "yyy")
23 | )
24 | end
25 |
26 | context "including invalid key" do
27 | it "raises an error" do
28 | redis.redis do |conn|
29 | conn.sadd("sidekiq-merger:merges", "string:foo:xxx")
30 | conn.sadd("sidekiq-merger:merges", "invalid")
31 | end
32 | expect {
33 | described_class.all
34 | }.to raise_error RuntimeError, "Invalid merge key"
35 | end
36 | end
37 | end
38 |
39 | describe ".initialize_with_args" do
40 | it "provides merge_key from args" do
41 | expect(described_class).to receive(:new).with(worker_class, queue, "[1,2,3]", anything)
42 | described_class.initialize_with_args(worker_class, queue, [1, 2, 3])
43 | end
44 | it "passes options" do
45 | expect(described_class).to receive(:new).with(worker_class, queue, anything, { redis: 1 })
46 | described_class.initialize_with_args(worker_class, queue, anything, redis: 1)
47 | end
48 | end
49 |
50 | describe ".merge_key" do
51 | let(:args) { "foo" }
52 | let(:worker_options) { {} }
53 | it "returns an empty string" do
54 | expect(described_class.merge_key(worker_class, args)).to eq ""
55 | end
56 | context "string key" do
57 | let(:worker_options) { { key: "bar" } }
58 | it "returns the string" do
59 | expect(described_class.merge_key(worker_class, args)).to eq "bar"
60 | end
61 | end
62 | context "other type key" do
63 | let(:worker_options) { { key: [1, 2, 3] } }
64 | it "returns nil" do
65 | expect(described_class.merge_key(worker_class, args)).to eq "[1,2,3]"
66 | end
67 | end
68 | context "proc key" do
69 | let(:args) { [1, 2, 3] }
70 | let(:worker_options) { { key: -> (args) { args[0].to_s } } }
71 | it "returns the result of the proc" do
72 | expect(described_class.merge_key(worker_class, args)).to eq "1"
73 | end
74 | context "non-string result" do
75 | let(:worker_options) { { key: -> (args) { args[0] } } }
76 | it "returns nil" do
77 | expect(described_class.merge_key(worker_class, args)).to eq "1"
78 | end
79 | end
80 | end
81 | end
82 |
83 | describe "#add" do
84 | it "adds the args in lazy merge" do
85 | expect(redis).to receive(:push_message).with("some_worker:queue:foo", [1, 2, 3], execution_time)
86 | subject.add([1, 2, 3], execution_time)
87 | end
88 | context "with unique option" do
89 | let(:worker_options) { { key: -> (args) { args.to_json }, unique: true } }
90 | it "adds the args in lazy merge" do
91 | expect(redis).to receive(:push_message).with("some_worker:queue:foo", [1, 2, 3], execution_time)
92 | subject.add([1, 2, 3], execution_time)
93 | end
94 | context "the args has alredy been added" do
95 | before { subject.add([1, 2, 3], execution_time) }
96 | it "adds the args in lazy merge" do
97 | expect(redis).not_to receive(:push_message)
98 | subject.add([1, 2, 3], execution_time)
99 | end
100 | end
101 | end
102 | end
103 |
104 | describe "#delete" do
105 | it "adds the args in lazy merge" do
106 | expect(redis).to receive(:delete_message).with("some_worker:queue:foo", [1, 2, 3])
107 | subject.delete([1, 2, 3])
108 | end
109 | end
110 |
111 | describe "#delete_all" do
112 | before do
113 | subject.add([1, 2, 3], execution_time)
114 | subject.add([2, 3, 4], execution_time)
115 | end
116 | it "deletes all" do
117 | expect {
118 | subject.delete_all
119 | }.to change { subject.size }.from(2).to(0)
120 | end
121 | end
122 |
123 | describe "#size" do
124 | before do
125 | subject.add([1, 2, 3], execution_time)
126 | subject.add([2, 3, 4], execution_time)
127 | end
128 | it "returns the size" do
129 | expect(subject.size).to eq 2
130 | end
131 | end
132 |
133 | describe "#all_args" do
134 | before do
135 | subject.add([1, 2, 3], execution_time)
136 | subject.add([2, 3, 4], execution_time)
137 | end
138 | it "returns all args" do
139 | expect(subject.all_args).to contain_exactly [1, 2, 3], [2, 3, 4]
140 | end
141 | end
142 |
143 | describe "#flush" do
144 | context "when no batch_size is configured" do
145 | before do
146 | subject.add([1, 2, 3], execution_time)
147 | subject.add([2, 3, 4], execution_time)
148 | end
149 | it "flushes all the args" do
150 | expect(Sidekiq::Client).to receive(:push).with(
151 | "class" => worker_class,
152 | "queue" => queue,
153 | "args" => a_collection_containing_exactly([1, 2, 3], [2, 3, 4]),
154 | "merged" => true
155 | )
156 |
157 | subject.flush
158 | end
159 | end
160 |
161 | context "when batch_size is configured to 2" do
162 | let(:worker_options) { { key: -> (args) { args.to_json }, batch_size: 2 } }
163 | before do
164 | subject.add([1, 2, 3], execution_time)
165 | subject.add([2, 3, 4], execution_time)
166 | subject.add([3, 4, 5], execution_time)
167 | subject.add([4, 5, 6], execution_time)
168 | end
169 | it "flushes all the args" do
170 | expect(Sidekiq::Client).to receive(:push).with(
171 | "class" => worker_class,
172 | "queue" => queue,
173 | "args" => a_collection_containing_exactly([1, 2, 3], [2, 3, 4]),
174 | "merged" => true
175 | )
176 |
177 | expect(Sidekiq::Client).to receive(:push).with(
178 | "class" => worker_class,
179 | "queue" => queue,
180 | "args" => a_collection_containing_exactly([3, 4, 5], [4, 5, 6]),
181 | "merged" => true
182 | )
183 |
184 | subject.flush
185 | end
186 | end
187 | end
188 |
189 | describe "#can_flush?" do
190 | context "it has not get anything in merge" do
191 | it "returns false" do
192 | expect(subject.can_flush?).to eq false
193 | end
194 | end
195 | context "it has not passed the execution time" do
196 | it "returns false" do
197 | subject.add([], execution_time)
198 | expect(subject.can_flush?).to eq false
199 | end
200 | end
201 | context "it has passed the execution time" do
202 | it "returns true" do
203 | subject.add([], execution_time)
204 | Timecop.travel(10.seconds)
205 | expect(subject.can_flush?).to eq true
206 | end
207 | end
208 | end
209 |
210 | describe "#full_merge_key" do
211 | it "returns full merge key" do
212 | expect(subject.full_merge_key).to eq "some_worker:queue:foo"
213 | end
214 | end
215 | end
216 |
--------------------------------------------------------------------------------
/spec/sidekiq/merger/middleware_spec.rb:
--------------------------------------------------------------------------------
1 | require "spec_helper"
2 |
3 | describe Sidekiq::Merger::Middleware, worker_class: true do
4 | subject { described_class.new }
5 | let(:flusher) { Sidekiq::Merger::Flusher.new(Sidekiq.logger) }
6 | let(:queue) { "queue" }
7 | let(:now) { Time.now }
8 | before :example do
9 | Timecop.freeze(now)
10 | end
11 |
12 | describe "#call" do
13 | context "non-merger worker" do
14 | it "leaves args alone" do
15 | msg = { "args" => [1, 2, 3] }
16 | expect { |b| subject.call(non_merge_worker_class, msg, queue, &b) }.to yield_with_no_args
17 | expect(msg).to eq({ "args" => [1, 2, 3] }) #unmodified
18 | flusher.flush
19 | expect(worker_class.jobs.size).to eq 0
20 | end
21 | end
22 | it "adds the args to the merge" do
23 | subject.call(worker_class, { "args" => [1, 2, 3], "at" => (now + 10.seconds).to_f }, queue) {}
24 | subject.call(worker_class, { "args" => [2, 3, 4], "at" => (now + 15.seconds).to_f }, queue) {}
25 | flusher.flush
26 | expect(worker_class.jobs.size).to eq 0
27 | Timecop.travel(10.seconds)
28 | flusher.flush
29 | expect(worker_class.jobs.size).to eq 1
30 | job = worker_class.jobs[0]
31 | expect(job["queue"]).to eq queue
32 | expect(job["args"]).to contain_exactly [1, 2, 3], [2, 3, 4]
33 | end
34 | context "without at msg" do
35 | it "peforms now with brackets" do
36 | msg = { "args" => [1, 2, 3] }
37 | expect { |b| subject.call(worker_class, msg, queue, &b) }.to yield_with_no_args
38 | expect(msg).to eq({ "args" => [[1, 2, 3]] })
39 | flusher.flush
40 | expect(worker_class.jobs.size).to eq 0
41 | end
42 | context "merged msgs" do
43 | it "performs now" do
44 | msg = { "args" => [[1, 2, 3]], "merged" => true }
45 | expect { |b| subject.call(worker_class, msg, queue, &b) }.to yield_with_no_args
46 | expect(msg).to eq({ "args" => [[1, 2, 3]] })
47 | flusher.flush
48 | expect(worker_class.jobs.size).to eq 0
49 | end
50 | end
51 | end
52 | context "at is before current time" do
53 | it "peforms now" do
54 | msg = { "args" => [1, 2, 3], "at" => now.to_f }
55 | expect { |b| subject.call(worker_class, msg, queue, &b) }.to yield_with_no_args
56 | expect(msg).to eq({ "args" => [[1, 2, 3]], "at" => now.to_f })
57 | flusher.flush
58 | expect(worker_class.jobs.size).to eq 0
59 | end
60 | end
61 | end
62 | end
63 |
--------------------------------------------------------------------------------
/spec/sidekiq/merger/redis_spec.rb:
--------------------------------------------------------------------------------
1 | require "spec_helper"
2 |
3 | describe Sidekiq::Merger::Redis do
4 | subject { described_class.new }
5 | let(:now) { Time.now }
6 | let(:execution_time) { now + 10.seconds }
7 | before { Timecop.freeze(now) }
8 |
9 | describe ".purge" do
10 | it "cleans up all the keys" do
11 | described_class.redis do |conn|
12 | conn.sadd("sidekiq-merger:merges", "test")
13 | conn.set("sidekiq-merger:unique_msg:foo", "test")
14 | conn.set("sidekiq-merger:msg:foo", "test")
15 | conn.set("sidekiq-merger:lock:foo", "test")
16 | end
17 |
18 | described_class.purge!
19 |
20 | described_class.redis do |conn|
21 | expect(conn.smembers("sidekiq-merger:merges")).to be_empty
22 | expect(conn.keys("sidekiq-merger:unique_msg:*")).to be_empty
23 | expect(conn.keys("sidekiq-merger:msg:*")).to be_empty
24 | expect(conn.keys("sidekiq-merger:lock:*")).to be_empty
25 | end
26 | end
27 | end
28 |
29 | describe "#push_message" do
30 | shared_examples_for "push_message spec" do
31 | it "pushes the msg" do
32 | subject.push_message(pushing_key, pushing_msg, pushing_execution_time)
33 | described_class.redis do |conn|
34 | expect(conn.smembers("sidekiq-merger:merges")).to contain_exactly(*merge_keys)
35 | expect(conn.keys("sidekiq-merger:time:*")).to contain_exactly(*times.keys)
36 | expect(conn.keys("sidekiq-merger:unique_msg:*")).to contain_exactly(*unique_msgs_h.keys)
37 | unique_msgs_h.each do |key, msgs|
38 | expect(conn.smembers(key)).to contain_exactly(*msgs)
39 | end
40 | expect(conn.keys("sidekiq-merger:msg:*")).to contain_exactly(*msgs_h.keys)
41 | msgs_h.each do |key, msgs|
42 | expect(conn.lrange(key, 0, -1)).to contain_exactly(*msgs)
43 | end
44 | end
45 | end
46 | it "sets the execution time" do
47 | subject.push_message(pushing_key, pushing_msg, pushing_execution_time)
48 | described_class.redis do |conn|
49 | merge_keys.each do |key, time|
50 | expect(conn.get(key)).to eq time
51 | end
52 | end
53 | end
54 | end
55 |
56 | let(:pushing_key) { "foo" }
57 | let(:pushing_msg) { [1, 2, 3] }
58 | let(:pushing_execution_time) { execution_time }
59 |
60 | include_examples "push_message spec" do
61 | let(:merge_keys) { ["foo"] }
62 | let(:times) { {
63 | "sidekiq-merger:time:foo" => execution_time.to_i.to_s,
64 | } }
65 | let(:unique_msgs_h) { {
66 | "sidekiq-merger:unique_msg:foo" => ["[1,2,3]"]
67 | } }
68 | let(:msgs_h) { {
69 | "sidekiq-merger:msg:foo" => ["[1,2,3]"]
70 | } }
71 | end
72 |
73 | context "the merge key already exists" do
74 | let(:pushing_msg) { [2, 3, 4] }
75 | before { subject.push_message("foo", [1, 2, 3], execution_time) }
76 | include_examples "push_message spec" do
77 | let(:merge_keys) { ["foo"] }
78 | let(:times) { {
79 | "sidekiq-merger:time:foo" => execution_time.to_i.to_s,
80 | } }
81 | let(:unique_msgs_h) { {
82 | "sidekiq-merger:unique_msg:foo" => ["[1,2,3]", "[2,3,4]"]
83 | } }
84 | let(:msgs_h) { {
85 | "sidekiq-merger:msg:foo" => ["[1,2,3]", "[2,3,4]"]
86 | } }
87 | end
88 | end
89 |
90 | context "the msg has already ben pushed" do
91 | before { subject.push_message("foo", [1, 2, 3], execution_time) }
92 | include_examples "push_message spec" do
93 | let(:merge_keys) { ["foo"] }
94 | let(:times) { {
95 | "sidekiq-merger:time:foo" => execution_time.to_i.to_s,
96 | } }
97 | let(:unique_msgs_h) { {
98 | "sidekiq-merger:unique_msg:foo" => ["[1,2,3]"]
99 | } }
100 | let(:msgs_h) { {
101 | "sidekiq-merger:msg:foo" => ["[1,2,3]", "[1,2,3]"]
102 | } }
103 | end
104 | end
105 |
106 | context "other merge key already exists" do
107 | let(:pushing_key) { "bar" }
108 | let(:pushing_msg) { [2, 3, 4] }
109 | let(:pushing_execution_time) { execution_time + 1.hour }
110 | before { subject.push_message("foo", [1, 2, 3], execution_time) }
111 | include_examples "push_message spec" do
112 | let(:merge_keys) { ["foo", "bar"] }
113 | let(:times) { {
114 | "sidekiq-merger:time:foo" => execution_time.to_i.to_s,
115 | "sidekiq-merger:time:bar" => (execution_time + 1.hour).to_i.to_s,
116 | } }
117 | let(:unique_msgs_h) { {
118 | "sidekiq-merger:unique_msg:foo" => ["[1,2,3]"],
119 | "sidekiq-merger:unique_msg:bar" => ["[2,3,4]"],
120 | } }
121 | let(:msgs_h) { {
122 | "sidekiq-merger:msg:foo" => ["[1,2,3]"],
123 | "sidekiq-merger:msg:bar" => ["[2,3,4]"],
124 | } }
125 | end
126 | end
127 | end
128 |
129 | describe "#delete_message" do
130 | before do
131 | subject.redis do |conn|
132 | conn.sadd("sidekiq-merger:unique_msg:foo", "[1,2,3]")
133 | conn.sadd("sidekiq-merger:unique_msg:foo", "[2,3,4]")
134 | conn.lpush("sidekiq-merger:msg:foo", "[1,2,3]")
135 | conn.lpush("sidekiq-merger:msg:foo", "[2,3,4]")
136 | end
137 | end
138 | it "deletes the msg" do
139 | subject.delete_message("foo", [1, 2, 3])
140 | subject.redis do |conn|
141 | expect(conn.smembers("sidekiq-merger:unique_msg:foo")).to contain_exactly "[2,3,4]"
142 | expect(conn.lrange("sidekiq-merger:msg:foo", 0, -1)).to contain_exactly "[2,3,4]"
143 | end
144 | end
145 | context "with duplicate msgs" do
146 | it "deletes the msg" do
147 | subject.redis do |conn|
148 | conn.lpush("sidekiq-merger:msg:foo", "[1,2,3]")
149 | end
150 | subject.delete_message("foo", [1, 2, 3])
151 | subject.redis do |conn|
152 | expect(conn.smembers("sidekiq-merger:unique_msg:foo")).to contain_exactly "[2,3,4]"
153 | expect(conn.lrange("sidekiq-merger:msg:foo", 0, -1)).to contain_exactly "[2,3,4]"
154 | end
155 | end
156 | end
157 | end
158 |
159 | describe "#merge_size" do
160 | before do
161 | subject.redis do |conn|
162 | conn.lpush("sidekiq-merger:msg:foo", "[1,2,3]")
163 | conn.lpush("sidekiq-merger:msg:foo", "[2,3,4]")
164 | end
165 | end
166 | it "returns the size" do
167 | expect(subject.merge_size("foo")).to eq 2
168 | end
169 | end
170 |
171 | describe "#merge_exists?" do
172 | context "unique key exists" do
173 | it "returns true" do
174 | described_class.redis { |conn| conn.sadd("sidekiq-merger:unique_msg:foo", "\"test\"") }
175 | expect(subject.merge_exists?("foo", "test")).to eq true
176 | end
177 | end
178 | context "unique key does not exists" do
179 | it "returns false" do
180 | expect(subject.merge_exists?("foo", "test")).to eq false
181 | end
182 | end
183 | end
184 |
185 | describe "#all_merges" do
186 | before do
187 | subject.push_message("foo", [1, 2, 3], execution_time)
188 | subject.push_message("bar", [2, 3, 4], execution_time)
189 | end
190 | it "gets all the merges" do
191 | expect(subject.all_merges).to contain_exactly "foo", "bar"
192 | end
193 | end
194 |
195 | describe "#lock_merge" do
196 | it "locks the key" do
197 | subject.lock_merge("foo", 3)
198 | subject.redis do |conn|
199 | expect(conn.exists("sidekiq-merger:lock:foo")).to eq true
200 | end
201 | end
202 | end
203 |
204 | describe "#get_merge" do
205 | before do
206 | subject.push_message("bar", [1, 2, 3], execution_time)
207 | subject.push_message("bar", [2, 3, 4], execution_time)
208 | end
209 | it "gets all the msg" do
210 | expect(subject.get_merge("bar")).to contain_exactly [1, 2, 3], [2, 3, 4]
211 | expect(subject.merge_size("bar")).to eq 2
212 | end
213 | end
214 |
215 | describe "#pluck_merge" do
216 | before do
217 | subject.push_message("bar", [1, 2, 3], execution_time)
218 | subject.push_message("bar", [2, 3, 4], execution_time)
219 | end
220 | it "plucks all the msg" do
221 | expect(subject.pluck_merge("bar")).to contain_exactly [1, 2, 3], [2, 3, 4]
222 | expect(subject.merge_size("bar")).to eq 0
223 | end
224 | end
225 |
226 | describe "#delete_merge" do
227 | before do
228 | subject.push_message("foo", [1, 2, 3], execution_time)
229 | subject.push_message("foo", [1, 2, 3], execution_time)
230 | end
231 | it "deletes the merge" do
232 | expect {
233 | subject.delete_merge("foo")
234 | }.to change { subject.merge_size("foo") }.from(2).to(0)
235 | end
236 | end
237 | end
238 |
--------------------------------------------------------------------------------
/spec/sidekiq/merger_spec.rb:
--------------------------------------------------------------------------------
1 | require "spec_helper"
2 |
3 | describe Sidekiq::Merger do
4 | it "has a version number" do
5 | expect(described_class::VERSION).not_to be nil
6 | end
7 | describe ".create_task" do
8 | it "starts a monitoring task" do
9 | task = described_class.create_task
10 | expect(task).to be_a Concurrent::TimerTask
11 | task.shutdown
12 | end
13 | end
14 | describe ".configure" do
15 | it "yields to the config" do
16 | expect { |b| described_class.configure(&b) }.to yield_with_args(described_class.config)
17 | end
18 | end
19 | describe ".config" do
20 | it "returns a config" do
21 | expect(described_class.config).to be_a Sidekiq::Merger::Config
22 | end
23 | context "called twice" do
24 | it "returns the same config instance" do
25 | expect(described_class.config).to be described_class.config
26 | end
27 | end
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/spec/spec_helper.rb:
--------------------------------------------------------------------------------
1 | $LOAD_PATH.unshift File.expand_path("../../lib", __FILE__)
2 | require "rubygems"
3 | require "sidekiq/testing"
4 | require "active_support/core_ext/numeric/time"
5 | require "timecop"
6 | require "simplecov"
7 | require "coveralls"
8 |
9 | pid = Process.pid
10 | SimpleCov.at_exit do
11 | SimpleCov.result.format! if Process.pid == pid
12 | end
13 | SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter[
14 | SimpleCov::Formatter::HTMLFormatter,
15 | Coveralls::SimpleCov::Formatter
16 | ]
17 | SimpleCov.start
18 |
19 | require "sidekiq/merger"
20 |
21 | Dir[File.join(__dir__, "support", "**", "*.rb")].each { |f| require f }
22 |
23 | RSpec.configure do |config|
24 | config.expect_with :rspec do |expectations|
25 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true
26 | end
27 |
28 | config.mock_with :rspec do |mocks|
29 | mocks.verify_partial_doubles = true
30 | end
31 |
32 | if config.files_to_run.one?
33 | config.default_formatter = "doc"
34 | end
35 |
36 | config.order = :random
37 |
38 | Kernel.srand config.seed
39 |
40 | config.before :suite do
41 | Sidekiq::Testing.fake!
42 | Sidekiq::Merger.logger = nil
43 | Sidekiq.logger = nil
44 | if Redis.respond_to?(:exists_returns_integer)
45 | Redis.exists_returns_integer = false
46 | end
47 | end
48 |
49 | config.around :example do |example|
50 | Sidekiq::Merger::Redis.redis { |conn| conn.flushall }
51 | begin
52 | example.run
53 | ensure
54 | Sidekiq::Merger::Redis.redis { |conn| conn.flushall }
55 | end
56 | end
57 |
58 | config.after :example do
59 | Timecop.return
60 | end
61 | end
62 |
--------------------------------------------------------------------------------
/spec/support/matchers.rb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dtaniwaki/sidekiq-merger/b7f67d386dca9631bdc263652412c965b671150d/spec/support/matchers.rb
--------------------------------------------------------------------------------
/spec/support/worker_class.rb:
--------------------------------------------------------------------------------
1 | RSpec.shared_context "worker class", worker_class: true do
2 | let(:worker_options) { { key: -> (args) { "key" } } }
3 | let(:worker_class) do
4 | local_options = worker_options
5 | Class.new do
6 | include Sidekiq::Worker
7 |
8 | sidekiq_options merger: local_options
9 |
10 | def self.name
11 | "SomeWorker"
12 | end
13 |
14 | def self.to_s
15 | "SomeWorker"
16 | end
17 |
18 | def perform(*args)
19 | end
20 | end
21 | end
22 | let(:non_merge_worker_class) do
23 | Class.new do
24 | include Sidekiq::Worker
25 |
26 | def self.to_s
27 | "NonMergeWorker"
28 | end
29 |
30 | def perform(*args)
31 | end
32 | end
33 | end
34 | before :example do
35 | allow(Object).to receive(:const_get).with(anything).and_call_original
36 | allow(Object).to receive(:const_get).with("SomeWorker").and_return worker_class
37 | allow(Object).to receive(:const_get).with("NonMergeWorker").and_return non_merge_worker_class
38 | end
39 | around :example do |example|
40 | worker_class.jobs.clear
41 | non_merge_worker_class.jobs.clear
42 | begin
43 | example.run
44 | ensure
45 | worker_class.jobs.clear
46 | non_merge_worker_class.jobs.clear
47 | end
48 | end
49 | end
50 |
--------------------------------------------------------------------------------