Problem/Motivation
Note: This could just as well be marked against the entity system.
Data type info depends on bundle info
\Drupal\Core\Entity\Plugin\DataType\Deriver\EntityDeriver generates a data type for every entity bundle, so that you can, for example, validate that a given data structure is a valid entity:node:article and not just any entity:node. Thus, the data type info depends directly on the entity bundle info.
Bundle info depends on data type info
Bundles for one entity type are usually provided by (configuration) entities of another type. Those other entities, however, are themselves instances of data types, for example entity:node_type. So by loading those entities during the building of the bundle info it "conceptually" depends on the data type info. "Conceptually" here means that due to a variety of circumstances it happens to be the case that the typed data info is never actually primed during the loading of those bundle entities. Thus, as it stands the bundle info can be built correctly and completely without any recursion.
But if - for whatever reason - any contrib or custom module (or combination thereof) leads to TypedDataManager::getDefinitions() being called during the loading of a bundle entity this will start to go south. Examples of how this can happen:
- You use content entities as bundles* If you have a field with a property constraint on that entity, simply loading the entity will build the field definitions and that will end up calling
TypedDataManager::getDefaultConstraints()for that field which callsTypedDataManager::getDefinitions(). Alternatively if you interact with any fieldFieldTypePluginManager::createFieldItemList()will triggerEntity::getTypedData(). - You trigger validation Even though generally this is only done before saving an entity, there really is no reason why it should not be allowed to validate an entity on load. You may not be able to resolve any errors, but you may want to log something or show a warning in the user interface that there is a problem.
- Generally speaking: You trigger
Entity::getTypedData()This is a bit vague, but the existence of that method is a clear pointer that you should be allowed to interact with the typed data system during the loading of an entity. JSON:API does this inResourceObject::extractContentEntityFields()to get a list of non-internal fields. That isn't directly called on entity load, but there's no reason why another module shouldn't be allowed to have that exact information during entity load.
* Even though this is not done in core, there is nothing that prevents you from using a content entity as a bundle entity for another entity. If you know that you are never going to have more than a handful of those entities but you want those entities to be managed by the website admins and not be reverted by a config import, that's a great fit. In fact for many sites things like menus or webforms would be better suited as content entities and those could be bundles for other entities (menu links and webform submissions, for example). That's not to say, that we should necessarily have a "bundle content entity" in core, rather to give some rationale for why that's not as far fetched as it may seem at first thought.
The symptom: missing data types
One might think that if this recursive dependency is triggered as described above and neither the bundle info nor the data type info are cached, this leads to infinite recursion. This is not the case, however, because of the specifics of how EntityTypeBundleInfo works: It first checks whether there is something in its internal static cache before building the bundle info. It then directly (partially) populates that static cache with the result of hook_entity_bundle_info() implementations and proceeds to amend that cache with the info from the various bundle entities. If during the loading of the bundle entities it happens to recurse into itself via TypedDataManager it will find an already - partially! - populated static cache and pass that partial info along. TypedDataManager (or to be precise EntityDeriver), thus, may not derive bundle data types for entities which actually do have bundles, because EntityTypeBundleInfo didn't have those bundles yet. The data type info will then be cached without those bundle data types leading to various errors down the road if those data types are (rightly) assumed to be present on the system.
Assuming no bundle info or data type info cache a more concrete code flow for this could be:
EntityTypeBundleInfo::getAllBundleInfo()gets called by somethingEntityTypeBundleInfo::getAllBundleInfo()does$this->bundleInfo = $this->moduleHandler->invokeAll('entity_bundle_info')so that the static cache is no longer emptyEntityTypeBundleInfo::getAllBundleInfo()builds the bundles for thenodeentity type and calls$this->entityTypeManager->getStorage('node_type')->loadMultiple()for that purpose. At this point the bundles for thetaxonomy_termentity type have not been built. (The order here is unspecified so let's just assume this to be the case in this example.)- Some module calls
$nodeType->getTypedData()inhook_node_type_load(). (See above for real-world scenarios.) - This calls into the
TypedDataManager::getDefinitions()viaEntityBase::getTypedDataClass()andTypedDataManager::hasDefinition() - The typed data discovery calls
EntityDeriver::getDerivativeDefinitions() EntityDeriver::getDerivativeDefinitions()callsEntityTypeBundleInfo::getBundleInfo()which callsEntityTypeBundleInfo::getAllBundleInfo(). Since$this->bundleInfowas set to something non-empty in 2. above, it is returned directly. This is repeated for every entity type untiltaxonomy_termis reached- Since the bundle info for the
taxonomy_termentity type is empty, only theentity:taxonomy_termdata type is derived, but notentity:taxonomy_term:tags TypedDataManager::getDefinitions()now caches the list of data types withoutentity:taxonomy_term:tags- The outer call to
EntityTypeBundleInfo::getAllBundleInfo()now resumes building the bundle info for all entity types including bundles fortaxonomy_term - The entire (correct) bundle info is cached
At this point, given that the entity type bundle info will (correctly) return the tags bundle for the taxonomy_term entity type, it is valid to expect \Drupal::typedDataManager()->getDefinition('entity:taxonomy_term:tags') to work. That will, however, yield a PluginNotFoundException.
Given a tag taxonomy term calling $term->getTypedData() will yield a typed data instance with the plugin ID entity:taxonomy_term at this point. Then, if the typed data cache is cleared for some reason, $term->getTypedData() will suddenly (correctly) return a typed data instance with the plugin ID entity:taxonomy_term:tags.
Steps to reproduce
- Create a module that calls
$nodeType->getTypedData()inhook_node_type_load() - Clear the bundle cache
- Fetch the bundle info before the data type info is built**
- Call
\Drupal::typedDataManager()->getDefinition('entity:taxonomy_term:tags')
** If the typed data info is built first, that will call into the entity type bundle info and that will trigger the code flow described above. However, the outer call to TypedDataManager::getDefinitions() will then receive the correct bundle info from the (outer) call to EntityTypeBundleInfo::getAllBundleInfo() and it will overwrite the incorrect cache entry set by the inner call to TypedDataManager::getDefinitions() with the correct data. Thus, in the end everything is correct.
Proposed resolution
Clear the typed data cache at the ending of building the bundle info cache. As noted above, the current symptom is that if the bundle info cache is primed before the data type info cache, the latter is effectively cache poisoned. So we can fix the symptom by simply clearing the (potentially) poisoned cache entry.
This only fixes the symptom, but the root cause - the circular dependency - is not fixable without breaking valid expectations as explained above, so this seems to be the best we can do.
Remaining tasks
User interface changes
API changes
-
Data model changes
-
Release notes snippet
Issue fork drupal-3399914
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #2
tstoecklerComment #3
tstoecklerComment #4
tstoecklerCreated a merge request with a failing test to show this issue. I guess the MR doesn't show up here, because I targeted the branch in the MR of #3399559: Statically cached derivative definitions cannot be cleared in any way. That's only because that already introduces a basic test for the entity bundle data types which I just amended here to show the bug.
Not 100% sure yet how to fix this, will see if I can find some time to play around with this and work on a fix.
Comment #5
tstoecklerOK, I guess the Gitlab Pipeline doesn't work if not targeting the proper repo. Leaving as is, though, for now, as it makes the review of the test change introduced here easier. Can of course easily change this and retarget the MR if this gets traction sooner than #3399559: Statically cached derivative definitions cannot be cleared in any way lands, but let's see.
Comment #6
tstoecklerOh, totally forgot to link the merge request here, sorry: https://git.drupalcode.org/issue/drupal-3399559/-/merge_requests/1
Comment #7
tstoecklerDidn't expect to be able to fix this with a literal one-liner but, yeah, pushed a commit now that makes the test green.
The fix is really just duct tape, but having thought about this for the bit I'm somewhat convinced it's the best we can do. I don't see a way to resolve the circular dependency in principal: both sides of the dependency as described in the issue summary are valid and needed APIs. And we also have no mechanism to partially or incrementally build up these sets of data: either you have cached data type definitions or you don't in which case we must build all of them right now. Same goes for the (cached) bundle info. In theory one could devise a system where the entity system is able to dynamically amend an already existing set of data types. But that system does not and cannot exist in Drupal in its current form. So I think, unfortunately, building behavior around the expectations of when things are cached (or not) is the next best thing.
Marking this as needs review now (see above for the link to the merge request). If someone wants to actually review this and set this to RTBC I can re-target the MR against 11.x properly and then we'll see if this or #3399559: Statically cached derivative definitions cannot be cleared in any way lands first. The current MR just makes it a tad easier to review (at least I hope so).
Comment #8
smustgrave commentedTook a look at the linked issue and seems to have some failures.
Comment #9
tstoecklerDo you mean the MR? See #5
Comment #10
smustgrave commentedThe MR from #6 has a composer failure and the tests were skipped.
Comment #11
tstoecklerYes, I tried to explain that in #5. It's that way because the MR doesn't target the core branch but the branch from #3399559: Statically cached derivative definitions cannot be cleared in any way. I verified that the test passes locally and was looking for a review on the approach here (and targeting that other branch makes it a bit easier to review). But I guess that's causing more confusion than it's helping so I will retarget the MR.
Comment #14
tstoecklerWent ahead and opened another merge request against 11.x now. I rant the test-only pipeline and was surprised to see the test pass even though the non-test changes were correctly reverted. It took me a while to realize why, but the test actually suffers from #3399559: Statically cached derivative definitions cannot be cleared in any way. It totally makes sense because I had only found that one "by accident" when debugging this, but then when I later created this issue I didn't realize that the test actually needs that to properly fail without the fix. So marking postponed now (even though this could definitely still really benefit from a review).
Comment #16
tstoeckler#3399559: Statically cached derivative definitions cannot be cleared in any way landed, so this is now genuinely ready for review.
Comment #17
tstoecklerAdded the proposed resolution (vageuly copied from #7) to the issue summary. There is no functional change here, but for the actual bugfix, so I don't think this needs a change notice.
Comment #18
alexpottThe MR feels very much like a sticking plaster. I'm not sure you'd expect different results from
Entity::getTypedData()depending on when it is called.Do you think that it is worth deprecating the recursive call into \Drupal\Core\Entity\EntityTypeBundleInfo::getAllBundleInfo() and in a future version of Drupal throwing a exception when this happens.
Really tricky issue and great sleuthing on working about what is good on. The issue summary is impressive.
Comment #19
tstoecklerThanks for taking a look @alexpott!
Basically the tl:dr; of the below is: would love to hear more of the thoughts behind your comment ;-)
Thanks! That really means a lot.
Not sure what you mean by this. One symptom of this bug is exactly that
Entity::getTypedData()may return different results depending on what happened before. In particular, in the example in the summary$term->getTypedData()would incorrectly return a typed data instance ofentity:taxonomy_termand then if for some reason only the typed data cache was cleared it would then correctly return an instance ofentity:taxonomy_term:tags. Adding this to the issue summary in fact, because I think it's helpful context, thanks for bringing that up! So, yeah, the MR doesn't feel great, 100% agreed, but maybe you can elaborate what you mean by this specifically.Again, not really sure what you mean by this, unfortunately. Will take some stabs at a guess, but maybe you can elaborate here, as well:
$typedDataManager->getDefinitions()without a primed bundle cache would yield a (deprecation notice, but eventually an) exception?label()and do something fancy there, but maybe it's fair to deprecate doing that for bundle entities (because presumably we don't want to just wholesale deprecate overriding that method)? While throwing the deprecation notice / exception would then require something like the somewhat uglymethod_exists($entityType->getClass(), 'label')it would at least be a way forward that does not depend on the internal state of the typed data manager.Comment #20
catchI think we should explore #19.2 and #19.3 more.
Comment #21
smustgrave commentedThanks @catch for taking a look.
Comment #22
tstoecklerSince I just encountered this beauty of a bug once again (yes, don't ask...) adding another suggestion to fix this here that was proposed by a certain ghost of drupal past on Mastodon during this year's Drupal Dev Days so that I don't have to dig that up again. Slightly altering/rephrasing the details, but I cannot take credit for the idea itself. Will refer to this as "Suggestion 4" for now:
getAllBundleInfo()todoGetAllBundleInfo()(or similar).doGetAllBundleInfo()and, thus, from the cache entirely. Instead just load the label dynamically ingetBundleInfo(). (We could also consider adding a newgetBundleNamesor something if you just need the keys and not the labels, but that's a minor detail (and possibly a follow-up).)getAllBundleInfo()to just callgetBundleInfo()for all entity types. It will be slower than before but it's not a very common use-case to need bundle information for all entity types. It could also easily be deprecated and have the few cases where it's used in core just move to callinggetBundleInfo()for the entity types that it needs.I personally think this is the best suggestion so far. Would love to hear thoughts on this!
Comment #23
geek-merlin@tstoeckler #22
Which essentially means to generate lazy instead of bulk.
#1 for best suggestion so far.