Skip to content

Commit

Permalink
Support non-deterministic resource configuration
Browse files Browse the repository at this point in the history
This behavior allows the HTTP adapter to call the configuration block on
every request.

Used in combination with external variables, such as thread local
variable, it enables the configuration of a single host/port combination
as multiple resources.

```ruby
SEMIAN_PARAMETERS = {
  # ...
  dynamic: true,
}

class CurrentSemianSubResource < ActiveSupport::Attributes
 attribute :name
end

Semian::NetHTTP.semian_configuration = proc do |host, port|
  name = "#{host}_#{port}"
  if (sub_resource_name = CurrentSemianSubResource.name)
    name << "_#{name}"
  end
  SEMIAN_PARAMETERS.merge(name: name)
end

# Two requests to example.com can use two different semian resources,
# as long as `CurrentSemianSubResource.name` is set accordingly:
# CurrentSemianSubResource.set(name: "sub_resource_1") { Net::HTTP.get_response(URI("http://example.com")) }
# and:
# CurrentSemianSubResource.set(name: "sub_resource_2") { Net::HTTP.get_response(URI("http://example.com")) }
```
  • Loading branch information
pjambet committed May 2, 2023
1 parent c6f5208 commit 14eb794
Show file tree
Hide file tree
Showing 7 changed files with 150 additions and 14 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Unreleased

* New `dynamic` options, allowing a per-request resource configuration with the HTTP adapter. (#485)

# v0.18.1

* Remove dependency on activerecord-trilogy-adapter in Trilogy adapter for Semian. (#486)
Expand Down
38 changes: 34 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,10 +254,40 @@ The `semian_options` passed apply to that resource. Semian creates the `semian_i
from the `name` to look up and store changes in the circuit breaker and bulkhead states
and associate successes, failures, errors with the protected resource.

We only require that:
* the `semian_configuration` be **set only once** over the lifetime of the library
* the output of the `proc` be the same over time, that is, the configuration produced by
each pair of `host`, `port` is **the same each time** the callback is invoked.
We only require that the `semian_configuration` be **set only once** over the lifetime of
the library.

If you need to return different values for the same pair of `host`/`port` value, you **must**
include the `dynamic: true` option. Returning different values for the same `host`/`port` values
without setting the `dynamic` option can lead to undesirable behavior.

A common example for dynamic options is the use of a thread local variable, such as
`ActiveSupport::CurrentAttributes`, for requests to a service acting as a proxy.

```ruby
SEMIAN_PARAMETERS = {
# ...
dynamic: true,
}

class CurrentSemianSubResource < ActiveSupport::Attributes
attribute :name
end

Semian::NetHTTP.semian_configuration = proc do |host, port|
name = "#{host}_#{port}"
if (sub_resource_name = CurrentSemianSubResource.name)
name << "_#{name}"
end
SEMIAN_PARAMETERS.merge(name: name)
end

# Two requests to example.com can use two different semian resources,
# as long as `CurrentSemianSubResource.name` is set accordingly:
# CurrentSemianSubResource.set(name: "sub_resource_1") { Net::HTTP.get_response(URI("http://example.com")) }
# and:
# CurrentSemianSubResource.set(name: "sub_resource_2") { Net::HTTP.get_response(URI("http://example.com")) }
```

For most purposes, `"#{host}_#{port}"` is a good default `name`. Custom `name` formats
can be useful to grouping related subdomains as one resource, so that they all
Expand Down
20 changes: 15 additions & 5 deletions lib/semian/adapter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,25 @@ def semian_identifier
end

def semian_resource
@semian_resource ||= case semian_options
return @semian_resource if @semian_resource

case semian_options
when false
UnprotectedResource.new(semian_identifier)
@semian_resource = UnprotectedResource.new(semian_identifier)
when nil
Semian.logger.info("Semian is not configured for #{self.class.name}: #{semian_identifier}")
UnprotectedResource.new(semian_identifier)
@semian_resource = UnprotectedResource.new(semian_identifier)
else
options = semian_options.dup
options.delete(:name)
options[:consumer] = self
options[:exceptions] ||= []
options[:exceptions] += resource_exceptions
::Semian.retrieve_or_register(semian_identifier, **options)
resource = ::Semian.retrieve_or_register(semian_identifier, **options)

@semian_resource = resource unless options.fetch(:dynamic, false)

resource
end
end

Expand Down Expand Up @@ -53,7 +59,11 @@ def semian_options
return @semian_options if defined? @semian_options

options = raw_semian_options
@semian_options = options && options.map { |k, v| [k.to_sym, v] }.to_h

symbolized_options = options && options.transform_keys(&:to_sym) # rubocop:disable Style/SafeNavigation
symbolized_options.tap do
@semian_options = symbolized_options if !symbolized_options || !symbolized_options.fetch(:dynamic, false)
end
end

def raw_semian_options
Expand Down
32 changes: 27 additions & 5 deletions lib/semian/net_http.rb
Original file line number Diff line number Diff line change
Expand Up @@ -91,16 +91,20 @@ def disabled?
end

def connect
return super if disabled?
with_cleared_dynamic_options do
return super if disabled?

acquire_semian_resource(adapter: :http, scope: :connection) { super }
acquire_semian_resource(adapter: :http, scope: :connection) { super }
end
end

def transport_request(*)
return super if disabled?
with_cleared_dynamic_options do
return super if disabled?

acquire_semian_resource(adapter: :http, scope: :query) do
handle_error_responses(super)
acquire_semian_resource(adapter: :http, scope: :query) do
handle_error_responses(super)
end
end
end

Expand All @@ -125,6 +129,24 @@ def handle_error_responses(result)
end
result
end

def with_cleared_dynamic_options
unless @resource_acquisition_in_progress
@resource_acquisition_in_progress = true
resource_acquisition_started = true
end

yield
ensure
if resource_acquisition_started
if @raw_semian_options&.fetch(:dynamic, false)
# Clear @raw_semian_options if the resource was flagged as dynamic.
@raw_semian_options = nil
end

@resource_acquisition_in_progress = false
end
end
end
end

Expand Down
9 changes: 9 additions & 0 deletions test/adapter_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,15 @@ def test_consumer_registration_does_not_prevent_gc
end
end

def test_does_not_memoize_dynamic_options
dynamic_client = Semian::DynamicAdapterTestClient.new(quota: 0.5)

refute_nil(dynamic_client.semian_resource)
assert_equal(4, dynamic_client.raw_semian_options[:success_threshold])
assert_equal(5, dynamic_client.raw_semian_options[:success_threshold])
assert_nil(dynamic_client.instance_variable_get("@semian_options"))
end

class MyAdapterError < StandardError
include Semian::AdapterError
end
Expand Down
29 changes: 29 additions & 0 deletions test/adapters/net_http_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ def setup
destroy_all_semian_resources
end

def teardown
Thread.current[:sub_resource_name] = nil
end

def test_semian_identifier
with_server do
with_semian_configuration do
Expand Down Expand Up @@ -484,6 +488,31 @@ def test_semian_disabled_env
ENV.delete("SEMIAN_DISABLED")
end

def test_dynamic_config
options = proc do |host, port|
DEFAULT_SEMIAN_OPTIONS.merge(
dynamic: true,
name: "#{host}_#{port}_#{Thread.current[:sub_resource_name]}",
)
end
host = SemianConfig["toxiproxy_upstream_host"]
port = SemianConfig["http_toxiproxy_port"]
http = Net::HTTP.new(host, port)
Thread.current[:sub_resource_name] = "service_a"

with_semian_configuration(options) do
with_server do
http.get("/200")

Thread.current[:sub_resource_name] = "service_b"
http.get("/200")

assert_includes(Semian.resources.keys, "nethttp_#{host}_#{port}_service_a")
assert_includes(Semian.resources.keys, "nethttp_#{host}_#{port}_service_b")
end
end
end

private

def half_open_cicuit!(backwards_time_travel = 10)
Expand Down
34 changes: 34 additions & 0 deletions test/helpers/adapter_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,28 @@ def resource_exceptions
end
end

module DynamicAdapterTest
include Semian::Adapter

def semian_identifier
:dynamic_semian_adapter_test
end

def raw_semian_options
{
bulkhead: false,
error_threshold: 1,
error_timeout: 1,
dynamic: true,
success_threshold: @current_success_threshold += 1,
}
end

def resource_exceptions
[]
end
end

class AdapterTestClient
include AdapterTest

Expand All @@ -34,4 +56,16 @@ def ==(other)
inspect == other.inspect
end
end

class DynamicAdapterTestClient
include DynamicAdapterTest

def initialize(**args)
@current_success_threshold = 1
end

def ==(other)
inspect == other.inspect
end
end
end

0 comments on commit 14eb794

Please sign in to comment.