-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
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
Support for WaitStrategy on DockerComposeContainer - alternative approach #600
Support for WaitStrategy on DockerComposeContainer - alternative approach #600
Conversation
As discussed in #174
fixed some tests and added some files missed from previous commit
@barrycommins while I like the final result a lot, I'm thinking what if we dramatically reduce the number of changes by going a bit to the low level landscape and using This way we can unify the strategies between GenericContainer and Docker Compose, always use the actual state (current implementation with GenericContainer will use the state set by user (i.e. envs), but what if Docker daemon will change them? Would be more safe to use WDYT? |
That would certainly be a simpler solution. I think it would probably work ok for the For docker-compose, that wait strategy needs to know the port the service is mapped to on the I'll make another attempt at it though with that approach and see if I can come up with a solution. Are you ok with deprecating the current Or can you suggest an alternative approach for that? Edit: thinking about it, the |
@barrycommins Docker client is always available as Re Maybe need a "proxying" object, something like |
Ok, good suggestions, thanks. I'm very much in favour of reducing the changeset, so I'll take try this approach in the next week or so. |
@barrycommins let me know if you need a help or something :)
P.S. good job, thanks! Will be one of the biggest (and most awaited) features of the upcoming release 👍 |
Those are all really helpful suggestions, thanks. |
The |
@barrycommins oh, yes, |
Added new WaitStrategyTarget interface, plus moved some methods into new LogFollower and CommandExecutor interfaces
@bsideup I added two more interfaces The change set is still quite large, so if you have any more suggestions, or if I have misunderstood your earlier suggestions, please let me know. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We're getting much closer to a final result 👍
Added a few comments, but it looks much better in general 👍
/** | ||
* @return the logger for the current container | ||
*/ | ||
Logger getLogger(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
logger should not be a part of the container's state
/** | ||
* @return the container info | ||
*/ | ||
default InspectContainerResponse getContainerInfo() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the result of this method must be cached, otherwise it's a huge performance penalty. Please either make it abstract or use a caching Map (with weak keys)
@@ -32,6 +30,11 @@ default SELF self() { | |||
return (SELF) this; | |||
} | |||
|
|||
@Override | |||
default String getContainerName() { | |||
return CommandExecutor.super.getContainerName(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should use ContainerState
as a super interface here
} | ||
|
||
private String getServiceNameFromContainer(Container container) { | ||
final String containerName = container.labels.get("com.docker.compose.service"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
please use getters, not fields
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When was this label added to the Docker Compose? I want to make sure we're not raising our compatibility baseline
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Seems to be there since Docker 1.6.0 in 2015 (docker-compose 1.3.0).
docker/compose#1066
https://github.com/docker/compose/pull/1356/files#diff-84582fcbdd42b004bb763c973ef1e9b5
@@ -343,13 +377,13 @@ protected Integer getLivenessCheckPort() { | |||
|
|||
/** | |||
* @return the ports on which to check if the container is ready | |||
* @deprecated use {@link #getLivenessCheckPortNumbers()} instead |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
getLivenessCheckPorts()
in GenericContainer
is protected, not public, so I couldn't move it to an interface without a breaking change. It's overridden in several subclasses of GenericContainer
, so I presume it is probably implemented by some other users.
*/ | ||
@Deprecated | ||
@NonNull | ||
protected WaitStrategy waitStrategy = Wait.defaultWaitStrategy(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
old WaitStrategies should extend from new so simplify the transition and also to remove this duality of strategies here
@@ -13,12 +13,12 @@ | |||
*/ | |||
@RequiredArgsConstructor | |||
public class ExternalPortListeningCheck implements Callable<Boolean> { | |||
private final Container<?> container; | |||
private final WaitStrategyTarget waitStrategyTarget; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would use ContainerState
here
import java.io.IOException; | ||
import java.nio.charset.Charset; | ||
|
||
public interface CommandExecutor extends ContainerState { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
While I understand the motivation for this interface (and also LogFollower), I see more value in some utility class with execInContainer(containerId, Charset charset, String...command)
method instead, and both GenericContainer and WaitStrategy calling it when needed.
The motivation is simple:
- it will be possible to use it with any container (currently one will have to implement this interface to access this functionality)
- it will get us closer to the "reusable container patterns" idea we had some time ago
- having less interfaces gives us more flexibility to change the API in future
So, something like:
public class ExecInContainerPattern {
// Note generic ExecResult instead of Container.ExecResult (should be converted to later in GenericContainer)
public static ExecResult execInContainer(String containerId, Charset charset, String... command) {
// ...
}
}
WDYT?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Made this change along with the other suggested changes in the latest commit.
I've added a Logger
parameter to the method in this new class, it's probably not ideal since it is only used for debug and trace messages, but they can be useful and I didn't want to change the existing behaviour.
* @param types types that should be followed (one or both of STDOUT, STDERR) | ||
*/ | ||
default void followOutput(Consumer<OutputFrame> consumer, OutputFrame.OutputType... types) { | ||
LogUtils.followOutput(DockerClientFactory.instance().client(), getContainerId(), consumer, types); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since we already have a LogUtils
, I would also (the same as CommandExecutor
) remove this interface (but maybe add an overload for followOutput
with default output types)
Removed CommandExecutor and LogFollower interfaces. Added ExecInContainerPattern utility class. Changed existing WaitStrategy implementations to inherit from new ones
Sorry for my continuing lack of review on this - it looks like a lot of hard, good work has gone into it, so thank you! I'll try to review tomorrow. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm requesting some changes but we're getting much closer to the final result 👍 Super excited!
this.proxyContainer = proxyContainer; | ||
this.logger = logger; | ||
|
||
if (mappedPorts != null) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
please add @NonNull
to mappedPorts
argument and use this.mappedPorts = new HashMap(mappedPorts)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A ComposeServiceWaitStrategyTarget
is created for every service returned by listChildContainers()
in DockerComposeContainer
, which can return services with no exposed ports.
for example:
redis:
image: redis
db:
image: orchardup/mysql
and a test with
new DockerComposeContainer(new File("src/test/resources/compose-test.yml"))
.withExposedService("redis_1", REDIS_PORT, Wait.forListeningPort());
Only the redis container has exposed/mapped ports, so the ComposeServiceWaitStrategyTarget
for the db
service would have a null value for mappedPorts
.
I'll change the behaviour to only create a ComposeServiceWaitStrategyTarget
for services that have been explicitly exposed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should never accept null as a valid collection argument's value, that was the motivation :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll change the behaviour to only create a ComposeServiceWaitStrategyTarget for services that have been explicitly exposed.
Now I think about use cases where waiting strategy doesn't use the ports at all but checks the logs for instance.
Current API doesn't allow that without exposing a port, right?
Should we maybe add waitingFor("redis_1", Wait.forLogMessage())
method?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I can add that method, but then we get into the problem of a ComposeServiceWaitStrategyTarget
with no exposed ports, so the constructor would have to accept a null or empty value for mappedPorts
, which would mean returning a empty list from getExposedPorts
.
That goes against this idea:
We should never accept null as a valid collection argument's value, that was the motivation :)
It also means you could use DockerComposeContainer
like this:
new DockerComposeContainer(new File("src/test/resources/compose-test.yml"))
.withExposedService("redis_1", REDIS_PORT, Wait.forListeningPort());
or like this:
new DockerComposeContainer(new File("src/test/resources/compose-test.yml"))
.withExposedService("redis_1", REDIS_PORT)
.waitingFor("redis_1", Wait.forListeningPort());
Is that slightly confusing for the user?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
but then we get into the problem of a ComposeServiceWaitStrategyTarget with no exposed ports, so the constructor would have to accept a null or empty value for mappedPorts, which would mean returning a empty list from getExposedPorts
Sounds absolutely fine to me - no exposed ports means an empty set.
Second example IMO doesn't look confusing, and makes even more sense because we're actually waiting for a service and not for the exposed port :)
Just .withExposedService("redis_1", REDIS_PORT, Wait.forListeningPort())
is a good alias/shortcut for it
|
||
if (mappedPorts != null) { | ||
this.mappedPorts.putAll(mappedPorts); | ||
this.exposedPorts.addAll(this.mappedPorts.keySet()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why do you store exposedPorts
when it's just an alias to this.mappedPorts.keySet()
? :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the getExposedPorts()
method from the ContainerState
interface returns Set<Integer>
, while this.mappedPorts.keySet()
returns List<Integer>
.
I didn't want to create a new Set
every time the getter is called.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
keySet()
returns Set
as far as I remember :) Maybe you meant that getExposedPorts
returns List<Integer>
and not Set<Integer>
?
Also, maybe we should make getExposedPortNumbers
return Set<Integer>
, use it where possible, and add getExposedPorts
to ComposeServiceWaitStrategyTarget
with getExposedPortNumbers().stream.collect(toList())
implementation?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, sorry, obviously keySet()
returns a Set
! :-)
getExposedPortNumbers().stream.collect(toList())
Isn't that functionally the same as new ArrayList<>(getExposedPortNumbers());
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it is :) Both are equally fine to me since we're talking about collections with just a few elements
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
add getExposedPorts to ComposeServiceWaitStrategyTarget with getExposedPortNumbers().stream.collect(toList()) implementation?
Ah, I've just realised why this won't work. getExposedPortNumbers()
in ContainerState
is a default method that calls getExposedPort()
!
default Set<Integer> getExposedPortNumbers() {
return getExposedPorts().stream()
.map(this::getMappedPort)
.collect(Collectors.toSet());
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
just override (implement) both inComposeServiceWaitStrategyTarget
, getExposedPortNumbers
and getExposedPorts
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
oh, I didn't notice that! Yes, sure!
P.S. I checked the implementation and it seems it is only used in WaitStrategyTarget ::getLivenessCheckPortNumbers
.
Let's just remove this method and use it's implementation in WaitStrategyTarget
.
WDYT?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Despite the similar names, getExposedPorts()
and getExposedPortNumbers()
do different things.
I think getExposedPortNumbers()
is confusingly named, because what it actually does is return the port that the service port has been mapped to.
e.g. .withExposedService("redis_1", 6379)
.
getExposedPorts()
returns 6379 and getExposedPortNumbers()
would return e.g. 32679
I took the name from a private method in GenericContainer
, but it should maybe be renamed in ContainerState
to getMappedPorts()
. WDYT?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry, I deleted the original comment, because I was starting to confuse myself.
Yes, it's only used in WaitStrategyTarget ::getLivenessCheckPortNumbers
I agree, it can definitely go!
} | ||
|
||
@Override | ||
public InspectContainerResponse getContainerInfo() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you can remove this method and use:
@Getter(lazy=true)
private InspectContainerResponse containerInfo = DockerClientFactory.instance().client().inspectContainerCmd(getContainerId()).exec();
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it will also make it thread safe
@@ -32,6 +32,11 @@ default SELF self() { | |||
return (SELF) this; | |||
} | |||
|
|||
@Override | |||
default String getContainerName() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why this change is needed?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
HostPortWaitStrategy
uses getContainerName()
, which was previously protected visibility in GenericContainer
.
It's only used for a debug statement, so it could probably be removed if you think this method shouldn't be added.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
since the method was protected, this is a breaking change anyway (making it public)
Let's just not expose this method and use getContainerInfo().getName()
in HostPortWaitStrategy
, or maybe even remove it since the name is not that helpful anyway (we assign random names)
(WaitAllStrategy) new WaitAllStrategy().withStartupTimeout(startupTimeout)); | ||
|
||
if(waitStrategy != null) { | ||
waitStrategy.withStartupTimeout(startupTimeout); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
already set in computeIfAbsent
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The startupTimeout
is set on the WaitAllStrategy
that wraps the (potentially) multiple WaitStrategy
instances for a particular service, but I thought it would be a good idea to apply the same timeout to all of those strategies.
Otherwise you could have the case where one strategy could time out after 1 minute, but the WaitAllStrategy
could be set to 5 minutes.
As an example:
ew DockerComposeContainer(new File("src/test/resources/compose-test.yml"))
.withStartupTimeout(Duration.ofSeconds(120))
.withExposedService("redis_1", REDIS_PORT, Wait.forListeningPort())
.withExposedService("redis_1", SOME_OTHER_PORT, Wait.forListeningPort());
Two instances of HostPortWaitStrategy
would be applied to the same service here. The idea was to apply the same timeout to both.
WDYT? Does it make sense to do this?
edit: it's just occurred to me that the order in which the methods are called would be important in this implementation, because if withStartupTimeout()
is called after withExposedService()
, the timeout would not be applied to the strategies. I'll fix after your feedback on this comment.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see. Hm, what if we rename the method to withDefaultStartupTimeout
here?
Not sure tho.
We need to be careful because of existing "startup timeout" in GenericContainer where it applies to the container and not a set of containers.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I had moved SELF withStartupTimeout(Duration startupTimeout);
to a new interface StartupTImeout
, which I thought was inherited by Container
and implemented by 'GenericContainerand
DockerComposeContainer`, but part of that seems to have been lost in one of the refactors.
Currently that interface is only implemented by DockerComposeContainer
, while Container
has a method with the same signature.
I could remove that interface and just add the method directly to DockerComposeContainer
? Or add a method like withDefaultStartupTimeout
, as you mentioned?
In general though, should we be applying a single timeout to all WaitStrategy
instances on docker-compose?
AbstractWaitStrategy
has a default timeout of 60s, so it would be difficult to check if a default should be applied to all strategies, or if it has been overridden for a particular strategy.
Since there's probably multiple services involved, the timeouts are likely to need to be longer than for a single GenericContainer
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would remove the interface, at least I see no use cases for it
Re "single timeout to all WaitStrategy instances" - good point about "all strategies". Maybe we should consider removing the method (withStartupTimeout
) at all?
So that users will explicitly add the timeout per exposed service:
.withExposedService("redis_1", 6379, Wait.forHttp("/").withStartupTimeout(...))
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's still the case where you may have more than one WaitStrategy
per service, e.g., it if exposes two ports
.withExposedService("redis_1", 6379, Wait.forListeningPort())
.withExposedService("redis_1", 6380, Wait.forListeningPort())
I think each service needs to have a WaitAllStrategy
to wrap the possible multiple strategies (which is what it's doing in the current PR), and that also needs to have a timout. Or at least, it has one in the current implementation.
There's no other current mechanism to expose multiple ports on the same service. We could have added varargs like withExposedService(String serviceName, int... ports)
, but that is not possible with these new methods, since varargs have to be the last parameter.
We could add an array/collection as a parameter on another overloaded method, but that's not so great from a user perspective, I think.
Maybe an array would be ok, because you could then use:
withExposedService("redis_1", {6379, 6380}, Wait.forListeningPort())
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
WaitAllStrategy
can have an infinite duration (means the duration will be controlled by inner waiters):
.withExposedService("redis_1", 6379, Wait.forListeningPort().withStartupTimeout(timeout1))
.withExposedService("redis_1", 6380, Wait.forListeningPort()) // Uses default
no?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The current implementation of WaitAllStrategy
has a default timeout of 30 seconds. For this case we can either implement a new InfiniteTimeWaitAllStrategy
, or just set the timeout for an improbably long time for the WaitAllStrategy
created in DockerComposeContainer
per service. Say 30 minutes or so?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
sounds good
final WaitAllStrategy waitAllStrategy = waitStrategyMap.computeIfAbsent(serviceInstanceName, __ -> | ||
(WaitAllStrategy) new WaitAllStrategy().withStartupTimeout(startupTimeout)); | ||
|
||
if(waitStrategy != null) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can't be null
@Override | ||
public SELF withStartupTimeout(Duration startupTimeout) { | ||
this.startupTimeout = startupTimeout; | ||
return (SELF) this; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
return self()
* {@inheritDoc} | ||
*/ | ||
@Override | ||
public Logger getLogger() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
a leftover? Logger should not be exposed
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The execInContainer()
methods, which have now been moved to ExecInContainerPattern
still use a logger which must be passed into the method.
It's used for a debug and two trace statements, which are useful, but not exactly crucial.
Should they be removed, or can you see another way around this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, I think we should remove it and create another logger in ExecInContainerPattern
@@ -837,6 +833,7 @@ public Integer getMappedPort(final int originalPort) { | |||
Preconditions.checkState(containerId != null, "Mapped port can only be obtained after the container is started"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
shouldn't getMappedPort
be a default implementation in ContainerState
interface? Ofc implementations like DockerComposeContainer
will override it because of their specifics, but the rest should be fine with this implementation
Moved getMappedPort to default implementation in ContainerState. Using proxyContainer in ComposeStrategyWaitServiceTarget to get ip. Removed unnecessary null checks. Using lazy getter for containerInfo in ComposeStrategyWaitServiceTarget.
@bsideup I've made some of the changes you requested in the review, but had a couple of questions on other ones. Let me know what you think, and I'll make further changes as necessary. |
Removed getLogger() method. Removed getContainerName from Container and ContainerState. Removed getExposedPortNumbers from ContainerState. Added waitingFor method to DockerComposeContainer. Removed StartupTimeout interface.
The latest commit is my favourite type of commit, because it mostly removes code. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you for this @barrycommins! I'm happy for this to be merged following @bsideup's review - I don't have anything to add.
Thanks for the review @rnorth, and thanks to @bsideup for helping beat this into shape. Went through about 10 commits, so I'm sure it will need a squash before merging. Any other features/bugs you would like someone to look at, just let me know. |
@barrycommins merged 🎉 Thanks a lot for this contribution 👍 |
@bsideup, just wanted to continue the conversation from #598
I didn't want to update that pull request for now, as this is a much bigger change.
I've implemented an alternative approach, involving breaking out all of the methods that apply after startup from the
Container
interface into a new interfaceContainerState
.I've also moved the functionality implemented in the various
WaitStrategy
classes into classes with the same names, but in a sub-packageorg.testcontainer.wait.strategy
and updated the current implementations to delegate to the new ones. I have also deprecated the use of the current classes in favour of the new ones.For each instance of a service started by docker-compose, I'm instantiating a class called
DockerComposeServiceInstance
, which implementsContainerState
, and applying the newWaitStrategy
implementation to them.For example:
would create two instance, and apply a
WaitStartegy
to each.This is a considerably larger change than the other pull request, and might not gel with the future plans for interface changes, so I thought I'd request feedback at this point. There's still work required on it, but all tests are passing.
If this is too large a change, or doesn't feel like the right approach, please let me know. If that's the case, maybe we should revisit after the planned changes for splitting configuration and state are done.