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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +188 -0
- data/README.md +212 -32
- data/lib/plumbing/actor/async.rb +43 -36
- data/lib/plumbing/actor/configuration.rb +29 -0
- data/lib/plumbing/actor/deferral.rb +21 -0
- data/lib/plumbing/actor/definitions.rb +70 -0
- data/lib/plumbing/actor/inline.rb +13 -25
- data/lib/plumbing/actor/message.rb +43 -0
- data/lib/plumbing/actor/rails.rb +13 -4
- data/lib/plumbing/actor/threaded.rb +52 -58
- data/lib/plumbing/actor/worker.rb +38 -0
- data/lib/plumbing/actor.rb +33 -79
- data/lib/plumbing/event.rb +10 -0
- data/lib/plumbing/object.rb +11 -0
- data/lib/plumbing/observable.rb +38 -0
- data/lib/plumbing/operation/dsl.rb +143 -0
- data/lib/plumbing/operation/errors.rb +11 -0
- data/lib/plumbing/operation/events.rb +46 -0
- data/lib/plumbing/operation/generator.rb +157 -0
- data/lib/plumbing/operation/mermaid.rb +34 -0
- data/lib/plumbing/operation/state.rb +19 -0
- data/lib/plumbing/operation/transition.rb +16 -0
- data/lib/plumbing/operation/wait_options.rb +12 -0
- data/lib/plumbing/operation.rb +169 -0
- data/lib/plumbing/pipeline/base.rb +115 -0
- data/lib/plumbing/pipeline/except.rb +17 -0
- data/lib/plumbing/pipeline/filter.rb +17 -0
- data/lib/plumbing/pipeline/junction.rb +15 -0
- data/lib/plumbing/pipeline/only.rb +17 -0
- data/lib/plumbing/pipeline/source.rb +11 -0
- data/lib/plumbing/pipeline.rb +27 -13
- data/lib/plumbing/provider/router.rb +156 -0
- data/lib/plumbing/provider.rb +164 -0
- data/lib/plumbing/types.rb +8 -4
- data/lib/plumbing/version.rb +1 -1
- data/lib/plumbing.rb +29 -8
- metadata +34 -27
- data/lib/plumbing/actor/kernel.rb +0 -21
- data/lib/plumbing/actor/transporter.rb +0 -64
- data/lib/plumbing/config.rb +0 -58
- data/lib/plumbing/error.rb +0 -13
- data/lib/plumbing/pipe/custom_filter.rb +0 -17
- data/lib/plumbing/pipe/filter.rb +0 -23
- data/lib/plumbing/pipe/junction.rb +0 -21
- data/lib/plumbing/pipe.rb +0 -62
- data/lib/plumbing/pipeline/contracts.rb +0 -68
- data/lib/plumbing/pipeline/operations.rb +0 -62
- data/lib/plumbing/rubber_duck/module.rb +0 -13
- data/lib/plumbing/rubber_duck/object.rb +0 -12
- data/lib/plumbing/rubber_duck/proxy.rb +0 -19
- data/lib/plumbing/rubber_duck.rb +0 -69
- data/lib/plumbing/spec/become_matchers.rb +0 -121
- data/lib/plumbing/spec/modes.rb +0 -14
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 00d6b8a7e8165f292a6a6f130e7a03ca220bf64b1be9c926313aea61d90d6ce1
|
|
4
|
+
data.tar.gz: 2b445dda48df90ff053a0890ee027241ff7b82e460f82f06dd428b4e94601ff4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
8
|
+
> **v1 is a breaking rewrite** (`0.5.2 → 1.0.0`). See the
|
|
9
|
+
> [CHANGELOG](CHANGELOG.md) for what changed.
|
|
6
10
|
|
|
7
|
-
|
|
11
|
+
## Philosophy
|
|
8
12
|
|
|
9
|
-
|
|
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
|
-
|
|
18
|
+
## Concepts
|
|
12
19
|
|
|
13
|
-
|
|
20
|
+
### Actors
|
|
14
21
|
|
|
15
|
-
|
|
22
|
+
Asynchronous, thread-safe objects. `include Plumbing::Actor`, declare typed
|
|
23
|
+
async messages, and resolve results with `await`.
|
|
16
24
|
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
+
```ruby
|
|
58
|
+
require "plumbing/actor/async" # also: add `async` to your Gemfile
|
|
59
|
+
Plumbing::Actor.uses :async
|
|
60
|
+
```
|
|
26
61
|
|
|
27
|
-
|
|
62
|
+
Actors track who called them — `current_sender` (immediate) and
|
|
63
|
+
`current_senders` (the full call-chain).
|
|
28
64
|
|
|
29
|
-
|
|
65
|
+
### Providers
|
|
30
66
|
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
41
|
-
Plumbing.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
119
|
+
```ruby
|
|
120
|
+
provider.register(path: "locate/:object", value: "object").await # => raises ArgumentError
|
|
121
|
+
```
|
|
54
122
|
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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).
|
data/lib/plumbing/actor/async.rb
CHANGED
|
@@ -1,52 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require "async"
|
|
2
|
-
|
|
3
|
-
|
|
4
|
+
require_relative "worker"
|
|
5
|
+
require_relative "message"
|
|
4
6
|
|
|
5
7
|
module Plumbing
|
|
6
8
|
module Actor
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
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
|
|
32
|
+
def active? = !@queue.closed?
|
|
34
33
|
|
|
35
|
-
def
|
|
34
|
+
def dispatch(message) = @queue.enqueue(message)
|
|
36
35
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|