r4r 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,8 @@
1
+ require "mkmf"
2
+
3
+ $CFLAGS = "-O2 -Wall -std=c99"
4
+
5
+ extension_name = 'system_clock_ext'
6
+ dir_config(extension_name)
7
+ create_makefile "#{extension_name}"
8
+
@@ -0,0 +1,31 @@
1
+ #include <ruby.h>
2
+ #include <sys/time.h>
3
+
4
+ /**
5
+ * Returns current system time in milliseconds.
6
+ */
7
+ static VALUE
8
+ system_clock_call(VALUE self) {
9
+ struct timeval tv;
10
+ long long millis;
11
+
12
+ gettimeofday(&tv, NULL);
13
+
14
+ millis = ((long long)tv.tv_sec) * 1000;
15
+ millis = millis + (tv.tv_usec / 1000);
16
+
17
+ return LONG2NUM(millis);
18
+ }
19
+
20
+ /**
21
+ * Module entry point.
22
+ */
23
+ void
24
+ Init_system_clock_ext() {
25
+ VALUE mR4r;
26
+ VALUE cSystemClock;
27
+
28
+ mR4r = rb_define_module("R4r");
29
+ cSystemClock = rb_define_class_under(mR4r, "SystemClockExt", rb_cObject);
30
+ rb_define_method(cSystemClock, "call", system_clock_call, 0);
31
+ }
@@ -0,0 +1,17 @@
1
+ require "r4r/version"
2
+
3
+ require "r4r/system_clock_ext"
4
+ require "r4r/clock"
5
+
6
+ require "r4r/ring_bits_ext"
7
+ require "r4r/ring_bits"
8
+
9
+ require "r4r/windowed_adder"
10
+ require "r4r/token_bucket"
11
+
12
+ require "r4r/retry_budget"
13
+ require "r4r/retry_policy"
14
+ require "r4r/retry"
15
+
16
+ module R4r
17
+ end
@@ -0,0 +1,40 @@
1
+ module R4r
2
+ # A system clock
3
+ #
4
+ # @abstract
5
+ class Clock
6
+ # Returns current system time in milliseconds
7
+ def call
8
+ raise NotImplementedError
9
+ end
10
+ end
11
+
12
+ # A frozen clock for testing
13
+ class FrozenClock < Clock
14
+ # Creates a new instance of frozen clock.
15
+ #
16
+ # @param [R4r::Clock] parent an initial time clock
17
+ def initialize(parent: nil)
18
+ @time = (parent || R4r.clock).call
19
+ end
20
+
21
+ # @see R4r::Clock#call
22
+ def call
23
+ @time
24
+ end
25
+
26
+ # Increase clock time by given seconds.
27
+ #
28
+ # @param [Fixnum] seconds a number of seconds to increase time
29
+ def advance(seconds:)
30
+ @time += (seconds.to_i * 1_000)
31
+ end
32
+ end
33
+
34
+ @@clock = R4r::SystemClockExt.new
35
+
36
+ # Default {R4r::Clock} instance.
37
+ def self.clock
38
+ @@clock
39
+ end
40
+ end
@@ -0,0 +1,136 @@
1
+ module R4r
2
+
3
+ # An error raises when retry was failed.
4
+ #
5
+ class NonRetriableError < RuntimeError
6
+ attr_reader :cause
7
+
8
+ # @param [String] message an error message
9
+ # @param [Exception] cause a error cause
10
+ def initialize(message:, cause:)
11
+ super(message)
12
+ @cause = cause
13
+ end
14
+ end
15
+
16
+ # Decorator that wrap blocks and call it within retries.
17
+ #
18
+ # @attr [Array[Float]] backoff
19
+ # @attr [R4r::RetryBudget] budget
20
+ #
21
+ # @example constant backoff, it will never pause between retries and will try 3 times.
22
+ # retry = R4r::Retry.constant_backoff(num_retries: 3)
23
+ # retry.call { get_http_request }
24
+ #
25
+ # @example exponential backoff, it will pause between invocations using given backoff invtervals and will try 4 times
26
+ # retry = R4r::Retry.backoff(backoff: [0.1, 0.3, 1, 5])
27
+ # retry.call { get_http_request }
28
+ #
29
+ class Retry
30
+
31
+ attr_reader :backoff
32
+ attr_reader :budget
33
+
34
+ # Creates a new retries dectorator.
35
+ #
36
+ # @param [Array[Float]] backoff an array with backoff intervals (in seconds)
37
+ # @param [R4r::RetryPolicy] policy a policy used for error filtiring
38
+ # @param [R4r::RetryBudget] budget a retry budget
39
+ #
40
+ # @raise [ArgumentError] when backoff is empty
41
+ # @raise [ArgumentError] when backoff has negative values
42
+ def initialize(backoff:, policy: nil, budget: nil)
43
+ @policy = (policy || R4r::RetryPolicy.always)
44
+ @backoff = Array.new(backoff).map { |i| i.to_f }
45
+ @budget = budget != nil ? budget : R4r::RetryBudget.create
46
+
47
+ raise ArgumentError, "backoff cannot be empty" if @backoff.empty?
48
+ raise ArgumentError, "backoff values cannot be negative" unless @backoff.all? {|i| i.to_f >= 0.0 }
49
+ end
50
+
51
+ # Decorates a given block within retries.
52
+ #
53
+ # @return [Proc]
54
+ def decorate(&block)
55
+ ->() { call { yield } }
56
+ end
57
+
58
+ # Calls given block within retries.
59
+ #
60
+ # @raise [NonRetriableError]
61
+ def call(&block)
62
+ return unless block_given?
63
+
64
+ num_retry = 0
65
+ @budget.deposit
66
+
67
+ while num_retry < @backoff.size
68
+
69
+ begin
70
+ return yield(num_retry)
71
+ rescue => err
72
+ raise err if err.is_a?(NonRetriableError)
73
+
74
+ if (num_retry + 1 == @backoff.size)
75
+ raise NonRetriableError.new(message: "Retry limit [#{@backoff.size}] reached: #{err}", cause: err)
76
+ end
77
+
78
+ unless @policy.call(error: err, num_retry: num_retry)
79
+ raise NonRetriableError.new(message: "An error was rejected by policy: #{err}", cause: err)
80
+ end
81
+
82
+ unless @budget.try_withdraw
83
+ raise NonRetriableError.new(message: "Budget was exhausted: #{err}", cause: err)
84
+ end
85
+ end
86
+
87
+ sleep @backoff[num_retry]
88
+ num_retry += 1
89
+ end
90
+ end
91
+
92
+ # Creates a {R4r::Retry} with fixed backoff rates.
93
+ #
94
+ # @param [R4r::RetryPolicy] policy a policy used for error filtiring
95
+ # @param [R4r::RetryBudget] budget a retry budget
96
+ # @return [R4r::Retry]
97
+ #
98
+ # @raise [ArgumentError] when num_retries is negative
99
+ # @raise [ArgumentError] when backoff is negative
100
+ #
101
+ # @example without sleep between invocations
102
+ # R4r::Retry.constant_backoff(num_retries:3)
103
+ #
104
+ # @example with sleep 1s between invocations
105
+ # R4r::Retry.constant_backoff(num_retries: 3, backoff: 1)
106
+ def self.constant_backoff(num_retries:, backoff: 0.0, policy: nil, budget: nil)
107
+ raise ArgumentError, "num_retries cannot be negative" unless num_retries.to_i >= 0
108
+ raise ArgumentError, "backoff cannot be negative" unless backoff.to_f >= 0.0
109
+
110
+ backoff = Array.new(num_retries.to_i) { backoff.to_f }
111
+ R4r::Retry.new(backoff: backoff, policy: policy, budget: budget)
112
+ end
113
+
114
+ # Creates a {R4r::Retry} with backoff intervals.
115
+ #
116
+ # @param [Array[Float]] backoff a list of sleep intervals (in seconds)
117
+ # @param [R4r::RetryPolicy] policy a policy used for error filtiring
118
+ # @param [R4r::RetryBudget] budget a retry budget
119
+ # @return [R4r::Retry]
120
+ #
121
+ # @raise [ArgumentError] when backoff is nil
122
+ # @raise [ArgumentError] when backoff isn't array
123
+ # @raise [ArgumentError] when backoff has negative values
124
+ #
125
+ # @example exponential backoff between invocations
126
+ # R4r::Retry.backoff(backoff: [0.1, 0.5, 1, 3])
127
+ def self.backoff(backoff:, policy: nil, budget: nil)
128
+ raise ArgumentError, "backoff cannot be nil" if backoff.nil?
129
+ raise ArgumentError, "backoff must be an array" unless backoff.is_a?(Array)
130
+ raise ArgumentError, "backoff values cannot be negative" unless backoff.all? {|i| i.to_f >= 0.0 }
131
+
132
+ R4r::Retry.new(backoff: backoff, policy: policy, budget: budget)
133
+ end
134
+
135
+ end
136
+ end
@@ -0,0 +1,150 @@
1
+ module R4r
2
+ # Represents a budget for retrying requests.
3
+ #
4
+ # A retry budget is useful for attenuating the amplifying effects
5
+ # of many clients within a process retrying requests multiple
6
+ # times. This acts as a form of coordination between those retries.
7
+ #
8
+ # A Ruby port of the finagle's RetryBudget.
9
+ #
10
+ # @abstract
11
+ # @see https://github.com/twitter/finagle/blob/master/finagle-core/src/main/scala/com/twitter/finagle/service/RetryBudget.scala
12
+ class RetryBudget
13
+
14
+ # Indicates a deposit, or credit, which will typically
15
+ # permit future withdrawals.
16
+ def deposit
17
+ raise NotImplementedError
18
+ end
19
+
20
+ # Check whether or not there is enough balance remaining
21
+ # to issue a retry, or make a withdrawal.
22
+ #
23
+ # @return [Boolean]
24
+ # `true`, if the retry is allowed and a withdrawal will take place.
25
+ # `false`, the balance should remain untouched.
26
+ def try_withdraw
27
+ raise NotImplementedError
28
+ end
29
+
30
+ # The balance or number of retries that can be made now.
31
+ def balance
32
+ raise NotImplementedError
33
+ end
34
+
35
+ # Creates a {R4r::RetryBudget} that allows for about `percent_can_retry` percent
36
+ # of the total {R4r::RetryBudget#deposit} requests to be retried.
37
+ #
38
+ # @param [Fixnum] ttl_ms deposits created by {R4r::RetryBudget#deposit} expire after
39
+ # approximately `ttl_ms` time has passed. Must be `>= 1 second`
40
+ # and `<= 60 seconds`.
41
+ # @param [Fixnum] min_retries_per_second the minimum rate of retries allowed in order to
42
+ # accommodate clients that have just started issuing requests as well as clients
43
+ # that do not issue many requests per window.
44
+ # Must be non-negative and if `0`, then no reserve is given.
45
+ # @param [Float] percent_can_retry the percentage of calls to `deposit()` that can be
46
+ # retried. This is in addition to any retries allowed for via `min_retries_per_second`.
47
+ # Must be >= 0 and <= 1000. As an example, if `0.1` is used, then for every
48
+ # 10 calls to `deposit()`, 1 retry will be allowed. If `2.0` is used then every
49
+ # `deposit` allows for 2 retries.
50
+ # @param [R4r::Clock] clock the current time for testing
51
+ #
52
+ # @raise [ArgumentError]
53
+ def self.create(ttl_ms: nil, min_retries_per_second: nil, percent_can_retry: nil, clock: nil)
54
+ ttl_ms = (ttl_ms || R4r::TokenRetryBudget::DEFAULT_TTL_MS).to_i
55
+ min_retries_per_second = (min_retries_per_second || 10).to_i
56
+ percent_can_retry = (percent_can_retry.to_f || 0.2).to_f
57
+
58
+ unless ttl_ms >= 0 && ttl_ms <= 60 * 1000
59
+ raise ArgumentError, "ttl_ms must be in [1.second, 60.seconds], got #{ttl_ms}"
60
+ end
61
+ unless min_retries_per_second >= 0
62
+ raise ArgumentError, "min_retries_per_second cannot be nagative, got #{min_retries_per_second}"
63
+ end
64
+ unless percent_can_retry >= 0.0
65
+ raise ArgumentError, "percent_can_retry cannot be negative, got #{percent_can_retry}"
66
+ end
67
+ unless percent_can_retry <= R4r::TokenRetryBudget::SCALE_FACTOR
68
+ raise ArgumentError, "percent_can_retry cannot be greater then #{R4r::TokenRetryBudget::SCALE_FACTOR}, got #{percent_can_retry}"
69
+ end
70
+
71
+ if min_retries_per_second == 0 && percent_can_retry == 0.0
72
+ return R4r::RetryBudget.empty
73
+ end
74
+
75
+ deposit_amount = percent_can_retry == 0.0 ? 0 : R4r::TokenRetryBudget::SCALE_FACTOR.to_i
76
+ withdrawal_amount = percent_can_retry == 0.0 ? 1 : (R4r::TokenRetryBudget::SCALE_FACTOR / percent_can_retry).to_i
77
+ reserve = min_retries_per_second * (ttl_ms / 1000) * withdrawal_amount
78
+ bucket = R4r::LeakyTokenBucket.new(ttl_ms: ttl_ms, reserve: reserve, clock: clock)
79
+ R4r::TokenRetryBudget.new(bucket: bucket, deposit_amount: deposit_amount, withdrawal_amount: withdrawal_amount)
80
+ end
81
+
82
+ # Creates an empty retry budget.
83
+ def self.empty
84
+ EmptyRetryBudget.new
85
+ end
86
+
87
+ # Creates an infinite retry budget.
88
+ def self.infinite
89
+ InfiniteRetryBudget.new
90
+ end
91
+ end
92
+
93
+ # A {R4r::RetryBudget} that never has a balance,
94
+ # and as such, will never allow a retry.
95
+ class EmptyRetryBudget < RetryBudget
96
+ def deposit ; end
97
+ def try_withdraw ; false ; end
98
+ def balance ; 0 ; end
99
+ end
100
+
101
+ # A {R4r::RetryBudget} that always has a balance of `100`,
102
+ # and as such, will always allow a retry.
103
+ class InfiniteRetryBudget < RetryBudget
104
+ def deposit ; end
105
+ def try_withdraw ; true end
106
+ def balance ; 100 end
107
+ end
108
+
109
+ class TokenRetryBudget < RetryBudget
110
+ # This scaling factor allows for `percent_can_retry` > 1 without
111
+ # having to use floating points (as the underlying mechanism
112
+ # here is a {R4r::TokenBucket} which is not floating point based).
113
+ SCALE_FACTOR = 1000.0
114
+ DEFAULT_TTL_MS = 60 * 1000
115
+
116
+ # Creates a new {R4r::TokenRetryBudget}.
117
+ #
118
+ # @param [R4r::TokenBucket] bucket
119
+ # @param [Fixnum] deposit_amount
120
+ # @param [Fixnum] withdrawal_amount
121
+ def initialize(bucket:, deposit_amount:, withdrawal_amount:)
122
+ raise ArgumentError, "bucket cannot be nil" if bucket.nil?
123
+ raise ArgumentError, "deposit_amount cannot be nil" if deposit_amount.nil?
124
+ raise ArgumentError, "withdrawal_amount cannot be nil" if withdrawal_amount.nil?
125
+
126
+ @bucket = bucket
127
+ @deposit_amount = deposit_amount.to_i
128
+ @withdrawal_amount = withdrawal_amount.to_i
129
+ end
130
+
131
+ # @see R4r::RetryBudget#deposit
132
+ def deposit
133
+ @bucket.put(@deposit_amount)
134
+ end
135
+
136
+ # @see R4r::RetryBudget#try_withdraw
137
+ def try_withdraw
138
+ @bucket.try_get(@withdrawal_amount)
139
+ end
140
+
141
+ # @see R4r::RetryBudget#balance
142
+ def balance
143
+ @bucket.count / @withdrawal_amount
144
+ end
145
+
146
+ def to_s
147
+ "R4r::TokenRetryBudget{deposit=#{@deposit_amount}, withdrawal=#{@withdrawal_amount}, balance=#{balance}}"
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,55 @@
1
+ module R4r
2
+
3
+ # Pluggable retry strategy.
4
+ #
5
+ # @abstract
6
+ class RetryPolicy
7
+ # Check that given error can be retried or not.
8
+ #
9
+ # @param [Exception] error an error was occured
10
+ # @param [Fixnum] num_retry a number of current retry, started from zero
11
+ #
12
+ # @return [Boolean] true if retry can be recovered
13
+ def call(error:, num_retry:)
14
+ raise NotImplementedError
15
+ end
16
+
17
+ # Creates a policy that always recover from any kind of errors.
18
+ #
19
+ # @return [R4r::RetryPolicy]
20
+ def self.always
21
+ ->(error:, num_retry:) { true }
22
+ end
23
+
24
+ # Creates a policy that never recover from any kind of errors.
25
+ #
26
+ # @return [R4r::RetryPolicy]
27
+ def self.never
28
+ ->(error:, num_retry:) { false }
29
+ end
30
+
31
+ # Creates a policy that recover from specified kind of errors
32
+ #
33
+ # @example
34
+ # R4r::RetryPolicy.instance_of(Some::Error, Service::Error)
35
+ #
36
+ # @return [R4r::RetryPolicy]
37
+ def self.instance_of(*klass)
38
+ R4r::InstanceOfRetryPolicy.new(klass: klass)
39
+ end
40
+ end
41
+
42
+ # A retry policy that catch specified kind of errors
43
+ class InstanceOfRetryPolicy < RetryPolicy
44
+ # @param [Array[Class]] klass an error classes list that used for filtering
45
+ def initialize(klass:)
46
+ @klass = klass
47
+ end
48
+
49
+ # @return [Boolean]
50
+ # @see R4r::RetryPolicy#call
51
+ def call(error:, num_retry:)
52
+ @klass.any? { |kind| error.is_a?(kind) }
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,59 @@
1
+ module R4r ; class RingBits
2
+ attr_reader :index, :length, :cardinality
3
+
4
+ # Creates a ring bit set whose size is large enough to explicitly
5
+ # represent bits with indices in the range 0 through
6
+ # size-1. All bits are initially set to false.
7
+ #
8
+ # @param [Fixnum] size the size of ring bits buffer
9
+ # @raise [ArgumentError] if the specified size is negitive
10
+ def initialize(size:, bit_set_class: nil)
11
+ @size = size
12
+ @bit_set = (bit_set_class || RingBitsExt).new(size.to_i)
13
+ @is_full = false
14
+ @index = -1
15
+ @length = 0
16
+ @cardinality = 0
17
+ end
18
+
19
+ # Current ring bits buffer size.
20
+ def size
21
+ @size
22
+ end
23
+
24
+ # An actual ring bits buffer capacity.
25
+ def bit_set_size
26
+ @bit_set.size
27
+ end
28
+
29
+ # Sets the bit at the next index to the specified value.
30
+ #
31
+ # @param [Boolean] value is a boolean value to set
32
+ # @return [Fixnum] the number of bits set to true
33
+ def set_next(value)
34
+ increase_length
35
+
36
+ new_index = (@index + 1) % @size
37
+ previous = @bit_set.set(new_index, value == true) ? 1 : 0
38
+ current = value == true ? 1 : 0
39
+
40
+
41
+ @index = new_index
42
+ @cardinality = @cardinality - previous + current
43
+ end
44
+
45
+ private
46
+
47
+ def increase_length
48
+ return if @is_full
49
+
50
+ next_length = @length + 1
51
+ if (next_length < @size)
52
+ @length = next_length
53
+ else
54
+ @length = size
55
+ @is_full = true
56
+ end
57
+ end
58
+
59
+ end ; end