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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 298693a66f5e1042dadbdc1ce6e772f3169dd858bdb1389316d8228e2daa4fa6
4
- data.tar.gz: de5a5fa27a550d03b93b7fdfd00f82f54709258758915a8506516ba8c0fa38a8
3
+ metadata.gz: 2896fb5ddac45462754f578010d435045bdaef50926865591b21bc49f68f8127
4
+ data.tar.gz: 86e67b664260f455dd0c0d47d5ad7492d5164e408d4f580b00e17f4b46a2873e
5
5
  SHA512:
6
- metadata.gz: 804e89d48a58add65d94afaab6866264915aab3dad53db19b6ba6a875c262775092de2403b50ed2b1e06c6ee28c04ea3d19e59762678816a0c5be0babee3c943
7
- data.tar.gz: c5e7bdb7320e0845910f1d905c8d91bb1ae7b83b4ddbb981ba8b6a70b841c5ade7d017d5ebbd95c23c52759e4ecfc9acbef0e06550a9b47945ac0362dc6c9840
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 `:teardown`.
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, or mounting a component that isn't open |
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 stopping a component by key once its root is torn down |
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 :teardown
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
- attr_reader :names
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 { |key, name| [nodes.fetch(key), name, :"@#{name}"] }.freeze
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Sourced
4
4
  class Component
5
- VERSION = "0.1.0"
5
+ VERSION = "0.1.1"
6
6
  end
7
7
  end
@@ -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
- STATUSES = %i[open prepared built started stopped torn_down].freeze
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 locked?
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
- emit(Events::ComponentDeclared, key: node.path, type_name: node.type_name)
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 locked?
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 locked?
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 locked?
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 locked?
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: !root? && owner.equal?(self),
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 started?
899
+ implementation.stop(value) if was_started
675
900
  ensure
676
901
  implementation.teardown(value)
677
902
  end
678
- @status = :torn_down
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 = memoized? ? value : build_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 do |n|
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 StandardError => e
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 locked?
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.0
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: