standard-procedure-plumbing 0.3.2 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 54681502a7df050136406e706c4fd6b9d9bffea81e44c151e969379e6803c532
4
- data.tar.gz: ed03cbc9476d43822792fd6a4788b0c2e6c629545056063342f15c0861dc7dd0
3
+ metadata.gz: a51cb6cf16fcc70cd0f6607a461e817c7021ed9f0de445e1f628ff1a8de9e489
4
+ data.tar.gz: 992e53f3a79cb4f3597b9758e10953156ec9dda073eff97bc740e6050746fddc
5
5
  SHA512:
6
- metadata.gz: 1387fdd83a547157541f444ccf2dcb9465c261616f424dc530ef4c9c13dda52214c21c4d50c99eb825e4827268f4892d62a29d212af9be21bc5bd65dd83422a4
7
- data.tar.gz: c0dcbde3271e4d34cd41d16bda9c488ee40bd838ac0f006fd46bd23eb2c0c1cc4ea18d86236de1d8e87f6b5b97fc5dc728bedbd5a3e354fa09886094866404c9
6
+ metadata.gz: f4ff32c0bbd46d2433bcb051572914fa96378f5ffe6badd0fa77462565c058357b6803ff98afcf4534a739a571bb9084e39fe045ec4415dd084379d12e99e684
7
+ data.tar.gz: 6a1d52c882d1edb1d5705383596e4b4c556f4f561770466a4bd18529e8e21630d3c522d323feb6fed8e0b87c22bc35b56075b47ae53a62a9176473061b0840a0
data/README.md CHANGED
@@ -1,55 +1,61 @@
1
1
  # Plumbing
2
2
 
3
- ## Configuration
3
+ Actors, Observers and Data Pipelines.
4
4
 
5
- The most important configuration setting is the `mode`, which governs how messages are handled by Valves.
5
+ ## Configuration
6
6
 
7
- By default it is `:inline`, so every command or query is handled synchronously.
7
+ The most important configuration setting is the `mode`, which governs how background tasks are handled.
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
+ By default it is `:inline`, so every command or query is handled synchronously. This is the ruby behaviour you know and love (although see the section on `await` below).
10
10
 
11
- The `timeout` setting is used when performing queries - it defaults to 30s.
11
+ `:async` mode handles tasks 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.
12
+
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
+
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.
16
+
17
+ The `timeout` setting is used when performing queries - it defaults to 30s.
12
18
 
13
19
  ```ruby
14
20
  require "plumbing"
15
- puts Plumbing.config.mode
21
+ puts Plumbing.config.mode
16
22
  # => :inline
17
23
 
18
24
  Plumbing.configure mode: :async, timeout: 10
19
25
 
20
- puts Plumbing.config.mode
26
+ puts Plumbing.config.mode
21
27
  # => :async
22
28
  ```
23
29
 
24
- If you are running a test suite, you can temporarily update the configuration by passing a block.
30
+ If you are running a test suite, you can temporarily update the configuration by passing a block.
25
31
 
26
32
  ```ruby
27
33
  require "plumbing"
28
- puts Plumbing.config.mode
34
+ puts Plumbing.config.mode
29
35
  # => :inline
30
36
 
31
- Plumbing.configure mode: :async do
32
- puts Plumbing.config.mode
37
+ Plumbing.configure mode: :async do
38
+ puts Plumbing.config.mode
33
39
  # => :async
34
40
  first_test
35
41
  second_test
36
42
  end
37
43
 
38
- puts Plumbing.config.mode
44
+ puts Plumbing.config.mode
39
45
  # => :inline
40
46
  ```
41
47
 
42
48
  ## Plumbing::Pipeline - transform data through a pipeline
43
49
 
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.
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.
45
51
 
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.
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.
49
55
 
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.
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.
51
57
 
52
- You can also verify that the output generated is as expected by defining a `post_condition`.
58
+ You can also verify that the output generated is as expected by defining a `post_condition`.
53
59
 
54
60
  ### Usage:
55
61
 
@@ -116,7 +122,7 @@ You can also verify that the output generated is as expected by defining a `post
116
122
  end
117
123
 
118
124
  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
125
+ # => Hello Alice - I will now send a load of annoying marketing messages to alice@example.com
120
126
 
121
127
  SayHello.new.call(some: "other data")
122
128
  # => Plumbing::PreConditionError
@@ -150,36 +156,61 @@ You can also verify that the output generated is as expected by defining a `post
150
156
  # => ["first", "external", "third"]
151
157
  ```
152
158
 
153
- ## Plumbing::Valve - safe asynchronous objects
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 rails mode.
169
+
170
+ When sending messages to an actor, this just works.
154
171
 
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.
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.
156
173
 
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.
174
+ The actor model does not eliminate every possible concurrency issue. If you use `await`, it is possible to deadlock yourself.
175
+ 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.
158
176
 
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.
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.
160
178
 
161
- - 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
- - Queries return a value so the caller blocks until the actor has returned a value
163
- - 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
- - 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
179
+ ### Inline actors
165
180
 
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.
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.
167
182
 
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.
183
+ ### Async actors
169
184
 
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.
185
+ 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.
171
186
 
172
- ### Usage
187
+ ### Threaded actprs
188
+
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.
190
+
191
+ 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.
194
+
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).
196
+
197
+ 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
+
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.
200
+
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.
202
+
203
+ ### Usage
173
204
 
174
- [Defining an actor](/spec/examples/valve_spec.rb)
205
+ [Defining an actor](/spec/examples/actor_spec.rb)
175
206
 
176
207
  ```ruby
177
208
  require "plumbing"
178
-
209
+
179
210
  class Employee
180
211
  attr_reader :name, :job_title
181
212
 
182
- include Plumbing::Valve
213
+ include Plumbing::Actor
183
214
  query :name, :job_title, :greet_slowly
184
215
  command :promote
185
216
 
@@ -193,70 +224,40 @@ Also be aware that if you use valves in one place, you need to use them everywhe
193
224
  @job_title = "Sales manager"
194
225
  end
195
226
 
196
- def greet_slowly
227
+ def greet_slowly
197
228
  sleep 0.2
198
229
  "H E L L O"
199
230
  end
200
231
  end
201
- ```
202
232
 
203
- [Acting inline](/spec/examples/valve_spec.rb) with no concurrency
204
-
205
- ```ruby
206
- require "plumbing"
207
-
208
233
  @person = Employee.start "Alice"
209
234
 
210
- puts @person.name
211
- # => "Alice"
212
- puts @person.job_title
213
- # => "Sales assistant"
235
+ await { @person.name }
236
+ # => "Alice"
237
+ await { @person.job_title }
238
+ # => "Sales assistant"
214
239
 
215
- @person.promote
216
- # this will block for 0.5 seconds
217
- puts @person.job_title
218
- # => "Sales manager"
240
+ # `greet_slowly` is a query so will block until a response is received
241
+ await { @person.greet_slowly }
242
+ # => "H E L L O"
219
243
 
220
- @person.greet_slowly
221
- # this will block for 0.2 seconds before returning "H E L L O"
222
-
223
- @person.greet_slowly(ignore_result: true)
224
- # this will block for 0.2 seconds (as the mode is :inline) before returning nil
225
- ```
226
-
227
- [Using fibers](/spec/examples/valve_spec.rb) with concurrency but no parallelism
228
-
229
- ```ruby
230
- require "plumbing"
231
- require "async"
232
-
233
- Plumbing.configure mode: :async
234
- @person = Employee.start "Alice"
235
-
236
- puts @person.name
237
- # => "Alice"
238
- puts @person.job_title
239
- # => "Sales assistant"
244
+ # we're not awaiting the result, so this will run in the background (unless we're using inline mode)
245
+ @person.greet_slowly
240
246
 
247
+ # This will run in the background
241
248
  @person.promote
242
- # this will return immediately without blocking
243
- puts @person.job_title
244
- # => "Sales manager" (this will block for 0.5s because #job_title query will not start until the #promote command has completed)
245
-
246
- @person.greet_slowly
247
- # this will block for 0.2 seconds before returning "H E L L O"
248
-
249
- @person.greet_slowly(ignore_result: true)
250
- # this will not block and returns nil
249
+ # this will block, as we wait for the result from #job_title and #job_title will not run until after #promote has completed
250
+ await { @person.job_title }
251
+ # => "Sales manager"
251
252
  ```
252
253
 
253
254
  ## Plumbing::Pipe - a composable observer
254
255
 
255
256
  [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
257
 
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.
258
+ [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
259
 
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.
260
+ 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.
260
261
 
261
262
  ### Usage
262
263
 
@@ -279,7 +280,7 @@ Pipes are implemented as valves, meaning that event notifications can be dispatc
279
280
 
280
281
  @source = Plumbing::Pipe.start
281
282
  @filter = Plumbing::Filter.start source: @source do |event|
282
- %w[important urgent].include? event.type
283
+ %w[important urgent].include? event.type
283
284
  end
284
285
  @observer = @filter.add_observer do |event|
285
286
  puts event.type
@@ -349,16 +350,16 @@ Pipes are implemented as valves, meaning that event notifications can be dispatc
349
350
  require "plumbing"
350
351
  require "async"
351
352
 
352
- Plumbing.configure mode: :async
353
+ Plumbing.configure mode: :async
353
354
 
354
- Sync do
355
- @first_source = Plumbing::Pipe.start
355
+ Sync do
356
+ @first_source = Plumbing::Pipe.start
356
357
  @second_source = Plumbing::Pipe.start
357
358
 
358
359
  @junction = Plumbing::Junction.start @first_source, @second_source
359
360
 
360
361
  @filter = Plumbing::Filter.start source: @junction do |event|
361
- %w[one-one two-two].include? event.type
362
+ %w[one-one two-two].include? event.type
362
363
  end
363
364
 
364
365
  @first_source.notify "one-one"
@@ -370,19 +371,20 @@ Pipes are implemented as valves, meaning that event notifications can be dispatc
370
371
 
371
372
  ## Plumbing::RubberDuck - duck types and type-casts
372
373
 
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. Then cast an object into that type, which first tests that the object can respond to those messages and then builds a proxy that responds to just those messages and no others (so no-one can abuse the specific type-casting you have specified). However, if you take one of these proxies, you can safely re-cast it as another type (as long as the original target object is castable).
374
+ 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
375
 
376
+ 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
377
 
376
- ### Usage
378
+ ### Usage
377
379
 
378
- Define your interface (Person in this example), then cast your objects (instances of PersonData and CarData).
380
+ Define your interface (Person in this example), then cast your objects (instances of PersonData and CarData).
379
381
 
380
382
  [Casting objects as duck-types](/spec/examples/rubber_duck_spec.rb):
381
383
  ```ruby
382
384
  require "plumbing"
383
385
 
384
- Person = Plumbing::RubberDuck.define :first_name, :last_name, :email
385
- LikesFood = Plumbing::RubberDuck.define :favourite_food
386
+ Person = Plumbing::RubberDuck.define :first_name, :last_name, :email
387
+ LikesFood = Plumbing::RubberDuck.define :favourite_food
386
388
 
387
389
  PersonData = Struct.new(:first_name, :last_name, :email, :favourite_food)
388
390
  CarData = Struct.new(:make, :model, :colour)
@@ -395,17 +397,54 @@ Define your interface (Person in this example), then cast your objects (instance
395
397
  @person = @alice.as Person
396
398
  @person.first_name
397
399
  # => "Alice"
398
- @person.email
400
+ @person.email
399
401
  # => "alice@example.com"
400
402
  @person.favourite_food
401
403
  # => NoMethodError - #favourite_food is not part of the Person rubber duck (even though it is part of the underlying PersonData struct)
402
404
 
403
405
  # Cast our Person into a LikesFood rubber duck
404
- @hungry = @person.as LikesFood
405
- @hungry.favourite_food
406
+ @hungry = @person.as LikesFood
407
+ @hungry.favourite_food
406
408
  # => "Ice cream"
407
409
  ```
408
410
 
411
+ 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.
412
+
413
+ ```ruby
414
+ require "plumbing"
415
+
416
+ module Person
417
+ def first_name = @first_name
418
+
419
+ def last_name = @last_name
420
+
421
+ def email = @email
422
+ end
423
+
424
+ module LikesFood
425
+ def favourite_food = @favourite_food
426
+ end
427
+
428
+ PersonData = Struct.new(:first_name, :last_name, :email, :favourite_food)
429
+ CarData = Struct.new(:make, :model, :colour)
430
+
431
+ @porsche_911 = CarData.new "Porsche", "911", "black"
432
+ expect { @porsche_911.as Person }.to raise_error(TypeError)
433
+
434
+ @alice = PersonData.new "Alice", "Aardvark", "alice@example.com", "Ice cream"
435
+
436
+ @alics.is_a? Person
437
+ # => false - PersonData does not `include Person`
438
+ @person = @alice.as Person
439
+ # This cast is OK because PersonData responds to :first_name, :last_name and :email
440
+ expect(@person.first_name).to eq "Alice"
441
+ expect(@person.email).to eq "alice@example.com"
442
+ expect { @person.favourite_food }.to raise_error(NoMethodError)
443
+
444
+ @hungry = @person.as LikesFood
445
+ expect(@hungry.favourite_food).to eq "Ice cream"
446
+ ```
447
+
409
448
  ## Installation
410
449
 
411
450
  Install the gem and add to the application's Gemfile by executing:
@@ -418,6 +457,9 @@ Then:
418
457
 
419
458
  ```ruby
420
459
  require 'plumbing'
460
+
461
+ # Set the mode for your Actors and Pipes
462
+ Plumbing.config mode: :async
421
463
  ```
422
464
 
423
465
  ## Development
@@ -0,0 +1,38 @@
1
+ require "async"
2
+ require "async/semaphore"
3
+ require "timeout"
4
+
5
+ module Plumbing
6
+ module Actor
7
+ class Async
8
+ attr_reader :target
9
+
10
+ def initialize target
11
+ @target = target
12
+ @queue = []
13
+ @semaphore = ::Async::Semaphore.new(1)
14
+ end
15
+
16
+ # Send the message to the target and wrap the result
17
+ def send_message message_name, *args, &block
18
+ task = @semaphore.async do
19
+ @target.send message_name, *args, &block
20
+ end
21
+ Result.new(task)
22
+ end
23
+
24
+ Result = Data.define(:task) do
25
+ def await
26
+ Timeout.timeout(Plumbing::Actor.timeout) do
27
+ task.wait
28
+ end
29
+ end
30
+ end
31
+ private_constant :Result
32
+ end
33
+
34
+ def self.timeout
35
+ Plumbing.config.timeout
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,22 @@
1
+ module Plumbing
2
+ module Actor
3
+ class Inline
4
+ def initialize target
5
+ @target = target
6
+ end
7
+
8
+ # Send the message to the target and wrap the result
9
+ def send_message(message_name, *, &)
10
+ value = @target.send(message_name, *, &)
11
+ Result.new(value)
12
+ rescue => ex
13
+ Result.new(ex)
14
+ end
15
+
16
+ Result = Data.define(:value) do
17
+ def await = value.is_a?(Exception) ? raise(value) : value
18
+ end
19
+ private_constant :Result
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,9 @@
1
+ module Plumbing
2
+ module Actor
3
+ ::Kernel.class_eval do
4
+ def await &block
5
+ block.call.await
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,15 @@
1
+ require_relative "threaded"
2
+
3
+ module Plumbing
4
+ module Actor
5
+ class Rails < Threaded
6
+ protected
7
+
8
+ def future(&)
9
+ Concurrent::Promises.future do
10
+ Rails.application.executor.wrap(&)
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,62 @@
1
+ require "concurrent/array"
2
+ require "concurrent/mvar"
3
+ require "concurrent/immutable_struct"
4
+ require "concurrent/promises"
5
+ require_relative "transporter"
6
+
7
+ module Plumbing
8
+ module Actor
9
+ class Threaded
10
+ attr_reader :target
11
+
12
+ def initialize target
13
+ @target = target
14
+ @queue = Concurrent::Array.new
15
+ end
16
+
17
+ # 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
22
+ end
23
+ end
24
+
25
+ protected
26
+
27
+ def future(&)
28
+ Concurrent::Promises.future(&)
29
+ end
30
+
31
+ private
32
+
33
+ def send_messages
34
+ future do
35
+ while (message = @queue.shift)
36
+ message.call
37
+ end
38
+ end
39
+ end
40
+
41
+ class Message < Concurrent::ImmutableStruct.new(:target, :message_name, :packed_args, :unsafe_block, :result)
42
+ def call
43
+ args = Plumbing::Actor.transporter.unmarshal(*packed_args)
44
+ value = target.send message_name, *args, &unsafe_block
45
+ result.put Plumbing::Actor.transporter.marshal(value)
46
+ rescue => ex
47
+ result.put ex
48
+ end
49
+
50
+ def await
51
+ value = Plumbing::Actor.transporter.unmarshal(*result.take(Plumbing.config.timeout)).first
52
+ raise value if value.is_a? Exception
53
+ value
54
+ end
55
+ end
56
+ end
57
+
58
+ def self.transporter
59
+ @transporter ||= Plumbing::Actor::Transporter.new
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,61 @@
1
+ require "global_id"
2
+
3
+ module Plumbing
4
+ module Actor
5
+ class Transporter
6
+ def marshal *arguments
7
+ pack_array arguments
8
+ end
9
+
10
+ def unmarshal *arguments
11
+ unpack_array arguments
12
+ end
13
+
14
+ private
15
+
16
+ def pack argument
17
+ case argument
18
+ when GlobalID::Identification then pack_global_id argument
19
+ when Array then pack_array argument
20
+ when Hash then pack_hash argument
21
+ else argument.clone
22
+ end
23
+ end
24
+
25
+ def pack_array arguments
26
+ arguments.map { |a| pack a }
27
+ end
28
+
29
+ def pack_hash arguments
30
+ arguments.transform_values { |v| pack v }
31
+ end
32
+
33
+ def pack_global_id argument
34
+ argument.to_global_id.to_s
35
+ end
36
+
37
+ def unpack argument
38
+ case argument
39
+ when String then unpack_string argument
40
+ when Array then unpack_array argument
41
+ when Hash then unpack_hash argument
42
+ else argument
43
+ end
44
+ end
45
+
46
+ def unpack_array arguments
47
+ arguments.map { |a| unpack a }
48
+ end
49
+
50
+ def unpack_hash arguments
51
+ arguments.to_h do |key, value|
52
+ [key, unpack(value)]
53
+ end
54
+ end
55
+
56
+ def unpack_string argument
57
+ argument.start_with?("gid://") ? GlobalID::Locator.locate(argument) : argument
58
+ end
59
+ end
60
+ end
61
+ end