standard-procedure-plumbing 0.4.5 → 0.4.6
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +12 -494
- data/lib/plumbing/actor/async.rb +9 -4
- data/lib/plumbing/actor/inline.rb +7 -2
- data/lib/plumbing/actor/threaded.rb +5 -6
- data/lib/plumbing/actor.rb +1 -1
- data/lib/plumbing/config.rb +19 -12
- data/lib/plumbing/{custom_filter.rb → pipe/custom_filter.rb} +5 -3
- data/lib/plumbing/{filter.rb → pipe/filter.rb} +5 -4
- data/lib/plumbing/{junction.rb → pipe/junction.rb} +3 -3
- data/lib/plumbing/pipe.rb +16 -27
- data/lib/plumbing/spec/modes.rb +14 -0
- data/lib/plumbing/version.rb +1 -1
- data/lib/plumbing.rb +0 -3
- metadata +6 -6
- data/lib/plumbing/event.rb +0 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 82a3aa2f2a6718e748faf14e12dcf8df50928cfcdb19bf75641731e38122c694
|
4
|
+
data.tar.gz: d13610908d60830323e6921f77628c73ce9920475086a1475f21323def38f3a3
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: e54c7820faf040901935c80797d737cc89dd1b6c7da873621361295446b35d2dab4987dee6ee423086a9dc3a7b6fb3ce20f699a5ff92edf1c7673ef071f089f9
|
7
|
+
data.tar.gz: f47b41311a3aa11fc38479d98f0fc11ce1f370f23ceea127ac44462ea7bf148c66d190662dcd05d50f955cab117b9802cd4d8bb639d747a91db8136bcda16e43
|
data/README.md
CHANGED
@@ -2,509 +2,25 @@
|
|
2
2
|
|
3
3
|
Actors, Observers and Data Pipelines.
|
4
4
|
|
5
|
-
##
|
5
|
+
## Usage
|
6
6
|
|
7
|
-
|
7
|
+
Start off by [configuring Plumbing](/docs/config.md) and selecting your `mode`.
|
8
8
|
|
9
|
-
|
9
|
+
## Pipelines
|
10
10
|
|
11
|
-
|
11
|
+
[Data transformations](/docs/pipelines.md) similar to unix pipes.
|
12
12
|
|
13
|
-
|
13
|
+
## Actors
|
14
14
|
|
15
|
-
|
15
|
+
[Asynchronous, thread-safe, objects](/docs/actors.md).
|
16
16
|
|
17
|
-
|
17
|
+
## Pipes
|
18
18
|
|
19
|
-
|
20
|
-
require "plumbing"
|
21
|
-
puts Plumbing.config.mode
|
22
|
-
# => :inline
|
23
|
-
|
24
|
-
Plumbing.configure mode: :async, timeout: 10
|
25
|
-
|
26
|
-
puts Plumbing.config.mode
|
27
|
-
# => :async
|
28
|
-
```
|
29
|
-
|
30
|
-
If you are running a test suite, you can temporarily update the configuration by passing a block.
|
31
|
-
|
32
|
-
```ruby
|
33
|
-
require "plumbing"
|
34
|
-
puts Plumbing.config.mode
|
35
|
-
# => :inline
|
36
|
-
|
37
|
-
Plumbing.configure mode: :async do
|
38
|
-
puts Plumbing.config.mode
|
39
|
-
# => :async
|
40
|
-
first_test
|
41
|
-
second_test
|
42
|
-
end
|
43
|
-
|
44
|
-
puts Plumbing.config.mode
|
45
|
-
# => :inline
|
46
|
-
```
|
47
|
-
|
48
|
-
## Plumbing::Pipeline - transform data through a pipeline
|
49
|
-
|
50
|
-
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.
|
51
|
-
|
52
|
-
Use `perform` to define a step that takes some input and returns a different output.
|
53
|
-
Specify `using` to re-use an existing `Plumbing::Pipeline` as a step within this pipeline.
|
54
|
-
Use `execute` to define a step that takes some input, performs an action but passes the input, unchanged, to the next step.
|
55
|
-
|
56
|
-
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.
|
57
|
-
|
58
|
-
You can also verify that the output generated is as expected by defining a `post_condition`.
|
59
|
-
|
60
|
-
### Usage:
|
61
|
-
|
62
|
-
[Building an array using multiple steps with a pre-condition and post-condition](/spec/examples/pipeline_spec.rb)
|
63
|
-
|
64
|
-
```ruby
|
65
|
-
require "plumbing"
|
66
|
-
class BuildArray < Plumbing::Pipeline
|
67
|
-
perform :add_first
|
68
|
-
perform :add_second
|
69
|
-
perform :add_third
|
70
|
-
|
71
|
-
pre_condition :must_be_an_array do |input|
|
72
|
-
input.is_a? Array
|
73
|
-
end
|
74
|
-
|
75
|
-
post_condition :must_have_three_elements do |output|
|
76
|
-
output.length == 3
|
77
|
-
end
|
78
|
-
|
79
|
-
private
|
80
|
-
|
81
|
-
def add_first(input) = input << "first"
|
82
|
-
|
83
|
-
def add_second(input) = input << "second"
|
84
|
-
|
85
|
-
def add_third(input) = input << "third"
|
86
|
-
end
|
87
|
-
|
88
|
-
BuildArray.new.call []
|
89
|
-
# => ["first", "second", "third"]
|
90
|
-
|
91
|
-
BuildArray.new.call 1
|
92
|
-
# => Plumbing::PreconditionError("must_be_an_array")
|
93
|
-
|
94
|
-
BuildArray.new.call ["extra element"]
|
95
|
-
# => Plumbing::PostconditionError("must_have_three_elements")
|
96
|
-
```
|
97
|
-
|
98
|
-
[Validating input parameters with a contract](/spec/examples/pipeline_spec.rb)
|
99
|
-
```ruby
|
100
|
-
require "plumbing"
|
101
|
-
require "dry/validation"
|
102
|
-
|
103
|
-
class SayHello < Plumbing::Pipeline
|
104
|
-
validate_with "SayHello::Input"
|
105
|
-
perform :say_hello
|
106
|
-
|
107
|
-
private
|
108
|
-
|
109
|
-
def say_hello input
|
110
|
-
"Hello #{input[:name]} - I will now send a load of annoying marketing messages to #{input[:email]}"
|
111
|
-
end
|
112
|
-
|
113
|
-
class Input < Dry::Validation::Contract
|
114
|
-
params do
|
115
|
-
required(:name).filled(:string)
|
116
|
-
required(:email).filled(:string)
|
117
|
-
end
|
118
|
-
rule :email do
|
119
|
-
key.failure("must be a valid email") unless /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i.match? value
|
120
|
-
end
|
121
|
-
end
|
122
|
-
end
|
123
|
-
|
124
|
-
SayHello.new.call(name: "Alice", email: "alice@example.com")
|
125
|
-
# => Hello Alice - I will now send a load of annoying marketing messages to alice@example.com
|
126
|
-
|
127
|
-
SayHello.new.call(some: "other data")
|
128
|
-
# => Plumbing::PreConditionError
|
129
|
-
```
|
130
|
-
|
131
|
-
[Building a pipeline through composition](/spec/examples/pipeline_spec.rb)
|
132
|
-
|
133
|
-
```ruby
|
134
|
-
require "plumbing"
|
135
|
-
class ExternalStep < Plumbing::Pipeline
|
136
|
-
perform :add_item_to_array
|
137
|
-
|
138
|
-
private
|
139
|
-
|
140
|
-
def add_item_to_array(input) = input << "external"
|
141
|
-
end
|
142
|
-
|
143
|
-
class BuildSequenceWithExternalStep < Plumbing::Pipeline
|
144
|
-
perform :add_first
|
145
|
-
perform :add_second, using: "ExternalStep"
|
146
|
-
perform :add_third
|
19
|
+
[Composable observers](/docs/pipes.md).
|
147
20
|
|
148
|
-
|
21
|
+
## Rubber ducks
|
149
22
|
|
150
|
-
|
151
|
-
|
152
|
-
def add_third(input) = input << "third"
|
153
|
-
end
|
154
|
-
|
155
|
-
BuildSequenceWithExternalStep.new.call([])
|
156
|
-
# => ["first", "external", "third"]
|
157
|
-
```
|
158
|
-
|
159
|
-
## Plumbing::Actor - safe asynchronous objects
|
160
|
-
|
161
|
-
An [actor](https://en.wikipedia.org/wiki/Actor_model) defines the messages an object can receive, similar to a regular object.
|
162
|
-
However, in traditional object-orientated programming, a thread of execution moves from one object to another. If there are multiple threads, then each object may be accessed concurrently, leading to race conditions or data-integrity problems - and very hard to track bugs.
|
163
|
-
|
164
|
-
Actors are different. Conceptually, each actor has it's own thread of execution, isolated from every other actor in the system. When one actor sends a message to another actor, the receiver does not execute its method in the caller's thread. Instead, it places the message on a queue and waits until its own thread is free to process the work. If the caller would like to access the return value from the method, then it must wait until the receiver has finished processing.
|
165
|
-
|
166
|
-
This means each actor is only ever accessed by a single thread and the vast majority of concurrency issues are eliminated.
|
167
|
-
|
168
|
-
[Plumbing::Actor](/lib/plumbing/actor.rb) allows you to define the `async` public interface to your objects. Calling `.start` builds a proxy to the actual instance of your object and ensures that any messages sent are handled in a manner appropriate to the current mode - immediately for inline mode, using fibers for async mode and using threads for threaded and threaded_rails mode.
|
169
|
-
|
170
|
-
When sending messages to an actor, this just works.
|
171
|
-
|
172
|
-
However, as the caller, you do not have direct access to the return values of the messages that you send. Instead, you must call `#value` - or alternatively, wrap your call in `await { ... }`. The block form of `await` is added in to ruby's `Kernel` so it is available everywhere. It is also safe to use with non-actors (in which case it just returns the original value from the block).
|
173
|
-
|
174
|
-
```ruby
|
175
|
-
@actor = MyActor.start name: "Alice"
|
176
|
-
|
177
|
-
@actor.name.value
|
178
|
-
# => "Alice"
|
179
|
-
|
180
|
-
await { @actor.name }
|
181
|
-
# => "Alice"
|
182
|
-
|
183
|
-
await { "Bob" }
|
184
|
-
# => "Bob"
|
185
|
-
```
|
186
|
-
|
187
|
-
This then makes the caller's thread block until the receiver's thread has finished its work and returned a value. Or if the receiver raises an exception, that exception is then re-raised in the calling thread.
|
188
|
-
|
189
|
-
The actor model does not eliminate every possible concurrency issue. If you use `value` or `await`, it is possible to deadlock yourself.
|
190
|
-
|
191
|
-
Actor A, running in Thread 1, sends a message to Actor B and then awaits the result, meaning Thread 1 is blocked. Actor B, running in Thread 2, starts to work, but needs to ask Actor A a question. So it sends a message to Actor A and awaits the result. Thread 2 is now blocked, waiting for Actor A to respond. But Actor A, running in Thread 1, is blocked, waiting for Actor B to respond.
|
192
|
-
|
193
|
-
This potential deadlock only occurs if you use `value` or `await` and have actors that call back in to each other. If your objects are strictly layered, or you never use `value` or `await` (perhaps, instead using a Pipe to observe events), then this particular deadlock should not occur. However, just in case, every call to `value` has a timeout defaulting to 30s.
|
194
|
-
|
195
|
-
### Inline actors
|
196
|
-
|
197
|
-
Even though inline mode is not asynchronous, you must still use `value` or `await` to access the results from another actor. However, as deadlocks are impossible in a single thread, there is no timeout.
|
198
|
-
|
199
|
-
### Async actors
|
200
|
-
|
201
|
-
Using async mode is probably the easiest way to add concurrency to your application. It uses fibers to allow for "concurrency but not parallelism" - that is execution will happen in the background but your objects or data will never be accessed by two things at the exact same time.
|
202
|
-
|
203
|
-
### Threaded actors
|
204
|
-
|
205
|
-
Using threaded (or threaded_rails) mode gives you concurrency and parallelism. If all your public objects are actors and you are careful about callbacks then the actor model will keep your code safe. But there are a couple of extra things to consider.
|
206
|
-
|
207
|
-
Firstly, when you pass parameters or return results between threads, those objects are "transported" across the boundaries.
|
208
|
-
Most objects are cloned. Hashes, keyword arguments and arrays have their contents recursively transported. And any object that uses `GlobalID::Identification` (for example, ActiveRecord models) are marshalled into a GlobalID, then unmarshalled back in to their original object. This is to prevent the same object from being amended in both the caller and receiver's threads.
|
209
|
-
|
210
|
-
Secondly, when you pass a block (or Proc parameter) to another actor, the block/proc will be executed in the receiver's thread. This means you must not access any variables that would normally be in scope for your block (whether local variables or instance variables of other objects - see note below) This is because you will be accessing them from a different thread to where they were defined, leading to potential race conditions. And, if you access any actors, you must not use `value` or `await` or you risk a deadlock. If you are within an actor and need to pass a block or proc parameter, you should use the `safely` method to ensure that your block is run within the context of the calling actor, not the receiving actor.
|
211
|
-
|
212
|
-
For example, when defining a custom filter, the filter adds itself as an observer to its source. The source triggers the `received` method on the filter, which will run in the context of the source. So the custom filter uses `safely` to move back into its own context and access its instance variables.
|
213
|
-
|
214
|
-
```ruby
|
215
|
-
class EveryThirdEvent < Plumbing::CustomFilter
|
216
|
-
def initialize source:
|
217
|
-
super
|
218
|
-
@events = []
|
219
|
-
end
|
220
|
-
|
221
|
-
def received event
|
222
|
-
safely do
|
223
|
-
@events << event
|
224
|
-
if @events.count >= 3
|
225
|
-
@events.clear
|
226
|
-
self << event
|
227
|
-
end
|
228
|
-
end
|
229
|
-
end
|
230
|
-
end
|
231
|
-
```
|
232
|
-
|
233
|
-
(Note: we break that rule in the specs for Pipe objects - we use a block observer that sets the value on a local variable. That's because it is a controlled situation where we know there are only two threads involved and we are explicitly waiting for the second thread to complete. For almost every app that uses actors, there will be multiple threads and it will be impossible to predict the access patterns).
|
234
|
-
|
235
|
-
### Constructing actors
|
236
|
-
|
237
|
-
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 actor are available in this proxy - so you don't have to worry about callers bypassing the actor's internal context.
|
238
|
-
|
239
|
-
### Referencing actors
|
240
|
-
|
241
|
-
If you're within a method inside your actor and you want to pass a reference to yourself, instead of using `self`, you should use `proxy` (which is also aliased as `as_actor` or `async`).
|
242
|
-
|
243
|
-
Also be aware that if you use actors in one place, you need to use them everywhere - especially if you're using threads. This is because as the actor sends messages to its collaborators, those calls will be made from within the actor's internal context. If the collaborators are also actors, the subsequent messages will be handled correctly, if not, data consistency bugs could occur. This does not mean that every class needs to be an actor, just your "public API" classes which may be accessed from multiple actors or other threads.
|
244
|
-
|
245
|
-
### Usage
|
246
|
-
|
247
|
-
[Defining an actor](/spec/examples/actor_spec.rb)
|
248
|
-
|
249
|
-
```ruby
|
250
|
-
require "plumbing"
|
251
|
-
|
252
|
-
class Employee
|
253
|
-
include Plumbing::Actor
|
254
|
-
async :name, :job_title, :greet_slowly, :promote
|
255
|
-
attr_reader :name, :job_title
|
256
|
-
|
257
|
-
def initialize(name)
|
258
|
-
@name = name
|
259
|
-
@job_title = "Sales assistant"
|
260
|
-
end
|
261
|
-
|
262
|
-
private
|
263
|
-
|
264
|
-
def promote
|
265
|
-
sleep 0.5
|
266
|
-
@job_title = "Sales manager"
|
267
|
-
end
|
268
|
-
|
269
|
-
def greet_slowly
|
270
|
-
sleep 0.2
|
271
|
-
"H E L L O"
|
272
|
-
end
|
273
|
-
end
|
274
|
-
|
275
|
-
@person = Employee.start "Alice"
|
276
|
-
|
277
|
-
await { @person.name }
|
278
|
-
# => "Alice"
|
279
|
-
await { @person.job_title }
|
280
|
-
# => "Sales assistant"
|
281
|
-
|
282
|
-
# by using `await`, we will block until `greet_slowly` has returned a value
|
283
|
-
await { @person.greet_slowly }
|
284
|
-
# => "H E L L O"
|
285
|
-
|
286
|
-
# this time, we're not awaiting the result, so this will run in the background (unless we're using inline mode)
|
287
|
-
@person.greet_slowly
|
288
|
-
|
289
|
-
# this will run in the background
|
290
|
-
@person.promote
|
291
|
-
# this will block - it will not return until the previous calls, #greet_slowly, #promote, and this call to #job_title have completed
|
292
|
-
await { @person.job_title }
|
293
|
-
# => "Sales manager"
|
294
|
-
```
|
295
|
-
|
296
|
-
## Plumbing::Pipe - a composable observer
|
297
|
-
|
298
|
-
[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)).
|
299
|
-
|
300
|
-
[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.
|
301
|
-
|
302
|
-
Pipes are implemented as actors, 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 actor to maintain safety.
|
303
|
-
|
304
|
-
### Usage
|
305
|
-
|
306
|
-
[A simple observer](/spec/examples/pipe_spec.rb):
|
307
|
-
```ruby
|
308
|
-
require "plumbing"
|
309
|
-
|
310
|
-
@source = Plumbing::Pipe.start
|
311
|
-
@observer = @source.add_observer do |event|
|
312
|
-
puts event.type
|
313
|
-
end
|
314
|
-
|
315
|
-
@source.notify "something_happened", message: "But what was it?"
|
316
|
-
# => "something_happened"
|
317
|
-
```
|
318
|
-
|
319
|
-
[Simple filtering](/spec/examples/pipe_spec.rb):
|
320
|
-
```ruby
|
321
|
-
require "plumbing"
|
322
|
-
|
323
|
-
@source = Plumbing::Pipe.start
|
324
|
-
@filter = Plumbing::Filter.start source: @source do |event|
|
325
|
-
%w[important urgent].include? event.type
|
326
|
-
end
|
327
|
-
@observer = @filter.add_observer do |event|
|
328
|
-
puts event.type
|
329
|
-
end
|
330
|
-
|
331
|
-
@source.notify "important", message: "ALERT! ALERT!"
|
332
|
-
# => "important"
|
333
|
-
@source.notify "unimportant", message: "Nothing to see here"
|
334
|
-
# => <no output>
|
335
|
-
```
|
336
|
-
|
337
|
-
[Custom filtering](/spec/examples/pipe_spec.rb):
|
338
|
-
```ruby
|
339
|
-
require "plumbing"
|
340
|
-
class EveryThirdEvent < Plumbing::CustomFilter
|
341
|
-
def initialize source:
|
342
|
-
super source: source
|
343
|
-
@events = []
|
344
|
-
end
|
345
|
-
|
346
|
-
def received event
|
347
|
-
# #received is called in the context of the `source` actor
|
348
|
-
# in order to safely access the `EveryThirdEvent` instance variables
|
349
|
-
# we need to move into the context of our own actor
|
350
|
-
safely do
|
351
|
-
# store this event into our buffer
|
352
|
-
@events << event
|
353
|
-
# if this is the third event we've received then clear the buffer and broadcast the latest event
|
354
|
-
if @events.count >= 3
|
355
|
-
@events.clear
|
356
|
-
self << event
|
357
|
-
end
|
358
|
-
end
|
359
|
-
end
|
360
|
-
end
|
361
|
-
|
362
|
-
@source = Plumbing::Pipe.start
|
363
|
-
@filter = EveryThirdEvent.start(source: @source)
|
364
|
-
@observer = @filter.add_observer do |event|
|
365
|
-
puts event.type
|
366
|
-
end
|
367
|
-
|
368
|
-
1.upto 10 do |i|
|
369
|
-
@source.notify i.to_s
|
370
|
-
end
|
371
|
-
# => "3"
|
372
|
-
# => "6"
|
373
|
-
# => "9"
|
374
|
-
```
|
375
|
-
|
376
|
-
[Joining multiple sources](/spec/examples/pipe_spec.rb):
|
377
|
-
```ruby
|
378
|
-
require "plumbing"
|
379
|
-
|
380
|
-
@first_source = Plumbing::Pipe.start
|
381
|
-
@second_source = Plumbing::Pipe.start
|
382
|
-
|
383
|
-
@junction = Plumbing::Junction.start @first_source, @second_source
|
384
|
-
|
385
|
-
@observer = @junction.add_observer do |event|
|
386
|
-
puts event.type
|
387
|
-
end
|
388
|
-
|
389
|
-
@first_source.notify "one"
|
390
|
-
# => "one"
|
391
|
-
@second_source.notify "two"
|
392
|
-
# => "two"
|
393
|
-
```
|
394
|
-
## Plumbing::RubberDuck - duck types and type-casts
|
395
|
-
|
396
|
-
Define an [interface or protocol](https://en.wikipedia.org/wiki/Interface_(object-oriented_programming)) specifying which messages you expect to be able to send.
|
397
|
-
|
398
|
-
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).
|
399
|
-
|
400
|
-
### Usage
|
401
|
-
|
402
|
-
Define your interface (Person in this example), then cast your objects (instances of PersonData and CarData).
|
403
|
-
|
404
|
-
[Casting objects as duck-types](/spec/examples/rubber_duck_spec.rb):
|
405
|
-
```ruby
|
406
|
-
require "plumbing"
|
407
|
-
|
408
|
-
Person = Plumbing::RubberDuck.define :first_name, :last_name, :email
|
409
|
-
LikesFood = Plumbing::RubberDuck.define :favourite_food
|
410
|
-
|
411
|
-
PersonData = Struct.new(:first_name, :last_name, :email, :favourite_food)
|
412
|
-
CarData = Struct.new(:make, :model, :colour)
|
413
|
-
|
414
|
-
@porsche_911 = CarData.new "Porsche", "911", "black"
|
415
|
-
@person = @porsche_911.as Person
|
416
|
-
# => Raises a TypeError as CarData does not respond_to #first_name, #last_name, #email
|
417
|
-
|
418
|
-
@alice = PersonData.new "Alice", "Aardvark", "alice@example.com", "Ice cream"
|
419
|
-
@person = @alice.as Person
|
420
|
-
@person.first_name
|
421
|
-
# => "Alice"
|
422
|
-
@person.email
|
423
|
-
# => "alice@example.com"
|
424
|
-
@person.favourite_food
|
425
|
-
# => NoMethodError - #favourite_food is not part of the Person rubber duck (even though it is part of the underlying PersonData struct)
|
426
|
-
|
427
|
-
# Cast our Person into a LikesFood rubber duck
|
428
|
-
@hungry = @person.as LikesFood
|
429
|
-
@hungry.favourite_food
|
430
|
-
# => "Ice cream"
|
431
|
-
```
|
432
|
-
|
433
|
-
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.
|
434
|
-
|
435
|
-
```ruby
|
436
|
-
require "plumbing"
|
437
|
-
|
438
|
-
module Person
|
439
|
-
def first_name = @first_name
|
440
|
-
|
441
|
-
def last_name = @last_name
|
442
|
-
|
443
|
-
def email = @email
|
444
|
-
end
|
445
|
-
|
446
|
-
module LikesFood
|
447
|
-
def favourite_food = @favourite_food
|
448
|
-
end
|
449
|
-
|
450
|
-
PersonData = Struct.new(:first_name, :last_name, :email, :favourite_food)
|
451
|
-
CarData = Struct.new(:make, :model, :colour)
|
452
|
-
|
453
|
-
@porsche_911 = CarData.new "Porsche", "911", "black"
|
454
|
-
expect { @porsche_911.as Person }.to raise_error(TypeError)
|
455
|
-
|
456
|
-
@alice = PersonData.new "Alice", "Aardvark", "alice@example.com", "Ice cream"
|
457
|
-
|
458
|
-
@alics.is_a? Person
|
459
|
-
# => false - PersonData does not `include Person`
|
460
|
-
@person = @alice.as Person
|
461
|
-
# This cast is OK because PersonData responds to :first_name, :last_name and :email
|
462
|
-
expect(@person.first_name).to eq "Alice"
|
463
|
-
expect(@person.email).to eq "alice@example.com"
|
464
|
-
expect { @person.favourite_food }.to raise_error(NoMethodError)
|
465
|
-
|
466
|
-
@hungry = @person.as LikesFood
|
467
|
-
expect(@hungry.favourite_food).to eq "Ice cream"
|
468
|
-
```
|
469
|
-
|
470
|
-
## Testing
|
471
|
-
|
472
|
-
As soon as you're working in :async or :threaded mode, you'll find your tests become unpredictable.
|
473
|
-
|
474
|
-
To help with this there are some helpers that you can include in your code.
|
475
|
-
|
476
|
-
Firstly, you can wait for something to become true. The `#wait_for` method is added into `Kernel` so it is available everywhere. It repeatedly executes the given block until a truthy value is returned or the timeout is reached (at which point it raises a Timeout::Error). Note that you still need to use `await` (or call `#value`) to access return values from messages sent to actors.
|
477
|
-
|
478
|
-
```ruby
|
479
|
-
@target = SomeActor.start
|
480
|
-
@subject = SomeOtherActor.start
|
481
|
-
|
482
|
-
@subject.do_something_to @target
|
483
|
-
|
484
|
-
wait_for do
|
485
|
-
await { @target.was_updated? }
|
486
|
-
end
|
487
|
-
|
488
|
-
```
|
489
|
-
|
490
|
-
Secondly, if you're using RSpec, you can `require "plumbing/spec/become_matchers"` to add some extra expectation matchers. These matchers use `wait_for` to repeatedly evaluate the given block until it reaches the expected value or times out. The matchers are `become(value)`, `become_true`, `become_false`, `become_truthy` and `become_falsey`. Note that you still need to use `await` (or call `#value`) to access return values from messages sent to actors.
|
491
|
-
|
492
|
-
```ruby
|
493
|
-
@target = SomeActor.start
|
494
|
-
@subject = SomeOtherActor.start
|
495
|
-
|
496
|
-
@subject.do_something_to @target
|
497
|
-
|
498
|
-
expect { @target.was_updated?.value }.to become_true
|
499
|
-
|
500
|
-
@employee = Employee.start
|
501
|
-
|
502
|
-
expect { @employee.job_title.value }.to become "Sales assistant"
|
503
|
-
|
504
|
-
@employee.promote!
|
505
|
-
|
506
|
-
expect { @employee.job_title.value }.to become "Manager"
|
507
|
-
```
|
23
|
+
[Type-safety the ruby way](/docs/rubber_ducks.md).
|
508
24
|
|
509
25
|
## Installation
|
510
26
|
|
@@ -525,6 +41,8 @@ require 'plumbing'
|
|
525
41
|
Plumbing.config mode: :async
|
526
42
|
```
|
527
43
|
|
44
|
+
|
45
|
+
|
528
46
|
## Development
|
529
47
|
|
530
48
|
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
data/lib/plumbing/actor/async.rb
CHANGED
@@ -9,20 +9,24 @@ module Plumbing
|
|
9
9
|
|
10
10
|
def initialize target
|
11
11
|
@target = target
|
12
|
-
@
|
13
|
-
@semaphore = ::Async::Semaphore.new(1)
|
12
|
+
@semaphore = ::Async::Semaphore.new(Plumbing.config.max_concurrency)
|
14
13
|
end
|
15
14
|
|
16
15
|
# Send the message to the target and wrap the result
|
17
|
-
def send_message(message_name,
|
16
|
+
def send_message(message_name, *args, **params, &block)
|
17
|
+
Plumbing.config.logger.debug { "-> #{@target.class}##{message_name}(#{args.inspect}, #{params.inspect})" }
|
18
18
|
task = @semaphore.async do
|
19
|
-
@target.
|
19
|
+
Plumbing.config.logger.debug { "---> #{@target.class}##{message_name}(#{args.inspect}, #{params.inspect})" }
|
20
|
+
@target.send(message_name, *args, **params, &block)
|
20
21
|
end
|
22
|
+
sleep 0.01
|
21
23
|
Result.new(task)
|
22
24
|
end
|
23
25
|
|
24
26
|
def safely(&)
|
27
|
+
Plumbing.config.logger.debug { "-> #{@target.class}#perform_safely" }
|
25
28
|
send_message(:perform_safely, &)
|
29
|
+
sleep 0.01
|
26
30
|
nil
|
27
31
|
end
|
28
32
|
|
@@ -32,6 +36,7 @@ module Plumbing
|
|
32
36
|
|
33
37
|
Result = Data.define(:task) do
|
34
38
|
def value
|
39
|
+
sleep 0.01
|
35
40
|
Timeout.timeout(Plumbing::Actor.timeout) do
|
36
41
|
task.wait
|
37
42
|
end
|
@@ -6,14 +6,19 @@ module Plumbing
|
|
6
6
|
end
|
7
7
|
|
8
8
|
# Send the message to the target and wrap the result
|
9
|
-
def send_message(message_name,
|
10
|
-
|
9
|
+
def send_message(message_name, *args, **params, &)
|
10
|
+
Plumbing.config.logger.debug { "-> #{@target.class}##{message_name}(#{args.inspect}, #{params.inspect})" }
|
11
|
+
Plumbing.config.logger.debug { "---> #{@target.class}##{message_name}(#{args.inspect}, #{params.inspect})" }
|
12
|
+
value = @target.send(message_name, *args, **params, &)
|
13
|
+
Plumbing.config.logger.debug { "===> #{@target.class}##{message_name} => #{value}" }
|
11
14
|
Result.new(value)
|
12
15
|
rescue => ex
|
16
|
+
Plumbing.config.logger.debug { "!!!! #{@target.class}##{message_name} => #{ex}" }
|
13
17
|
Result.new(ex)
|
14
18
|
end
|
15
19
|
|
16
20
|
def safely(&)
|
21
|
+
Plumbing.config.logger.debug { "-> #{@target.class}#perform_safely" }
|
17
22
|
send_message(:perform_safely, &)
|
18
23
|
nil
|
19
24
|
end
|
@@ -8,8 +8,6 @@ require_relative "transporter"
|
|
8
8
|
module Plumbing
|
9
9
|
module Actor
|
10
10
|
class Threaded
|
11
|
-
attr_reader :target
|
12
|
-
|
13
11
|
def initialize target
|
14
12
|
@target = target
|
15
13
|
@queue = Concurrent::Array.new
|
@@ -18,7 +16,7 @@ module Plumbing
|
|
18
16
|
|
19
17
|
# Send the message to the target and wrap the result
|
20
18
|
def send_message(message_name, *args, **params, &block)
|
21
|
-
|
19
|
+
Plumbing.config.logger.debug { "-> #{@target.class}##{message_name}(#{args.inspect}, #{params.inspect})\n#{Thread.current.name}" }
|
22
20
|
Message.new(@target, message_name, Plumbing::Actor.transporter.marshal(*args), Plumbing::Actor.transporter.marshal(params).first, block, Concurrent::MVar.new).tap do |message|
|
23
21
|
@queue << message
|
24
22
|
send_messages
|
@@ -26,6 +24,7 @@ module Plumbing
|
|
26
24
|
end
|
27
25
|
|
28
26
|
def safely(&)
|
27
|
+
Plumbing.config.logger.debug { "-> #{@target.class}#perform_safely\n#{Thread.current.name}" }
|
29
28
|
send_message(:perform_safely, &)
|
30
29
|
nil
|
31
30
|
end
|
@@ -58,13 +57,13 @@ module Plumbing
|
|
58
57
|
def call
|
59
58
|
args = Plumbing::Actor.transporter.unmarshal(*packed_args)
|
60
59
|
params = Plumbing::Actor.transporter.unmarshal(packed_params)
|
61
|
-
|
60
|
+
Plumbing.config.logger.debug { "---> #{target.class}##{message_name}(#{args.first.inspect}, #{params.first.inspect}, &#{!unsafe_block.nil?})\n#{Thread.current.name}" }
|
62
61
|
value = target.send message_name, *args, **params.first, &unsafe_block
|
62
|
+
Plumbing.config.logger.debug { "===> #{target.class}##{message_name} => #{value}\n#{Thread.current.name}" }
|
63
63
|
|
64
64
|
result.put Plumbing::Actor.transporter.marshal(value)
|
65
65
|
rescue => ex
|
66
|
-
|
67
|
-
puts ex.backtrace
|
66
|
+
Plumbing.config.logger.debug { "!!!! #{target.class}##{message_name} => #{ex}\n#{Thread.current.name}" }
|
68
67
|
result.put ex
|
69
68
|
end
|
70
69
|
|
data/lib/plumbing/actor.rb
CHANGED
data/lib/plumbing/config.rb
CHANGED
@@ -1,16 +1,7 @@
|
|
1
|
+
require "logger"
|
2
|
+
|
1
3
|
# Pipes, pipelines, actors and rubber ducks
|
2
4
|
module Plumbing
|
3
|
-
Config = Data.define :mode, :actor_proxy_classes, :timeout, :debug do
|
4
|
-
def actor_proxy_class_for target_class
|
5
|
-
actor_proxy_classes[target_class]
|
6
|
-
end
|
7
|
-
|
8
|
-
def register_actor_proxy_class_for target_class, proxy_class
|
9
|
-
actor_proxy_classes[target_class] = proxy_class
|
10
|
-
end
|
11
|
-
end
|
12
|
-
private_constant :Config
|
13
|
-
|
14
5
|
# Access the current configuration
|
15
6
|
# @return [Config]
|
16
7
|
def self.config
|
@@ -45,7 +36,23 @@ module Plumbing
|
|
45
36
|
private_class_method :set_configuration_and_yield
|
46
37
|
|
47
38
|
def self.configs
|
48
|
-
@configs ||= [Config.new(mode: :inline, timeout: 30, actor_proxy_classes: {},
|
39
|
+
@configs ||= [Config.new(mode: :inline, timeout: 30, actor_proxy_classes: {}, max_concurrency: 12, logger: logger)]
|
49
40
|
end
|
50
41
|
private_class_method :configs
|
42
|
+
|
43
|
+
def self.logger
|
44
|
+
@logger = Logger.new($stdout, level: Logger::INFO)
|
45
|
+
end
|
46
|
+
private_class_method :logger
|
47
|
+
|
48
|
+
Config = Data.define :mode, :actor_proxy_classes, :timeout, :max_concurrency, :logger do
|
49
|
+
def actor_proxy_class_for target_class
|
50
|
+
actor_proxy_classes[target_class]
|
51
|
+
end
|
52
|
+
|
53
|
+
def register_actor_proxy_class_for target_class, proxy_class
|
54
|
+
actor_proxy_classes[target_class] = proxy_class
|
55
|
+
end
|
56
|
+
end
|
57
|
+
private_constant :Config
|
51
58
|
end
|
@@ -1,15 +1,17 @@
|
|
1
1
|
module Plumbing
|
2
2
|
# A pipe that can be subclassed to filter events from a source pipe
|
3
|
-
class CustomFilter < Pipe
|
3
|
+
class Pipe::CustomFilter < Pipe
|
4
4
|
# Chain this pipe to the source pipe
|
5
5
|
# @param source [Plumbing::Observable] the source from which to receive and filter events
|
6
6
|
def initialize source:
|
7
7
|
super()
|
8
|
-
source.as(Observable).add_observer
|
8
|
+
source.as(Observable).add_observer do |event_name, data|
|
9
|
+
received event_name, data
|
10
|
+
end
|
9
11
|
end
|
10
12
|
|
11
13
|
protected
|
12
14
|
|
13
|
-
def received(
|
15
|
+
def received(event_name, data) = raise NoMethodError.new("Subclass should define #received")
|
14
16
|
end
|
15
17
|
end
|
@@ -1,7 +1,7 @@
|
|
1
1
|
require_relative "custom_filter"
|
2
2
|
module Plumbing
|
3
3
|
# A pipe that filters events from a source pipe
|
4
|
-
class Filter < CustomFilter
|
4
|
+
class Pipe::Filter < Pipe::CustomFilter
|
5
5
|
# Chain this pipe to the source pipe
|
6
6
|
# @param source [Plumbing::Observable] the source from which to receive and filter events
|
7
7
|
# @param &accepts [Block] a block that returns a boolean value - true to accept the event, false to reject it
|
@@ -14,9 +14,10 @@ module Plumbing
|
|
14
14
|
|
15
15
|
protected
|
16
16
|
|
17
|
-
def received(
|
18
|
-
|
19
|
-
|
17
|
+
def received(event_name, data)
|
18
|
+
safely do
|
19
|
+
notify event_name, **data if @accepts.call(event_name, data)
|
20
|
+
end
|
20
21
|
end
|
21
22
|
end
|
22
23
|
end
|
@@ -1,6 +1,6 @@
|
|
1
1
|
module Plumbing
|
2
2
|
# A pipe that filters events from a source pipe
|
3
|
-
class Junction < Pipe
|
3
|
+
class Pipe::Junction < Pipe
|
4
4
|
# Chain multiple sources to this pipe
|
5
5
|
# @param sources [Array<Plumbing::Observable>] the sources which will be joined and relayed
|
6
6
|
def initialize *sources
|
@@ -11,9 +11,9 @@ module Plumbing
|
|
11
11
|
private
|
12
12
|
|
13
13
|
def add source
|
14
|
-
source.as(Observable).add_observer do |
|
14
|
+
source.as(Observable).add_observer do |event_name, **data|
|
15
15
|
safely do
|
16
|
-
|
16
|
+
notify event_name, **data
|
17
17
|
end
|
18
18
|
end
|
19
19
|
end
|
data/lib/plumbing/pipe.rb
CHANGED
@@ -3,21 +3,19 @@ module Plumbing
|
|
3
3
|
class Pipe
|
4
4
|
include Plumbing::Actor
|
5
5
|
|
6
|
-
async :notify,
|
6
|
+
async :notify, :remove_observer, :add_observer, :is_observer?, :shutdown
|
7
7
|
|
8
|
-
#
|
9
|
-
# @param
|
10
|
-
def << event
|
11
|
-
raise Plumbing::InvalidEvent.new event unless event.is_a? Plumbing::Event
|
12
|
-
dispatch event
|
13
|
-
end
|
14
|
-
|
15
|
-
# A shortcut to creating and then pushing an event
|
16
|
-
# @param event_type [String] representing the type of event this is
|
8
|
+
# Notify observers about an event
|
9
|
+
# @param event_name [String] representing the type of event this is
|
17
10
|
# @param data [Hash] representing the event-specific data to be passed to the observers
|
18
|
-
def notify
|
19
|
-
|
20
|
-
|
11
|
+
def notify event_name, **data
|
12
|
+
Plumbing.config.logger.debug { "-> #{self.class}#notify #{event_name}" }
|
13
|
+
observers.each do |observer|
|
14
|
+
Plumbing.config.logger.debug { "===> #{self.class}#dispatch #{event_name}(#{data}) to #{observer}" }
|
15
|
+
observer.call event_name, data
|
16
|
+
rescue => ex
|
17
|
+
Plumbing.config.logger.error { "!!!! #{self.class}#dispatch #{event_name} => #{ex}" }
|
18
|
+
ex
|
21
19
|
end
|
22
20
|
end
|
23
21
|
|
@@ -52,23 +50,14 @@ module Plumbing
|
|
52
50
|
stop
|
53
51
|
end
|
54
52
|
|
55
|
-
|
56
|
-
|
57
|
-
# Dispatch an event to all observers
|
58
|
-
# @param event [Plumbing::Event]
|
59
|
-
# Enumerates all observers and `calls` them with this event
|
60
|
-
# Discards any errors raised by the observer so that all observers will be successfully notified
|
61
|
-
def dispatch event
|
62
|
-
observers.each do |observer|
|
63
|
-
observer.call event
|
64
|
-
rescue => ex
|
65
|
-
puts ex
|
66
|
-
ex
|
67
|
-
end
|
68
|
-
end
|
53
|
+
private
|
69
54
|
|
70
55
|
def observers
|
71
56
|
@observers ||= []
|
72
57
|
end
|
58
|
+
|
59
|
+
require_relative "pipe/filter"
|
60
|
+
require_relative "pipe/junction"
|
61
|
+
|
73
62
|
end
|
74
63
|
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
module Plumbing
|
2
|
+
module Spec
|
3
|
+
def self.modes(inline: true, async: true, threaded: true, &)
|
4
|
+
Plumbing.configure(mode: :inline, &) if inline
|
5
|
+
Sync { Plumbing.configure(mode: :async, &) } if async
|
6
|
+
Plumbing.configure(mode: thread_mode, &) if threaded
|
7
|
+
end
|
8
|
+
|
9
|
+
def self.thread_mode
|
10
|
+
defined?(::Rails) ? :threaded_rails : :threaded
|
11
|
+
end
|
12
|
+
private_class_method :thread_mode
|
13
|
+
end
|
14
|
+
end
|
data/lib/plumbing/version.rb
CHANGED
data/lib/plumbing.rb
CHANGED
@@ -6,10 +6,7 @@ module Plumbing
|
|
6
6
|
require_relative "plumbing/rubber_duck"
|
7
7
|
require_relative "plumbing/types"
|
8
8
|
require_relative "plumbing/error"
|
9
|
-
require_relative "plumbing/event"
|
10
9
|
require_relative "plumbing/pipe"
|
11
|
-
require_relative "plumbing/filter"
|
12
|
-
require_relative "plumbing/junction"
|
13
10
|
require_relative "plumbing/pipeline"
|
14
11
|
require_relative "plumbing/version"
|
15
12
|
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.4.
|
4
|
+
version: 0.4.6
|
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-20 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: globalid
|
@@ -43,12 +43,11 @@ files:
|
|
43
43
|
- lib/plumbing/actor/threaded.rb
|
44
44
|
- lib/plumbing/actor/transporter.rb
|
45
45
|
- lib/plumbing/config.rb
|
46
|
-
- lib/plumbing/custom_filter.rb
|
47
46
|
- lib/plumbing/error.rb
|
48
|
-
- lib/plumbing/event.rb
|
49
|
-
- lib/plumbing/filter.rb
|
50
|
-
- lib/plumbing/junction.rb
|
51
47
|
- lib/plumbing/pipe.rb
|
48
|
+
- lib/plumbing/pipe/custom_filter.rb
|
49
|
+
- lib/plumbing/pipe/filter.rb
|
50
|
+
- lib/plumbing/pipe/junction.rb
|
52
51
|
- lib/plumbing/pipeline.rb
|
53
52
|
- lib/plumbing/pipeline/contracts.rb
|
54
53
|
- lib/plumbing/pipeline/operations.rb
|
@@ -57,6 +56,7 @@ files:
|
|
57
56
|
- lib/plumbing/rubber_duck/object.rb
|
58
57
|
- lib/plumbing/rubber_duck/proxy.rb
|
59
58
|
- lib/plumbing/spec/become_matchers.rb
|
59
|
+
- lib/plumbing/spec/modes.rb
|
60
60
|
- lib/plumbing/types.rb
|
61
61
|
- lib/plumbing/version.rb
|
62
62
|
homepage: https://github.com/standard-procedure/plumbing
|
data/lib/plumbing/event.rb
DELETED