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: or replacement: name (but not both), and if it does, implemented_twice will 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 at rate for comparison). Arity 1 receives a BothIsGood::Context::Switching object, making it straightforward to drive from a feature-flag system. The context exposes target_class, method_name, target_class_name, target_class_string (underscored, like "my_module/my_class"), and tag (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 with initialize(a, b) and a zero-arity call (instantiated with both results, then called), or a symbol naming a registered comparator (see Comparators).
  • The on_mismatch: parameter takes a callable that receives a BothIsGood::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 unless rate is 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 a BothIsGood::Context::Error. The exception will be re-raised after handling. With switch active, "primary" is the replacement method.
  • The on_secondary_error: parameter takes the same shaped callable, but secondary exceptions are not re-raised. With switch active, "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 if on_hook_error is 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 within Float::EPSILON relative 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 .id on 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