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.message}") }
)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 takes a callable, and yields two arguments to it (the results of the two implementations); its result is truthy or falsey. By default, comparison is done using==. -
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, andtag. Whenswitchis 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, andtag. -
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_twoIf 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_twoConfiguration
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