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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 83b6121c17aee582566059f1664b3d72a167d458f56f685e754b2baee68c35ee
4
- data.tar.gz: 1daa46552ab5bfd252654e608608169d6cd5bad555c9dc3263377fdeb164c534
3
+ metadata.gz: 270200ede82d457029fffede0c22fd9dc4add6b593b595036bcf3af494b615ac
4
+ data.tar.gz: 10f2da11660e04cf7c94a402bf4e0111e1a1a7b6879d9e441145f9ea93c657d3
5
5
  SHA512:
6
- metadata.gz: e8d219a62e8dfc86375875c450294cca263146d4d187bf910824d0b56fa3a15a6208d0719c388dd4f2f5d968f6a310f9f19b0bea24a70af8fb4869316bbe16b1
7
- data.tar.gz: 21860bfa5ae3787cfd2ca35b4fe2c26fe5e25ba90e94866c7d56880bd3227cf8d3f7d31335d799fe922918a1f8ea0891669e88bf3b124375b7937e5a32dc1746
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 *__falsey__* value (`nil`, `false`) or *__raises an error__*.
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 succesfully executed.
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 PostRemovalContract < Contr::Act # or Contr::Base if you're a boring person
41
- guarantee :verified_via_api do |user_id, post_id, _|
42
- !API.post_exists?(user_id, post_id)
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 :verified_via_web do |*, post_url|
46
- !Web.post_exists?(post_url)
49
+ guarantee :args_are_numbers do |args|
50
+ args.all?(Numeric)
47
51
  end
48
52
 
49
- expect :removed_from_user_feed do |user_id, post_id, _|
50
- !Feed::User.post_exists?(user_id, post_id)
53
+ expect :arg_1_is_float do |(arg_1, _)|
54
+ arg_1.is_a?(Float)
51
55
  end
52
56
 
53
- expect :removed_from_global_feed do |user_id, post_id, _|
54
- !Feed::Global.post_exists?(user_id, post_id)
57
+ expect :arg_2_is_float do |(_, arg_2)|
58
+ arg_2.is_a?(Float)
55
59
  end
56
60
  end
57
61
 
58
- contract = PostRemovalContract.new
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 all rules are executed sequentially in the same thread with the operation.
73
+ In `sync` mode rules are executed sequentially in the same thread with the operation.
66
74
 
67
- If contract is matched - operation result is returned.
75
+ If contract matched - operation result is returned afterwards:
68
76
 
69
77
  ```ruby
70
- api_response = contract.check(user_id, post_id, post_url) { API.delete_post(*some_args) }
71
- # => {data: {deleted: true}}
78
+ contract.check(*args) { 1 + 1 }
79
+ # => 2
72
80
  ```
73
81
 
74
- If contract fails - the contract state is dumped via [Sampler](#Sampler), logged via [Logger](#Logger) and match error is raised:
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) { operation }
78
- # if one of the guarantees failed
85
+ contract.check(*args) { 1 + 1 }
86
+ # when one of the guarantees failed
79
87
  # => Contr::Matcher::GuaranteesNotMatched: failed rules: [...], args: [...]
80
88
 
81
- # if all expectations failed
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
- WIP
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: "PostRemovalContract",
203
+ contract_name: "SumContract",
105
204
  failed_rules: [
106
- {type: :expectation, name: :removed_from_user_feed, status: :unexpected_error, error: error_instance},
107
- {type: :expectation, name: :removed_from_global_feed, status: :failed}
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: :verified_via_api, status: :ok},
111
- {type: :guarantee, name: :verified_via_web, status: :ok}
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, "url"],
115
- result: {data: {deleted: true}}
213
+ args: [1, 2.0],
214
+ result: 3.0
116
215
  }
117
216
 
118
217
  # default sampler can be reconfigured
119
- ConfigedSampler = Contr::Sampler::Default.new(
120
- folder: "/tmp/contract_dumps", # default: "/tmp/contracts"
121
- path_template: "%<contract_name>_%<period_id>i.bin", # default: "%<contract_name>s/%<period_id>i.dump"
122
- period: 3600 # default: 600 (= 10 minutes)
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 ConfigedSampler
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 for sampler with a small addition of `tag` field.
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
- ConfigedLogger = Contr::Logger::Default.new(
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 ConfigedLogger
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: check_1
279
- # expecations: check_2
280
- # sampler: Contr::Sampler::Default
281
- # logger: Contr::Logger:Default
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: check_1, check_3
291
- # expecations: check_2
292
- # sampler: CustomSampler
293
- # logger: Contr::Logger:Default
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: check_1, check_3
303
- # expecations: check_2, check_4
304
- # sampler: CustomSampler
305
- # logger: nil
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
- Contract can be configured using args passed to `.new` method:
437
+ Rule block arguments can be accessed in different ways:
309
438
 
310
439
  ```ruby
311
440
  class SomeContract < Contr::Act
312
- end
313
-
314
- contract = SomeContract.new(sampler: CustomSampler.new, logger: CustomLogger.new)
315
-
316
- contract.sampler
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
- Rule block arguments are optional:
447
+ guarantee :result_ignored do |(arg_1, arg_2)|
448
+ arg_1 # => 1
449
+ arg_2 # => 2
450
+ end
324
451
 
325
- ```ruby
326
- class SomeContract < Contr::Act
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) { operation }
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 `@result` can be really useful in contracts where operation produces a data that should be used inside the rules:
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 = @result["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
- Having access to `@guarantees` and `@expectations` makes possible building dynamic contracts (in case you really need it):
481
+ Contract instances are fully isolated from check invocations and can be safely cached:
377
482
 
378
483
  ```ruby
379
- class SomeContract < Contr::Act
380
- guarantee :guarantee_1 do
381
- # add new guarantee to the end of guarantees list
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
- expect :expect_1 do
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
- SomeContract.new.check(1, 2) { operation }
412
-
413
- # it will print:
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
- Other instance variables (e.g. `@args`, `@logger` etc.) can be modified on the fly too but make sure you really know what you do.
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
- - [ ] Async matcher
429
- - [ ] Add `before` block for contract definition
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,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Contr
4
+ module Async
5
+ module Pool
6
+ class GlobalIO < Pool::Base
7
+ def create_executor
8
+ Concurrent.global_io_executor
9
+ end
10
+ end
11
+ end
12
+ end
13
+ 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
- @logger = choose_logger(instance_config)
46
- @sampler = choose_sampler(instance_config)
49
+ @config = merge_configs(instance_config)
50
+
51
+ init_logger!
52
+ init_sampler!
53
+ init_main_pool!
54
+ init_rules_pool!
47
55
 
48
- validate_logger!
49
- validate_sampler!
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
- def guarantees
63
- @guarantees ||= aggregate_rules(:guarantees)
64
- end
78
+ private
65
79
 
66
- def expectations
67
- @expectations ||= aggregate_rules(:expectations)
68
- end
80
+ using Refines::Hash
69
81
 
70
- private
82
+ def merge_configs(instance_config)
83
+ configs = contracts_chain.filter_map(&:config)
84
+ configs << instance_config
71
85
 
72
- def choose_logger(instance_config)
73
- parent_config = find_parent_config(:logger)
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 choose_sampler(instance_config)
88
- parent_config = find_parent_config(:sampler)
89
-
90
- case [instance_config, self.class.config, parent_config]
91
- in [{sampler: }, *]
92
- sampler
93
- in [_, {sampler: }, _]
94
- sampler
95
- in [*, {sampler: }]
96
- sampler
97
- else
98
- Sampler::Default.new
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 find_parent_config(key)
103
- parents = self.class.ancestors.take_while { |klass| klass != Contr::Base }.drop(1)
104
- parents.detect { |klass| klass.config&.key?(key) }&.config
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 aggregate_rules(rule_type)
108
- contracts_chain = self.class.ancestors.take_while { |klass| klass != Contr::Base }.reverse
109
- contracts_chain.flat_map(&rule_type).compact
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 validate_logger!
113
- return unless logger
114
- return if logger.class.ancestors.include?(Logger::Base)
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
- raise ArgumentError, "logger should be inherited from Contr::Logger::Base or be falsey, received: #{logger.inspect}"
146
+ def aggregate_guarantees!
147
+ @guarantees = aggregate_rules(:guarantees).freeze
117
148
  end
118
149
 
119
- def validate_sampler!
120
- return unless sampler
121
- return if sampler.class.ancestors.include?(Sampler::Base)
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
- raise ArgumentError, "sampler should be inherited from Contr::Sampler::Base or be falsey, received: #{sampler.inspect}"
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
@@ -12,37 +12,12 @@ module Contr
12
12
  end
13
13
 
14
14
  def match
15
- guarantees_matched? || dump_state_and_raise(GuaranteesNotMatched)
16
- expectations_matched? || dump_state_and_raise(ExpectationsNotMatched)
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 match_rule(rule)
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 = instance_exec(*@args, &block)
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
- state = compile_state
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
- @logger&.log(state)
82
+ logger&.log(state)
63
83
  end
64
84
 
65
- def compile_state
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
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
4
+
3
5
  module Contr
4
6
  class Sampler
5
7
  class Default < Sampler::Base
data/lib/contr/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Contr
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
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.1.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-02-28 00:00:00.000000000 Z
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