├── .github └── workflows │ └── test.yml ├── .gitignore ├── .rspec ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── examples ├── circuit_breaker.rb └── example.rb ├── expeditor.gemspec ├── lib ├── expeditor.rb └── expeditor │ ├── command.rb │ ├── errors.rb │ ├── rich_future.rb │ ├── ring_buffer.rb │ ├── rolling_number.rb │ ├── service.rb │ ├── services.rb │ ├── services │ └── default.rb │ ├── status.rb │ └── version.rb ├── scripts └── command_performance.rb └── spec ├── expeditor ├── circuit_break_function_spec.rb ├── command_functions_spec.rb ├── command_spec.rb ├── current_thread_function_spec.rb ├── retry_function_spec.rb ├── rich_future_spec.rb ├── ring_buffer_spec.rb ├── rolling_number_spec.rb ├── service_spec.rb └── status_spec.rb ├── spec_helper.rb └── support └── command.rb /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | types: 8 | - opened 9 | - synchronize 10 | - reopened 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | ruby: 18 | - '2.6' 19 | - '2.7' 20 | - '3.0' 21 | - '3.1' 22 | steps: 23 | - uses: actions/checkout@v2 24 | - name: Set up Ruby 25 | uses: ruby/setup-ruby@v1 26 | with: 27 | ruby-version: ${{ matrix.ruby }} 28 | bundler-cache: true 29 | - run: bundle exec rake spec 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | /vendor/ 11 | .ruby-version 12 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.7.1 2 | - Fix Ruby 2 style keyword arguments to support Ruby 3 [#45](https://github.com/cookpad/expeditor/pull/27) 3 | 4 | ## 0.7.0 5 | - Add `gem 'concurrent-ruby-ext'` to your Gemfile if you want to use that gem. 6 | - We should not depend on this in a gemspec [#27](https://github.com/cookpad/expeditor/pull/27) 7 | - Fix possible race conditions. 8 | - Fix bug on cutting passing size [#30](https://github.com/cookpad/expeditor/pull/30) 9 | - Implement sleep feature on circuit breaker [#36](https://github.com/cookpad/expeditor/pull/36) 10 | 11 | ## 0.6.0 12 | - Improve default configuration of circuit breaker [#25](https://github.com/cookpad/expeditor/pull/25) 13 | - Default `non_break_count` is reduced from 100 to 20 14 | - Return proper status of service [#26](https://github.com/cookpad/expeditor/pull/26) 15 | - Use `Expeditor::Service#status` instead of `#current_status` 16 | 17 | ## 0.5.0 18 | - Add a `current_thread` option of `Expeditor::Command#start` method to execute a task on current thread [#13](https://github.com/cookpad/expeditor/pull/13) 19 | - Drop support for MRI 2.0.x [#15](https://github.com/cookpad/expeditor/pull/15) 20 | - Deprecate Expeditor::Command#with_fallback. Use `set_fallback` instead [#14](https://github.com/cookpad/expeditor/pull/14) 21 | - Do not allow set_fallback call after command is started. [#18](https://github.com/cookpad/expeditor/pull/18) 22 | 23 | ## 0.4.0 24 | - Add Expeditor::Service#current\_status [#9](https://github.com/cookpad/expeditor/issues/9) 25 | - Add Expeditor::Service#reset\_status! [#10](https://github.com/cookpad/expeditor/issues/10) 26 | - Add Expeditor::Service#fallback\_enabled [#11](https://github.com/cookpad/expeditor/issues/11) 27 | 28 | ## 0.3.0 29 | - Support concurrent-ruby 1.0. 30 | 31 | ## 0.2.0 32 | - Support concurrent-ruby 0.9. 33 | 34 | ## 0.1.1 35 | - Avoid to use concurrent-ruby 0.9.x. #1 36 | 37 | ## 0.1.0 38 | - First release :tada: 39 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in rystrix.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Cookpad Inc. 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Expeditor 2 | [![Gem Version](https://badge.fury.io/rb/expeditor.svg)](http://badge.fury.io/rb/expeditor) 3 | [![Build Status](https://github.com/cookpad/expeditor/actions/workflows/test.yml/badge.svg)](https://github.com/cookpad/expeditor/actions/workflows/test.yml) 4 | 5 | Expeditor is a Ruby library that provides asynchronous execution and fault tolerance for microservices. 6 | 7 | It is inspired by [Netflix/Hystrix](https://github.com/Netflix/Hystrix). 8 | 9 | ## Installation 10 | 11 | Expeditor currently supports Ruby 2.1 and higher. 12 | 13 | Add this line to your application's Gemfile: 14 | 15 | ```ruby 16 | gem 'expeditor' 17 | ``` 18 | 19 | And then execute: 20 | 21 | $ bundle 22 | 23 | Or install it yourself as: 24 | 25 | $ gem install expeditor 26 | 27 | ## Usage 28 | 29 | ### asynchronous execution 30 | 31 | ```ruby 32 | command1 = Expeditor::Command.new do 33 | ... 34 | end 35 | 36 | command2 = Expeditor::Command.new do 37 | ... 38 | end 39 | 40 | command1.start # non blocking 41 | command2.start # non blocking 42 | 43 | command1.get # wait until command1 execution is finished and get the result 44 | command2.get # wait until command2 execution is finished and get the result 45 | ``` 46 | 47 | ### asynchronous execution with dependencies 48 | 49 | ```ruby 50 | command1 = Expeditor::Command.new do 51 | ... 52 | end 53 | 54 | command2 = Expeditor::Command.new do 55 | ... 56 | end 57 | 58 | command3 = Expeditor::Command.new(dependencies: [command1, command2]) do |val1, val2| 59 | ... 60 | end 61 | 62 | command3.start # command1 and command2 are started concurrently, execution of command3 is wait until command1 and command2 are finished. 63 | ``` 64 | 65 | ### fallback 66 | 67 | ```ruby 68 | command = Expeditor::Command.new do 69 | # something that may be failed 70 | end 71 | 72 | # use fallback value if command is failed 73 | command_with_fallback = command.set_fallback do |e| 74 | log(e) 75 | default_value 76 | end 77 | 78 | command.start.get #=> error may be raised 79 | command_with_fallback.start.get #=> default_value if command is failed 80 | ``` 81 | 82 | If you set `false` to `Expeditor::Service#fallback_enabled`, fallbacks do not occur. It is useful in test codes. 83 | 84 | ### timeout 85 | 86 | ```ruby 87 | command = Expeditor::Command.new(timeout: 1) do 88 | ... 89 | end 90 | 91 | command.start 92 | command.get #=> Timeout::Error is raised if execution is timed out 93 | ``` 94 | 95 | ### retry 96 | 97 | ```ruby 98 | command = Expeditor::Command.new do 99 | ... 100 | end 101 | 102 | # the option is completely same as retryable gem 103 | command.start_with_retry( 104 | tries: 3, 105 | sleep: 1, 106 | on: [StandardError], 107 | ) 108 | ``` 109 | 110 | ### using thread pool 111 | 112 | Expeditor use [concurrent-ruby](https://github.com/ruby-concurrency/concurrent-ruby/)'s executors as thread pool. 113 | 114 | ```ruby 115 | require 'concurrent' 116 | 117 | service = Expeditor::Service.new( 118 | executor: Concurrent::ThreadPoolExecutor.new( 119 | min_threads: 0, 120 | max_threads: 5, 121 | max_queue: 100, 122 | ) 123 | ) 124 | 125 | command = Expeditor::Command.new(service: service) do 126 | ... 127 | end 128 | 129 | service.status 130 | # => # 131 | 132 | service.reset_status! # reset status in the service 133 | ``` 134 | 135 | ### circuit breaker 136 | The circuit breaker needs a service metrics (success, failure, timeout, ...) to decide open the circuit or not. 137 | Expeditor's circuit breaker has a few configuration for how it collects service metrics and how it opens the circuit. 138 | 139 | For service metrics, Expeditor collects them with the given time window. 140 | The metrics is gradually collected by breaking given time window into some peice of short time windows and resetting previous metrics when passing each short time window. 141 | 142 | ```ruby 143 | service = Expeditor::Service.new( 144 | threshold: 0.5, # If the failure rate is more than or equal to threshold, the circuit will be opened. 145 | sleep: 1, # If once the circuit is opened, the circuit is still open until sleep time seconds is passed even though failure rate is less than threshold. 146 | non_break_count: 20, # If the total count of metrics is not more than non_break_count, the circuit is not opened even though failure rate is more than threshold. 147 | period: 10, # Time window of collecting metrics (in seconds). 148 | ) 149 | 150 | command = Expeditor::Command.new(service: service) do 151 | ... 152 | end 153 | ``` 154 | 155 | `non_break_count` is used to ignore requests to the service which is not frequentlly requested. Configure this value considering your estimated "requests per period to the service". 156 | For example, when `period = 10` and `non_break_count = 20` and the requests do not occur more than 20 per 10 seconds, the circuit never opens because Expeditor ignores that "small number of requests". 157 | If you don't ignore the failures in that case, set `non_break_count` to smaller value than `20`. 158 | 159 | The default values are: 160 | 161 | - threshold: 0.5 162 | - sleep: 1 163 | - non_break_count: 20 164 | - period: 10 165 | 166 | ### synchronous execution 167 | 168 | Use `current_thread` option of `#start`, command executes synchronous on current thread. 169 | 170 | ```ruby 171 | command1 = Expeditor::Command.new do 172 | ... 173 | end 174 | 175 | command2 = Expeditor::Command.new do 176 | ... 177 | end 178 | 179 | command1.start(current_thread: true) # blocking 180 | command2.start(current_thread: true) # blocking 181 | 182 | command1.get 183 | command2.get 184 | ``` 185 | 186 | ## Development 187 | 188 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `bin/console` for an interactive prompt that will allow you to experiment. 189 | 190 | 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` to create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 191 | 192 | ## Contributing 193 | 194 | 1. Fork it ( https://github.com/cookpad/expeditor/fork ) 195 | 2. Create your feature branch (`git checkout -b my-new-feature`) 196 | 3. Commit your changes (`git commit -am 'Add some feature'`) 197 | 4. Push to the branch (`git push origin my-new-feature`) 198 | 5. Create a new Pull Request 199 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | task default: [:spec, :performance_test] 6 | 7 | desc 'Check performance' 8 | task :performance_test do 9 | ruby 'scripts/command_performance.rb' 10 | end 11 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "rystrix" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start 15 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | 5 | bundle install 6 | 7 | # Do any other automated setup that you need to do here 8 | -------------------------------------------------------------------------------- /examples/circuit_breaker.rb: -------------------------------------------------------------------------------- 1 | require 'expeditor' 2 | 3 | service = Expeditor::Service.new 4 | 5 | i = 1 6 | loop do 7 | puts '=' * 100 8 | p i 9 | 10 | command = Expeditor::Command.new(service: service, timeout: 1) { 11 | sleep 0.001 # simulate remote resource access 12 | if File.exist?('foo') 13 | 'result' 14 | else 15 | raise 'Demo error' 16 | end 17 | }.set_fallback { |e| 18 | p e 19 | 'default value' 20 | }.start 21 | 22 | p command.get 23 | p service.status 24 | puts 25 | 26 | i += 1 27 | end 28 | -------------------------------------------------------------------------------- /examples/example.rb: -------------------------------------------------------------------------------- 1 | require 'expeditor' 2 | 3 | start_time = Time.now 4 | 5 | # Create new service (it is containing a thread pool and circuit breaker function) 6 | service = Expeditor::Service.new( 7 | executor: Concurrent::ThreadPoolExecutor.new( 8 | min_threads: 5, # minimum number of threads 9 | max_threads: 5, # maximum number of threads 10 | max_queue: 0, # max size of task queue (including executing threads) 11 | ), 12 | non_break_count: 10, # max count of non break 13 | threshold: 0.5, # failure rate to break (0.0 - 1.0) 14 | ) 15 | 16 | # Create commands 17 | command1 = Expeditor::Command.new(service: service) do 18 | sleep 0.1 19 | 'command1' 20 | end 21 | 22 | command2 = Expeditor::Command.new(service: service, timeout: 0.5) do 23 | sleep 1000 24 | 'command2' 25 | end 26 | # command2_d is command2 with fallback 27 | command2_d = command2.set_fallback do |e| 28 | 'command2 fallback' 29 | end 30 | 31 | command3 = Expeditor::Command.new( 32 | service: service, 33 | dependencies: [command1, command2_d] 34 | ) do |v1, v2| 35 | sleep 0.2 36 | v1 + ', ' + v2 37 | end 38 | 39 | command4 = Expeditor::Command.new( 40 | service: service, 41 | dependencies: [command2, command3], 42 | timeout: 1 43 | ) do |v2, v3| 44 | sleep 0.3 45 | v2 + ', ' + v3 46 | end 47 | command4_d = command4.set_fallback do 48 | 'command4 fallback' 49 | end 50 | 51 | # Start command (all dependencies of command4_d are executed. this is non blocking) 52 | command4_d.start 53 | 54 | puts Time.now - start_time #=> 0.00... 55 | puts command1.get #=> command1 56 | puts Time.now - start_time #=> 0.10... 57 | puts command2_d.get #=> command2 fallback 58 | puts Time.now - start_time #=> 0.50... 59 | puts command4_d.get #=> command4 fallback 60 | puts Time.now - start_time #=> 0.50... 61 | puts command3.get #=> command3 62 | puts Time.now - start_time #=> 0.70... 63 | -------------------------------------------------------------------------------- /expeditor.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'expeditor/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "expeditor" 8 | spec.version = Expeditor::VERSION 9 | spec.authors = ["shohei-yasutake"] 10 | spec.email = ["shohei-yasutake@cookpad.com"] 11 | spec.license = "MIT" 12 | 13 | spec.summary = "Expeditor provides asynchronous execution and fault tolerance for microservices" 14 | spec.description = "Expeditor provides asynchronous execution and fault tolerance for microservices" 15 | spec.homepage = "https://github.com/cookpad/expeditor" 16 | 17 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 18 | spec.bindir = "exe" 19 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 20 | spec.require_paths = ["lib"] 21 | 22 | spec.required_ruby_version = '>= 2.1.0' 23 | 24 | spec.add_runtime_dependency "concurrent-ruby", ">= 1.0.0" 25 | spec.add_runtime_dependency "retryable", "> 1.0" 26 | 27 | spec.add_development_dependency "benchmark-ips" 28 | spec.add_development_dependency "bundler" 29 | spec.add_development_dependency "concurrent-ruby-ext", ">= 1.0.0" 30 | spec.add_development_dependency "rake" 31 | spec.add_development_dependency "rspec", ">= 3.0.0" 32 | end 33 | -------------------------------------------------------------------------------- /lib/expeditor.rb: -------------------------------------------------------------------------------- 1 | require 'expeditor/rolling_number' 2 | require 'expeditor/command' 3 | require 'expeditor/errors' 4 | require 'expeditor/rich_future' 5 | require 'expeditor/service' 6 | require 'expeditor/status' 7 | require 'expeditor/version' 8 | -------------------------------------------------------------------------------- /lib/expeditor/command.rb: -------------------------------------------------------------------------------- 1 | require 'concurrent/ivar' 2 | require 'concurrent/executor/safe_task_executor' 3 | require 'concurrent/configuration' 4 | require 'expeditor/errors' 5 | require 'expeditor/rich_future' 6 | require 'expeditor/service' 7 | require 'expeditor/services' 8 | require 'retryable' 9 | require 'timeout' 10 | 11 | module Expeditor 12 | class Command 13 | def initialize(opts = {}, &block) 14 | @service = opts.fetch(:service, Expeditor::Services.default) 15 | @timeout = opts[:timeout] 16 | @dependencies = opts.fetch(:dependencies, []) 17 | 18 | @normal_future = nil 19 | @retryable_options = Concurrent::IVar.new 20 | @normal_block = block 21 | @fallback_block = nil 22 | @ivar = Concurrent::IVar.new 23 | end 24 | 25 | # @param current_thread [Boolean] Execute the task on current thread(blocking) 26 | def start(current_thread: false) 27 | unless started? 28 | if current_thread 29 | prepare(Concurrent::ImmediateExecutor.new) 30 | else 31 | prepare 32 | end 33 | @normal_future.safe_execute 34 | end 35 | self 36 | end 37 | 38 | # Equivalent to retryable gem options 39 | def start_with_retry(current_thread: false, **retryable_options) 40 | unless started? 41 | @retryable_options.set(retryable_options) 42 | start(current_thread: current_thread) 43 | end 44 | self 45 | end 46 | 47 | def started? 48 | @normal_future && @normal_future.executed? 49 | end 50 | 51 | def get 52 | raise NotStartedError unless started? 53 | @normal_future.get_or_else do 54 | if @fallback_block && @service.fallback_enabled? 55 | @ivar.wait 56 | if @ivar.rejected? 57 | raise @ivar.reason 58 | else 59 | @ivar.value 60 | end 61 | else 62 | raise @normal_future.reason 63 | end 64 | end 65 | end 66 | 67 | def set_fallback(&block) 68 | if started? 69 | raise AlreadyStartedError, "Do not allow set_fallback call after command is started" 70 | end 71 | reset_fallback(&block) 72 | self 73 | end 74 | 75 | def with_fallback(&block) 76 | warn 'Expeditor::Command#with_fallback is deprecated. Please use set_fallback instead' 77 | set_fallback(&block) 78 | end 79 | 80 | def wait 81 | raise NotStartedError unless started? 82 | @ivar.wait 83 | end 84 | 85 | # command.on_complete do |success, value, reason| 86 | # ... 87 | # end 88 | def on_complete(&block) 89 | on do |_, value, reason| 90 | block.call(reason == nil, value, reason) 91 | end 92 | end 93 | 94 | # command.on_success do |value| 95 | # ... 96 | # end 97 | def on_success(&block) 98 | on do |_, value, reason| 99 | block.call(value) unless reason 100 | end 101 | end 102 | 103 | # command.on_failure do |e| 104 | # ... 105 | # end 106 | def on_failure(&block) 107 | on do |_, _, reason| 108 | block.call(reason) if reason 109 | end 110 | end 111 | 112 | # XXX: Raise ArgumentError when given `opts` has :dependencies 113 | # because this forcefully change given :dependencies. 114 | # 115 | # `chain` returns new command that has self as dependencies 116 | def chain(opts = {}, &block) 117 | opts[:dependencies] = [self] 118 | Command.new(opts, &block) 119 | end 120 | 121 | def self.const(value) 122 | ConstCommand.new(value) 123 | end 124 | 125 | def self.start(opts = {}, &block) 126 | Command.new(opts, &block).start 127 | end 128 | 129 | private 130 | 131 | # set future 132 | # set fallback future as an observer 133 | # start dependencies 134 | def prepare(executor = @service.executor) 135 | @normal_future = initial_normal(executor, &@normal_block) 136 | @normal_future.add_observer do |_, value, reason| 137 | if reason # failure 138 | if @fallback_block 139 | future = RichFuture.new(executor: executor) do 140 | success, value, reason = Concurrent::SafeTaskExecutor.new(@fallback_block, rescue_exception: true).execute(reason) 141 | if success 142 | @ivar.set(value) 143 | else 144 | @ivar.fail(reason) 145 | end 146 | end 147 | future.safe_execute 148 | else 149 | @ivar.fail(reason) 150 | end 151 | else # success 152 | @ivar.set(value) 153 | end 154 | end 155 | 156 | @dependencies.each(&:start) 157 | end 158 | 159 | # timeout_block do 160 | # retryable_block do 161 | # breakable_block do 162 | # block.call 163 | # end 164 | # end 165 | # end 166 | def initial_normal(executor, &block) 167 | future = RichFuture.new(executor: executor) do 168 | args = wait_dependencies 169 | timeout_block(args, &block) 170 | end 171 | future.add_observer do |_, _, reason| 172 | metrics(reason) 173 | end 174 | future 175 | end 176 | 177 | def wait_dependencies 178 | if @dependencies.count > 0 179 | current = Thread.current 180 | executor = Concurrent::ThreadPoolExecutor.new( 181 | min_threads: 0, 182 | max_threads: 5, 183 | max_queue: 0, 184 | ) 185 | error = Concurrent::IVar.new 186 | error.add_observer do |_, e, _| 187 | executor.shutdown 188 | current.raise(DependencyError.new(e)) 189 | end 190 | args = [] 191 | @dependencies.each_with_index do |c, i| 192 | executor.post do 193 | begin 194 | args[i] = c.get 195 | rescue => e 196 | error.set(e) 197 | end 198 | end 199 | end 200 | executor.shutdown 201 | executor.wait_for_termination 202 | args 203 | else 204 | [] 205 | end 206 | end 207 | 208 | def timeout_block(args, &block) 209 | if @timeout 210 | Timeout::timeout(@timeout) do 211 | retryable_block(args, &block) 212 | end 213 | else 214 | retryable_block(args, &block) 215 | end 216 | end 217 | 218 | def retryable_block(args, &block) 219 | if @retryable_options.fulfilled? 220 | Retryable.retryable(@retryable_options.value) do |retries, exception| 221 | metrics(exception) if retries > 0 222 | breakable_block(args, &block) 223 | end 224 | else 225 | breakable_block(args, &block) 226 | end 227 | end 228 | 229 | def breakable_block(args, &block) 230 | @service.run_if_allowed do 231 | block.call(*args) 232 | end 233 | end 234 | 235 | def metrics(reason) 236 | case reason 237 | when nil 238 | @service.success 239 | when Timeout::Error 240 | @service.timeout 241 | when RejectedExecutionError 242 | @service.rejection 243 | when CircuitBreakError 244 | @service.break 245 | when DependencyError 246 | @service.dependency 247 | else 248 | @service.failure 249 | end 250 | end 251 | 252 | def reset_fallback(&block) 253 | @fallback_block = block 254 | end 255 | 256 | def on(&callback) 257 | @ivar.add_observer(&callback) 258 | end 259 | 260 | class ConstCommand < Command 261 | def initialize(value) 262 | super(){ value } 263 | self.start 264 | end 265 | end 266 | end 267 | end 268 | -------------------------------------------------------------------------------- /lib/expeditor/errors.rb: -------------------------------------------------------------------------------- 1 | require 'concurrent/errors' 2 | 3 | module Expeditor 4 | NotStartedError = Class.new(StandardError) 5 | RejectedExecutionError = Concurrent::RejectedExecutionError 6 | CircuitBreakError = Class.new(StandardError) 7 | AlreadyStartedError = Class.new(StandardError) 8 | class DependencyError < StandardError 9 | attr :error 10 | def initialize(e) 11 | @error = e 12 | end 13 | 14 | def message 15 | @error.message 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/expeditor/rich_future.rb: -------------------------------------------------------------------------------- 1 | require 'concurrent/configuration' 2 | require 'concurrent/future' 3 | 4 | module Expeditor 5 | class RichFuture < Concurrent::Future 6 | def get 7 | wait 8 | if rejected? 9 | raise reason 10 | else 11 | value 12 | end 13 | end 14 | 15 | def get_or_else(&block) 16 | wait 17 | if rejected? 18 | block.call 19 | else 20 | value 21 | end 22 | end 23 | 24 | def set(v) 25 | complete(true, v, nil) 26 | end 27 | 28 | def safe_set(v) 29 | set(v) unless complete? 30 | end 31 | 32 | def fail(e) 33 | super(e) 34 | end 35 | 36 | def safe_fail(e) 37 | fail(e) unless complete? 38 | end 39 | 40 | def executed? 41 | not unscheduled? 42 | end 43 | 44 | def safe_execute(*args) 45 | if args.empty? 46 | begin 47 | execute 48 | rescue Exception => e 49 | fail(e) 50 | end 51 | else 52 | super(*args) 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/expeditor/ring_buffer.rb: -------------------------------------------------------------------------------- 1 | module Expeditor 2 | # Circular buffer with user-defined initialization and optimized `move` 3 | # implementation. The next element will be always initialized with 4 | # user-defined initialization proc. 5 | # 6 | # Thread unsafe. 7 | class RingBuffer 8 | # @params [Integer] size 9 | def initialize(size, &initialize_proc) 10 | raise ArgumentError.new('initialize_proc is not given') unless initialize_proc 11 | @size = size 12 | @initialize_proc = initialize_proc 13 | @elements = Array.new(@size, &initialize_proc) 14 | @current_index = 0 15 | end 16 | 17 | # @return [Object] user created object with given initialization proc. 18 | def current 19 | @elements[@current_index] 20 | end 21 | 22 | # @params [Integer] times How many elements will we pass. 23 | # @return [Object] current element after moving. 24 | def move(times) 25 | cut_moving_time_if_possible(times).times do 26 | next_element 27 | end 28 | end 29 | 30 | # @return [Array] Array of elements. 31 | def all 32 | @elements 33 | end 34 | 35 | private 36 | 37 | # This logic is used for cutting moving times. When moving times is greater 38 | # than statuses size, we can cut moving times to less than statuses size 39 | # because the statuses are circulated. 40 | # 41 | # `*` is current index. 42 | # When the statuses size is 3: 43 | # 44 | # [*, , ] 45 | # 46 | # Then when the moving times = 3, current index will be 0 (0-origin): 47 | # 48 | # [*, , ] -3> [ ,*, ] -2> [ , ,*] -1> [*, , ] 49 | # 50 | # Then moving times = 6, current index will be 0 again: 51 | # 52 | # [*, , ] -6> [ ,*, ] -5> [ , ,*] -4> [*, , ] -3> [ ,*, ] -2> [ , ,*] -1> [*, , ] 53 | # 54 | # In that case we can cut the moving times from 6 to 3. 55 | # That is "cut moving times" here. 56 | # 57 | # TODO: We can write more optimized code which resets all elements with 58 | # Array.new if given moving times is greater than `@size`. 59 | def cut_moving_time_if_possible(times) 60 | if times >= @size * 2 61 | (times % @size) + @size 62 | else 63 | times 64 | end 65 | end 66 | 67 | def next_element 68 | if @current_index == @size - 1 69 | @current_index = 0 70 | else 71 | @current_index += 1 72 | end 73 | @elements[@current_index] = @initialize_proc.call 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /lib/expeditor/rolling_number.rb: -------------------------------------------------------------------------------- 1 | require 'expeditor/status' 2 | require 'expeditor/ring_buffer' 3 | 4 | module Expeditor 5 | # A RollingNumber holds some Status objects and it rolls statuses each 6 | # `per_time` (default is 1 second). This is done so that the statistics are 7 | # recorded gradually with short time interval rahter than reset all the 8 | # record every wide time range (default is 10 seconds). 9 | class RollingNumber 10 | def initialize(size:, per_time:) 11 | @mutex = Mutex.new 12 | @ring = RingBuffer.new(size) do 13 | Expeditor::Status.new 14 | end 15 | @per_time = per_time 16 | @current_start = Time.now 17 | end 18 | 19 | # @params [Symbol] type 20 | def increment(type) 21 | @mutex.synchronize do 22 | update 23 | @ring.current.increment(type) 24 | end 25 | end 26 | 27 | # @return [Expeditor::Status] Newly created status 28 | def total 29 | @mutex.synchronize do 30 | update 31 | @ring.all.inject(Expeditor::Status.new) {|i, s| i.merge!(s) } 32 | end 33 | end 34 | 35 | # @deprecated Don't use, use `#total` instead. 36 | def current 37 | warn 'Expeditor::RollingNumber#current is deprecated. Please use #total instead to fetch correct status object.' 38 | @mutex.synchronize do 39 | update 40 | @ring.current 41 | end 42 | end 43 | 44 | private 45 | 46 | def update 47 | passing = last_passing 48 | if passing > 0 49 | @current_start = @current_start + @per_time * passing 50 | @ring.move(passing) 51 | end 52 | end 53 | 54 | def last_passing 55 | (Time.now - @current_start).div(@per_time) 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/expeditor/service.rb: -------------------------------------------------------------------------------- 1 | require 'concurrent/executor/thread_pool_executor' 2 | 3 | module Expeditor 4 | class Service 5 | attr_reader :executor 6 | attr_accessor :fallback_enabled 7 | 8 | def initialize(opts = {}) 9 | @mutex = Mutex.new 10 | @executor = opts.fetch(:executor) { Concurrent::ThreadPoolExecutor.new } 11 | @threshold = opts.fetch(:threshold, 0.5) 12 | @non_break_count = opts.fetch(:non_break_count, 20) 13 | @sleep = opts.fetch(:sleep, 1) 14 | granularity = 10 15 | @rolling_number_opts = { 16 | size: granularity, 17 | per_time: opts.fetch(:period, 10).to_f / granularity 18 | } 19 | reset_status! 20 | @fallback_enabled = true 21 | end 22 | 23 | def success 24 | @rolling_number.increment :success 25 | end 26 | 27 | def failure 28 | @rolling_number.increment :failure 29 | end 30 | 31 | def rejection 32 | @rolling_number.increment :rejection 33 | end 34 | 35 | def timeout 36 | @rolling_number.increment :timeout 37 | end 38 | 39 | def break 40 | @rolling_number.increment :break 41 | end 42 | 43 | def dependency 44 | @rolling_number.increment :dependency 45 | end 46 | 47 | def fallback_enabled? 48 | !!fallback_enabled 49 | end 50 | 51 | def breaking? 52 | @breaking 53 | end 54 | 55 | # Run given block when the request is allowed, otherwise raise 56 | # Expeditor::CircuitBreakError. When breaking and sleep time was passed, 57 | # the circuit breaker tries to close the circuit. So subsequent single 58 | # command execution is allowed (will not be breaked) to check the service 59 | # is healthy or not. The circuit breaker only allows one request so other 60 | # subsequent requests will be aborted with CircuitBreakError. When the test 61 | # request succeeds, the circuit breaker resets the service status and 62 | # closes the circuit. 63 | def run_if_allowed 64 | if @breaking 65 | now = Time.now 66 | 67 | # Only one thread can be allowed to execute single request when half-opened. 68 | allow_single_request = false 69 | @mutex.synchronize do 70 | allow_single_request = now - @break_start > @sleep 71 | @break_start = now if allow_single_request 72 | end 73 | 74 | if allow_single_request 75 | result = yield # This can be raise exception. 76 | # The execution succeed, then 77 | reset_status! 78 | result 79 | else 80 | raise CircuitBreakError 81 | end 82 | else 83 | open = calc_open 84 | if open 85 | change_state(true, Time.now) 86 | raise CircuitBreakError 87 | else 88 | yield 89 | end 90 | end 91 | end 92 | 93 | # shutdown thread pool 94 | # after shutdown, if you create thread, RejectedExecutionError is raised. 95 | def shutdown 96 | @executor.shutdown 97 | end 98 | 99 | def status 100 | @rolling_number.total 101 | end 102 | 103 | # @deprecated Use `#status` instead. 104 | def current_status 105 | warn 'Expeditor::Service#current_status is deprecated. Please use #status instead.' 106 | @rolling_number.current 107 | end 108 | 109 | def reset_status! 110 | @mutex.synchronize do 111 | @rolling_number = Expeditor::RollingNumber.new(**@rolling_number_opts) 112 | @breaking = false 113 | @break_start = nil 114 | end 115 | end 116 | 117 | private 118 | 119 | def calc_open 120 | s = @rolling_number.total 121 | total_count = s.success + s.failure + s.timeout 122 | if total_count >= [@non_break_count, 1].max 123 | failure_count = s.failure + s.timeout 124 | failure_count.to_f / total_count.to_f >= @threshold 125 | else 126 | false 127 | end 128 | end 129 | 130 | def change_state(breaking, break_start) 131 | @mutex.synchronize do 132 | @breaking = breaking 133 | @break_start = break_start 134 | end 135 | end 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /lib/expeditor/services.rb: -------------------------------------------------------------------------------- 1 | require 'concurrent/configuration' 2 | require 'expeditor/services/default' 3 | 4 | module Expeditor 5 | module Services 6 | DEFAULT_SERVICE = Expeditor::Services::Default.new 7 | private_constant :DEFAULT_SERVICE 8 | 9 | def default 10 | DEFAULT_SERVICE 11 | end 12 | module_function :default 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/expeditor/services/default.rb: -------------------------------------------------------------------------------- 1 | require 'concurrent/configuration' 2 | require 'expeditor/service' 3 | 4 | module Expeditor 5 | module Services 6 | class Default < Expeditor::Service 7 | def initialize 8 | @executor = Concurrent.global_io_executor 9 | @bucket = nil 10 | @fallback_enabled = true 11 | end 12 | 13 | def success 14 | end 15 | 16 | def failure 17 | end 18 | 19 | def rejection 20 | end 21 | 22 | def timeout 23 | end 24 | 25 | def break 26 | end 27 | 28 | def dependency 29 | end 30 | 31 | def run_if_allowed 32 | yield 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/expeditor/status.rb: -------------------------------------------------------------------------------- 1 | module Expeditor 2 | # Thread unsafe. 3 | class Status 4 | attr_reader :success 5 | attr_reader :failure 6 | attr_reader :rejection 7 | attr_reader :timeout 8 | attr_reader :break 9 | attr_reader :dependency 10 | 11 | def initialize 12 | set(0, 0, 0, 0, 0, 0) 13 | end 14 | 15 | def increment(type, i = 1) 16 | case type 17 | when :success 18 | @success += i 19 | when :failure 20 | @failure += i 21 | when :rejection 22 | @rejection += i 23 | when :timeout 24 | @timeout += i 25 | when :break 26 | @break += i 27 | when :dependency 28 | @dependency += i 29 | else 30 | raise ArgumentError.new("Unknown type: #{type}") 31 | end 32 | end 33 | 34 | def merge!(other) 35 | increment(:success, other.success) 36 | increment(:failure, other.failure) 37 | increment(:rejection, other.rejection) 38 | increment(:timeout, other.timeout) 39 | increment(:break, other.break) 40 | increment(:dependency, other.dependency) 41 | self 42 | end 43 | 44 | def reset 45 | set(0, 0, 0, 0, 0, 0) 46 | end 47 | 48 | private 49 | 50 | def set(s, f, r, t, b, d) 51 | @success = s 52 | @failure = f 53 | @rejection = r 54 | @timeout = t 55 | @break = b 56 | @dependency = d 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/expeditor/version.rb: -------------------------------------------------------------------------------- 1 | module Expeditor 2 | VERSION = "0.7.1" 3 | end 4 | -------------------------------------------------------------------------------- /scripts/command_performance.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift(File.expand_path('../lib', __dir__)) 2 | require 'expeditor' 3 | 4 | require 'benchmark/ips' 5 | 6 | Benchmark.ips do |x| 7 | x.report("simple command") do |i| 8 | executor = Concurrent::ThreadPoolExecutor.new(min_threads: 100, max_threads: 100, max_queue: 100) 9 | service = Expeditor::Service.new(period: 10, non_break_count: 0, threshold: 0.5, sleep: 1, executor: executor) 10 | 11 | i.times do 12 | commands = 10000.times.map do 13 | Expeditor::Command.new { 1 }.start 14 | end 15 | command = Expeditor::Command.new(service: service, dependencies: commands) do |*vs| 16 | vs.inject(0, &:+) 17 | end.start 18 | command.get 19 | 20 | service.reset_status! 21 | end 22 | end 23 | 24 | x.compare! 25 | end 26 | -------------------------------------------------------------------------------- /spec/expeditor/circuit_break_function_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe Expeditor::Command do 4 | describe 'circuit break function' do 5 | context 'with circuit break' do 6 | it 'should reject execution' do 7 | executor = Concurrent::ThreadPoolExecutor.new(max_queue: 0) 8 | service = Expeditor::Service.new(executor: executor, threshold: 0.1, non_break_count: 2, sleep: 1, period: 10) 9 | 10 | 3.times do 11 | Expeditor::Command.new(service: service) do 12 | raise RuntimeError 13 | end.start.wait 14 | end 15 | expect(service.breaking?).to eq(true) 16 | 17 | command = Expeditor::Command.new(service: service) { 42 }.start 18 | expect { command.get }.to raise_error(Expeditor::CircuitBreakError) 19 | 20 | service.shutdown 21 | end 22 | 23 | it 'should not count circuit break' do 24 | service = Expeditor::Service.new(threshold: 0, non_break_count: 0) 25 | 5.times do 26 | Expeditor::Command.new(service: service) do 27 | raise Expeditor::CircuitBreakError 28 | end.start.wait 29 | end 30 | 31 | command = Expeditor::Command.new(service: service) { 42 }.start 32 | expect(command.get).to eq(42) 33 | 34 | service.shutdown 35 | end 36 | end 37 | 38 | context 'with circuit break and wait' do 39 | it 'should reject execution and back' do 40 | sleep_value = 0.03 41 | config = { threshold: 0.1, non_break_count: 5, sleep: sleep_value, period: 0.1 } 42 | service = Expeditor::Service.new(config) 43 | failure_commands = 10.times.map do 44 | Expeditor::Command.new(service: service) do 45 | raise RuntimeError 46 | end 47 | end 48 | failure_commands.each(&:start) 49 | failure_commands.each(&:wait) 50 | expect(service.breaking?).to eq(true) 51 | 52 | # Store break count to compare later. 53 | last_breaked_count = service.status.break 54 | 55 | success_commands = 5.times.map do 56 | Expeditor::Command.new(service: service) { 0 } 57 | end 58 | success_commands.each(&:start) 59 | success_commands.each(&:wait) 60 | # The executions were short circuited. 61 | expect(service.breaking?).to eq(true) 62 | expect(service.status.break).to be > last_breaked_count 63 | 64 | # Wait sleep time then circuit bacomes half-open. 65 | sleep sleep_value + 0.01 66 | 67 | # The circuit is half-open now so the circuit breaker allow single 68 | # request to check the dependent service is healthy or not. The circuit 69 | # breaker will only allow single request, so subsequent requests will 70 | # trip the circuit. When the test request succeeds, the circuit breaker 71 | # will reset the status. 72 | command = Expeditor::Command.new(service: service) { sleep 0.01; 1 }.start 73 | command2 = Expeditor::Command.new(service: service) { 1 }.start 74 | expect { command2.get }.to raise_error(Expeditor::CircuitBreakError) 75 | expect(command.get).to eq(1) 76 | expect(service.status.success).to eq(1) 77 | expect(service.status.failure).to eq(0) 78 | 79 | # Since the last execution was succeed, the circuit becames closed. 80 | expect(service.breaking?).to eq(false) 81 | command = Expeditor::Command.new(service: service) { 1 }.start 82 | expect(command.get).to eq(1) 83 | 84 | service.shutdown 85 | end 86 | end 87 | 88 | context 'with circuit break (large case)' do 89 | specify 'circuit will be opened after 100 failure and skip success_commands' do 90 | service = Expeditor::Service.new( 91 | executor: Concurrent::ThreadPoolExecutor.new(max_threads: 100), 92 | threshold: 0.1, 93 | non_break_count: 1000, 94 | period: 100, 95 | sleep: 100, # Should be larger than test case execution time. 96 | ) 97 | 98 | # At first, runs failure_commands and open the circuit. 99 | failure_commands = 2000.times.map do 100 | Expeditor::Command.new(service: service) do 101 | raise RuntimeError 102 | end.set_fallback { 1 }.start 103 | end 104 | 105 | # Then runs success_commands but it will be skiped and calls fallback logic. 106 | success_commands = 8000.times.map do 107 | Expeditor::Command.new(service: service) do 108 | raise "Won't reach here" 109 | end.set_fallback { 1 }.start 110 | end 111 | 112 | reason = nil 113 | result = Object.new 114 | deps = failure_commands + success_commands 115 | command = Expeditor::Command.new(service: service, dependencies: deps) do |_| 116 | raise "Won't reach here" 117 | end.set_fallback do |e| 118 | reason = e 119 | result 120 | end 121 | command.start 122 | 123 | expect(command.get).to equal(result) 124 | expect(reason).to be_instance_of(Expeditor::CircuitBreakError) 125 | service.shutdown 126 | end 127 | end 128 | 129 | context "with dependency's error of circuit break" do 130 | let(:executor) { Concurrent::ThreadPoolExecutor.new(max_threads: 100) } 131 | let(:service) { Expeditor::Service.new(executor: executor, threshold: 0.2, non_break_count: 10, period: 10, sleep: 5) } 132 | 133 | it 'should not fall deadlock' do 134 | failure_commands = 20.times.map do 135 | Expeditor::Command.new(service: service) do 136 | raise RuntimeError 137 | end.set_fallback do 138 | 1 139 | end 140 | end 141 | success_commands = 80.times.map do 142 | Expeditor::Command.new(service: service) do 143 | 1 144 | end 145 | end 146 | command = Expeditor::Command.new( 147 | service: service, 148 | dependencies: failure_commands + success_commands, 149 | ) do |*vs| 150 | vs.inject(:+) 151 | end.set_fallback do |e| 152 | 0 153 | end 154 | command.start 155 | expect(command.get).to eq(0) 156 | service.shutdown 157 | end 158 | end 159 | end 160 | end 161 | -------------------------------------------------------------------------------- /spec/expeditor/command_functions_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe Expeditor::Command do 4 | let(:error_in_command) { Class.new(StandardError) } 5 | 6 | describe 'dependencies function' do 7 | context 'with normal and no sleep' do 8 | it 'should be ok' do 9 | command1 = simple_command('The world of truth is...: ') 10 | command2 = simple_command(42) 11 | command3 = Expeditor::Command.new(dependencies: [command1, command2]) do |v1, v2| 12 | v1 + v2.to_s 13 | end 14 | command3.start 15 | expect(command3.get).to eq('The world of truth is...: 42') 16 | end 17 | end 18 | 19 | context 'with normal and sleep' do 20 | let(:event) { Concurrent::Event.new } 21 | 22 | it 'should start dependencies concurrently' do 23 | command1 = Expeditor::Command.new { event.wait(1); 1 } 24 | command2 = Expeditor::Command.new { event.set; 2 } 25 | command3 = Expeditor::Command.new(dependencies: [command1, command2]) do |v1, v2| 26 | v1 + v2 27 | end 28 | command3.start 29 | expect(command3.get).to eq(3) 30 | end 31 | end 32 | 33 | context 'with failure' do 34 | it 'should throw error DependencyError' do 35 | command1 = simple_command(42) 36 | command2 = error_command(error_in_command) 37 | command3 = Expeditor::Command.new(dependencies: [command1, command2]) do |v1, v2| 38 | v1 + v2 39 | end 40 | command3.start 41 | expect { command3.get }.to raise_error(Expeditor::DependencyError) 42 | end 43 | end 44 | 45 | context 'with sleep and failure' do 46 | let(:sleep_time) { 1 } 47 | 48 | it 'should throw error immediately' do 49 | command1 = sleep_command(sleep_time, 42) 50 | command2 = error_command(error_in_command) 51 | command3 = Expeditor::Command.new(dependencies: [command1, command2]) do |v1, v2| 52 | v1 + v2 53 | end 54 | 55 | command3.start 56 | start = Time.now 57 | expect { command3.get }.to raise_error(Expeditor::DependencyError) 58 | expect(Time.now - start).to be < sleep_time 59 | end 60 | end 61 | 62 | context 'with large number of horizontal dependencies' do 63 | it 'should be ok' do 64 | commands = 100.times.map do 65 | simple_command(1) 66 | end 67 | command = Expeditor::Command.new(dependencies: commands) do |*vs| 68 | vs.inject(:+) 69 | end 70 | command.start 71 | expect(command.get).to eq(100) 72 | end 73 | end 74 | 75 | context 'with large number of horizontal dependencies ^ 2 (long test case)' do 76 | it 'should be ok' do 77 | commands = 20.times.map do 78 | dependencies = 20.times.map do 79 | simple_command(1) 80 | end 81 | Expeditor::Command.new(dependencies: dependencies) do |*vs| 82 | vs.inject(:+) 83 | end 84 | end 85 | command = Expeditor::Command.new(dependencies: commands) do |*vs| 86 | vs.inject(:+) 87 | end 88 | command.start 89 | expect(command.get).to eq(400) 90 | end 91 | end 92 | 93 | context 'with large number of vertical dependencies' do 94 | it 'should be ok' do 95 | command0 = simple_command(0) 96 | command = 100.times.inject(command0) do |c| 97 | Expeditor::Command.new(dependencies: [c]) do |v| 98 | v + 1 99 | end 100 | end 101 | command.start 102 | expect(command.get).to eq(100) 103 | end 104 | end 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /spec/expeditor/command_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe Expeditor::Command do 4 | let(:error_in_command) { Class.new(StandardError) } 5 | 6 | describe '#start' do 7 | context 'with normal' do 8 | it 'should not block' do 9 | command = sleep_command(1, 42) 10 | 11 | start = Time.now 12 | command.start 13 | expect(Time.now - start).to be < 1 14 | end 15 | 16 | it 'should return self' do 17 | command = simple_command(42) 18 | expect(command.start).to eq(command) 19 | end 20 | 21 | it 'should ignore from the second time' do 22 | count = Concurrent::CAtomicFixnum.new(0) 23 | command = Expeditor::Command.new do 24 | count.increment 25 | count 26 | end 27 | 3.times { command.start } 28 | expect(command.get.value).to eq(1) 29 | expect(count.value).to eq(1) 30 | end 31 | end 32 | 33 | context 'with thread pool overflow' do 34 | it 'should throw RejectedExecutionError in #get, not #start' do 35 | service = Expeditor::Service.new(executor: Concurrent::ThreadPoolExecutor.new(max_threads: 1, min_threads: 1, max_queue: 1)) 36 | mutex = Mutex.new 37 | command1 = Expeditor::Command.new(service: service) do 38 | begin 39 | mutex.lock 40 | 1 41 | ensure 42 | mutex.unlock 43 | end 44 | end 45 | command2 = simple_command(2, service: service) 46 | command3 = simple_command(3, service: service) 47 | 48 | mutex.lock 49 | command1.start 50 | command2.start 51 | command3.start 52 | mutex.unlock 53 | 54 | expect(command1.get).to eq(1) 55 | expect(command2.get).to eq(2) 56 | expect { command3.get }.to raise_error(Expeditor::RejectedExecutionError) 57 | service.shutdown 58 | end 59 | end 60 | 61 | context 'with double starting and circuit breaking' do 62 | let(:period) { 0.01 } 63 | let(:service) { Expeditor::Service.new(threshold: 0.1, non_break_count: 0, sleep: 0, period: period) } 64 | let(:commands) do 65 | 1000.times.map do 66 | error_command(error_in_command, service: service).set_fallback { 1 } 67 | end 68 | end 69 | 70 | # See original test case in df2ea9a957f2eea3889f73187d1915f3eee998a9. 71 | it 'should not throw MultipleAssignmentError' do 72 | 10.times { commands.each(&:start) } 73 | commands.each(&:wait) 74 | # Wait until circuit is closed. 75 | sleep period * 2 76 | 77 | command = Expeditor::Command.start(service: service, dependencies: commands) do |*vs| 78 | vs.inject(:+) 79 | end 80 | expect(command.get).to eq(1000) 81 | service.shutdown 82 | end 83 | end 84 | end 85 | 86 | describe '#started?' do 87 | context 'with started' do 88 | it 'should be true' do 89 | command = simple_command(42) 90 | command.start 91 | expect(command.started?).to be true 92 | end 93 | end 94 | 95 | context 'with not started' do 96 | it 'should be falsy' do 97 | command = simple_command(42) 98 | expect(command.started?).to be_falsy 99 | end 100 | end 101 | 102 | context 'with fallback' do 103 | it 'should be true (both) if the command with no fallback is started' do 104 | command = simple_command(42) 105 | fallback_command = command.set_fallback { 0 } 106 | expect(command.started?).to be_falsy 107 | expect(fallback_command.started?).to be_falsy 108 | command.start 109 | expect(command.started?).to be true 110 | expect(fallback_command.started?).to be true 111 | end 112 | 113 | it 'should be true (both) if the command with fallback is started' do 114 | command = simple_command(42) 115 | fallback_command = command.set_fallback { 0 } 116 | expect(command.started?).to be_falsy 117 | expect(fallback_command.started?).to be_falsy 118 | fallback_command.start 119 | expect(command.started?).to be true 120 | expect(fallback_command.started?).to be true 121 | end 122 | end 123 | end 124 | 125 | describe '#get' do 126 | context 'with success' do 127 | it 'should return success value' do 128 | command = simple_command(42).start 129 | expect(command.get).to eq(42) 130 | end 131 | end 132 | 133 | context 'with sleep and success' do 134 | let(:sleep_time) { 0.001 } 135 | let(:command) { sleep_command(sleep_time, 42).start } 136 | 137 | it 'should block and return success value' do 138 | start = Time.new 139 | expect(command.get).to eq(42) 140 | expect(Time.now - start).to be > sleep_time 141 | end 142 | end 143 | 144 | context 'with failure' do 145 | it 'should throw exception' do 146 | command = error_command(error_in_command).start 147 | expect { command.get }.to raise_error(error_in_command) 148 | end 149 | 150 | it 'should throw exception without deadlock' do 151 | error = Class.new(Exception) 152 | command = error_command(error).start 153 | expect { command.get }.to raise_error(error) 154 | end 155 | end 156 | 157 | context 'with not started' do 158 | it 'should throw NotStartedError' do 159 | command = simple_command(42) 160 | expect { command.get }.to raise_error(Expeditor::NotStartedError) 161 | end 162 | end 163 | 164 | context 'with timeout' do 165 | let(:sleep_time) { 1 } 166 | let(:command) { sleep_command(sleep_time, 42, timeout: 0.001) } 167 | before { command.start } 168 | 169 | it 'should throw Timeout::Error' do 170 | start = Time.now 171 | expect { command.get }.to raise_error(Timeout::Error) 172 | expect(Time.now - start).to be < sleep_time 173 | end 174 | end 175 | end 176 | 177 | describe '#set_fallback' do 178 | it 'should return new command' do 179 | command = simple_command(42) 180 | fallback_command = command.set_fallback do 181 | 0 182 | end 183 | expect(fallback_command).to eq(command) 184 | end 185 | 186 | it 'should not block' do 187 | command = error_command(error_in_command) 188 | start_time = Time.now 189 | fallback_command = command.set_fallback do 190 | sleep 1 191 | 0 192 | end 193 | expect(Time.now - start_time).to be < 1 194 | end 195 | 196 | context 'with normal success' do 197 | it 'should return normal result' do 198 | command = simple_command(42).set_fallback { 0 }.start 199 | expect(command.get).to eq(42) 200 | end 201 | end 202 | 203 | context 'after #start called' do 204 | it 'should throw AlreadyStartedError' do 205 | command = simple_command(42) 206 | command.start 207 | expect { command.set_fallback{} }.to raise_error(Expeditor::AlreadyStartedError) 208 | end 209 | end 210 | end 211 | 212 | describe '#wait' do 213 | let(:sleep_time) { 0.001 } 214 | 215 | context 'without fallback' do 216 | it 'should wait execution' do 217 | command = sleep_command(sleep_time, nil) 218 | command.start 219 | 220 | start_time = Time.now 221 | command.wait 222 | expect(Time.now - start_time).to be > sleep_time 223 | end 224 | end 225 | 226 | context 'with fallback' do 227 | it 'should wait execution' do 228 | command = Expeditor::Command.new { 229 | sleep sleep_time 230 | raise error_in_command 231 | }.set_fallback { 232 | sleep sleep_time 233 | nil 234 | } 235 | 236 | start_time = Time.now 237 | command.start.wait 238 | expect(Time.now - start_time).to be > (sleep_time * 2) 239 | end 240 | end 241 | 242 | context 'with fallback but normal success' do 243 | let(:sleep_time) { 10 } 244 | 245 | it 'should not wait fallback execution' do 246 | command = simple_command(42).set_fallback do 247 | sleep sleep_time 248 | 0 249 | end 250 | 251 | start_time = Time.now 252 | command.start.wait 253 | expect(Time.now - start_time).to be < sleep_time 254 | expect(command.get).to eq(42) 255 | end 256 | end 257 | 258 | context 'with fail both' do 259 | let(:error_in_fallback) { Class.new(Exception) } 260 | 261 | it 'should throw fallback error' do 262 | command = error_command(error_in_command).set_fallback do 263 | raise error_in_fallback 264 | end 265 | command.start 266 | expect { command.get }.to raise_error(error_in_fallback) 267 | end 268 | end 269 | 270 | context 'with large number of commands' do 271 | it 'should not throw any errors' do 272 | service = Expeditor::Service.new(executor: Concurrent::ThreadPoolExecutor.new(max_threads: 10, min_threads: 10, max_queue: 100)) 273 | commands = 100.times.map do 274 | Expeditor::Command.new(service: service) do 275 | raise error_in_command 276 | end.set_fallback do |e| 277 | 1 278 | end 279 | end 280 | commands.each(&:start) 281 | sum = commands.map(&:get).inject(:+) 282 | expect(sum).to eq(100) 283 | service.shutdown 284 | end 285 | end 286 | 287 | context 'with not started' do 288 | it 'should throw NotStartedError without waiting' do 289 | command = sleep_command(sleep_time, 42) 290 | start = Time.now 291 | expect { command.wait }.to raise_error(Expeditor::NotStartedError) 292 | expect(Time.now - start).to be < sleep_time 293 | end 294 | end 295 | end 296 | 297 | describe '#on_complete' do 298 | context 'with normal success and without fallback' do 299 | it 'should run callback with success' do 300 | command = simple_command(42) 301 | success, value, reason = nil, nil, nil 302 | command.on_complete do |s, v, r| 303 | success, value, reason = s, v, r 304 | end 305 | command.start.wait 306 | expect(success).to be true 307 | expect(value).to eq(42) 308 | expect(reason).to be_nil 309 | end 310 | end 311 | 312 | context 'with normal success and with fallback' do 313 | it 'should run callback with success' do 314 | command = simple_command(42).set_fallback { 0 } 315 | success, value, reason = nil, nil, nil 316 | command.on_complete do |s, v, r| 317 | success, value, reason = s, v, r 318 | end 319 | command.start.wait 320 | expect(success).to be true 321 | expect(value).to eq(42) 322 | expect(reason).to be_nil 323 | end 324 | end 325 | 326 | context 'with normal failure and without fallback' do 327 | it 'should run callback with failure' do 328 | command = error_command(error_in_command) 329 | success, value, reason = nil, nil, nil 330 | command.on_complete do |s, v, r| 331 | success, value, reason = s, v, r 332 | end 333 | command.start.wait 334 | expect(success).to be false 335 | expect(value).to be_nil 336 | expect(reason).to be_instance_of(error_in_command) 337 | end 338 | end 339 | 340 | context 'with normal failure and with fallback success' do 341 | it 'should run callback with success' do 342 | command = error_command(error_in_command).set_fallback { 0 } 343 | success, value, reason = nil, nil, nil 344 | command.on_complete do |s, v, r| 345 | success, value, reason = s, v, r 346 | end 347 | command.start.wait 348 | expect(success).to be true 349 | expect(value).to eq(0) 350 | expect(reason).to be_nil 351 | end 352 | end 353 | 354 | context 'with normal failure and with fallback failure' do 355 | it 'should run callback with failure' do 356 | command = error_command(error_in_command).set_fallback do |e| 357 | raise e 358 | end 359 | success, value, reason = nil, nil, nil 360 | command.on_complete do |s, v, r| 361 | success, value, reason = s, v, r 362 | end 363 | command.start.wait 364 | expect(success).to be false 365 | expect(value).to be_nil 366 | expect(reason).to be_instance_of(error_in_command) 367 | end 368 | end 369 | end 370 | 371 | describe '#on_success' do 372 | context 'with normal success and without fallback' do 373 | it 'should run callback' do 374 | command = simple_command(42) 375 | res = nil 376 | command.on_success do |v| 377 | res = v 378 | end 379 | command.start.wait 380 | expect(res).to eq(42) 381 | end 382 | end 383 | 384 | context 'with normal success and with fallback' do 385 | it 'should run callback' do 386 | command = simple_command(42).set_fallback { 0 } 387 | res = nil 388 | command.on_success do |v| 389 | res = v 390 | end 391 | command.start.wait 392 | expect(res).to eq(42) 393 | end 394 | end 395 | 396 | context 'with normal failure and without fallback' do 397 | it 'should not run callback' do 398 | command = error_command(error_in_command) 399 | res = nil 400 | command.on_success do |v| 401 | res = v 402 | end 403 | command.start.wait 404 | expect(res).to be_nil 405 | end 406 | end 407 | 408 | context 'with normal failure and with fallback success' do 409 | it 'should run callback' do 410 | command = error_command(error_in_command).set_fallback do 411 | 0 412 | end 413 | res = nil 414 | command.on_success do |v| 415 | res = v 416 | end 417 | command.start.wait 418 | expect(res).to eq(0) 419 | end 420 | end 421 | 422 | context 'with normal failure and with fallback failure' do 423 | it 'should not run callback' do 424 | command = error_command(error_in_command).set_fallback do |e| 425 | raise e 426 | end 427 | res = nil 428 | command.on_success do |v| 429 | res = v 430 | end 431 | command.start.wait 432 | expect(res).to be_nil 433 | end 434 | end 435 | end 436 | 437 | describe '#on_failure' do 438 | context 'with normal success and without fallback' do 439 | it 'should not run callback' do 440 | command = simple_command(42) 441 | flag = false 442 | command.on_failure do |e| 443 | flag = true 444 | end 445 | command.start.wait 446 | expect(flag).to be false 447 | end 448 | end 449 | 450 | context 'with normal failure and without fallback' do 451 | it 'should run callback' do 452 | command = error_command(error_in_command) 453 | flag = false 454 | command.on_failure do |e| 455 | flag = true 456 | end 457 | command.start.wait 458 | expect(flag).to be true 459 | end 460 | end 461 | 462 | context 'with normal success and with fallback' do 463 | it 'should not run callback' do 464 | command = simple_command(42).set_fallback do 465 | 0 466 | end 467 | flag = false 468 | command.on_failure do |e| 469 | flag = true 470 | end 471 | command.start.wait 472 | expect(flag).to be false 473 | end 474 | end 475 | 476 | context 'with normal failure and with fallback success' do 477 | it 'should not run callback' do 478 | command = error_command(error_in_command).set_fallback do 479 | 0 480 | end 481 | flag = false 482 | command.on_failure do |e| 483 | flag = true 484 | end 485 | command.start.wait 486 | expect(flag).to be false 487 | end 488 | end 489 | 490 | context 'with normal failure and with fallback failure' do 491 | it 'should run callback' do 492 | command = error_command(error_in_command).set_fallback do |e| 493 | raise e 494 | end 495 | flag = false 496 | command.on_failure do |e| 497 | flag = true 498 | end 499 | command.start.wait 500 | expect(flag).to be true 501 | end 502 | end 503 | end 504 | 505 | describe '#chain' do 506 | context 'with normal' do 507 | it 'should chain command' do 508 | command_double = simple_command(42).chain do |n| 509 | n * 2 510 | end 511 | command_double.start 512 | expect(command_double.get).to eq(84) 513 | end 514 | end 515 | 516 | context 'with options' do 517 | it 'should recognize options' do 518 | command_sleep = simple_command(42).chain(timeout: 0.001) do 519 | sleep 0.1 520 | nil 521 | end.start 522 | expect { command_sleep.get }.to raise_error(Timeout::Error) 523 | end 524 | end 525 | end 526 | 527 | describe '.const' do 528 | it 'should be ok' do 529 | command = Expeditor::Command.const(42) 530 | expect(command.started?).to be true 531 | expect(command.start).to eq(command) 532 | expect(command.get).to eq(42) 533 | end 534 | end 535 | 536 | describe '.start' do 537 | it 'should be already started' do 538 | command = Expeditor::Command.start do 539 | 42 540 | end 541 | expect(command.started?).to be true 542 | expect(command.get).to be 42 543 | end 544 | end 545 | end 546 | -------------------------------------------------------------------------------- /spec/expeditor/current_thread_function_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe Expeditor::Command do 4 | let(:error_in_command) { Class.new(StandardError) } 5 | 6 | describe '#start' do 7 | context 'with normal' do 8 | it 'executes on current thread' do 9 | Thread.current.thread_variable_set('foo', 'bar') 10 | command = Expeditor::Command.new do 11 | Thread.current.thread_variable_get('foo') 12 | end 13 | expect(command.start(current_thread: true).get).to eq('bar') 14 | end 15 | 16 | it 'returns self' do 17 | command = simple_command(42) 18 | expect(command.start(current_thread: true)).to eq(command) 19 | end 20 | 21 | it 'ignores from the second time' do 22 | count = 0 23 | command = Expeditor::Command.new do 24 | count += 1 25 | count 26 | end 27 | command.start(current_thread: true) 28 | command.start(current_thread: true) 29 | command.start(current_thread: true) 30 | expect(command.get).to eq(1) 31 | expect(count).to eq(1) 32 | end 33 | end 34 | 35 | context 'with fallback' do 36 | it 'works fallback proc' do 37 | command = error_command(error_in_command) 38 | command.set_fallback do 39 | 42 40 | end 41 | 42 | expect(command.start(current_thread: true).get).to eq(42) 43 | end 44 | 45 | it 'works fallback on current thread' do 46 | Thread.current.thread_variable_set("count", 1) 47 | command = Expeditor::Command.new do 48 | count = Thread.current.thread_variable_get("count") 49 | count += 1 50 | Thread.current.thread_variable_set("count", count) # => 2 51 | raise error_in_command 52 | end 53 | 54 | command.set_fallback do 55 | count = Thread.current.thread_variable_get("count") 56 | count += 1 57 | count # => 3 58 | end 59 | 60 | expect(command.start(current_thread: true).get).to eq(3) 61 | end 62 | end 63 | 64 | context 'explicitly specify `current_thread: false`' do 65 | it 'is asynchronous' do 66 | command = sleep_command(0.2, nil) 67 | start_time = Time.now 68 | command.start(current_thread: false) 69 | expect(Time.now - start_time).to be < 0.2 70 | end 71 | 72 | it 'does not execute on current thread' do 73 | Thread.current.thread_variable_set('foo', 1) 74 | command = Expeditor::Command.new do 75 | Thread.current.thread_variable_get('foo') 76 | end 77 | command.start(current_thread: false) 78 | expect(command.get).to eq nil 79 | end 80 | end 81 | end 82 | 83 | describe '#start_with_retry' do 84 | context 'with 3 tries' do 85 | it 'executes 3 times on current thread' do 86 | Thread.current.thread_variable_set('count', 0) 87 | command = Expeditor::Command.new do 88 | count = Thread.current.thread_variable_get('count') 89 | count += 1 90 | Thread.current.thread_variable_set('count', count) 91 | raise RuntimeError 92 | end 93 | command.start_with_retry(tries: 3, sleep: 0, current_thread: true) 94 | expect { command.get }.to raise_error(RuntimeError) 95 | expect(Thread.current.thread_variable_get('count')).to eq 3 96 | end 97 | end 98 | 99 | context 'explicitly specify `current_thread: false`' do 100 | it 'is asynchronous' do 101 | command = sleep_command(0.2, nil) 102 | start_time = Time.now 103 | command.start_with_retry(current_thread: false) 104 | expect(Time.now - start_time).to be < 0.2 105 | end 106 | 107 | it 'does not execute on current thread' do 108 | Thread.current.thread_variable_set('foo', 1) 109 | command = Expeditor::Command.new do 110 | Thread.current.thread_variable_get('foo') 111 | end 112 | command.start_with_retry(current_thread: false) 113 | expect(command.get).to eq nil 114 | end 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /spec/expeditor/retry_function_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe Expeditor::Command do 4 | describe '#start_with_retry' do 5 | context 'with 3 tries' do 6 | it 'should be run 3 times' do 7 | count = 0 8 | command = Expeditor::Command.new do 9 | count += 1 10 | raise RuntimeError 11 | end 12 | command.start_with_retry(tries: 3, sleep: 0) 13 | expect { command.get }.to raise_error(RuntimeError) 14 | expect(count).to eq(3) 15 | end 16 | end 17 | 18 | context 'with 0 tries' do 19 | it 'should be run 0 time and return nil' do 20 | count = 0 21 | command = Expeditor::Command.new do 22 | count += 1 23 | raise RuntimeError 24 | end 25 | command.start_with_retry(tries: 0, sleep: 0) 26 | expect(command.get).to be_nil 27 | expect(count).to eq(0) 28 | end 29 | end 30 | 31 | context 'with retry -1 time' do 32 | it 'should be run 1 times' do 33 | count = 0 34 | command = Expeditor::Command.new do 35 | count += 1 36 | raise RuntimeError 37 | end 38 | command.start_with_retry(tries: -1, sleep: 0) 39 | expect { command.get }.to raise_error(RuntimeError) 40 | expect(count).to eq(1) 41 | end 42 | end 43 | 44 | context 'with passsing error' do 45 | it 'should not retry' do 46 | count = 0 47 | command = Expeditor::Command.new do 48 | count += 1 49 | raise RuntimeError 50 | end 51 | command.start_with_retry(tries: 5, sleep: 0, on: ArgumentError) 52 | expect { command.get }.to raise_error(RuntimeError) 53 | expect(count).to eq(1) 54 | end 55 | end 56 | 57 | context 'with retry in case of only specified errors' do 58 | it 'should retry' do 59 | count = 0 60 | command = Expeditor::Command.new do 61 | count += 1 62 | raise RuntimeError if count < 2 63 | raise ArgumentError if count < 3 64 | raise StandardError 65 | end 66 | command.start_with_retry(tries: 5, sleep: 0, on: [ArgumentError, RuntimeError]) 67 | expect { command.get }.to raise_error(StandardError) 68 | expect(count).to eq(3) 69 | end 70 | end 71 | 72 | context 'with retry and timeout' do 73 | it 'should be timed out when over time' do 74 | command = Expeditor::Command.new(timeout: 0.01) do 75 | raise RuntimeError 76 | end 77 | command.start_with_retry(tries: 100, sleep: 0.001) 78 | expect { command.get }.to raise_error(Timeout::Error) 79 | end 80 | end 81 | 82 | context 'with retry and circuit break' do 83 | it 'should break when over threshold' do 84 | service = Expeditor::Service.new(threshold: 1, non_break_count: 100) 85 | command = Expeditor::Command.new(service: service) do 86 | raise RuntimeError 87 | end 88 | command.start_with_retry(tries: 101, sleep: 0, on: [RuntimeError]) 89 | expect { command.get }.to raise_error(Expeditor::CircuitBreakError) 90 | end 91 | end 92 | 93 | context 'with retry with fallback' do 94 | it 'should retry if start fallback command' do 95 | count = 0 96 | command = Expeditor::Command.new do 97 | count += 1 98 | raise RuntimeError 99 | end.set_fallback do 100 | 42 101 | end 102 | command.start_with_retry(tries: 10, sleep: 0) 103 | expect(command.get).to eq(42) 104 | expect(count).to eq(10) 105 | end 106 | 107 | it 'should retry if start normal command' do 108 | count = 0 109 | command = Expeditor::Command.new do 110 | count += 1 111 | raise RuntimeError 112 | end 113 | command_f = command.set_fallback do 114 | 42 115 | end 116 | command.start_with_retry(tries: 10, sleep: 0) 117 | expect(command_f.get).to eq(42) 118 | expect(count).to eq(10) 119 | end 120 | end 121 | 122 | context 'with (1) start and (2) start_with_retry' do 123 | it 'should ignore start_with_retry' do 124 | count = 0 125 | command = Expeditor::Command.new do 126 | count += 1 127 | raise RuntimeError 128 | end 129 | command.start 130 | command.start_with_retry(tries: 10, sleep: 0) 131 | command.wait 132 | expect(count).to eq(1) 133 | end 134 | end 135 | 136 | context 'with (1) start_with_retry and (2) start' do 137 | it 'should ignore start' do 138 | count = 0 139 | command = Expeditor::Command.new do 140 | count += 1 141 | raise RuntimeError 142 | end 143 | command.start_with_retry(tries: 10, sleep: 0) 144 | command.start 145 | command.wait 146 | expect(count).to eq(10) 147 | end 148 | end 149 | end 150 | end 151 | -------------------------------------------------------------------------------- /spec/expeditor/rich_future_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe Expeditor::RichFuture do 4 | let(:error_in_future) { Class.new(StandardError) } 5 | 6 | describe '#get' do 7 | context 'with success' do 8 | it 'should return normal value' do 9 | future = Expeditor::RichFuture.new do 10 | 42 11 | end 12 | future.execute 13 | expect(future.get).to eq(42) 14 | end 15 | end 16 | 17 | context 'with failure' do 18 | it 'should raise exception' do 19 | future = Expeditor::RichFuture.new do 20 | raise error_in_future 21 | end 22 | future.execute 23 | expect { future.get }.to raise_error(error_in_future) 24 | end 25 | end 26 | end 27 | 28 | describe '#get_or_else' do 29 | context 'with success' do 30 | it 'should return normal value' do 31 | future = Expeditor::RichFuture.new do 32 | 42 33 | end 34 | future.execute 35 | expect(future.get_or_else { 0 }).to eq(42) 36 | end 37 | end 38 | 39 | context 'with recover' do 40 | it 'should raise exception' do 41 | future = Expeditor::RichFuture.new do 42 | raise error_in_future 43 | end 44 | future.execute 45 | expect(future.get_or_else { 0 }).to eq(0) 46 | end 47 | end 48 | 49 | context 'with also failure' do 50 | let(:error_in_fallback) { Class.new(StandardError) } 51 | 52 | it 'should raise exception' do 53 | future = Expeditor::RichFuture.new do 54 | raise error_in_future 55 | end 56 | future.execute 57 | expect { future.get_or_else { raise error_in_fallback } }.to raise_error(error_in_fallback) 58 | end 59 | end 60 | end 61 | 62 | describe '#set' do 63 | it 'should success immediately' do 64 | future = Expeditor::RichFuture.new do 65 | sleep 1000 66 | raise error_in_future 67 | end 68 | future.execute 69 | future.set(42) 70 | expect(future.complete?).to be true 71 | expect(future.fulfilled?).to be true 72 | expect(future.get).to eq(42) 73 | end 74 | 75 | it 'should notify to observer' do 76 | future = Expeditor::RichFuture.new do 77 | sleep 1000 78 | raise error_in_future 79 | end 80 | value = nil 81 | future.add_observer do |_, v, _| 82 | value = v 83 | end 84 | future.set(42) 85 | expect(value).to eq(42) 86 | end 87 | 88 | it 'should throw error if it is already completed' do 89 | future = Expeditor::RichFuture.new do 90 | 42 91 | end 92 | future.execute 93 | future.wait 94 | expect { future.set(0) }.to raise_error(Concurrent::MultipleAssignmentError) 95 | end 96 | end 97 | 98 | describe '#safe_set' do 99 | it 'should set immediately' do 100 | future = Expeditor::RichFuture.new do 101 | sleep 1000 102 | raise error_in_future 103 | end 104 | future.execute 105 | future.safe_set(42) 106 | expect(future.complete?).to be true 107 | expect(future.fulfilled?).to be true 108 | expect(future.get).to eq(42) 109 | end 110 | 111 | it 'should not throw error although it is already completed' do 112 | future = Expeditor::RichFuture.new do 113 | 42 114 | end 115 | future.execute 116 | future.wait 117 | future.safe_set(0) 118 | end 119 | 120 | it 'should ignore if it is already completed' do 121 | future = Expeditor::RichFuture.new do 122 | 42 123 | end 124 | future.execute 125 | future.wait 126 | future.safe_set(0) 127 | expect(future.value).to eq(42) 128 | end 129 | end 130 | 131 | describe '#fail' do 132 | it 'should fail immediately' do 133 | future = Expeditor::RichFuture.new do 134 | sleep 1000 135 | 42 136 | end 137 | future.execute 138 | future.fail(error_in_future.new) 139 | expect(future.complete?).to be true 140 | expect(future.rejected?).to be true 141 | expect(future.reason).to be_instance_of(error_in_future) 142 | end 143 | 144 | it 'should notify to observer' do 145 | future = Expeditor::RichFuture.new do 146 | sleep 1000 147 | 42 148 | end 149 | reason = nil 150 | future.add_observer do |_, _, r| 151 | reason = r 152 | end 153 | future.fail(error_in_future.new) 154 | expect(reason).to be_instance_of(error_in_future) 155 | end 156 | 157 | it 'should throw error if it is already completed' do 158 | future = Expeditor::RichFuture.new do 159 | 42 160 | end 161 | future.execute 162 | future.wait 163 | expect { future.fail(error_in_future.new) }.to raise_error(Concurrent::MultipleAssignmentError) 164 | end 165 | end 166 | 167 | describe '#safe_fail' do 168 | it 'should fail immediately' do 169 | future = Expeditor::RichFuture.new do 170 | sleep 1000 171 | 42 172 | end 173 | future.execute 174 | future.safe_fail(error_in_future.new) 175 | expect(future.complete?).to be true 176 | expect(future.rejected?).to be true 177 | expect(future.reason).to be_instance_of(error_in_future) 178 | end 179 | 180 | it 'should not throw error although it is already completed' do 181 | future = Expeditor::RichFuture.new do 182 | 42 183 | end 184 | future.execute 185 | future.wait 186 | future.safe_fail(error_in_future.new) 187 | end 188 | 189 | it 'should ignore if it is already completed' do 190 | future = Expeditor::RichFuture.new do 191 | 42 192 | end 193 | future.execute 194 | future.wait 195 | future.safe_fail(error_in_future.new) 196 | expect(future.value).to eq(42) 197 | end 198 | end 199 | 200 | describe '#executed?' do 201 | context 'with executed' do 202 | it 'should be true' do 203 | future = Expeditor::RichFuture.new do 204 | 42 205 | end 206 | future.execute 207 | expect(future.executed?).to be true 208 | end 209 | end 210 | 211 | context 'with not executed' do 212 | it 'should be false' do 213 | future = Expeditor::RichFuture.new do 214 | 42 215 | end 216 | expect(future.executed?).to be false 217 | end 218 | end 219 | end 220 | 221 | describe '#execute' do 222 | context 'with thread pool overflow' do 223 | it 'should throw RejectedExecutionError' do 224 | executor = Concurrent::ThreadPoolExecutor.new( 225 | min_threads: 1, 226 | max_threads: 1, 227 | max_queue: 1, 228 | ) 229 | mutex = Mutex.new 230 | future1 = Expeditor::RichFuture.new(executor: executor) do 231 | begin 232 | mutex.lock 233 | 42 234 | ensure 235 | mutex.unlock 236 | end 237 | end 238 | future2 = Expeditor::RichFuture.new(executor: executor) do 239 | 42 240 | end 241 | future3 = Expeditor::RichFuture.new(executor: executor) do 242 | 42 243 | end 244 | mutex.lock 245 | begin 246 | future1.execute 247 | future2.execute 248 | expect { future3.execute }.to raise_error(Expeditor::RejectedExecutionError) 249 | ensure 250 | mutex.unlock 251 | end 252 | end 253 | end 254 | end 255 | 256 | describe '#safe_execute' do 257 | context 'with thread pool overflow' do 258 | it 'should not throw RejectedExecutionError' do 259 | executor = Concurrent::ThreadPoolExecutor.new( 260 | min_threads: 1, 261 | max_threads: 1, 262 | max_queue: 1, 263 | ) 264 | futures = 10.times.map do 265 | Expeditor::RichFuture.new(executor: executor) do 266 | sleep 1 267 | 42 268 | end 269 | end 270 | expect { futures.each(&:safe_execute) }.to_not raise_error 271 | futures.each(&:wait) 272 | expect(futures.first.get).to eq(42) 273 | expect { futures.last.get }.to raise_error(Expeditor::RejectedExecutionError) 274 | end 275 | end 276 | end 277 | end 278 | -------------------------------------------------------------------------------- /spec/expeditor/ring_buffer_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe Expeditor::RingBuffer do 4 | def build 5 | Expeditor::RingBuffer.new(3) { 1 } 6 | end 7 | 8 | describe '#all' do 9 | it 'returns all elements' do 10 | expect(build.all).to eq([1, 1, 1]) 11 | end 12 | end 13 | 14 | describe '#current' do 15 | it 'returns current element' do 16 | expect(build.current).to eq(1) 17 | end 18 | end 19 | 20 | describe '#move' do 21 | let(:size) { 3 } 22 | let(:dirty_ring) { Expeditor::RingBuffer.new(size) { '' } } 23 | before do 24 | dirty_ring.current << '0' 25 | (size - 1).times do |i| 26 | dirty_ring.move(1) 27 | dirty_ring.current << (i + 1).to_s 28 | end 29 | end 30 | 31 | context 'when times < size' do 32 | it 'moves given times with initialization' do 33 | expect(dirty_ring.current).to eq('2') 34 | expect(dirty_ring.all).to eq(%w[0 1 2]) 35 | 36 | dirty_ring.move(1) 37 | expect(dirty_ring.current).to eq('') 38 | end 39 | end 40 | 41 | context 'when times > size' do 42 | it 'moves given times with initialization' do 43 | dirty_ring.move(size + 1) 44 | expect(dirty_ring.all).to eq(['', '', '']) 45 | end 46 | end 47 | 48 | context 'when optimized situation (time > size * 2)' do 49 | it 'moves given times with initialization' do 50 | dirty_ring.move(size * 2 + 1) 51 | expect(dirty_ring.all).to eq(['', '', '']) 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /spec/expeditor/rolling_number_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe Expeditor::RollingNumber do 4 | describe '#increment' do 5 | context 'with same status' do 6 | it 'should be increased' do 7 | rolling_number = Expeditor::RollingNumber.new(size: 10, per_time: 1) 8 | rolling_number.increment :success 9 | rolling_number.increment :success 10 | rolling_number.increment :success 11 | expect(rolling_number.current.success).to eq(3) 12 | end 13 | end 14 | 15 | context 'across statuses' do 16 | it 'should be ok' do 17 | rolling_number = Expeditor::RollingNumber.new(size: 10, per_time: 0.01) 18 | rolling_number.increment :success 19 | sleep 0.01 20 | rolling_number.increment :success 21 | expect(rolling_number.current.success).to eq(1) 22 | end 23 | end 24 | end 25 | 26 | describe '#total' do 27 | context 'with no limit exceeded' do 28 | it 'should be ok' do 29 | size = 1000 30 | per_time = 0.001 31 | rolling_number = Expeditor::RollingNumber.new(size: size, per_time: per_time) 32 | 20.times do |n| 33 | rolling_number.increment :success 34 | sleep 0.002 35 | end 36 | expect(rolling_number.current.success).not_to eq(20) 37 | expect(rolling_number.total.success).to eq(20) 38 | end 39 | end 40 | 41 | context 'with limit exceeded' do 42 | it 'should be ok' do 43 | size = 5 44 | per_time = 0.001 45 | rolling_number = Expeditor::RollingNumber.new(size: size, per_time: per_time) 46 | 10.times do 47 | rolling_number.increment :success 48 | end 49 | sleep 0.008 50 | expect(rolling_number.total.success).to eq(0) 51 | end 52 | end 53 | end 54 | 55 | context 'passing many (> size) sliced time' do 56 | let(:size) { 3 } 57 | let(:per_time) { 0.01 } 58 | 59 | it 'resets all statuses' do 60 | rolling_number = Expeditor::RollingNumber.new(size: size, per_time: per_time) 61 | # Make all statuses dirty. 62 | 3.times do 63 | 3.times { rolling_number.increment(:success) } 64 | sleep per_time 65 | end 66 | # Move to next rolling_number. 67 | sleep per_time 68 | # Pass size + 1 rolling_numbers. 69 | sleep per_time * size 70 | expect(rolling_number.total.success).to eq(0) 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /spec/expeditor/service_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe Expeditor::Service do 4 | describe '#run_if_allowed' do 5 | context 'with no count' do 6 | it 'runs given block' do 7 | options = { 8 | threshold: 0, 9 | non_break_count: 0, 10 | } 11 | service = Expeditor::Service.new(options) 12 | expect(service.run_if_allowed { 1 }).to be(1) 13 | end 14 | end 15 | 16 | context 'within non_break_count' do 17 | it 'runs given block' do 18 | options = { 19 | threshold: 0.0, 20 | non_break_count: 100, 21 | } 22 | service = Expeditor::Service.new(options) 23 | 99.times do 24 | service.failure 25 | end 26 | expect(service.run_if_allowed { 1 }).to be(1) 27 | end 28 | end 29 | 30 | context 'with non_break_count exceeded but not exceeded threshold' do 31 | it 'runs given block' do 32 | options = { 33 | threshold: 0.2, 34 | non_break_count: 100, 35 | } 36 | service = Expeditor::Service.new(options) 37 | 81.times do 38 | service.success 39 | end 40 | 19.times do 41 | service.failure 42 | end 43 | expect(service.run_if_allowed { 1 }).to be(1) 44 | end 45 | end 46 | 47 | context 'with non_break_count and threshold exceeded' do 48 | it 'raises CircuitBreakError' do 49 | options = { 50 | threshold: 0.2, 51 | non_break_count: 100, 52 | } 53 | service = Expeditor::Service.new(options) 54 | 80.times do 55 | service.success 56 | end 57 | 20.times do 58 | service.failure 59 | end 60 | 61 | expect { 62 | service.run_if_allowed { 1 } 63 | }.to raise_error(Expeditor::CircuitBreakError) 64 | end 65 | end 66 | end 67 | 68 | describe '#shutdown' do 69 | let(:executor) { Concurrent::ThreadPoolExecutor.new(min_threads: 2, max_threads: 2, max_queue: 1000) } 70 | let(:service) { Expeditor::Service.new(executor: executor) } 71 | 72 | it 'should reject execution' do 73 | service.shutdown 74 | command = Expeditor::Command.start(service: service) do 75 | 42 76 | end 77 | expect { command.get }.to raise_error(Expeditor::RejectedExecutionError) 78 | end 79 | 80 | it 'should not kill queued tasks' do 81 | commands = (1..10).map do |i| 82 | Expeditor::Command.new(service: service) do 83 | sleep 0.001 84 | 1 85 | end 86 | end 87 | commands.each(&:start) 88 | service.shutdown 89 | expect(commands.map(&:get).inject(0, &:+)).to eq(10) 90 | end 91 | end 92 | 93 | describe '#status' do 94 | it 'returns current status' do 95 | # Set large value of period in case test takes long time. 96 | service = Expeditor::Service.new(period: 100) 97 | 98 | 3.times do 99 | Expeditor::Command.new(service: service) { 100 | raise 101 | }.set_fallback { nil }.start.get 102 | end 103 | 104 | expect(service.status.success).to eq(0) 105 | expect(service.status.failure).to eq(3) 106 | end 107 | end 108 | 109 | describe '#current_status' do 110 | it 'warns deprecation' do 111 | service = Expeditor::Service.new 112 | expect { 113 | service.current_status 114 | }.to output(/current_status is deprecated/).to_stderr 115 | end 116 | end 117 | 118 | describe '#reset_status!' do 119 | let(:service) { Expeditor::Service.new(non_break_count: 1, threshold: 0.1) } 120 | 121 | it "resets the service's status" do 122 | 2.times do 123 | service.failure 124 | end 125 | expect { 126 | service.run_if_allowed { 1 } 127 | }.to raise_error(Expeditor::CircuitBreakError) 128 | expect(service.breaking?).to be(true) 129 | 130 | service.reset_status! 131 | expect(service.breaking?).to be(false) 132 | end 133 | end 134 | 135 | describe '#fallback_enabled' do 136 | let(:service) { Expeditor::Service.new(period: 10) } 137 | 138 | context 'fallback_enabled is true' do 139 | before do 140 | service.fallback_enabled = true 141 | end 142 | 143 | it 'returns fallback value' do 144 | result = Expeditor::Command.new(service: service) { 145 | raise 'error!' 146 | }.set_fallback { 147 | 0 148 | }.start.get 149 | expect(result).to eq(0) 150 | end 151 | end 152 | 153 | context 'fallback_enabled is false' do 154 | before do 155 | service.fallback_enabled = false 156 | end 157 | 158 | it 'does not call fallback and raises error' do 159 | expect { 160 | Expeditor::Command.new(service: service) { 161 | raise 'error!' 162 | }.set_fallback { 163 | 0 164 | }.start.get 165 | }.to raise_error(RuntimeError, 'error!') 166 | end 167 | end 168 | end 169 | end 170 | -------------------------------------------------------------------------------- /spec/expeditor/status_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe Expeditor::Status do 4 | describe '#initialize' do 5 | it 'should be zero all' do 6 | status = Expeditor::Status.new 7 | expect(status.success).to eq(0) 8 | expect(status.failure).to eq(0) 9 | expect(status.rejection).to eq(0) 10 | expect(status.timeout).to eq(0) 11 | end 12 | end 13 | 14 | describe '#increment' do 15 | context 'with success increment' do 16 | it 'should be increased only success' do 17 | status = Expeditor::Status.new 18 | status.increment :success 19 | expect(status.success).to eq(1) 20 | expect(status.failure).to eq(0) 21 | expect(status.rejection).to eq(0) 22 | expect(status.timeout).to eq(0) 23 | end 24 | 25 | it 'should be increased normally if #increment is called in parallel' do 26 | # XXX: Remove this example... 27 | skip 'Status is not thread safe now and it have no need to be so...' 28 | 29 | status = Expeditor::Status.new 30 | threads = 1000.times.map do 31 | Thread.start do 32 | status.increment :success 33 | end 34 | end 35 | threads.each(&:join) 36 | expect(status.success).to eq(1000) 37 | end 38 | end 39 | 40 | context 'with all increment' do 41 | it 'should be increased all' do 42 | status = Expeditor::Status.new 43 | status.increment :success 44 | status.increment :failure 45 | status.increment :rejection 46 | status.increment :timeout 47 | expect(status.success).to eq(1) 48 | expect(status.failure).to eq(1) 49 | expect(status.rejection).to eq(1) 50 | expect(status.timeout).to eq(1) 51 | end 52 | end 53 | end 54 | 55 | describe '#merge!' do 56 | let(:original) { Expeditor::Status.new } 57 | let(:other) { Expeditor::Status.new } 58 | before do 59 | i = 1 60 | [original, other].each do |s| 61 | %i[success failure rejection timeout].each do |type| 62 | s.increment(type, i) 63 | i += 1 64 | end 65 | end 66 | end 67 | 68 | it 'merges destructively and returns self' do 69 | back = original.success 70 | result = original.merge!(other) 71 | 72 | expect(result.success).not_to eq(back) 73 | expect(result.success).to eq(original.success) 74 | expect(result.success).not_to eq(other.success) 75 | expect(result).to equal(original) 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 2 | require 'expeditor' 3 | 4 | Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each { |f| require f } 5 | 6 | RSpec.configure do |config| 7 | config.expect_with :rspec do |expectations| 8 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 9 | end 10 | 11 | config.mock_with :rspec do |mocks| 12 | mocks.verify_partial_doubles = true 13 | end 14 | 15 | config.shared_context_metadata_behavior = :apply_to_host_groups 16 | 17 | config.filter_run_when_matching :focus 18 | 19 | config.example_status_persistence_file_path = "tmp/rspec_examples.txt" 20 | 21 | config.disable_monkey_patching! 22 | 23 | config.warnings = false 24 | 25 | if config.files_to_run.one? 26 | config.default_formatter = 'doc' 27 | end 28 | 29 | config.profile_examples = 10 30 | 31 | config.order = :random 32 | 33 | Kernel.srand config.seed 34 | end 35 | -------------------------------------------------------------------------------- /spec/support/command.rb: -------------------------------------------------------------------------------- 1 | module CommandHelpers 2 | def simple_command(v, opts = {}) 3 | Expeditor::Command.new(opts) do 4 | v 5 | end 6 | end 7 | 8 | def sleep_command(n, v, opts = {}) 9 | Expeditor::Command.new(opts) do 10 | sleep n 11 | v 12 | end 13 | end 14 | 15 | def error_command(e, opts = {}) 16 | Expeditor::Command.new(opts) do 17 | raise e 18 | end 19 | end 20 | end 21 | 22 | RSpec.configure do |c| 23 | c.include CommandHelpers 24 | end 25 | --------------------------------------------------------------------------------