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 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