r4r 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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