contr 0.1.0 → 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +218 -130
- data/lib/contr/async/pool/fixed.rb +24 -0
- data/lib/contr/async/pool/global_io.rb +13 -0
- data/lib/contr/async/pool.rb +27 -0
- data/lib/contr/base.rb +88 -52
- data/lib/contr/matcher/async.rb +58 -0
- data/lib/contr/matcher/sync.rb +2 -27
- data/lib/contr/matcher.rb +38 -18
- data/lib/contr/refines/hash.rb +17 -0
- data/lib/contr/sampler/default.rb +2 -0
- data/lib/contr/version.rb +1 -1
- data/lib/contr.rb +5 -0
- metadata +22 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 270200ede82d457029fffede0c22fd9dc4add6b593b595036bcf3af494b615ac
|
4
|
+
data.tar.gz: 10f2da11660e04cf7c94a402bf4e0111e1a1a7b6879d9e441145f9ea93c657d3
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: a79e20a17786f89df7b4e10339db4718e9a5da6118faedc150ef7aadd17771b941cce44f25d2f63cb356348e4f866c802259a2a29fc9b4837bcc4652bb25886e
|
7
|
+
data.tar.gz: 31046e13a3e23dc558cde4e38919b577fe5583e4d23e451df5a76cf64053d2141607e3a449ce46fd743376297e8197bfdeea75023259ddafca09b7f9ad975c54
|
data/README.md
CHANGED
@@ -1,5 +1,9 @@
|
|
1
1
|
# Contr
|
2
2
|
|
3
|
+
[![Gem Version](https://badge.fury.io/rb/contr.svg)](https://badge.fury.io/rb/contr)
|
4
|
+
[![Test](https://github.com/ocvit/contr/workflows/Test/badge.svg)](https://github.com/ocvit/contr/actions)
|
5
|
+
[![Coverage Status](https://coveralls.io/repos/github/ocvit/contr/badge.svg?branch=main)](https://coveralls.io/github/ocvit/contr?branch=main)
|
6
|
+
|
3
7
|
Minimalistic contracts in plain Ruby.
|
4
8
|
|
5
9
|
## Installation
|
@@ -28,57 +32,61 @@ Contract is called *__matched__* when:
|
|
28
32
|
- *__at least one__* expectation is matched (if present)
|
29
33
|
|
30
34
|
Rule is *__matched__* when it returns *__truthy__* value.\
|
31
|
-
Rule is *__not matched__* when it returns *
|
35
|
+
Rule is *__not matched__* when it returns *__falsy__* value (`nil`, `false`) or *__raises an error__*.
|
32
36
|
|
33
|
-
Contract is triggered *__after__* operation under guard is
|
37
|
+
Contract is triggered *__after__* operation under guard is successfully executed.
|
34
38
|
|
35
39
|
## Usage
|
36
40
|
|
37
41
|
Example of basic contract:
|
38
42
|
|
39
43
|
```ruby
|
40
|
-
class
|
41
|
-
guarantee :
|
42
|
-
|
44
|
+
class SumContract < Contr::Act # or Contr::Base if you're a boring person
|
45
|
+
guarantee :result_is_positive_float do |(_), result|
|
46
|
+
result.is_a?(Float) && result > 0
|
43
47
|
end
|
44
48
|
|
45
|
-
guarantee :
|
46
|
-
|
49
|
+
guarantee :args_are_numbers do |args|
|
50
|
+
args.all?(Numeric)
|
47
51
|
end
|
48
52
|
|
49
|
-
expect :
|
50
|
-
|
53
|
+
expect :arg_1_is_float do |(arg_1, _)|
|
54
|
+
arg_1.is_a?(Float)
|
51
55
|
end
|
52
56
|
|
53
|
-
expect :
|
54
|
-
|
57
|
+
expect :arg_2_is_float do |(_, arg_2)|
|
58
|
+
arg_2.is_a?(Float)
|
55
59
|
end
|
56
60
|
end
|
57
61
|
|
58
|
-
|
62
|
+
args = [1, 2.0]
|
63
|
+
contract = SumContract.new
|
64
|
+
|
65
|
+
contract.check(*args) { args.inject(:+) }
|
66
|
+
# => 3.0
|
59
67
|
```
|
60
68
|
|
61
69
|
Contract check can be run in 2 modes: `sync` and `async`.
|
62
70
|
|
63
71
|
### Sync
|
64
72
|
|
65
|
-
In `sync` mode
|
73
|
+
In `sync` mode rules are executed sequentially in the same thread with the operation.
|
66
74
|
|
67
|
-
If contract
|
75
|
+
If contract matched - operation result is returned afterwards:
|
68
76
|
|
69
77
|
```ruby
|
70
|
-
|
71
|
-
# =>
|
78
|
+
contract.check(*args) { 1 + 1 }
|
79
|
+
# => 2
|
72
80
|
```
|
73
81
|
|
74
|
-
If contract
|
82
|
+
If contract failed - contract state is dumped via [Sampler](#Sampler), logged via [Logger](#Logger) and match error is raised:
|
75
83
|
|
76
84
|
```ruby
|
77
|
-
contract.check(*args) {
|
78
|
-
#
|
85
|
+
contract.check(*args) { 1 + 1 }
|
86
|
+
# when one of the guarantees failed
|
79
87
|
# => Contr::Matcher::GuaranteesNotMatched: failed rules: [...], args: [...]
|
80
88
|
|
81
|
-
#
|
89
|
+
# when all expectations failed
|
82
90
|
# => Contr::Matcher::ExpectationsNotMatched: failed rules: [...], args: [...]
|
83
91
|
```
|
84
92
|
|
@@ -87,11 +95,102 @@ If operation raises an error it will be propagated right away, without triggerin
|
|
87
95
|
```ruby
|
88
96
|
contract.check(*args) { raise StandardError, "some error" }
|
89
97
|
# => StandardError: some error
|
98
|
+
# (no state dump, no log)
|
90
99
|
```
|
91
100
|
|
92
101
|
### Async
|
93
102
|
|
94
|
-
|
103
|
+
In `async` mode rules are executed in a separate thread. Operation result is returned immediately regardless of contract match status:
|
104
|
+
|
105
|
+
```ruby
|
106
|
+
contract.check_async(*args) { 1 + 1 }
|
107
|
+
# => 2
|
108
|
+
# (contract is still being checked in a background)
|
109
|
+
#
|
110
|
+
# if contract matched - nothing additional happens
|
111
|
+
# if contract failed - state is dumped and logged as with `#check`
|
112
|
+
```
|
113
|
+
|
114
|
+
If operation raises an error it will be propagated right away, without triggering the contract itself:
|
115
|
+
|
116
|
+
```ruby
|
117
|
+
contract.check_async(*args) { raise StandardError, "some error" }
|
118
|
+
# => StandardError: some error
|
119
|
+
# (no state dump, no log)
|
120
|
+
```
|
121
|
+
|
122
|
+
Each contract instance can work with 2 dedicated thread pools:
|
123
|
+
|
124
|
+
- `main` - to execute contract checks asynchronously (always present)
|
125
|
+
- `rules` - to execute rules asynchronously (not set by default)
|
126
|
+
|
127
|
+
There are couple of predefined pool primitives that can be used:
|
128
|
+
|
129
|
+
```ruby
|
130
|
+
# fixed
|
131
|
+
# - works as a fixed pool of size: 0..max_threads
|
132
|
+
# - max_threads == vCPU cores, but can be overridden
|
133
|
+
# - similar to `fast` provided by `concurrent-ruby`, but is not global
|
134
|
+
Contr::Async::Pool::Fixed.new
|
135
|
+
Contr::Async::Pool::Fixed.new(max_threads: 9000)
|
136
|
+
|
137
|
+
# io (global)
|
138
|
+
# - provided by `concurrent-ruby`
|
139
|
+
# - works as a dynamic pool of almost unlimited size (danger!)
|
140
|
+
Contr::Async::Pool::GlobalIO.new
|
141
|
+
```
|
142
|
+
|
143
|
+
Default contract `async` config looks like this:
|
144
|
+
|
145
|
+
```ruby
|
146
|
+
class SomeContract < Contr::Act
|
147
|
+
async pools: {
|
148
|
+
main: Contr::Async::Pool::Fixed.new,
|
149
|
+
rules: nil # disabled, rules are executed synchronously
|
150
|
+
}
|
151
|
+
end
|
152
|
+
```
|
153
|
+
|
154
|
+
To enable asynchronous execution of rules:
|
155
|
+
|
156
|
+
```ruby
|
157
|
+
class SomeContract < Contr::Act
|
158
|
+
async pools: {
|
159
|
+
rules: Contr::Async::Pool::GlobalIO.new # or any other pool
|
160
|
+
}
|
161
|
+
end
|
162
|
+
```
|
163
|
+
|
164
|
+
> [!NOTE]
|
165
|
+
> 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).
|
166
|
+
|
167
|
+
It's also possible to define custom pool:
|
168
|
+
|
169
|
+
```ruby
|
170
|
+
class CustomPool < Contr::Async::Pool::Base
|
171
|
+
# optional
|
172
|
+
def initialize(*some_args)
|
173
|
+
# ...
|
174
|
+
end
|
175
|
+
|
176
|
+
# required!
|
177
|
+
def create_executor
|
178
|
+
Concurrent::ThreadPoolExecutor.new(
|
179
|
+
min_threads: 0,
|
180
|
+
max_threads: 1234
|
181
|
+
# ...other opts
|
182
|
+
)
|
183
|
+
end
|
184
|
+
end
|
185
|
+
|
186
|
+
class SomeContract < Contr::Act
|
187
|
+
async pools: {
|
188
|
+
main: CustomPool.new(*some_args)
|
189
|
+
}
|
190
|
+
end
|
191
|
+
```
|
192
|
+
|
193
|
+
Comparison of different pools configurations can be checked in [Benchmarks](#Benchmarks) section.
|
95
194
|
|
96
195
|
## Sampler
|
97
196
|
|
@@ -101,29 +200,29 @@ Default sampler creates marshalized dumps of contract state in specified folder
|
|
101
200
|
# state structure
|
102
201
|
{
|
103
202
|
ts: "2024-02-26T14:16:28.044Z",
|
104
|
-
contract_name: "
|
203
|
+
contract_name: "SumContract",
|
105
204
|
failed_rules: [
|
106
|
-
{type: :expectation, name: :
|
107
|
-
{type: :expectation, name: :
|
205
|
+
{type: :expectation, name: :arg_1_is_float, status: :failed},
|
206
|
+
{type: :expectation, name: :arg_2_check_that_raises, status: :unexpected_error, error: error_instance}
|
108
207
|
],
|
109
208
|
ok_rules: [
|
110
|
-
{type: :guarantee, name: :
|
111
|
-
{type: :guarantee, name: :
|
209
|
+
{type: :guarantee, name: :result_is_positive_float, status: :ok},
|
210
|
+
{type: :guarantee, name: :args_are_numbers, status: :ok}
|
112
211
|
],
|
113
212
|
async: false,
|
114
|
-
args: [1, 2
|
115
|
-
result:
|
213
|
+
args: [1, 2.0],
|
214
|
+
result: 3.0
|
116
215
|
}
|
117
216
|
|
118
217
|
# default sampler can be reconfigured
|
119
|
-
|
120
|
-
folder: "/tmp/contract_dumps",
|
121
|
-
path_template: "%<contract_name>
|
122
|
-
period: 3600
|
218
|
+
ConfiguredSampler = Contr::Sampler::Default.new(
|
219
|
+
folder: "/tmp/contract_dumps", # default: "/tmp/contracts"
|
220
|
+
path_template: "%<contract_name>s_%<period_id>i.bin", # default: "%<contract_name>s/%<period_id>i.dump"
|
221
|
+
period: 3600 # default: 600 (= 10 minutes)
|
123
222
|
)
|
124
223
|
|
125
224
|
class SomeContract < Contr::Act
|
126
|
-
sampler
|
225
|
+
sampler ConfiguredSampler
|
127
226
|
|
128
227
|
# ...
|
129
228
|
end
|
@@ -156,7 +255,7 @@ class CustomSampler < Contr::Sampler::Base
|
|
156
255
|
# ...
|
157
256
|
end
|
158
257
|
|
159
|
-
# required
|
258
|
+
# required!
|
160
259
|
def sample!(state)
|
161
260
|
# ...
|
162
261
|
end
|
@@ -195,7 +294,7 @@ contract.sampler.read(contract_name: "SomeContract", period_id: "474750")
|
|
195
294
|
|
196
295
|
## Logger
|
197
296
|
|
198
|
-
Default logger logs contract state to specified stream in JSON format. State structure is the same as
|
297
|
+
Default logger logs contract state to specified stream in JSON format. State structure is the same as in sampler plus additional `tag` field:
|
199
298
|
|
200
299
|
```ruby
|
201
300
|
# state structure
|
@@ -205,14 +304,14 @@ Default logger logs contract state to specified stream in JSON format. State str
|
|
205
304
|
}
|
206
305
|
|
207
306
|
# default logger can be reconfigured
|
208
|
-
|
307
|
+
ConfiguredLogger = Contr::Logger::Default.new(
|
209
308
|
stream: $stderr, # default: $stdout
|
210
309
|
log_level: :warn, # default: :debug
|
211
310
|
tag: "shit-happened" # default: "contract-failed"
|
212
311
|
)
|
213
312
|
|
214
313
|
class SomeContract < Contr::Act
|
215
|
-
logger
|
314
|
+
logger ConfiguredLogger
|
216
315
|
|
217
316
|
# ...
|
218
317
|
end
|
@@ -240,7 +339,7 @@ class CustomLogger < Contr::Sampler::Base
|
|
240
339
|
# ...
|
241
340
|
end
|
242
341
|
|
243
|
-
# required
|
342
|
+
# required!
|
244
343
|
def log(state)
|
245
344
|
# ...
|
246
345
|
end
|
@@ -263,6 +362,31 @@ end
|
|
263
362
|
|
264
363
|
## Configuration
|
265
364
|
|
365
|
+
Contract can be configured using arguments passed to `.new` method:
|
366
|
+
|
367
|
+
```ruby
|
368
|
+
class SomeContract < Contr::Act
|
369
|
+
end
|
370
|
+
|
371
|
+
contract = SomeContract.new(
|
372
|
+
async: {pools: {main: OtherPool.new, rules: AnotherPool.new}},
|
373
|
+
sampler: CustomSampler.new,
|
374
|
+
logger: CustomLogger.new
|
375
|
+
)
|
376
|
+
|
377
|
+
contract.main_pool
|
378
|
+
# => #<OtherPool:...>
|
379
|
+
|
380
|
+
contract.rules_pool
|
381
|
+
# => #<AnotherPool:...>
|
382
|
+
|
383
|
+
contract.sampler
|
384
|
+
# => #<CustomSampler:...>
|
385
|
+
|
386
|
+
contract.logger
|
387
|
+
# => #<CustomLogger:...>
|
388
|
+
```
|
389
|
+
|
266
390
|
Contracts can be deeply inherited:
|
267
391
|
|
268
392
|
```ruby
|
@@ -275,58 +399,58 @@ class SomeContract < Contr::Act
|
|
275
399
|
# ...
|
276
400
|
end
|
277
401
|
end
|
278
|
-
# guarantees:
|
279
|
-
#
|
280
|
-
#
|
281
|
-
#
|
402
|
+
# guarantees: check_1
|
403
|
+
# expectations: check_2
|
404
|
+
# async: pools: {main: <fixed>, rules: nil}
|
405
|
+
# sampler: Contr::Sampler::Default
|
406
|
+
# logger: Contr::Logger:Default
|
282
407
|
|
283
408
|
class OtherContract < SomeContract
|
409
|
+
async pools: {rules: Contr::Async::Pool::GlobalIO.new}
|
284
410
|
sampler CustomSampler.new
|
285
411
|
|
286
412
|
guarantee :check_3 do
|
287
413
|
# ...
|
288
414
|
end
|
289
415
|
end
|
290
|
-
# guarantees:
|
291
|
-
#
|
292
|
-
#
|
293
|
-
#
|
416
|
+
# guarantees: check_1, check_3
|
417
|
+
# expectations: check_2
|
418
|
+
# async pools: {main: <fixed>, rules: <global_io>}
|
419
|
+
# sampler: CustomSampler
|
420
|
+
# logger: Contr::Logger:Default
|
294
421
|
|
295
422
|
class AnotherContract < OtherContract
|
423
|
+
async pools: {main: Contr::Async::Pool::GlobalIO.new}
|
296
424
|
logger nil
|
297
425
|
|
298
426
|
expect :check_4 do
|
299
427
|
# ...
|
300
428
|
end
|
301
429
|
end
|
302
|
-
# guarantees:
|
303
|
-
#
|
304
|
-
#
|
305
|
-
#
|
430
|
+
# guarantees: check_1, check_3
|
431
|
+
# expectations: check_2, check_4
|
432
|
+
# async pools: {main: <global_io>, rules: <global_io>}
|
433
|
+
# sampler: CustomSampler
|
434
|
+
# logger: nil
|
306
435
|
```
|
307
436
|
|
308
|
-
|
437
|
+
Rule block arguments can be accessed in different ways:
|
309
438
|
|
310
439
|
```ruby
|
311
440
|
class SomeContract < Contr::Act
|
312
|
-
|
313
|
-
|
314
|
-
|
315
|
-
|
316
|
-
|
317
|
-
# => #<CustomSampler:...>
|
318
|
-
|
319
|
-
contract.logger
|
320
|
-
# => #<CustomLogger:...>
|
321
|
-
```
|
441
|
+
guarantee :all_args_used do |(arg_1, arg_2), result|
|
442
|
+
arg_1 # => 1
|
443
|
+
arg_2 # => 2
|
444
|
+
result # => 3
|
445
|
+
end
|
322
446
|
|
323
|
-
|
447
|
+
guarantee :result_ignored do |(arg_1, arg_2)|
|
448
|
+
arg_1 # => 1
|
449
|
+
arg_2 # => 2
|
450
|
+
end
|
324
451
|
|
325
|
-
|
326
|
-
|
327
|
-
guarantee :args_used do |arg_1, arg_2|
|
328
|
-
arg_1 # => 1
|
329
|
-
arg_2 # => 2
|
452
|
+
guarantee :check_args_ignored do |(_), result|
|
453
|
+
result # => 3
|
330
454
|
end
|
331
455
|
|
332
456
|
guarantee :args_not_used do
|
@@ -334,33 +458,15 @@ class SomeContract < Contr::Act
|
|
334
458
|
end
|
335
459
|
end
|
336
460
|
|
337
|
-
SomeContract.new.check(1, 2) {
|
338
|
-
```
|
339
|
-
|
340
|
-
Each rule has access to contract variables:
|
341
|
-
|
342
|
-
```ruby
|
343
|
-
class SomeContract < Contr::Act
|
344
|
-
guarantee :check_1 do
|
345
|
-
@args # => [1, [2, 3], {key: :value}]
|
346
|
-
@result # => 2
|
347
|
-
@contract # => #<SomeContract:...>
|
348
|
-
@guarantees # => [{type: :guarantee, name: :check_1, block: #<Proc:...>}]
|
349
|
-
@expectations # => []
|
350
|
-
@sampler # => #<Contr::Sampler::Default:...>
|
351
|
-
@logger # => #<Contr::Logger::Default:...>
|
352
|
-
end
|
353
|
-
end
|
354
|
-
|
355
|
-
SomeContract.new.check(1, [2, 3], {key: :value}) { 1 + 1 }
|
461
|
+
SomeContract.new.check(1, 2) { 1 + 2 }
|
356
462
|
```
|
357
463
|
|
358
|
-
Having access to
|
464
|
+
Having access to `result` can be really useful in contracts where operation produces a data that must be used inside the rules:
|
359
465
|
|
360
466
|
```ruby
|
361
467
|
class PostCreationContract < Contr::Act
|
362
|
-
guarantee :verified_via_api do |user_id|
|
363
|
-
post_id =
|
468
|
+
guarantee :verified_via_api do |(user_id), result|
|
469
|
+
post_id = result["id"]
|
364
470
|
API.post_exists?(user_id, post_id)
|
365
471
|
end
|
366
472
|
|
@@ -368,56 +474,32 @@ class PostCreationContract < Contr::Act
|
|
368
474
|
end
|
369
475
|
|
370
476
|
contract = PostCreationContract.new
|
371
|
-
|
372
477
|
contract.check(user_id) { API.create_post(*some_args) }
|
373
478
|
# => {"id":1050118621198921700, "text":"Post text", ...}
|
374
479
|
```
|
375
480
|
|
376
|
-
|
481
|
+
Contract instances are fully isolated from check invocations and can be safely cached:
|
377
482
|
|
378
483
|
```ruby
|
379
|
-
|
380
|
-
|
381
|
-
|
382
|
-
@guarantees << {
|
383
|
-
type: :guarantee,
|
384
|
-
name: :guarantee_2,
|
385
|
-
block: proc do
|
386
|
-
puts "guarantee 2"
|
387
|
-
true
|
388
|
-
end
|
389
|
-
}
|
390
|
-
|
391
|
-
puts "guarantee 1"
|
392
|
-
true
|
393
|
-
end
|
484
|
+
module Contracts
|
485
|
+
PostRemoval = PostRemovalContract.new
|
486
|
+
PostRemovalNoLogger = PostRemovalContract.new(logger: nil)
|
394
487
|
|
395
|
-
|
396
|
-
# add new expectation to the end of expectations list
|
397
|
-
@expectations << {
|
398
|
-
type: :expectation,
|
399
|
-
name: :expect_2,
|
400
|
-
block: proc do |*args|
|
401
|
-
puts "expect 2, args: #{args}"
|
402
|
-
true
|
403
|
-
end
|
404
|
-
}
|
405
|
-
|
406
|
-
puts "expect 1"
|
407
|
-
false
|
408
|
-
end
|
488
|
+
# ...
|
409
489
|
end
|
410
490
|
|
411
|
-
|
412
|
-
|
413
|
-
|
414
|
-
# => guarantee 1
|
415
|
-
# => guarantee 2
|
416
|
-
# => expect 1
|
417
|
-
# => expect 2, args: [1, 2]
|
491
|
+
posts.each do |post|
|
492
|
+
Contracts::PostRemovalNoLogger.check_async(*args) { delete_post(post) }
|
493
|
+
end
|
418
494
|
```
|
419
495
|
|
420
|
-
|
496
|
+
## Examples
|
497
|
+
|
498
|
+
Examples can be found [here](https://github.com/ocvit/contr/tree/main/examples).
|
499
|
+
|
500
|
+
## Benchmarks
|
501
|
+
|
502
|
+
Comparison of different pool configs for [I/O blocking](https://github.com/ocvit/contr/blob/main/benchmarks/io_task.rb) and [CPU intensive](https://github.com/ocvit/contr/blob/main/benchmarks/cpu_task.rb) tasks can be found in [benchmarks](https://github.com/ocvit/contr/tree/main/benchmarks) folder.
|
421
503
|
|
422
504
|
## TODO
|
423
505
|
|
@@ -425,8 +507,9 @@ Other instance variables (e.g. `@args`, `@logger` etc.) can be modified on the f
|
|
425
507
|
- [x] Sampler
|
426
508
|
- [x] Logger
|
427
509
|
- [x] Sync matcher
|
428
|
-
- [
|
429
|
-
- [ ] Add `before` block for
|
510
|
+
- [x] Async matcher
|
511
|
+
- [ ] Add `before` block for rules variables pre-initialization
|
512
|
+
- [ ] Add `meta` hash to have ability to capture additional debug data from within the rules
|
430
513
|
|
431
514
|
## Development
|
432
515
|
|
@@ -445,3 +528,8 @@ Bug reports and pull requests are welcome on GitHub at https://github.com/ocvit/
|
|
445
528
|
## License
|
446
529
|
|
447
530
|
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
531
|
+
|
532
|
+
## Credits
|
533
|
+
|
534
|
+
- [simple_contracts](https://github.com/bibendi/simple_contracts) by [bibendi](https://github.com/bibendi)
|
535
|
+
- [poro_contract](https://github.com/sclinede/poro_contract/) by [sclinede](https://github.com/sclinede)
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Contr
|
4
|
+
module Async
|
5
|
+
module Pool
|
6
|
+
class Fixed < Pool::Base
|
7
|
+
def initialize(max_threads: Concurrent.processor_count)
|
8
|
+
@max_threads = max_threads
|
9
|
+
end
|
10
|
+
|
11
|
+
def create_executor
|
12
|
+
Concurrent::ThreadPoolExecutor.new(
|
13
|
+
min_threads: 0,
|
14
|
+
max_threads: @max_threads,
|
15
|
+
auto_terminate: true,
|
16
|
+
idletime: 60, # 1 minute
|
17
|
+
max_queue: 0, # unlimited
|
18
|
+
fallback_policy: :caller_runs # doesn't matter - max_queue 0
|
19
|
+
)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "concurrent-ruby"
|
4
|
+
|
5
|
+
module Contr
|
6
|
+
module Async
|
7
|
+
module Pool
|
8
|
+
class Base
|
9
|
+
def executor
|
10
|
+
@executor ||= create_executor
|
11
|
+
end
|
12
|
+
|
13
|
+
def create_executor
|
14
|
+
raise NotImplementedError, "pool should implement `#create_executor` method"
|
15
|
+
end
|
16
|
+
|
17
|
+
def future(*args, &block)
|
18
|
+
Concurrent::Promises.future_on(executor, *args, &block)
|
19
|
+
end
|
20
|
+
|
21
|
+
def zip(*futures)
|
22
|
+
Concurrent::Promises.zip_futures_on(executor, *futures)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
data/lib/contr/base.rb
CHANGED
@@ -5,6 +5,10 @@ module Contr
|
|
5
5
|
class << self
|
6
6
|
attr_reader :config, :guarantees, :expectations
|
7
7
|
|
8
|
+
def async(async)
|
9
|
+
set_config(:async, async)
|
10
|
+
end
|
11
|
+
|
8
12
|
def logger(logger)
|
9
13
|
set_config(:logger, logger)
|
10
14
|
end
|
@@ -39,14 +43,20 @@ module Contr
|
|
39
43
|
end
|
40
44
|
end
|
41
45
|
|
42
|
-
attr_reader :logger, :sampler
|
46
|
+
attr_reader :config, :logger, :sampler, :main_pool, :rules_pool, :guarantees, :expectations
|
43
47
|
|
44
48
|
def initialize(instance_config = {})
|
45
|
-
@
|
46
|
-
|
49
|
+
@config = merge_configs(instance_config)
|
50
|
+
|
51
|
+
init_logger!
|
52
|
+
init_sampler!
|
53
|
+
init_main_pool!
|
54
|
+
init_rules_pool!
|
47
55
|
|
48
|
-
|
49
|
-
|
56
|
+
aggregate_guarantees!
|
57
|
+
aggregate_expectations!
|
58
|
+
|
59
|
+
freeze
|
50
60
|
end
|
51
61
|
|
52
62
|
def check(*args)
|
@@ -55,72 +65,98 @@ module Contr
|
|
55
65
|
result
|
56
66
|
end
|
57
67
|
|
68
|
+
def check_async(*args)
|
69
|
+
result = yield
|
70
|
+
Matcher::Async.new(self, args, result).match
|
71
|
+
result
|
72
|
+
end
|
73
|
+
|
58
74
|
def name
|
59
75
|
self.class.name
|
60
76
|
end
|
61
77
|
|
62
|
-
|
63
|
-
@guarantees ||= aggregate_rules(:guarantees)
|
64
|
-
end
|
78
|
+
private
|
65
79
|
|
66
|
-
|
67
|
-
@expectations ||= aggregate_rules(:expectations)
|
68
|
-
end
|
80
|
+
using Refines::Hash
|
69
81
|
|
70
|
-
|
82
|
+
def merge_configs(instance_config)
|
83
|
+
configs = contracts_chain.filter_map(&:config)
|
84
|
+
configs << instance_config
|
71
85
|
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
case [instance_config, self.class.config, parent_config]
|
76
|
-
in [{logger: }, *]
|
77
|
-
logger
|
78
|
-
in [_, {logger: }, _]
|
79
|
-
logger
|
80
|
-
in [*, {logger: }]
|
81
|
-
logger
|
82
|
-
else
|
83
|
-
Logger::Default.new
|
84
|
-
end
|
86
|
+
merged = configs.inject(&:deep_merge) || {}
|
87
|
+
merged.freeze
|
85
88
|
end
|
86
89
|
|
87
|
-
def
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
end
|
90
|
+
def init_logger!
|
91
|
+
@logger =
|
92
|
+
case config
|
93
|
+
in logger: nil | false
|
94
|
+
nil
|
95
|
+
in logger: Logger::Base => custom_logger
|
96
|
+
custom_logger
|
97
|
+
in logger: invalid_logger
|
98
|
+
raise ArgumentError, "logger should be inherited from Contr::Logger::Base or be falsy, received: #{invalid_logger.inspect}"
|
99
|
+
else
|
100
|
+
Logger::Default.new
|
101
|
+
end
|
100
102
|
end
|
101
103
|
|
102
|
-
def
|
103
|
-
|
104
|
-
|
104
|
+
def init_sampler!
|
105
|
+
@sampler =
|
106
|
+
case config
|
107
|
+
in sampler: nil | false
|
108
|
+
nil
|
109
|
+
in sampler: Sampler::Base => custom_sampler
|
110
|
+
custom_sampler
|
111
|
+
in sampler: invalid_sampler
|
112
|
+
raise ArgumentError, "sampler should be inherited from Contr::Sampler::Base or be falsy, received: #{invalid_sampler.inspect}"
|
113
|
+
else
|
114
|
+
Sampler::Default.new
|
115
|
+
end
|
105
116
|
end
|
106
117
|
|
107
|
-
def
|
108
|
-
|
109
|
-
|
118
|
+
def init_main_pool!
|
119
|
+
@main_pool =
|
120
|
+
case config.dig(:async, :pools)
|
121
|
+
in main: nil | false
|
122
|
+
raise ArgumentError, "main pool can't be disabled"
|
123
|
+
in main: Async::Pool::Base => custom_pool
|
124
|
+
custom_pool
|
125
|
+
in main: invalid_pool
|
126
|
+
raise ArgumentError, "main pool should be inherited from Contr::Async::Pool::Base, received: #{invalid_pool.inspect}"
|
127
|
+
else
|
128
|
+
Async::Pool::Fixed.new
|
129
|
+
end
|
110
130
|
end
|
111
131
|
|
112
|
-
def
|
113
|
-
|
114
|
-
|
132
|
+
def init_rules_pool!
|
133
|
+
@rules_pool =
|
134
|
+
case config.dig(:async, :pools)
|
135
|
+
in rules: nil | false
|
136
|
+
nil
|
137
|
+
in rules: Async::Pool::Base => custom_pool
|
138
|
+
custom_pool
|
139
|
+
in rules: invalid_pool
|
140
|
+
raise ArgumentError, "rules pool should be inherited from Contr::Async::Pool::Base or be falsy, received: #{invalid_pool.inspect}"
|
141
|
+
else
|
142
|
+
nil
|
143
|
+
end
|
144
|
+
end
|
115
145
|
|
116
|
-
|
146
|
+
def aggregate_guarantees!
|
147
|
+
@guarantees = aggregate_rules(:guarantees).freeze
|
117
148
|
end
|
118
149
|
|
119
|
-
def
|
120
|
-
|
121
|
-
|
150
|
+
def aggregate_expectations!
|
151
|
+
@expectations = aggregate_rules(:expectations).freeze
|
152
|
+
end
|
153
|
+
|
154
|
+
def aggregate_rules(rule_type)
|
155
|
+
contracts_chain.flat_map(&rule_type).compact
|
156
|
+
end
|
122
157
|
|
123
|
-
|
158
|
+
def contracts_chain
|
159
|
+
@contracts_chain ||= self.class.ancestors.take_while { |klass| klass != Contr::Base }.reverse
|
124
160
|
end
|
125
161
|
end
|
126
162
|
|
@@ -0,0 +1,58 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Contr
|
4
|
+
class Matcher
|
5
|
+
class Async < Matcher::Base
|
6
|
+
def initialize(*)
|
7
|
+
super
|
8
|
+
|
9
|
+
@async = true
|
10
|
+
@ok_rules = Concurrent::Array.new
|
11
|
+
@failed_rules = Concurrent::Array.new
|
12
|
+
end
|
13
|
+
|
14
|
+
def match
|
15
|
+
contract_future = main_pool.future do
|
16
|
+
contract_failed = rules_pool ? check_with_async_rules : check_with_sync_rules
|
17
|
+
|
18
|
+
dump_state! if contract_failed
|
19
|
+
end
|
20
|
+
|
21
|
+
contract_future.wait! if inline?
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def check_with_async_rules
|
27
|
+
checked_rules = rules_pool.zip(
|
28
|
+
*futures_from(guarantees),
|
29
|
+
*futures_from(expectations)
|
30
|
+
).value!
|
31
|
+
|
32
|
+
checked_guarantees, checked_expectations = checked_rules.partition { |rule| rule[:type] == :guarantee }
|
33
|
+
|
34
|
+
guarantees_failed = any_failed?(checked_guarantees)
|
35
|
+
expectations_failed = all_failed?(checked_expectations)
|
36
|
+
|
37
|
+
guarantees_failed || expectations_failed
|
38
|
+
end
|
39
|
+
|
40
|
+
def check_with_sync_rules
|
41
|
+
any_failed?(guarantees) || all_failed?(expectations)
|
42
|
+
end
|
43
|
+
|
44
|
+
def futures_from(rules)
|
45
|
+
rules.map do |rule|
|
46
|
+
rules_pool.future(rule) do |rule|
|
47
|
+
check_rule(rule)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
# for testing purposes
|
53
|
+
def inline?
|
54
|
+
false
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
data/lib/contr/matcher/sync.rb
CHANGED
@@ -12,37 +12,12 @@ module Contr
|
|
12
12
|
end
|
13
13
|
|
14
14
|
def match
|
15
|
-
|
16
|
-
|
15
|
+
any_failed?(guarantees) && dump_state_and_raise(GuaranteesNotMatched)
|
16
|
+
all_failed?(expectations) && dump_state_and_raise(ExpectationsNotMatched)
|
17
17
|
end
|
18
18
|
|
19
19
|
private
|
20
20
|
|
21
|
-
def guarantees_matched?
|
22
|
-
return true if @guarantees.empty?
|
23
|
-
|
24
|
-
@guarantees.all? { |rule| ok_rule?(rule) }
|
25
|
-
end
|
26
|
-
|
27
|
-
def expectations_matched?
|
28
|
-
return true if @expectations.empty?
|
29
|
-
|
30
|
-
@expectations.any? { |rule| ok_rule?(rule) }
|
31
|
-
end
|
32
|
-
|
33
|
-
def ok_rule?(rule)
|
34
|
-
match_result = match_rule(rule)
|
35
|
-
checked_rule = rule.slice(:type, :name).merge!(match_result)
|
36
|
-
|
37
|
-
if match_result[:status] == :ok
|
38
|
-
@ok_rules << checked_rule
|
39
|
-
true
|
40
|
-
else
|
41
|
-
@failed_rules << checked_rule
|
42
|
-
false
|
43
|
-
end
|
44
|
-
end
|
45
|
-
|
46
21
|
def dump_state_and_raise(error_class)
|
47
22
|
dump_state!
|
48
23
|
|
data/lib/contr/matcher.rb
CHANGED
@@ -23,17 +23,14 @@ module Contr
|
|
23
23
|
end
|
24
24
|
|
25
25
|
class Base
|
26
|
+
extend Forwardable
|
27
|
+
|
28
|
+
def_delegators :@contract, :logger, :sampler, :main_pool, :rules_pool, :guarantees, :expectations
|
29
|
+
|
26
30
|
def initialize(contract, args, result)
|
27
31
|
@contract = contract
|
28
|
-
@args = args
|
29
|
-
@result = result
|
30
|
-
|
31
|
-
# def_delegators would be slickier but it breaks consistency of
|
32
|
-
# variables names when used within the rules definitions
|
33
|
-
@guarantees = @contract.guarantees
|
34
|
-
@expectations = @contract.expectations
|
35
|
-
@sampler = @contract.sampler
|
36
|
-
@logger = @contract.logger
|
32
|
+
@args = args.freeze
|
33
|
+
@result = result.freeze
|
37
34
|
end
|
38
35
|
|
39
36
|
def match
|
@@ -42,28 +39,51 @@ module Contr
|
|
42
39
|
|
43
40
|
private
|
44
41
|
|
45
|
-
def
|
42
|
+
def any_failed?(rules)
|
43
|
+
rules.any? { |rule| rule_failed?(rule) }
|
44
|
+
end
|
45
|
+
|
46
|
+
def all_failed?(rules)
|
47
|
+
!rules.empty? && rules.all? { |rule| rule_failed?(rule) }
|
48
|
+
end
|
49
|
+
|
50
|
+
def rule_failed?(rule)
|
51
|
+
rule = check_rule(rule) unless rule.key?(:status)
|
52
|
+
|
53
|
+
if rule[:status] == :ok
|
54
|
+
@ok_rules << rule
|
55
|
+
false
|
56
|
+
else
|
57
|
+
@failed_rules << rule
|
58
|
+
true
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def check_rule(rule)
|
63
|
+
status_data = call_rule(rule)
|
64
|
+
rule.slice(:type, :name).merge(status_data)
|
65
|
+
end
|
66
|
+
|
67
|
+
def call_rule(rule)
|
46
68
|
block = rule[:block]
|
47
69
|
|
48
|
-
block_result =
|
70
|
+
block_result = block.parameters.empty? ? block.call : block.call(@args, @result)
|
49
71
|
block_result ? {status: :ok} : {status: :failed}
|
50
72
|
rescue => error
|
51
73
|
{status: :unexpected_error, error: error}
|
52
74
|
end
|
53
75
|
|
54
76
|
def dump_state!
|
55
|
-
|
56
|
-
|
57
|
-
if @sampler
|
58
|
-
dump_info = @sampler.sample!(state)
|
77
|
+
if sampler
|
78
|
+
dump_info = sampler.sample!(state)
|
59
79
|
state[:dump_info] = dump_info if dump_info
|
60
80
|
end
|
61
81
|
|
62
|
-
|
82
|
+
logger&.log(state)
|
63
83
|
end
|
64
84
|
|
65
|
-
def
|
66
|
-
{
|
85
|
+
def state
|
86
|
+
@state ||= {
|
67
87
|
ts: Time.now.utc.iso8601(3),
|
68
88
|
contract_name: @contract.name,
|
69
89
|
failed_rules: @failed_rules,
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Refines
|
4
|
+
module Hash
|
5
|
+
refine ::Hash do
|
6
|
+
def deep_merge(other_hash)
|
7
|
+
merge(other_hash) do |_key, this_value, other_value|
|
8
|
+
if this_value.is_a?(::Hash) && other_value.is_a?(::Hash)
|
9
|
+
this_value.deep_merge(other_value)
|
10
|
+
else
|
11
|
+
other_value
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
data/lib/contr/version.rb
CHANGED
data/lib/contr.rb
CHANGED
@@ -1,12 +1,17 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require_relative "contr/version"
|
4
|
+
require_relative "contr/refines/hash"
|
5
|
+
require_relative "contr/async/pool"
|
6
|
+
require_relative "contr/async/pool/fixed"
|
7
|
+
require_relative "contr/async/pool/global_io"
|
4
8
|
require_relative "contr/logger"
|
5
9
|
require_relative "contr/logger/default"
|
6
10
|
require_relative "contr/sampler"
|
7
11
|
require_relative "contr/sampler/default"
|
8
12
|
require_relative "contr/matcher"
|
9
13
|
require_relative "contr/matcher/sync"
|
14
|
+
require_relative "contr/matcher/async"
|
10
15
|
require_relative "contr/base"
|
11
16
|
|
12
17
|
module Contr
|
metadata
CHANGED
@@ -1,15 +1,29 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: contr
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Dmytro Horoshko
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2024-
|
12
|
-
dependencies:
|
11
|
+
date: 2024-03-12 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: concurrent-ruby
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '1.0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '1.0'
|
13
27
|
description: Minimalistic contracts in plain Ruby.
|
14
28
|
email:
|
15
29
|
- electric.molfar@gmail.com
|
@@ -21,11 +35,16 @@ files:
|
|
21
35
|
- LICENSE.txt
|
22
36
|
- README.md
|
23
37
|
- lib/contr.rb
|
38
|
+
- lib/contr/async/pool.rb
|
39
|
+
- lib/contr/async/pool/fixed.rb
|
40
|
+
- lib/contr/async/pool/global_io.rb
|
24
41
|
- lib/contr/base.rb
|
25
42
|
- lib/contr/logger.rb
|
26
43
|
- lib/contr/logger/default.rb
|
27
44
|
- lib/contr/matcher.rb
|
45
|
+
- lib/contr/matcher/async.rb
|
28
46
|
- lib/contr/matcher/sync.rb
|
47
|
+
- lib/contr/refines/hash.rb
|
29
48
|
- lib/contr/sampler.rb
|
30
49
|
- lib/contr/sampler/default.rb
|
31
50
|
- lib/contr/version.rb
|