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
|
@@ -0,0 +1,952 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'monitor'
|
|
4
|
+
require 'tsort'
|
|
5
|
+
require 'plumb'
|
|
6
|
+
require_relative 'component/version'
|
|
7
|
+
require_relative 'component/errors'
|
|
8
|
+
require_relative 'component/dsl'
|
|
9
|
+
require_relative 'component/implementation'
|
|
10
|
+
require_relative 'component/injector'
|
|
11
|
+
require_relative 'component/env_provider'
|
|
12
|
+
require_relative 'component/mermaid'
|
|
13
|
+
require_relative 'component/graph'
|
|
14
|
+
require_relative 'component/events'
|
|
15
|
+
require_relative 'component/notifier'
|
|
16
|
+
require_relative 'component/tree'
|
|
17
|
+
|
|
18
|
+
module Sourced
|
|
19
|
+
# A tree of components. Every node is a Component: it can declare a type, be implemented with
|
|
20
|
+
# dependencies and lifecycle hooks, have its own lifecycle status, and have subcomponents.
|
|
21
|
+
# app.declare('sourced.db', DB) # builds the 'sourced' > 'db' branch
|
|
22
|
+
# app.component!('sourced.db', ['logger']) { build { |logger| DB.new(logger:) } }
|
|
23
|
+
# app.mount('payments', Payments) # attach an existing component as a branch
|
|
24
|
+
# Components own their declarations and the sub-trees under them: an ancestor can implement
|
|
25
|
+
# (or re-implement) any node below it, but can only declare under nodes it declared itself.
|
|
26
|
+
# The root component drives the lifecycle of the whole tree.
|
|
27
|
+
class Component
|
|
28
|
+
module T
|
|
29
|
+
include Plumb::Types
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Lifecycle statuses, in order. Shared by the root (boot status) and every node.
|
|
33
|
+
# Only nodes are ever :stopped: a started node stopped by key (see #stop_component!),
|
|
34
|
+
# which can be started again.
|
|
35
|
+
STATUSES = %i[open prepared built started stopped torn_down].freeze
|
|
36
|
+
|
|
37
|
+
# What #mount takes: anything that returns a Component from #to_component
|
|
38
|
+
MountableInterface = Plumb::Types::Interface[:to_component]
|
|
39
|
+
|
|
40
|
+
# key: local segment, ex. 'db'. nil for a root that isn't mounted anywhere
|
|
41
|
+
# owner: the component that declared this node. Standalone components own themselves
|
|
42
|
+
# type: the declared type. Any for implicit nodes created as intermediate segments (namespaces)
|
|
43
|
+
# index: every descendant, by relative key, ex. { 'sourced' => <Component>, 'sourced.db' => <Component> }
|
|
44
|
+
attr_reader :key, :parent, :owner, :type, :implementation, :status, :value, :children, :index, :boot_status
|
|
45
|
+
|
|
46
|
+
# notifier: receives lifecycle events. See Component::Notifier and Component::Events.
|
|
47
|
+
# The root's notifier receives the events of the whole tree, so a mounted component uses its root's.
|
|
48
|
+
def initialize(owner: nil, type: Plumb::Undefined, notifier: Notifier.new)
|
|
49
|
+
@notifier = NotifierInterface.parse(notifier)
|
|
50
|
+
@key = nil
|
|
51
|
+
@parent = nil
|
|
52
|
+
@owner = owner || self
|
|
53
|
+
@implicit = Plumb::Undefined == type # not declared with a type: a namespace, unless implemented
|
|
54
|
+
@type = @implicit ? Plumb::Types::Any : Plumb::Composable.wrap(type)
|
|
55
|
+
@implementation = nil
|
|
56
|
+
@status = :open
|
|
57
|
+
@value = nil
|
|
58
|
+
@deferred = false # skipped by the root's #start!, see #defer
|
|
59
|
+
@held = false # not started with the tree or its dependencies: deferred, or stopped by key
|
|
60
|
+
@deps = [].freeze # resolved deps: a node, or { segment => node } for a wildcard
|
|
61
|
+
@dep_nodes = [].freeze # every node in @deps, for sorting
|
|
62
|
+
@children = {}
|
|
63
|
+
@index = {}
|
|
64
|
+
# Only used on the root
|
|
65
|
+
@boot_status = :open
|
|
66
|
+
@readable = false
|
|
67
|
+
@starting = false
|
|
68
|
+
@order = nil
|
|
69
|
+
@lock = Monitor.new
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def root = parent ? parent.root : self
|
|
73
|
+
def root? = parent.nil?
|
|
74
|
+
|
|
75
|
+
# The notifier lifecycle events are published to: the root's, for every component in the tree
|
|
76
|
+
def notifier = root? ? @notifier : root.notifier
|
|
77
|
+
|
|
78
|
+
# Full key from the root, ex. 'sourced.db'. nil for the root
|
|
79
|
+
def path
|
|
80
|
+
return nil unless parent
|
|
81
|
+
|
|
82
|
+
[parent.path, key].compact.join('.')
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def implicit? = @implicit
|
|
86
|
+
def namespace? = implicit? && implementation.nil?
|
|
87
|
+
|
|
88
|
+
# Whether the root's #start! skips it (see #defer)
|
|
89
|
+
def deferred? = @deferred
|
|
90
|
+
def locked? = root.boot_status != :open
|
|
91
|
+
|
|
92
|
+
def inspect
|
|
93
|
+
details = namespace? ? '(namespace)' : "#{type_name} (#{[implementation&.mode || 'not implemented', status, ('deferred' if deferred?)].compact.join(', ')})"
|
|
94
|
+
"#<#{self.class} #{path || '(root)'} #{details}>"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Declare a typed node. Dot-separated keys build the tree, creating intermediate (namespace) nodes as needed.
|
|
98
|
+
# An optional block provides a default singleton implementation.
|
|
99
|
+
# comp.declare('sourced.db.logger', Logger) { Logger.new(STDOUT) }
|
|
100
|
+
def declare(ckey, type = T::Any, &default)
|
|
101
|
+
synchronize do
|
|
102
|
+
raise LockedComponentError, "can't declare #{ckey} in a locked component" if locked?
|
|
103
|
+
|
|
104
|
+
branch, leaf = walk(ckey)
|
|
105
|
+
node = branch.children[leaf]
|
|
106
|
+
if node.nil?
|
|
107
|
+
node = branch.attach(leaf, Component.new(owner: self, type:))
|
|
108
|
+
elsif !node.owner.equal?(self)
|
|
109
|
+
raise OwnershipError, ownership_message(node)
|
|
110
|
+
elsif !node.implicit?
|
|
111
|
+
raise DeclarationOverrideError, "#{node.path} is already declared"
|
|
112
|
+
else
|
|
113
|
+
node.declare_type!(type) # an implicit namespace this component created, now with a type
|
|
114
|
+
end
|
|
115
|
+
emit(Events::ComponentDeclared, key: node.path, type_name: node.type_name)
|
|
116
|
+
|
|
117
|
+
implement_node(node, Implementation.from_block([], implementer: self, mode: :singleton) { build(&default) }) if default
|
|
118
|
+
self
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Implement (or re-implement) a node anywhere under this component as a singleton,
|
|
123
|
+
# built once on #build! and memoized. The last implementation wins.
|
|
124
|
+
# Deps are keys relative to this component.
|
|
125
|
+
# comp.component!('sourced.db', ['logger']) do
|
|
126
|
+
# prepare { require 'sequel' }
|
|
127
|
+
# build { |logger| Sequel.sqlite(logger:) }
|
|
128
|
+
# start { |db, context| }
|
|
129
|
+
# teardown { |db| db.disconnect }
|
|
130
|
+
# end
|
|
131
|
+
# Instead of a block, a provider can implement the component:
|
|
132
|
+
# - a callable, called with the deps' values, as the build step
|
|
133
|
+
# - or an object with #builder_for(node), which returns that callable for the node (ex. ENVProvider)
|
|
134
|
+
# The callable can also implement any of #prepare, #start(value, context) and #teardown(value),
|
|
135
|
+
# which become the component's other hooks.
|
|
136
|
+
# comp.component!('db', ['db.url'], DBFactory) # DBFactory.call(url)
|
|
137
|
+
# comp.component!('clock', -> { Time }) # no deps
|
|
138
|
+
# comp.component!('user.email', Component::ENVProvider.new('USER_EMAIL'))
|
|
139
|
+
# Given a component (anything with #to_component), it mounts it instead. See #mount
|
|
140
|
+
# comp.component!('sourced', Sourced)
|
|
141
|
+
def component!(ckey, deps_or_provider = [], provider = nil, &block)
|
|
142
|
+
return mount_from(ckey, deps_or_provider, provider, &block) if MountableInterface === deps_or_provider
|
|
143
|
+
|
|
144
|
+
implement(ckey, deps_or_provider, provider, :singleton, &block)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Same as #component!, but built on every read, ex. a per-request value.
|
|
148
|
+
# comp.component('request_id') { build { SecureRandom.uuid } }
|
|
149
|
+
# comp.component('request_id', -> { SecureRandom.uuid })
|
|
150
|
+
# Given a component (anything with #to_component), it mounts it instead, same as #component!
|
|
151
|
+
# comp.component('sourced', Sourced)
|
|
152
|
+
def component(ckey, deps_or_provider = [], provider = nil, &block)
|
|
153
|
+
return mount_from(ckey, deps_or_provider, provider, &block) if MountableInterface === deps_or_provider
|
|
154
|
+
|
|
155
|
+
implement(ckey, deps_or_provider, provider, :dynamic, &block)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# A singleton component with only a build step. The block gets the deps' values.
|
|
159
|
+
# comp.config!('db.url') { 'sqlite://app.db' }
|
|
160
|
+
# comp.config!('db', ['db.url']) { |url| DB.new(url) }
|
|
161
|
+
def config!(ckey, deps = [], &build_block)
|
|
162
|
+
raise ArgumentError, "config! #{ckey} needs a block to build its value" unless build_block
|
|
163
|
+
|
|
164
|
+
implement(ckey, deps, build_block, :singleton)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Same as #config!, but built on every read
|
|
168
|
+
# comp.config('now') { Time.now }
|
|
169
|
+
def config(ckey, deps = [], &build_block)
|
|
170
|
+
raise ArgumentError, "config #{ckey} needs a block to build its value" unless build_block
|
|
171
|
+
|
|
172
|
+
implement(ckey, deps, build_block, :dynamic)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Implement a node as an alias of another component: reading it reads the target, through the
|
|
176
|
+
# node's declared type. Deps are keys relative to this component, and so is the target.
|
|
177
|
+
# comp.alias('sidereal.store', 'sourced.store')
|
|
178
|
+
# Same as comp.config!('sidereal.store', ['sourced.store']) { |store| store }, except that an alias of a
|
|
179
|
+
# dynamic component is dynamic too. An alias has no hooks: the target runs its own lifecycle,
|
|
180
|
+
# and the alias follows it like any other dependent.
|
|
181
|
+
def alias(ckey, target)
|
|
182
|
+
synchronize do
|
|
183
|
+
raise LockedComponentError, "can't implement #{ckey} in a locked component" if locked?
|
|
184
|
+
|
|
185
|
+
implement_node(node(ckey), Implementation.alias(target, implementer: self))
|
|
186
|
+
self
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Implement singleton components built from ENV variables (see Component::ENVProvider),
|
|
191
|
+
# decoding values into each declared type with Plumb::Codec::Forms. ENV is read when components are built.
|
|
192
|
+
# comp.env('USER_EMAIL' => 'user.email') # a single variable
|
|
193
|
+
# comp.env(/^USER_/ => 'user.info') # matching variables into a hash, match removed: USER_NAME => NAME
|
|
194
|
+
# comp.env(:downcase, /^USER_/ => 'user.info') # ... with modifiers: USER_NAME => name
|
|
195
|
+
# comp.env('user.info') # all variables into a hash
|
|
196
|
+
# comp.env(:downcase, 'user.info') # all variables, with modifiers
|
|
197
|
+
# A hash can map several sources at once. Modifiers are only allowed when collecting variables with a regex.
|
|
198
|
+
# Keys are relative to this component. Every source, key and type is checked before anything is implemented.
|
|
199
|
+
def env(*args)
|
|
200
|
+
mapping = args.last.is_a?(::Hash) ? args.pop : { ENVProvider::ALL => args.pop }
|
|
201
|
+
if mapping.empty? || mapping.value?(nil)
|
|
202
|
+
raise ArgumentError, 'env needs a component key, or a hash of ENV variables (or regexes) => component keys'
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
synchronize do
|
|
206
|
+
raise LockedComponentError, "can't implement ENV components in a locked component" if locked?
|
|
207
|
+
|
|
208
|
+
builders = mapping.map do |source, ckey|
|
|
209
|
+
target = node(ckey)
|
|
210
|
+
provider = ENVProvider.new(source, *args)
|
|
211
|
+
[target, provider, provider.builder_for(target)]
|
|
212
|
+
end
|
|
213
|
+
builders.each do |target, provider, builder|
|
|
214
|
+
implement_node(target, Implementation.from_builder(builder, [], implementer: self, mode: :singleton, provider:))
|
|
215
|
+
end
|
|
216
|
+
self
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Defer a node under this component: the root's #start! skips it, and every component that
|
|
221
|
+
# depends on it, directly or not, since they can't start before it. Start it by key instead,
|
|
222
|
+
# ex. when its process is elected to run it (see #start_component!).
|
|
223
|
+
# app.defer('sourced.dispatcher')
|
|
224
|
+
# app.start!(task) # everything else
|
|
225
|
+
# app.start_component!('sourced.dispatcher', task) # later, and again after each #stop_component!
|
|
226
|
+
# Deferring is a property of the node, not of its implementation, so it's kept when the node is
|
|
227
|
+
# implemented again. Any component can defer a node below it, like implementing one.
|
|
228
|
+
def defer(ckey)
|
|
229
|
+
synchronize do
|
|
230
|
+
raise LockedComponentError, "can't defer #{ckey} in a locked component" if locked?
|
|
231
|
+
|
|
232
|
+
target = node(ckey)
|
|
233
|
+
target.defer!
|
|
234
|
+
emit(Events::ComponentDeferred, key: target.path, deferrer: path)
|
|
235
|
+
self
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Build an Injector for components under this component, by relative key.
|
|
240
|
+
# Keys map to kwargs named after their last segment ('sourced.store' => :store),
|
|
241
|
+
# and a Hash maps keys to custom kwarg names ('sourced.store' => 'st').
|
|
242
|
+
# class Dispatcher
|
|
243
|
+
# include App.inject('logger', 'sourced.store' => 'st')
|
|
244
|
+
# end
|
|
245
|
+
# Values are read when objects are instantiated, so classes can be defined before the component is built.
|
|
246
|
+
def inject(*keys)
|
|
247
|
+
names = keys.each_with_object({}) do |arg, map|
|
|
248
|
+
pairs = arg.is_a?(::Hash) ? arg : { arg => arg.to_s.split('.').last }
|
|
249
|
+
pairs.each do |key, name|
|
|
250
|
+
map[key.to_s] = name.to_sym
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
duplicates = names.values.tally.select { |_, count| count > 1 }.keys
|
|
255
|
+
raise ArgumentError, "duplicate injected names: #{duplicates.join(', ')}" if duplicates.any?
|
|
256
|
+
|
|
257
|
+
Injector.new(names.to_h { |key, _| [key, node(key)] }, names)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Attach an existing standalone component as a branch. It keeps owning its declarations,
|
|
261
|
+
# and this component can implement its nodes.
|
|
262
|
+
# Takes anything with #to_component, which must return a Component, ex. a library module:
|
|
263
|
+
# app.mount('sourced', Sourced.component)
|
|
264
|
+
# app.mount('sourced', Sourced) # Sourced.to_component => its Component
|
|
265
|
+
def mount(ckey, mountable)
|
|
266
|
+
unless MountableInterface === mountable
|
|
267
|
+
raise ArgumentError, "can't mount #{mountable.inspect}: it must respond to #to_component"
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
sub = mountable.to_component
|
|
271
|
+
raise ArgumentError, "#{mountable.inspect}.to_component must return a Component, got #{sub.inspect}" unless sub.is_a?(Component)
|
|
272
|
+
|
|
273
|
+
synchronize do
|
|
274
|
+
raise LockedComponentError, "can't mount #{ckey} in a locked component" if locked?
|
|
275
|
+
raise SubcomponentError, "#{sub.inspect} is already mounted in another component" unless sub.root?
|
|
276
|
+
raise SubcomponentError, "can't mount a component into its own tree" if sub.equal?(root)
|
|
277
|
+
raise LockedComponentError, "can't mount a #{sub.boot_status} component: it must be open" if sub.locked?
|
|
278
|
+
|
|
279
|
+
branch, leaf = walk(ckey)
|
|
280
|
+
if (existing = branch.children[leaf])
|
|
281
|
+
raise DeclarationOverrideError, "#{existing.path} is already declared: can't mount a component there"
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
branch.attach(leaf, sub)
|
|
285
|
+
self
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# The mountable interface (see #mount)
|
|
290
|
+
def to_component = self
|
|
291
|
+
|
|
292
|
+
# A node under this component, by relative key
|
|
293
|
+
def node(ckey)
|
|
294
|
+
index.fetch(ckey.to_s) { raise UndeclaredComponentError, "#{ckey} is not declared in #{path || 'this component'}" }
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def declared?(ckey) = index.key?(ckey.to_s)
|
|
298
|
+
|
|
299
|
+
# Read a node's value. Singletons are memoized on #build!, dynamic nodes are built on each read.
|
|
300
|
+
def [](ckey) = node(ckey).read
|
|
301
|
+
|
|
302
|
+
def read
|
|
303
|
+
raise NotBuiltError, 'component is not built yet' unless root.readable?
|
|
304
|
+
raise UndeclaredComponentError, "#{path} is a namespace, not a component" unless implementation
|
|
305
|
+
|
|
306
|
+
current_value
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# ---- Lifecycle. Only the root drives it ----------------------------------------
|
|
310
|
+
|
|
311
|
+
# Each step publishes root.<stage>ing and root.<stage>ed events (or root.failed),
|
|
312
|
+
# and component events for every component whose hooks run. See Component::Events
|
|
313
|
+
def prepare!
|
|
314
|
+
raise_mounted!
|
|
315
|
+
synchronize do
|
|
316
|
+
return self if past?(:prepared)
|
|
317
|
+
|
|
318
|
+
instrument_root(:prepare) do
|
|
319
|
+
@order = resolve_order
|
|
320
|
+
@order.each { |n| instrument_component(n, :prepare) { n.prepare_node! } }
|
|
321
|
+
@boot_status = :prepared
|
|
322
|
+
end
|
|
323
|
+
self
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def build!
|
|
328
|
+
raise_mounted!
|
|
329
|
+
synchronize do
|
|
330
|
+
prepare!
|
|
331
|
+
return self if past?(:built)
|
|
332
|
+
|
|
333
|
+
instrument_root(:build) do
|
|
334
|
+
@order.each { |n| instrument_component(n, :build) { n.build_node! } }
|
|
335
|
+
@boot_status = :built
|
|
336
|
+
@readable = true
|
|
337
|
+
end
|
|
338
|
+
self
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# If a start hook raises, nodes already started are torn down (in reverse order),
|
|
343
|
+
# the component is left :torn_down, and the error is re-raised.
|
|
344
|
+
# :torn_down is terminal: starting a torn down component raises TornDownError.
|
|
345
|
+
def start!(context = Thread.current)
|
|
346
|
+
raise_mounted!
|
|
347
|
+
synchronize do
|
|
348
|
+
raise TornDownError, "can't start a torn down component" if boot_status == :torn_down
|
|
349
|
+
|
|
350
|
+
build!
|
|
351
|
+
return self if past?(:started)
|
|
352
|
+
|
|
353
|
+
instrument_root(:start) do
|
|
354
|
+
@starting = true
|
|
355
|
+
@order.each do |n|
|
|
356
|
+
# Deferred nodes, and the ones depending on them, wait to be started by key.
|
|
357
|
+
# A start hook may already have started some of them by key (see #start_component!)
|
|
358
|
+
next if n.held? || !n.dep_nodes.all? { |dep| dep.started? }
|
|
359
|
+
|
|
360
|
+
instrument_component(n, :start) { n.start_node!(context) }
|
|
361
|
+
end
|
|
362
|
+
@boot_status = :started
|
|
363
|
+
rescue Exception # rubocop:disable Lint/RescueException -- any error (incl. Interrupt) must tear down what was started. Always re-raised
|
|
364
|
+
teardown_nodes(include_built: false)
|
|
365
|
+
@boot_status = :torn_down
|
|
366
|
+
raise
|
|
367
|
+
ensure
|
|
368
|
+
@starting = false
|
|
369
|
+
end
|
|
370
|
+
self
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# Tears down every node, in reverse dependency order, even if some raise. The first error is re-raised.
|
|
375
|
+
# Started nodes run their stop hooks, then their teardown hooks. Nodes that never started (deferred)
|
|
376
|
+
# or were stopped run their teardown hooks.
|
|
377
|
+
def teardown!
|
|
378
|
+
raise_mounted!
|
|
379
|
+
synchronize do
|
|
380
|
+
return self unless boot_status == :started
|
|
381
|
+
|
|
382
|
+
instrument_root(:teardown) do
|
|
383
|
+
errors = teardown_nodes(include_built: true)
|
|
384
|
+
@boot_status = :torn_down
|
|
385
|
+
raise errors.first if errors.any?
|
|
386
|
+
end
|
|
387
|
+
self
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# ---- Starting and stopping components by key ------------------------------------
|
|
392
|
+
|
|
393
|
+
# Start a component that isn't running, by key relative to this component: a deferred one
|
|
394
|
+
# (see #defer), or one stopped with #stop_component!. Follows the dependency graph:
|
|
395
|
+
# - first, any of its dependencies that aren't running, in dependency order
|
|
396
|
+
# - then the component itself
|
|
397
|
+
# - then the components depending on it that aren't running, once all their dependencies are,
|
|
398
|
+
# ex. the ones its #stop_component! stopped. Not the ones stopped by key themselves
|
|
399
|
+
# Each one runs its start hooks with +context+. A no-op for components already running.
|
|
400
|
+
# If a start hook raises, the components this call started are stopped again, in reverse order,
|
|
401
|
+
# and the error is re-raised: the rest of the tree is left as it was.
|
|
402
|
+
# The root must be started, or starting (a start hook can start a deferred component).
|
|
403
|
+
# app.start_component!('sourced.dispatcher', task)
|
|
404
|
+
def start_component!(ckey, context = Thread.current)
|
|
405
|
+
synchronize do
|
|
406
|
+
target = running_node(ckey)
|
|
407
|
+
dependencies = target.transitive_dependencies
|
|
408
|
+
dependents = target.transitive_dependents
|
|
409
|
+
holds = [target, *dependencies].to_h { |n| [n, n.held?] }
|
|
410
|
+
started = []
|
|
411
|
+
|
|
412
|
+
begin
|
|
413
|
+
root.order.each do |n|
|
|
414
|
+
if n.equal?(target) || dependencies.include?(n)
|
|
415
|
+
next if n.started?
|
|
416
|
+
|
|
417
|
+
n.release!
|
|
418
|
+
elsif dependents.include?(n)
|
|
419
|
+
next if n.started? || n.held? || !n.dep_nodes.all? { |dep| dep.started? }
|
|
420
|
+
else
|
|
421
|
+
next
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
instrument_component(n, :start) { n.start_node!(context) }
|
|
425
|
+
started << n
|
|
426
|
+
end
|
|
427
|
+
rescue Exception # rubocop:disable Lint/RescueException -- any error (incl. Interrupt) must stop what this call started. Always re-raised
|
|
428
|
+
stop_nodes(started.reverse)
|
|
429
|
+
holds.each { |n, held| n.hold! if held }
|
|
430
|
+
raise
|
|
431
|
+
end
|
|
432
|
+
self
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# Stop a running component, by key relative to this component, and every running component
|
|
437
|
+
# that depends on it, directly or not: dependents first, in reverse dependency order. Each one
|
|
438
|
+
# runs its stop hooks, and keeps its value, so it can be started again (see #start_component!).
|
|
439
|
+
# The component's own dependencies keep running.
|
|
440
|
+
# The component stays stopped until it's started by key: the root's lifecycle, and starting
|
|
441
|
+
# its dependencies, don't start it again. Its dependents start again with it.
|
|
442
|
+
# Every one is stopped even if stop hooks raise, and the first error is re-raised.
|
|
443
|
+
# app.stop_component!('sourced.dispatcher')
|
|
444
|
+
def stop_component!(ckey)
|
|
445
|
+
synchronize do
|
|
446
|
+
target = running_node(ckey)
|
|
447
|
+
target.hold!
|
|
448
|
+
stopping = [target, *target.transitive_dependents]
|
|
449
|
+
errors = stop_nodes(root.order.reverse.select { |n| stopping.include?(n) && n.started? })
|
|
450
|
+
raise errors.first if errors.any?
|
|
451
|
+
|
|
452
|
+
self
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# #stop_component! then #start_component!: stops the component and its dependents, and starts
|
|
457
|
+
# them again, in dependency order.
|
|
458
|
+
# app.restart_component!('sourced.dispatcher', task)
|
|
459
|
+
def restart_component!(ckey, context = Thread.current)
|
|
460
|
+
synchronize do
|
|
461
|
+
stop_component!(ckey)
|
|
462
|
+
start_component!(ckey, context)
|
|
463
|
+
end
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
# A Component::Graph describing the components under this component, by full path from the root.
|
|
467
|
+
# Components are listed in dependency order once the tree is prepared, and in declaration order before that.
|
|
468
|
+
# Namespaces without an implementation are left out.
|
|
469
|
+
# graph = comp.graph
|
|
470
|
+
# graph.status # => :built, the root's status
|
|
471
|
+
# graph.components # => [{ key: 'logger', type:, type_name:, implemented:, mode:, status:, deps:, missing:, dependents:, provider: }, ...]
|
|
472
|
+
# graph.to_mermaid # => a Mermaid flowchart
|
|
473
|
+
# See Component::Graph
|
|
474
|
+
def graph
|
|
475
|
+
synchronize do
|
|
476
|
+
nodes = index.values.reject(&:namespace?)
|
|
477
|
+
nodes = (root.order & nodes) | nodes if root.order
|
|
478
|
+
|
|
479
|
+
described = nodes.map { |n| [n, *graph_deps(n)] }
|
|
480
|
+
dependents = Hash.new { |h, k| h[k] = [] }
|
|
481
|
+
described.each { |n, deps, _| deps.each { |dep| dependents[dep] << n.path } }
|
|
482
|
+
|
|
483
|
+
components = described.map do |n, deps, missing|
|
|
484
|
+
impl = n.implementation
|
|
485
|
+
{
|
|
486
|
+
key: n.path,
|
|
487
|
+
type: n.type,
|
|
488
|
+
type_name: n.type_name,
|
|
489
|
+
implemented: !impl.nil?,
|
|
490
|
+
mode: impl&.mode,
|
|
491
|
+
status: n.status,
|
|
492
|
+
deferred: n.deferred?,
|
|
493
|
+
deps:,
|
|
494
|
+
missing:,
|
|
495
|
+
dependents: dependents[n.path],
|
|
496
|
+
provider: impl&.provider
|
|
497
|
+
}
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
Graph.new(status: root.boot_status, components:)
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
# A Component::Tree of the components under this one, as nested nodes: how components are nested,
|
|
505
|
+
# which components are mounted, and who declared and implemented each one. See #graph for dependencies.
|
|
506
|
+
# puts App.tree
|
|
507
|
+
# (root)
|
|
508
|
+
# ├── logger Interface[info] (singleton, built)
|
|
509
|
+
# └── sourced [mounted]
|
|
510
|
+
# └── db DB (singleton, built) implemented by (root)
|
|
511
|
+
def tree
|
|
512
|
+
synchronize { Tree.new(status: root.boot_status, root: tree_node) }
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
protected def tree_node
|
|
516
|
+
Tree::Node.new(
|
|
517
|
+
key:,
|
|
518
|
+
path:,
|
|
519
|
+
type:,
|
|
520
|
+
type_name:,
|
|
521
|
+
namespace: namespace?,
|
|
522
|
+
mounted: !root? && owner.equal?(self),
|
|
523
|
+
implemented: !implementation.nil?,
|
|
524
|
+
mode: implementation&.mode,
|
|
525
|
+
status:,
|
|
526
|
+
deferred: deferred?,
|
|
527
|
+
owner: owner.equal?(self) ? path : owner.path,
|
|
528
|
+
implementer: implementation&.implementer&.path,
|
|
529
|
+
children: children.values.map { |child| child.tree_node }
|
|
530
|
+
)
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
# Nodes in dependency order. Available after #prepare!
|
|
534
|
+
def ordered_nodes
|
|
535
|
+
raise_mounted!
|
|
536
|
+
raise NotBuiltError, 'component is not prepared yet' unless @order
|
|
537
|
+
|
|
538
|
+
@order.dup
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
# ---- Node internals -------------------------------------------------------------
|
|
542
|
+
|
|
543
|
+
protected def readable? = @readable
|
|
544
|
+
protected def starting? = @starting
|
|
545
|
+
protected def lock = @lock
|
|
546
|
+
protected def dep_nodes = @dep_nodes
|
|
547
|
+
protected def order = @order
|
|
548
|
+
|
|
549
|
+
private def synchronize(&) = root.lock.synchronize(&)
|
|
550
|
+
|
|
551
|
+
protected def declare_type!(type)
|
|
552
|
+
@implicit = false
|
|
553
|
+
@type = Plumb::Composable.wrap(type)
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
protected def implement!(implementation)
|
|
557
|
+
@implementation = implementation
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
protected def defer! = @deferred = true
|
|
561
|
+
protected def held? = @held
|
|
562
|
+
protected def hold! = @held = true
|
|
563
|
+
protected def release! = @held = false
|
|
564
|
+
protected def started? = status == :started
|
|
565
|
+
|
|
566
|
+
# Every node this one depends on, directly or not
|
|
567
|
+
protected def transitive_dependencies
|
|
568
|
+
dep_nodes.each_with_object([]) do |dep, all|
|
|
569
|
+
next if all.include?(dep)
|
|
570
|
+
|
|
571
|
+
all << dep
|
|
572
|
+
dep.transitive_dependencies.each { |n| all << n unless all.include?(n) }
|
|
573
|
+
end
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
# Every node that depends on this one, directly or not. Nodes come after their
|
|
577
|
+
# dependencies in the root's order, so one pass over the order finds them all.
|
|
578
|
+
protected def transitive_dependents
|
|
579
|
+
reached = [self]
|
|
580
|
+
root.order.each do |n|
|
|
581
|
+
reached << n if !reached.include?(n) && n.dep_nodes.any? { |dep| reached.include?(dep) }
|
|
582
|
+
end
|
|
583
|
+
reached.drop(1)
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
protected def adopt!(parent, key)
|
|
587
|
+
@parent = parent
|
|
588
|
+
@key = key.freeze
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
# Add a child node and index it (and its own descendants) here and in every ancestor
|
|
592
|
+
protected def attach(segment, child)
|
|
593
|
+
@children[segment] = child
|
|
594
|
+
child.adopt!(self, segment)
|
|
595
|
+
index!(segment, child)
|
|
596
|
+
child.index.each { |sub_key, n| index!("#{segment}.#{sub_key}", n) }
|
|
597
|
+
child
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
protected def index!(ckey, node)
|
|
601
|
+
@index[ckey] = node
|
|
602
|
+
parent&.index!("#{key}.#{ckey}", node)
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
# Resolve deps through the implementer's index. Called on #prepare!, so declaration order doesn't matter.
|
|
606
|
+
# A wildcard dep ('reactors.*') resolves to a hash of the components directly under its key, by segment,
|
|
607
|
+
# and to an empty hash if there are none.
|
|
608
|
+
protected def resolve_deps!
|
|
609
|
+
@deps = implementation.deps.map do |dep|
|
|
610
|
+
next wildcard_nodes(dep) if dep.end_with?('.*')
|
|
611
|
+
|
|
612
|
+
dep_node = implementation.implementer.index[dep]
|
|
613
|
+
unless dep_node && !dep_node.namespace?
|
|
614
|
+
where = implementation.implementer.path
|
|
615
|
+
full = [where, dep].compact.join('.')
|
|
616
|
+
raise MissingDependencyError, "#{path} depends on #{full}, which is not #{dep_node ? 'implemented' : 'declared'}"
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
dep_node
|
|
620
|
+
end.freeze
|
|
621
|
+
@dep_nodes = @deps.flat_map { |d| d.is_a?(::Hash) ? d.values : [d] }.freeze
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
# { 'segment' => <Component> } for the components directly under a wildcard dep's key
|
|
625
|
+
protected def wildcard_nodes(dep)
|
|
626
|
+
branch = implementation.implementer.index[dep.delete_suffix('.*')]
|
|
627
|
+
return {}.freeze unless branch
|
|
628
|
+
|
|
629
|
+
branch.children.reject { |_, child| child.namespace? }.freeze
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
protected def prepare_node!
|
|
633
|
+
return self unless pending?(:prepare)
|
|
634
|
+
|
|
635
|
+
implementation.prepare
|
|
636
|
+
@status = :prepared
|
|
637
|
+
self
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
protected def build_node!
|
|
641
|
+
return self unless pending?(:build)
|
|
642
|
+
|
|
643
|
+
@value = build_value if memoized?
|
|
644
|
+
@status = :built
|
|
645
|
+
self
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
# From :built, or again from :stopped
|
|
649
|
+
protected def start_node!(context)
|
|
650
|
+
return self unless pending?(:start)
|
|
651
|
+
|
|
652
|
+
implementation.start(value, context)
|
|
653
|
+
@status = :started
|
|
654
|
+
self
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
# Stopped even if a stop hook raises: it's no longer running either way
|
|
658
|
+
protected def stop_node!
|
|
659
|
+
return self unless pending?(:stop)
|
|
660
|
+
|
|
661
|
+
begin
|
|
662
|
+
implementation.stop(value)
|
|
663
|
+
ensure
|
|
664
|
+
@status = :stopped
|
|
665
|
+
end
|
|
666
|
+
self
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
# A started node runs its stop hooks first. Teardown hooks run even if those raise
|
|
670
|
+
protected def teardown_node!
|
|
671
|
+
return self unless pending?(:teardown)
|
|
672
|
+
|
|
673
|
+
begin
|
|
674
|
+
implementation.stop(value) if started?
|
|
675
|
+
ensure
|
|
676
|
+
implementation.teardown(value)
|
|
677
|
+
end
|
|
678
|
+
@status = :torn_down
|
|
679
|
+
self
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
# Without the readable check: deps are read while the component is building, in dependency order
|
|
683
|
+
protected def current_value = memoized? ? value : build_value
|
|
684
|
+
|
|
685
|
+
# Whether the value is built once, on #build!: singletons, and aliases of memoized components.
|
|
686
|
+
# Only known once deps are resolved, on #prepare!
|
|
687
|
+
protected def memoized?
|
|
688
|
+
implementation.alias? ? dep_nodes.first.memoized? : implementation.singleton?
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
# Parse the built value through the declared type. Type errors name the component, ex.
|
|
692
|
+
# Plumb::ParseError: db.port: Must be a Integer
|
|
693
|
+
# The value is left out, as it can hold secrets.
|
|
694
|
+
private def build_value
|
|
695
|
+
values = @deps.map { |d| d.is_a?(::Hash) ? d.transform_values { |n| n.current_value } : d.current_value }
|
|
696
|
+
result = type.resolve(implementation.build(*values))
|
|
697
|
+
return result.value if result.valid?
|
|
698
|
+
|
|
699
|
+
errors = result.errors.is_a?(::String) ? result.errors : result.errors.inspect
|
|
700
|
+
raise Plumb::ParseError, "#{path || '(root)'}: #{errors}"
|
|
701
|
+
end
|
|
702
|
+
|
|
703
|
+
# Whether a lifecycle stage would run this node's hooks
|
|
704
|
+
protected def pending?(stage)
|
|
705
|
+
case stage
|
|
706
|
+
when :prepare then status == :open
|
|
707
|
+
when :build then status == :prepared
|
|
708
|
+
when :start then status == :built || status == :stopped
|
|
709
|
+
when :stop then started?
|
|
710
|
+
when :teardown then %i[built started stopped].include?(status)
|
|
711
|
+
end
|
|
712
|
+
end
|
|
713
|
+
|
|
714
|
+
# ---- Root internals -------------------------------------------------------------
|
|
715
|
+
|
|
716
|
+
private def raise_mounted!
|
|
717
|
+
raise SubcomponentError, "#{path} is mounted in another component: boot the root" unless root?
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
private def past?(new_status)
|
|
721
|
+
STATUSES.index(boot_status) >= STATUSES.index(new_status)
|
|
722
|
+
end
|
|
723
|
+
|
|
724
|
+
# Every node that is declared or implemented, in dependency order
|
|
725
|
+
private def resolve_order
|
|
726
|
+
nodes = [self, *index.values].reject(&:namespace?)
|
|
727
|
+
|
|
728
|
+
unimplemented = nodes.reject(&:implementation)
|
|
729
|
+
if unimplemented.any?
|
|
730
|
+
raise UnimplementedComponentError, "components are declared but not implemented: #{unimplemented.map(&:path).join(', ')}"
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
deferred_namespaces = index.values.select { |n| n.deferred? && n.namespace? }
|
|
734
|
+
if deferred_namespaces.any?
|
|
735
|
+
raise UnimplementedComponentError, "components are deferred but not implemented: #{deferred_namespaces.map(&:path).join(', ')}"
|
|
736
|
+
end
|
|
737
|
+
|
|
738
|
+
nodes.each do |n|
|
|
739
|
+
n.resolve_deps!
|
|
740
|
+
n.release!
|
|
741
|
+
n.hold! if n.deferred?
|
|
742
|
+
end
|
|
743
|
+
|
|
744
|
+
each_node = ->(&b) { nodes.each(&b) }
|
|
745
|
+
each_child = ->(n, &b) { n.dep_nodes.each(&b) }
|
|
746
|
+
cycle = TSort.each_strongly_connected_component(each_node, each_child).find do |c|
|
|
747
|
+
c.size > 1 || c.first.dep_nodes.include?(c.first)
|
|
748
|
+
end
|
|
749
|
+
raise CircularDependencyError, "circular dependency between #{cycle.map(&:path).join(', ')}" if cycle
|
|
750
|
+
|
|
751
|
+
TSort.tsort(each_node, each_child)
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
# include_built: also tear down nodes that are built but never started (deferred, or depending
|
|
755
|
+
# on a deferred node). A failed #start! leaves the ones it didn't reach alone.
|
|
756
|
+
private def teardown_nodes(include_built:)
|
|
757
|
+
@order.reverse.each_with_object([]) do |n, errors|
|
|
758
|
+
next if !include_built && n.status == :built
|
|
759
|
+
|
|
760
|
+
instrument_component(n, :teardown) { n.teardown_node! }
|
|
761
|
+
rescue StandardError => e
|
|
762
|
+
errors << e
|
|
763
|
+
end
|
|
764
|
+
end
|
|
765
|
+
|
|
766
|
+
# Stop the given nodes, in the given order, even if some raise. Returns the errors
|
|
767
|
+
private def stop_nodes(nodes)
|
|
768
|
+
nodes.each_with_object([]) do |n, errors|
|
|
769
|
+
instrument_component(n, :stop) { n.stop_node! }
|
|
770
|
+
rescue StandardError => e
|
|
771
|
+
errors << e
|
|
772
|
+
end
|
|
773
|
+
end
|
|
774
|
+
|
|
775
|
+
# A node to start or stop by key: implemented, under a root that's started, or starting
|
|
776
|
+
private def running_node(ckey)
|
|
777
|
+
target = node(ckey)
|
|
778
|
+
raise UndeclaredComponentError, "#{target.path} is a namespace, not a component" if target.namespace?
|
|
779
|
+
raise TornDownError, "can't start or stop #{target.path}: the component is torn down" if root.boot_status == :torn_down
|
|
780
|
+
unless root.boot_status == :started || root.starting?
|
|
781
|
+
raise NotStartedError, "can't start or stop #{target.path} before the root is started: start! it first"
|
|
782
|
+
end
|
|
783
|
+
|
|
784
|
+
target
|
|
785
|
+
end
|
|
786
|
+
|
|
787
|
+
# ---- Telemetry ------------------------------------------------------------------
|
|
788
|
+
|
|
789
|
+
# Publish root.<stage>ing, run the block, and publish root.<stage>ed with its duration,
|
|
790
|
+
# or root.failed if it raises.
|
|
791
|
+
private def instrument_root(stage)
|
|
792
|
+
before, after = ROOT_EVENTS.fetch(stage)
|
|
793
|
+
emit(before)
|
|
794
|
+
started_at = now
|
|
795
|
+
begin
|
|
796
|
+
yield
|
|
797
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- re-raised
|
|
798
|
+
emit(Events::RootFailed, stage:, **error_attributes(e))
|
|
799
|
+
raise
|
|
800
|
+
end
|
|
801
|
+
emit(after, duration: now - started_at)
|
|
802
|
+
end
|
|
803
|
+
|
|
804
|
+
# Same as #instrument_root for a component, but only if the stage would run its hooks.
|
|
805
|
+
# Components that aren't memoized aren't built on #build!, so they don't publish build events.
|
|
806
|
+
private def instrument_component(node, stage)
|
|
807
|
+
return yield unless node.pending?(stage)
|
|
808
|
+
return yield if stage == :build && !node.memoized?
|
|
809
|
+
|
|
810
|
+
before, after = COMPONENT_EVENTS.fetch(stage)
|
|
811
|
+
emit(before, key: node.path)
|
|
812
|
+
started_at = now
|
|
813
|
+
begin
|
|
814
|
+
yield
|
|
815
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- re-raised
|
|
816
|
+
emit(Events::ComponentFailed, key: node.path, stage:, **error_attributes(e))
|
|
817
|
+
raise
|
|
818
|
+
end
|
|
819
|
+
emit(after, key: node.path, duration: now - started_at)
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
# Errors as JSON-friendly values. See Component::Event
|
|
823
|
+
private def error_attributes(error)
|
|
824
|
+
{
|
|
825
|
+
error_class: error.class.name || error.class.inspect,
|
|
826
|
+
error_message: error.message.to_s,
|
|
827
|
+
backtrace: error.backtrace || []
|
|
828
|
+
}
|
|
829
|
+
end
|
|
830
|
+
|
|
831
|
+
# Publish an event to the root's notifier, with the process, thread and fiber it was published from
|
|
832
|
+
private def emit(event_class, **attrs)
|
|
833
|
+
event = event_class.new(
|
|
834
|
+
payload: {
|
|
835
|
+
pid: Process.pid,
|
|
836
|
+
thread_id: Thread.current.object_id,
|
|
837
|
+
fiber_id: Fiber.current.object_id,
|
|
838
|
+
**attrs
|
|
839
|
+
}
|
|
840
|
+
)
|
|
841
|
+
raise ArgumentError, "invalid #{event_class.type} event: #{event.errors}" unless event.valid?
|
|
842
|
+
|
|
843
|
+
notifier.publish(event)
|
|
844
|
+
end
|
|
845
|
+
|
|
846
|
+
private def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
847
|
+
|
|
848
|
+
# #component! and #component given a component: an alias to #mount, which takes nothing else
|
|
849
|
+
private def mount_from(ckey, mountable, provider, &block)
|
|
850
|
+
raise ArgumentError, "#{ckey}: can't pass a provider or a block when mounting a component" if provider || block
|
|
851
|
+
|
|
852
|
+
mount(ckey, mountable)
|
|
853
|
+
end
|
|
854
|
+
|
|
855
|
+
# deps_or_provider: deps, or a provider when there are no deps (#component!('clock', -> { Time }))
|
|
856
|
+
private def implement(ckey, deps_or_provider, provider, mode, &block)
|
|
857
|
+
deps = deps_or_provider
|
|
858
|
+
unless deps.is_a?(::Array)
|
|
859
|
+
raise ArgumentError, "#{ckey}: deps must be an Array, got #{deps.inspect}" if provider
|
|
860
|
+
|
|
861
|
+
deps = []
|
|
862
|
+
provider = deps_or_provider
|
|
863
|
+
end
|
|
864
|
+
raise ArgumentError, "#{ckey}: pass either a provider or a block, not both" if provider && block
|
|
865
|
+
|
|
866
|
+
synchronize do
|
|
867
|
+
raise LockedComponentError, "can't implement #{ckey} in a locked component" if locked?
|
|
868
|
+
|
|
869
|
+
target = node(ckey)
|
|
870
|
+
implementation = if provider
|
|
871
|
+
Implementation.from_builder(builder_from(target, provider), deps, implementer: self, mode:, provider:)
|
|
872
|
+
else
|
|
873
|
+
Implementation.from_block(deps, implementer: self, mode:, &block)
|
|
874
|
+
end
|
|
875
|
+
implement_node(target, implementation)
|
|
876
|
+
self
|
|
877
|
+
end
|
|
878
|
+
end
|
|
879
|
+
|
|
880
|
+
private def implement_node(target, implementation)
|
|
881
|
+
override = !target.implementation.nil?
|
|
882
|
+
target.implement!(implementation)
|
|
883
|
+
emit(
|
|
884
|
+
Events::ComponentImplemented,
|
|
885
|
+
key: target.path,
|
|
886
|
+
mode: implementation.mode,
|
|
887
|
+
deps: implementation.deps,
|
|
888
|
+
implementer: implementation.implementer.path,
|
|
889
|
+
override:
|
|
890
|
+
)
|
|
891
|
+
end
|
|
892
|
+
|
|
893
|
+
# The builder for a node, from a provider: the provider itself if it's callable,
|
|
894
|
+
# or what its #builder_for(node) returns, which must be callable.
|
|
895
|
+
private def builder_from(target, provider)
|
|
896
|
+
if provider.respond_to?(:builder_for)
|
|
897
|
+
builder = provider.builder_for(target)
|
|
898
|
+
return builder if builder.respond_to?(:call)
|
|
899
|
+
|
|
900
|
+
raise ArgumentError, "#{target.path}: #{provider.inspect}.builder_for must return a callable, got #{builder.inspect}"
|
|
901
|
+
end
|
|
902
|
+
return provider if provider.respond_to?(:call)
|
|
903
|
+
|
|
904
|
+
raise ArgumentError, "#{target.path}: a provider must respond to #call or #builder_for(node), got #{provider.inspect}"
|
|
905
|
+
end
|
|
906
|
+
|
|
907
|
+
# A node's deps, as full paths, and the ones that don't resolve to a component
|
|
908
|
+
private def graph_deps(node)
|
|
909
|
+
impl = node.implementation
|
|
910
|
+
return [[], []] unless impl
|
|
911
|
+
|
|
912
|
+
impl.deps.each_with_object([[], []]) do |dep, (deps, missing)|
|
|
913
|
+
if dep.end_with?('.*')
|
|
914
|
+
deps.concat(node.wildcard_nodes(dep).values.map(&:path))
|
|
915
|
+
next
|
|
916
|
+
end
|
|
917
|
+
|
|
918
|
+
target = impl.implementer.index[dep]
|
|
919
|
+
if target && !target.namespace?
|
|
920
|
+
deps << target.path
|
|
921
|
+
else
|
|
922
|
+
full = [impl.implementer.path, dep].compact.join('.')
|
|
923
|
+
deps << full
|
|
924
|
+
missing << full
|
|
925
|
+
end
|
|
926
|
+
end
|
|
927
|
+
end
|
|
928
|
+
|
|
929
|
+
# Walk a key's intermediate segments from this component, creating namespace nodes owned by it.
|
|
930
|
+
# Returns the branch node and the last segment.
|
|
931
|
+
private def walk(ckey)
|
|
932
|
+
ckey = ckey.to_s
|
|
933
|
+
raise ArgumentError, "invalid key #{ckey.inspect}" unless ckey.match?(/\A[^.]+(\.[^.]+)*\z/)
|
|
934
|
+
|
|
935
|
+
*segments, leaf = ckey.split('.')
|
|
936
|
+
branch = segments.reduce(self) do |current, segment|
|
|
937
|
+
child = current.children[segment] || current.attach(segment, Component.new(owner: self))
|
|
938
|
+
raise OwnershipError, ownership_message(child) unless child.owner.equal?(self)
|
|
939
|
+
|
|
940
|
+
child
|
|
941
|
+
end
|
|
942
|
+
[branch, leaf.freeze]
|
|
943
|
+
end
|
|
944
|
+
|
|
945
|
+
private def ownership_message(node)
|
|
946
|
+
owner = node.owner.path || 'another component'
|
|
947
|
+
"#{node.path} is owned by #{owner}: declare it there. This component can only implement it"
|
|
948
|
+
end
|
|
949
|
+
|
|
950
|
+
protected def type_name = type.inspect.gsub(/(Plumb::Types|Sourced::Component::T)::/, '')
|
|
951
|
+
end
|
|
952
|
+
end
|