gruf 2.4.2 → 2.5.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -0
- data/README.md +39 -3
- data/gruf.gemspec +1 -0
- data/lib/gruf.rb +1 -0
- data/lib/gruf/client.rb +14 -30
- data/lib/gruf/client/error.rb +64 -0
- data/lib/gruf/client/error_factory.rb +99 -0
- data/lib/gruf/configuration.rb +1 -0
- data/lib/gruf/interceptors/instrumentation/request_logging/interceptor.rb +2 -1
- data/lib/gruf/response.rb +3 -2
- data/lib/gruf/synchronized_client.rb +93 -0
- data/lib/gruf/timer.rb +2 -2
- data/lib/gruf/version.rb +1 -1
- metadata +19 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: a2452f173e2987612fde71b5e170874f52718e5a7f820658378c7a98e54f5b1f
|
4
|
+
data.tar.gz: bcbb20ab199f78fadd1926d1314fe881fccda9413f149ebb8ad1e4daeba259f8
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: d9ae08c458006784a089c1390000870febe48a01e8e6be85673e343639ec8283410c76fa6110981916f1333bc3e49c499d845cb03551cf1ea42cab8b5455c2c1
|
7
|
+
data.tar.gz: 26833397af9630db7a6c3d0429ddcaaf63d84e076e8d6691719b2210b8f7523b3a89169e2826ad2f66d67dd64e313a31f87da423d5f4580c4b7ac6fc26fc54d7
|
data/CHANGELOG.md
CHANGED
@@ -2,6 +2,14 @@ Changelog for the gruf gem. This includes internal history before the gem was ma
|
|
2
2
|
|
3
3
|
### Pending release
|
4
4
|
|
5
|
+
### 2.5.0
|
6
|
+
|
7
|
+
- Client exceptions raised now contain mapped subclasses, such as `Gruf::Client::Errors::InvalidArgument`
|
8
|
+
- Client exceptions will also now catch StandardError and GRPC::Core errors, and handle them as Internal errors
|
9
|
+
- Added SynchronizedClient which prevents multiple calls to the same endpoint with the same params at
|
10
|
+
a given time. This is useful for mitigating thundering herds. To skip this behavior for certain endpoints,
|
11
|
+
pass the `options[:unsynchronized_methods]` param with a list of method names (as symbols).
|
12
|
+
|
5
13
|
### 2.4.2
|
6
14
|
|
7
15
|
- Added error handling for GRPC::Core::CallError, a low-level error in the grpc library that does not inherit
|
data/README.md
CHANGED
@@ -48,7 +48,7 @@ Gruf.configure do |c|
|
|
48
48
|
end
|
49
49
|
```
|
50
50
|
|
51
|
-
If you don't explicitly set `default_client_host`, you will need to pass it into the
|
51
|
+
If you don't explicitly set `default_client_host`, you will need to pass it into the options, like so:
|
52
52
|
|
53
53
|
```ruby
|
54
54
|
client = ::Gruf::Client.new(service: ::Demo::ThingService, options: {hostname: 'grpc.service.com:9003'})
|
@@ -72,10 +72,46 @@ end
|
|
72
72
|
|
73
73
|
Note this returns a response object. The response object can provide `trailing_metadata` as well as a `execution_time`.
|
74
74
|
|
75
|
+
### SynchronizedClient
|
76
|
+
|
77
|
+
SynchronizedClient wraps Client with some additional behavior to help prevent generating spikes
|
78
|
+
of redundant requests. If multiple calls to the same endpoint with the same parameters are made,
|
79
|
+
the first one will be executed and the following ones will block, waiting for the first result.
|
80
|
+
|
81
|
+
```ruby
|
82
|
+
require 'gruf'
|
83
|
+
require 'thwait'
|
84
|
+
|
85
|
+
id = args[:id].to_i.presence || 1
|
86
|
+
client = ::Gruf::SynchronizedClient.new(service: ::Demo::ThingService)
|
87
|
+
thread1 = Thread.new { client.call(:GetMyThing, id: id) }
|
88
|
+
thread2 = Thread.new { client.call(:GetMyThing, id: id) }
|
89
|
+
ThreadsWait.all_waits(thread1, thread2)
|
90
|
+
```
|
91
|
+
|
92
|
+
In the above example, thread1 will make the rpc call, thread2 will block until the call is complete, and then
|
93
|
+
will get the same value without making a second rpc call.
|
94
|
+
|
95
|
+
You can also skip this behavior for certain methods if desired.
|
96
|
+
|
97
|
+
```ruby
|
98
|
+
require 'gruf'
|
99
|
+
require 'thwait'
|
100
|
+
|
101
|
+
id = args[:id].to_i.presence || 1
|
102
|
+
client = ::Gruf::SynchronizedClient.new(service: ::Demo::ThingService, options: { unsynchronized_methods: [:GetMyThing] })
|
103
|
+
thread1 = Thread.new { client.call(:GetMyThing, id: id) }
|
104
|
+
thread2 = Thread.new { client.call(:GetMyThing, id: id) }
|
105
|
+
ThreadsWait.all_waits(thread1, thread2)
|
106
|
+
```
|
107
|
+
|
108
|
+
In the above example, thread1 and thread2 will make rpc calls in parallel, in the same way as if you had used
|
109
|
+
`Gruf::Client`.
|
110
|
+
|
75
111
|
### Client Interceptors
|
76
112
|
|
77
113
|
Gruf comes with an assistance class for client interceptors that you can use - or you can use the native gRPC core
|
78
|
-
interceptors. Either way, you pass them into the client_options when creating a client:
|
114
|
+
interceptors. Either way, you pass them into the `client_options` when creating a client:
|
79
115
|
|
80
116
|
```ruby
|
81
117
|
class MyInterceptor < Gruf::Interceptors::ClientInterceptor
|
@@ -487,7 +523,7 @@ gruf that you can use today:
|
|
487
523
|
|
488
524
|
* [gruf-zipkin](https://github.com/bigcommerce/gruf-zipkin) - Provides a [Zipkin](https://zipkin.io)
|
489
525
|
integration
|
490
|
-
* [gruf-lightstep](https://github.com/bigcommerce/gruf-lightstep) - Provides a seamless
|
526
|
+
* [gruf-lightstep](https://github.com/bigcommerce/gruf-lightstep) - Provides a seamless
|
491
527
|
[LightStep](https://lightstep.com) integration
|
492
528
|
* [gruf-circuit-breaker](https://github.com/bigcommerce/gruf-circuit-breaker) - Circuit breaker
|
493
529
|
support for services
|
data/gruf.gemspec
CHANGED
@@ -39,5 +39,6 @@ Gem::Specification.new do |spec|
|
|
39
39
|
spec.add_runtime_dependency 'grpc', '~> 1.10'
|
40
40
|
spec.add_runtime_dependency 'grpc-tools', '~> 1.10'
|
41
41
|
spec.add_runtime_dependency 'activesupport'
|
42
|
+
spec.add_runtime_dependency 'concurrent-ruby'
|
42
43
|
spec.add_runtime_dependency 'slop', '~> 4.6'
|
43
44
|
end
|
data/lib/gruf.rb
CHANGED
@@ -32,6 +32,7 @@ require_relative 'gruf/timer'
|
|
32
32
|
require_relative 'gruf/response'
|
33
33
|
require_relative 'gruf/error'
|
34
34
|
require_relative 'gruf/client'
|
35
|
+
require_relative 'gruf/synchronized_client'
|
35
36
|
require_relative 'gruf/instrumentable_grpc_server'
|
36
37
|
require_relative 'gruf/server'
|
37
38
|
|
data/lib/gruf/client.rb
CHANGED
@@ -13,6 +13,9 @@
|
|
13
13
|
# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
14
14
|
# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
15
15
|
#
|
16
|
+
require_relative 'client/error'
|
17
|
+
require_relative 'client/error_factory'
|
18
|
+
|
16
19
|
module Gruf
|
17
20
|
##
|
18
21
|
# Abstracts out the calling interface for interacting with gRPC clients. Streamlines calling and provides
|
@@ -33,25 +36,6 @@ module Gruf
|
|
33
36
|
class Client < SimpleDelegator
|
34
37
|
include Gruf::Loggable
|
35
38
|
|
36
|
-
##
|
37
|
-
# Represents an error that was returned from the server's trailing metadata. Used as a custom exception object
|
38
|
-
# that is instead raised in the case of the service returning serialized error data, as opposed to the normal
|
39
|
-
# GRPC::BadStatus error
|
40
|
-
#
|
41
|
-
class Error < StandardError
|
42
|
-
# @return [Object] error The deserialized error
|
43
|
-
attr_reader :error
|
44
|
-
|
45
|
-
##
|
46
|
-
# Initialize the client error
|
47
|
-
#
|
48
|
-
# @param [Object] error The deserialized error
|
49
|
-
#
|
50
|
-
def initialize(error)
|
51
|
-
@error = error
|
52
|
-
end
|
53
|
-
end
|
54
|
-
|
55
39
|
# @return [Class] The base, friendly name of the service being requested
|
56
40
|
attr_reader :base_klass
|
57
41
|
# @return [Class] The class name of the gRPC service being requested
|
@@ -74,6 +58,7 @@ module Gruf
|
|
74
58
|
@opts = options || {}
|
75
59
|
@opts[:password] = options.fetch(:password, '').to_s
|
76
60
|
@opts[:hostname] = options.fetch(:hostname, Gruf.default_client_host)
|
61
|
+
@error_factory = Gruf::Client::ErrorFactory.new
|
77
62
|
client = "#{service}::Stub".constantize.new(@opts[:hostname], build_ssl_credentials, client_options)
|
78
63
|
super(client)
|
79
64
|
end
|
@@ -98,16 +83,11 @@ module Gruf
|
|
98
83
|
|
99
84
|
raise NotImplementedError, "The method #{request_method} has not been implemented in this service." unless call_sig
|
100
85
|
|
101
|
-
resp = execute(call_sig, req, md, opts, &block)
|
86
|
+
resp, operation = execute(call_sig, req, md, opts, &block)
|
87
|
+
|
88
|
+
raise @error_factory.from_exception(resp.result) unless resp.success?
|
102
89
|
|
103
|
-
Gruf::Response.new(resp.result, resp.time)
|
104
|
-
rescue GRPC::BadStatus => e
|
105
|
-
emk = Gruf.error_metadata_key.to_s
|
106
|
-
raise Gruf::Client::Error, error_deserializer_class.new(e.metadata[emk]).deserialize if e.respond_to?(:metadata) && e.metadata.key?(emk)
|
107
|
-
raise # passthrough
|
108
|
-
rescue StandardError => e
|
109
|
-
Gruf.logger.error e.message
|
110
|
-
raise
|
90
|
+
Gruf::Response.new(operation: operation, message: resp.result, execution_time: resp.time)
|
111
91
|
end
|
112
92
|
|
113
93
|
##
|
@@ -137,13 +117,17 @@ module Gruf
|
|
137
117
|
# @param [Object] req (Optional) The protobuf request message to send
|
138
118
|
# @param [Hash] metadata (Optional) A hash of metadata key/values that are transported with the client request
|
139
119
|
# @param [Hash] opts (Optional) A hash of options to send to the gRPC request_response method
|
120
|
+
# @return [Array<Gruf::Timer::Result, GRPC::ActiveCall::Operation>]
|
140
121
|
#
|
141
122
|
def execute(call_sig, req, metadata, opts = {}, &block)
|
142
|
-
|
123
|
+
operation = nil
|
124
|
+
result = Gruf::Timer.time do
|
143
125
|
opts[:return_op] = true
|
144
126
|
opts[:metadata] = metadata
|
145
|
-
send(call_sig, req, opts, &block)
|
127
|
+
operation = send(call_sig, req, opts, &block)
|
128
|
+
operation.execute
|
146
129
|
end
|
130
|
+
[result, operation]
|
147
131
|
end
|
148
132
|
|
149
133
|
##
|
@@ -0,0 +1,64 @@
|
|
1
|
+
# Copyright (c) 2017-present, BigCommerce Pty. Ltd. All rights reserved
|
2
|
+
#
|
3
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
|
4
|
+
# documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
|
5
|
+
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
|
6
|
+
# persons to whom the Software is furnished to do so, subject to the following conditions:
|
7
|
+
#
|
8
|
+
# The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
|
9
|
+
# Software.
|
10
|
+
#
|
11
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
|
12
|
+
# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
13
|
+
# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
14
|
+
# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
15
|
+
#
|
16
|
+
module Gruf
|
17
|
+
class Client < SimpleDelegator
|
18
|
+
##
|
19
|
+
# Represents an error that was returned from the server's trailing metadata. Used as a custom exception object
|
20
|
+
# that is instead raised in the case of the service returning serialized error data, as opposed to the normal
|
21
|
+
# GRPC::BadStatus error
|
22
|
+
#
|
23
|
+
class Error < StandardError
|
24
|
+
# @return [Object] error The deserialized error
|
25
|
+
attr_reader :error
|
26
|
+
|
27
|
+
##
|
28
|
+
# Initialize the client error
|
29
|
+
#
|
30
|
+
# @param [Object] error The deserialized error
|
31
|
+
#
|
32
|
+
def initialize(error)
|
33
|
+
@error = error
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
##
|
38
|
+
# See https://github.com/grpc/grpc-go/blob/master/codes/codes.go for a detailed summary of each error type
|
39
|
+
#
|
40
|
+
module Errors
|
41
|
+
class Base < Gruf::Client::Error; end
|
42
|
+
class Error < Base; end
|
43
|
+
class Validation < Base; end
|
44
|
+
|
45
|
+
class Ok < Base; end
|
46
|
+
|
47
|
+
class InvalidArgument < Validation; end
|
48
|
+
class NotFound < Validation; end
|
49
|
+
class AlreadyExists < Validation; end
|
50
|
+
class OutOfRange < Validation; end
|
51
|
+
|
52
|
+
class Cancelled < Error; end
|
53
|
+
class DataLoss < Error; end
|
54
|
+
class DeadlineExceeded < Error; end
|
55
|
+
class FailedPrecondition < Error; end
|
56
|
+
class Internal < Error; end
|
57
|
+
class PermissionDenied < Error; end
|
58
|
+
class Unauthenticated < Error; end
|
59
|
+
class Unavailable < Error; end
|
60
|
+
class Unimplemented < Error; end
|
61
|
+
class Unknown < Error; end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,99 @@
|
|
1
|
+
# Copyright (c) 2017-present, BigCommerce Pty. Ltd. All rights reserved
|
2
|
+
#
|
3
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
|
4
|
+
# documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
|
5
|
+
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
|
6
|
+
# persons to whom the Software is furnished to do so, subject to the following conditions:
|
7
|
+
#
|
8
|
+
# The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
|
9
|
+
# Software.
|
10
|
+
#
|
11
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
|
12
|
+
# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
13
|
+
# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
14
|
+
# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
15
|
+
#
|
16
|
+
module Gruf
|
17
|
+
class Client < SimpleDelegator
|
18
|
+
##
|
19
|
+
# Translates exceptions into Gruf::Client::Errors
|
20
|
+
#
|
21
|
+
class ErrorFactory
|
22
|
+
##
|
23
|
+
# @param [Class] default_class
|
24
|
+
# @param [Class] deserializer_class
|
25
|
+
# @param [String|Symbol] metadata_key
|
26
|
+
#
|
27
|
+
def initialize(
|
28
|
+
default_class: nil,
|
29
|
+
deserializer_class: nil,
|
30
|
+
metadata_key: nil
|
31
|
+
)
|
32
|
+
@default_class = default_class || Gruf::Client::Errors::Internal
|
33
|
+
@metadata_key = (metadata_key || Gruf.error_metadata_key).to_s
|
34
|
+
default_serializer = if Gruf.error_serializer
|
35
|
+
Gruf.error_serializer.is_a?(Class) ? Gruf.error_serializer : Gruf.error_serializer.to_s.constantize
|
36
|
+
else
|
37
|
+
Gruf::Serializers::Errors::Json
|
38
|
+
end
|
39
|
+
@deserializer_class = deserializer_class || default_serializer
|
40
|
+
end
|
41
|
+
|
42
|
+
##
|
43
|
+
# Determine the proper error class to raise given the incoming exception. This will attempt to coalesce the
|
44
|
+
# exception object into the appropriate Gruf::Client::Errors subclass, or fallback to the default class if none
|
45
|
+
# is found (or it is a StandardError or higher-level error). It will leave alone Signals instead of attempting to
|
46
|
+
# coalesce them.
|
47
|
+
#
|
48
|
+
# @param [Exception] exception
|
49
|
+
# @return [Gruf::Client::Errors::Base|SignalException]
|
50
|
+
#
|
51
|
+
def from_exception(exception)
|
52
|
+
# passthrough on Signals, we don't want to mess with these
|
53
|
+
return exception if exception.is_a?(SignalException)
|
54
|
+
|
55
|
+
exception_class = determine_class(exception)
|
56
|
+
if exception.is_a?(GRPC::BadStatus)
|
57
|
+
# if it's a GRPC::BadStatus code, let's check for any trailing error metadata and decode it
|
58
|
+
exception_class.new(deserialize(exception))
|
59
|
+
else
|
60
|
+
# otherwise, let's just capture the error and build the wrapper class
|
61
|
+
exception_class.new(exception)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
private
|
66
|
+
|
67
|
+
##
|
68
|
+
# Deserialize any trailing metadata error payload from the exception
|
69
|
+
#
|
70
|
+
# @param [Gruf::Client::Errors::Base]
|
71
|
+
# @return [String]
|
72
|
+
#
|
73
|
+
def deserialize(exception)
|
74
|
+
if exception.respond_to?(:metadata)
|
75
|
+
key = exception.metadata.key?(@metadata_key.to_s) ? @metadata_key.to_s : @metadata_key.to_sym
|
76
|
+
return @deserializer_class.new(exception.metadata[key]).deserialize if exception.metadata.key?(key)
|
77
|
+
end
|
78
|
+
|
79
|
+
exception
|
80
|
+
end
|
81
|
+
|
82
|
+
##
|
83
|
+
# @param [Exception] exception
|
84
|
+
# @return [Gruf::Client::Errors::Base]
|
85
|
+
#
|
86
|
+
def determine_class(exception)
|
87
|
+
error_class = Gruf::Client::Errors.const_get(exception.class.name.demodulize)
|
88
|
+
|
89
|
+
# Ruby module inheritance will have StandardError, ScriptError, etc still get to this point
|
90
|
+
# So we need to explicitly check for ancestry here
|
91
|
+
return @default_class unless error_class.ancestors.include?(Gruf::Client::Errors::Base)
|
92
|
+
|
93
|
+
error_class
|
94
|
+
rescue NameError => _
|
95
|
+
@default_class
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
data/lib/gruf/configuration.rb
CHANGED
@@ -40,6 +40,7 @@ module Gruf
|
|
40
40
|
use_exception_message: true,
|
41
41
|
internal_error_message: 'Internal Server Error',
|
42
42
|
event_listener_proc: nil,
|
43
|
+
synchronized_client_internal_cache_expiry: 60,
|
43
44
|
rpc_server_options: {
|
44
45
|
pool_size: GRPC::RpcServer::DEFAULT_POOL_SIZE,
|
45
46
|
max_waiting_requests: GRPC::RpcServer::DEFAULT_MAX_WAITING_REQUESTS,
|
@@ -78,7 +78,7 @@ module Gruf
|
|
78
78
|
end
|
79
79
|
|
80
80
|
payload = {}
|
81
|
-
if !request.client_streamer?
|
81
|
+
if !request.client_streamer? && !request.bidi_streamer?
|
82
82
|
payload[:params] = sanitize(request.message.to_h) if options.fetch(:log_parameters, false)
|
83
83
|
payload[:message] = message(request, result)
|
84
84
|
payload[:status] = status(result.message, result.successful?)
|
@@ -87,6 +87,7 @@ module Gruf
|
|
87
87
|
payload[:message] = ''
|
88
88
|
payload[:status] = GRPC::Core::StatusCodes::OK
|
89
89
|
end
|
90
|
+
|
90
91
|
payload[:service] = request.service_key
|
91
92
|
payload[:method] = request.method_key
|
92
93
|
payload[:action] = request.method_key
|
data/lib/gruf/response.rb
CHANGED
@@ -35,11 +35,12 @@ module Gruf
|
|
35
35
|
# Initialize a response object with the given gRPC operation
|
36
36
|
#
|
37
37
|
# @param [GRPC::ActiveCall::Operation] operation The given operation for the current call
|
38
|
+
# @param [StdClass] message
|
38
39
|
# @param [Float] execution_time The amount of time that the response took to occur
|
39
40
|
#
|
40
|
-
def initialize(operation
|
41
|
+
def initialize(operation:, message:, execution_time: nil)
|
41
42
|
@operation = operation
|
42
|
-
@message =
|
43
|
+
@message = message
|
43
44
|
@metadata = operation.metadata
|
44
45
|
@trailing_metadata = operation.trailing_metadata
|
45
46
|
@deadline = operation.deadline
|
@@ -0,0 +1,93 @@
|
|
1
|
+
# Copyright (c) 2017-present, BigCommerce Pty. Ltd. All rights reserved
|
2
|
+
#
|
3
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
|
4
|
+
# documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
|
5
|
+
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
|
6
|
+
# persons to whom the Software is furnished to do so, subject to the following conditions:
|
7
|
+
#
|
8
|
+
# The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
|
9
|
+
# Software.
|
10
|
+
#
|
11
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
|
12
|
+
# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
13
|
+
# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
14
|
+
# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
15
|
+
#
|
16
|
+
require 'concurrent'
|
17
|
+
|
18
|
+
module Gruf
|
19
|
+
##
|
20
|
+
# Ensures that we only have one active call to a given endpoint with a given set of params. This can be useful
|
21
|
+
# to mitigate thundering herds.
|
22
|
+
#
|
23
|
+
class SynchronizedClient < Gruf::Client
|
24
|
+
attr_reader :unsynchronized_methods
|
25
|
+
|
26
|
+
##
|
27
|
+
# Initialize the client and setup the stub
|
28
|
+
#
|
29
|
+
# @param [Module] service The namespace of the client Stub that is desired to load
|
30
|
+
# @param [Hash] options A hash of options for the client
|
31
|
+
# @option options [Array] :unsynchronized_methods A list of methods (as symbols) that should be excluded from synchronization
|
32
|
+
# @option options [Integer] :internal_cache_expiry The length of time to keep results around for other threads to fetch (in seconds)
|
33
|
+
# @param [Hash] client_options A hash of options to pass to the gRPC client stub
|
34
|
+
#
|
35
|
+
def initialize(service:, options: {}, client_options: {})
|
36
|
+
@unsynchronized_methods = options.delete(:unsynchronized_methods) { [] }
|
37
|
+
@expiry = options.delete(:internal_cache_expiry) { Gruf.synchronized_client_internal_cache_expiry }
|
38
|
+
@locks = Concurrent::Map.new
|
39
|
+
@results = Concurrent::Map.new
|
40
|
+
super
|
41
|
+
end
|
42
|
+
|
43
|
+
##
|
44
|
+
# Call the client's method with given params. If another call is already active for the same endpoint and the same
|
45
|
+
# params, block until the active call is complete. When unblocked, callers will get a copy of the original result.
|
46
|
+
#
|
47
|
+
# @param [String|Symbol] request_method The method that is being requested on the service
|
48
|
+
# @param [Hash] params (Optional) A hash of parameters that will be inserted into the gRPC request message that is required
|
49
|
+
# for the given above call
|
50
|
+
# @param [Hash] metadata (Optional) A hash of metadata key/values that are transported with the client request
|
51
|
+
# @param [Hash] opts (Optional) A hash of options to send to the gRPC request_response method
|
52
|
+
# @return [Gruf::Response] The response from the server
|
53
|
+
# @raise [Gruf::Client::Error|GRPC::BadStatus] If an error occurs, an exception will be raised according to the
|
54
|
+
# error type that was returned
|
55
|
+
#
|
56
|
+
def call(request_method, params = {}, metadata = {}, opts = {}, &block)
|
57
|
+
# Allow for bypassing extra behavior for selected methods
|
58
|
+
return super if unsynchronized_methods.include?(request_method.to_sym)
|
59
|
+
|
60
|
+
# Generate a unique key based on the method and params
|
61
|
+
key = "#{request_method}.#{params.hash}"
|
62
|
+
|
63
|
+
# Create a lock for this call if we haven't seen it already, then acquire it
|
64
|
+
lock = @locks.compute_if_absent(key) { Mutex.new }
|
65
|
+
lock.synchronize do
|
66
|
+
# Return value from results cache if it exists. This occurs for callers that were
|
67
|
+
# waiting on the lock while the first caller was making the actual grpc call.
|
68
|
+
response = @results.get(lock)
|
69
|
+
if response
|
70
|
+
Gruf.logger.debug "Returning cached result for #{key}:#{lock.inspect}"
|
71
|
+
next response
|
72
|
+
end
|
73
|
+
|
74
|
+
# Make the grpc call and record response for other callers that are blocked
|
75
|
+
# on the same lock
|
76
|
+
response = super
|
77
|
+
@results.put(lock, response)
|
78
|
+
|
79
|
+
# Schedule a task to come in later and clean out result to prevent memory bloat
|
80
|
+
Concurrent::ScheduledTask.new(@expiry, args: [@results, lock]) { |h, k| h.delete(k) }.execute
|
81
|
+
|
82
|
+
# Remove the lock from the map. The next caller to come through with the
|
83
|
+
# same params will create a new lock and start the process over again.
|
84
|
+
# Anyone who was waiting on this call will be using a local reference
|
85
|
+
# to the same lock as us, and will fetch the result from the cache.
|
86
|
+
@locks.delete(key)
|
87
|
+
|
88
|
+
# Return response
|
89
|
+
response
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
data/lib/gruf/timer.rb
CHANGED
@@ -50,7 +50,7 @@ module Gruf
|
|
50
50
|
# @return [Boolean] Whether or not this result was a success
|
51
51
|
#
|
52
52
|
def success?
|
53
|
-
!result.is_a?(GRPC::BadStatus)
|
53
|
+
!result.is_a?(GRPC::BadStatus) && !result.is_a?(StandardError) && !result.is_a?(GRPC::Core::CallError)
|
54
54
|
end
|
55
55
|
end
|
56
56
|
|
@@ -65,7 +65,7 @@ module Gruf
|
|
65
65
|
start_time = Time.now
|
66
66
|
begin
|
67
67
|
result = yield
|
68
|
-
rescue GRPC::BadStatus => e
|
68
|
+
rescue GRPC::BadStatus, StandardError, GRPC::Core::CallError => e
|
69
69
|
result = e
|
70
70
|
end
|
71
71
|
end_time = Time.now
|
data/lib/gruf/version.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: gruf
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 2.
|
4
|
+
version: 2.5.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Shaun McCormick
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2018-09-
|
11
|
+
date: 2018-09-14 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: bundler
|
@@ -94,6 +94,20 @@ dependencies:
|
|
94
94
|
- - ">="
|
95
95
|
- !ruby/object:Gem::Version
|
96
96
|
version: '0'
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: concurrent-ruby
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - ">="
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: '0'
|
104
|
+
type: :runtime
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - ">="
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '0'
|
97
111
|
- !ruby/object:Gem::Dependency
|
98
112
|
name: slop
|
99
113
|
requirement: !ruby/object:Gem::Requirement
|
@@ -124,6 +138,8 @@ files:
|
|
124
138
|
- lib/gruf.rb
|
125
139
|
- lib/gruf/cli/executor.rb
|
126
140
|
- lib/gruf/client.rb
|
141
|
+
- lib/gruf/client/error.rb
|
142
|
+
- lib/gruf/client/error_factory.rb
|
127
143
|
- lib/gruf/configuration.rb
|
128
144
|
- lib/gruf/controllers/base.rb
|
129
145
|
- lib/gruf/controllers/request.rb
|
@@ -154,6 +170,7 @@ files:
|
|
154
170
|
- lib/gruf/serializers/errors/base.rb
|
155
171
|
- lib/gruf/serializers/errors/json.rb
|
156
172
|
- lib/gruf/server.rb
|
173
|
+
- lib/gruf/synchronized_client.rb
|
157
174
|
- lib/gruf/timer.rb
|
158
175
|
- lib/gruf/version.rb
|
159
176
|
homepage: https://github.com/bigcommerce/gruf
|