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.
@@ -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