Problem/Motivation

We need a standardized and complete way to copy config between two config storages.
Many tools such as drush and drupal console and also a couple of modules do this all in their own way now and some do it in a incomplete fashion that may lead to strange behaviour in edge cases.

Examples in core:
\Drupal\Core\Config\ConfigManager::createSnapshot misbehaves badly when storages are not in default collection.
\Drupal\Tests\ConfigTestTrait::copyConfig ignores collections (so all tests based on this also ignore collections.)

related bug in drush: https://github.com/drush-ops/drush/issues/3572
related bug in drupal console: https://github.com/hechoendrupal/drupal-console/issues/1736

Modules implementing this logic on their own: config_split, config_sync, config_owner (and for sure a couple more)

Proposed resolution

Add a utility trait to copy config between storages which also takes care of collections.

Remaining tasks

port code from #2991683: Move configuration transformation API in \Drupal\Core\Config namespace
review
commit

User interface changes

none

API changes

todo

Data model changes

Release notes snippet

CommentFileSizeAuthor
#39 3016429-37.patch8.6 KBbircher
#37 3016429-37-no-ref-test-only.patch8.6 KBbircher
#37 interdiff-3016429-34-37.txt4.35 KBbircher
#37 3016429-37.patch8.6 KBbircher
#34 interdiff-3016429-31-34.txt635 bytesbircher
#34 3016429-34.patch8.04 KBbircher
#31 interdiff-3016429-27-31.txt1.14 KBbircher
#31 3016429-31.patch8.03 KBbircher
#27 interdiff-3016429-26-27.txt2.81 KBbircher
#27 3016429-27.patch8.01 KBbircher
#26 interdiff-3016429-13-26.txt4.11 KBmpotter
#26 3016429-26.patch7.96 KBmpotter
#20 3016429-20.patch12.66 KBbircher
#20 interdiff-3016429-19-20.txt8.55 KBbircher
#19 interdiff-3016429-13-19.txt9.01 KBbircher
#19 3016429-19.patch10.22 KBbircher
#13 interdiff-3016429-12-13.txt1.55 KBbircher
#13 3016429-13.patch8.04 KBbircher
#12 interdiff-3016429-11-12.txt4.14 KBbircher
#12 3016429-12.patch6.49 KBbircher
#11 interdiff-3016429-10-11.txt1.79 KBmpotter
#11 3016429-11.patch5.74 KBmpotter
#10 interdiff-3016429-6-10.txt6.72 KBbircher
#10 3016429-10.patch6.27 KBbircher
#6 interdiff-3016429-3-6.txt2.66 KBbircher
#6 3016429-6.patch6.07 KBbircher
#3 3016429-3.patch6.04 KBbircher
#2 interdiff-3016429-use-in-test-trait.txt875 bytesbircher
#2 3016429-2-trait.patch11.46 KBbircher
#2 3016429-2.patch10.61 KBbircher
#2 3016429-notest.patch5.18 KBbircher
Support from Acquia helps fund testing for Drupal Acquia logo

Comments

bircher created an issue. See original summary.

bircher’s picture

Adding the copier class with the MemoryStorage from #3015886: Add an in-memory config storage because it is so much simpler than setting up a temporary or virtual file system backed FileStorage. If the other issue is not committed in a reasonable time one might want to rewrite the test to use another config storage.

Attached is also a patch that uses the new copying mechanism in the also incomplete test trait.

bircher’s picture

FileSize
6.04 KB

Attached the patch which has the usage in the test trait but not the code from #3015886: Add an in-memory config storage. (ie the code that can be postponed) leaving for needs review to not scare off potential reviewers.

borisson_’s picture

This patch is super clear and really easy to read, I have found some really small things but not setting back to needs work for any of them. I think on all of them you could just answer "no" and I'd still be happy with this patch :)

I also already agreed at drupalcamp Ghent that delete first, copy everything to target was a good sequence.

Great work @bircher!

  1. +++ b/core/lib/Drupal/Core/Config/StorageCopier.php
    @@ -0,0 +1,57 @@
    +   * and copies also all collections. This is not importing configuration,
    +   * DO NOT use the active storage as the target.
    

    Should we try to throw an exception when someone tries to do this? Or can we imagine someone trying to do this?

  2. +++ b/core/lib/Drupal/Core/Config/StorageCopier.php
    @@ -0,0 +1,57 @@
    +    if ($target->getCollectionName() != StorageInterface::DEFAULT_COLLECTION) {
    

    Can we use strict comparison here (and in similar points in this method?)

  3. +++ b/core/lib/Drupal/Core/Config/StorageCopier.php
    @@ -0,0 +1,57 @@
    +    // Copy everything.
    ...
    +    // Copy collections as well.
    

    This isn't very clear. we first copy everthing, and then we copy some other things. The first thing is collection-less config, right?

    Can we document that more clearly?

  4. +++ b/core/tests/Drupal/Tests/Core/Config/StorageCopierTest.php
    @@ -0,0 +1,76 @@
    +  public function testCopyConfig() {
    

    This function tests both the copy and the fact that the copy command deletes before copying, should we split that up? Not sure, I like that this is only one method, but since this is a unit test there's hardly any cost to making it more clear?

Upchuk’s picture

I agree, looks good. It definitely can be a statically used class as it's not meant to be injected anywhere and there are no other ways storage values should be copied over.

A couple of minor things on my part as well:

  1. +++ b/core/lib/Drupal/Core/Config/StorageCopier.php
    @@ -0,0 +1,57 @@
    +    foreach ($source->getAllCollectionNames() as $collection) {
    

    This made me wonder about what getAllCollectionNames() returns or should return. I see all the storage implementations returning the collection names without the default collection. Maybe we should document this on the StorageInterface::getAllCollectionNames() no? That implementations should not return inclusive of the default name.

  2. +++ b/core/tests/Drupal/Tests/Core/Config/StorageCopierTest.php
    @@ -0,0 +1,76 @@
    +   * Generate random data in a config Storage.
    

    No need to capitalise "Storage" I guess.

  3. +++ b/core/tests/Drupal/Tests/Core/Config/StorageCopierTest.php
    @@ -0,0 +1,76 @@
    +  protected function generateRandomData(StorageInterface &$storage) {
    

    No need to pass this by reference no? It's an object so it's automatically by ref.

  4. +++ b/core/lib/Drupal/Core/Config/StorageCopier.php
    @@ -0,0 +1,57 @@
    +    // Copy everything.
    

    As borrison_ pointed out, I'd update this description to: "Copy the default collection" or something like this.

bircher’s picture

Thanks for the review. attached is the patch and interdiff, I didn't kick the test bot as the other issue is not committed yet.

RE #4
1) I don't think we can easily detect that a storage is the active storage in this layer. Also there is nothing preventing you from just writing to the active storage directly in general. So I think a warning is enough.
2) sure thing
3) I agree
4) well, the test asserts that the storage that target storage contains the identical configuration (including its collections), I don't know if that is two things. but I am always more happy for more tests.

RE #5
1) According to the documentation: A configuration storage can contain multiple sets of configuration objects in partitioned collections. The collection key name identifies the current collection used. So one could argue that only the partitions are collections and the default collection is the no-name collection of un-partitioned config. In any case I am not sure we need to update that documentation in this issue. but by all means: better documentation is better!
2) of course
3) no need indeed
4) yes

borisson_’s picture

Awesome, I'd rtbc this, but it's actually postponed, so not doing that yet. In any case this looks great.

bircher’s picture

  1. +++ b/core/tests/Drupal/Tests/Core/Config/StorageCopierTest.php
    @@ -0,0 +1,75 @@
    +    $connection_property = $reflection->getProperty('config');
    

    this should just be $property

  2. +++ b/core/tests/Drupal/Tests/Core/Config/StorageCopierTest.php
    @@ -0,0 +1,75 @@
    +    return array_filter((array) $connection_property->getValue($storage));
    

    With the latest patch form #3015886: Add an in-memory config storage we can return it straight. and then compare with ->getArrayCopy(). Maybe we want to make the method name shorter too, or in fact make new methods $this->assertMemoryEquals() and $this->assertMemoryNotEquals(). to make the test more readable.

alexpott’s picture

Status: Needs review » Needs work
+++ b/core/lib/Drupal/Core/Config/StorageCopier.php
@@ -0,0 +1,57 @@
+class StorageCopier {
...
+  public static function copyConfig(StorageInterface $source, StorageInterface $target) {

Let's make this a trait with a protected static instead of a public static function. This makes it a bit less of generic utility that can be (ab)used to copy something to active storage.

bircher’s picture

Title: Add a config storage copier utility class » Add a config storage copy utility trait
Status: Needs work » Needs review
FileSize
6.27 KB
6.72 KB

Attached the trait version.

mpotter’s picture

I wonder if this can be simplified rather than handling StorageInterface::DEFAULT_COLLECTION so differently. Here is a patch that passes the test, but this needs checking by somebody who understands the complexity of Collections and DEFAULT_COLLECTION more than I.

bircher’s picture

Made the method static again and also added more tests.
I think now it is good to go.

bircher’s picture

@mpotter discovered that in #2991683: Move configuration transformation API in \Drupal\Core\Config namespace we were also using it in \Drupal\Core\Config\ConfigManager::createSnapshot.

So adding that bit here too.

bircher’s picture

Title: Add a config storage copy utility trait » Add a config storage copy utility trait and fix ConfigManager::createSnapshot
Category: Feature request » Bug report

So this is actually a bug fix, as discussed with @alexpott on slack.

Currently when you pass the $snapshot_storage not in its default collection to \Drupal\Core\Config\ConfigManager::createSnapshot it will not have its default collection emptied out.
What is worse is that it will but the collection config of whatever the $source_storage happens to be in to the collection of whatever $snapshot_storage happens to be in.
So if the source or target are not in their default collection createSnapshot creates very strange results.

johnwebdev’s picture

Some thoughts:

  1. +++ b/core/lib/Drupal/Core/Config/StorageCopyTrait.php
    @@ -0,0 +1,42 @@
    +   * This method makes sure that the target storage is emptied before copying
    +   * and copies also all collections. This is not importing configuration,
    +   * DO NOT use the active storage as the target.
    

    can we prevent this from happening programmatically instead of just a warning in the docbloc?

  2. +++ b/core/lib/Drupal/Core/Config/StorageCopyTrait.php
    @@ -0,0 +1,42 @@
    +  protected static function copyConfig(StorageInterface $source, StorageInterface &$target) {
    +    // Delete all, the destination storage needs to be empty.
    +    foreach (array_merge([StorageInterface::DEFAULT_COLLECTION], $target->getAllCollectionNames()) as $collection) {
    +      $target_collection = $target->createCollection($collection);
    +      $target_collection->deleteAll();
    +    }
    

    we could separate the empty logic here to its own method and throw an exception if the targeted storage isn't empty. that means the method is only responsible for one thing and not putting the target in its correct state first.

bircher’s picture

Issue summary: View changes

Thanks @johndevman for looking at this issue,

1. Unfortunately no. In the this context we have no knowledge what a storage is used for or if it is the active storage or not. The active storage is not read-only because it is used when config entities are saved etc. So nothing currently prevents you from trying to enable new modules by directly writing to core.extensions in the active storage. For some modules (that don't have any install hooks) or configuration to install this could work, but I think we can all agree that this is very wrong. We already made the method protected and into a trait so that it doesn't look too inviting and will be used only when appropriate. I would argue that preventing developers to ignore the clear warning is out of scope.

2. I understand this idea and I have quarreled with it myself. But there is no one line method on the StorageInterface to know if it is "really empty" by which I mean all collections including the default are empty. So to check that in order to throw the exception one would have to do the same loop but have:
if (!empty($target_collection->listAll())) {throw \Exception();}
instead of:
$target_collection->deleteAll();
To achieve the goal of providing a method of correctly copying configuration from one storage to another we would then also have to add a new method that calls this emptyConfigStorage method and then the copyConfig. So I am not sure the added complexity is justified. I am not against renaming the copyConfig method to something else, though.
In addition I like that the deleteAll is called on the storage but then continues without making sure that the storage is indeed empty because that gives us more flexibility. We can test that too if really needed.

I updated the issue summary and added a change notice: https://www.drupal.org/node/3037305

alexpott’s picture

I think I agree with @johndevman we should not do copy and delete in the same function. I'm not sure that copy should check for emptiness by default.

mpotter’s picture

I don't agree with removing the "empty/delete" logic. That is part of the copy process. We are trying to make the dest config the same as the source config, and that means adding new items, changing existing items, and deleting old items. This is implemented by emptying the dest, then adding the source. The fact it is "deleting" is an implementation detail and not something that should be split into a separate callable function. If the caller forgot to call the `emptyConfigStorage` then whey would have a mess, or they would get an exception for "storage not empty" and in general that is making it needlessly more complex for the caller.

I think `copyConfig` is still a valid name for this. It is not just `addConfig`. We aren't making a CRUD interface here.

bircher’s picture

Attached is a quick patch with two separate methods, so that we can have an agreement and then pick.
I do not have a strong opinion on which is better. I tend to agree with @mpotter in #18 that deleting is an implementation detail. But I can see both arguments being valid.

  1. one method makes sure target contains the same config as the source.
    This is like rsync --recursive --delete
  2. two methods for copying config over and deleting files.
    This is like copying files and deleting files with cp and rm
bircher’s picture

And attached a compromise patch with new names:

syncStorage: Make sure two storages are the same.
copyStorage: copy contents
clearStorage: clear/delete all

mpotter’s picture

I can see the use-case of a newly created Storage object only wanting to call `copyStorage` without needing to clear it first. So I'm happy with this and the new names.

+++ b/core/lib/Drupal/Core/Config/StorageCopyTrait.php
@@ -0,0 +1,69 @@
+    foreach (array_merge([StorageInterface::DEFAULT_COLLECTION], $storage->getAllCollectionNames()) as $collection) {
+      $storage->createCollection($collection)->deleteAll();

Wonder if DEFAULT_COLLECTION needs to come last in the array_merge here. Thinking of how this would work on a FileStorage where the parent directory needs to be deleted last when empty.

bircher’s picture

RE #21 It shouldn't matter in which order they are in. The file storage doesn't delete the folder of the default collection and only deletes config files in it so this is fine.
This order is currently implemented in config splits export drush command and it works fine with a FileStorage.

mpotter’s picture

Status: Needs review » Reviewed & tested by the community

OK, with that answered I'm ready to RTBC this now.

alexpott’s picture

Status: Reviewed & tested by the community » Needs work

Oh noes seems like I had an xpost... I thought I'd commented on this hours ago. I'm not sure the compromise is a good deal. We've now got three methods to cope with and we've got to account for the difference between syncStorage and the ConfigSync class.

Let's choose one way (copy and clear) or another (sync - but with a better name). I'm not particularly in favour one on over the other. The 2 method approach offers future flexibility whilst the 1 method approach offers ease of use and consistency.

mpotter’s picture

I could go either way. I understand both sides. I actually like the name "syncStorage" because that's exactly what it does and doesn't get confused with whether Copy deletes stuff or just adds new stuff. I think @bircher was trying to support more flexibility and other use cases. It also makes the syncStorage a bit easier to understand by splitting the sub-functions into their own. Would we combine those back into the single sync method, or just mark them as private?

At this point I'd say to go with whichever has the best chance of getting committed. We only have a couple days left for 8.7 and it would be super helpful to get this into core so other parts of the initiative can start using it.

mpotter’s picture

Status: Needs work » Needs review
FileSize
7.96 KB
4.11 KB

To help move this along, here is a version back to a single "syncStorage" method. This is the smallest refactor from current core and includes the bug fix from #14. Included an interdiff from #13 since this is going back to the single-method approach.

As mentioned above, I think "syncStorage" is a better indication of what this does. If somebody has a better name, please make a specific suggestion.

We could also rename the Trait itself from StorageCopyTrait to StorageSyncTrait but that would have made the interdiff harder to review.

bircher’s picture

So the discussion on the #config slack channel lead to a new suggestion which I now attached.

I left the name of StorageCopyTrait since if we need to we can add more methods here and refactor the one method we are adding here.

I was a bit on the fence before, but especially in light of maybe adding one day a way to have temporary storages that are not memory storages we need to make sure that when we copy config we empty the storage properly before. So I agree with the simple approach and if the need arises we can always expand it then.

I think we need someone with authority to decide that or another name is what we want.

johnwebdev’s picture

+++ b/core/lib/Drupal/Core/Config/StorageCopyTrait.php
@@ -0,0 +1,41 @@
+   * Synchronize the configuration from one storage to another.

This should be updated to reflect the name of the method.

Mean while I'm more +1 to have two methods, I understand the concerns adressed by bircher and mpotter and I buy it. I also think the naming makes sense, in my opinion 'replace' is the closest thing to describe what we actually do and I think the verbosity of actually stating that it's the contents of the storage that is replaced makes it clear to the user what's going on. This behaviour is similar to when you move a folder on the file system to another one where it already exists, you "Replace" it.

Status: Needs review » Needs work

The last submitted patch, 27: 3016429-27.patch, failed testing. View results

alexpott’s picture

+1 on replaceStorageContents - nice work on the name.

bircher’s picture

Status: Needs work » Needs review
FileSize
8.03 KB
1.14 KB

also change the annotation on the test as the method is now called differently.

johnwebdev’s picture

Status: Needs review » Reviewed & tested by the community
alexpott’s picture

Status: Reviewed & tested by the community » Needs work
+++ b/core/tests/Drupal/Tests/Core/Config/StorageCopyTraitTest.php
@@ -16,7 +16,7 @@ class StorageCopyTraitTest extends UnitTestCase {
   public function testSyncStorage() {

The test method name too :)

bircher’s picture

Status: Needs work » Needs review
FileSize
8.04 KB
635 bytes

good catch..

johnwebdev’s picture

Status: Needs review » Reviewed & tested by the community
larowlan’s picture

Status: Reviewed & tested by the community » Needs work

This patch makes a lot of sense, nice work. Some observations below

  1. +++ b/core/lib/Drupal/Core/Config/StorageCopyTrait.php
    @@ -0,0 +1,41 @@
    +   * Configuration is only copied and not imported, so should not use
    +   * with active storage as the target.
    

    should not use => should not be used ?

  2. +++ b/core/lib/Drupal/Core/Config/StorageCopyTrait.php
    @@ -0,0 +1,41 @@
    +  protected static function replaceStorageContents(StorageInterface $source, StorageInterface &$target) {
    

    do we need the pass by reference operator given this is an object and therefore is passed by reference by default?

  3. +++ b/core/tests/Drupal/Tests/Core/Config/StorageCopyTraitTest.php
    @@ -0,0 +1,95 @@
    +    if (!empty($collections)) {
    ...
    +    if (!empty($collections)) {
    

    what if collection is empty? should we $this->fail() cause we're in an unexpected state?

  4. +++ b/core/tests/Drupal/Tests/Core/Config/StorageCopyTraitTest.php
    @@ -0,0 +1,95 @@
    +    for ($i = 0; $i < rand(0, 5); $i++) {
    

    ah, so this is why we have the code above - in some tests we test for collection names, but in others we don't. I think this is brittle, if we care about the behaviour in both cases, we should have an explicit case for with and without a collection, and we can easily do that with a dataProvider

bircher’s picture

RE #36
1) Yes and also adding an article.
2) Yes, we need to pass it by reference because the collection is an internal state of the storage and the only way to change it is with StorageInterface::createCollection which returns a new storage. Attached is a version without the reference which fails the tests.
3 & 4) should now be improved.

Status: Needs review » Needs work

The last submitted patch, 37: 3016429-37-no-ref-test-only.patch, failed testing. View results

bircher’s picture

Status: Needs work » Needs review
FileSize
8.6 KB

attached is the already tested patch.
How does one make the testbot not fail patches that are to test only, I thought test-only would do the trick..

borisson_’s picture

How does one make the testbot not fail patches that are to test only, I thought test-only would do the trick..

I think that only works when the test-only patch is the first uploaded patch.

I really like the new direction + name here, replaceStorageContents is very clear.

johnwebdev’s picture

Status: Needs review » Reviewed & tested by the community

Interdiff looks great.

We have better test coverage and proved why we need to pass by reference.

Back to RTBC.

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 39: 3016429-37.patch, failed testing. View results

Mixologic’s picture

Status: Needs work » Reviewed & tested by the community

dispatcher/testbot issue. back to rtbc

alexpott’s picture

Status: Reviewed & tested by the community » Fixed

Committed and pushed 21c3504993 to 8.8.x and fe487bd5e2 to 8.7.x. Thanks!

  • alexpott committed 21c3504 on 8.8.x
    Issue #3016429 by bircher, mpotter, alexpott, johndevman, borisson_,...

  • alexpott committed fe487bd on 8.7.x
    Issue #3016429 by bircher, mpotter, alexpott, johndevman, borisson_,...

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.

quietone’s picture

publish change record