Circular dependency between bundle and data type info

Created on 7 November 2023, about 1 year ago

Problem/Motivation

Note: This could just as well be marked against the entity system.

\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 typed data info depends directly on the entity bundle 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 typed data 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 completely built without any recursion.

But if - for whatever reason - any contrib or custom module (or combination thereof) lead to TypedDataManager::getDefinitions() being called this will start to go south. Examples of how this can happen:

  1. 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 calls TypedDataManager::getDefinitions(). Alternatively if you interact with any field FieldTypePluginManager::createFieldItemList() will trigger Entity::getTypedData().
  2. 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.
  3. Generally speaking: You trigger Entity::getTypedData() during loading. 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 in ResourceObject::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 wouldn't want to have that exact information during entity load.

One might think that if this happens 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:

  1. EntityTypeBundleInfo::getAllBundleInfo() gets called by something
  2. EntityTypeBundleInfo::getAllBundleInfo() does $this->bundleInfo = $this->moduleHandler->invokeAll('entity_bundle_info') so that the static cache is no longer empty
  3. EntityTypeBundleInfo::getAllBundleInfo() builds the bundles for the node entity type and calls $this->entityTypeManager->getStorage('node_type')->loadMultiple() for that purpose. At this point the bundles for the taxonomy_term entity type have not been built. (The order here is unspecified so let's just assume this to be the case in this example.)
  4. Some module calls $nodeType->getTypedData() in hook_node_type_load(). (See above for real-world scenarios.)
  5. This calls into the TypedDataManager::getDefinitions() via EntityBase::getTypedDataClass() and TypedDataManager::hasDefinition().
  6. The typed data discovery calls EntityDeriver::getDerivativeDefinitions()
  7. EntityDeriver::getDerivativeDefinitions() calls EntityTypeBundleInfo::getBundleInfo() which calls EntityTypeBundleInfo::getAllBundleInfo(). Since $this->bundleInfo was set to something non-empty in 2. above, it is returned directly. This is repeated for every entity type until taxonomy_term is reached.
  8. Since the bundle info for the taxonomy_term entity type is empty, only the entity:taxonomy_term data type is derived, but not entity:taxonomy_term:tags.
  9. TypedDataManager::getDefinitions() now caches the list of data types without entity:taxonomy_term:tags.
  10. The outer call to EntityTypeBundleInfo::getAllBundleInfo() now resumes building the bundle info for all entity types including bundles for taxonomy_term.
  11. 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.

* 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.

Steps to reproduce

  1. Create a module that calls $nodeType->getTypedData() in hook_node_type_load()
  2. Clear the bundle cache
  3. Fetch the bundle info before the data type info is built**
  4. 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

Remaining tasks

User interface changes

API changes

Data model changes

Release notes snippet

πŸ› Bug report
Status

Active

Version

11.0 πŸ”₯

Component
Typed dataΒ  β†’

Last updated 16 days ago

  • Maintained by
  • πŸ‡¦πŸ‡ΉAustria @fago
Created by

πŸ‡©πŸ‡ͺGermany tstoeckler Essen, Germany

Live updates comments and jobs are added and updated live.
Sign in to follow issues

Merge Requests

Comments & Activities

  • Issue created by @tstoeckler
  • πŸ‡©πŸ‡ͺGermany tstoeckler Essen, Germany
  • πŸ‡©πŸ‡ͺGermany tstoeckler Essen, Germany
  • Status changed to Needs work about 1 year ago
  • πŸ‡©πŸ‡ͺGermany tstoeckler Essen, Germany

    Created 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 πŸ› Statically cached derivative definitions cannot be cleared in any way RTBC . 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.

  • πŸ‡©πŸ‡ͺGermany tstoeckler Essen, Germany

    OK, 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 πŸ› Statically cached derivative definitions cannot be cleared in any way RTBC lands, but let's see.

  • πŸ‡©πŸ‡ͺGermany tstoeckler Essen, Germany

    Oh, totally forgot to link the merge request here, sorry: https://git.drupalcode.org/issue/drupal-3399559/-/merge_requests/1

  • Status changed to Needs review about 1 year ago
  • πŸ‡©πŸ‡ͺGermany tstoeckler Essen, Germany

    Didn'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 πŸ› Statically cached derivative definitions cannot be cleared in any way RTBC lands first. The current MR just makes it a tad easier to review (at least I hope so).

  • Status changed to Needs work about 1 year ago
  • πŸ‡ΊπŸ‡ΈUnited States smustgrave

    Took a look at the linked issue and seems to have some failures.

  • Status changed to Needs review about 1 year ago
  • πŸ‡©πŸ‡ͺGermany tstoeckler Essen, Germany

    Do you mean the MR? See #5

  • πŸ‡ΊπŸ‡ΈUnited States smustgrave

    The MR from #6 has a composer failure and the tests were skipped.

  • πŸ‡©πŸ‡ͺGermany tstoeckler Essen, Germany

    Yes, I tried to explain that in #5. It's that way because the MR doesn't target the core branch but the branch from πŸ› Statically cached derivative definitions cannot be cleared in any way RTBC . 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.

  • Status changed to Postponed about 1 year ago
  • πŸ‡©πŸ‡ͺGermany tstoeckler Essen, Germany

    Went 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 πŸ› Statically cached derivative definitions cannot be cleared in any way RTBC . 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).

  • Status changed to Needs review 11 months ago
  • πŸ‡©πŸ‡ͺGermany tstoeckler Essen, Germany

    πŸ› Statically cached derivative definitions cannot be cleared in any way RTBC landed, so this is now genuinely ready for review.

  • πŸ‡©πŸ‡ͺGermany tstoeckler Essen, Germany

    Added 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.

  • πŸ‡¬πŸ‡§United Kingdom alexpott πŸ‡ͺπŸ‡ΊπŸŒ

    The 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.

  • πŸ‡©πŸ‡ͺGermany tstoeckler Essen, Germany

    Thanks for taking a look @alexpott!

    Basically the tl:dr; of the below is: would love to hear more of the thoughts behind your comment ;-)

    Really tricky issue and great sleuthing on working about what is good on. The issue summary is impressive.

    Thanks! That really means a lot.

    The 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.

    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 of entity:taxonomy_term and then if for some reason only the typed data cache was cleared it would then correctly return an instance of entity: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.

    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.

    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:

    1. Are you suggesting to not have bundle-specific data types at all? Not sure how we would properly validate bundle-specific validation constraints if we were to do that. But that would very cleanly solve this issue by actually properly getting rid of the circular dependency. (To be clear, not necessarily implying you actually suggested this, just trying to explore all possible options here.)
    2. Are suggesting to not allow fetching the typed data information when loading a (bundle) entity? In theory I guess this would be the less invasive half of the circle to break, as the use-cases described in the issue summary (while valid) seem less important than having bundle-specific data types at all (i.e. 1.) Not really sure how to achieve this, though, as this would mean calling $typedDataManager->getDefinitions() without a primed bundle cache would yield a (deprecation notice, but eventually an) exception?
    3. Having written the above and looked at the issue again, I had an idea of a maybe less terrible way to solve this issue at hand, so just listing it here to make it easier to refer to down below: Avoid loading entities at all for the building the bundle information. We really only need the IDs and labels of the entities, so we could fetch those directly from the storage (either via an entity query directly or via a new dedicated method on the storage). The only problem would be entity types that override 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 ugly method_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.
  • πŸ‡¬πŸ‡§United Kingdom catch

    I think we should explore #19.2 and #19.3 more.

  • Status changed to Needs work 8 months ago
  • πŸ‡ΊπŸ‡ΈUnited States smustgrave

    Thanks @catch for taking a look.

  • πŸ‡©πŸ‡ͺGermany tstoeckler Essen, Germany

    Since 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:

    • Rename getAllBundleInfo() to doGetAllBundleInfo() (or similar).
    • Remove the fetching of the labels from doGetAllBundleInfo() and, thus, from the cache entirely. Instead just load the label dynamically in getBundleInfo(). (We could also consider adding a new getBundleNames or something if you just need the keys and not the labels, but that's a minor detail (and possibly a follow-up).)
    • Re-instate getAllBundleInfo() to just call getBundleInfo() 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 calling getBundleInfo() for the entity types that it needs.

    I personally think this is the best suggestion so far. Would love to hear thoughts on this!

Production build 0.71.5 2024