BothIsGood
This gem adds a module to include into classes, supplying a convenient, concise way to implement multiple versions of the same method, and run them both. Then you can still use the old implementation, but get an alert or log message if the new version ever produces a different result.
This is not a new concept; scientist pioneered the approach in 2016. But
scientist is moderately heavy, and takes significant effort to use, so I've
ended up implementing lightweight dual-implementation libraries multiple times;
this time I'm publishing it so I won't have to do so again later!
Inline Invocation
The "simplest" way to use BothIsGood is 'inline' - no configuration object,
you just supply all of the needed options on the implemented_twice call in
place.
include BothIsGood
def foo_one = implementation(details)
def foo_two = more_implementation(details)
# A minimal call. Note that with no global configuration this is not very
# valuable, since if the implementations disagree, there's no hook implemented
# to _tell you_ that.
implemented_twice(:foo, original: :foo_one, replacement: :foo_two)
# A complex call using all of the available options:
implemented_twice(
:foo,
original: :foo_one,
replacement: :foo_two,
rate: 0.01,
switch: ->(ctx) { FeatureFlags.enabled?(:"enable_#{ctx.tag}") },
comparator: ->(val_one, val_two) { Math.abs(val_one - val_two) < 0.01 },
on_mismatch: ->(ctx) { LOGGER.warn("mismatch: #{ctx.primary_result} | #{ctx.secondary_result}") },
on_compare: ->(ctx) { LOGGER.warn("comparing #{ctx.primary_result} to #{ctx.secondary_result}") },
on_primary_error: ->(ctx) { LOGGER.warn("primary error #{ctx.error.class.name}") },
on_secondary_error: ->(ctx) { LOGGER.warn("secondary error #{ctx.error.class.name}") },
on_hook_error: ->(err) { LOGGER.warn("OH NO! #{err.class.name}: #{err.}") }
)
The method takes these parameters:
- The (only) positional parameter is the name of the method it will implement.
This can match the
original:orreplacement:name (but not both), and if it does,implemented_twicewill alias the existing method out of the way (to_bothisgood_original_#{name}or_bothisgood_replacement_#{name}). - The
original:parameter specifies a method name that will be called and have its result used as the return value regardless of the comparison outcome. Errors from the original method are bubbled up as usual. - The
replacement:parameter specifies a method name that will be called for comparison's sake (though not necessarily every time). Errors raised from the replacement method are swallowed. - The
rate:parameter (default 1.0) specifies what fraction of the calls should bother evaluating the shadow implementation for comparison. If the implementation is costly (makes significant database calls, for example) and/or invoked frequently, you probably want a lower rate in production. - The
switch:parameter takes a callable with arity 0 or 1. When it returns a truthy value, the roles swap: replacement becomes the return value and original becomes the shadow (called atratefor comparison). Arity 1 receives aBothIsGood::Context::Switchingobject, making it straightforward to drive from a feature-flag system. The context exposestarget_class,method_name,target_class_name,target_class_string(underscored, like"my_module/my_class"), andtag(like"my_mod/my_class--my_method") - The
comparator:parameter controls how results are compared. By default, comparison is done using==. It accepts a callable with arity 2 (called with both results), a class withinitialize(a, b)and a zero-aritycall(instantiated with both results, thencalled), or a symbol naming a registered comparator (see Comparators). - The
on_mismatch:parameter takes a callable that receives aBothIsGood::Context::Result. It fires any time the results differ. - The
on_compare:parameter takes the same shaped callable, but fires any time both implementations are evaluated (every time unlessrateis set).
The result context exposes primary_result, secondary_result,
primary_name, secondary_name, args, target_class, method_name,
target_class_name, target_class_string, and tag. When switch is
active, "primary" is the replacement and "secondary" is the original.
- The
on_primary_error:parameter takes a callable that receives aBothIsGood::Context::Error. The exception will be re-raised after handling. Withswitchactive, "primary" is the replacement method. - The
on_secondary_error:parameter takes the same shaped callable, but secondary exceptions are not re-raised. Withswitchactive, "secondary" is the original method.
The error context exposes error, args, dispatched_name (the actual
method called - primary or secondary), target_class, method_name,
target_class_name, target_class_string, and tag.
- The
on_hook_error:parameter is a callable that will be yielded one parameter (the StandardError instance), and is invoked if an error is raised during one of the other hooks. Those errors will be swallowed ifon_hook_erroris supplied (unless your hook re-raises!), and bubbled otherwise.
implemented_twice can additionally be called with three positional
parameters; the second is used as the original method name, and the third
as replacement. That means that, if you use a configuration object, you can
just:
include BothIsGood
def foo_one = implementation(details)
def foo_two = more_implementation(details)
# defines `foo`, using `foo_one` as original and `foo_two` as replacement.
implemented_twice :foo, :foo_one, :foo_two
If called with two positional parameters, the first is used as both the final method name and the original implementation.
include BothIsGood
def foo = implementation(details)
def foo_two = more_implementation(details)
# Defines `foo`, using `foo` as original and `foo_two` as replacement.
# The original `foo` method is aliased to `_bothisgood_original_foo`.
implemented_twice :foo, :foo_two
Comparators
Built-in comparators
Three named comparators are registered by default and can be used by symbol:
implemented_twice :foo, :foo_one, :foo_two, comparator: :float
implemented_twice :bar, :bar_one, :bar_two, comparator: :string_ci
implemented_twice :baz, :baz_one, :baz_two, comparator: :same_id
:float(BothIsGood::Comparators::FloatingPoint) - compares numeric values withinFloat::EPSILONrelative tolerance; handles infinities and NaN.:string_ci(BothIsGood::Comparators::StringCaseInsensitive) - compares strings case-insensitively; treats two nils as equal.:same_id(BothIsGood::Comparators::SameId) - compares objects by calling.idon each; treats two nils as equal.
Class-based comparators
You can pass a class directly as the comparator:. It must define
initialize(a, b) and a zero-arity call:
class MyComparator
def initialize(a, b)
@a = a
@b = b
end
def call
@a.normalize == @b.normalize
end
end
implemented_twice :foo, :foo_one, :foo_two, comparator: MyComparator
BothIsGood::Comparators::Base is available as a convenience base class
that provides initialize and private attr_reader :a, :b.
Registering custom named comparators
You can register a comparator class under a symbol name so it can be
referenced by name in any implemented_twice call:
BothIsGood.register_comparator(:normalized, MyComparator)
implemented_twice :foo, :foo_one, :foo_two, comparator: :normalized
Named comparators are stored on the global configuration and inherited by class-level configurations.
Configuration
All parameters aside from the positional, original:, and replacement: ones
can be configured globally, or onto a BothIsGood::Configuration object, to
avoid having to supply them constantly.
# Global configuration
BothIsGood.configure do |config|
config.rate = 0.5
config.switch = ->(ctx) { FeatureFlags.enabled?(:"enable_#{ctx.tag}") }
config.on_compare = ->(a, b) { LOGGER.puts "compared!" }
config.on_hook_error = ->(e) { LOGGER.puts "bad -.-" }
end
# Local configuration - starting values are taken from the global config
MY_BIG_CONFIG = BothIsGood::Configuration.new
MY_BIG_CONFIG.rate = 0.7
MY_BIG_CONFIG.on_secondary_error = ->(e) { LOGGER.puts "No" }
module MyFoo
include BothIsGood
self.both_is_good_configure(MY_BIG_CONFIG)
end
# In-class configuration - starting values are taken from the global config,
# or the supplied config object if one is given.
module MyBar
include BothIsGood
self.both_is_good_configure(rate: 0.02)
end