-
Notifications
You must be signed in to change notification settings - Fork 64
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Subsystems or components that take as input the whole system? #21
Comments
My solution to these sort of problems has not been nested systems, but functions for querying and transforming the configuration. In Duct, there's the concept of "modules" that provides exactly this. My intent is to keep modules specific to Duct for now (more specifically, |
It seems to me that modules are essentially a way to merge second level map into root config with a custom transform :fn. At the moment I don't quite see how this helps with nested systems. Let's say I have a system {[::generic-subsystem ::sys1] {:name "AAA" ... }
[::generic-subsystem ::sys2] {:name "BBB" ... } If I understand correctly if I use modules for this task it would merge What I am really looking for is a simple mechanism to mark a component as a system and then be able to reference components of sub-system A from sub-system B. First part is easy through derived keywords: (defmethod ig/init-key ::system [_ m]
(ig/init m))
(defmethod ig/halt-key! ::system [_ s]
(ig/halt! s))
;; would be still nicer to have a reader `#ig/system` for this The second part is a bit trickier but it looks to me that allowing In a nutshell: {:sys1 #ig/system {:comp-a {:param-a 1}
:comp-b {:param-b 2}}
:sys2 #ig/system {:comp-c #ig/ref [:sys1 :comp-b]}
:comp-y {:param-y #ig/ref [:sys1 :comp-b]}} |
Nested systems could also be used for grouping similar components into easy-to-reference packs: {::reporters #ig/system {:my-app.reporters/console {}
:my-app.reporters/influx {:host "localhost"
:port 8086}
...}
::monitor {:reporters (ig/ref ::reporters)}} Instead of the flat and redundant: {
:my-app.reporters/console {}
:my-app.reporters/influx {:host "localhost"
:port 8086}
...
::monitor {:reporters {:console (ig/ref :my-app.reporters/console)
:influx (ig/ref :my-app.reporters/influx)
...}}
} |
You're describing a solution, rather than the problem you want to solve. Why do you think you need nested systems? We need to put together a clear use-case first, then we can work through potential solutions. Can you describe in more detail one of the monitoring/statistics systems you mentioned previously? Modules are pure functions that transform the configuration. They're not just ways of merging maps into the configuration; they're far more flexible than that. Any solution we can come up with can be replicated using modules. The question is not so much "can we use modules for this", because the answer is always "yes", but rather "should we use modules for this, or introduce something new"? |
I have a generic system (trading app) which I want to use as a component in a bigger system. Each such component is a replication of the generic trading app for different exchanges/currency pairs. All these app publish statistics to one common core/async channel (separate component). I just need a intermediate unit between system and component, a grouping of components which I can move around, instantiate and replicate at will. It's a rather obvious modularity requirement - recursively build components as if they are systems. The monitoring part is as follows. My system is a bunch of components represented by core.async channels. I want to have a separate monitoring component which is able to install listeners on selected core.async channels and report appropriately. Thus, my monitoring component needs to take as an input the whole system after it has been built by integrant. Then inspect each node and keep only core async channels. And finally hook on them and start reporting. I just don't see how this could be done with a flat map besides listing each channel as a dependency to the reporter. BTW, the concern of #20 is somewhat related. If I have a watch-dog component which monitors a sub-system, then having a notion of a subsystem opens a door for watch-dogs which could alter the system by restarting parts of the syb-system. For instance, if a sub-system is be stored as an atom inside the parent system. Does this make a bit of sense? |
Thanks for the more detailed explanation. I believe I can see where you're coming from. I'm not currently sure what would be the best way to handle this, but let me put forward my thoughts so far. Bear in mind that these are little more than rough notes so far; this isn't intended to be a complete solution. When it comes to nested maps, there's always a way to transform a nested map into a flat map or vice versa. For example: {:a {:b 1
:c {:d 2
:e 3}} Could be represented as: {[:a :b] 1
[:a :c :d] 2
[:a :c :e] 3} Similarly, a nested set of system maps can always be represented as a flat system map. Whether to use nested systems or something else is effectively a question of presentation. If subsystems are kept in atoms, then the situation changes. I think we need to distinguish between what is a true subsystem, and what is merely a grouping of keys. To my mind, a true subsystem has its own lifecycle. I'm not sure whether this is a good idea or not. If we're just talking about a grouping of keys, we can achieve that with composite keys. Consider a configuration like: {:database/sql {:uri "jdbc:sqlite:db/usd.sqlite"}
:handler/trade {:currency :usd, :db #ig/ref :database/sql}} We can both group and make the keys unique using composites: {[:group/usd :database/sql] {:uri "jdbc:sqlite:db/usd.sqlite"}
[:group/usd :handler/trade] {:currency :usd, :db #ig/ref [:group/usd :database/sql]}} Currently this won't work in Integrant, because references can't be vectors. However, since we have composite keys, having composite references seems like a logical and consistent extension to the syntax. This syntax is somewhat verbose, but it shouldn't be too hard to come up with some syntax sugar. Perhaps: {[:group/usd :_]
{:database/sql {:uri "jdbc:sqlite:db/usd.sqlite"}
:handler/trade {:currency :usd, :db #ig/ref :database/sql}}} In any case, we preserve the flat structure of the configuration map. A flat map is simpler to reason about and allows for easier cross-referencing. You might well point out that merely grouping keys doesn't solve the problem of repetition. Suppose we want to have several trading systems, each passing data to a central monitor. We might write: {:global/monitor {}
[:group/usd :database/sql]
{:uri "jdbc:sqlite:db/usd.sqlite"}
[:group/usd :handler/trade]
{:currency :usd, :monitor #ig/ref :global/monitor, :db #ig/ref [:group/usd :database/sql]}
[:group/eur :database/sql]
{:uri "jdbc:sqlite:db/eur.sqlite"}
[:group/eur :handler/trade]
{:currency :eur, :monitor #ig/ref :global/monitor, :db #ig/ref [:group/eur :database/sql]}} Not very concise, but this is where modules come in. Modules in Duct are pure functions that transform the configuration, so any repetition can be factored out: {:global/monitor {}
[:group/usd :module/trade] {:currency :usd}
[:group/eur :module/trade] {:currency :eur}} Anyway, this is all very rough and by no means a firm solution. However, my current inclination is to avoid nesting subsystems, as it seems like something that could rapidly get complex. Keeping the system a single block, and instead using pure functions and syntax in the configuration feels conceptually simpler, even if the syntax currently feels rather rough. |
Thanks for having a thorough take on this.
Sure, but it might lead to incompatible semantics as integrant already uses composite keys as a synonym for key inheritance.
I see grouping of keys as a sub-system where components don't necessarily depend on each other. If sub-systems can do grouping why to invent a separate concept for those? I see atom-sub-systems just as an optional feature for cases when components in the parent system need to manage the life cycle of the sub-system. For most cases imutable sub-systems should be just fine.
Component, systems and subsystems all have lifecycles and the distinction between them strikes me as being a bit artificial. I think it makes sense to reduce them to a single notion and allow each component to accept both other components and references to other compoenents. Enforcing components to accept only references leads to unnecessary repetitions. If a component A depends on B and no other component depends on B why am I forced to declare it in the root config and need to duplicate the key in the component A?
It should then handle grouping key From the user prospective composite keys are less natural than nested maps. From programmer prospective
Easier to reason for the user or the integrant developer? I think that the main aim of integrant (component, mount etc) is to allow each component to access its resources through a flat map. And this is where integrant shines. But it seems to me that flat config map has little usefulness to the end user. Quite the opposite actually. It forces system designer to bent natural 3D designs into a flat 2D paper. I am afraid transformers are also quite complex beasts actually. The user writes an essentially hierarchical config which is then transformed under the hood into a flat map by who knows which rules and then into system which doesn't resemble the original. So each time I want to understand what a module does, I need to look at the :fn transformer and figure out what extra cookies it adds to the standard integrant semantics. Also, with arbitrary transformers standardized tooling ecosystem for integrant would be hard to implement. My primary concern is with visualization. If I want to visualize a system I can either make a graph of the config or the instantiated system. Currently both graphs are the same and this is just great. With modules as transformers I won't be able to visualize satisfactory either of these two. Config with modules is a truncated version of the system and there is no way to infer how the modules look without actually running them. The instantiated system is a transformed flat sausage which might or might not resemble the original configuration. This is what I have right now as a working system: (def config
{::exchanges {:ex1 #'config-ex1
:ex2 #'config-ex2}
::reporters {:reporters/console {}
:reporters/influx {:host "localhost"
:port 8086}}
:monitor {:systems (ig/ref ::exchanges)
:reporters (ig/ref ::reporters)}})
Where ::exchanges is a map of vars with config of the sub-systems :ex1 and :ex2; ::reporters is a grouping sub-system with components :reporters/console and :reporters/influx. This example ilustrates 3 concepts. First ::exchanges is a map of sub-systems - a grouping. Second ::reporters is a sub-system which acts as a grouping of homogenous components. Finally, monitor is a stand alone component which reports the status of ::exchanges using reporters. Note that monitor here is not the same as monitor in your example. My sub-systems are not aware of the monitor; the monitor is an external watch dog which knows how to monitor the systems because of their regular design (core.async channels). The lifesycle of ::systems and ::system are: (defmethod ig/init-key ::system [_ var]
(ig/init var))
(defmethod ig/halt-key! ::system [_ s]
(ig/halt! s))
(defmethod ig/init-key ::systems [_ m]
(map-vals #(ig/init @%) m))
(defmethod ig/halt-key! ::systems [_ m]
(map-vals ig/halt! m))
|
There's no incompatibility here; inheritance is just a way of grouping keys. We're essentially saying "this subsystem is defined by all keys derived from X".
The difference is whether their lifecycle is independent. For instance, if a subsystem is defined in an atom, we could
I think we'd want a separate tag for that. Maybe
The more we restrict a data structure, the more we can reason about it. For instance, let's say you're asked "What components are in the system?" With a flat map, the answer is If we're using tags, like We already have composites, which allow grouping of keys. If that's not enough we can consider introducing something new, but I'd like to see what we can do with minimal changes first.
Right, that's definitely a concern. Modules do have the advantage of being pure, so we can always run them and see what they produce, but too much transformation and it becomes hard to reason about the configuration. I'd certainly like to see something a little more restrictive and easier to reason about than transformation functions.
This is the sort of design I want to avoid, as I'd rather query the configuration than the system. My reasoning is that the more information the configuration provides, the more we can understand the system without needing to initiate it. For example, we can ask programmatically "What components are being monitored?" without needing a running system. Integrant is designed to have as little to do with the running system as possible. The more we focus on the configuration, the more we can do using only pure functions before the system is initiated. Would it be possible for you to give a little info on |
Very much agreed, but I would like to be able to monitor the running system as well. A graph of the system config is cool, but being able to interactively explore a graph of the running system is much more exiting. This is why having identical config's and system's topology would be quite useful.
Those are systems which listen to a web-socket, do some data processing, compute statistics and perform algoritmic trading but I don't think there is anything special about those; any other system listening to an external event stream would do. I cannot share much of it right now because it involves quite some hackery on top of core.async and is very experimental at the moment, but I don't think it matters much. The idea is to be able to easily pick a system or a complex component with its own lifecycle and be able to plug it directly into your system without much ado. One important thing which I currently cannot do within integrant's framework is to share statistics between those two sub-systems. Allowing for that would probably involve quite some core changes to integrant and introduction of nested references syntax as you pointed out. I think I will go ahead and do a bit of hackery on top of integrant myself to try this system==subsystem==component idea which I am currently quite exited about. Will be back with it if it proves worthwhile. |
Sure, but it seems to me that there are two ways of doing this.
Both these approaches are equivalent in functionality; we could use either approach and get back the same information. However, to my mind option 2 is the better design, because it allows us to know at compile time what is being monitored, and it more cleanly separates functional and side-effectful code.
Good luck! I think I'm going to give this problem some more hammock-time as well. I'd ideally want a solution where the dependencies between components are explicit at compile-time. |
Yerh. I just figured that the hard way. I just implemented option 1 thinking that it will save me loads of time later but no, even before being able to monitor anything I started running into huge design issues. Global monitor will never be flexible enough and I would need to also either declare/implement a bunch of stuff at component level or specify those for each component separately in the monitor itself. So I am moving full speed from strategy 1 to 2 right now. Thanks for the input! |
Hi James, I am back with commix. It solves all my issues and I am reasonably confident that it should be able to provide an elegant solution for duct modules as well. I have added a wiki page on differences with Integrant for the motivation. At this stage I am almost happy with it except of one conceptual inconsistency with lifecycle method dispatch which I am still brewing. I will likely not preserve |
Thanks for providing an alternative approach. It goes in a different direction to where I want to take Integrant, but that's the benefit of forks and alternative designs :) It doesn't seem powerful enough to act as a solution for Duct modules, however. Duct modules are Turing-complete transformations of the configuration; they're not merely groupings or subsystems. I'll keep this issue open, in case I find another solution that fits in more with my plans for Integrant. |
Do they really need to be transformations? Do you already have an example of a duct module which couldn't be accomplished with a sub-system? |
Sure. A lot of modules involve searching and transforming the configuration. One example is the {:duct.core/project-ns foo
:duct.module/ataraxy {["/bar/" id] [:bar id]}} Will be transformed into: {:duct.core/project-ns foo
:duct.module/ataraxy {["/bar/" id] [:bar id]}
:duct.router/ataraxy
{:routes {["/bar/" id] [:bar id]}
:handlers {:bar #ig/ref :foo.handler/bar}}} Note that the result key There are other examples that are more based around searching and making decisions based on what already exists in the configuration. For instance, the |
Thank you for the examples. I think I understand now better what you are after. I might be wrong, but it seems that you implicitly assume that there will be one instance per module type in the system. How would those transforms interact when there are multiple modules of same type within a system? In my mind there should be a clear distinctions between actions which operate on the whole system (or config) and action which operate on component. What you are after with modules is essentially a pre-processor which takes a config and returns a config. In Commix this job is done by actions. Actions operate on systems and can modify system's topology if they so desire. (-> config
(ataraxy/preconfig)
(init)
(halt)) In Commix config is just one stage in the life-cycle, not more special than any other stage. I believe preserving the topology across different stages is very important for the transparency sake. If a change in topology is required users must be made aware of it. Perhaps the only reason for topology changes is when you need to create nodes which other components or sub-systems can reference. In your example ataraxy module creates :routes and :handlers, but then the consumer of those nodes is ataraxy itself. Sounds complicated. A good feature of life-cycle methods in Integrant and Commix is that they are localized. They grab a value do some transformation and return a value which is assocced into the system map. No further modifications of the system-map. I think this separation of concerns between life-cycle actions and life-cyle key methods is a fruitful idea. Without such separation it would be hard to build a framework that can go beyond simple (start) -> (stop) cycle.
I think ;; define the "module" in duct.router
(def ataraxy
(cx/com :duct.router/ataraxy
:project-ns (cx/ref :duct.core/project-ns)
:routes nil
:handlers nil))
(defmethod cx/init-key [c _]
(do-what-duct.module/ataraxy-does-with c))
;; use the "module" on user side
{:duct.core/project-ns foo
:ataraxy (cx/com duct.router/ataraxy)}
Note that
I was still brewing this part yesterday. Since today, first argument in commix methods is the component's configuration with all dependencies resolved. I didn't like the fact that only init-key received dependencies; now all methods do. The first argument also contains a special :cx/system which holds the whole system. Components are free to use it, but I am not keen on advertising this feature too much. Such lookups often involve implicit assumptions which need not be true for all systems. For instance, do you assume that there is one server in the app and you will find the right one in your This is how web module might look with commix: ;; ON MODULE SIDE
(def cascading (cx/com ::cascading {:routes []}))
(def base-config (cx/com ::base-config
{:bad-request (plaintext-response "Bad Request"),
:static-not-found (plaintext-response "Not Found"),
,,,}))
(def api-config (cx/com ::api-config
{:bad-request {:body ^:displace {:error :bad-request}}
:static-not-found {:body ^:displace {:error :not-found}}
,,,}))
(def web (cx/com ::web
{:router (cx/com ::cascading
{:routes (cx/ref :duct/routes)})
:site (cx/com ::base-config)}))
;; ON THE USER SIDE
{:duct/routes [,,,]
:web (cx/com duct.web/web
;; overwrite the entire site component
{:site (cx/com :duct.web/api-config)})
}
;; or
{:web (cx/com duct.web/web
;; overwrite only routes within cascading router
{:router {:routes [,,,]} })
}
|
Right. Most modules currently have that assumption, though there's no reason that needs to be the case.
Right again, but the pre-processing in Duct is defined as data, and self-ordering.
Sure, but I think you're missing the point. One of the central ideas of both Duct and Integrant is that we should favour pure transformations of the configuration over logic in the implementation. Modules in Duct are pure and transparent; we can not only apply them without side effects, but we can also print the output. Contrast this to function composition, which is pure, but opaque; we cannot take a function and easily discover which functions were composed to produce it. Duct takes Integrant's This is why I don't think Commix is conceptually a good fit for Duct. It's not necessarily a bad approach; it just goes in the opposite direction of where I want to take Duct. Many of the things you consider to be advantages of Commix are all disadvantages if the goal is to move logic away from |
I think I understood your point pretty well, but I fail to see how making config transformers the "ultimate goal" helps the end users. You split the init logic across configuration and "init". You still use arbitrary code for tranforms, bring new syntax layers and pretty much force users to be aware of both module and primitive component's semantics as well as relationships between them. To me, this is all too much hustle and complexity with little benefits. But well, you have your vision and I am really curious to see it completed. Good luck!
That's not the case. Pre-config is orthogonal to commix's design. Pre-config is just one life-cycle phase - a plug-in which users or libraries can add as they wish. I can muddle the init with pre-configure hooks of course, but I am poised to see if that's indeed such a great idea as you claim it to be. |
I guess it depends on how much you buy into the idea of "simple" over "easy", and how many short-term conveniences you're willing to sacrifice to achieve it. Ultimately I think that pure data transformations should be almost always preferred over opaque or side-effectful operations, but time will tell if this idea has any merit.
Thanks! You too!
Sure, but the design decisions you've made arguably make it more difficult. For instance, nested maps are more difficult to query and transform than flat maps. If I want to know the components in an Integrant configuration, I can use Integrant and Duct are built around the idea that querying and transforming the configuration is the most important consideration. My impression of Commix (and correct me if I'm wrong), is that it's more focused on making it easy for the end user to write configurations. |
It could be both, "simple" and "easy". In my view, extra layers of idiosyncratic DSL on top of a language to overcome a perceived limitations of idiomatic nested maps doesn't quite qualify as "simple". But well, I might be wrong ...
In commix I can use
Yes, that's the main goal but there are others. Making system specification distributed and composable for instance. In integrant behaviors are distributed but system is a monolitic construct. In commix, both behavior and system configuration can be distributed. Another goal is to have a consistent and extensible life-cycle action system. Another goal is not to enforce cide-effectfull components. I find it hard to swallow that all Clojure life-cycle systems assume side-effectfull systems. In commix even halt-key returns a value which could be potentially useful for some post-halt actions (statistics, post-mortem debugging etc). |
In some cases, but frequently it's a trade-off. A flat map is simpler than a nested map. A
Right. Integrant deliberately views systems as black boxes. You can leave it running, or halt it and throw it away. Even Anyway, it's clear we have some different ideas! 😃 I'll definitely be keeping an eye on Commix and seeing how that approach works out compared to the direction I'm taking Integrant and Duct. |
The following two patterns keep occurring in my design.
First is the monitoring or reporter functionality which is not strictly part of the system but needs access to the whole system in order to report its state. The way I imagine it currently is to have a nested system, the outer layer contains two components - monitoring and original system as a component. Or, alternatively a special keyword
:integrant/system
which would indicate that a components accepts the whole system as input (recursive dependency in a sense).Second pattern is when an application consists of almost identical sub-systems which are loosely connected through some statistics channels. This again calls for some notion of nested systems.
I wonder what are your thoughts on this and if you intend to add some explicit provisions for nested systems in the future. Thanks!
The text was updated successfully, but these errors were encountered: