standard-procedure-plumbing 0.3.2 → 0.3.3
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +119 -47
- data/lib/plumbing/rubber_duck/module.rb +13 -0
- data/lib/plumbing/rubber_duck/object.rb +3 -2
- data/lib/plumbing/rubber_duck.rb +14 -4
- data/lib/plumbing/valve/rails.rb +15 -0
- data/lib/plumbing/valve/threaded.rb +67 -0
- data/lib/plumbing/version.rb +1 -1
- data/spec/examples/rubber_duck_spec.rb +93 -10
- data/spec/examples/valve_spec.rb +39 -46
- data/spec/plumbing/pipe_spec.rb +8 -0
- data/spec/plumbing/rubber_duck_spec.rb +46 -48
- data/spec/plumbing/valve_spec.rb +33 -29
- metadata +6 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: ba7883be2c006839c549d70a37c96dced0cc0e38af1093676a3503bbbd9dcd95
|
4
|
+
data.tar.gz: 9ad82790ba2badcc614a795b559347b6413e810e35fe01db572f1a0f2ccbeb49
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 57f478c6b91598bdc88028ac99bceff692a34d620809ac71b3d89b24930794d8db5d9723b0cde844deaf3b9973bdda3c68ee7c77e1a8c9c824748b5278e5c606
|
7
|
+
data.tar.gz: 2dd95476896445746356141cbe5925490d9d7e4b96b9a60aefd0841b8b8851990d235c866bfb4073a8a0b5adb9588af1f88eff43988969ac1a5960ef091483ed
|
data/README.md
CHANGED
@@ -1,55 +1,59 @@
|
|
1
1
|
# Plumbing
|
2
2
|
|
3
|
-
## Configuration
|
3
|
+
## Configuration
|
4
4
|
|
5
|
-
The most important configuration setting is the `mode`, which governs how messages are handled by Valves.
|
5
|
+
The most important configuration setting is the `mode`, which governs how messages are handled by Valves.
|
6
6
|
|
7
|
-
By default it is `:inline`, so every command or query is handled synchronously.
|
7
|
+
By default it is `:inline`, so every command or query is handled synchronously. This is the ruby behaviour you know and love.
|
8
8
|
|
9
|
-
If it is set to `:async`, commands and queries will be handled using fibers (via the [Async gem](https://socketry.github.io/async/index.html)).
|
9
|
+
If it is set to `:async`, commands and queries will be handled asynchronously using fibers (via the [Async gem](https://socketry.github.io/async/index.html)). Your code should include the "async" gem in its bundle, as Plumbing does not load it by default.
|
10
10
|
|
11
|
-
|
11
|
+
If it is set to `:threaded`, commands and queries will be handled asynchronously by a thread pool (via [Concurrent Ruby](https://ruby-concurrency.github.io/concurrent-ruby/master/Concurrent/Promises.html)), using the default `:io` executor. Your code should include the "concurrent-ruby" gem in its bundle, as Plumbing does not load it by default.
|
12
|
+
|
13
|
+
If you want to use threads in a Rails application, set the mode to `:rails`. This ensures that the work is wrapped in the Rails executor (which prevents multi-threading issues in the framework). At present, the `:io` executor may cause issues as we may exceed the number of database connections in the Rails' connection pool. We will fix this at some point in the future.
|
14
|
+
|
15
|
+
The `timeout` setting is used when performing queries - it defaults to 30s.
|
12
16
|
|
13
17
|
```ruby
|
14
18
|
require "plumbing"
|
15
|
-
puts Plumbing.config.mode
|
19
|
+
puts Plumbing.config.mode
|
16
20
|
# => :inline
|
17
21
|
|
18
22
|
Plumbing.configure mode: :async, timeout: 10
|
19
23
|
|
20
|
-
puts Plumbing.config.mode
|
24
|
+
puts Plumbing.config.mode
|
21
25
|
# => :async
|
22
26
|
```
|
23
27
|
|
24
|
-
If you are running a test suite, you can temporarily update the configuration by passing a block.
|
28
|
+
If you are running a test suite, you can temporarily update the configuration by passing a block.
|
25
29
|
|
26
30
|
```ruby
|
27
31
|
require "plumbing"
|
28
|
-
puts Plumbing.config.mode
|
32
|
+
puts Plumbing.config.mode
|
29
33
|
# => :inline
|
30
34
|
|
31
|
-
Plumbing.configure mode: :async do
|
32
|
-
puts Plumbing.config.mode
|
35
|
+
Plumbing.configure mode: :async do
|
36
|
+
puts Plumbing.config.mode
|
33
37
|
# => :async
|
34
38
|
first_test
|
35
39
|
second_test
|
36
40
|
end
|
37
41
|
|
38
|
-
puts Plumbing.config.mode
|
42
|
+
puts Plumbing.config.mode
|
39
43
|
# => :inline
|
40
44
|
```
|
41
45
|
|
42
46
|
## Plumbing::Pipeline - transform data through a pipeline
|
43
47
|
|
44
|
-
Define a sequence of operations that proceed in order, passing their output from one operation as the input to another. [Unix pipes](https://en.wikipedia.org/wiki/Pipeline_(Unix)) in Ruby.
|
48
|
+
Define a sequence of operations that proceed in order, passing their output from one operation as the input to another. [Unix pipes](https://en.wikipedia.org/wiki/Pipeline_(Unix)) in Ruby.
|
45
49
|
|
46
|
-
Use `perform` to define a step that takes some input and returns a different output.
|
47
|
-
Specify `using` to re-use an existing `Plumbing::Pipeline` as a step within this pipeline.
|
48
|
-
Use `execute` to define a step that takes some input, performs an action but passes the input, unchanged, to the next step.
|
50
|
+
Use `perform` to define a step that takes some input and returns a different output.
|
51
|
+
Specify `using` to re-use an existing `Plumbing::Pipeline` as a step within this pipeline.
|
52
|
+
Use `execute` to define a step that takes some input, performs an action but passes the input, unchanged, to the next step.
|
49
53
|
|
50
|
-
If you have [dry-validation](https://dry-rb.org/gems/dry-validation/1.10/) installed, you can validate your input using a `Dry::Validation::Contract`. Alternatively, you can define a `pre_condition` to test that the inputs are valid.
|
54
|
+
If you have [dry-validation](https://dry-rb.org/gems/dry-validation/1.10/) installed, you can validate your input using a `Dry::Validation::Contract`. Alternatively, you can define a `pre_condition` to test that the inputs are valid.
|
51
55
|
|
52
|
-
You can also verify that the output generated is as expected by defining a `post_condition`.
|
56
|
+
You can also verify that the output generated is as expected by defining a `post_condition`.
|
53
57
|
|
54
58
|
### Usage:
|
55
59
|
|
@@ -116,7 +120,7 @@ You can also verify that the output generated is as expected by defining a `post
|
|
116
120
|
end
|
117
121
|
|
118
122
|
SayHello.new.call(name: "Alice", email: "alice@example.com")
|
119
|
-
# => Hello Alice - I will now send a load of annoying marketing messages to alice@example.com
|
123
|
+
# => Hello Alice - I will now send a load of annoying marketing messages to alice@example.com
|
120
124
|
|
121
125
|
SayHello.new.call(some: "other data")
|
122
126
|
# => Plumbing::PreConditionError
|
@@ -152,30 +156,30 @@ You can also verify that the output generated is as expected by defining a `post
|
|
152
156
|
|
153
157
|
## Plumbing::Valve - safe asynchronous objects
|
154
158
|
|
155
|
-
An [actor](https://en.wikipedia.org/wiki/Actor_model) defines the messages an object can receive, similar to a regular object. However, a normal object if accessed concurrently can have data consistency issues and race conditions leading to hard-to-reproduce bugs. Actors, however, ensure that, no matter which thread (or fiber) is sending the message, the internal processing of the message (the method definition) is handled sequentially. This means the internal state of an object is never accessed concurrently, eliminating those issues.
|
159
|
+
An [actor](https://en.wikipedia.org/wiki/Actor_model) defines the messages an object can receive, similar to a regular object. However, a normal object if accessed concurrently can have data consistency issues and race conditions leading to hard-to-reproduce bugs. Actors, however, ensure that, no matter which thread (or fiber) is sending the message, the internal processing of the message (the method definition) is handled sequentially. This means the internal state of an object is never accessed concurrently, eliminating those issues.
|
156
160
|
|
157
|
-
[Plumbing::Valve](/lib/plumbing/valve.rb) ensures that all messages received are channelled into a concurrency-safe queue. This allows you to take an existing class and ensures that messages received via its public API are made concurrency-safe.
|
161
|
+
[Plumbing::Valve](/lib/plumbing/valve.rb) ensures that all messages received are channelled into a concurrency-safe queue. This allows you to take an existing class and ensures that messages received via its public API are made concurrency-safe.
|
158
162
|
|
159
|
-
Include the Plumbing::Valve module into your class, define the messages the objects can respond to and set the `Plumbing` configuration to set the desired concurrency model. Messages themselves are split into two categories: commands and queries.
|
163
|
+
Include the Plumbing::Valve module into your class, define the messages the objects can respond to and set the `Plumbing` configuration to set the desired concurrency model. Messages themselves are split into two categories: commands and queries.
|
160
164
|
|
161
165
|
- Commands have no return value so when the message is sent, the caller does not block, the task is called asynchronously and the caller continues immediately
|
162
166
|
- Queries return a value so the caller blocks until the actor has returned a value
|
163
167
|
- However, if you call a query and pass `ignore_result: true` then the query will not block, although you will not be able to access the return value - this is for commands that do something and then return a result based on that work (which you may or may not be interested in - see Plumbing::Pipe#add_observer)
|
164
168
|
- None of the above applies if the `Plumbing mode` is set to `:inline` (which is the default) - in this case, the actor behaves like normal ruby code
|
165
169
|
|
166
|
-
Instead of constructing your object with `.new`, use `.start`. This builds a proxy object that wraps the target instance and dispatches messages through a safe mechanism. Only messages that have been defined as part of the valve are available in this proxy - so you don't have to worry about callers bypassing the valve's internal context.
|
170
|
+
Instead of constructing your object with `.new`, use `.start`. This builds a proxy object that wraps the target instance and dispatches messages through a safe mechanism. Only messages that have been defined as part of the valve are available in this proxy - so you don't have to worry about callers bypassing the valve's internal context.
|
167
171
|
|
168
|
-
Even when using actors, there is one condition where concurrency may cause issues. If object A makes a query to object B which in turn makes a query back to object A, you will hit a deadlock. This is because A is waiting on the response from B but B is now querying, and waiting for, A. This does not apply to commands because they do not wait for a response. However, when writing queries, be careful who you interact with - the configuration allows you to set a timeout (defaulting to 30s) in case this happens.
|
172
|
+
Even when using actors, there is one condition where concurrency may cause issues. If object A makes a query to object B which in turn makes a query back to object A, you will hit a deadlock. This is because A is waiting on the response from B but B is now querying, and waiting for, A. This does not apply to commands because they do not wait for a response. However, when writing queries, be careful who you interact with - the configuration allows you to set a timeout (defaulting to 30s) in case this happens.
|
169
173
|
|
170
|
-
Also be aware that if you use valves in one place, you need to use them everywhere - especially if you're using threads or ractors (coming soon). This is because as the valve sends messages to its collaborators, those calls will be made from within the valve's internal context. If the collaborators are also valves, the subsequent messages will be handled correctly, if not, data consistency bugs could occur.
|
174
|
+
Also be aware that if you use valves in one place, you need to use them everywhere - especially if you're using threads or ractors (coming soon). This is because as the valve sends messages to its collaborators, those calls will be made from within the valve's internal context. If the collaborators are also valves, the subsequent messages will be handled correctly, if not, data consistency bugs could occur.
|
171
175
|
|
172
|
-
### Usage
|
176
|
+
### Usage
|
173
177
|
|
174
178
|
[Defining an actor](/spec/examples/valve_spec.rb)
|
175
179
|
|
176
180
|
```ruby
|
177
181
|
require "plumbing"
|
178
|
-
|
182
|
+
|
179
183
|
class Employee
|
180
184
|
attr_reader :name, :job_title
|
181
185
|
|
@@ -193,7 +197,7 @@ Also be aware that if you use valves in one place, you need to use them everywhe
|
|
193
197
|
@job_title = "Sales manager"
|
194
198
|
end
|
195
199
|
|
196
|
-
def greet_slowly
|
200
|
+
def greet_slowly
|
197
201
|
sleep 0.2
|
198
202
|
"H E L L O"
|
199
203
|
end
|
@@ -204,7 +208,7 @@ Also be aware that if you use valves in one place, you need to use them everywhe
|
|
204
208
|
|
205
209
|
```ruby
|
206
210
|
require "plumbing"
|
207
|
-
|
211
|
+
|
208
212
|
@person = Employee.start "Alice"
|
209
213
|
|
210
214
|
puts @person.name
|
@@ -217,7 +221,7 @@ Also be aware that if you use valves in one place, you need to use them everywhe
|
|
217
221
|
puts @person.job_title
|
218
222
|
# => "Sales manager"
|
219
223
|
|
220
|
-
@person.greet_slowly
|
224
|
+
@person.greet_slowly
|
221
225
|
# this will block for 0.2 seconds before returning "H E L L O"
|
222
226
|
|
223
227
|
@person.greet_slowly(ignore_result: true)
|
@@ -230,7 +234,7 @@ Also be aware that if you use valves in one place, you need to use them everywhe
|
|
230
234
|
require "plumbing"
|
231
235
|
require "async"
|
232
236
|
|
233
|
-
Plumbing.configure mode: :async
|
237
|
+
Plumbing.configure mode: :async
|
234
238
|
@person = Employee.start "Alice"
|
235
239
|
|
236
240
|
puts @person.name
|
@@ -243,20 +247,47 @@ Also be aware that if you use valves in one place, you need to use them everywhe
|
|
243
247
|
puts @person.job_title
|
244
248
|
# => "Sales manager" (this will block for 0.5s because #job_title query will not start until the #promote command has completed)
|
245
249
|
|
246
|
-
@person.greet_slowly
|
250
|
+
@person.greet_slowly
|
247
251
|
# this will block for 0.2 seconds before returning "H E L L O"
|
248
252
|
|
249
253
|
@person.greet_slowly(ignore_result: true)
|
250
254
|
# this will not block and returns nil
|
251
255
|
```
|
252
256
|
|
257
|
+
[Using threads](/spec/examples/valve_spec.rb) with concurrency and some parallelism
|
258
|
+
|
259
|
+
```ruby
|
260
|
+
require "plumbing"
|
261
|
+
require "concurrent"
|
262
|
+
|
263
|
+
Plumbing.configure mode: :threaded
|
264
|
+
@person = Employee.start "Alice"
|
265
|
+
|
266
|
+
puts @person.name
|
267
|
+
# => "Alice"
|
268
|
+
puts @person.job_title
|
269
|
+
# => "Sales assistant"
|
270
|
+
|
271
|
+
@person.promote
|
272
|
+
# this will return immediately without blocking
|
273
|
+
puts @person.job_title
|
274
|
+
# => "Sales manager" (this will block for 0.5s because #job_title query will not start until the #promote command has completed)
|
275
|
+
|
276
|
+
@person.greet_slowly
|
277
|
+
# this will block for 0.2 seconds before returning "H E L L O"
|
278
|
+
|
279
|
+
@person.greet_slowly(ignore_result: true)
|
280
|
+
# this will not block and returns nil
|
281
|
+
```
|
282
|
+
|
283
|
+
|
253
284
|
## Plumbing::Pipe - a composable observer
|
254
285
|
|
255
286
|
[Observers](https://ruby-doc.org/3.3.0/stdlibs/observer/Observable.html) in Ruby are a pattern where objects (observers) register their interest in another object (the observable). This pattern is common throughout programming languages (event listeners in Javascript, the dependency protocol in [Smalltalk](https://en.wikipedia.org/wiki/Smalltalk)).
|
256
287
|
|
257
|
-
[Plumbing::Pipe](lib/plumbing/pipe.rb) makes observers "composable". Instead of simply just registering for notifications from a single observable, we can build sequences of pipes. These sequences can filter notifications and route them to different listeners, or merge multiple sources into a single stream of notifications.
|
288
|
+
[Plumbing::Pipe](lib/plumbing/pipe.rb) makes observers "composable". Instead of simply just registering for notifications from a single observable, we can build sequences of pipes. These sequences can filter notifications and route them to different listeners, or merge multiple sources into a single stream of notifications.
|
258
289
|
|
259
|
-
Pipes are implemented as valves, meaning that event notifications can be dispatched asynchronously. The observer's callback will be triggered from within the pipe's internal context so you should immediately trigger a command on another valve to maintain safety.
|
290
|
+
Pipes are implemented as valves, meaning that event notifications can be dispatched asynchronously. The observer's callback will be triggered from within the pipe's internal context so you should immediately trigger a command on another valve to maintain safety.
|
260
291
|
|
261
292
|
### Usage
|
262
293
|
|
@@ -279,7 +310,7 @@ Pipes are implemented as valves, meaning that event notifications can be dispatc
|
|
279
310
|
|
280
311
|
@source = Plumbing::Pipe.start
|
281
312
|
@filter = Plumbing::Filter.start source: @source do |event|
|
282
|
-
%w[important urgent].include? event.type
|
313
|
+
%w[important urgent].include? event.type
|
283
314
|
end
|
284
315
|
@observer = @filter.add_observer do |event|
|
285
316
|
puts event.type
|
@@ -349,16 +380,16 @@ Pipes are implemented as valves, meaning that event notifications can be dispatc
|
|
349
380
|
require "plumbing"
|
350
381
|
require "async"
|
351
382
|
|
352
|
-
Plumbing.configure mode: :async
|
383
|
+
Plumbing.configure mode: :async
|
353
384
|
|
354
|
-
Sync do
|
355
|
-
@first_source = Plumbing::Pipe.start
|
385
|
+
Sync do
|
386
|
+
@first_source = Plumbing::Pipe.start
|
356
387
|
@second_source = Plumbing::Pipe.start
|
357
388
|
|
358
389
|
@junction = Plumbing::Junction.start @first_source, @second_source
|
359
390
|
|
360
391
|
@filter = Plumbing::Filter.start source: @junction do |event|
|
361
|
-
%w[one-one two-two].include? event.type
|
392
|
+
%w[one-one two-two].include? event.type
|
362
393
|
end
|
363
394
|
|
364
395
|
@first_source.notify "one-one"
|
@@ -370,19 +401,20 @@ Pipes are implemented as valves, meaning that event notifications can be dispatc
|
|
370
401
|
|
371
402
|
## Plumbing::RubberDuck - duck types and type-casts
|
372
403
|
|
373
|
-
Define an [interface or protocol](https://en.wikipedia.org/wiki/Interface_(object-oriented_programming)) specifying which messages you expect to be able to send.
|
404
|
+
Define an [interface or protocol](https://en.wikipedia.org/wiki/Interface_(object-oriented_programming)) specifying which messages you expect to be able to send.
|
374
405
|
|
406
|
+
Then cast an object into that type. This first tests that the object can respond to those messages and then builds a proxy that responds to those messages (and no others). However, if you take one of these proxies, you can safely re-cast it as another type (as long as the original target object responds to the correct messages).
|
375
407
|
|
376
|
-
### Usage
|
408
|
+
### Usage
|
377
409
|
|
378
|
-
Define your interface (Person in this example), then cast your objects (instances of PersonData and CarData).
|
410
|
+
Define your interface (Person in this example), then cast your objects (instances of PersonData and CarData).
|
379
411
|
|
380
412
|
[Casting objects as duck-types](/spec/examples/rubber_duck_spec.rb):
|
381
413
|
```ruby
|
382
414
|
require "plumbing"
|
383
415
|
|
384
|
-
Person = Plumbing::RubberDuck.define :first_name, :last_name, :email
|
385
|
-
LikesFood = Plumbing::RubberDuck.define :favourite_food
|
416
|
+
Person = Plumbing::RubberDuck.define :first_name, :last_name, :email
|
417
|
+
LikesFood = Plumbing::RubberDuck.define :favourite_food
|
386
418
|
|
387
419
|
PersonData = Struct.new(:first_name, :last_name, :email, :favourite_food)
|
388
420
|
CarData = Struct.new(:make, :model, :colour)
|
@@ -395,17 +427,54 @@ Define your interface (Person in this example), then cast your objects (instance
|
|
395
427
|
@person = @alice.as Person
|
396
428
|
@person.first_name
|
397
429
|
# => "Alice"
|
398
|
-
@person.email
|
430
|
+
@person.email
|
399
431
|
# => "alice@example.com"
|
400
432
|
@person.favourite_food
|
401
433
|
# => NoMethodError - #favourite_food is not part of the Person rubber duck (even though it is part of the underlying PersonData struct)
|
402
434
|
|
403
435
|
# Cast our Person into a LikesFood rubber duck
|
404
|
-
@hungry = @person.as LikesFood
|
405
|
-
@hungry.favourite_food
|
436
|
+
@hungry = @person.as LikesFood
|
437
|
+
@hungry.favourite_food
|
406
438
|
# => "Ice cream"
|
407
439
|
```
|
408
440
|
|
441
|
+
You can also use the same `@object.as type` to type-check instances against modules or classes. This creates a RubberDuck proxy based on the module or class you're casting into. So the cast will pass if the object responds to the correct messages, even if a strict `.is_a?` test would fail.
|
442
|
+
|
443
|
+
```ruby
|
444
|
+
require "plumbing"
|
445
|
+
|
446
|
+
module Person
|
447
|
+
def first_name = @first_name
|
448
|
+
|
449
|
+
def last_name = @last_name
|
450
|
+
|
451
|
+
def email = @email
|
452
|
+
end
|
453
|
+
|
454
|
+
module LikesFood
|
455
|
+
def favourite_food = @favourite_food
|
456
|
+
end
|
457
|
+
|
458
|
+
PersonData = Struct.new(:first_name, :last_name, :email, :favourite_food)
|
459
|
+
CarData = Struct.new(:make, :model, :colour)
|
460
|
+
|
461
|
+
@porsche_911 = CarData.new "Porsche", "911", "black"
|
462
|
+
expect { @porsche_911.as Person }.to raise_error(TypeError)
|
463
|
+
|
464
|
+
@alice = PersonData.new "Alice", "Aardvark", "alice@example.com", "Ice cream"
|
465
|
+
|
466
|
+
@alics.is_a? Person
|
467
|
+
# => false - PersonData does not `include Person`
|
468
|
+
@person = @alice.as Person
|
469
|
+
# This cast is OK because PersonData responds to :first_name, :last_name and :email
|
470
|
+
expect(@person.first_name).to eq "Alice"
|
471
|
+
expect(@person.email).to eq "alice@example.com"
|
472
|
+
expect { @person.favourite_food }.to raise_error(NoMethodError)
|
473
|
+
|
474
|
+
@hungry = @person.as LikesFood
|
475
|
+
expect(@hungry.favourite_food).to eq "Ice cream"
|
476
|
+
```
|
477
|
+
|
409
478
|
## Installation
|
410
479
|
|
411
480
|
Install the gem and add to the application's Gemfile by executing:
|
@@ -418,6 +487,9 @@ Then:
|
|
418
487
|
|
419
488
|
```ruby
|
420
489
|
require 'plumbing'
|
490
|
+
|
491
|
+
# Set the mode for your Valves and Pipes
|
492
|
+
Plumbing.config mode: :async
|
421
493
|
```
|
422
494
|
|
423
495
|
## Development
|
@@ -2,9 +2,10 @@ module Plumbing
|
|
2
2
|
class RubberDuck
|
3
3
|
::Object.class_eval do
|
4
4
|
# Cast the object to a duck-type
|
5
|
+
# @param type [Plumbing::RubberDuck, Module]
|
5
6
|
# @return [Plumbing::RubberDuck::Proxy] the duck-type proxy
|
6
|
-
def as
|
7
|
-
|
7
|
+
def as type
|
8
|
+
Plumbing::RubberDuck.cast self, type: type
|
8
9
|
end
|
9
10
|
end
|
10
11
|
end
|
data/lib/plumbing/rubber_duck.rb
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
module Plumbing
|
2
2
|
# A type-checker for duck-types
|
3
3
|
class RubberDuck
|
4
|
+
require_relative "rubber_duck/module"
|
4
5
|
require_relative "rubber_duck/object"
|
5
6
|
require_relative "rubber_duck/proxy"
|
6
7
|
|
@@ -26,10 +27,19 @@ module Plumbing
|
|
26
27
|
is_a_proxy?(object) || build_proxy_for(object)
|
27
28
|
end
|
28
29
|
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
30
|
+
class << self
|
31
|
+
# Define a new rubber duck type
|
32
|
+
# @param *methods [Array<Symbol>] the methods that the duck-type should respond to
|
33
|
+
def define *methods
|
34
|
+
new(*methods)
|
35
|
+
end
|
36
|
+
|
37
|
+
# Cast the object to the given type
|
38
|
+
# @param object [Object] to be csat
|
39
|
+
# @param to [Module, Plumbing::RubberDuck] the type to cast into
|
40
|
+
def cast object, type:
|
41
|
+
type.proxy_for object
|
42
|
+
end
|
33
43
|
end
|
34
44
|
|
35
45
|
private
|
@@ -0,0 +1,67 @@
|
|
1
|
+
require "concurrent/array"
|
2
|
+
require "concurrent/mvar"
|
3
|
+
require "concurrent/immutable_struct"
|
4
|
+
require "concurrent/promises"
|
5
|
+
|
6
|
+
module Plumbing
|
7
|
+
module Valve
|
8
|
+
class Threaded
|
9
|
+
attr_reader :target
|
10
|
+
|
11
|
+
def initialize target
|
12
|
+
@target = target
|
13
|
+
@queue = Concurrent::Array.new
|
14
|
+
end
|
15
|
+
|
16
|
+
# Ask the target to answer the given message
|
17
|
+
def ask(message, *, **, &)
|
18
|
+
add_message_to_queue(message, *, **, &).value
|
19
|
+
end
|
20
|
+
|
21
|
+
# Tell the target to execute the given message
|
22
|
+
def tell(message, *, **, &)
|
23
|
+
add_message_to_queue(message, *, **, &)
|
24
|
+
nil
|
25
|
+
rescue
|
26
|
+
nil
|
27
|
+
end
|
28
|
+
|
29
|
+
protected
|
30
|
+
|
31
|
+
def future(&)
|
32
|
+
Concurrent::Promises.future(&)
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def send_messages
|
38
|
+
future do
|
39
|
+
while (message = @queue.shift)
|
40
|
+
message.call
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def add_message_to_queue message_name, *args, **params, &block
|
46
|
+
Message.new(@target, message_name, args, params, block, Concurrent::MVar.new).tap do |message|
|
47
|
+
@queue << message
|
48
|
+
send_messages if @queue.size == 1
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
class Message < Concurrent::ImmutableStruct.new(:target, :name, :args, :params, :block, :result)
|
53
|
+
def value
|
54
|
+
result.take(Plumbing.config.timeout).tap do |value|
|
55
|
+
raise value if value.is_a? Exception
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def call
|
60
|
+
result.put target.send(name, *args, **params, &block)
|
61
|
+
rescue => ex
|
62
|
+
result.put ex
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
data/lib/plumbing/version.rb
CHANGED
@@ -1,26 +1,109 @@
|
|
1
1
|
require "spec_helper"
|
2
2
|
|
3
3
|
RSpec.describe "Rubber Duck examples" do
|
4
|
-
it "casts objects
|
4
|
+
it "casts objects into duck types" do
|
5
5
|
# standard:disable Lint/ConstantDefinitionInBlock
|
6
|
-
|
7
|
-
|
6
|
+
module DuckExample
|
7
|
+
Person = Plumbing::RubberDuck.define :first_name, :last_name, :email
|
8
|
+
LikesFood = Plumbing::RubberDuck.define :favourite_food
|
9
|
+
|
10
|
+
PersonData = Struct.new(:first_name, :last_name, :email, :favourite_food)
|
11
|
+
CarData = Struct.new(:make, :model, :colour)
|
12
|
+
end
|
8
13
|
|
9
|
-
PersonData = Struct.new(:first_name, :last_name, :email, :favourite_food)
|
10
|
-
CarData = Struct.new(:make, :model, :colour)
|
11
14
|
# standard:enable Lint/ConstantDefinitionInBlock
|
12
15
|
|
13
|
-
@porsche_911 = CarData.new "Porsche", "911", "black"
|
14
|
-
expect { @porsche_911.as Person }.to raise_error(TypeError)
|
16
|
+
@porsche_911 = DuckExample::CarData.new "Porsche", "911", "black"
|
17
|
+
expect { @porsche_911.as DuckExample::Person }.to raise_error(TypeError)
|
18
|
+
|
19
|
+
@alice = DuckExample::PersonData.new "Alice", "Aardvark", "alice@example.com", "Ice cream"
|
20
|
+
|
21
|
+
@person = @alice.as DuckExample::Person
|
22
|
+
expect(@person.first_name).to eq "Alice"
|
23
|
+
expect(@person.email).to eq "alice@example.com"
|
24
|
+
expect { @person.favourite_food }.to raise_error(NoMethodError)
|
25
|
+
|
26
|
+
@hungry = @person.as DuckExample::LikesFood
|
27
|
+
expect(@hungry.favourite_food).to eq "Ice cream"
|
28
|
+
end
|
29
|
+
|
30
|
+
it "casts objects into modules" do
|
31
|
+
# standard:disable Lint/ConstantDefinitionInBlock
|
32
|
+
module ModuleExample
|
33
|
+
module Person
|
34
|
+
def first_name = @first_name
|
35
|
+
|
36
|
+
def last_name = @last_name
|
37
|
+
|
38
|
+
def email = @email
|
39
|
+
end
|
40
|
+
|
41
|
+
module LikesFood
|
42
|
+
def favourite_food = @favourite_food
|
43
|
+
end
|
44
|
+
|
45
|
+
PersonData = Struct.new(:first_name, :last_name, :email, :favourite_food)
|
46
|
+
CarData = Struct.new(:make, :model, :colour)
|
47
|
+
end
|
48
|
+
# standard:enable Lint/ConstantDefinitionInBlock
|
49
|
+
@porsche_911 = ModuleExample::CarData.new "Porsche", "911", "black"
|
50
|
+
expect { @porsche_911.as ModuleExample::Person }.to raise_error(TypeError)
|
51
|
+
|
52
|
+
@alice = ModuleExample::PersonData.new "Alice", "Aardvark", "alice@example.com", "Ice cream"
|
53
|
+
|
54
|
+
@person = @alice.as ModuleExample::Person
|
55
|
+
expect(@person.first_name).to eq "Alice"
|
56
|
+
expect(@person.email).to eq "alice@example.com"
|
57
|
+
expect { @person.favourite_food }.to raise_error(NoMethodError)
|
58
|
+
|
59
|
+
@hungry = @person.as ModuleExample::LikesFood
|
60
|
+
expect(@hungry.favourite_food).to eq "Ice cream"
|
61
|
+
end
|
62
|
+
|
63
|
+
it "casts objects into clases" do
|
64
|
+
# standard:disable Lint/ConstantDefinitionInBlock
|
65
|
+
module ClassExample
|
66
|
+
class Person
|
67
|
+
def initialize first_name, last_name, email
|
68
|
+
@first_name = first_name
|
69
|
+
@last_name = last_name
|
70
|
+
@email = email
|
71
|
+
end
|
72
|
+
|
73
|
+
attr_reader :first_name
|
74
|
+
attr_reader :last_name
|
75
|
+
attr_reader :email
|
76
|
+
end
|
77
|
+
|
78
|
+
class PersonWhoLikesFood < Person
|
79
|
+
def initialize first_name, last_name, email, favourite_food
|
80
|
+
super(first_name, last_name, email)
|
81
|
+
@favourite_food = favourite_food
|
82
|
+
end
|
83
|
+
|
84
|
+
attr_reader :favourite_food
|
85
|
+
end
|
86
|
+
|
87
|
+
class CarData
|
88
|
+
def initialize make, model, colour
|
89
|
+
@make = make
|
90
|
+
@model = model
|
91
|
+
@colour = colour
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
# standard:enable Lint/ConstantDefinitionInBlock
|
96
|
+
@porsche_911 = ClassExample::CarData.new "Porsche", "911", "black"
|
97
|
+
expect { @porsche_911.as ClassExample::Person }.to raise_error(TypeError)
|
15
98
|
|
16
|
-
@alice =
|
99
|
+
@alice = ClassExample::PersonWhoLikesFood.new "Alice", "Aardvark", "alice@example.com", "Ice cream"
|
17
100
|
|
18
|
-
@person = @alice.as Person
|
101
|
+
@person = @alice.as ClassExample::Person
|
19
102
|
expect(@person.first_name).to eq "Alice"
|
20
103
|
expect(@person.email).to eq "alice@example.com"
|
21
104
|
expect { @person.favourite_food }.to raise_error(NoMethodError)
|
22
105
|
|
23
|
-
@hungry = @person.as
|
106
|
+
@hungry = @person.as ClassExample::PersonWhoLikesFood
|
24
107
|
expect(@hungry.favourite_food).to eq "Ice cream"
|
25
108
|
end
|
26
109
|
end
|
data/spec/examples/valve_spec.rb
CHANGED
@@ -1,6 +1,33 @@
|
|
1
1
|
require "spec_helper"
|
2
2
|
|
3
|
-
RSpec.
|
3
|
+
RSpec.shared_examples "an example valve" do |runs_in_background|
|
4
|
+
it "queries an object" do
|
5
|
+
@person = Employee.start "Alice"
|
6
|
+
|
7
|
+
expect(@person.name).to eq "Alice"
|
8
|
+
expect(@person.job_title).to eq "Sales assistant"
|
9
|
+
|
10
|
+
@time = Time.now
|
11
|
+
# `greet_slowly` is a query so will block until a response is received
|
12
|
+
expect(@person.greet_slowly).to eq "H E L L O"
|
13
|
+
expect(Time.now - @time).to be > 0.1
|
14
|
+
|
15
|
+
@time = Time.now
|
16
|
+
# we're ignoring the result so this will not block (except :inline mode which does not run in the background)
|
17
|
+
expect(@person.greet_slowly(ignore_result: true)).to be_nil
|
18
|
+
expect(Time.now - @time).to be < 0.1 if runs_in_background
|
19
|
+
expect(Time.now - @time).to be > 0.1 if !runs_in_background
|
20
|
+
end
|
21
|
+
|
22
|
+
it "commands an object" do
|
23
|
+
@person = Employee.start "Alice"
|
24
|
+
@person.promote
|
25
|
+
@job_title = @person.job_title
|
26
|
+
expect(@job_title).to eq "Sales manager"
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
RSpec.describe "Valve example: " do
|
4
31
|
# standard:disable Lint/ConstantDefinitionInBlock
|
5
32
|
class Employee
|
6
33
|
include Plumbing::Valve
|
@@ -26,63 +53,29 @@ RSpec.describe "Valve examples" do
|
|
26
53
|
end
|
27
54
|
# standard:enable Lint/ConstantDefinitionInBlock
|
28
55
|
|
29
|
-
context "inline" do
|
30
|
-
|
31
|
-
Plumbing.configure mode: :inline
|
32
|
-
@person = Employee.start "Alice"
|
33
|
-
|
34
|
-
expect(@person.name).to eq "Alice"
|
35
|
-
expect(@person.job_title).to eq "Sales assistant"
|
36
|
-
|
37
|
-
@time = Time.now
|
38
|
-
expect(@person.greet_slowly).to eq "H E L L O"
|
39
|
-
expect(Time.now - @time).to be > 0.1
|
40
|
-
|
41
|
-
@time = Time.now
|
42
|
-
expect(@person.greet_slowly(ignore_result: true)).to be_nil
|
43
|
-
expect(Time.now - @time).to be > 0.1
|
44
|
-
end
|
56
|
+
context "inline mode" do
|
57
|
+
around :example do |example|
|
58
|
+
Plumbing.configure mode: :inline, &example
|
45
59
|
end
|
46
60
|
|
47
|
-
|
48
|
-
Plumbing.configure mode: :inline do
|
49
|
-
@person = Employee.start "Alice"
|
50
|
-
|
51
|
-
@person.promote
|
52
|
-
|
53
|
-
expect(@person.job_title).to eq "Sales manager"
|
54
|
-
end
|
55
|
-
end
|
61
|
+
it_behaves_like "an example valve", false
|
56
62
|
end
|
57
63
|
|
58
|
-
context "async" do
|
64
|
+
context "async mode" do
|
59
65
|
around :example do |example|
|
60
66
|
Plumbing.configure mode: :async do
|
61
67
|
Kernel::Async(&example)
|
62
68
|
end
|
63
69
|
end
|
64
70
|
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
expect(@person.name).to eq "Alice"
|
69
|
-
expect(@person.job_title).to eq "Sales assistant"
|
70
|
-
|
71
|
-
@time = Time.now
|
72
|
-
expect(@person.greet_slowly).to eq "H E L L O"
|
73
|
-
expect(Time.now - @time).to be > 0.1
|
71
|
+
it_behaves_like "an example valve", true
|
72
|
+
end
|
74
73
|
|
75
|
-
|
76
|
-
|
77
|
-
|
74
|
+
context "threaded mode" do
|
75
|
+
around :example do |example|
|
76
|
+
Plumbing.configure mode: :threaded, &example
|
78
77
|
end
|
79
78
|
|
80
|
-
|
81
|
-
@person = Employee.start "Alice"
|
82
|
-
|
83
|
-
@person.promote
|
84
|
-
|
85
|
-
expect(@person.job_title).to eq "Sales manager"
|
86
|
-
end
|
79
|
+
it_behaves_like "an example valve", true
|
87
80
|
end
|
88
81
|
end
|
data/spec/plumbing/pipe_spec.rb
CHANGED
@@ -5,72 +5,70 @@ RSpec.describe Plumbing::RubberDuck do
|
|
5
5
|
class Duck
|
6
6
|
def quack = "Quack"
|
7
7
|
|
8
|
-
def swim place
|
9
|
-
"Swim in #{place}"
|
10
|
-
end
|
8
|
+
def swim(place) = "Swim in #{place}"
|
11
9
|
|
12
|
-
def fly
|
13
|
-
"Fly #{block.call}"
|
14
|
-
end
|
10
|
+
def fly(&block) = "Fly #{block.call}"
|
15
11
|
end
|
16
12
|
# standard:enable Lint/ConstantDefinitionInBlock
|
17
13
|
|
18
|
-
|
19
|
-
|
20
|
-
|
14
|
+
context "defining rubber ducks" do
|
15
|
+
it "verifies that an object matches the RubberDuck type" do
|
16
|
+
@duck_type = described_class.define :quack, :swim, :fly
|
17
|
+
@duck = Duck.new
|
21
18
|
|
22
|
-
|
23
|
-
|
19
|
+
expect(@duck_type.verify(@duck)).to eq @duck
|
20
|
+
end
|
24
21
|
|
25
|
-
|
26
|
-
|
27
|
-
|
22
|
+
it "casts the object to a duck type" do
|
23
|
+
@duck_type = described_class.define :quack, :swim, :fly
|
24
|
+
@duck = Duck.new
|
28
25
|
|
29
|
-
|
26
|
+
@proxy = @duck.as @duck_type
|
30
27
|
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
28
|
+
expect(@proxy).to be_kind_of Plumbing::RubberDuck::Proxy
|
29
|
+
expect(@proxy).to respond_to :quack
|
30
|
+
expect(@proxy.quack).to eq "Quack"
|
31
|
+
expect(@proxy).to respond_to :swim
|
32
|
+
expect(@proxy.swim("the river")).to eq "Swim in the river"
|
33
|
+
expect(@proxy).to respond_to :fly
|
34
|
+
expect(@proxy.fly { "ducky fly" }).to eq "Fly ducky fly"
|
35
|
+
end
|
39
36
|
|
40
|
-
|
41
|
-
|
42
|
-
|
37
|
+
it "does not forward methods that are not part of the duck type" do
|
38
|
+
@duck_type = described_class.define :swim, :fly
|
39
|
+
@duck = Duck.new
|
43
40
|
|
44
|
-
|
41
|
+
@proxy = @duck.as @duck_type
|
45
42
|
|
46
|
-
|
47
|
-
|
43
|
+
expect(@proxy).to_not respond_to :quack
|
44
|
+
end
|
48
45
|
|
49
|
-
|
50
|
-
|
51
|
-
|
46
|
+
it "does not wrap rubber ducks in a proxy" do
|
47
|
+
@duck_type = described_class.define :swim, :fly
|
48
|
+
@duck = Duck.new
|
52
49
|
|
53
|
-
|
50
|
+
@proxy = @duck.as @duck_type
|
54
51
|
|
55
|
-
|
56
|
-
|
52
|
+
expect(@proxy.as(@duck_type)).to eq @proxy
|
53
|
+
end
|
57
54
|
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
55
|
+
it "allows rubber ducks to be expanded and cast to other types" do
|
56
|
+
@quackers = described_class.define :quack
|
57
|
+
@swimming_bird = described_class.define :swim, :fly
|
58
|
+
@duck = Duck.new
|
62
59
|
|
63
|
-
|
64
|
-
|
60
|
+
@swimmer = @duck.as @swimming_bird
|
61
|
+
@quacker = @swimmer.as @quackers
|
65
62
|
|
66
|
-
|
67
|
-
|
68
|
-
|
63
|
+
expect(@swimmer).to respond_to :swim
|
64
|
+
expect(@quacker).to respond_to :quack
|
65
|
+
end
|
69
66
|
|
70
|
-
|
71
|
-
|
72
|
-
|
67
|
+
it "raises a TypeError if the object does not respond to the given methods" do
|
68
|
+
@cow_type = described_class.define :moo, :chew
|
69
|
+
@duck = Duck.new
|
73
70
|
|
74
|
-
|
71
|
+
expect { @cow_type.verify(@duck) }.to raise_error(TypeError)
|
72
|
+
end
|
75
73
|
end
|
76
74
|
end
|
data/spec/plumbing/valve_spec.rb
CHANGED
@@ -1,6 +1,8 @@
|
|
1
1
|
require "spec_helper"
|
2
|
-
|
2
|
+
|
3
3
|
require_relative "../../lib/plumbing/valve/async"
|
4
|
+
require_relative "../../lib/plumbing/valve/threaded"
|
5
|
+
require_relative "../../lib/plumbing/valve/rails"
|
4
6
|
|
5
7
|
RSpec.describe Plumbing::Valve do
|
6
8
|
# standard:disable Lint/ConstantDefinitionInBlock
|
@@ -124,44 +126,46 @@ RSpec.describe Plumbing::Valve do
|
|
124
126
|
end
|
125
127
|
end
|
126
128
|
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
129
|
+
[:threaded, :async].each do |mode|
|
130
|
+
context mode.to_s do
|
131
|
+
around :example do |example|
|
132
|
+
Sync do
|
133
|
+
Plumbing.configure mode: mode, &example
|
134
|
+
end
|
131
135
|
end
|
132
|
-
end
|
133
136
|
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
+
it "performs queries in the background and waits for the response" do
|
138
|
+
@counter = Counter.start "async counter", initial_value: 100
|
139
|
+
@time = Time.now
|
137
140
|
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
+
expect(@counter.name).to eq "async counter"
|
142
|
+
expect(@counter.count).to eq 100
|
143
|
+
expect(Time.now - @time).to be < 0.1
|
141
144
|
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
+
expect(@counter.slow_query).to eq 100
|
146
|
+
expect(Time.now - @time).to be > 0.4
|
147
|
+
end
|
145
148
|
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
+
it "performs queries ignoring the response and returning immediately" do
|
150
|
+
@counter = Counter.start "threaded counter", initial_value: 100
|
151
|
+
@time = Time.now
|
149
152
|
|
150
|
-
|
153
|
+
expect(@counter.slow_query(ignore_result: true)).to be_nil
|
151
154
|
|
152
|
-
|
153
|
-
|
155
|
+
expect(Time.now - @time).to be < 0.1
|
156
|
+
end
|
154
157
|
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
+
it "performs commands in the background and returning immediately" do
|
159
|
+
@counter = Counter.start "threaded counter", initial_value: 100
|
160
|
+
@time = Time.now
|
158
161
|
|
159
|
-
|
160
|
-
|
162
|
+
@counter.slowly_increment
|
163
|
+
expect(Time.now - @time).to be < 0.1
|
161
164
|
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
+
# wait for the threaded task to complete
|
166
|
+
expect(101).to become_equal_to { @counter.count }
|
167
|
+
expect(Time.now - @time).to be > 0.4
|
168
|
+
end
|
165
169
|
end
|
166
170
|
end
|
167
171
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: standard-procedure-plumbing
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.3.
|
4
|
+
version: 0.3.3
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Rahoul Baruah
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2024-09-
|
11
|
+
date: 2024-09-14 00:00:00.000000000 Z
|
12
12
|
dependencies: []
|
13
13
|
description: A composable event pipeline and sequential pipelines of operations
|
14
14
|
email:
|
@@ -31,6 +31,7 @@ files:
|
|
31
31
|
- lib/plumbing/pipeline/contracts.rb
|
32
32
|
- lib/plumbing/pipeline/operations.rb
|
33
33
|
- lib/plumbing/rubber_duck.rb
|
34
|
+
- lib/plumbing/rubber_duck/module.rb
|
34
35
|
- lib/plumbing/rubber_duck/object.rb
|
35
36
|
- lib/plumbing/rubber_duck/proxy.rb
|
36
37
|
- lib/plumbing/types.rb
|
@@ -38,6 +39,8 @@ files:
|
|
38
39
|
- lib/plumbing/valve/async.rb
|
39
40
|
- lib/plumbing/valve/inline.rb
|
40
41
|
- lib/plumbing/valve/message.rb
|
42
|
+
- lib/plumbing/valve/rails.rb
|
43
|
+
- lib/plumbing/valve/threaded.rb
|
41
44
|
- lib/plumbing/version.rb
|
42
45
|
- spec/become_equal_to_matcher.rb
|
43
46
|
- spec/examples/pipe_spec.rb
|
@@ -76,7 +79,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
76
79
|
- !ruby/object:Gem::Version
|
77
80
|
version: '0'
|
78
81
|
requirements: []
|
79
|
-
rubygems_version: 3.5.
|
82
|
+
rubygems_version: 3.5.12
|
80
83
|
signing_key:
|
81
84
|
specification_version: 4
|
82
85
|
summary: Plumbing - various pipelines for your ruby application
|