standard-procedure-plumbing 0.4.0 → 0.4.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a51cb6cf16fcc70cd0f6607a461e817c7021ed9f0de445e1f628ff1a8de9e489
4
- data.tar.gz: 992e53f3a79cb4f3597b9758e10953156ec9dda073eff97bc740e6050746fddc
3
+ metadata.gz: 77cd887e8bdfc09198bcf94c65b6d52014383b6691625f41e7301b52ae223adf
4
+ data.tar.gz: 4196496eff4bbc7b277592b22855dca3c29d87115130ca08a57796cb14436aae
5
5
  SHA512:
6
- metadata.gz: f4ff32c0bbd46d2433bcb051572914fa96378f5ffe6badd0fa77462565c058357b6803ff98afcf4534a739a571bb9084e39fe045ec4415dd084379d12e99e684
7
- data.tar.gz: 6a1d52c882d1edb1d5705383596e4b4c556f4f561770466a4bd18529e8e21630d3c522d323feb6fed8e0b87c22bc35b56075b47ae53a62a9176473061b0840a0
6
+ metadata.gz: 8e8614ed73bb33446e89294b7c94da3f39786a3c82704ac1c5187d5310edb5756db00bdfd38128743496b4af2bd1fdbde3027850a83406efdf76d5bd5fb3cf74
7
+ data.tar.gz: f5b1d8f95e6a275817348a95b3761d0dcb991d2e504b87dbcf2a35f1b4a4ac0f96bc0a90a70f59113644034291dd9af83f30adcfb30965bc4c35ec3dc5996502
data/README.md CHANGED
@@ -12,7 +12,7 @@ By default it is `:inline`, so every command or query is handled synchronously.
12
12
 
13
13
  `:threaded` mode handles tasks using a thread pool via [Concurrent Ruby](https://ruby-concurrency.github.io/concurrent-ruby/master/Concurrent/Promises.html)). Your code should include the "concurrent-ruby" gem in its bundle, as Plumbing does not load it by default.
14
14
 
15
- However, `:threaded` mode is not safe for Ruby on Rails applications. In this case, use `:rails` mode, which is identical to `:threaded`, except it wraps the tasks in the Rails executor. This ensures your actors do not interfere with the Rails framework. Note that the Concurrent Ruby's default `:io` scheduler will create extra threads at times of high demand, which may put pressure on the ActiveRecord database connection pool. A future version of plumbing will allow the thread pool to be adjusted with a maximum number of threads, preventing contention with the connection pool.
15
+ However, `:threaded` mode is not safe for Ruby on Rails applications. In this case, use `:threaded_rails` mode, which is identical to `:threaded`, except it wraps the tasks in the Rails executor. This ensures your actors do not interfere with the Rails framework. Note that the Concurrent Ruby's default `:io` scheduler will create extra threads at times of high demand, which may put pressure on the ActiveRecord database connection pool. A future version of plumbing will allow the thread pool to be adjusted with a maximum number of threads, preventing contention with the connection pool.
16
16
 
17
17
  The `timeout` setting is used when performing queries - it defaults to 30s.
18
18
 
@@ -165,40 +165,82 @@ Actors are different. Conceptually, each actor has it's own thread of execution
165
165
 
166
166
  This means each actor is only ever accessed by a single thread and the vast majority of concurrency issues are eliminated.
167
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 rails mode.
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
169
 
170
170
  When sending messages to an actor, this just works.
171
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 `#await` - or alternatively, wrap your call in `await { ... }`. `await` is added in to ruby's `Kernel` so it is available everywhere. 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.
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.
173
190
 
174
- The actor model does not eliminate every possible concurrency issue. If you use `await`, it is possible to deadlock yourself.
175
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.
176
192
 
177
- This potential deadlock only occurs if you use `await` and have actors that call back in to each other. If your objects are strictly layered, or you never use `await` (perhaps, instead using a Pipe to observe events), then this particular deadlock should not occur. However, just in case, every call to `await` has a timeout defaulting to 30s.
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.
178
194
 
179
195
  ### Inline actors
180
196
 
181
- Even though inline mode is not asynchronous, you must still use `await` to access the results from another actor. However, as deadlocks are impossible in a single thread, there is no timeout.
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.
182
198
 
183
199
  ### Async actors
184
200
 
185
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.
186
202
 
187
- ### Threaded actprs
203
+ ### Threaded actors
188
204
 
189
- Using threaded (or 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.
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.
190
206
 
191
207
  Firstly, when you pass parameters or return results between threads, those objects are "transported" across the boundaries.
192
- Most objects are `clone`d. 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.
193
- 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 `await` or you risk a deadlock. If you do pass a block or proc parameter, you should limit your actions to sending a message to other actors without awaiting the results.
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).
194
234
 
195
- (Note: we break that rule in the specs for the Pipe object - 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).
235
+ ### Constructing actors
196
236
 
197
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.
198
238
 
199
- 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.
239
+ ### Referencing actors
200
240
 
201
- Also be aware that if you use actors in one place, you need to use them everywhere - especially if you're using threads or ractors (coming soon). 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.
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.
202
244
 
203
245
  ### Usage
204
246
 
@@ -208,17 +250,17 @@ Also be aware that if you use actors in one place, you need to use them everywhe
208
250
  require "plumbing"
209
251
 
210
252
  class Employee
211
- attr_reader :name, :job_title
212
-
213
253
  include Plumbing::Actor
214
- query :name, :job_title, :greet_slowly
215
- command :promote
254
+ async :name, :job_title, :greet_slowly, :promote
255
+ attr_reader :name, :job_title
216
256
 
217
257
  def initialize(name)
218
258
  @name = name
219
259
  @job_title = "Sales assistant"
220
260
  end
221
261
 
262
+ private
263
+
222
264
  def promote
223
265
  sleep 0.5
224
266
  @job_title = "Sales manager"
@@ -237,16 +279,16 @@ Also be aware that if you use actors in one place, you need to use them everywhe
237
279
  await { @person.job_title }
238
280
  # => "Sales assistant"
239
281
 
240
- # `greet_slowly` is a query so will block until a response is received
282
+ # by using `await`, we will block until `greet_slowly` has returned a value
241
283
  await { @person.greet_slowly }
242
284
  # => "H E L L O"
243
285
 
244
- # we're not awaiting the result, so this will run in the background (unless we're using inline mode)
286
+ # this time, we're not awaiting the result, so this will run in the background (unless we're using inline mode)
245
287
  @person.greet_slowly
246
288
 
247
- # This will run in the background
289
+ # this will run in the background
248
290
  @person.promote
249
- # this will block, as we wait for the result from #job_title and #job_title will not run until after #promote has completed
291
+ # this will block - it will not return until the previous calls, #greet_slowly, #promote, and this call to #job_title have completed
250
292
  await { @person.job_title }
251
293
  # => "Sales manager"
252
294
  ```
@@ -302,12 +344,17 @@ Pipes are implemented as actors, meaning that event notifications can be dispatc
302
344
  end
303
345
 
304
346
  def received event
305
- # store this event into our buffer
306
- @events << event
307
- # if this is the third event we've received then clear the buffer and broadcast the latest event
308
- if @events.count >= 3
309
- @events.clear
310
- self << 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
311
358
  end
312
359
  end
313
360
  end
@@ -344,31 +391,6 @@ Pipes are implemented as actors, meaning that event notifications can be dispatc
344
391
  @second_source.notify "two"
345
392
  # => "two"
346
393
  ```
347
-
348
- [Dispatching events asynchronously (using Fibers)](/spec/examples/pipe_spec.rb):
349
- ```ruby
350
- require "plumbing"
351
- require "async"
352
-
353
- Plumbing.configure mode: :async
354
-
355
- Sync do
356
- @first_source = Plumbing::Pipe.start
357
- @second_source = Plumbing::Pipe.start
358
-
359
- @junction = Plumbing::Junction.start @first_source, @second_source
360
-
361
- @filter = Plumbing::Filter.start source: @junction do |event|
362
- %w[one-one two-two].include? event.type
363
- end
364
-
365
- @first_source.notify "one-one"
366
- @first_source.notify "one-two"
367
- @second_source.notify "two-one"
368
- @second_source.notify "two-two"
369
- end
370
- ```
371
-
372
394
  ## Plumbing::RubberDuck - duck types and type-casts
373
395
 
374
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.
@@ -447,6 +469,8 @@ You can also use the same `@object.as type` to type-check instances against modu
447
469
 
448
470
  ## Installation
449
471
 
472
+ Note: this gem is licensed under the [LGPL](/LICENCE). This may or may not make it unsuitable for use by you or your company.
473
+
450
474
  Install the gem and add to the application's Gemfile by executing:
451
475
 
452
476
  ```sh
@@ -14,15 +14,24 @@ module Plumbing
14
14
  end
15
15
 
16
16
  # Send the message to the target and wrap the result
17
- def send_message message_name, *args, &block
17
+ def send_message(message_name, *, **, &)
18
18
  task = @semaphore.async do
19
- @target.send message_name, *args, &block
19
+ @target.send(message_name, *, **, &)
20
20
  end
21
21
  Result.new(task)
22
22
  end
23
23
 
24
+ def safely(&)
25
+ send_message(:perform_safely, &)
26
+ nil
27
+ end
28
+
29
+ def within_actor? = true
30
+
31
+ def stop = nil
32
+
24
33
  Result = Data.define(:task) do
25
- def await
34
+ def value
26
35
  Timeout.timeout(Plumbing::Actor.timeout) do
27
36
  task.wait
28
37
  end
@@ -6,15 +6,24 @@ 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
- value = @target.send(message_name, *, &)
9
+ def send_message(message_name, *, **, &)
10
+ value = @target.send(message_name, *, **, &)
11
11
  Result.new(value)
12
12
  rescue => ex
13
13
  Result.new(ex)
14
14
  end
15
15
 
16
- Result = Data.define(:value) do
17
- def await = value.is_a?(Exception) ? raise(value) : value
16
+ def safely(&)
17
+ send_message(:perform_safely, &)
18
+ nil
19
+ end
20
+
21
+ def within_actor? = true
22
+
23
+ def stop = nil
24
+
25
+ Result = Data.define(:result) do
26
+ def value = result.is_a?(Exception) ? raise(result) : result
18
27
  end
19
28
  private_constant :Result
20
29
  end
@@ -2,7 +2,8 @@ module Plumbing
2
2
  module Actor
3
3
  ::Kernel.class_eval do
4
4
  def await &block
5
- block.call.await
5
+ result = block.call
6
+ result.respond_to?(:value) ? result.send(:value) : result
6
7
  end
7
8
  end
8
9
  end
@@ -12,28 +12,41 @@ module Plumbing
12
12
  def initialize target
13
13
  @target = target
14
14
  @queue = Concurrent::Array.new
15
+ @mutex = Thread::Mutex.new
15
16
  end
16
17
 
17
18
  # Send the message to the target and wrap the result
18
- def send_message message_name, *args, &block
19
- Message.new(@target, message_name, Plumbing::Actor.transporter.marshal(*args), block, Concurrent::MVar.new).tap do |message|
20
- @queue << message
21
- send_messages if @queue.size == 1
19
+ def send_message(message_name, *args, **params, &block)
20
+ Message.new(@target, message_name, Plumbing::Actor.transporter.marshal(*args, **params), block, Concurrent::MVar.new).tap do |message|
21
+ @mutex.synchronize do
22
+ @queue << message
23
+ send_messages if @queue.any?
24
+ end
22
25
  end
23
26
  end
24
27
 
25
- protected
28
+ def safely(&)
29
+ send_message(:perform_safely, &)
30
+ nil
31
+ end
32
+
33
+ def within_actor? = @mutex.owned?
26
34
 
27
- def future(&)
28
- Concurrent::Promises.future(&)
35
+ def stop
36
+ within_actor? ? @queue.clear : @mutex.synchronize { @queue.clear }
29
37
  end
30
38
 
39
+ protected
40
+
41
+ def future(&) = Concurrent::Promises.future(&)
42
+
31
43
  private
32
44
 
33
45
  def send_messages
34
46
  future do
35
- while (message = @queue.shift)
36
- message.call
47
+ @mutex.synchronize do
48
+ message = @queue.shift
49
+ message&.call
37
50
  end
38
51
  end
39
52
  end
@@ -42,12 +55,13 @@ module Plumbing
42
55
  def call
43
56
  args = Plumbing::Actor.transporter.unmarshal(*packed_args)
44
57
  value = target.send message_name, *args, &unsafe_block
58
+
45
59
  result.put Plumbing::Actor.transporter.marshal(value)
46
60
  rescue => ex
47
61
  result.put ex
48
62
  end
49
63
 
50
- def await
64
+ def value
51
65
  value = Plumbing::Actor.transporter.unmarshal(*result.take(Plumbing.config.timeout)).first
52
66
  raise value if value.is_a? Exception
53
67
  value
@@ -3,6 +3,15 @@ require_relative "actor/inline"
3
3
 
4
4
  module Plumbing
5
5
  module Actor
6
+ def safely(&)
7
+ proxy.safely(&)
8
+ nil
9
+ end
10
+
11
+ def within_actor? = proxy.within_actor?
12
+
13
+ def stop = proxy.stop
14
+
6
15
  def self.included base
7
16
  base.extend ClassMethods
8
17
  end
@@ -10,8 +19,11 @@ module Plumbing
10
19
  module ClassMethods
11
20
  # Create a new actor instance and build a proxy for it using the current mode
12
21
  # @return [Object] the proxy for the actor instance
13
- def start(*, **, &)
14
- build_proxy_for(new(*, **, &))
22
+ def start(...)
23
+ instance = new(...)
24
+ build_proxy_for(instance).tap do |proxy|
25
+ instance.send :"proxy=", proxy
26
+ end
15
27
  end
16
28
 
17
29
  # Define the async messages that this actor can respond to
@@ -41,7 +53,7 @@ module Plumbing
41
53
  inline: "Plumbing::Actor::Inline",
42
54
  async: "Plumbing::Actor::Async",
43
55
  threaded: "Plumbing::Actor::Threaded",
44
- rails: "Plumbing::Actor::Rails"
56
+ threaded_rails: "Plumbing::Actor::Rails"
45
57
  }.freeze
46
58
  private_constant :PROXY_BASE_CLASSES
47
59
 
@@ -52,12 +64,30 @@ module Plumbing
52
64
  def build_proxy_class
53
65
  Class.new(proxy_base_class).tap do |proxy_class|
54
66
  async_messages.each do |message|
55
- proxy_class.define_method message do |*args, &block|
56
- send_message(message, *args, &block)
67
+ proxy_class.define_method message do |*args, **params, &block|
68
+ send_message(message, *args, **params, &block)
57
69
  end
58
70
  end
59
71
  end
60
72
  end
61
73
  end
74
+
75
+ private
76
+
77
+ def proxy= proxy
78
+ @proxy = proxy
79
+ end
80
+
81
+ def proxy = @proxy
82
+ alias_method :as_actor, :proxy
83
+ alias_method :async, :proxy
84
+
85
+ def perform_safely(&)
86
+ instance_eval(&)
87
+ nil
88
+ rescue => ex
89
+ puts ex
90
+ nil
91
+ end
62
92
  end
63
93
  end
@@ -5,16 +5,17 @@ module Plumbing
5
5
  # @param sources [Array<Plumbing::Observable>] the sources which will be joined and relayed
6
6
  def initialize *sources
7
7
  super()
8
- @sources = sources.collect { |source| add(source) }
8
+ sources.each { |source| add(source) }
9
9
  end
10
10
 
11
11
  private
12
12
 
13
13
  def add source
14
14
  source.as(Observable).add_observer do |event|
15
- dispatch event
15
+ safely do
16
+ dispatch event
17
+ end
16
18
  end
17
- source
18
19
  end
19
20
  end
20
21
  end
data/lib/plumbing/pipe.rb CHANGED
@@ -49,6 +49,7 @@ module Plumbing
49
49
  # Subclasses should override this to perform their own shutdown routines and call `super` to ensure everything is tidied up
50
50
  def shutdown
51
51
  observers.clear
52
+ stop
52
53
  end
53
54
 
54
55
  protected
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Plumbing
4
- VERSION = "0.4.0"
4
+ VERSION = "0.4.2"
5
5
  end
@@ -9,12 +9,13 @@ require "rspec/expectations"
9
9
  #
10
10
  RSpec::Matchers.define :become_equal_to do
11
11
  match do |expected|
12
+ max = Plumbing.config.timeout * 10
12
13
  counter = 0
13
14
  matched = false
14
- while (counter < 50) && (matched == false)
15
- matched = true if (@result = block_arg.call) == expected
15
+ while (counter < max) && (matched == false)
16
16
  sleep 0.1
17
17
  counter += 1
18
+ matched = true if (@result = block_arg.call) == expected
18
19
  end
19
20
  matched
20
21
  end
@@ -1,6 +1,32 @@
1
1
  require "spec_helper"
2
+ require "plumbing/actor/async"
3
+ require "plumbing/actor/threaded"
2
4
 
3
5
  RSpec.shared_examples "an example actor" do |runs_in_background|
6
+ # standard:disable Lint/ConstantDefinitionInBlock
7
+ class Employee
8
+ include Plumbing::Actor
9
+ async :name, :job_title, :greet_slowly, :promote
10
+
11
+ def initialize(name)
12
+ @name = name
13
+ @job_title = "Sales assistant"
14
+ end
15
+
16
+ attr_reader :name, :job_title
17
+
18
+ def promote
19
+ sleep 0.5
20
+ @job_title = "Sales manager"
21
+ end
22
+
23
+ def greet_slowly
24
+ sleep 0.2
25
+ "H E L L O"
26
+ end
27
+ end
28
+ # standard:enable Lint/ConstantDefinitionInBlock
29
+
4
30
  it "queries an object" do
5
31
  @person = Employee.start "Alice"
6
32
 
@@ -18,41 +44,20 @@ RSpec.shared_examples "an example actor" do |runs_in_background|
18
44
 
19
45
  expect(Time.now - @time).to be < 0.1 if runs_in_background
20
46
  expect(Time.now - @time).to be > 0.1 if !runs_in_background
47
+ ensure
48
+ @person.stop
21
49
  end
22
50
 
23
51
  it "commands an object" do
24
52
  @person = Employee.start "Alice"
25
53
  @person.promote
26
- @job_title = await { @person.job_title }
27
- expect(@job_title).to eq "Sales manager"
54
+ expect(@person.job_title.value).to eq "Sales manager"
55
+ ensure
56
+ @person.stop
28
57
  end
29
58
  end
30
59
 
31
60
  RSpec.describe "Actor example: " do
32
- # standard:disable Lint/ConstantDefinitionInBlock
33
- class Employee
34
- include Plumbing::Actor
35
- async :name, :job_title, :greet_slowly, :promote
36
-
37
- def initialize(name)
38
- @name = name
39
- @job_title = "Sales assistant"
40
- end
41
-
42
- attr_reader :name, :job_title
43
-
44
- def promote
45
- sleep 0.5
46
- @job_title = "Sales manager"
47
- end
48
-
49
- def greet_slowly
50
- sleep 0.2
51
- "H E L L O"
52
- end
53
- end
54
- # standard:enable Lint/ConstantDefinitionInBlock
55
-
56
61
  context "inline mode" do
57
62
  around :example do |example|
58
63
  Plumbing.configure mode: :inline, &example
@@ -0,0 +1,43 @@
1
+ require "spec_helper"
2
+ require "plumbing/actor/async"
3
+ require "plumbing/actor/threaded"
4
+
5
+ RSpec.describe "await" do
6
+ # standard:disable Lint/ConstantDefinitionInBlock
7
+ class Person
8
+ include Plumbing::Actor
9
+ async :name
10
+ def initialize name
11
+ @name = name
12
+ end
13
+ attr_reader :name
14
+ end
15
+ # standard:enable Lint/ConstantDefinitionInBlock
16
+
17
+ [:inline, :async, :threaded].each do |mode|
18
+ context "#{mode} mode" do
19
+ around :example do |example|
20
+ Sync do
21
+ Plumbing.configure mode: mode, &example
22
+ end
23
+ end
24
+
25
+ it "awaits a result from the actor directly" do
26
+ @person = Person.start "Alice"
27
+
28
+ expect(@person.name.value).to eq "Alice"
29
+ end
30
+
31
+ it "uses a block to await the result from the actor" do
32
+ @person = Person.start "Alice"
33
+
34
+ expect(await { @person.name }).to eq "Alice"
35
+ end
36
+
37
+ it "uses a block to immediately access non-actor objects" do
38
+ @person = "Bob"
39
+ expect(await { @person }).to eq "Bob"
40
+ end
41
+ end
42
+ end
43
+ end
@@ -1,5 +1,7 @@
1
1
  require "spec_helper"
2
2
  require "async"
3
+ require "plumbing/actor/async"
4
+ require "plumbing/actor/threaded"
3
5
 
4
6
  RSpec.describe "Pipe examples" do
5
7
  it "observes events" do
@@ -42,17 +44,19 @@ RSpec.describe "Pipe examples" do
42
44
  end
43
45
 
44
46
  def received event
45
- @events << event
46
- if @events.count >= 3
47
- @events.clear
48
- self << event
47
+ safely do
48
+ @events << event
49
+ if @events.count >= 3
50
+ @events.clear
51
+ self << event
52
+ end
49
53
  end
50
54
  end
51
55
  end
52
56
  # standard:enable Lint/ConstantDefinitionInBlock
53
57
 
54
58
  @source = Plumbing::Pipe.start
55
- @filter = EveryThirdEvent.new(source: @source)
59
+ @filter = EveryThirdEvent.start(source: @source)
56
60
 
57
61
  @result = []
58
62
  @filter.add_observer do |event|
@@ -83,7 +87,7 @@ RSpec.describe "Pipe examples" do
83
87
  expect(@result).to eq ["one", "two"]
84
88
  end
85
89
 
86
- it "dispatches events asynchronously using fibers" do
90
+ it "dispatches events asynchronously using async" do
87
91
  Plumbing.configure mode: :async do
88
92
  Sync do
89
93
  @first_source = Plumbing::Pipe.start
@@ -106,4 +110,36 @@ RSpec.describe "Pipe examples" do
106
110
  end
107
111
  end
108
112
  end
113
+
114
+ it "dispatches events asynchronously using threads" do
115
+ Plumbing.configure mode: :threaded do
116
+ @result = []
117
+
118
+ @first_source = Plumbing::Pipe.start
119
+ @second_source = Plumbing::Pipe.start
120
+ @junction = Plumbing::Junction.start @first_source, @second_source
121
+
122
+ @filter = Plumbing::Filter.start source: @junction do |event|
123
+ %w[one-one two-two].include? event.type
124
+ end
125
+ await do
126
+ @filter.add_observer do |event|
127
+ puts "observing #{event.type}"
128
+ @result << event.type
129
+ end
130
+ end
131
+
132
+ @first_source.notify "one-one"
133
+ @first_source.notify "one-two"
134
+ @second_source.notify "two-one"
135
+ @second_source.notify "two-two"
136
+
137
+ expect(["one-one", "two-two"]).to become_equal_to { @result.sort }
138
+ ensure
139
+ @first_source.shutdown
140
+ @second_source.shutdown
141
+ @junction.shutdown
142
+ @filter.shutdown
143
+ end
144
+ end
109
145
  end
@@ -98,11 +98,13 @@ RSpec.shared_examples "a pipe" do
98
98
 
99
99
  it "shuts down the pipe" do
100
100
  @pipe = described_class.start
101
+ @results = []
101
102
  @observer = ->(event) { @results << event }
102
103
  @pipe.add_observer @observer
103
104
 
104
105
  @pipe.shutdown
105
-
106
- expect(await { @pipe.is_observer?(@observer) }).to eq false
106
+ @pipe.notify "ignore_me"
107
+ sleep 0.2
108
+ expect(@results).to be_empty
107
109
  end
108
110
  end
@@ -15,6 +15,7 @@ RSpec.describe Plumbing::Actor::Transporter do
15
15
  end
16
16
  end
17
17
  # standard:enable Lint/ConstantDefinitionInBlock
18
+
18
19
  before do
19
20
  GlobalID.app = "rspec"
20
21
  GlobalID::Locator.use :rspec do |gid, options|
@@ -51,32 +51,176 @@ RSpec.describe Plumbing::Actor do
51
51
  raise "I'm a failure"
52
52
  end
53
53
  end
54
- # standard:enable Lint/ConstantDefinitionInBlock
55
54
 
56
- it "knows which async messages are understood" do
57
- expect(Counter.async_messages).to eq [:name, :count, :slow_query, :slowly_increment, :raises_error]
55
+ class WhoAmI
56
+ include Plumbing::Actor
57
+ async :me_as_actor, :me_as_self
58
+
59
+ private
60
+
61
+ def me_as_actor = as_actor
62
+
63
+ def me_as_self = self
64
+
65
+ def prepare = @calling_thread = Thread.current
66
+
67
+ def check = @calling_thread == Thread.current
58
68
  end
59
69
 
60
- it "reuses existing proxy classes" do
61
- @counter = Counter.start "inline counter", initial_value: 100
62
- @proxy_class = @counter.class
70
+ class Actor
71
+ include Plumbing::Actor
72
+ async :get_object_id, :get_object
63
73
 
64
- @counter = Counter.start "another inline counter", initial_value: 200
65
- expect(@counter.class).to eq @proxy_class
74
+ private def get_object_id(record) = record.object_id
75
+ private def get_object(record) = record
66
76
  end
67
77
 
68
- it "includes commands and queries from the superclass" do
69
- expect(StepCounter.async_messages).to eq [:name, :count, :slow_query, :slowly_increment, :raises_error, :step_value]
78
+ class SafetyCheck
79
+ include Plumbing::Actor
80
+ async :called_from_actor_thread?
81
+
82
+ def initialize tester
83
+ @tester = tester
84
+ @called_from_actor_thread = false
85
+ configure_safety_check
86
+ end
70
87
 
71
- @step_counter = StepCounter.start "step counter", initial_value: 100, step_value: 10
88
+ private
72
89
 
73
- expect(@step_counter.count.await).to eq 100
74
- expect(@step_counter.step_value.await).to eq 10
75
- @step_counter.slowly_increment
76
- expect(@step_counter.count.await).to eq 110
90
+ def called_from_actor_thread? = @called_from_actor_thread
91
+
92
+ def configure_safety_check
93
+ @tester.on_safety_check do
94
+ safely do
95
+ @called_from_actor_thread = proxy.within_actor?
96
+ end
97
+ end
98
+ end
77
99
  end
78
100
 
79
- context "inline" do
101
+ class Tester
102
+ include Plumbing::Actor
103
+ async :on_safety_check, :do_safety_check
104
+
105
+ def initialize
106
+ @on_safety_check = nil
107
+ end
108
+
109
+ private
110
+
111
+ def on_safety_check(&block) = @on_safety_check = block
112
+
113
+ def do_safety_check = @on_safety_check&.call
114
+ end
115
+
116
+ class ParameterHandler
117
+ include Plumbing::Actor
118
+ async :set_values, :args, :params, :block
119
+ attr_reader :args, :params, :block
120
+
121
+ def initialize
122
+ @args = nil
123
+ @params = nil
124
+ @block = nil
125
+ end
126
+
127
+ private
128
+
129
+ def set_values *args, **params, &block
130
+ @args = args
131
+ @params = params
132
+ @block = block
133
+ end
134
+ end
135
+ # standard:enable Lint/ConstantDefinitionInBlock
136
+
137
+ [:inline, :async, :threaded].each do |mode|
138
+ context "In #{mode} mode" do
139
+ it "knows which async messages are understood" do
140
+ expect(Counter.async_messages).to eq [:name, :count, :slow_query, :slowly_increment, :raises_error]
141
+ end
142
+
143
+ it "reuses existing proxy classes" do
144
+ @counter = Counter.start "inline counter", initial_value: 100
145
+ @proxy_class = @counter.class
146
+
147
+ @counter = Counter.start "another inline counter", initial_value: 200
148
+ expect(@counter.class).to eq @proxy_class
149
+ end
150
+
151
+ it "includes async messages from the superclass" do
152
+ expect(StepCounter.async_messages).to eq [:name, :count, :slow_query, :slowly_increment, :raises_error, :step_value]
153
+
154
+ @step_counter = StepCounter.start "step counter", initial_value: 100, step_value: 10
155
+
156
+ expect(@step_counter.count.value).to eq 100
157
+ expect(@step_counter.step_value.value).to eq 10
158
+ @step_counter.slowly_increment
159
+ expect(@step_counter.count.value).to eq 110
160
+ end
161
+
162
+ it "can access its own proxy" do
163
+ @actor = WhoAmI.start
164
+
165
+ expect(await { @actor.me_as_self }).to_not eq @actor
166
+ expect(await { @actor.me_as_actor }).to eq @actor
167
+ end
168
+ it "sends a single positional parameter" do
169
+ @parameter_handler = ParameterHandler.start
170
+
171
+ @parameter_handler.set_values "this"
172
+ expect(await { @parameter_handler.args }).to eq ["this"]
173
+ end
174
+
175
+ it "sends multiple positional parameters" do
176
+ @parameter_handler = ParameterHandler.start
177
+
178
+ @parameter_handler.set_values "this", "that"
179
+ expect(await { @parameter_handler.args }).to eq ["this", "that"]
180
+ end
181
+
182
+ it "sends keyword parameters" do
183
+ @parameter_handler = ParameterHandler.start
184
+
185
+ @parameter_handler.set_values something: "for nothing", cat: "dog", number: 123
186
+ expect(await { @parameter_handler.params }).to eq({something: "for nothing", cat: "dog", number: 123})
187
+ end
188
+
189
+ it "sends a mix of positional and keyword parameters" do
190
+ @parameter_handler = ParameterHandler.start
191
+
192
+ @parameter_handler.set_values "what do you say", 123, something: "for nothing"
193
+ expect(await { @parameter_handler.args }).to eq ["what do you say", 123]
194
+ expect(await { @parameter_handler.params }).to eq({something: "for nothing"})
195
+ end
196
+
197
+ it "sends a block parameter" do
198
+ @parameter_handler = ParameterHandler.start
199
+
200
+ @parameter_handler.set_values do
201
+ "HELLO"
202
+ end
203
+
204
+ @block = await { @parameter_handler.block }
205
+ expect(@block.call).to eq "HELLO"
206
+ end
207
+
208
+ it "sends a mix of positional and keyword parameters with a block" do
209
+ @parameter_handler = ParameterHandler.start
210
+
211
+ @parameter_handler.set_values "what do you say", 123, something: "for nothing" do
212
+ "BOOM"
213
+ end
214
+
215
+ expect(await { @parameter_handler.args }).to eq ["what do you say", 123]
216
+ expect(await { @parameter_handler.params }).to eq({something: "for nothing"})
217
+ @block = await { @parameter_handler.block }
218
+ expect(@block.call).to eq "BOOM"
219
+ end
220
+ end
221
+ end
222
+
223
+ context "Inline mode only" do
80
224
  around :example do |example|
81
225
  Plumbing.configure mode: :inline, &example
82
226
  end
@@ -85,11 +229,11 @@ RSpec.describe Plumbing::Actor do
85
229
  @counter = Counter.start "inline counter", initial_value: 100
86
230
  @time = Time.now
87
231
 
88
- expect(@counter.name.await).to eq "inline counter"
89
- expect(@counter.count.await).to eq 100
232
+ expect(@counter.name.value).to eq "inline counter"
233
+ expect(@counter.count.value).to eq 100
90
234
  expect(Time.now - @time).to be < 0.1
91
235
 
92
- expect(@counter.slow_query.await).to eq 100
236
+ expect(@counter.slow_query.value).to eq 100
93
237
  expect(Time.now - @time).to be > 0.1
94
238
  end
95
239
 
@@ -99,13 +243,22 @@ RSpec.describe Plumbing::Actor do
99
243
 
100
244
  @counter.slowly_increment
101
245
 
102
- expect(@counter.count.await).to eq 101
246
+ expect(@counter.count.value).to eq 101
103
247
  expect(Time.now - @time).to be > 0.1
104
248
  end
249
+
250
+ it "can safely access its own data" do
251
+ @tester = Tester.start
252
+ @safety_check = SafetyCheck.start @tester
253
+
254
+ @tester.do_safety_check
255
+
256
+ expect(true).to become_equal_to { @safety_check.called_from_actor_thread?.value }
257
+ end
105
258
  end
106
259
 
107
260
  [:threaded, :async].each do |mode|
108
- context mode.to_s do
261
+ context "Asynchronously (#{mode})" do
109
262
  around :example do |example|
110
263
  Sync do
111
264
  Plumbing.configure mode: mode, &example
@@ -116,12 +269,14 @@ RSpec.describe Plumbing::Actor do
116
269
  @counter = Counter.start "async counter", initial_value: 100
117
270
  @time = Time.now
118
271
 
119
- expect(@counter.name.await).to eq "async counter"
120
- expect(@counter.count.await).to eq 100
272
+ expect(@counter.name.value).to eq "async counter"
273
+ expect(@counter.count.value).to eq 100
121
274
  expect(Time.now - @time).to be < 0.1
122
275
 
123
- expect(@counter.slow_query.await).to eq 100
276
+ expect(@counter.slow_query.value).to eq 100
124
277
  expect(Time.now - @time).to be > 0.1
278
+ ensure
279
+ @counter.stop
125
280
  end
126
281
 
127
282
  it "performs queries ignoring the response and returning immediately" do
@@ -131,6 +286,8 @@ RSpec.describe Plumbing::Actor do
131
286
  @counter.slow_query
132
287
 
133
288
  expect(Time.now - @time).to be < 0.1
289
+ ensure
290
+ @counter.stop
134
291
  end
135
292
 
136
293
  it "performs commands in the background and returning immediately" do
@@ -141,29 +298,42 @@ RSpec.describe Plumbing::Actor do
141
298
  expect(Time.now - @time).to be < 0.1
142
299
 
143
300
  # wait for the background task to complete
144
- expect(101).to become_equal_to { @counter.count.await }
301
+ expect(101).to become_equal_to { @counter.count.value }
145
302
  expect(Time.now - @time).to be > 0.1
303
+ ensure
304
+ @counter.stop
146
305
  end
147
306
 
148
307
  it "re-raises exceptions when checking the result" do
149
308
  @counter = Counter.start "failure"
150
309
 
151
- expect { @counter.raises_error.await }.to raise_error "I'm an error"
310
+ expect { @counter.raises_error.value }.to raise_error "I'm an error"
311
+ ensure
312
+ @counter.stop
152
313
  end
153
314
 
154
315
  it "does not raise exceptions if ignoring the result" do
155
316
  @counter = Counter.start "failure"
156
317
 
157
318
  expect { @counter.raises_error }.not_to raise_error
319
+ ensure
320
+ @counter.stop
158
321
  end
159
322
  end
160
323
  end
161
324
 
162
- context "threaded" do
325
+ context "Threaded mode only" do
163
326
  around :example do |example|
164
327
  Plumbing.configure mode: :threaded, &example
165
328
  end
166
329
 
330
+ before do
331
+ GlobalID.app = "rspec"
332
+ GlobalID::Locator.use :rspec do |gid, options|
333
+ Record.new gid.model_id
334
+ end
335
+ end
336
+
167
337
  # standard:disable Lint/ConstantDefinitionInBlock
168
338
  class Record
169
339
  include GlobalID::Identification
@@ -176,33 +346,41 @@ RSpec.describe Plumbing::Actor do
176
346
  other.id == @id
177
347
  end
178
348
  end
179
-
180
- class Actor
181
- include Plumbing::Actor
182
- async :get_object_id, :get_object
183
-
184
- private def get_object_id(record) = record.object_id
185
- private def get_object(record) = record
186
- end
187
349
  # standard:enable Lint/ConstantDefinitionInBlock
188
350
 
189
351
  it "packs and unpacks arguments when sending them across threads" do
190
352
  @actor = Actor.start
191
353
  @record = Record.new "999"
192
354
 
193
- @object_id = @actor.get_object_id(@record).await
355
+ @object_id = @actor.get_object_id(@record).value
194
356
 
195
357
  expect(@object_id).to_not eq @record.object_id
358
+ ensure
359
+ @actor.stop
196
360
  end
197
361
 
198
362
  it "packs and unpacks results when sending them across threads" do
199
363
  @actor = Actor.start
200
364
  @record = Record.new "999"
201
365
 
202
- @object = @actor.get_object(@record).await
366
+ @object = @actor.get_object(@record).value
203
367
 
204
368
  expect(@object.id).to eq @record.id
205
369
  expect(@object.object_id).to_not eq @record.object_id
370
+ ensure
371
+ @actor.stop
372
+ end
373
+
374
+ it "can safely access its own data" do
375
+ @tester = Tester.start
376
+ @safety_check = SafetyCheck.start @tester
377
+
378
+ @tester.do_safety_check
379
+
380
+ expect(true).to become_equal_to { @safety_check.called_from_actor_thread?.value }
381
+ ensure
382
+ @tester.stop
383
+ @safety_check.stop
206
384
  end
207
385
  end
208
386
  end
@@ -15,7 +15,7 @@ RSpec.describe Plumbing::CustomFilter do
15
15
  # standard:enable Lint/ConstantDefinitionInBlock
16
16
 
17
17
  @pipe = Plumbing::Pipe.start
18
- @filter = ReversingFilter.new(source: @pipe)
18
+ @filter = ReversingFilter.start(source: @pipe)
19
19
  @result = []
20
20
  @filter.add_observer do |event|
21
21
  @result << event.type
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.0
4
+ version: 0.4.2
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-15 00:00:00.000000000 Z
11
+ date: 2024-09-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: globalid
@@ -59,6 +59,7 @@ files:
59
59
  - lib/plumbing/version.rb
60
60
  - spec/become_equal_to_matcher.rb
61
61
  - spec/examples/actor_spec.rb
62
+ - spec/examples/await_spec.rb
62
63
  - spec/examples/pipe_spec.rb
63
64
  - spec/examples/pipeline_spec.rb
64
65
  - spec/examples/rubber_duck_spec.rb
@@ -95,7 +96,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
95
96
  - !ruby/object:Gem::Version
96
97
  version: '0'
97
98
  requirements: []
98
- rubygems_version: 3.5.12
99
+ rubygems_version: 3.5.17
99
100
  signing_key:
100
101
  specification_version: 4
101
102
  summary: Plumbing - various pipelines for your ruby application