standard-procedure-plumbing 0.5.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +188 -0
  3. data/README.md +212 -32
  4. data/lib/plumbing/actor/async.rb +43 -36
  5. data/lib/plumbing/actor/configuration.rb +29 -0
  6. data/lib/plumbing/actor/deferral.rb +21 -0
  7. data/lib/plumbing/actor/definitions.rb +70 -0
  8. data/lib/plumbing/actor/inline.rb +13 -25
  9. data/lib/plumbing/actor/message.rb +43 -0
  10. data/lib/plumbing/actor/rails.rb +13 -4
  11. data/lib/plumbing/actor/threaded.rb +52 -58
  12. data/lib/plumbing/actor/worker.rb +38 -0
  13. data/lib/plumbing/actor.rb +33 -79
  14. data/lib/plumbing/event.rb +10 -0
  15. data/lib/plumbing/object.rb +11 -0
  16. data/lib/plumbing/observable.rb +38 -0
  17. data/lib/plumbing/operation/dsl.rb +143 -0
  18. data/lib/plumbing/operation/errors.rb +11 -0
  19. data/lib/plumbing/operation/events.rb +46 -0
  20. data/lib/plumbing/operation/generator.rb +157 -0
  21. data/lib/plumbing/operation/mermaid.rb +34 -0
  22. data/lib/plumbing/operation/state.rb +19 -0
  23. data/lib/plumbing/operation/transition.rb +16 -0
  24. data/lib/plumbing/operation/wait_options.rb +12 -0
  25. data/lib/plumbing/operation.rb +169 -0
  26. data/lib/plumbing/pipeline/base.rb +115 -0
  27. data/lib/plumbing/pipeline/except.rb +17 -0
  28. data/lib/plumbing/pipeline/filter.rb +17 -0
  29. data/lib/plumbing/pipeline/junction.rb +15 -0
  30. data/lib/plumbing/pipeline/only.rb +17 -0
  31. data/lib/plumbing/pipeline/source.rb +11 -0
  32. data/lib/plumbing/pipeline.rb +27 -13
  33. data/lib/plumbing/provider/router.rb +156 -0
  34. data/lib/plumbing/provider.rb +164 -0
  35. data/lib/plumbing/types.rb +8 -4
  36. data/lib/plumbing/version.rb +1 -1
  37. data/lib/plumbing.rb +29 -8
  38. metadata +34 -27
  39. data/lib/plumbing/actor/kernel.rb +0 -21
  40. data/lib/plumbing/actor/transporter.rb +0 -64
  41. data/lib/plumbing/config.rb +0 -58
  42. data/lib/plumbing/error.rb +0 -13
  43. data/lib/plumbing/pipe/custom_filter.rb +0 -17
  44. data/lib/plumbing/pipe/filter.rb +0 -23
  45. data/lib/plumbing/pipe/junction.rb +0 -21
  46. data/lib/plumbing/pipe.rb +0 -62
  47. data/lib/plumbing/pipeline/contracts.rb +0 -68
  48. data/lib/plumbing/pipeline/operations.rb +0 -62
  49. data/lib/plumbing/rubber_duck/module.rb +0 -13
  50. data/lib/plumbing/rubber_duck/object.rb +0 -12
  51. data/lib/plumbing/rubber_duck/proxy.rb +0 -19
  52. data/lib/plumbing/rubber_duck.rb +0 -69
  53. data/lib/plumbing/spec/become_matchers.rb +0 -121
  54. data/lib/plumbing/spec/modes.rb +0 -14
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8071e0d012d60099bd30afaaabe7274fe4572a88686115e9b8db86fe880a0734
4
- data.tar.gz: 212b5f27a5309d3cd638e354d8a41cbd77dcf0a5b9f22d5976e04403cea200a4
3
+ metadata.gz: 00d6b8a7e8165f292a6a6f130e7a03ca220bf64b1be9c926313aea61d90d6ce1
4
+ data.tar.gz: 2b445dda48df90ff053a0890ee027241ff7b82e460f82f06dd428b4e94601ff4
5
5
  SHA512:
6
- metadata.gz: 82e7bf5617439df2e00886e7d95e9c4215e5f2e7dcf91a6a5de3673cd132bbc069c301fd0cdeb4cd315faff76981a4282898da66771be57ce19c7d9589fa7a30
7
- data.tar.gz: c64ee286c7fec70b9025bd32ceaa39379751d26f7e7947934f07c5651f62f564407a87a4227fd2e4202c042b2d9db7df48008e67ba06aea9f15b269b7606cd15
6
+ metadata.gz: cacee68df443db89b5c6fe3bf5b7726419363502625138e8bf8c1241fdcecda17155effb464e8a6af303d0bf3fe996bec22893c5035e90817776c590849401f0
7
+ data.tar.gz: 74fdda077f25c6b2c1c9d4a10db03e070df7865105dea27685177193910d4714f0999efc7032f8e9a401358c9a8cfe3ebc8bfc215f142322b1883cd9b825f2c0
data/CHANGELOG.md ADDED
@@ -0,0 +1,188 @@
1
+ ## [1.0.0] - 2026-07-07 — v1 rewrite
2
+
3
+ A ground-up rewrite. Plumbing is now a small, [`literal`](https://github.com/joeldrapper/literal)-based
4
+ toolkit of composable concurrency primitives: pluggable-worker **Actors**, a
5
+ **Provider** locator, a composable **Event / Pipeline** stream, an
6
+ **Observable** mixin, and a state-machine **Operation** engine.
7
+
8
+ **The only runtime dependency is `literal`.** `globalid` is dropped. The
9
+ heavier worker dependencies (`async`, `rails`) are no longer in the core — the
10
+ opt-in workers `require` their own and you add the gem to your app.
11
+
12
+ ### Breaking changes
13
+
14
+ **RubberDuck removed — use `Object#as` with a literal interface:**
15
+
16
+ ```ruby
17
+ obj.as(Plumbing::Callable) # before — returned a narrowing proxy
18
+ obj.as(Literal::Types._Callable) # after — validates and returns obj itself
19
+ obj.as(Literal::Types._Interface(:observe, :remove, :remove_all)) # any literal interface works
20
+ ```
21
+
22
+ **`Plumbing::Pipeline` is now the event stream (was `Plumbing::Pipe`).** The old
23
+ sequential-operations `Pipeline` is removed; the old `Pipe` message bus is
24
+ reborn as `Pipeline`, rebuilt around immutable `Plumbing::Event` values:
25
+
26
+ ```ruby
27
+ # before (0.x Pipe) — observers got (event_name, data)
28
+ pipe.add_observer { |event_name, data| ... }
29
+ pipe.notify "something_happened", foo: 1
30
+
31
+ # after (1.0 Pipeline) — events are Plumbing::Event value objects
32
+ class SomethingHappened < Plumbing::Event
33
+ prop :foo, Integer
34
+ end
35
+ source = Plumbing::Pipeline::Source.new
36
+ source.observe { |event| ... }
37
+ source << SomethingHappened.new(foo: 1)
38
+
39
+ # or via the registry, by type name:
40
+ Plumbing::Pipeline.register(SomethingHappened)
41
+ source.notify(event_type: "SomethingHappened", params: {foo: 1})
42
+ ```
43
+
44
+ Compose with `Only` / `Except` (string names, trailing `*` wildcard), `Filter`
45
+ (Regexp) and `Junction` (fan-in). Duplicate events are debounced.
46
+
47
+ **New: `Plumbing::Observable` module** — mix into any object (actor or not) to
48
+ give it its own event stream. It adds a public subscriber interface (`observe` /
49
+ `remove` / `remove_all`) and a private emit interface (`push` / `notify`), backed
50
+ by a lazily-created internal `Pipeline::Source`. `Operation` now uses it instead
51
+ of hand-rolling a pipeline. (The old `Plumbing::Observable` interface *constant*,
52
+ only ever an `Object#as` example, is removed — build interfaces inline with
53
+ `Literal::Types._Interface(...)`.)
54
+
55
+ **Actors rebuilt (composition, not proxies):**
56
+
57
+ ```ruby
58
+ # before — Counter.start returned a proxy; methods were plain defs
59
+ class Counter
60
+ include Plumbing::Actor
61
+ async :increment
62
+ def increment(by = 1) = @count += by
63
+ end
64
+ counter = Counter.start
65
+ counter.increment
66
+
67
+ # after — the async DSL carries typed params; resolve with await
68
+ class Counter
69
+ include Plumbing::Actor
70
+ async :increment do
71
+ param :by, Integer, default: 1
72
+ returns { |by:| @count += by }
73
+ end
74
+ end
75
+ counter = Counter.new
76
+ await { counter.increment(by: 2) }
77
+ ```
78
+
79
+ - Worker selection moved from `Plumbing.config(mode: :async)` to
80
+ `Plumbing::Actor.uses :async` (after `require "plumbing/actor/async"`).
81
+ - Workers: `inline` (default, zero-dependency), and opt-in `async`, `threaded`
82
+ (now core Ruby — no `concurrent-ruby`) and `rails`. Each delivers an actor's
83
+ messages **one at a time, in arrival order**.
84
+ - Actors now track who called them: `current_sender` / `current_senders`.
85
+
86
+ **New: `Provider` locator** — a path-based service locator, itself a
87
+ `Plumbing::Actor`, with `register`/`provide` (aliases `singleton`/`factory`).
88
+ `register`, `provide` and `get` are async messages taking keyword args; `[]` is
89
+ the synchronous convenience (`get(path:).await`). Paths may carry `:params`
90
+ passed to the block, and static routes win over dynamic ones on conflict.
91
+
92
+ ```ruby
93
+ Plumbing.services.register(path: "config", value: AppConfig.load) # eager singleton
94
+ Plumbing.services.register(path: "db") { Database.connect } # lazy singleton, cached
95
+ Plumbing.services.provide(path: "clock") { Time.now } # fresh every access
96
+ Plumbing.services["db"]
97
+ ```
98
+
99
+ Cached registrations accept an optional `expires_in:` (seconds) TTL — the value
100
+ is evicted after that long and re-resolved on the next lookup (eviction is
101
+ scheduled on the actor's worker, so it is a no-op under the `:inline` worker).
102
+
103
+ A Provider can also be mounted under a wildcard tail path
104
+ (`register(path: "prefix/*", value: nested_provider)`, or a block resolving to a
105
+ Provider) — lookups beneath the prefix delegate the remaining path to the nested
106
+ provider, like mounting a sub-router.
107
+
108
+ ## [0.5.2] - 2024-10-08
109
+
110
+ - Ensure preconditions are called in order
111
+
112
+ ## [0.5.1] - 2024-10-08
113
+
114
+ - Added exception handling for Pipeline preconditions
115
+
116
+ ## [0.5.0] - 2024-09-20
117
+
118
+ - Feature complete?
119
+
120
+ ## [0.4.5] - 2024-09-20
121
+
122
+ - Changed Plumbing::Pipeline into a module
123
+
124
+ ## [0.4.5] - 2024-09-20
125
+
126
+ - `become` matchers available to other gems
127
+ - `wait_for`
128
+
129
+ ## [0.4.4] - 2024-09-18
130
+
131
+ - Various bugfixes around the threading implementation
132
+
133
+ ## [0.4.1] - 2024-09-16
134
+
135
+ - Added `safely` to allow actors to run code within their own context
136
+
137
+ ## [0.4.0] - 2024-09-15
138
+
139
+ - Added #as_actor to allow actors to pass references to themselves
140
+
141
+ ## [0.3.3] - 2024-09-14
142
+
143
+ - Added :threaded and :rails modes
144
+ - RubberDuck now works with Module and Class
145
+
146
+ ## [0.3.2] - 2024-09-13
147
+
148
+ - URG - somehow I'd managed to exclude the lib folder from the gem contents
149
+
150
+ ## [0.3.1] - 2024-09-03
151
+
152
+ - Added `ignore_result` for queries on Plumbing::Valves
153
+
154
+ ## [0.3.0] - 2024-08-28
155
+
156
+ - Added Plumbing::Valve
157
+ - Reimplemented Plumbing::Pipe to use Plumbing::Valve
158
+
159
+ ## [0.2.2] - 2024-08-25
160
+
161
+ - Added Plumbing::RubberDuck
162
+
163
+ ## [0.2.1] - 2024-08-25
164
+
165
+ - Split the Pipe implementation between the Pipe and EventDispatcher
166
+ - Use different EventDispatchers to handle fibers or inline pipes
167
+ - Renamed Chain to Pipeline
168
+
169
+ ## [0.2.0] - 2024-08-14
170
+
171
+ - Added optional Dry::Validation support
172
+ - Use Async for fiber-based pipes
173
+
174
+ ## [0.1.2] - 2024-08-14
175
+
176
+ - Removed dependencies
177
+ - Removed Ractor-based concurrent pipe (as I don't trust it yet)
178
+
179
+ ## [0.1.1] - 2024-08-14
180
+
181
+ - Tidied up the code
182
+ - Added Plumbing::Chain
183
+
184
+ ## [0.1.0] - 2024-04-13
185
+
186
+ - Initial release
187
+
188
+ ## [Unreleased]
data/README.md CHANGED
@@ -1,63 +1,243 @@
1
1
  # Plumbing
2
2
 
3
- Actors, Observers and Data Pipelines.
3
+ Small, fast building blocks for concurrent Ruby: **actors**, a **service
4
+ locator**, a **composable event stream**, **observable** objects and a
5
+ **state-machine engine** — built on
6
+ [`literal`](https://github.com/joeldrapper/literal) and nothing else.
4
7
 
5
- ## Usage
8
+ > **v1 is a breaking rewrite** (`0.5.2 → 1.0.0`). See the
9
+ > [CHANGELOG](CHANGELOG.md) for what changed.
6
10
 
7
- Start off by [configuring Plumbing](/docs/config.md) and selecting your `mode`.
11
+ ## Philosophy
8
12
 
9
- ## Pipelines
13
+ Plumbing gives you the few concurrency patterns an app actually needs without
14
+ the surface area of the `dry-*` family. The core gem's **only runtime
15
+ dependency is `literal`**. Anything heavier is opt-in — you `require` it and
16
+ add the underlying gem yourself.
10
17
 
11
- [Data transformations](/docs/pipelines.md) similar to unix pipes.
18
+ ## Concepts
12
19
 
13
- ## Actors
20
+ ### Actors
14
21
 
15
- [Asynchronous, thread-safe, objects](/docs/actors.md).
22
+ Asynchronous, thread-safe objects. `include Plumbing::Actor`, declare typed
23
+ async messages, and resolve results with `await`.
16
24
 
17
- ## Pipes
25
+ ```ruby
26
+ class Greeting
27
+ include Plumbing::Actor
28
+ def initialize(name:) = @name = name
29
+
30
+ async :say do
31
+ param :greeting, String, default: "Hello"
32
+ returns { |greeting:| "#{greeting} #{@name}" } # validated params arrive as block kwargs
33
+ end
34
+ end
35
+
36
+ g = Greeting.new(name: "Alice")
37
+ await { g.say(greeting: "Hi") } # => "Hi Alice"
38
+ ```
18
39
 
19
- [Composable observers](/docs/pipes.md).
40
+ Async messages forward **blocks** as well as params — declare `&block` in the
41
+ `returns` signature and the caller's block arrives intact:
20
42
 
21
- ## Rubber ducks
43
+ ```ruby
44
+ class Speaker
45
+ include Plumbing::Actor
46
+ async :say_something do
47
+ returns { |&block| "I am speaking #{block.call}" }
48
+ end
49
+ end
50
+
51
+ await { Speaker.new.say_something { "in a block" } } # => "I am speaking in a block"
52
+ ```
22
53
 
23
- [Type-safety the ruby way](/docs/rubber_ducks.md).
54
+ Each actor owns a pluggable **worker**. `inline` (the default) ships with the
55
+ core; `async`, `threaded` and `rails` are opt-in:
24
56
 
25
- ## Installation
57
+ ```ruby
58
+ require "plumbing/actor/async" # also: add `async` to your Gemfile
59
+ Plumbing::Actor.uses :async
60
+ ```
26
61
 
27
- Note: this gem is licensed under the [LGPL](/LICENCE). This may or may not make it unsuitable for use by you or your company.
62
+ Actors track who called them `current_sender` (immediate) and
63
+ `current_senders` (the full call-chain).
28
64
 
29
- Install the gem and add to the application's Gemfile by executing:
65
+ ### Providers
30
66
 
31
- ```sh
32
- bundle add standard-procedure-plumbing
67
+ A parameterised object locator. `Provider` is itself a **Plumbing actor**, so
68
+ `register`, `provide` and `get` are async messages taking keyword arguments.
69
+ Lookups via `[]` are the synchronous convenience — `provider[path]` is exactly
70
+ `provider.get(path:).await`.
71
+
72
+ - `register` - registers an object at a path - lookups on that path return the same object each time
73
+ - `provide` - registers a factory at a path - lookups on that path return a new object each time
74
+
75
+ ```ruby
76
+ # Every lookup returns the same object which is registered immediately
77
+ Plumbing.services.register path: "app/config", value: AppConfig.load
78
+ Plumbing.services["app/config"]
79
+
80
+ # The first lookup calls the block and subsequent lookups are cached
81
+ Plumbing.services.register(path: "db") { Database.connect }
82
+ Plumbing.services["db"]
83
+
84
+ # Each lookup calls the block
85
+ Plumbing.services.provide(path: "system/clock") { Time.now }
86
+ Plumbing.services["system/clock"]
33
87
  ```
34
88
 
35
- Then:
89
+ The path can contain parameters which are then passed as keyword arguments to the provider block. The arguments are always strings as they are extracted from the lookup query.
36
90
 
37
91
  ```ruby
38
- require 'plumbing'
92
+ # Every lookup calls `Person.find`
93
+ Plumbing.services.provide(path: "people/:id") { |id:| Person.find(id) }
94
+ Plumbing.services["/people/123"]
39
95
 
40
- # Set the mode for your Actors and Pipes
41
- Plumbing.config mode: :async
96
+ # The first lookup calls `Person.find` and subsequent lookups are cached
97
+ Plumbing.services.register(path: "people/:id") { |id:| Person.find(id) }
98
+ Plumbing.services["/people/123"]
42
99
  ```
43
100
 
44
- ## Development
101
+ A cached registration can be given a **TTL** with `expires_in:` (seconds). After
102
+ that long the cached value is evicted and the next lookup re-resolves through the
103
+ block, restarting the clock — handy for singletons used in bursts that should
104
+ release their memory once cold. Eviction is scheduled on the actor's worker, so
105
+ it needs a worker that can defer: under the default `:inline` worker the TTL is a
106
+ silent no-op and the value caches forever (much like a cache store with no expiry
107
+ sweeper).
45
108
 
46
- ### To Do
109
+ ```ruby
110
+ # Re-fetched at most once every 60s; evicted in between so it can be reclaimed
111
+ Plumbing.services.register(path: "exchange/rates", expires_in: 60) { RateApi.fetch }
112
+ ```
47
113
 
48
- - [ ] Add a debouncing filter for pipes
49
- - [ ] Pass the mode as a block parameter in `Plumbing::Spec.modes`
50
- - [ ] Move Plumbing::Actor::Transporter to Plumbing::Transporter ?? (planning to use it outside of Plumbing so would make sense not to imply it is tied to Actors)
51
- - [X] Ensure transporters deal with GlobalID models not being found / errors when unpacking
114
+ Because `register` and `provide` are async, they return a message rather than
115
+ raising inline. Registration errors (an ambiguous registration, a static value
116
+ on a dynamic path) surface only when the message is awaited, so `await` if you
117
+ need to catch them:
52
118
 
53
- 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.
119
+ ```ruby
120
+ provider.register(path: "locate/:object", value: "object").await # => raises ArgumentError
121
+ ```
54
122
 
55
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
123
+ If there is a conflict between a static path and a dynamic path, the one with the most static matches wins.
56
124
 
57
- ## Contributing
125
+ ```ruby
126
+ @provider = Plumbing::Provider.new
127
+
128
+ @provider.register(path: "users/:id") { |id:| User.find(id) }
129
+ @provider.register(path: "users/me") { Current.user }
130
+
131
+ @provider["users/me"] # => Current.user
132
+ ```
133
+
134
+ ```ruby
135
+ @provider = Plumbing::Provider.new
136
+
137
+ # path has 2 static and 2 dynamic segments
138
+ @provider.register(path: "users/:username/comments/:comment_id") { |username:, comment_id:| "user #{username} and comment #{comment_id}" }
139
+ # path has 3 static and 1 dynamic segment
140
+ @provider.register(path: "users/alice/comments/:comment_id") { |comment_id:| "comment #{comment_id}" }
141
+
142
+ # matches alice because the path has 3 static segments
143
+ @provider["users/alice/comments/123"] # => comment 123
144
+ @provider["users/bob/comments/123"] # => user bob and comment 123
145
+ ```
58
146
 
59
- Bug reports and pull requests are welcome on GitHub at <https://github.com/standard_procedure/plumbing>. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/standard_procedure/plumbing/blob/main/CODE_OF_CONDUCT.md).
147
+ **Nested providers.** Mount another Provider under a wildcard tail path
148
+ (`"prefix/*"`) and lookups beneath that prefix are delegated to it — like
149
+ mounting a sub-router. A lookup of the bare prefix returns the nested provider
150
+ itself; a lookup with a tail forwards the remaining path. Only a Provider may be
151
+ mounted under a wildcard (a static value is checked on registration; a block is
152
+ checked when it resolves).
60
153
 
61
- ## Code of Conduct
154
+ ```ruby
155
+ users = Plumbing::Provider.new
156
+ users.register(path: "me") { Current.user }
157
+
158
+ app = Plumbing::Provider.new
159
+ app.register path: "users/*", value: users
160
+
161
+ app["users"] # => the `users` provider itself
162
+ app["users/me"] # => Current.user (delegates "me" to the nested provider)
163
+ ```
164
+
165
+ Paths are automatically stripped of leading and trailing slashes.
166
+
167
+ Use the global `Plumbing.services`, or build and manage your own registry instances independently.
168
+
169
+ ### Pipeline + Event
170
+
171
+ A composable, concurrency-safe event stream over immutable `Literal::Data`
172
+ events.
173
+
174
+ ```ruby
175
+ class SomethingHappened < Plumbing::Event
176
+ prop :id, String
177
+ end
178
+
179
+ errors = Pipeline::Only.new(
180
+ source: Pipeline::Junction.new(app_events, worker_events),
181
+ filters: ["Error*", "Critical*"],
182
+ )
183
+ errors.observe { |event| alert(event) }
184
+
185
+ app_events << SomethingHappened.new(id: "123")
186
+ ```
187
+
188
+ Compose with `Source`, `Only`, `Except`, `Filter` (regexp) and `Junction`
189
+ (fan-in). Pushes are debounced and batched into a single notify pass.
190
+
191
+ ### Observable
192
+
193
+ Mix `Plumbing::Observable` into any object — actor or not — to give it its own
194
+ event stream. The host gains a public subscriber interface (`observe`, `remove`,
195
+ `remove_all`) and a private emit interface (`push`, `notify`), backed by a
196
+ lazily-created internal `Pipeline::Source`. Because the pipeline is the actor,
197
+ these methods need not be async — they forward fire-and-forget.
198
+
199
+ ```ruby
200
+ class Thermostat
201
+ include Plumbing::Observable
202
+
203
+ def temperature=(celsius)
204
+ @temperature = celsius
205
+ push TemperatureChanged.new(celsius: celsius) # private — only the host emits
206
+ end
207
+ end
208
+
209
+ t = Thermostat.new
210
+ t.observe { |event| puts "now #{event.celsius}°C" }
211
+ t.temperature = 21 # => "now 21°C"
212
+ ```
213
+
214
+ Observers subscribe from the outside; only the host emits. `Plumbing::Operation`
215
+ is built on this — its lifecycle events (`Started`, `Transitioned`, …) are pushed
216
+ through an `Observable` stream.
217
+
218
+ ## Installation
219
+
220
+ ```sh
221
+ bundle add standard-procedure-plumbing
222
+ ```
223
+
224
+ ```ruby
225
+ require "plumbing"
226
+ ```
227
+
228
+ Note: this gem is licensed under the [LGPL](/LICENCE), which may or may not
229
+ make it unsuitable for use by you or your company.
230
+
231
+ ## Development
232
+
233
+ After checking out the repo, run `bin/setup` to install dependencies, then
234
+ `rake spec` to run the tests. `bin/console` gives you an interactive prompt.
235
+
236
+ To install locally, run `bundle exec rake install`. To release, update the
237
+ version in `lib/plumbing/version.rb` and run `bundle exec rake release`.
238
+
239
+ ## Contributing
62
240
 
63
- Everyone interacting in the Plumbing project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/standard_procedure/plumbing/blob/main/CODE_OF_CONDUCT.md).
241
+ Bug reports and pull requests are welcome on GitHub at
242
+ <https://github.com/standard-procedure/plumbing>. This project follows a
243
+ [code of conduct](https://github.com/standard-procedure/plumbing/blob/main/CODE_OF_CONDUCT.md).
@@ -1,52 +1,59 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "async"
2
- require "async/semaphore"
3
- require "timeout"
4
+ require_relative "worker"
5
+ require_relative "message"
4
6
 
5
7
  module Plumbing
6
8
  module Actor
7
- class Async
8
- attr_reader :target
9
-
10
- def initialize target
11
- @target = target
12
- @semaphore = ::Async::Semaphore.new(Plumbing.config.max_concurrency)
13
- end
14
-
15
- # Send the message to the target and wrap the result
16
- def send_message(message_name, *args, **params, &block)
17
- Plumbing.config.logger.debug { "-> #{@target.class}##{message_name}(#{args.inspect}, #{params.inspect})" }
18
- task = @semaphore.async do
19
- Plumbing.config.logger.debug { "---> #{@target.class}##{message_name}(#{args.inspect}, #{params.inspect})" }
20
- @target.send(message_name, *args, **params, &block)
9
+ # Processes an actor's messages on the Async reactor, ONE AT A TIME, IN
10
+ # ARRIVAL ORDER — a single sequential consumer loop preserves the actor
11
+ # guarantee.
12
+ #
13
+ # NOTE: we deliberately do NOT use `Async::Queue#async`. That spawns a new
14
+ # task per item (bounded by a semaphore), so a single actor would deliver
15
+ # several messages concurrently and complete them out of order — which
16
+ # defeats the entire point of an actor. Concurrency belongs BETWEEN actors
17
+ # (each has its own worker/queue/consumer), never within one.
18
+ class Async < Worker
19
+ prop :queue, ::Async::Queue, default: -> { ::Async::Queue.new }
20
+
21
+ def call
22
+ Kernel.Async(transient: true) do
23
+ while (message = @queue.dequeue)
24
+ message.deliver
25
+ end
21
26
  end
22
- sleep 0.01
23
- Result.new(task)
24
27
  end
28
+ alias_method :start, :call
25
29
 
26
- def safely(&)
27
- Plumbing.config.logger.debug { "-> #{@target.class}#perform_safely" }
28
- send_message(:perform_safely, &)
29
- sleep 0.01
30
- nil
31
- end
30
+ def stop = @queue.close
32
31
 
33
- def in_context? = true
32
+ def active? = !@queue.closed?
34
33
 
35
- def stop = nil
34
+ def dispatch(message) = @queue.enqueue(message)
36
35
 
37
- Result = Data.define(:task) do
38
- def value
39
- sleep 0.01
40
- Timeout.timeout(Plumbing::Actor.timeout) do
41
- task.wait
42
- end
36
+ def after(delay, method:, sender: nil, params: {}, block: nil)
37
+ message = build_message(method: method, sender: sender, params: params, block: block)
38
+ deferral = Plumbing::Actor::Deferral.new
39
+ Kernel.Async(transient: true) do |task|
40
+ task.sleep delay
41
+ dispatch(message) unless deferral.cancelled?
43
42
  end
43
+ deferral
44
44
  end
45
- private_constant :Result
46
- end
47
45
 
48
- def self.timeout
49
- Plumbing.config.timeout
46
+ def message_class = Plumbing::Actor::Async::Message
47
+
48
+ class Message < Actor::Message
49
+ def _wait_until_ready
50
+ sleep 0.001 while @status == :waiting
51
+ end
52
+ end
50
53
  end
51
54
  end
52
55
  end
56
+
57
+ # Opt-in worker: requiring this file registers it. Select with
58
+ # `Plumbing::Actor.uses :async` (the app must also depend on the `async` gem).
59
+ Plumbing::Actor.register(:async) { |actor| Plumbing::Actor::Async.new(actor: actor) }
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plumbing
4
+ module Actor
5
+ module Configuration
6
+ def worker_for actor
7
+ worker_types[selected_worker_type].call(actor)
8
+ end
9
+
10
+ def uses name
11
+ @selected_worker_type = name.to_sym
12
+ end
13
+
14
+ def selected_worker_type
15
+ @selected_worker_type ||= :inline
16
+ end
17
+
18
+ def workers = worker_types.keys
19
+
20
+ def register name, &builder
21
+ worker_types[name.to_sym] = builder
22
+ end
23
+
24
+ def worker_types
25
+ @worker_types ||= {inline: ->(actor) { Plumbing::Actor::Inline.new(actor: actor) }}
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plumbing
4
+ module Actor
5
+ # An opaque handle for a scheduled (deferred) message. Cancelling sets a
6
+ # mutex-guarded flag that the worker's timer checks before dispatching. The
7
+ # flag itself is race-safe; a cancel landing in the tiny window after the
8
+ # check still lets one (benign, in-order) message through — the operation
9
+ # layer's generation token discards such a stale fire.
10
+ class Deferral
11
+ def initialize
12
+ @lock = Mutex.new
13
+ @cancelled = false
14
+ end
15
+
16
+ def cancel = @lock.synchronize { @cancelled = true }
17
+
18
+ def cancelled? = @lock.synchronize { @cancelled }
19
+ end
20
+ end
21
+ end