Contr

Gem Version Test Coverage Status

Minimalistic contracts in plain Ruby.

Installation

Install the gem and add to Gemfile:

bundle add contr

Or install it manually:

gem install contr

Terminology

Contract consists of rules of 2 types:

  • guarantees - the ones that should be valid
  • expectations - the ones that could be valid

Contract is called matched when:

  • all guarantees are matched (if present)
  • at least one expectation is matched (if present)

Rule is matched when it returns truthy value.\ Rule is not matched when it returns falsy value (nil, false) or raises an error.

Contract is triggered after operation under guard is successfully executed.

Usage

Example of basic contract:

class SumContract < Contr::Act # or Contr::Base if you're a boring person
  guarantee :result_is_positive_float do |(_), result|
    result.is_a?(Float) && result > 0
  end

  guarantee :args_are_numbers do |args|
    args.all?(Numeric)
  end

  expect :arg_1_is_float do |(arg_1, _)|
    arg_1.is_a?(Float)
  end

  expect :arg_2_is_float do |(_, arg_2)|
    arg_2.is_a?(Float)
  end
end

args = [1, 2.0]
contract = SumContract.new

contract.check(*args) { args.inject(:+) }
# => 3.0

Contract check can be run in 2 modes: sync and async.

Sync

In sync mode rules are executed sequentially in the same thread with the operation.

If contract matched - operation result is returned afterwards:

contract.check(*args) { 1 + 1 }
# => 2

If contract failed - contract state is dumped via Sampler, logged via Logger and match error is raised:

contract.check(*args) { 1 + 1 }
# when one of the guarantees failed
# => Contr::Matcher::GuaranteesNotMatched: failed rules: [...], args: [...]

# when all expectations failed
# => Contr::Matcher::ExpectationsNotMatched: failed rules: [...], args: [...]

If operation raises an error it will be propagated right away, without triggering the contract itself:

contract.check(*args) { raise StandardError, "some error" }
# => StandardError: some error
# (no state dump, no log)

Async

In async mode rules are executed in a separate thread. Operation result is returned immediately regardless of contract match status:

contract.check_async(*args) { 1 + 1 }
# => 2
# (contract is still being checked in a background)
#
# if contract matched - nothing additional happens
# if contract failed - state is dumped and logged as with `#check`

If operation raises an error it will be propagated right away, without triggering the contract itself:

contract.check_async(*args) { raise StandardError, "some error" }
# => StandardError: some error
# (no state dump, no log)

Each contract instance can work with 2 dedicated thread pools:

  • main - to execute contract checks asynchronously (always present)
  • rules - to execute rules asynchronously (not set by default)

There are couple of predefined pool primitives that can be used:

# fixed
# - works as a fixed pool of size: 0..max_threads
# - max_threads == vCPU cores, but can be overridden
# - similar to `fast` provided by `concurrent-ruby`, but is not global
Contr::Async::Pool::Fixed.new
Contr::Async::Pool::Fixed.new(max_threads: 9000)

# io (global)
# - provided by `concurrent-ruby`
# - works as a dynamic pool of almost unlimited size (danger!)
Contr::Async::Pool::GlobalIO.new

Default contract async config looks like this:

class SomeContract < Contr::Act
  async pools: {
    main: Contr::Async::Pool::Fixed.new,
    rules: nil # disabled, rules are executed synchronously
  }
end

To enable asynchronous execution of rules:

class SomeContract < Contr::Act
  async pools: {
    rules: Contr::Async::Pool::GlobalIO.new # or any other pool
  }
end

[!NOTE] Asynchronous execution of rules forces to check them all - not the smallest scope possible as with the sequential one. Make sure that potential extra calls to DB/network are OK (if they have place).

It's also possible to define custom pool:

class CustomPool < Contr::Async::Pool::Base
  # optional
  def initialize(*some_args)
    # ...
  end

  # required!
  def create_executor
    Concurrent::ThreadPoolExecutor.new(
      min_threads: 0,
      max_threads: 1234
      # ...other opts
    )
  end
end

class SomeContract < Contr::Act
  async pools: {
    main: CustomPool.new(*some_args)
  }
end

Comparison of different pools configurations can be checked in Benchmarks section.

Sampler

Default sampler creates marshalized dumps of contract state in specified folder with sampling period frequency:

# state structure
{
  ts:            "2024-02-26T14:16:28.044Z",
  contract_name: "SumContract",
  failed_rules: [
    {type: :expectation, name: :arg_1_is_float, status: :failed},
    {type: :expectation, name: :arg_2_check_that_raises, status: :unexpected_error, error: error_instance}
  ],
  ok_rules: [
    {type: :guarantee, name: :result_is_positive_float, status: :ok},
    {type: :guarantee, name: :args_are_numbers, status: :ok}
  ],
  async:  false,
  args:   [1, 2.0],
  result: 3.0
}

# default sampler can be reconfigured
ConfiguredSampler = Contr::Sampler::Default.new(
  folder: "/tmp/contract_dumps",                         # default: "/tmp/contracts"
  path_template: "%<contract_name>s_%<period_id>i.bin",  # default: "%<contract_name>s/%<period_id>i.dump"
  period: 3600                                           # default: 600 (= 10 minutes)
)

class SomeContract < Contr::Act
  sampler ConfiguredSampler

  # ...
end

# it will create dumps:
#   /tmp/contract_dumps/SomeContract_474750.bin
#   /tmp/contract_dumps/SomeContract_474751.bin
#   /tmp/contract_dumps/SomeContract_474752.bin
#   ...

# NOTE: `period_id` is calculated as <unix_ts> / `period`

Sampler is enabled by default:

class SomeContract < Contr::Act
end

SomeContract.new.sampler
# => #<Contr::Sampler::Default:...>

It's possible to define custom sampler and use it instead:

class CustomSampler < Contr::Sampler::Base
  # optional
  def initialize(*some_args)
    # ...
  end

  # required!
  def sample!(state)
    # ...
  end
end

class SomeContract < Contr::Act
  sampler CustomSampler.new(*some_args)

  # ...
end

As well as to disable sampler completely:

class SomeContract < Contr::Act
  sampler nil # or `false`
end

Default sampler also provides a helper method to read created dumps:

contract.sampler
# => #<Contr::Sampler::Default:...>

# using absolute path
contract.sampler.read(path: "/tmp/contracts/SomeContract/474750.dump")
# => {ts: "2024-02-26T14:16:28.044Z", contract_name: "SomeContract", failed_rules: [...], ...}

# using `contract_name` + `period_id` args
# it uses `folder` and `path_template` from sampler config
contract.sampler.read(contract_name: "SomeContract", period_id: "474750")
# => {ts: "2024-02-26T14:16:28.044Z", contract_name: "SomeContract", failed_rules: [...], ...}

Logger

Default logger logs contract state to specified stream in JSON format. State structure is the same as in sampler plus additional tag field:

# state structure
{
  **sampler_state,
  tag: "contract-failed"
}

# default logger can be reconfigured
ConfiguredLogger = Contr::Logger::Default.new(
  stream: $stderr,     # default: $stdout
  log_level: :warn,    # default: :debug
  tag: "shit-happened" # default: "contract-failed"
)

class SomeContract < Contr::Act
  logger ConfiguredLogger

  # ...
end

# it will print:
# => W, [2024-02-27T14:36:53.607088 #58112]  WARN -- : {"ts":"...","contract_name":"...", ... "tag":"shit-happened"}

Logger is enabled by default:

class SomeContract < Contr::Act
end

SomeContract.new.logger
# => #<Contr::Logger::Default:...>

It's possible to define custom logger in the same manner as with sampler:

class CustomLogger < Contr::Sampler::Base
  # optional
  def initialize(*some_args)
    # ...
  end

  # required!
  def log(state)
    # ...
  end
end

class SomeContract < Contr::Act
  logger CustomLogger.new(*some_args)

  # ...
end

As well as to disable logger completely:

class SomeContract < Contr::Act
  logger nil # or `false`
end

Configuration

Contract can be configured using arguments passed to .new method:

class SomeContract < Contr::Act
end

contract = SomeContract.new(
  async: {pools: {main: OtherPool.new, rules: AnotherPool.new}},
  sampler: CustomSampler.new,
  logger: CustomLogger.new
)

contract.main_pool
# => #<OtherPool:...>

contract.rules_pool
# => #<AnotherPool:...>

contract.sampler
# => #<CustomSampler:...>

contract.logger
# => #<CustomLogger:...>

Contracts can be deeply inherited:

class SomeContract < Contr::Act
  guarantee :check_1 do
    # ...
  end

  expect :check_2 do
    # ...
  end
end
# guarantees:   check_1
# expectations: check_2
# async:        pools: {main: <fixed>, rules: nil}
# sampler:      Contr::Sampler::Default
# logger:       Contr::Logger:Default

class OtherContract < SomeContract
  async pools: {rules: Contr::Async::Pool::GlobalIO.new}
  sampler CustomSampler.new

  guarantee :check_3 do
    # ...
  end
end
# guarantees:   check_1, check_3
# expectations: check_2
# async         pools: {main: <fixed>, rules: <global_io>}
# sampler:      CustomSampler
# logger:       Contr::Logger:Default

class AnotherContract < OtherContract
  async pools: {main: Contr::Async::Pool::GlobalIO.new}
  logger nil

  expect :check_4 do
    # ...
  end
end
# guarantees:   check_1, check_3
# expectations: check_2, check_4
# async         pools: {main: <global_io>, rules: <global_io>}
# sampler:      CustomSampler
# logger:       nil

Rule block arguments can be accessed in different ways:

class SomeContract < Contr::Act
  guarantee :all_args_used do |(arg_1, arg_2), result|
    arg_1  # => 1
    arg_2  # => 2
    result # => 3
  end

  guarantee :result_ignored do |(arg_1, arg_2)|
    arg_1  # => 1
    arg_2  # => 2
  end

  guarantee :check_args_ignored do |(_), result|
    result # => 3
  end

  guarantee :args_not_used do
    # ...
  end
end

SomeContract.new.check(1, 2) { 1 + 2 }

Having access to result can be really useful in contracts where operation produces a data that must be used inside the rules:

class PostCreationContract < Contr::Act
  guarantee :verified_via_api do |(user_id), result|
    post_id = result["id"]
    API.post_exists?(user_id, post_id)
  end

  # ...
end

contract = PostCreationContract.new
contract.check(user_id) { API.create_post(*some_args) }
# => {"id":1050118621198921700, "text":"Post text", ...}

Contract instances are fully isolated from check invocations and can be safely cached:

module Contracts
  PostRemoval         = PostRemovalContract.new
  PostRemovalNoLogger = PostRemovalContract.new(logger: nil)

  # ...
end

posts.each do |post|
  Contracts::PostRemovalNoLogger.check_async(*args) { delete_post(post) }
end

Examples

Examples can be found here.

Benchmarks

Comparison of different pool configs for I/O blocking and CPU intensive tasks can be found in benchmarks folder.

TODO

  • [x] Contract definition
  • [x] Sampler
  • [x] Logger
  • [x] Sync matcher
  • [x] Async matcher
  • [ ] Add before block for rules variables pre-initialization
  • [ ] Add meta hash to have ability to capture additional debug data from within the rules

Development

bin/setup        # install deps
bin/console      # interactive prompt to play around
rake spec        # run tests
rake rubocop     # lint code
rake rubocop:md  # lint docs

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/ocvit/contr.

License

The gem is available as open source under the terms of the MIT License.

Credits