contr 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|
+
[](https://badge.fury.io/rb/contr)
|
4
|
+
[](https://github.com/ocvit/contr/actions)
|
5
|
+
[](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
|