sourced-component 0.1.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 +7 -0
- data/CHANGELOG.md +11 -0
- data/LICENSE.txt +21 -0
- data/README.md +862 -0
- data/Rakefile +8 -0
- data/examples/env.rb +78 -0
- data/examples/tree.rb +221 -0
- data/lib/sourced/component/dsl.rb +26 -0
- data/lib/sourced/component/env_provider.rb +167 -0
- data/lib/sourced/component/errors.rb +19 -0
- data/lib/sourced/component/events.rb +111 -0
- data/lib/sourced/component/graph.rb +60 -0
- data/lib/sourced/component/implementation.rb +83 -0
- data/lib/sourced/component/injector.rb +59 -0
- data/lib/sourced/component/mermaid.rb +36 -0
- data/lib/sourced/component/notifier.rb +44 -0
- data/lib/sourced/component/tree.rb +117 -0
- data/lib/sourced/component/version.rb +7 -0
- data/lib/sourced/component.rb +952 -0
- metadata +100 -0
data/README.md
ADDED
|
@@ -0,0 +1,862 @@
|
|
|
1
|
+
# Sourced::Component
|
|
2
|
+
|
|
3
|
+
Configuration as a tree of typed components, with dependencies and a managed lifecycle.
|
|
4
|
+
|
|
5
|
+
Every node in the tree is a `Sourced::Component`. A node can declare a type, be implemented with dependencies and lifecycle hooks (`prepare`, `build`, `start`, `stop`, `teardown`), and have subcomponents of its own. Libraries declare their own root components; applications mount them under a namespace, and implement or override their subcomponents.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
require 'sourced/component'
|
|
9
|
+
|
|
10
|
+
App = Sourced::Component.new
|
|
11
|
+
|
|
12
|
+
# 1. Declare what the component has, and the types values must satisfy
|
|
13
|
+
App.declare('db.url', String) { 'sqlite://app.db' } # with a default implementation
|
|
14
|
+
App.declare('db', DB)
|
|
15
|
+
|
|
16
|
+
# 2. Implement components, with dependencies and lifecycle hooks
|
|
17
|
+
App.component!('db', ['db.url']) do
|
|
18
|
+
build { |url| DB.new(url) }
|
|
19
|
+
start { |db, _context| db.connect }
|
|
20
|
+
teardown { |db| db.disconnect }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# 3. Boot
|
|
24
|
+
App.start!
|
|
25
|
+
|
|
26
|
+
# 4. Read values, or inject them into your classes
|
|
27
|
+
App['db'] # => #<DB ...>
|
|
28
|
+
|
|
29
|
+
class Repo
|
|
30
|
+
include App.inject('db')
|
|
31
|
+
end
|
|
32
|
+
Repo.new.db # => #<DB ...>
|
|
33
|
+
|
|
34
|
+
# 5. Shut down
|
|
35
|
+
App.teardown!
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
Add the gem to your application's Gemfile:
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
gem 'sourced-component'
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Requires Ruby 3.2+. Types are [Plumb](https://github.com/ismasan/plumb) types.
|
|
47
|
+
|
|
48
|
+
## Declaring components
|
|
49
|
+
|
|
50
|
+
`#declare(key, type = Any, &default)` adds a typed node to the tree. Values are parsed through the declared type when they're built, so a component that builds the wrong thing fails the boot.
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
T = Sourced::Component::T # Plumb::Types
|
|
54
|
+
|
|
55
|
+
App.declare('logger', T::Interface[:info, :debug])
|
|
56
|
+
App.declare('settings.retries', Integer)
|
|
57
|
+
App.declare('cache', T::Interface[:get, :set].nullable) # optional, by type
|
|
58
|
+
App.declare('anything') # Any
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
A block is a default implementation: a singleton built once, with no dependencies. It can be replaced with `#component!` or `#component`.
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
App.declare('logger', T::Interface[:info]) { Logger.new($stdout) }
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Nested keys
|
|
68
|
+
|
|
69
|
+
Dot-separated keys build the tree. Missing intermediate segments are created as *namespaces*: nodes without a type or an implementation, which are skipped by the lifecycle.
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
App.declare('sourced.db.logger', Logger)
|
|
73
|
+
# App
|
|
74
|
+
# └── sourced (namespace)
|
|
75
|
+
# └── db (namespace)
|
|
76
|
+
# └── logger (Logger)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
A component can keep declaring under the nodes it created, and can give a namespace it created a type later, so it becomes a component with a value of its own:
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
App.declare('db.url', String) { 'sqlite://app.db' }
|
|
83
|
+
App.declare('db', DB) # 'db' was a namespace, now it's a component too
|
|
84
|
+
App.component!('db', ['db.url']) { build { |url| DB.new(url) } }
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Declaring a key twice raises `DeclarationOverrideError`.
|
|
88
|
+
|
|
89
|
+
## Implementing components
|
|
90
|
+
|
|
91
|
+
`#component!(key, deps = [], provider = nil, &block)` implements a declared node as a singleton, with a block of lifecycle hooks or a [provider](#providers). Dependencies are keys of other components, and their values are passed to `build`, in order.
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
App.component!('db', ['db.url', 'logger']) do
|
|
95
|
+
prepare { require 'sequel' } # before anything is built
|
|
96
|
+
build { |url, logger| Sequel.connect(url, logger:) } # returns the component's value
|
|
97
|
+
start { |db, context| } # after everything is built
|
|
98
|
+
stop { |db| } # when it's stopped, and on shutdown, in reverse order
|
|
99
|
+
teardown { |db| db.disconnect } # once, on shutdown, after stop
|
|
100
|
+
end
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`start` and `stop` pair up, and can run more than once for the same value when a component is [stopped and started again by key](#deferred-components-and-starting-and-stopping-by-key). `teardown` runs once, when the root is torn down.
|
|
104
|
+
|
|
105
|
+
All hooks are optional. Hooks can also be any callable, and the block can take the DSL as an argument instead of being evaluated in it:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
App.component!('clock') { build(-> { Time }) }
|
|
109
|
+
App.component!('db', ['db.url']) { |c| c.build { |url| DB.new(url) } }
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Implementing a node again replaces its implementation (the last one wins), including declared defaults. Dependencies can be declared after the component that uses them: they're resolved when the component is prepared.
|
|
113
|
+
|
|
114
|
+
### Singleton and dynamic components
|
|
115
|
+
|
|
116
|
+
- `#component!` implements a singleton: built once on `#build!`, and memoized.
|
|
117
|
+
- `#component` implements a dynamic component: built on every read, ex. a per-request value. Dynamic components take the same deps and hooks, and still go through `prepare`, `start` and `teardown`, with a `nil` value.
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
App.declare('request_id', String)
|
|
121
|
+
App.component('request_id') { build { SecureRandom.uuid } }
|
|
122
|
+
|
|
123
|
+
App['request_id'] # => "8d1c..."
|
|
124
|
+
App['request_id'] # => "f30a..."
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Configs
|
|
128
|
+
|
|
129
|
+
`#config!` and `#config` are shortcuts for components that only have a build step. The block builds the value, and gets the dependencies' values:
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
App.config!('foo.bar') { 10 } # singleton
|
|
133
|
+
App.config!('with.deps', ['sourced.db']) { |db| Foo.new(db) } # singleton, with deps
|
|
134
|
+
App.config('now') { Time.now } # dynamic: built on every read
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
They're the same as:
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
App.component!('with.deps', ['sourced.db']) { build { |db| Foo.new(db) } }
|
|
141
|
+
App.component('now') { build { Time.now } }
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### Aliases
|
|
145
|
+
|
|
146
|
+
`#alias(key, target)` implements a declared node as an alias of another component: reading it reads the target. It's useful to wire one library's component to another's:
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
App.mount('sourced', Sourced)
|
|
150
|
+
App.mount('sidereal', Sidereal)
|
|
151
|
+
App.alias('sidereal.store', 'sourced.store') # sidereal uses sourced's store
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
It's like `App.config!('sidereal.store', ['sourced.store']) { |store| store }`, with a few differences:
|
|
155
|
+
|
|
156
|
+
- An alias follows its target's mode: an alias of a singleton is memoized, so it's the same object as the target, and an alias of a dynamic component is built on every read.
|
|
157
|
+
- An alias has no hooks. The target runs its own lifecycle, and the alias is a dependent like any other: it starts after the target, and waits for it if it's [deferred](#deferred-components-and-starting-and-stopping-by-key).
|
|
158
|
+
- The target's value is parsed through the alias' declared type, so a type mismatch raises `Plumb::ParseError` naming the alias.
|
|
159
|
+
- The target is a key relative to the implementing component, like any dependency. Wildcards aren't allowed (`ArgumentError`), and an alias of a namespace raises `MissingDependencyError` on `#prepare!`.
|
|
160
|
+
|
|
161
|
+
Aliases show their mode as `alias` in [`#tree`](#tree) and [`#graph`](#graph), with the target as their dependency.
|
|
162
|
+
|
|
163
|
+
### Wildcard dependencies
|
|
164
|
+
|
|
165
|
+
A dependency ending in `.*` depends on every component directly under that key, so components can be registered under a namespace without listing them anywhere else. Its value is a hash of their values, by key segment:
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
App.declare('reactors.foo', Foo) { Foo.new }
|
|
169
|
+
App.declare('reactors.bar', Bar) { Bar.new }
|
|
170
|
+
|
|
171
|
+
App.declare('runner', Runner)
|
|
172
|
+
App.config!('runner', ['logger', 'reactors.*']) do |logger, reactors|
|
|
173
|
+
Runner.new(logger, reactors) # reactors => { 'foo' => <Foo>, 'bar' => <Bar> }
|
|
174
|
+
end
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
- The components under the key are dependencies like any other: they're prepared, built and started before the component that depends on them, and torn down after it.
|
|
178
|
+
- Wildcards are resolved on `#prepare!`, once the tree is locked, so components declared after the dependent are included.
|
|
179
|
+
- Only direct children are included. Nested namespaces are skipped (`reactors.nested.deep` isn't included), but implemented components are included even if they have children of their own.
|
|
180
|
+
- If nothing is under the key, the value is an empty hash.
|
|
181
|
+
- Like other dependencies, wildcards are [relative to the implementing component](#dependencies-are-relative-to-the-implementing-component).
|
|
182
|
+
- The wildcard can only be the last segment: `reactors.*.foo` and `reactors.f*` raise `ArgumentError`.
|
|
183
|
+
- A library can let the applications that mount it register components under one of its namespaces: see [Extension points](#extension-points).
|
|
184
|
+
|
|
185
|
+
### Providers
|
|
186
|
+
|
|
187
|
+
Instead of a block, `#component!` and `#component` take a provider that implements the component. A provider is either:
|
|
188
|
+
|
|
189
|
+
- **a callable**, called with the dependencies' values as the build step:
|
|
190
|
+
|
|
191
|
+
```ruby
|
|
192
|
+
class DBFactory
|
|
193
|
+
def self.call(url) = DB.new(url)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
App.component!('db', ['db.url'], DBFactory)
|
|
197
|
+
App.component!('clock', -> { Time }) # no dependencies
|
|
198
|
+
App.component('request_id', -> { SecureRandom.uuid }) # dynamic
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
- **or an object with `#builder_for(node)`**, which returns that callable for the node it's implementing. Use it for providers that need to know about the node, ex. its `type` or `path`. `Sourced::Component::ENVProvider` is one:
|
|
202
|
+
|
|
203
|
+
```ruby
|
|
204
|
+
App.component!('user.email', Sourced::Component::ENVProvider.new('USER_EMAIL'))
|
|
205
|
+
App.component!('user.info', Sourced::Component::ENVProvider.new(/^USER_/, :downcase))
|
|
206
|
+
App.component!('everything', Sourced::Component::ENVProvider) # all variables
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
The callable (the provider itself, or what `#builder_for` returns) can also implement any of `#prepare`, `#start(value, context)`, `#stop(value)` and `#teardown(value)`, which become the component's other lifecycle hooks. Hooks it leaves out are skipped, so plain lambdas only build. A provider can supply a whole lifecycle:
|
|
210
|
+
|
|
211
|
+
```ruby
|
|
212
|
+
class PoolProvider
|
|
213
|
+
def self.builder_for(node) = new(node)
|
|
214
|
+
|
|
215
|
+
def initialize(node) = @node = node
|
|
216
|
+
def call(url) = Pool.new(url, name: @node.path) # build
|
|
217
|
+
def start(pool, _context) = pool.connect
|
|
218
|
+
def teardown(pool) = pool.close
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
App.component!('db.pool', ['db.url'], PoolProvider)
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Provided values are parsed through the declared type, like any other. Passing both a provider and a block raises `ArgumentError`, and so do a provider that responds to neither `#call` nor `#builder_for`, and a `#builder_for` that doesn't return a callable.
|
|
225
|
+
|
|
226
|
+
### ENV components
|
|
227
|
+
|
|
228
|
+
`#env` implements singleton components built from ENV variables. Values are decoded into each declared type with [`Plumb::Codec::Forms`](https://github.com/ismasan/plumb), the codec for string input (`'3000'` → `3000`, `'true'` → `true`, `'1977-11-29'` → a `Date`). It maps ENV variables, or regexes matching them, to component keys:
|
|
229
|
+
|
|
230
|
+
```ruby
|
|
231
|
+
# ENV: USER_EMAIL=me@example.com USER_NAME=Ismael USER_DOB=1977-11-29 APP_PORT=3000
|
|
232
|
+
|
|
233
|
+
App.declare('user.email', T::Email)
|
|
234
|
+
App.declare('app.port', Integer)
|
|
235
|
+
App.declare('user.info', T::Data[name: String, dob: Date])
|
|
236
|
+
|
|
237
|
+
# Single variables
|
|
238
|
+
App.env('USER_EMAIL' => 'user.email', 'APP_PORT' => 'app.port')
|
|
239
|
+
|
|
240
|
+
# Variables matching a regex, collected into a hash and decoded into a struct
|
|
241
|
+
App.env(:downcase, /^USER_/ => 'user.info')
|
|
242
|
+
|
|
243
|
+
App.start!
|
|
244
|
+
App['user.email'] # => "me@example.com"
|
|
245
|
+
App['app.port'] # => 3000
|
|
246
|
+
App['user.info'] # => #<User name="Ismael" dob=1977-11-29>
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
| Call | Reads |
|
|
250
|
+
| --- | --- |
|
|
251
|
+
| `env('USER_EMAIL' => 'user.email')` | the `USER_EMAIL` variable |
|
|
252
|
+
| `env(/^USER_/ => 'user.info')` | variables matching the regex, into a hash, with the match removed: `USER_NAME` → `NAME` |
|
|
253
|
+
| `env(:downcase, /^USER_/ => 'user.info')` | the same, with modifiers applied to the names: `USER_NAME` → `name` |
|
|
254
|
+
| `env('user.info')` | all variables, into a hash |
|
|
255
|
+
| `env(:downcase, 'user.info')` | all variables, with modifiers |
|
|
256
|
+
|
|
257
|
+
- **Collecting into a hash:**
|
|
258
|
+
- The matched part is removed from each name, then modifiers are applied (only `:downcase` so far). Names left empty are skipped.
|
|
259
|
+
- The hash is decoded into the declared type, and variables that aren't attributes are ignored. Keys come out as the type expects them: symbols for `Data` structs, `Hash[name: …]` schemas and `Hash[Symbol, …]` maps, and strings for `Hash[String, …]` maps and string-keyed schemas.
|
|
260
|
+
- **The declared type must take a hash:** a `Hash` schema or map, or a `Data` struct, including nullable ones, ones with defaults, or unions with a hash branch. This is checked when `#env` is called, so `env(/^USER_/ => 'user.email')` with a `String` type raises `ArgumentError` right away, suggesting a single variable instead.
|
|
261
|
+
- **Modifiers only apply when collecting** with a regex, or all variables. Using one with a single variable raises `ArgumentError`, and so does an unknown modifier.
|
|
262
|
+
- **Prefer a regex to collecting everything.** ENV is shared by the whole process, so `env(:downcase, 'user.info')` would read a `user` or `home` attribute from the system's `USER` or `HOME`.
|
|
263
|
+
- **One call can map several sources,** ex. `env(:downcase, /^USER_/ => 'user.info', /^APP_/ => 'app.settings')`. Every source, key and type is checked before any component is implemented.
|
|
264
|
+
- **ENV is read on `#build!`,** not when `#env` is called.
|
|
265
|
+
- **Keys are relative to the component** `#env` is called on, like `#component!`. An app can implement a mounted library's components from ENV: `App.env('DB_URL' => 'my_lib.db.url')`.
|
|
266
|
+
- **Untyped components** (declared without a type, so `Any`) get raw strings: the variable's value, or a hash of them with string keys.
|
|
267
|
+
- **Missing or invalid variables fail the build** with `Sourced::Component::ENVProvider::Error` (a `Plumb::ParseError`) naming the component and each variable. Values are left out of the message, since ENV often holds secrets. Use a nullable type, an optional attribute or a default if a variable may be absent.
|
|
268
|
+
|
|
269
|
+
```
|
|
270
|
+
invalid ENV for user.email: USER_EMAIL is missing
|
|
271
|
+
|
|
272
|
+
invalid ENV for user.info:
|
|
273
|
+
USER_DOB is invalid: Must match /\A\d{4}-\d{2}-\d{2}\z/
|
|
274
|
+
email is missing from ENV variables matching /^USER_/
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
If a missing attribute matches a variable in a different case, the message suggests `:downcase`.
|
|
278
|
+
|
|
279
|
+
Under the hood, `#env` implements components with [`ENVProvider`](#providers), which can also be used directly, ex. for a dynamic component that reads ENV on every read: `App.component('flag', Sourced::Component::ENVProvider.new('FLAG'))`.
|
|
280
|
+
|
|
281
|
+
See [examples/env.rb](examples/env.rb).
|
|
282
|
+
|
|
283
|
+
## Reading values
|
|
284
|
+
|
|
285
|
+
`#[]` reads a component's value by key, once the component is built.
|
|
286
|
+
|
|
287
|
+
```ruby
|
|
288
|
+
App['db']
|
|
289
|
+
App['settings.retries']
|
|
290
|
+
App.node('db') # the node itself, a Sourced::Component
|
|
291
|
+
App.declared?('db') # => true
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
Reads don't lock, and raise:
|
|
295
|
+
|
|
296
|
+
- `NotBuiltError` before the component is built
|
|
297
|
+
- `UndeclaredComponentError` for unknown keys, or for namespaces, which have no value
|
|
298
|
+
|
|
299
|
+
Values stay readable after teardown.
|
|
300
|
+
|
|
301
|
+
## Lifecycle
|
|
302
|
+
|
|
303
|
+
The root component drives the lifecycle of the whole tree. Each step runs the matching hook of every component, in dependency order (dependencies first), and moves every component to a new status:
|
|
304
|
+
|
|
305
|
+
| Method | Does | Status |
|
|
306
|
+
| --- | --- | --- |
|
|
307
|
+
| `#prepare!` | Checks the tree (see below), sorts components by dependency, runs `prepare` hooks | `:prepared` |
|
|
308
|
+
| `#build!` | Builds singletons and parses their values through their types | `:built` |
|
|
309
|
+
| `#start!(context = Thread.current)` | Runs `start` hooks with `(value, context)` | `:started` |
|
|
310
|
+
| `#teardown!` | Runs `stop` hooks on started components, then `teardown` hooks, with `(value)`, in reverse order | `:torn_down` |
|
|
311
|
+
|
|
312
|
+
Each step runs the ones before it if needed (`#start!` prepares and builds), and is idempotent. `#teardown!` is a no-op unless the component is started. `:torn_down` is terminal: `#start!` on a torn down component raises `TornDownError`.
|
|
313
|
+
|
|
314
|
+
```ruby
|
|
315
|
+
App.boot_status # => :started, the root's status
|
|
316
|
+
App.node('db').status # => :started, a component's status
|
|
317
|
+
App.ordered_nodes # components in dependency order, once prepared
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
`#prepare!` raises:
|
|
321
|
+
|
|
322
|
+
- `UnimplementedComponentError` for declared components without an implementation, listing every one
|
|
323
|
+
- `MissingDependencyError` for dependencies that aren't declared, or that are namespaces without an implementation
|
|
324
|
+
- `CircularDependencyError` for dependency cycles
|
|
325
|
+
|
|
326
|
+
Once prepared, the tree is locked: declaring, implementing or mounting anything raises `LockedComponentError`.
|
|
327
|
+
|
|
328
|
+
### Long-running components
|
|
329
|
+
|
|
330
|
+
`start` hooks run while holding the root's lock, so components that do long-running work (workers, servers, pollers) should spawn a thread or fiber and return. The context passed to `#start!` (the current thread by default) can be used to spawn work in a particular place, ex. an Async task.
|
|
331
|
+
|
|
332
|
+
```ruby
|
|
333
|
+
App.declare('worker', Worker)
|
|
334
|
+
App.component!('worker', ['db']) do
|
|
335
|
+
build { |db| Worker.new(db) }
|
|
336
|
+
start { |worker, _context| worker.start } # spawns a thread and returns
|
|
337
|
+
teardown { |worker| worker.stop } # signals the thread, and joins it
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
App.start!
|
|
341
|
+
trap('TERM') { Thread.main.raise(Interrupt) }
|
|
342
|
+
begin
|
|
343
|
+
sleep # components run in their own threads
|
|
344
|
+
rescue Interrupt
|
|
345
|
+
ensure
|
|
346
|
+
App.teardown!
|
|
347
|
+
end
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
Components that depend on others start after them and are torn down before them, so a producer that depends on a worker never pushes work to a stopped worker. See [examples/tree.rb](examples/tree.rb).
|
|
351
|
+
|
|
352
|
+
### Deferred components, and starting and stopping by key
|
|
353
|
+
|
|
354
|
+
`#defer(key)` makes the root's `#start!` skip a component, along with every component that depends on it, directly or not, since they can't start before it. Start it by key instead, when it should run, and stop it by key when it shouldn't, any number of times, ex. workers that only run while their process holds a leader lock:
|
|
355
|
+
|
|
356
|
+
```ruby
|
|
357
|
+
App.defer('sourced.dispatcher')
|
|
358
|
+
App.start!(task) # everything but the dispatcher, and whatever depends on it
|
|
359
|
+
|
|
360
|
+
elector.on_promote { App.start_component!('sourced.dispatcher', task) }
|
|
361
|
+
elector.on_demote { App.stop_component!('sourced.dispatcher') }
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
Starting and stopping follow the dependency graph:
|
|
365
|
+
|
|
366
|
+
- **`#start_component!(key, context = Thread.current)`** starts any of the component's dependencies that aren't running, in dependency order, then the component, then the components depending on it that aren't running, once all their dependencies are. A no-op for a component already running.
|
|
367
|
+
- **`#stop_component!(key)`** stops every running component that depends on it, directly or not, in reverse dependency order, then the component. Its own dependencies keep running. Each one runs its `stop` hooks and keeps its value, so it can be started again: the value must support it.
|
|
368
|
+
- **`#restart_component!(key, context = Thread.current)`** stops, then starts.
|
|
369
|
+
|
|
370
|
+
```
|
|
371
|
+
db <- store <- dispatcher <- monitor
|
|
372
|
+
|
|
373
|
+
App.stop_component!('dispatcher') # stops monitor, then dispatcher
|
|
374
|
+
App.start_component!('dispatcher') # starts dispatcher, then monitor
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
- **A component stopped by key stays stopped** until it's started by key: starting one of its dependencies again doesn't start it, nor does the root. The components its stop stopped start again with it. A deferred component is the same, until its first start.
|
|
378
|
+
- **Statuses:** a deferred component is `:built` until it's started. A stopped one is `:stopped`. `#tree` and `#graph` show both, and which components are deferred.
|
|
379
|
+
- **Keys are relative to the component** the methods are called on, like `#component!`, and the root must be started, or starting: a `start` hook can start a deferred component, ex. right away when its process is already leader. Before that they raise `NotStartedError`, and `TornDownError` once the root is torn down.
|
|
380
|
+
- **Deferring belongs to the node,** not to its implementation, so implementing a deferred component again keeps it deferred. Any component can defer a node below it, like implementing one, ex. an app deferring a mounted library's component. Like declaring and implementing, it's only allowed before the tree is prepared.
|
|
381
|
+
- **If a `start` hook raises,** the components that call started are stopped again, in reverse order, and the error is re-raised. The rest of the tree is left as it was, and the root stays started.
|
|
382
|
+
- **If `stop` hooks raise,** every component is still stopped, and the first error is re-raised.
|
|
383
|
+
- **`#teardown!`** runs `stop` on the started components and `teardown` on all of them, deferred or stopped ones included.
|
|
384
|
+
|
|
385
|
+
### Signal handlers
|
|
386
|
+
|
|
387
|
+
The lifecycle methods, and anything else that takes the root's lock (declaring, implementing and mounting components, `#graph`, `#tree`), can't be called from a `trap` block: Ruby doesn't allow locking a `Monitor` in trap context, so they raise `ThreadError: can't be called from trap context` and nothing is torn down. Reading values doesn't take the lock.
|
|
388
|
+
|
|
389
|
+
Instead, have the trap wake up the main thread, and tear down from there, as in the example above:
|
|
390
|
+
|
|
391
|
+
```ruby
|
|
392
|
+
# Don't: raises ThreadError, and the components keep running
|
|
393
|
+
trap('TERM') { App.teardown! }
|
|
394
|
+
|
|
395
|
+
# Do: interrupt the main thread, and tear down in its ensure block
|
|
396
|
+
trap('TERM') { Thread.main.raise(Interrupt) }
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
Any other way out of trap context works too, ex. pushing to a `Queue` or writing to a self-pipe that a thread waits on.
|
|
400
|
+
|
|
401
|
+
### Errors while starting and tearing down
|
|
402
|
+
|
|
403
|
+
- If a `start` hook raises, the components already started are torn down in reverse order, the component is left `:torn_down`, and the error is re-raised.
|
|
404
|
+
- If `teardown` hooks raise, every component is still torn down, and the first error is re-raised.
|
|
405
|
+
|
|
406
|
+
## Mounting components
|
|
407
|
+
|
|
408
|
+
A library can declare its components in its own root component, with default implementations:
|
|
409
|
+
|
|
410
|
+
```ruby
|
|
411
|
+
module MyLib
|
|
412
|
+
def self.component
|
|
413
|
+
@component ||= Sourced::Component.new.tap do |s|
|
|
414
|
+
s.declare('logger', T::Interface[:info]) { Logger.new($stdout, progname: 'my_lib') }
|
|
415
|
+
s.declare('store', Store)
|
|
416
|
+
s.component!('store', ['logger']) { build { |logger| MemoryStore.new(logger:) } }
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
An application mounts it under a namespace with `#mount(key, mountable)`, which attaches the library's root component as a branch of the app's tree. The app reads its components under the namespace, and can implement (or re-implement) them:
|
|
423
|
+
|
|
424
|
+
```ruby
|
|
425
|
+
App.declare('db', DB) { DB.new }
|
|
426
|
+
App.mount('my_lib', MyLib.component)
|
|
427
|
+
|
|
428
|
+
# Override the library's store, with the app's db
|
|
429
|
+
App.component!('my_lib.store', ['db']) { build { |db| DBStore.new(db) } }
|
|
430
|
+
|
|
431
|
+
App.start!
|
|
432
|
+
App['my_lib.store'] # => #<DBStore ...>
|
|
433
|
+
MyLib.component['store'] # => the same object
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
Mounted components aren't copied. The tree is made of the same node objects, so the library reads the app's overrides through its own keys, ex. from classes that only know about `MyLib.component`.
|
|
437
|
+
|
|
438
|
+
Keys can be nested (`App.mount('libs.my_lib', MyLib.component)`), components can mount other components, and a mounted component can keep declaring components: every ancestor indexes them.
|
|
439
|
+
|
|
440
|
+
### Mountables
|
|
441
|
+
|
|
442
|
+
`#mount` takes anything that implements `#to_component`, returning a `Sourced::Component`. Components implement it, returning themselves, and a library can implement it so apps mount the library itself:
|
|
443
|
+
|
|
444
|
+
```ruby
|
|
445
|
+
module MyLib
|
|
446
|
+
def self.to_component = component
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
App.mount('my_lib', MyLib.component)
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
`#mount` raises `ArgumentError` for objects that don't respond to `#to_component`, or whose `#to_component` doesn't return a `Sourced::Component`.
|
|
453
|
+
|
|
454
|
+
`#component!` and `#component` mount components too: given anything that implements `#to_component`, they're an alias to `#mount`.
|
|
455
|
+
|
|
456
|
+
```ruby
|
|
457
|
+
App.component('my_lib', MyLib) # same as App.mount('my_lib', MyLib)
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
Mounting takes no provider or block, so passing either along with a component raises `ArgumentError`.
|
|
461
|
+
|
|
462
|
+
### Dependencies are relative to the implementing component
|
|
463
|
+
|
|
464
|
+
Dependency keys are resolved from the component that called `#component!` (or `#component`):
|
|
465
|
+
|
|
466
|
+
- The library's `component!('store', ['logger'])` depends on `my_lib.logger`.
|
|
467
|
+
- The app's `component!('my_lib.store', ['db'])` depends on the app's `db`.
|
|
468
|
+
|
|
469
|
+
So a library's implementations can only depend on components in its own tree, and an application wires library components to its own components by overriding them.
|
|
470
|
+
|
|
471
|
+
### Ownership
|
|
472
|
+
|
|
473
|
+
Components own their declarations and the sub-trees under them. Any component can implement any node below it, but can only declare under nodes it declared itself:
|
|
474
|
+
|
|
475
|
+
```ruby
|
|
476
|
+
App.declare('my_lib.extra', String)
|
|
477
|
+
# => Sourced::Component::OwnershipError: my_lib is owned by my_lib: declare it there. This component can only implement it
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
The root of the tree owns the lifecycle. Booting a mounted component directly (`MyLib.component.start!`) raises `SubcomponentError`: boot the root.
|
|
481
|
+
|
|
482
|
+
`#mount` raises if the component (what `#to_component` returns) is already mounted somewhere, is the root of the tree it's being mounted into, isn't open, or if the key is taken.
|
|
483
|
+
|
|
484
|
+
### Extension points
|
|
485
|
+
|
|
486
|
+
Ownership is checked against the component `#declare` is called on, not against the code calling it. So an application can add a declaration to a library's tree by declaring it through the library's own component, ex. a reactor that the library collects with a [wildcard dependency](#wildcard-dependencies):
|
|
487
|
+
|
|
488
|
+
```ruby
|
|
489
|
+
module MyLib
|
|
490
|
+
def self.component
|
|
491
|
+
@component ||= Sourced::Component.new.tap do |c|
|
|
492
|
+
c.declare('runner', Runner)
|
|
493
|
+
c.config!('runner', ['reactors.*']) { |reactors| Runner.new(reactors) }
|
|
494
|
+
end
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
# The one place where applications add to MyLib's tree
|
|
498
|
+
def self.reactor(name, type = Reactor) = component.declare("reactors.#{name}", type)
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
App.mount('my_lib', MyLib.component)
|
|
502
|
+
MyLib.reactor('emails') # declared in MyLib's tree, so reactors.* includes it
|
|
503
|
+
App.component!('my_lib.reactors.emails', ['mailer']) do # implemented by the app, with the app's deps
|
|
504
|
+
build { |mailer| EmailsReactor.new(mailer) }
|
|
505
|
+
end
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
This should be the exception. A library owns its declarations because only the library knows what to do with them: it decides which components exist, what type they have, and what depends on them. An application declaring arbitrary keys in a library's tree is relying on the library's internals, and can break when they change. So:
|
|
509
|
+
|
|
510
|
+
- Only declare under namespaces that a library documents as extension points, like `reactors` above, and leave everything else to the library.
|
|
511
|
+
- Prefer a method that the library provides for it (`MyLib.reactor`) over calling `#declare` on its component directly. The library then decides the key and type, and can change how it stores them.
|
|
512
|
+
- Implement the declared components from the application, so they can depend on the application's components. Implementations given to the library's component resolve their dependencies from the library's tree.
|
|
513
|
+
- Declare before the root is prepared. Once it's prepared, the whole tree is locked, including mounted components.
|
|
514
|
+
|
|
515
|
+
## Dependency injection
|
|
516
|
+
|
|
517
|
+
`#inject(*keys)` builds a module that injects components into a class, as keyword arguments to `#initialize` with readers. Each one defaults to the component's value, read when the object is instantiated.
|
|
518
|
+
|
|
519
|
+
```ruby
|
|
520
|
+
class Dispatcher
|
|
521
|
+
include App.inject('logger', 'my_lib.store')
|
|
522
|
+
|
|
523
|
+
def dispatch(event)
|
|
524
|
+
store.append(event)
|
|
525
|
+
logger.info("dispatched #{event}")
|
|
526
|
+
end
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
Dispatcher.new.store # => App['my_lib.store']
|
|
530
|
+
Dispatcher.new(store: FakeStore.new) # any dependency can be passed explicitly, ex. in tests
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
- Kwargs are named after the last segment of each key (`'my_lib.store'` => `store`). A hash gives them custom names: `App.inject('my_lib.store' => 'st')`.
|
|
534
|
+
- Keys are relative to the component `#inject` is called on: `App.node('my_lib').inject('store')`.
|
|
535
|
+
- Injections compose: a class can include several, and keep its own `#initialize` (positional and keyword arguments are passed through). Subclasses inherit them.
|
|
536
|
+
- Values are read on instantiation, so classes can be defined before the component is built, and dynamic components give each object a fresh value. Instantiating before the component is built raises `NotBuiltError`.
|
|
537
|
+
- Injecting an undeclared key raises `UndeclaredComponentError`, and injecting two components under the same name raises `ArgumentError`.
|
|
538
|
+
- Injections never overwrite methods: including one raises `InjectionError` if the class already has a method with an injected name, defined in the class or inherited, including private ones like `Kernel#format`. Inject under another name instead: `App.inject('logger' => 'app_logger')`. Methods defined after the include are the class' own, and replace the reader as usual.
|
|
539
|
+
|
|
540
|
+
Injectors hold on to the nodes themselves, so a library's classes can inject from the library's own root component, and get the overrides of the application that mounts it:
|
|
541
|
+
|
|
542
|
+
```ruby
|
|
543
|
+
module MyLib
|
|
544
|
+
class Dispatcher
|
|
545
|
+
include MyLib.component.inject('store')
|
|
546
|
+
end
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
App.mount('my_lib', MyLib.component)
|
|
550
|
+
App.component!('my_lib.store', ['db']) { build { |db| DBStore.new(db) } }
|
|
551
|
+
App.start!
|
|
552
|
+
|
|
553
|
+
MyLib::Dispatcher.new.store # => #<DBStore ...>, the app's override
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
## Inspecting the tree
|
|
557
|
+
|
|
558
|
+
```ruby
|
|
559
|
+
App.index.keys
|
|
560
|
+
# => ["db", "my_lib", "my_lib.logger", "my_lib.store"]
|
|
561
|
+
|
|
562
|
+
App.node('my_lib.store')
|
|
563
|
+
# => #<Sourced::Component my_lib.store Store (singleton, started)>
|
|
564
|
+
|
|
565
|
+
node = App.node('my_lib.store')
|
|
566
|
+
node.path # => "my_lib.store"
|
|
567
|
+
node.key # => "store"
|
|
568
|
+
node.parent # => the my_lib component
|
|
569
|
+
node.root # => App
|
|
570
|
+
node.owner # => the component that declared it
|
|
571
|
+
node.type # => the declared type
|
|
572
|
+
node.implementation # => deps, mode (:singleton, :dynamic or :alias) and the implementing component
|
|
573
|
+
node.children # => { segment => Component }
|
|
574
|
+
node.namespace? # => no type and no implementation
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
### `#tree`
|
|
578
|
+
|
|
579
|
+
Returns a `Sourced::Component::Tree` of the components under a component: how components are nested, which components are mounted, and who declared and implemented each one. (`#graph`, below, shows how components depend on each other instead.)
|
|
580
|
+
|
|
581
|
+
```ruby
|
|
582
|
+
puts App.tree
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
```
|
|
586
|
+
(root)
|
|
587
|
+
├── db DB (singleton, started)
|
|
588
|
+
├── my_lib [mounted]
|
|
589
|
+
│ ├── logger Interface[info] (singleton, started)
|
|
590
|
+
│ └── store Store (singleton, started) implemented by (root)
|
|
591
|
+
└── cache
|
|
592
|
+
└── redis String (singleton, started)
|
|
593
|
+
└── pool Integer (not implemented, open)
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
- **Mounted components** are marked `[mounted]`, and namespaces (no type, no implementation) are shown by their key alone.
|
|
597
|
+
- **`implemented by`** marks components implemented by a component other than the one that declared them, ex. an app overriding a library's component. `(root)` is the root of the tree.
|
|
598
|
+
- **Called on a mounted component,** it renders only the tree under it: `MyLib.component.tree`.
|
|
599
|
+
|
|
600
|
+
`tree.root` is a `Tree::Node`, with `#children`, and `tree.to_h` returns nested hashes:
|
|
601
|
+
|
|
602
|
+
```ruby
|
|
603
|
+
tree = App.tree
|
|
604
|
+
tree.status # => :started, the root's status
|
|
605
|
+
tree.root.children.map(&:key) # => ["db", "my_lib", "cache"]
|
|
606
|
+
|
|
607
|
+
store = tree.root.children[1].children[1]
|
|
608
|
+
store.key # => "store"
|
|
609
|
+
store.path # => "my_lib.store"
|
|
610
|
+
store.type # => the declared type
|
|
611
|
+
store.type_name # => "Store"
|
|
612
|
+
store.namespace # => false
|
|
613
|
+
store.mounted # => false, true for mounted components
|
|
614
|
+
store.implemented # => true
|
|
615
|
+
store.mode # => :singleton
|
|
616
|
+
store.status # => :started
|
|
617
|
+
store.owner # => "my_lib", the full path of the component that declared it (nil for the root)
|
|
618
|
+
store.implementer # => nil, the full path of the component that implemented it (nil for the root)
|
|
619
|
+
store.overridden? # => true: implemented by a component other than its owner
|
|
620
|
+
store.children # => []
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
`Tree#to_mermaid` returns a top-down [Mermaid](https://mermaid.js.org) flowchart of the tree:
|
|
624
|
+
|
|
625
|
+
```ruby
|
|
626
|
+
puts App.tree.to_mermaid
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
```mermaid
|
|
630
|
+
flowchart TD
|
|
631
|
+
n0{{"(root)"}}:::component
|
|
632
|
+
n1["db<br/>DB<br/><i>singleton, started</i>"]:::started
|
|
633
|
+
n2{{"my_lib"}}:::component
|
|
634
|
+
n3["logger<br/>Interface[info]<br/><i>singleton, started</i>"]:::started
|
|
635
|
+
n4["store<br/>Store<br/><i>singleton, started</i><br/><i>implemented by (root)</i>"]:::started
|
|
636
|
+
n5("cache"):::namespace
|
|
637
|
+
n6["redis<br/>String<br/><i>singleton, started</i>"]:::started
|
|
638
|
+
n7["pool<br/>Integer<br/><i>not implemented</i>"]:::unimplemented
|
|
639
|
+
n0 --> n1
|
|
640
|
+
n0 --> n2
|
|
641
|
+
n2 --> n3
|
|
642
|
+
n2 --> n4
|
|
643
|
+
n0 --> n5
|
|
644
|
+
n5 --> n6
|
|
645
|
+
n6 --> n7
|
|
646
|
+
classDef started fill:#dcfce7,stroke:#16a34a
|
|
647
|
+
classDef unimplemented fill:#fef9c3,stroke:#ca8a04,stroke-dasharray:4 3
|
|
648
|
+
classDef namespace fill:#ffffff,stroke:#a1a1aa
|
|
649
|
+
classDef component fill:#fafafa,stroke:#18181b,stroke-width:2px
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
- **Edges point from each node to its children.**
|
|
653
|
+
- **Components** (the root of the tree, and mounted components) are hexagons, and **namespaces** are rounded and plain.
|
|
654
|
+
- **Components** are drawn like in `Graph#to_mermaid`: singletons are rectangles, dynamic components are rounded, colored by status, and unimplemented ones are yellow and dashed. Components implemented by another component say which one.
|
|
655
|
+
|
|
656
|
+
### `#graph`
|
|
657
|
+
|
|
658
|
+
Returns a `Sourced::Component::Graph` describing the components under a component, useful for tooling, visualisation or debugging. Components are listed by their full path from the root, in dependency order once the tree is prepared, and in declaration order before that. Namespaces without an implementation are left out.
|
|
659
|
+
|
|
660
|
+
```ruby
|
|
661
|
+
graph = App.graph
|
|
662
|
+
graph.status # => :built, the root's status
|
|
663
|
+
graph.components # => an array of hashes, one per component
|
|
664
|
+
|
|
665
|
+
graph.to_h
|
|
666
|
+
# {
|
|
667
|
+
# status: :built,
|
|
668
|
+
# components: [
|
|
669
|
+
# {
|
|
670
|
+
# key: 'my_lib.store', # full path from the root
|
|
671
|
+
# type: <Plumb type>, # the declared type
|
|
672
|
+
# type_name: 'Interface[append]', # readable version of it
|
|
673
|
+
# implemented: true,
|
|
674
|
+
# mode: :singleton, # :singleton, :dynamic or :alias. nil if not implemented
|
|
675
|
+
# status: :built,
|
|
676
|
+
# deps: ['db'], # full paths of the components this one depends on
|
|
677
|
+
# missing: [], # deps that aren't declared, or are namespaces without an implementation
|
|
678
|
+
# dependents: ['app'], # components in the graph that depend on this one
|
|
679
|
+
# provider: <provider> # the provider, or the block of #config!/#config. nil for blocks of hooks
|
|
680
|
+
# },
|
|
681
|
+
# ...
|
|
682
|
+
# ]
|
|
683
|
+
# }
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
It's a graph rather than a tree, since several components can share a dependency. Each component lists both `deps` and `dependents`, so you can walk the graph in either direction. Dependencies are resolved from the component that implemented each component, so an app's override of a library component lists the app's dependencies.
|
|
687
|
+
|
|
688
|
+
Calling `#graph` on a mounted component describes only the components under it, ex. `MyLib.component.graph` lists `my_lib.*`. Their `deps` can point outside it (to an app's components, through its overrides), and `dependents` only include components in that graph.
|
|
689
|
+
|
|
690
|
+
### Mermaid diagrams
|
|
691
|
+
|
|
692
|
+
`Graph#to_mermaid` returns a [Mermaid](https://mermaid.js.org) flowchart of the dependency graph, which GitHub, many docs tools and editors render natively.
|
|
693
|
+
|
|
694
|
+
```ruby
|
|
695
|
+
puts App.graph.to_mermaid
|
|
696
|
+
```
|
|
697
|
+
|
|
698
|
+
```mermaid
|
|
699
|
+
flowchart LR
|
|
700
|
+
c0["db<br/>DB<br/><i>singleton, started</i>"]:::started
|
|
701
|
+
c1["my_lib.logger<br/>Interface[info]<br/><i>singleton, started</i>"]:::started
|
|
702
|
+
c2["my_lib.store<br/>Interface[append]<br/><i>singleton, started</i>"]:::started
|
|
703
|
+
c3(["request_id<br/>String<br/><i>dynamic, started</i>"]):::started
|
|
704
|
+
c0 --> c2
|
|
705
|
+
classDef started fill:#dcfce7,stroke:#16a34a
|
|
706
|
+
```
|
|
707
|
+
|
|
708
|
+
- **Arrows point from each dependency to the components that depend on it,** which is the order they're built and started in.
|
|
709
|
+
- **Each node shows** the full path, the declared type, and the mode and status.
|
|
710
|
+
- **Shapes:** singletons are rectangles and dynamic components are rounded.
|
|
711
|
+
- **Nodes are colored by status.** Declared but unimplemented components (yellow) and dependencies that aren't declared (red) have dashed borders, so problems that `#prepare!` would reject are visible in the diagram. In a mounted component's graph, dependencies outside it are drawn with a light dashed border.
|
|
712
|
+
- **Label text is escaped,** so type names with brackets, pipes or quotes are safe.
|
|
713
|
+
|
|
714
|
+
## Events
|
|
715
|
+
|
|
716
|
+
Declaring, implementing and every lifecycle step publish an event to the root's notifier, ex. for telemetry:
|
|
717
|
+
|
|
718
|
+
```ruby
|
|
719
|
+
App.notifier.subscribe('components.built') do |event|
|
|
720
|
+
Metrics.timing("boot.#{event.payload.key}", event.payload.duration)
|
|
721
|
+
end
|
|
722
|
+
```
|
|
723
|
+
|
|
724
|
+
### Event types
|
|
725
|
+
|
|
726
|
+
| Type | When | Payload |
|
|
727
|
+
| --- | --- | --- |
|
|
728
|
+
| `components.declared` | `#declare` | `key`, `type_name` |
|
|
729
|
+
| `components.implemented` | a component is implemented, or re-implemented | `key`, `mode`, `deps`, `implementer`, `override` |
|
|
730
|
+
| `components.preparing` / `components.prepared` | around a component's `prepare` hooks | `key`, and `duration` when finished |
|
|
731
|
+
| `components.building` / `components.built` | around a **singleton**'s `build` hooks | `key`, and `duration` when finished |
|
|
732
|
+
| `components.starting` / `components.started` | around a component's `start` hooks | `key`, and `duration` when finished |
|
|
733
|
+
| `components.stopping` / `components.stopped` | around a component's `stop` hooks, when stopped by key (on `#teardown!`, they're part of tearing down) | `key`, and `duration` when finished |
|
|
734
|
+
| `components.tearing_down` / `components.torn_down` | around a component's `teardown` hooks | `key`, and `duration` when finished |
|
|
735
|
+
| `components.deferred` | `#defer` | `key`, `deferrer` (the full path of the component that deferred it, `nil` for the root) |
|
|
736
|
+
| `components.failed` | a component's hook (or type check) raised | `key`, `stage`, `error_class`, `error_message`, `backtrace` |
|
|
737
|
+
| `root.preparing` / `root.prepared` | around `#prepare!` | `duration` when finished |
|
|
738
|
+
| `root.building` / `root.built` | around `#build!` | `duration` when finished |
|
|
739
|
+
| `root.starting` / `root.started` | around `#start!` | `duration` when finished |
|
|
740
|
+
| `root.tearing_down` / `root.torn_down` | around `#teardown!` | `duration` when finished |
|
|
741
|
+
| `root.failed` | a lifecycle step raised | `stage`, `error_class`, `error_message`, `backtrace` |
|
|
742
|
+
|
|
743
|
+
- **Every payload also has `pid`, `thread_id` and `fiber_id`:** the process, thread and fiber the event was published from (`Process.pid`, `Thread.current.object_id`, `Fiber.current.object_id`). Lifecycle events are published by whatever runs that step, so `components.started` shows where a component started.
|
|
744
|
+
- **`key`** is the component's full path from the root, ex. `sourced.db`.
|
|
745
|
+
- **`deps`** are relative to the `implementer`, the full path of the component that implemented the component (`nil` for the root).
|
|
746
|
+
- **`duration`** is in seconds, measured with a monotonic clock.
|
|
747
|
+
- **`stage`** is one of `:prepare`, `:build`, `:start`, `:stop` or `:teardown`.
|
|
748
|
+
- **Errors are described, not attached:** `error_class`, `error_message` and `backtrace` are strings, so events stay serializable (see below). The error itself is re-raised to the caller of the lifecycle method.
|
|
749
|
+
|
|
750
|
+
A few rules:
|
|
751
|
+
|
|
752
|
+
- **Build events are only published for singletons.** Dynamic components are built on every read, and publishing each one would be too noisy.
|
|
753
|
+
- **Events are only published for steps that run.** Repeat calls to `#prepare!`, `#build!` and friends are silent.
|
|
754
|
+
- **Failures are published before the error is re-raised.** A failed `#start!` publishes, in order: `components.failed` for the failing component, the teardown events from the rollback, then `root.failed`.
|
|
755
|
+
|
|
756
|
+
```
|
|
757
|
+
root.starting
|
|
758
|
+
components.starting a → components.started a
|
|
759
|
+
components.starting b → components.failed b
|
|
760
|
+
components.tearing_down a → components.torn_down a
|
|
761
|
+
root.failed
|
|
762
|
+
```
|
|
763
|
+
|
|
764
|
+
### Event classes
|
|
765
|
+
|
|
766
|
+
Events are [`Sourced::Message`](https://github.com/ismasan/sourced-message) structs, all subclasses of `Sourced::Component::Event`:
|
|
767
|
+
|
|
768
|
+
```
|
|
769
|
+
Sourced::Message
|
|
770
|
+
└── Sourced::Component::Event # payload: pid, thread_id, fiber_id
|
|
771
|
+
├── Sourced::Component::Events::RootEvent # root.*
|
|
772
|
+
└── Sourced::Component::Events::ComponentEvent # components.*, adds key
|
|
773
|
+
```
|
|
774
|
+
|
|
775
|
+
Each event type is a class, ex. `Sourced::Component::Events::ComponentBuilt`, with the usual message attributes (`id`, `type`, `created_at`, `metadata`, `payload`, ...). They're registered in `Sourced::Component::Event.registry`, which is also visible from `Sourced::Message.registry`:
|
|
776
|
+
|
|
777
|
+
```ruby
|
|
778
|
+
Sourced::Component::Event.registry['components.built'] # => Sourced::Component::Events::ComponentBuilt
|
|
779
|
+
```
|
|
780
|
+
|
|
781
|
+
So events can be serialized with Sourced::Message codecs, ex. to ship them to another process:
|
|
782
|
+
|
|
783
|
+
```ruby
|
|
784
|
+
codec = Sourced::Message::JSONCodec.default.compile!
|
|
785
|
+
App.notifier.subscribe(Sourced::Component::Event) { |event| queue << JSON.dump(codec.encode(event)) }
|
|
786
|
+
```
|
|
787
|
+
|
|
788
|
+
That's also why payloads only hold JSON-friendly values: `JSONCodec#compile!` checks every message type in the process, these events included.
|
|
789
|
+
|
|
790
|
+
### The default notifier
|
|
791
|
+
|
|
792
|
+
`Sourced::Component.new` creates a `Sourced::Component::Notifier`. Every component in a tree publishes to its root's notifier, so `App.notifier` and `MyLib.component.notifier` are the same once `MyLib` is mounted. Events published by a component before it's mounted (ex. a library's declarations) go to its own notifier, so subscribe on the root before mounting and declaring, or before booting for lifecycle events.
|
|
793
|
+
|
|
794
|
+
```ruby
|
|
795
|
+
# by type string (or symbol)
|
|
796
|
+
App.notifier.subscribe('root.started') { |event| ... }
|
|
797
|
+
|
|
798
|
+
# by class. Also matches subclasses
|
|
799
|
+
App.notifier.subscribe(Sourced::Component::Events::ComponentStarted) { |event| ... }
|
|
800
|
+
App.notifier.subscribe(Sourced::Component::Events::ComponentEvent) { |event| ... } # all component events
|
|
801
|
+
App.notifier.subscribe(Sourced::Component::Event) { |event| ... } # everything
|
|
802
|
+
```
|
|
803
|
+
|
|
804
|
+
- **Unknown type strings raise `ArgumentError`,** so a typo can't silently subscribe to nothing.
|
|
805
|
+
- **Handlers run synchronously,** in the order they subscribed. They run in the thread or fiber performing the lifecycle step, while it holds the root's lock. Keep handlers fast, or hand the work off to a queue.
|
|
806
|
+
- **Errors raised by handlers propagate.** A handler that raises during `#start!` fails the boot and triggers the rollback.
|
|
807
|
+
- **Subscribing is thread safe,** and it can happen at any time, including after the component is locked.
|
|
808
|
+
|
|
809
|
+
### Custom notifiers
|
|
810
|
+
|
|
811
|
+
Pass any object that responds to `#publish(event)` and `#subscribe(event_class_or_type, &block)` to the root:
|
|
812
|
+
|
|
813
|
+
```ruby
|
|
814
|
+
class OTelNotifier
|
|
815
|
+
def publish(event) = Tracer.add_event(event.type, attributes: event.payload.to_h)
|
|
816
|
+
def subscribe(...) = raise(NotImplementedError)
|
|
817
|
+
end
|
|
818
|
+
|
|
819
|
+
App = Sourced::Component.new(notifier: OTelNotifier.new)
|
|
820
|
+
```
|
|
821
|
+
|
|
822
|
+
The notifier is checked when the component is created. An object without these methods raises `Plumb::ParseError`.
|
|
823
|
+
|
|
824
|
+
## Errors
|
|
825
|
+
|
|
826
|
+
All errors inherit from `Sourced::Component::ComponentError`, except type mismatches, which raise `Plumb::ParseError` naming the component (without the value, which can hold secrets):
|
|
827
|
+
|
|
828
|
+
```
|
|
829
|
+
Plumb::ParseError: db.port: Must be a Integer
|
|
830
|
+
Plumb::ParseError: user: {age: "Must be a Integer"}
|
|
831
|
+
```
|
|
832
|
+
|
|
833
|
+
| Error | Raised when |
|
|
834
|
+
| --- | --- |
|
|
835
|
+
| `DeclarationOverrideError` | declaring or mounting on a key that's already declared |
|
|
836
|
+
| `OwnershipError` | declaring or mounting under nodes owned by another component |
|
|
837
|
+
| `LockedComponentError` | changing the tree after it's prepared, or mounting a component that isn't open |
|
|
838
|
+
| `SubcomponentError` | booting a mounted component, or mounting a component that's already mounted |
|
|
839
|
+
| `UndeclaredComponentError` | implementing or reading an undeclared key, or reading a namespace |
|
|
840
|
+
| `UnimplementedComponentError` | preparing with declared (or deferred) components that have no implementation |
|
|
841
|
+
| `MissingDependencyError` | preparing with dependencies that aren't declared or implemented |
|
|
842
|
+
| `CircularDependencyError` | preparing with dependency cycles |
|
|
843
|
+
| `NotBuiltError` | reading values before the component is built |
|
|
844
|
+
| `TornDownError` | starting a component that's torn down, or starting or stopping a component by key once its root is torn down |
|
|
845
|
+
| `NotStartedError` | starting or stopping a component by key before its root is started |
|
|
846
|
+
| `InjectionError` | including an injector in a class that already has a method with an injected name, or already injects it |
|
|
847
|
+
|
|
848
|
+
## Thread safety
|
|
849
|
+
|
|
850
|
+
Declaring, implementing, mounting and lifecycle methods are synchronized with a `Monitor` on the root of the tree (mounted components share their host's). A component can be booted from multiple threads: concurrent callers wait for the first one to finish, and then no-op. Reads (including injected defaults) take no lock, as values are immutable once built.
|
|
851
|
+
|
|
852
|
+
## Development
|
|
853
|
+
|
|
854
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `bundle exec rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
855
|
+
|
|
856
|
+
Run the example with `bundle exec ruby examples/tree.rb` (Ctrl-C to stop).
|
|
857
|
+
|
|
858
|
+
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).
|
|
859
|
+
|
|
860
|
+
## License
|
|
861
|
+
|
|
862
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|