In implementing proper dependency removal in the Search API and writing tests for it, I came across a strange behavior in the config dependency logic which I think constitutes a bug, since I can't imagine this would be desired behavior. (If it cannot be fixed, it should at least be clearly documented.)
The setup is as follows: Let's assume we have three config entities A, B and C. B has a dependency on A, and C has a dependency on B.
Now, when I delete entity A, the system determines that B and C will be affected, and the onDependencyRemoval()
method of both B and C will be called, for B with A as the removed dependendcy, and for C with B.
However, if B succeeds in removing its dependency on A, it will not be deleted, thus rendering C's actions in reaction to the removal of B irrelevant. Contrary to this logic, though, the changes made by C will be saved anyways. (If C doesn't make changes, though, it will not be deleted – the current code correctly catches this case, it seems, maybe inadvertently, by setting $return['delete']
only after all onDependencyRemoval()
methdos have been called.)
To summarize the order of events, maybe more clearly:
- A is deleted.
ConfigManager::getConfigEntitiesToChangeOnDependencyRemoval()
determines that B and C will be affected (C only because B might be deleted).C::onDependencyRemoval
is called. (This is the order for me, but that might depend on the details. I think the bug will occur in any case, though.) It changes the entity and returnsTRUE
.ConfigManager::getConfigEntitiesToChangeOnDependencyRemoval()
therefore saves it in$return['update']
.B::onDependencyRemoval
is called. It changes the entity and returnsTRUE
.ConfigManager::getConfigEntitiesToChangeOnDependencyRemoval()
therefore saves it in$return['update']
.- Neither B nor C will be deleted, but both will be saved with the made changes.
There is actually code in ConfigManager::getConfigEntitiesToChangeOnDependencyRemoval()
to take the constantly changing dependencies into account, but it doesn't work completely correctly. It seems to me as if the code assumes that the list of dependents (as returned by findConfigEntityDependentsAsEntities()
) is ordered with "direct" dependents first and their dependents afterwards, but this is not actually the case. Maybe ensuring that order will be the best way to fix this issue, or maybe there is an even easier way.
Comment | File | Size | Author |
---|---|---|---|
#12 | 2642374-12.patch | 6.45 KB | alexpott |
#12 | 2642374.12.test-only.patch | 2.7 KB | alexpott |
#11 | 2642374-11.patch | 3.75 KB | alexpott |
#10 | goto.png | 25.6 KB | DamienMcKenna |
#9 | config-manager-nested-2642374-9-goto.patch | 1.9 KB | Berdir |
Comments
Comment #2
xjmThanks @drunken monkey for the excellent issue report. @alexpott, @catch, and I all thought this issue might in fact be critical.
Comment #3
chx CreditAttribution: chx as a volunteer commentedSo the way I would do this is: when a config object gets deleted then its direct dependents gets collected and one by one getConfigEntitiesToChangeOnDependencyRemoval is called. If one of them decides to delete then the process from above repeats. If it doesn't delete then nothing will happen. Ie C never happens because C doesn't directly depend on A just B which now doesn't depend on A. End.
tl;dr: BFS instead of DFS.
Comment #4
Anonymous (not verified) CreditAttribution: Anonymous commentedi went to write a test for this, but it seems there already is one.
ConfigDependencyTest explicitly contains code to test transitive dependencies, and it passes.
@drunken monkey - can you share the Search API test code that triggers the issue?
i'll post a patch shortly with a simplified A, B, C test that seeks to reproduce the original issue so we have a simplified testing ground to reproduce this.
Comment #5
drunken monkeyIt's in
\Drupal\Tests\search_api\Kernel\DependencyRemovalTest::testBackendDependency()
. There is a commented-out assertion at the bottom of the method along with a comment referencing this issue – if you re-add the assertion, the test fails.Comment #6
BerdirIs this maybe related to the same problem that we had in #2541206: Consider field storage dependency removal on Index?
There we had a problem that the function changed global state. And because it is called twice on form submission (once to re-build the form again, and then once on submit), it didn't work anymore on the submit call as it thought that it already got rid of the changes.
Comment #7
drunken monkeyHuh. Good idea, you had me worried a bit there. But if I reset the storage cache and reload the index the result is the same, the test fails. I guess that's what you meant?
I think I also debugged this problem quite thoroughly back when I discovered it, and it also appears "in the wild", not just in tests – and the config manager acts as outlined in the issue summary.
Looking into the code again now I even see this code in
\Drupal\Core\Config\Entity\ConfigDependencyManager::getDependentEntities()
:If I understand this correctly, it seems this will order the dependencies on purpose the way that will break
\Drupal\Core\Config\ConfigManager::getConfigEntitiesToChangeOnDependencyRemoval()
. So maybe just do anarray_reverse()
there, too?Anyways, first we probably really need a test case for this in Core to demonstrate it's wrong now and to verify that this would indeed fix it. (Just checked with the Search API test, though, and it seems: no, it doesn't. But didn't debug thoroughly this time.)
Comment #8
Berdiri don't think it's about resetting the storage, config entities don't use the static cache anyway by default.
The exapt change and explanation is in #18 there.
Comment #9
BerdirYeah, ignore my comments above, that's a different scenario.
I agree, there is indeed something missing. I think we need to do two changes:
1. Process them in reverse, as you already suggested.
2. That's not enough because we still have all dependencies in the initial loop. So what I'm doing is wrapping the foreach inside a do-while (TRUE). Then I can restart the foreach if something changed. Thinking about it, this might actually be a good use case for a goto statement. Never used that before in PHP. So, attaching a second patch that uses a goto.
Comment #10
DamienMcKennaComment #11
alexpottI've spent sometime staring at @Berdir's patches and I think we can do this without a goto or extra nesting of complexity.
Comment #12
alexpottDrupal\Tests\search_api\Kernel\DependencyRemovalTest
passes with #11. The test only patch is the interdiff :)Comment #14
Anonymous (not verified) CreditAttribution: Anonymous commentednice patch.
not confident enough about the code to mark RTBC.
Comment #15
andypostAwesome patch, greatly explained comments and test
the only nit, tb fixed on commit
Comment #16
alexpott@andypost what is wrong there? Both variables need to be initialised to an empty array.
Comment #17
andypost@alexpott I suppose they should be separate lines. But checked coding standards and found no mentions.
Comment #20
catchReviewed this a couple of times and can't find anything wrong with it, although also not 100% confident I'd spot something wrong with it if there is.
Committed/pushed to 8.1.x and cherry-picked to 8.0.x. Thanks!