sourced-component 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -0
- data/README.md +154 -6
- data/lib/sourced/component/errors.rb +1 -0
- data/lib/sourced/component/events.rb +10 -1
- data/lib/sourced/component/injector.rb +30 -2
- data/lib/sourced/component/reconfiguration.rb +135 -0
- data/lib/sourced/component/version.rb +1 -1
- data/lib/sourced/component.rb +401 -23
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2896fb5ddac45462754f578010d435045bdaef50926865591b21bc49f68f8127
|
|
4
|
+
data.tar.gz: 86e67b664260f455dd0c0d47d5ad7492d5164e408d4f580b00e17f4b46a2873e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6fd436c2496d183ee205625cffdd0f8714835083d5e081713dbf012f20412e7d04d454fbdb0cf689154158425d0ef4f268c5a4c0c0d741870a20ef6b11671850
|
|
7
|
+
data.tar.gz: a2925c94cdc0fcfd6aafb5152ac051e9ed43680551e1e7891fb801c9e6b49e7d44597f7d269bda1950ac695a49c42f5e0dcca7e6cdac27874a25d95460957718
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,14 @@
|
|
|
5
5
|
- `#start_component!`, `#stop_component!` and `#restart_component!` start and stop components by key, following the dependency graph
|
|
6
6
|
- `components.deferred`, `components.stopping` and `components.stopped` events, and `NotStartedError`
|
|
7
7
|
- `#tree` and `#graph` show deferred and stopped components
|
|
8
|
+
- `.__component_deps` on classes including an injector: every component injected into them, including inherited ones, as the key it's registered under mapped to the name it's injected as
|
|
9
|
+
- `#factory(key, constructor)` implements a declared component from a class that injects, taking its dependencies and its constructor's keyword arguments from `.__component_deps`
|
|
10
|
+
- `#recycle_component!`, `#recycle_components!` and `#recycle!` run a component's whole lifecycle again, leaving every affected component in the status it was in. A recycle that raises can be retried: components remember the status to restore until one completes
|
|
11
|
+
- `root.recycling` and `root.recycled` events
|
|
12
|
+
- `#reconfigure(key) { |branch| ... }` re-declares one branch of a booted tree from scratch: what the block doesn't declare is removed, unchanged components keep running, and the new declaration set is validated before anything is torn down, so a failure leaves the tree as it was
|
|
13
|
+
- `root.reconfiguring`, `root.reconfigured` and `components.removed` events, and `RemovedComponentError`
|
|
14
|
+
- Document forking after `#prepare!`, with `#build!` and `#start!` in each child
|
|
15
|
+
- Fix: `#teardown!` left the rest of the tree running, and the root `:started`, when a hook raised something that wasn't a `StandardError` (ex. the `Interrupt` a signal handler raises). Every component is now torn down exactly once, and a component whose hooks already ran is never torn down again
|
|
8
16
|
|
|
9
17
|
## [0.1.0] - 2026-10-04
|
|
10
18
|
|
data/README.md
CHANGED
|
@@ -141,6 +141,8 @@ App.component!('with.deps', ['sourced.db']) { build { |db| Foo.new(db) } }
|
|
|
141
141
|
App.component('now') { build { Time.now } }
|
|
142
142
|
```
|
|
143
143
|
|
|
144
|
+
A class that injects its own dependencies can be implemented from them, without repeating the list: see [`#factory`](#factory).
|
|
145
|
+
|
|
144
146
|
### Aliases
|
|
145
147
|
|
|
146
148
|
`#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:
|
|
@@ -382,9 +384,81 @@ App.start_component!('dispatcher') # starts dispatcher, then monitor
|
|
|
382
384
|
- **If `stop` hooks raise,** every component is still stopped, and the first error is re-raised.
|
|
383
385
|
- **`#teardown!`** runs `stop` on the started components and `teardown` on all of them, deferred or stopped ones included.
|
|
384
386
|
|
|
387
|
+
### Recycling components
|
|
388
|
+
|
|
389
|
+
`#recycle_component!(key, context = Thread.current)` runs a component's whole lifecycle again, from the top: it stops the component if it's running, tears it down, drops its value, then prepares and builds it from scratch. This is for class reloaders and the like — a file changes, and the components built from it are recycled.
|
|
390
|
+
|
|
391
|
+
```ruby
|
|
392
|
+
App.recycle_component!('sourced.store', task)
|
|
393
|
+
App.recycle_components!('sourced.store', 'repos.users', context: task) # several at once
|
|
394
|
+
App.recycle!(task) # the whole tree
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
- **Every component is left in the status it was in before.** A started one is started again with `context`, and one that was only built stops at built. Nothing changes the root's own status.
|
|
398
|
+
- **The components depending on it are recycled too**, directly or not: their values were built from its old value, so they're stale. Its own dependencies are left alone, running.
|
|
399
|
+
- **A component that was stopped by key, or deferred, comes back `:built` and still held.** Its fresh value has never run, so it waits to be started by `#start_component!`, exactly as it was waiting before.
|
|
400
|
+
- **Order:** down in reverse dependency order, dependents first, then up in dependency order, one stage at a time — every affected component is prepared before any is built, as when the root boots.
|
|
401
|
+
- **Keys are relative to the component** the method is called on, like `#component!`. Only the root can `#recycle!` the whole tree.
|
|
402
|
+
- **Hooks:** a started component runs `stop` then `teardown` on the way down, and `prepare`, `build` and `start` on the way back up. A component that wasn't running only runs `teardown`.
|
|
403
|
+
- **Events:** `root.recycling` and `root.recycled` wrap the operation, and each component publishes its usual stage events, so a reloader can follow along. As with `#teardown!`, the `stop` hooks of a started component run under its `components.tearing_down` event.
|
|
404
|
+
|
|
405
|
+
```
|
|
406
|
+
db <- store <- dispatcher
|
|
407
|
+
|
|
408
|
+
App.recycle_component!('store') # tears down dispatcher, then store
|
|
409
|
+
# then builds store, dispatcher, and starts both again
|
|
410
|
+
# db keeps running, with the same value
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
- **If a `stop` or `teardown` hook raises,** every affected component is still torn down and its value dropped, and the first error is re-raised before anything is prepared again. They're left `:open`, with no value: reading one raises `NotBuiltError`.
|
|
414
|
+
- **If a `prepare`, `build` or `start` hook raises,** the component is left where it got to and the error is re-raised, along with the components the recycle hadn't reached: `:open` or `:prepared` with no value, where reading one raises `NotBuiltError`, or `:built` with the value it just built for them.
|
|
415
|
+
- **Recycling again recovers, however it failed.** Each component remembers the status the first recycle meant to restore, until one completes, so a retry puts it back even when its own status no longer says it was running: a component a failed `start` left `:built` is started again, not left behind. A reloader can just retry on the next save.
|
|
416
|
+
- **The root must be built, or started.** Before that there's nothing to recycle, and it raises `NotBuiltError`; once the root is torn down, `TornDownError`. Recycling while the root is booting raises `LockedComponentError`: mid-`#start!` there's no settled status to go back to.
|
|
417
|
+
- **No code is reloaded.** Recycling re-runs the hooks a component was implemented with; it doesn't re-implement it. The tree is locked once prepared, so a class captured in a provider (`App.component!('store', Store)`) stays captured — only a block that resolves the constant when it builds (`build { Store.new }`) picks up a reloaded class.
|
|
418
|
+
|
|
419
|
+
### Re-configuring a booted tree
|
|
420
|
+
|
|
421
|
+
`#reconfigure(key, context = Thread.current)` re-declares one branch of the tree from scratch while it's running. The block declares the branch's new contents, and whatever it doesn't declare is removed. This is for file watchers: rescan a directory, re-register every file, and removal is a consequence of not declaring a key rather than an operation of its own.
|
|
422
|
+
|
|
423
|
+
```ruby
|
|
424
|
+
App.reconfigure('reactors') do |reactors|
|
|
425
|
+
files.each { |f| reactors.declare(f.key) } # idempotent: these are kept
|
|
426
|
+
changed.each { |f| reactors.component!(f.key, f.deps, &f.hooks) } # these recycle
|
|
427
|
+
end
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
| In the block | Before | Result |
|
|
431
|
+
| --- | --- | --- |
|
|
432
|
+
| declared, not re-implemented | existed | kept, untouched: same value and status |
|
|
433
|
+
| declared and re-implemented | existed | recycled |
|
|
434
|
+
| declared with another type | existed | recycled |
|
|
435
|
+
| declared | new | prepared, built, and started if the root is started |
|
|
436
|
+
| not declared | existed | torn down and removed |
|
|
437
|
+
|
|
438
|
+
- **Re-declaring is idempotent**, so a component whose declaration didn't change keeps its value and status, and runs no hooks. Only the ones the block re-implements, re-types or declares for the first time are recycled (see above), along with everything depending on them.
|
|
439
|
+
- **Components elsewhere in the tree are recycled if their dependencies changed**, which is how a `config('runner', ['reactors.*'])` picks up an added or removed reactor: its memoized value listed the old ones.
|
|
440
|
+
- **Nothing runs any hooks until the whole new declaration set has been validated.** A block that raises, a missing dependency, a cycle, a declaration with no implementation, or dropping a component something still depends on: each leaves the tree exactly as it was, still running. A broken file save is the normal case in a dev loop, so recovering from it is the point.
|
|
441
|
+
- **Components stopped by key stay stopped**, and deferred ones stay deferred. Re-declaring doesn't start anything that wasn't running.
|
|
442
|
+
- **Keys are relative to the branch**, and the block declares through the component `#reconfigure` was called on, so ownership comes out exactly as plain declaration would. The branch must be a namespace, and the tree is locked for anything outside the block as usual.
|
|
443
|
+
- **Removed components are `:removed`**, which is terminal. They're gone from `#index`, `#tree` and `#graph`, so reading them by key raises `UndeclaredComponentError` — and anything still holding the node itself, ex. a class that injected it, raises `RemovedComponentError` rather than reading a dead value.
|
|
444
|
+
- **Nested keys work the same**, since dotted keys build the tree. A namespace left with nothing under it is removed too, pruned bottom-up: dropping `billing.deep.x` leaves neither `deep` nor `billing` behind.
|
|
445
|
+
- **A component that keeps declared children reverts to a namespace** instead of being removed. That's the `billing.rb` → `billing/` refactor: it's torn down and loses its implementation, but stays as the parent of its new children. Collapsing it back the other way re-implements it and removes the children.
|
|
446
|
+
- **An open tree** reconfigures as plain declaration: nothing is built yet, so nothing is torn down or recycled, and `#prepare!` validates the whole set when it boots.
|
|
447
|
+
- **Events:** `root.reconfiguring` and `root.reconfigured` wrap the operation, with `components.removed` for each removal and the usual stage and recycling events in between.
|
|
448
|
+
- **No code is reloaded**, as with recycling: the block supplies the new implementations, and a class captured in a provider is only re-captured if the block passes it again.
|
|
449
|
+
|
|
450
|
+
```ruby
|
|
451
|
+
App.reconfigure('reactors') do |reactors|
|
|
452
|
+
reactors.declare('audit') # kept, still running
|
|
453
|
+
reactors.declare('billing.invoices') # kept
|
|
454
|
+
reactors.component!('billing.invoices', [], NewImpl) # ... but re-implemented, so recycled
|
|
455
|
+
# 'billing.payments' isn't declared: torn down and removed
|
|
456
|
+
end
|
|
457
|
+
```
|
|
458
|
+
|
|
385
459
|
### Signal handlers
|
|
386
460
|
|
|
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.
|
|
461
|
+
The lifecycle methods, and anything else that takes the root's lock (declaring, implementing and mounting components, `#recycle_component!` and friends, `#reconfigure`, `#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
462
|
|
|
389
463
|
Instead, have the trap wake up the main thread, and tear down from there, as in the example above:
|
|
390
464
|
|
|
@@ -398,10 +472,31 @@ trap('TERM') { Thread.main.raise(Interrupt) }
|
|
|
398
472
|
|
|
399
473
|
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
474
|
|
|
475
|
+
### Forked processes
|
|
476
|
+
|
|
477
|
+
`#prepare!` resolves dependencies, computes the boot order and runs the `prepare` hooks, but builds no values. That makes it the point to fork from: the parent holds no connections, sockets or threads, and each child builds and starts its own.
|
|
478
|
+
|
|
479
|
+
```ruby
|
|
480
|
+
App.prepare! # once, in the parent: requires, validation, dependency order
|
|
481
|
+
|
|
482
|
+
workers.times do
|
|
483
|
+
fork do
|
|
484
|
+
App.build!
|
|
485
|
+
App.start!(task) # this process' own values, from here on
|
|
486
|
+
...
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
- **Don't fork a built or started tree.** Threads don't survive `fork`, so a long-running component would report `:started` in the child with nothing running, and file descriptors *do* survive, shared: the child and the parent would write to the same connection. Tearing down or recycling in the child would then run `stop` and `teardown` hooks on resources the parent still owns.
|
|
492
|
+
- **Fork from the main thread**, with no lifecycle call in flight. The root's `Monitor` is copied as-is, so forking while another thread holds it leaves the child's copy locked forever.
|
|
493
|
+
- **A reconfigured branch comes up to where the tree already is**, and no further: on a prepared tree its new components are prepared but not built, so children forked afterwards build them for themselves. Deferring lowers that ceiling to `:built`, it never raises it.
|
|
494
|
+
- **Everything else is per-process.** `#recycle_component!` and `#reconfigure` act on one process' tree, in memory, with no coordination between them: a file watcher has to run in each process that should react to it, as `ActiveSupport::FileUpdateChecker` and Zeitwerk do. Driving one from a signal has the `trap` restriction above, since it takes the root's lock.
|
|
495
|
+
|
|
401
496
|
### Errors while starting and tearing down
|
|
402
497
|
|
|
403
498
|
- 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.
|
|
499
|
+
- If `stop` or `teardown` hooks raise, every component is still torn down, exactly once, and the first error is re-raised. This holds for anything they raise, `Interrupt` included: a signal handler interrupting the shutdown can't leave part of the tree running, and the root still ends `:torn_down`. A component whose hooks already ran is never torn down again, so `#teardown!` is safe to call once more after one failed.
|
|
405
500
|
|
|
406
501
|
## Mounting components
|
|
407
502
|
|
|
@@ -553,6 +648,53 @@ App.start!
|
|
|
553
648
|
MyLib::Dispatcher.new.store # => #<DBStore ...>, the app's override
|
|
554
649
|
```
|
|
555
650
|
|
|
651
|
+
### `.__component_deps`
|
|
652
|
+
|
|
653
|
+
Including an injector also defines `.__component_deps` on the class: every component injected into it, including the ones injected into its ancestors, as the key it's registered under mapped to the name it's injected as.
|
|
654
|
+
|
|
655
|
+
```ruby
|
|
656
|
+
class Foo
|
|
657
|
+
include App.inject('logger', 'repos.users' => 'customers')
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
Foo.__component_deps # => { "logger" => :logger, "repos.users" => :customers }
|
|
661
|
+
```
|
|
662
|
+
|
|
663
|
+
- **Keys** are the ones components are **registered** under, never the names they're injected as. **Values** are those names, as symbols, so they can go straight into keyword arguments.
|
|
664
|
+
- Inherited dependencies are included, the superclass' first, each class' own in the order they were injected. A component injected twice is listed once, under the name closest to the class: a subclass injecting `'logger' => 'app_logger'` over its parent's `'logger'` reports `:app_logger`.
|
|
665
|
+
- Keys are paths from the root of the component's tree, so they follow mounting. `MyLib::Dispatcher` above reports `{ "store" => :store }` while the library is standalone, and `{ "my_lib.store" => :store }` once the app mounts it at `my_lib`: the key the app would use to read it.
|
|
666
|
+
- An alias (see `#alias`) is listed under its own key, not its target's.
|
|
667
|
+
- A class defining its own `def self.__component_deps` keeps it: unlike the injected readers, this one is never overwritten.
|
|
668
|
+
- `Sourced::Component::Injector.deps_for(klass)` returns the same hash for any class or module, which is useful when an injector is included into a module rather than directly into a class: the module gets `.__component_deps`, but classes including it don't.
|
|
669
|
+
|
|
670
|
+
### `#factory`
|
|
671
|
+
|
|
672
|
+
Keys and values line up with a component's dependencies and its constructor's keyword arguments, so `#factory(key, constructor)` implements a declared component from a class that injects:
|
|
673
|
+
|
|
674
|
+
```ruby
|
|
675
|
+
class Dispatcher
|
|
676
|
+
include App.inject('logger', 'repos.users' => 'customers')
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
App.declare('dispatcher', Dispatcher)
|
|
680
|
+
App.factory('dispatcher', Dispatcher) # Dispatcher.new(logger:, customers:)
|
|
681
|
+
```
|
|
682
|
+
|
|
683
|
+
which is the same as writing out what the class already declares:
|
|
684
|
+
|
|
685
|
+
```ruby
|
|
686
|
+
deps = Dispatcher.__component_deps # { "logger" => :logger, "repos.users" => :customers }
|
|
687
|
+
App.config('dispatcher', deps.keys) { |*values| Dispatcher.new(**deps.values.zip(values).to_h) }
|
|
688
|
+
```
|
|
689
|
+
|
|
690
|
+
- **The injected components become the component's dependencies**, so it's built after them and [`#graph`](#graph) shows the edges.
|
|
691
|
+
- **Values are passed to the constructor**, so each dependency is read once per build. The injector takes the keyword arguments it's given instead of reading the components itself, which matters for a dynamic dependency: the instance and the build see the same value.
|
|
692
|
+
- **It's dynamic**, like `#config`: a new instance on every read. For one instance, built once, use `App.config!('dispatcher', deps.keys) { ... }` with the block above.
|
|
693
|
+
- **Any class works.** One that injects nothing is built with `new` and no arguments, and so has no dependencies. One that needs positional arguments doesn't fit: `#factory` only passes keywords.
|
|
694
|
+
- **Inherited injections are included**, since `.__component_deps` includes them.
|
|
695
|
+
- **Declare the key first.** `#factory` implements an existing declaration, and the built instance is parsed through its declared type like any other value.
|
|
696
|
+
- **Keys are paths from the root of the injector's tree** (see above), so call `#factory` on that root. Calling it on a component mounted inside that tree resolves them relative to itself instead, and `#prepare!` then raises `MissingDependencyError`.
|
|
697
|
+
|
|
556
698
|
## Inspecting the tree
|
|
557
699
|
|
|
558
700
|
```ruby
|
|
@@ -570,8 +712,10 @@ node.root # => App
|
|
|
570
712
|
node.owner # => the component that declared it
|
|
571
713
|
node.type # => the declared type
|
|
572
714
|
node.implementation # => deps, mode (:singleton, :dynamic or :alias) and the implementing component
|
|
715
|
+
node.dep_nodes # => the components it depends on, wildcards included. See #graph for keys
|
|
573
716
|
node.children # => { segment => Component }
|
|
574
717
|
node.namespace? # => no type and no implementation
|
|
718
|
+
node.removed? # => whether a #reconfigure dropped it from the tree
|
|
575
719
|
```
|
|
576
720
|
|
|
577
721
|
### `#tree`
|
|
@@ -733,10 +877,13 @@ end
|
|
|
733
877
|
| `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
878
|
| `components.tearing_down` / `components.torn_down` | around a component's `teardown` hooks | `key`, and `duration` when finished |
|
|
735
879
|
| `components.deferred` | `#defer` | `key`, `deferrer` (the full path of the component that deferred it, `nil` for the root) |
|
|
880
|
+
| `components.removed` | a component `#reconfigure` dropped from the tree | `key`, `remover` (the full path of the component that reconfigured, `nil` for the root) |
|
|
736
881
|
| `components.failed` | a component's hook (or type check) raised | `key`, `stage`, `error_class`, `error_message`, `backtrace` |
|
|
737
882
|
| `root.preparing` / `root.prepared` | around `#prepare!` | `duration` when finished |
|
|
738
883
|
| `root.building` / `root.built` | around `#build!` | `duration` when finished |
|
|
739
884
|
| `root.starting` / `root.started` | around `#start!` | `duration` when finished |
|
|
885
|
+
| `root.recycling` / `root.recycled` | around `#recycle_component!`, `#recycle_components!` and `#recycle!` | `duration` when finished |
|
|
886
|
+
| `root.reconfiguring` / `root.reconfigured` | around `#reconfigure` | `duration` when finished |
|
|
740
887
|
| `root.tearing_down` / `root.torn_down` | around `#teardown!` | `duration` when finished |
|
|
741
888
|
| `root.failed` | a lifecycle step raised | `stage`, `error_class`, `error_message`, `backtrace` |
|
|
742
889
|
|
|
@@ -744,7 +891,7 @@ end
|
|
|
744
891
|
- **`key`** is the component's full path from the root, ex. `sourced.db`.
|
|
745
892
|
- **`deps`** are relative to the `implementer`, the full path of the component that implemented the component (`nil` for the root).
|
|
746
893
|
- **`duration`** is in seconds, measured with a monotonic clock.
|
|
747
|
-
- **`stage`** is one of `:prepare`, `:build`, `:start`, `:stop` or `:
|
|
894
|
+
- **`stage`** is one of `:prepare`, `:build`, `:start`, `:stop`, `:teardown`, `:recycle` or `:reconfigure`.
|
|
748
895
|
- **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
896
|
|
|
750
897
|
A few rules:
|
|
@@ -834,15 +981,16 @@ Plumb::ParseError: user: {age: "Must be a Integer"}
|
|
|
834
981
|
| --- | --- |
|
|
835
982
|
| `DeclarationOverrideError` | declaring or mounting on a key that's already declared |
|
|
836
983
|
| `OwnershipError` | declaring or mounting under nodes owned by another component |
|
|
837
|
-
| `LockedComponentError` | changing the tree after it's prepared,
|
|
984
|
+
| `LockedComponentError` | changing the tree after it's prepared, mounting a component that isn't open, recycling or reconfiguring while the root is booting, or a nested `#reconfigure` |
|
|
838
985
|
| `SubcomponentError` | booting a mounted component, or mounting a component that's already mounted |
|
|
839
986
|
| `UndeclaredComponentError` | implementing or reading an undeclared key, or reading a namespace |
|
|
840
987
|
| `UnimplementedComponentError` | preparing with declared (or deferred) components that have no implementation |
|
|
841
988
|
| `MissingDependencyError` | preparing with dependencies that aren't declared or implemented |
|
|
842
989
|
| `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
|
|
990
|
+
| `NotBuiltError` | reading values before the component is built, reading one that was recycled away, or recycling before the root is built |
|
|
991
|
+
| `TornDownError` | starting a component that's torn down, or starting, stopping or recycling a component by key once its root is torn down |
|
|
845
992
|
| `NotStartedError` | starting or stopping a component by key before its root is started |
|
|
993
|
+
| `RemovedComponentError` | reading a component that `#reconfigure` removed from the tree |
|
|
846
994
|
| `InjectionError` | including an injector in a class that already has a method with an injected name, or already injects it |
|
|
847
995
|
|
|
848
996
|
## Thread safety
|
|
@@ -14,6 +14,7 @@ module Sourced
|
|
|
14
14
|
NotBuiltError = Class.new(ComponentError)
|
|
15
15
|
TornDownError = Class.new(ComponentError)
|
|
16
16
|
NotStartedError = Class.new(ComponentError)
|
|
17
|
+
RemovedComponentError = Class.new(ComponentError)
|
|
17
18
|
InjectionError = Class.new(ComponentError)
|
|
18
19
|
end
|
|
19
20
|
end
|
|
@@ -49,7 +49,7 @@ module Sourced
|
|
|
49
49
|
|
|
50
50
|
Completed = proc { attribute :duration, Float }
|
|
51
51
|
Failed = proc do
|
|
52
|
-
attribute :stage, Symbol # :prepare, :build, :start, :stop or :
|
|
52
|
+
attribute :stage, Symbol # :prepare, :build, :start, :stop, :teardown, :recycle or :reconfigure
|
|
53
53
|
attribute :error_class, String
|
|
54
54
|
attribute :error_message, String
|
|
55
55
|
attribute :backtrace, Plumb::Types::Array[String]
|
|
@@ -61,6 +61,10 @@ module Sourced
|
|
|
61
61
|
RootBuilt = RootEvent.define('root.built', &Completed)
|
|
62
62
|
RootStarting = RootEvent.define('root.starting')
|
|
63
63
|
RootStarted = RootEvent.define('root.started', &Completed)
|
|
64
|
+
RootReconfiguring = RootEvent.define('root.reconfiguring')
|
|
65
|
+
RootReconfigured = RootEvent.define('root.reconfigured', &Completed)
|
|
66
|
+
RootRecycling = RootEvent.define('root.recycling')
|
|
67
|
+
RootRecycled = RootEvent.define('root.recycled', &Completed)
|
|
64
68
|
RootTearingDown = RootEvent.define('root.tearing_down')
|
|
65
69
|
RootTornDown = RootEvent.define('root.torn_down', &Completed)
|
|
66
70
|
RootFailed = RootEvent.define('root.failed', &Failed)
|
|
@@ -77,6 +81,9 @@ module Sourced
|
|
|
77
81
|
ComponentDeferred = ComponentEvent.define('components.deferred') do
|
|
78
82
|
attribute :deferrer, Plumb::Types::String.nullable # the full path of the component that deferred it. nil for the root
|
|
79
83
|
end
|
|
84
|
+
ComponentRemoved = ComponentEvent.define('components.removed') do
|
|
85
|
+
attribute :remover, Plumb::Types::String.nullable # the full path of the component that removed it. nil for the root
|
|
86
|
+
end
|
|
80
87
|
ComponentPreparing = ComponentEvent.define('components.preparing')
|
|
81
88
|
ComponentPrepared = ComponentEvent.define('components.prepared', &Completed)
|
|
82
89
|
ComponentBuilding = ComponentEvent.define('components.building')
|
|
@@ -97,6 +104,8 @@ module Sourced
|
|
|
97
104
|
prepare: [Events::RootPreparing, Events::RootPrepared],
|
|
98
105
|
build: [Events::RootBuilding, Events::RootBuilt],
|
|
99
106
|
start: [Events::RootStarting, Events::RootStarted],
|
|
107
|
+
reconfigure: [Events::RootReconfiguring, Events::RootReconfigured],
|
|
108
|
+
recycle: [Events::RootRecycling, Events::RootRecycled],
|
|
100
109
|
teardown: [Events::RootTearingDown, Events::RootTornDown]
|
|
101
110
|
}.freeze
|
|
102
111
|
|
|
@@ -11,16 +11,33 @@ module Sourced
|
|
|
11
11
|
# Values are read from the nodes themselves, so a class injecting from a library's component
|
|
12
12
|
# gets the overrides of the application that mounts it.
|
|
13
13
|
class Injector < Module
|
|
14
|
-
|
|
14
|
+
# Extended into whatever includes an Injector, see #included
|
|
15
|
+
module ComponentDeps
|
|
16
|
+
# Every component injected into this class, including inherited ones, by key.
|
|
17
|
+
# See Injector.deps_for
|
|
18
|
+
def __component_deps = Injector.deps_for(self)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Every component injected into mod, as the key it's registered under (a path from the root of
|
|
22
|
+
# its tree) mapped to the name it's injected as, in injection order, with the ones injected
|
|
23
|
+
# into mod's ancestors first. Injecting a key again under another name replaces the name, so
|
|
24
|
+
# the one closest to mod wins.
|
|
25
|
+
# { 'logger' => :logger, 'repos.users' => :customers }
|
|
26
|
+
def self.deps_for(mod)
|
|
27
|
+
mod.ancestors.grep(Injector).reverse.reduce({}) { |deps, injector| deps.merge(injector.mapping) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
attr_reader :names, :nodes
|
|
15
31
|
|
|
16
32
|
# nodes: { 'component.key' => <Component node> }
|
|
17
33
|
# names: { 'component.key' => :kwarg_name }
|
|
18
34
|
def initialize(nodes, names)
|
|
19
35
|
super()
|
|
20
36
|
@names = names.freeze
|
|
37
|
+
@nodes = names.keys.map { |key| nodes.fetch(key) }.freeze
|
|
21
38
|
|
|
22
39
|
# Instance variable names are computed once, not on every #new
|
|
23
|
-
entries = names.map { |
|
|
40
|
+
entries = @nodes.zip(names.values).map { |node, name| [node, name, :"@#{name}"] }.freeze
|
|
24
41
|
|
|
25
42
|
initializer = Module.new do
|
|
26
43
|
define_method(:initialize) do |*args, **kwargs, &block|
|
|
@@ -50,9 +67,20 @@ module Sourced
|
|
|
50
67
|
define_singleton_method(:included) do |base|
|
|
51
68
|
base.prepend(initializer)
|
|
52
69
|
base.attr_reader(*names.values)
|
|
70
|
+
base.extend(ComponentDeps)
|
|
53
71
|
end
|
|
54
72
|
end
|
|
55
73
|
|
|
74
|
+
# The keys as passed to Component#inject, relative to the component they were injected from
|
|
75
|
+
def keys = names.keys
|
|
76
|
+
|
|
77
|
+
# The keys the injected components are registered under, as paths from the root of their tree.
|
|
78
|
+
# Computed on each call: a node's path changes when its root is mounted into another component.
|
|
79
|
+
def paths = nodes.map(&:path)
|
|
80
|
+
|
|
81
|
+
# Those keys, mapped to the names the components are injected as
|
|
82
|
+
def mapping = paths.zip(names.values).to_h
|
|
83
|
+
|
|
56
84
|
def inspect = "#<#{self.class} #{names.map { |key, name| "#{key} => #{name}" }.join(', ')}>"
|
|
57
85
|
end
|
|
58
86
|
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sourced
|
|
4
|
+
class Component
|
|
5
|
+
# A re-declaration of one branch of a booted tree, driven by Component#reconfigure: it records
|
|
6
|
+
# what the block declares, and snapshots what's needed to roll back if anything raises.
|
|
7
|
+
# Branch what the block declares through, see below
|
|
8
|
+
# #snapshot! everything the block or the validation can touch
|
|
9
|
+
# #declared! one per key the block declares, from Component#declare
|
|
10
|
+
# #implemented! one per key it re-implements, from Component#implement_node
|
|
11
|
+
# #retyped! one per surviving node whose type changed, from Component#declare
|
|
12
|
+
class Reconfiguration
|
|
13
|
+
# What a reconfiguration can change about one node: captured before its block runs, restored
|
|
14
|
+
# if anything raises. The members drive both the capture and the restore, so a new one can't
|
|
15
|
+
# be captured and then forgotten on the way back
|
|
16
|
+
NodeState = Data.define(
|
|
17
|
+
:implementation, :type, :implicit, :deps, :dep_nodes, :deferred, :held, :status, :value,
|
|
18
|
+
:recycle_to, :children, :index
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# The tree as it was before the block: every node's state, and the root's order
|
|
22
|
+
Snapshot = Data.define(:order, :nodes)
|
|
23
|
+
|
|
24
|
+
# Declares into +branch+ on behalf of +owner+, the component #reconfigure was called on: keys
|
|
25
|
+
# are prefixed and passed on, so the owner declares them as it would itself. That's what makes
|
|
26
|
+
# nested keys work, since a branch node can't declare under namespaces the root created.
|
|
27
|
+
class Branch
|
|
28
|
+
def initialize(owner, prefix)
|
|
29
|
+
@owner = owner
|
|
30
|
+
@prefix = prefix
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def declare(ckey, ...) = @owner.declare(key(ckey), ...)
|
|
34
|
+
def component!(ckey, ...) = @owner.component!(key(ckey), ...)
|
|
35
|
+
def component(ckey, ...) = @owner.component(key(ckey), ...)
|
|
36
|
+
def config!(ckey, ...) = @owner.config!(key(ckey), ...)
|
|
37
|
+
def config(ckey, ...) = @owner.config(key(ckey), ...)
|
|
38
|
+
def alias(ckey, ...) = @owner.alias(key(ckey), ...)
|
|
39
|
+
def defer(ckey) = @owner.defer(key(ckey))
|
|
40
|
+
def node(ckey) = @owner.node(key(ckey))
|
|
41
|
+
def declared?(ckey) = @owner.declared?(key(ckey))
|
|
42
|
+
|
|
43
|
+
def inspect = "#<#{self.class} #{@prefix}>"
|
|
44
|
+
|
|
45
|
+
private def key(ckey) = "#{@prefix}.#{ckey}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# branch: the namespace node being re-declared. owner: the component #reconfigure was called on
|
|
49
|
+
def initialize(branch, owner)
|
|
50
|
+
@branch = branch
|
|
51
|
+
@owner = owner
|
|
52
|
+
@declared = Set.new # nodes the block declared, new and surviving
|
|
53
|
+
@changed = Set.new # nodes it re-implemented, or declared with another type
|
|
54
|
+
@before = Set.new # the nodes under the branch before the block
|
|
55
|
+
@snapshot = nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def branch_handle = Branch.new(@owner, @branch.path)
|
|
59
|
+
|
|
60
|
+
def snapshot!
|
|
61
|
+
root = @branch.root
|
|
62
|
+
@before = @branch.index.values.to_set
|
|
63
|
+
nodes = [root, *root.index.values]
|
|
64
|
+
@snapshot = Snapshot.new(order: root.order&.dup, nodes: nodes.to_h { |n| [n, n.capture_state] })
|
|
65
|
+
self
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Put it all back. Nothing has run any hooks yet, so this is all it takes
|
|
69
|
+
def rollback!
|
|
70
|
+
@snapshot.nodes.each { |n, state| n.restore_state!(state) }
|
|
71
|
+
@branch.root.restore_order!(@snapshot.order)
|
|
72
|
+
self
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def declared!(node) = @declared << node
|
|
76
|
+
def changed!(node) = @changed << node
|
|
77
|
+
|
|
78
|
+
# Whether the tree didn't have this component before the block. Component#declare asks, to
|
|
79
|
+
# announce only those
|
|
80
|
+
def new?(node) = !@before.include?(node)
|
|
81
|
+
|
|
82
|
+
# The order before the block ran, to tear removed components down in reverse
|
|
83
|
+
def order_before = @snapshot.order || []
|
|
84
|
+
|
|
85
|
+
# What the block changed, plus every node whose resolved deps are no longer the same nodes
|
|
86
|
+
def affected
|
|
87
|
+
redeps = @snapshot.nodes.filter_map { |n, state| n unless state.dep_nodes == n.dep_nodes }
|
|
88
|
+
(@changed | created | redeps).reject { |n| n.removed? || n.namespace? }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Components the tree didn't have: new nodes, and namespaces the block implemented
|
|
92
|
+
def newly_components
|
|
93
|
+
was_namespace = @snapshot.nodes.filter_map do |n, state|
|
|
94
|
+
n if state.implementation.nil? && !n.implementation.nil?
|
|
95
|
+
end
|
|
96
|
+
(created | was_namespace).reject(&:namespace?)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Components the block declared that the tree didn't have before
|
|
100
|
+
def created = @declared - @before
|
|
101
|
+
|
|
102
|
+
# Dropped components that still have declared descendants, ex. one at 'billing' whose file
|
|
103
|
+
# became a 'billing/' directory. They revert to namespaces: removing them would orphan those
|
|
104
|
+
def reverting = dropped.fetch(:reverting)
|
|
105
|
+
|
|
106
|
+
# Everything to take out of the tree: dropped components with no declared descendants left,
|
|
107
|
+
# plus the namespaces that leaves empty, pruned bottom-up until stable (dropping
|
|
108
|
+
# 'billing.deep.x' can empty 'deep', which can empty 'billing')
|
|
109
|
+
def removing
|
|
110
|
+
gone = dropped.fetch(:removing).dup
|
|
111
|
+
loop do
|
|
112
|
+
empty = @before.select do |n|
|
|
113
|
+
n.namespace? && !gone.include?(n) && n.children.any? && (n.children.values - gone).empty?
|
|
114
|
+
end
|
|
115
|
+
break gone if empty.empty?
|
|
116
|
+
|
|
117
|
+
gone.concat(empty)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The nodes the block didn't declare, split into the ones to take out of the tree and the ones
|
|
122
|
+
# that revert to namespaces. Computed once: the block has finished by the time anything asks
|
|
123
|
+
private def dropped
|
|
124
|
+
@dropped ||= begin
|
|
125
|
+
kept, gone = (@before - @declared).partition { |n| keeps_children?(n) }
|
|
126
|
+
{ removing: gone, reverting: kept.reject(&:namespace?) }
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private def keeps_children?(node)
|
|
131
|
+
node.index.values.any? { |d| @declared.include?(d) }
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
data/lib/sourced/component.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'monitor'
|
|
4
|
+
require 'set'
|
|
4
5
|
require 'tsort'
|
|
5
6
|
require 'plumb'
|
|
6
7
|
require_relative 'component/version'
|
|
@@ -14,6 +15,7 @@ require_relative 'component/graph'
|
|
|
14
15
|
require_relative 'component/events'
|
|
15
16
|
require_relative 'component/notifier'
|
|
16
17
|
require_relative 'component/tree'
|
|
18
|
+
require_relative 'component/reconfiguration'
|
|
17
19
|
|
|
18
20
|
module Sourced
|
|
19
21
|
# A tree of components. Every node is a Component: it can declare a type, be implemented with
|
|
@@ -31,8 +33,9 @@ module Sourced
|
|
|
31
33
|
|
|
32
34
|
# Lifecycle statuses, in order. Shared by the root (boot status) and every node.
|
|
33
35
|
# Only nodes are ever :stopped: a started node stopped by key (see #stop_component!),
|
|
34
|
-
# which can be started again.
|
|
35
|
-
|
|
36
|
+
# which can be started again. Only nodes are ever :removed either: a component a
|
|
37
|
+
# reconfiguration dropped from the tree (see #reconfigure), which is terminal.
|
|
38
|
+
STATUSES = %i[open prepared built started stopped torn_down removed].freeze
|
|
36
39
|
|
|
37
40
|
# What #mount takes: anything that returns a Component from #to_component
|
|
38
41
|
MountableInterface = Plumb::Types::Interface[:to_component]
|
|
@@ -57,6 +60,8 @@ module Sourced
|
|
|
57
60
|
@value = nil
|
|
58
61
|
@deferred = false # skipped by the root's #start!, see #defer
|
|
59
62
|
@held = false # not started with the tree or its dependencies: deferred, or stopped by key
|
|
63
|
+
@recycle_to = nil # the status to come back to while recycling, see #recycle_component!
|
|
64
|
+
@reconfiguration = nil # the reconfiguration in progress, on the root only. See #reconfigure
|
|
60
65
|
@deps = [].freeze # resolved deps: a node, or { segment => node } for a wildcard
|
|
61
66
|
@dep_nodes = [].freeze # every node in @deps, for sorting
|
|
62
67
|
@children = {}
|
|
@@ -87,8 +92,18 @@ module Sourced
|
|
|
87
92
|
|
|
88
93
|
# Whether the root's #start! skips it (see #defer)
|
|
89
94
|
def deferred? = @deferred
|
|
95
|
+
# Whether the tree is past :open. #mount asks this of the component being mounted
|
|
90
96
|
def locked? = root.boot_status != :open
|
|
91
97
|
|
|
98
|
+
# Whether a reconfiguration's block is running. Only ever true on the root
|
|
99
|
+
def reconfiguring? = !@reconfiguration.nil?
|
|
100
|
+
|
|
101
|
+
# Whether the tree refuses declarations: locked, unless a #reconfigure block is open
|
|
102
|
+
private def declarations_locked? = locked? && !root.reconfiguring?
|
|
103
|
+
|
|
104
|
+
# The Reconfiguration in progress, which records what its block declares
|
|
105
|
+
protected def reconfiguration = @reconfiguration
|
|
106
|
+
|
|
92
107
|
def inspect
|
|
93
108
|
details = namespace? ? '(namespace)' : "#{type_name} (#{[implementation&.mode || 'not implemented', status, ('deferred' if deferred?)].compact.join(', ')})"
|
|
94
109
|
"#<#{self.class} #{path || '(root)'} #{details}>"
|
|
@@ -99,20 +114,28 @@ module Sourced
|
|
|
99
114
|
# comp.declare('sourced.db.logger', Logger) { Logger.new(STDOUT) }
|
|
100
115
|
def declare(ckey, type = T::Any, &default)
|
|
101
116
|
synchronize do
|
|
102
|
-
raise LockedComponentError, "can't declare #{ckey} in a locked component" if
|
|
117
|
+
raise LockedComponentError, "can't declare #{ckey} in a locked component" if declarations_locked?
|
|
103
118
|
|
|
119
|
+
reconf = root.reconfiguration
|
|
104
120
|
branch, leaf = walk(ckey)
|
|
105
121
|
node = branch.children[leaf]
|
|
106
122
|
if node.nil?
|
|
107
123
|
node = branch.attach(leaf, Component.new(owner: self, type:))
|
|
108
124
|
elsif !node.owner.equal?(self)
|
|
109
125
|
raise OwnershipError, ownership_message(node)
|
|
110
|
-
elsif !node.implicit?
|
|
126
|
+
elsif !node.implicit? && reconf.nil?
|
|
111
127
|
raise DeclarationOverrideError, "#{node.path} is already declared"
|
|
112
128
|
else
|
|
129
|
+
# Idempotent while reconfiguring: an unchanged component keeps its value and status,
|
|
130
|
+
# and a changed type marks it to be recycled
|
|
131
|
+
was = node.implicit? ? nil : node.type_name
|
|
113
132
|
node.declare_type!(type) # an implicit namespace this component created, now with a type
|
|
133
|
+
reconf&.changed!(node) if was && was != node.type_name
|
|
114
134
|
end
|
|
115
|
-
|
|
135
|
+
new_to_the_tree = reconf.nil? || reconf.new?(node)
|
|
136
|
+
reconf&.declared!(node)
|
|
137
|
+
# While reconfiguring, only new components are announced: re-declaring the rest is noise
|
|
138
|
+
emit(Events::ComponentDeclared, key: node.path, type_name: node.type_name) if new_to_the_tree
|
|
116
139
|
|
|
117
140
|
implement_node(node, Implementation.from_block([], implementer: self, mode: :singleton) { build(&default) }) if default
|
|
118
141
|
self
|
|
@@ -180,7 +203,7 @@ module Sourced
|
|
|
180
203
|
# and the alias follows it like any other dependent.
|
|
181
204
|
def alias(ckey, target)
|
|
182
205
|
synchronize do
|
|
183
|
-
raise LockedComponentError, "can't implement #{ckey} in a locked component" if
|
|
206
|
+
raise LockedComponentError, "can't implement #{ckey} in a locked component" if declarations_locked?
|
|
184
207
|
|
|
185
208
|
implement_node(node(ckey), Implementation.alias(target, implementer: self))
|
|
186
209
|
self
|
|
@@ -203,7 +226,7 @@ module Sourced
|
|
|
203
226
|
end
|
|
204
227
|
|
|
205
228
|
synchronize do
|
|
206
|
-
raise LockedComponentError, "can't implement ENV components in a locked component" if
|
|
229
|
+
raise LockedComponentError, "can't implement ENV components in a locked component" if declarations_locked?
|
|
207
230
|
|
|
208
231
|
builders = mapping.map do |source, ckey|
|
|
209
232
|
target = node(ckey)
|
|
@@ -217,6 +240,22 @@ module Sourced
|
|
|
217
240
|
end
|
|
218
241
|
end
|
|
219
242
|
|
|
243
|
+
# Implement a declared component by building +constructor+ on each read, from the components it
|
|
244
|
+
# injects: #__component_deps maps each one's key to the keyword argument it's injected as, so the
|
|
245
|
+
# component's dependencies and the constructor's arguments come from the same place.
|
|
246
|
+
# A constructor that injects nothing is built with no arguments.
|
|
247
|
+
# class Dispatcher
|
|
248
|
+
# include App.inject('logger', 'repos.users' => 'customers')
|
|
249
|
+
# end
|
|
250
|
+
# app.declare('dispatcher', Dispatcher)
|
|
251
|
+
# app.factory('dispatcher', Dispatcher) # Dispatcher.new(logger:, customers:)
|
|
252
|
+
# Keys are paths from the root of the injector's tree, so call this on that root.
|
|
253
|
+
def factory(ckey, constructor)
|
|
254
|
+
deps = constructor.respond_to?(:__component_deps) ? constructor.__component_deps : {}
|
|
255
|
+
config(ckey, deps.keys) { |*values| constructor.new(**deps.values.zip(values).to_h) }
|
|
256
|
+
self
|
|
257
|
+
end
|
|
258
|
+
|
|
220
259
|
# Defer a node under this component: the root's #start! skips it, and every component that
|
|
221
260
|
# depends on it, directly or not, since they can't start before it. Start it by key instead,
|
|
222
261
|
# ex. when its process is elected to run it (see #start_component!).
|
|
@@ -227,7 +266,7 @@ module Sourced
|
|
|
227
266
|
# implemented again. Any component can defer a node below it, like implementing one.
|
|
228
267
|
def defer(ckey)
|
|
229
268
|
synchronize do
|
|
230
|
-
raise LockedComponentError, "can't defer #{ckey} in a locked component" if
|
|
269
|
+
raise LockedComponentError, "can't defer #{ckey} in a locked component" if declarations_locked?
|
|
231
270
|
|
|
232
271
|
target = node(ckey)
|
|
233
272
|
target.defer!
|
|
@@ -271,7 +310,7 @@ module Sourced
|
|
|
271
310
|
raise ArgumentError, "#{mountable.inspect}.to_component must return a Component, got #{sub.inspect}" unless sub.is_a?(Component)
|
|
272
311
|
|
|
273
312
|
synchronize do
|
|
274
|
-
raise LockedComponentError, "can't mount #{ckey} in a locked component" if
|
|
313
|
+
raise LockedComponentError, "can't mount #{ckey} in a locked component" if declarations_locked?
|
|
275
314
|
raise SubcomponentError, "#{sub.inspect} is already mounted in another component" unless sub.root?
|
|
276
315
|
raise SubcomponentError, "can't mount a component into its own tree" if sub.equal?(root)
|
|
277
316
|
raise LockedComponentError, "can't mount a #{sub.boot_status} component: it must be open" if sub.locked?
|
|
@@ -300,6 +339,7 @@ module Sourced
|
|
|
300
339
|
def [](ckey) = node(ckey).read
|
|
301
340
|
|
|
302
341
|
def read
|
|
342
|
+
raise RemovedComponentError, "#{path} was removed from the tree" if removed?
|
|
303
343
|
raise NotBuiltError, 'component is not built yet' unless root.readable?
|
|
304
344
|
raise UndeclaredComponentError, "#{path} is a namespace, not a component" unless implementation
|
|
305
345
|
|
|
@@ -317,6 +357,9 @@ module Sourced
|
|
|
317
357
|
|
|
318
358
|
instrument_root(:prepare) do
|
|
319
359
|
@order = resolve_order
|
|
360
|
+
# Holds start from the declarations, on a first boot only: #reresolve! must leave the ones
|
|
361
|
+
# #stop_component! set alone, or a reconfiguration would start a stopped component again
|
|
362
|
+
@order.each { |n| n.release!; n.hold! if n.deferred? }
|
|
320
363
|
@order.each { |n| instrument_component(n, :prepare) { n.prepare_node! } }
|
|
321
364
|
@boot_status = :prepared
|
|
322
365
|
end
|
|
@@ -463,6 +506,94 @@ module Sourced
|
|
|
463
506
|
end
|
|
464
507
|
end
|
|
465
508
|
|
|
509
|
+
# ---- Recycling components -------------------------------------------------------
|
|
510
|
+
|
|
511
|
+
# Run a component's whole lifecycle again, by key relative to this component: stop it if it's
|
|
512
|
+
# running, tear it down, drop its value, then prepare and build it from scratch.
|
|
513
|
+
# Every component depending on it, directly or not, is recycled too: their values were built
|
|
514
|
+
# from its old one. Its own dependencies are left alone.
|
|
515
|
+
# Each one is left in the status it had before, so a started component is started again with
|
|
516
|
+
# +context+, and one that was only built stops at built. A component that was stopped by key
|
|
517
|
+
# (or deferred) comes back built and still held: the fresh value has never run, so it waits to
|
|
518
|
+
# be started by key, as it was.
|
|
519
|
+
# If a stop or teardown hook raises, every component is still torn down and its value dropped,
|
|
520
|
+
# and the first error is re-raised before anything is prepared again. A prepare, build or start
|
|
521
|
+
# hook leaves the component where it got to. Either way, recycling again recovers: each one
|
|
522
|
+
# remembers the status to restore until a recycle completes, so a retry puts it back even when
|
|
523
|
+
# its own status no longer says it was running.
|
|
524
|
+
# The root must be built, or started, and not booting.
|
|
525
|
+
# app.recycle_component!('sourced.store')
|
|
526
|
+
def recycle_component!(ckey, context = Thread.current)
|
|
527
|
+
recycle_components!(ckey, context:)
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
# #recycle_component! for several keys at once: a component depending on more than one of them
|
|
531
|
+
# is recycled once, not once per key.
|
|
532
|
+
# app.recycle_components!('sourced.store', 'repos.users')
|
|
533
|
+
def recycle_components!(*ckeys, context: Thread.current)
|
|
534
|
+
synchronize do
|
|
535
|
+
nodes = ckeys.flatten.map { |ckey| recyclable_node(ckey) }
|
|
536
|
+
# Recycling nothing resolves no nodes, so it checks the tree itself, and does nothing
|
|
537
|
+
if nodes.empty?
|
|
538
|
+
check_recyclable!('components')
|
|
539
|
+
return self
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
recycle_nodes!(nodes, context)
|
|
543
|
+
end
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
# #recycle_component! for every component in the tree, in dependency order. Only the root
|
|
547
|
+
# can recycle the whole tree.
|
|
548
|
+
# app.recycle!
|
|
549
|
+
def recycle!(context = Thread.current)
|
|
550
|
+
raise_mounted!
|
|
551
|
+
synchronize do
|
|
552
|
+
check_recyclable!('the tree')
|
|
553
|
+
recycle_nodes!(order, context)
|
|
554
|
+
end
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
# ---- Re-configuring a booted tree ----------------------------------------------
|
|
558
|
+
|
|
559
|
+
# Re-declare one branch of a booted tree, by key relative to this component. The block declares
|
|
560
|
+
# the branch's new contents, and whatever it doesn't declare is removed.
|
|
561
|
+
# Re-declaring a key is idempotent, so a component that didn't change keeps its value and status.
|
|
562
|
+
# Re-implementing it, re-typing it, or declaring a new key recycles it (see #recycle_component!),
|
|
563
|
+
# along with its dependents and anything whose resolved deps changed, ex. a wildcard over the branch.
|
|
564
|
+
# Nothing runs any hooks until the new set is validated, so a block that raises, or a set with a
|
|
565
|
+
# missing dep or a cycle, leaves the tree as it was. Holds survive: stopped stays stopped.
|
|
566
|
+
# app.reconfigure('reactors') do |reactors|
|
|
567
|
+
# files.each { |f| reactors.declare(f.key) }
|
|
568
|
+
# changed.each { |f| reactors.component!(f.key, f.deps, &f.implementation) }
|
|
569
|
+
# end
|
|
570
|
+
def reconfigure(ckey, context = Thread.current, &block)
|
|
571
|
+
raise ArgumentError, "reconfigure #{ckey}: a block must declare the branch's contents" unless block
|
|
572
|
+
|
|
573
|
+
synchronize do
|
|
574
|
+
branch = reconfigurable_branch(ckey)
|
|
575
|
+
reconf = Reconfiguration.new(branch, self)
|
|
576
|
+
root.reconfiguring!(reconf)
|
|
577
|
+
|
|
578
|
+
begin
|
|
579
|
+
instrument_root(:reconfigure) do
|
|
580
|
+
reconf.snapshot!
|
|
581
|
+
plan = begin
|
|
582
|
+
block.call(reconf.branch_handle)
|
|
583
|
+
validate_reconfiguration!(reconf)
|
|
584
|
+
rescue Exception # rubocop:disable Lint/RescueException -- nothing has run yet: put the tree back, whatever it was. Always re-raised
|
|
585
|
+
reconf.rollback!
|
|
586
|
+
raise
|
|
587
|
+
end
|
|
588
|
+
commit_reconfiguration!(reconf, plan, context)
|
|
589
|
+
end
|
|
590
|
+
ensure
|
|
591
|
+
root.reconfiguring!(nil)
|
|
592
|
+
end
|
|
593
|
+
self
|
|
594
|
+
end
|
|
595
|
+
end
|
|
596
|
+
|
|
466
597
|
# A Component::Graph describing the components under this component, by full path from the root.
|
|
467
598
|
# Components are listed in dependency order once the tree is prepared, and in declaration order before that.
|
|
468
599
|
# Namespaces without an implementation are left out.
|
|
@@ -519,7 +650,7 @@ module Sourced
|
|
|
519
650
|
type:,
|
|
520
651
|
type_name:,
|
|
521
652
|
namespace: namespace?,
|
|
522
|
-
mounted:
|
|
653
|
+
mounted: mounted?,
|
|
523
654
|
implemented: !implementation.nil?,
|
|
524
655
|
mode: implementation&.mode,
|
|
525
656
|
status:,
|
|
@@ -540,11 +671,18 @@ module Sourced
|
|
|
540
671
|
|
|
541
672
|
# ---- Node internals -------------------------------------------------------------
|
|
542
673
|
|
|
674
|
+
# The nodes this one depends on, resolved on #prepare!, wildcards included. See #graph for keys
|
|
675
|
+
def dep_nodes = @dep_nodes
|
|
676
|
+
|
|
677
|
+
# Every node in dependency order, or nil before #prepare!. #ordered_nodes is the checked version
|
|
678
|
+
def order = @order
|
|
679
|
+
|
|
680
|
+
# Whether a #reconfigure dropped this node. Terminal: reading it raises RemovedComponentError
|
|
681
|
+
def removed? = status == :removed
|
|
682
|
+
|
|
543
683
|
protected def readable? = @readable
|
|
544
684
|
protected def starting? = @starting
|
|
545
685
|
protected def lock = @lock
|
|
546
|
-
protected def dep_nodes = @dep_nodes
|
|
547
|
-
protected def order = @order
|
|
548
686
|
|
|
549
687
|
private def synchronize(&) = root.lock.synchronize(&)
|
|
550
688
|
|
|
@@ -553,6 +691,29 @@ module Sourced
|
|
|
553
691
|
@type = Plumb::Composable.wrap(type)
|
|
554
692
|
end
|
|
555
693
|
|
|
694
|
+
# Back to an implicit namespace: a dropped component that still has children (see #reconfigure).
|
|
695
|
+
# Returns the implementation it had, to tear it down with, and keeps its value until then.
|
|
696
|
+
# Clearing it before the order is resolved leaves it out, and fails validation for its dependents
|
|
697
|
+
protected def undeclare_type!
|
|
698
|
+
@implicit = true
|
|
699
|
+
@type = Plumb::Types::Any
|
|
700
|
+
@deps = [].freeze
|
|
701
|
+
@dep_nodes = [].freeze
|
|
702
|
+
@implementation.tap { @implementation = nil }
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
# Run a reverted component's hooks with the implementation it had, then leave it a bare namespace
|
|
706
|
+
protected def teardown_reverted!(implementation)
|
|
707
|
+
return self unless pending?(:teardown)
|
|
708
|
+
|
|
709
|
+
begin
|
|
710
|
+
implementation.stop(value) if started?
|
|
711
|
+
ensure
|
|
712
|
+
implementation.teardown(value)
|
|
713
|
+
end
|
|
714
|
+
recycle_node!
|
|
715
|
+
end
|
|
716
|
+
|
|
556
717
|
protected def implement!(implementation)
|
|
557
718
|
@implementation = implementation
|
|
558
719
|
end
|
|
@@ -563,6 +724,48 @@ module Sourced
|
|
|
563
724
|
protected def release! = @held = false
|
|
564
725
|
protected def started? = status == :started
|
|
565
726
|
|
|
727
|
+
# A component mounted into another tree: it owns itself, and isn't the root of its own
|
|
728
|
+
protected def mounted? = !root? && owner.equal?(self)
|
|
729
|
+
|
|
730
|
+
# ---- Reconfiguration internals (see #reconfigure) -------------------------------
|
|
731
|
+
|
|
732
|
+
# Public because Component::Reconfiguration is a collaborator, not another Component
|
|
733
|
+
|
|
734
|
+
def reconfiguring!(reconf) = @reconfiguration = reconf
|
|
735
|
+
def restore_order!(order) = @order = order
|
|
736
|
+
|
|
737
|
+
# Re-resolve every node's deps and the order, leaving holds alone. Raises if the new set is invalid
|
|
738
|
+
def reresolve! = resolve_order
|
|
739
|
+
|
|
740
|
+
# Everything a reconfiguration can change about this node, as a frozen
|
|
741
|
+
# Reconfiguration::NodeState. #children and #index are the node's own mutable hashes, so they're
|
|
742
|
+
# copied; everything else is frozen, or a reference it has to keep as it is — its value above all
|
|
743
|
+
def capture_state
|
|
744
|
+
Reconfiguration::NodeState.new(
|
|
745
|
+
implementation:, type:, implicit: implicit?, deps:, dep_nodes:, deferred: deferred?,
|
|
746
|
+
held: held?, status:, value:, recycle_to:, children: children.dup, index: index.dup
|
|
747
|
+
)
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
# Every member, so a new one can't be captured and then forgotten on the way back
|
|
751
|
+
def restore_state!(state)
|
|
752
|
+
state.to_h.each { |member, value| send(:"#{member}=", value) }
|
|
753
|
+
self
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
# Only #capture_state and #restore_state! use these: a reconfiguration rolling back is the one
|
|
757
|
+
# thing that writes a node's state wholesale
|
|
758
|
+
private attr_reader :deps
|
|
759
|
+
private attr_writer :implementation, :type, :implicit, :deps, :dep_nodes, :deferred, :held,
|
|
760
|
+
:status, :value, :recycle_to, :children, :index
|
|
761
|
+
|
|
762
|
+
# The status a recycle is bringing this node back to, remembered before anything is torn down.
|
|
763
|
+
# A recycle that raises leaves it set, so a retry restores what the first one meant to: the
|
|
764
|
+
# node's own status is no use by then, ex. :built for one a failed start never reached
|
|
765
|
+
protected def recycle_to = @recycle_to
|
|
766
|
+
protected def recycle_to!(status) = @recycle_to = status
|
|
767
|
+
protected def recycled! = @recycle_to = nil
|
|
768
|
+
|
|
566
769
|
# Every node this one depends on, directly or not
|
|
567
770
|
protected def transitive_dependencies
|
|
568
771
|
dep_nodes.each_with_object([]) do |dep, all|
|
|
@@ -602,6 +805,22 @@ module Sourced
|
|
|
602
805
|
parent&.index!("#{key}.#{ckey}", node)
|
|
603
806
|
end
|
|
604
807
|
|
|
808
|
+
# The inverse of #index!: drop a descendant here and in every ancestor
|
|
809
|
+
protected def unindex!(ckey)
|
|
810
|
+
@index.delete(ckey)
|
|
811
|
+
parent&.unindex!("#{key}.#{ckey}")
|
|
812
|
+
end
|
|
813
|
+
|
|
814
|
+
# Drop a child and its descendants from this node's children, and from every index
|
|
815
|
+
protected def detach!(segment)
|
|
816
|
+
child = @children.delete(segment)
|
|
817
|
+
return nil unless child
|
|
818
|
+
|
|
819
|
+
child.index.each_key { |sub_key| unindex!("#{segment}.#{sub_key}") }
|
|
820
|
+
unindex!(segment)
|
|
821
|
+
child
|
|
822
|
+
end
|
|
823
|
+
|
|
605
824
|
# Resolve deps through the implementer's index. Called on #prepare!, so declaration order doesn't matter.
|
|
606
825
|
# A wildcard dep ('reactors.*') resolves to a hash of the components directly under its key, by segment,
|
|
607
826
|
# and to an empty hash if there are none.
|
|
@@ -651,6 +870,7 @@ module Sourced
|
|
|
651
870
|
|
|
652
871
|
implementation.start(value, context)
|
|
653
872
|
@status = :started
|
|
873
|
+
recycled! # whatever a recycle meant to restore, this is the node's status now
|
|
654
874
|
self
|
|
655
875
|
end
|
|
656
876
|
|
|
@@ -662,25 +882,55 @@ module Sourced
|
|
|
662
882
|
implementation.stop(value)
|
|
663
883
|
ensure
|
|
664
884
|
@status = :stopped
|
|
885
|
+
recycled! # ... and the same when a component is stopped by key
|
|
665
886
|
end
|
|
666
887
|
self
|
|
667
888
|
end
|
|
668
889
|
|
|
669
|
-
# A started node runs its stop hooks first. Teardown hooks run even if those raise
|
|
890
|
+
# A started node runs its stop hooks first. Teardown hooks run even if those raise, and the node
|
|
891
|
+
# is torn down whatever they raise (incl. Interrupt): its hooks have had their turn either way,
|
|
892
|
+
# and running them again would tear the same value down twice
|
|
670
893
|
protected def teardown_node!
|
|
671
894
|
return self unless pending?(:teardown)
|
|
672
895
|
|
|
896
|
+
was_started = started?
|
|
897
|
+
@status = :torn_down
|
|
673
898
|
begin
|
|
674
|
-
implementation.stop(value) if
|
|
899
|
+
implementation.stop(value) if was_started
|
|
675
900
|
ensure
|
|
676
901
|
implementation.teardown(value)
|
|
677
902
|
end
|
|
678
|
-
|
|
903
|
+
self
|
|
904
|
+
end
|
|
905
|
+
|
|
906
|
+
# Back to :open, so every hook runs again from the top (see #recycle_component!).
|
|
907
|
+
# Keeps the node's hold, its deps and its place in the root's order: the tree is locked,
|
|
908
|
+
# so nothing about the graph can have changed
|
|
909
|
+
protected def recycle_node!
|
|
910
|
+
@status = :open
|
|
911
|
+
@value = nil
|
|
912
|
+
self
|
|
913
|
+
end
|
|
914
|
+
|
|
915
|
+
# Whether the node has been built. A torn down node keeps its value, which stays readable;
|
|
916
|
+
# recycling drops it, back to :open, and removing it drops it for good
|
|
917
|
+
protected def built? = !%i[open prepared removed].include?(status)
|
|
918
|
+
|
|
919
|
+
# Drop the value and mark the node removed, once torn down: anything still holding it (an
|
|
920
|
+
# Injector, say) then fails loudly instead of reading a dead value. Terminal
|
|
921
|
+
protected def remove_node!
|
|
922
|
+
@value = nil
|
|
923
|
+
@status = :removed
|
|
679
924
|
self
|
|
680
925
|
end
|
|
681
926
|
|
|
682
927
|
# Without the readable check: deps are read while the component is building, in dependency order
|
|
683
|
-
protected def current_value
|
|
928
|
+
protected def current_value
|
|
929
|
+
raise RemovedComponentError, "#{path} was removed from the tree" if removed?
|
|
930
|
+
raise NotBuiltError, "#{path} is not built: it was torn down or recycled" if memoized? && !built?
|
|
931
|
+
|
|
932
|
+
memoized? ? value : build_value
|
|
933
|
+
end
|
|
684
934
|
|
|
685
935
|
# Whether the value is built once, on #build!: singletons, and aliases of memoized components.
|
|
686
936
|
# Only known once deps are resolved, on #prepare!
|
|
@@ -735,11 +985,7 @@ module Sourced
|
|
|
735
985
|
raise UnimplementedComponentError, "components are deferred but not implemented: #{deferred_namespaces.map(&:path).join(', ')}"
|
|
736
986
|
end
|
|
737
987
|
|
|
738
|
-
nodes.each
|
|
739
|
-
n.resolve_deps!
|
|
740
|
-
n.release!
|
|
741
|
-
n.hold! if n.deferred?
|
|
742
|
-
end
|
|
988
|
+
nodes.each { |n| n.resolve_deps! }
|
|
743
989
|
|
|
744
990
|
each_node = ->(&b) { nodes.each(&b) }
|
|
745
991
|
each_child = ->(n, &b) { n.dep_nodes.each(&b) }
|
|
@@ -758,7 +1004,7 @@ module Sourced
|
|
|
758
1004
|
next if !include_built && n.status == :built
|
|
759
1005
|
|
|
760
1006
|
instrument_component(n, :teardown) { n.teardown_node! }
|
|
761
|
-
rescue
|
|
1007
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- any error (incl. Interrupt) must still tear the rest down. The first is re-raised
|
|
762
1008
|
errors << e
|
|
763
1009
|
end
|
|
764
1010
|
end
|
|
@@ -784,6 +1030,137 @@ module Sourced
|
|
|
784
1030
|
target
|
|
785
1031
|
end
|
|
786
1032
|
|
|
1033
|
+
# A node to recycle by key: implemented, under a root that can be recycled
|
|
1034
|
+
private def recyclable_node(ckey)
|
|
1035
|
+
target = node(ckey)
|
|
1036
|
+
raise UndeclaredComponentError, "#{target.path} is a namespace, not a component" if target.namespace?
|
|
1037
|
+
|
|
1038
|
+
check_recyclable!(target.path)
|
|
1039
|
+
target
|
|
1040
|
+
end
|
|
1041
|
+
|
|
1042
|
+
# A branch to reconfigure: a namespace, under a tree that isn't booting or torn down
|
|
1043
|
+
private def reconfigurable_branch(ckey)
|
|
1044
|
+
branch = node(ckey)
|
|
1045
|
+
raise LockedComponentError, "can't reconfigure #{branch.path}: a reconfiguration is already running" if root.reconfiguring?
|
|
1046
|
+
raise TornDownError, "can't reconfigure #{branch.path}: the component is torn down" if root.boot_status == :torn_down
|
|
1047
|
+
if root.starting?
|
|
1048
|
+
raise LockedComponentError, "can't reconfigure #{branch.path} while the root is booting"
|
|
1049
|
+
end
|
|
1050
|
+
unless branch.namespace?
|
|
1051
|
+
raise UndeclaredComponentError, "#{branch.path} is a component, not a namespace: reconfigure the branch above it"
|
|
1052
|
+
end
|
|
1053
|
+
|
|
1054
|
+
mounted = [branch, *branch.index.values].find { |n| n.mounted? }
|
|
1055
|
+
raise SubcomponentError, "can't reconfigure #{branch.path}: #{mounted.path} is a mounted component" if mounted
|
|
1056
|
+
|
|
1057
|
+
branch
|
|
1058
|
+
end
|
|
1059
|
+
|
|
1060
|
+
# Apply the new set structurally and re-resolve, before any hooks run: raises what a first boot
|
|
1061
|
+
# would if it's invalid, with nothing torn down yet
|
|
1062
|
+
private def validate_reconfiguration!(reconf)
|
|
1063
|
+
reverting = reconf.reverting
|
|
1064
|
+
removing = reconf.removing
|
|
1065
|
+
|
|
1066
|
+
# Each reverted component hands its implementation back, to tear down once validation passes
|
|
1067
|
+
reverted = reverting.to_h { |n| [n, n.undeclare_type!] }
|
|
1068
|
+
removing.each { |n| n.parent.detach!(n.key) }
|
|
1069
|
+
|
|
1070
|
+
# An open tree has nothing resolved or built: #prepare! validates the set when it boots
|
|
1071
|
+
{ reverted:, removing:, order: root.boot_status == :open ? nil : root.reresolve! }
|
|
1072
|
+
end
|
|
1073
|
+
|
|
1074
|
+
# Past validation, so nothing here rolls back: tear down what's going, recycle what changed
|
|
1075
|
+
private def commit_reconfiguration!(reconf, plan, context)
|
|
1076
|
+
booted = !plan[:order].nil?
|
|
1077
|
+
was = reconf.order_before.each_with_index.to_h
|
|
1078
|
+
root.restore_order!(plan[:order]) if booted
|
|
1079
|
+
|
|
1080
|
+
# Dependents first. On an open tree every hook is pending?-skipped, so this only unindexes
|
|
1081
|
+
going = plan[:removing] + plan[:reverted].keys
|
|
1082
|
+
going.sort_by { |n| -(was[n] || -1) }.each do |n|
|
|
1083
|
+
if (implementation = plan[:reverted][n])
|
|
1084
|
+
instrument_component(n, :teardown) { n.teardown_reverted!(implementation) }
|
|
1085
|
+
next
|
|
1086
|
+
end
|
|
1087
|
+
|
|
1088
|
+
instrument_component(n, :teardown) { n.teardown_node! }
|
|
1089
|
+
n.remove_node!
|
|
1090
|
+
emit(Events::ComponentRemoved, key: n.path, remover: path)
|
|
1091
|
+
end
|
|
1092
|
+
return unless booted
|
|
1093
|
+
|
|
1094
|
+
# Nothing new has a status to go back to, so it comes up to where the root is: :prepared,
|
|
1095
|
+
# :built or :started. Deferring lowers that ceiling to :built, never raises it
|
|
1096
|
+
coming_up = root.boot_status
|
|
1097
|
+
reconf.newly_components.each do |n|
|
|
1098
|
+
n.hold! if n.deferred?
|
|
1099
|
+
n.recycle_to!(n.deferred? && coming_up == :started ? :built : coming_up)
|
|
1100
|
+
end
|
|
1101
|
+
affected = reconf.affected
|
|
1102
|
+
recycle_nodes!(affected, context) if affected.any?
|
|
1103
|
+
end
|
|
1104
|
+
|
|
1105
|
+
# Whether the tree can be recycled: built or started, and not booting. +what+ names what
|
|
1106
|
+
# the caller is recycling, for the error messages
|
|
1107
|
+
private def check_recyclable!(what)
|
|
1108
|
+
raise TornDownError, "can't recycle #{what}: the component is torn down" if root.boot_status == :torn_down
|
|
1109
|
+
if root.starting?
|
|
1110
|
+
raise LockedComponentError, "can't recycle #{what} while the root is booting: it has no status to go back to"
|
|
1111
|
+
end
|
|
1112
|
+
return if %i[built started].include?(root.boot_status)
|
|
1113
|
+
|
|
1114
|
+
raise NotBuiltError, "can't recycle #{what} before the root is built: build! it first"
|
|
1115
|
+
end
|
|
1116
|
+
|
|
1117
|
+
# Tear the targets and their dependents down, drop their values, and prepare and build them
|
|
1118
|
+
# again, leaving each one in the status it had before. See #recycle_component!
|
|
1119
|
+
private def recycle_nodes!(targets, context)
|
|
1120
|
+
# Nodes come after their dependencies in the order, so one forward pass seeded with every
|
|
1121
|
+
# target reaches the same nodes as a closure per target, without the walk-per-target
|
|
1122
|
+
reached = targets.to_set
|
|
1123
|
+
root.order.each { |n| reached << n if n.dep_nodes.any? { |dep| reached.include?(dep) } }
|
|
1124
|
+
ordered = root.order.select { |n| reached.include?(n) }
|
|
1125
|
+
# What each node comes back to, remembered before anything is torn down. A node a previous
|
|
1126
|
+
# recycle left part way through keeps the status that recycle meant to restore
|
|
1127
|
+
ordered.each { |n| n.recycle_to!(n.recycle_to || n.status) }
|
|
1128
|
+
|
|
1129
|
+
instrument_root(:recycle) do
|
|
1130
|
+
# Down, dependents first. Started nodes run their stop hooks, then their teardown hooks.
|
|
1131
|
+
# Their values are dropped whatever a hook raises (incl. Interrupt): they've been torn down
|
|
1132
|
+
errors = []
|
|
1133
|
+
begin
|
|
1134
|
+
ordered.reverse.each do |n|
|
|
1135
|
+
instrument_component(n, :teardown) { n.teardown_node! }
|
|
1136
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- as #teardown_nodes: the rest are still torn down
|
|
1137
|
+
errors << e
|
|
1138
|
+
end
|
|
1139
|
+
ensure
|
|
1140
|
+
ordered.each { |n| n.recycle_node! }
|
|
1141
|
+
end
|
|
1142
|
+
raise errors.first if errors.any?
|
|
1143
|
+
|
|
1144
|
+
# And up again, in dependency order, one stage at a time, as the root boots
|
|
1145
|
+
ordered.each { |n| instrument_component(n, :prepare) { n.prepare_node! } }
|
|
1146
|
+
ordered.each do |n|
|
|
1147
|
+
# A tree that's only prepared stops there: its values are built by #build!, which is what
|
|
1148
|
+
# a process forked after #prepare! runs for itself
|
|
1149
|
+
next if n.recycle_to == :prepared
|
|
1150
|
+
|
|
1151
|
+
instrument_component(n, :build) { n.build_node! }
|
|
1152
|
+
end
|
|
1153
|
+
ordered.each do |n|
|
|
1154
|
+
next unless n.recycle_to == :started && n.dep_nodes.all? { |dep| dep.started? }
|
|
1155
|
+
|
|
1156
|
+
instrument_component(n, :start) { n.start_node!(context) }
|
|
1157
|
+
end
|
|
1158
|
+
# Only once every node is back: a recycle that raises keeps the statuses for the retry
|
|
1159
|
+
ordered.each { |n| n.recycled! }
|
|
1160
|
+
end
|
|
1161
|
+
self
|
|
1162
|
+
end
|
|
1163
|
+
|
|
787
1164
|
# ---- Telemetry ------------------------------------------------------------------
|
|
788
1165
|
|
|
789
1166
|
# Publish root.<stage>ing, run the block, and publish root.<stage>ed with its duration,
|
|
@@ -864,7 +1241,7 @@ module Sourced
|
|
|
864
1241
|
raise ArgumentError, "#{ckey}: pass either a provider or a block, not both" if provider && block
|
|
865
1242
|
|
|
866
1243
|
synchronize do
|
|
867
|
-
raise LockedComponentError, "can't implement #{ckey} in a locked component" if
|
|
1244
|
+
raise LockedComponentError, "can't implement #{ckey} in a locked component" if declarations_locked?
|
|
868
1245
|
|
|
869
1246
|
target = node(ckey)
|
|
870
1247
|
implementation = if provider
|
|
@@ -880,6 +1257,7 @@ module Sourced
|
|
|
880
1257
|
private def implement_node(target, implementation)
|
|
881
1258
|
override = !target.implementation.nil?
|
|
882
1259
|
target.implement!(implementation)
|
|
1260
|
+
root.reconfiguration&.changed!(target)
|
|
883
1261
|
emit(
|
|
884
1262
|
Events::ComponentImplemented,
|
|
885
1263
|
key: target.path,
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: sourced-component
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ismael Celis
|
|
@@ -75,6 +75,7 @@ files:
|
|
|
75
75
|
- lib/sourced/component/injector.rb
|
|
76
76
|
- lib/sourced/component/mermaid.rb
|
|
77
77
|
- lib/sourced/component/notifier.rb
|
|
78
|
+
- lib/sourced/component/reconfiguration.rb
|
|
78
79
|
- lib/sourced/component/tree.rb
|
|
79
80
|
- lib/sourced/component/version.rb
|
|
80
81
|
licenses:
|