Contr
Minimalistic contracts in plain Ruby.
Installation
Install the gem and add to Gemfile:
bundle add contr
Or install it manually:
gem install contr
Glossary
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.positive?
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 minimally required scope as with the synchronous 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: "SumContract", failed_rules: [...], ...}
# using `contract_name` and `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: "SumContract", failed_rules: [...], ...}
Logger
Default logger logs contract state to specified stream in JSON format. State structure is the same as in sampler with addition of 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-26T14:16:28.043910 #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), api_response|
post_id = api_response["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
- Contract definition
- Sampler
- Logger
- Sync matcher
- 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.