Proposed for the first time in #2996637: [META] Improve collection and include performance.

Currently, included items are resolved for each entity in a response. This means that for a collection endpoint, like /jsonapi/node/article, with 50 resources. 50 separate node loads will be executed for ?include=uid, 50 more for ?include=uid,node_type.

This is because includes are resolved as each entity is normalized.

A corollary to this is that include logic is centered on the idea of normalizing entire JSON API top-level documents to share logic.

Ideally, entity loads would be aggregated as much as possible to reduce DB round-trips and only the resources themselves would be normalized without also running all the other logic associated with the top-level document in a recursive process.

Comments

gabesullice created an issue. See original summary.

gabesullice’s picture

Status: Active » Needs review
StatusFileSize
new54.85 KB
new66.51 KB

This depends on #2996000: Deduplicate `meta.omitted.links` based on `href` (`via` link).

I'll provide a self-review of this patch to help explain it in my next comment.

The last submitted patch, 2: 2997600-2.patch, failed testing. View results

Status: Needs review » Needs work

The last submitted patch, 2: 2997600-2.combined.patch, failed testing. View results

gabesullice’s picture

  1. +++ b/src/Controller/EntityResource.php
    @@ -123,12 +137,12 @@ class EntityResource {
    -  public function getIndividual(ResourceType $resource_type, EntityInterface $entity) {
    +  public function getIndividual(ResourceType $resource_type, EntityInterface $entity, Request $request) {
    

    Since includes are now being resolved prior to normalization, we need access to the include parameter. Hence, we add the Request object.

  2. +++ b/src/Controller/EntityResource.php
    @@ -123,12 +137,12 @@ class EntityResource {
    -    $response = $this->buildWrappedResponse($entity);
    +    $response = $this->buildWrappedResponse($entity, $this->getIncludes($request, $entity));
    

    Now, the primary data and includes are passed as an explicit argument to buildWrappedResponse.

  3. +++ b/src/Controller/EntityResource.php
    @@ -385,7 +399,7 @@ class EntityResource {
    -    $response = $this->respondWithCollection($entity_collection, $entity_type_id);
    +    $response = $this->respondWithCollection($resource_type, $entity_collection, $this->getIncludes($request, $entity_collection));
    

    The same is true for respondWithCollection

  4. +++ b/src/Controller/EntityResource.php
    @@ -872,6 +898,30 @@ class EntityResource {
    +  public function getIncludes(Request $request, $data, $related_field = NULL) {
    +    return $request->query->has('include') && !empty($include_parameter = $request->query->get('include', ''))
    +      ? $this->includeResolver->resolve($request->get(Routes::RESOURCE_TYPE_KEY), $data, $include_parameter, $related_field)
    +      : NULL;
    +  }
    

    This is what resolves includes in the controller. We only enter the resolution process if the request actually has an include parameter and it's not empty.

  5. +++ b/src/Exception/EntityAccessDeniedHttpException.php
    @@ -8,15 +8,19 @@ use Drupal\Core\Cache\CacheableMetadata;
    +use Drupal\jsonapi\JsonApiResource\ResourceIdentifier;
    +use Drupal\jsonapi\JsonApiResource\ResourceIdentifierInterface;
    +use Drupal\jsonapi\JsonApiResource\ResourceIdentifierTrait;
    
    1. The spec says that included must not contain duplicate resources.
    2. This patch resolves includes into an EntityCollection.
    3. It makes sense for EntityCollection to then have a method to deduplicate itself.
    4. EntityCollection can receive objects that are not entities, but have a 1:1 correspondence with an entity. Like a LabelOnlyEntity or an EntityAccessDeniedHttpException.
    5. Therefore, EntityCollection needs a uniform interface that it can use to compare and deduplicate its items.
    6. This patch introduces a ResourceIdentifierInterface for that purpose and a trait that makes it easy to implement.
  6. +++ b/src/IncludeResolver.php
    @@ -0,0 +1,220 @@
    +/**
    + * Resolves included resources for an entity or collection of entities.
    + *
    + * @internal
    + */
    +class IncludeResolver {
    ...
    +  public function resolve(ResourceType $base_resource_type, $data, $include_parameter, $related_field = NULL) {
    ...
    +    $include_tree = static::toIncludeTree($base_resource_type, $include_parameter, $related_field);
    +    return EntityCollection::deduplicate($this->resolveIncludeTree($include_tree, $entity_collection));
    +  }
    

    The include resolver works with a 3 phased approach.

    1. First, it parses the include query parameter into a tree of field names.
    2. Then, it recursively descends that tree of field names against an initial EntityCollection. As it descends the tree, it loads targeted entities and adds them to a resolved EntityCollection.
    3. Finally, before it returns the resolved EntityCollection it deduplicates it.
  7. +++ b/src/JsonApiResource/EntityCollection.php
    @@ -144,4 +144,42 @@ class EntityCollection implements \IteratorAggregate, \Countable {
    +  public static function merge(EntityCollection $a, EntityCollection $b) {
    

    This is how the include resolver appends newly resolved includes to an EntityCollection

  8. +++ b/src/JsonApiResource/EntityCollection.php
    @@ -144,4 +144,42 @@ class EntityCollection implements \IteratorAggregate, \Countable {
    +  public static function deduplicate(EntityCollection $collection) {
    ...
    +        $deduplicated[$resource_identifier->getTypeName() . ':' . $resource_identifier->getId()] = $resource;
    ...
    +        $deduplicated[$resource->getTypeName() . ':' . $resource->getId()] = $resource;
    

    This is how the ResourceIdentifierInterface is used to deduplicate includes.

  9. +++ b/src/JsonApiResource/JsonApiDocumentTopLevel.php
    @@ -10,7 +10,7 @@ namespace Drupal\jsonapi\JsonApiResource;
    - * @todo Add support for the missing optional members: 'jsonapi', 'links' and 'included' or document why not.
    + * @todo Add support for the missing optional members: 'jsonapi' and 'links' or document why not.
    

    It seems that we're slowly getting rid of all this :D

  10. +++ b/src/JsonApiResource/JsonApiDocumentTopLevel.php
    @@ -21,15 +21,27 @@ class JsonApiDocumentTopLevel {
    +    assert(!$data instanceof ErrorCollection || is_null($includes));
    

    This assert is unnecessary. Will remove it.

  11. +++ b/src/Normalizer/EntityNormalizer.php
    @@ -9,8 +9,6 @@ use Drupal\Core\Field\FieldTypePluginManagerInterface;
    -use Drupal\jsonapi\Normalizer\Value\IncludeOnlyRelationshipNormalizerValue;
    -use Drupal\jsonapi\Normalizer\Value\NullFieldNormalizerValue;
    
    +++ b/src/Normalizer/RelationshipItemNormalizer.php
    @@ -5,9 +5,7 @@ namespace Drupal\jsonapi\Normalizer;
    -use Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel;
    ...
    -use Drupal\jsonapi\Controller\EntityResource;
    
    @@ -50,27 +48,15 @@ class RelationshipItemNormalizer extends FieldItemNormalizer {
    -    $target_entity = $relationship_item->getTargetEntity();
    ...
    -    if (!empty($context['include']) && in_array($host_field_name, $context['include']) && $target_entity !== NULL) {
    
    +++ b/src/Normalizer/Value/EntityNormalizerValue.php
    @@ -67,24 +60,12 @@ class EntityNormalizerValue implements ValueExtractorInterface, CacheableDepende
    -    // Gather includes from all normalizer values, before filtering away null
    -    // and include-only normalizer values.
    -    $this->includes = array_map(function ($value) {
    -      return $value->getIncludes();
    -    }, $values);
    ...
    -    $this->includes = array_reduce($this->includes, function ($carry, $includes) {
    -      return array_merge($carry, $includes);
    -    }, []);
    ...
    -    $this->includes = array_filter($this->includes);
    @@ -113,16 +94,6 @@ class EntityNormalizerValue implements ValueExtractorInterface, CacheableDepende
    -  public function rasterizeIncludes() {
    
    @@ -133,19 +104,4 @@ class EntityNormalizerValue implements ValueExtractorInterface, CacheableDepende
    -  public function getIncludes() {
    -    $nested_includes = array_map(function ($include) {
    -      return $include->getIncludes();
    -    }, $this->includes);
    -    return array_reduce(array_filter($nested_includes), function ($carry, $item) {
    -      return array_merge($carry, $item);
    -    }, $this->includes);
    
    +++ b/src/Normalizer/Value/FieldNormalizerValue.php
    @@ -60,13 +53,6 @@ class FieldNormalizerValue implements FieldNormalizerValueInterface {
    -    $this->includes = array_map(function ($value) {
    -      if (!$value instanceof RelationshipItemNormalizerValue) {
    -        return NULL;
    -      }
    -      return $value->getInclude();
    -    }, $values);
    -    $this->includes = array_filter($this->includes);
    
    @@ -90,22 +76,6 @@ class FieldNormalizerValue implements FieldNormalizerValueInterface {
    -  public function rasterizeIncludes() {
    -    return array_map(function ($include) {
    -      return $include->rasterizeValue();
    -    }, $this->includes);
    -  }
    ...
    -  public function getIncludes() {
    -    return $this->includes;
    -  }
    @@ -113,22 +83,4 @@ class FieldNormalizerValue implements FieldNormalizerValueInterface {
    -  public function getAllIncludes() {
    -    $nested_includes = array_map(function ($include) {
    -      return $include->getIncludes();
    -    }, $this->getIncludes());
    -    $includes = array_reduce(array_filter($nested_includes), function ($carry, $item) {
    -      return array_merge($carry, $item);
    -    }, $this->getIncludes());
    -    // Make sure we don't output duplicate includes.
    -    return array_values(array_reduce($includes, function ($unique_includes, $include) {
    -      $rasterized_include = $include->rasterizeValue();
    -      $unique_includes[$rasterized_include['data']['type'] . ':' . $rasterized_include['data']['id']] = $include;
    -      return $unique_includes;
    -    }, []));
    -  }
    
    +++ b/src/Normalizer/Value/FieldNormalizerValueInterface.php
    @@ -11,14 +11,6 @@ use Drupal\Core\Cache\CacheableDependencyInterface;
    -  public function getIncludes();
    
    @@ -27,12 +19,4 @@ interface FieldNormalizerValueInterface extends ValueExtractorInterface, Cacheab
    -  public function getAllIncludes();
    
    +++ /dev/null
    @@ -1,32 +0,0 @@
    -class IncludeOnlyRelationshipNormalizerValue extends FieldNormalizerValue {
    
    +++ b/src/Normalizer/Value/JsonApiDocumentTopLevelNormalizerValue.php
    @@ -109,16 +112,11 @@ class JsonApiDocumentTopLevelNormalizerValue implements ValueExtractorInterface,
    -      $this->includes = array_map(function ($value) {
    -        return $value->getIncludes();
    -      }, $values);
    ...
    -      $this->includes = array_reduce($this->includes, function ($carry, $includes) {
    -        array_walk($includes, [$this, 'addCacheableDependency']);
    -        return array_merge($carry, $includes);
    -      }, []);
    @@ -246,42 +224,13 @@ class JsonApiDocumentTopLevelNormalizerValue implements ValueExtractorInterface,
    -  public function getIncludes() {
    -    $nested_includes = array_map(function ($include) {
    -      return $include->getIncludes();
    -    }, $this->includes);
    -    $includes = array_reduce(array_filter($nested_includes), function ($carry, $item) {
    -      return array_merge($carry, $item);
    -    }, $this->includes);
    -    // Make sure we don't output duplicate includes.
    -    return array_values(array_reduce($includes, function ($unique_includes, $include) {
    -      $rasterized_include = $include->rasterizeValue();
    -
    -      if (empty($rasterized_include['data'])) {
    -        $unique_includes[] = $include;
    -      }
    -      else {
    -        $unique_key = $rasterized_include['data']['type'] . ':' . $rasterized_include['data']['id'];
    -        $unique_includes[$unique_key] = $include;
    -      }
    -      return $unique_includes;
    -    }, []));
    -  }
    
    +++ b/src/Normalizer/Value/NullFieldNormalizerValue.php
    @@ -36,13 +36,6 @@ class NullFieldNormalizerValue implements FieldNormalizerValueInterface {
    -  public function getIncludes() {
    
    @@ -57,18 +50,4 @@ class NullFieldNormalizerValue implements FieldNormalizerValueInterface {
    -  public function rasterizeIncludes() {
    ...
    -  public function getAllIncludes() {
    
    +++ b/src/Normalizer/Value/RelationshipItemNormalizerValue.php
    @@ -40,17 +33,10 @@ class RelationshipItemNormalizerValue extends FieldItemNormalizerValue implement-    assert($include === NULL || $include instanceof EntityNormalizerValue || $include instanceof JsonApiDocumentTopLevelNormalizerValue || $include instanceof HttpExceptionNormalizerValue);
    ...
    -    if ($include !== NULL) {
    -      $this->setCacheability(static::mergeCacheableDependencies([$include, $values_cacheability]));
    -    }
    
    @@ -72,21 +58,4 @@ class RelationshipItemNormalizerValue extends FieldItemNormalizerValue implement
    -  public function rasterizeIncludes() {
    ...
    -  public function getInclude() {
    +++ b/src/Normalizer/Value/ValueExtractorInterface.php
    @@ -17,12 +17,4 @@ interface ValueExtractorInterface {
    -  public function rasterizeIncludes();
    

    This patch let's us eliminate SOO much complexity from the normalization process 🤩. I didn't even highlight all of it.

    A lot of that complexity to the include resolver, but hopefully having it all in one place will make the whole system easier to reason about, profile, and maintain.

gabesullice’s picture

WOOT, #2 only has Kernel and Unit test failures!

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new12.44 KB
new67.29 KB
new78.94 KB

This cleans up all the unit tests.

The last submitted patch, 7: 2997600-7.patch, failed testing. View results

Status: Needs review » Needs work

The last submitted patch, 7: 2997600-7.combined.patch, failed testing. View results

gabesullice’s picture

Assigned: gabesullice » Unassigned
Status: Needs work » Needs review
StatusFileSize
new75.35 KB
new87.28 KB

The last submitted patch, 10: 2997600-10.patch, failed testing. View results

Status: Needs review » Needs work

The last submitted patch, 10: 2997600-10.combined.patch, failed testing. View results

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new1.53 KB
new75.85 KB
new88.63 KB

The last submitted patch, 13: 2997600-13.patch, failed testing. View results

gabesullice’s picture

I reached out to a few individuals who I thought may have the infrastructure in place to run some performance benchmarks against this patch (@btully, @caseylau, @mglaman, @sime & @steven.wichers)

I'm excited to see all of their results, but I have some initial results to share.

@mglaman was kind enough to set up two environments based on the 2.x-dev branch, one without the patch applied and one with it applied. He also provided test data and a testing URL that emulates a typical commerce site. That data came from Commerce Demo. On those applications, both page_cache and dynamic_page_cache were uninstalled so that each request would thoroughly exercise the changes in the patch.

That URL was: https://{environment_hostname}/jsonapi/commerce_product/simple?include=field_brand,field_product_categories,field_special_categories,stores,variations&fields[taxonomy--term]=name

I used Postman to run 100 requests against each environment from my local machine.

I've compiled the results here: https://docs.google.com/spreadsheets/d/1OkB-MdDXPt3o_v6oxIKx7ZCDnDpFa9y5...

On average, the request took 5.77 seconds to complete without the patch in #13. With the patch applied, the request took an average of 4.08 seconds to complete. These times include network round trips from my local to the testing environment and back.

This means that the patch reduced average request time by 29.19%, or, put differently, the requests were completed 41.22% faster.

Given that these results include network latency, I believe the performance improvement is underestimated.

lawxen’s picture

Status: Needs review » Needs work

The patch 2997600-13.combined.patch has some bugs which make me can't test the performance.

One of the bug description:
1. one field reference two different bundle, the two different bundle's 'XXX' field reference two different entity type A and B, then nested include A's exclusive field, then an error: detail": "Field bundle_items is unknown.","Field attribute_spicy is unknown." .........

Ps: I intend to test a very complexed url(include many nested include) which use 3.2min on jsonapi:2.x-dev originally.

gabesullice’s picture

@caseylau, does that bug exist without the patch on 2.x-dev?

lawxen’s picture

does that bug exist without the patch on 2.x-dev?

@gabesullice I have tested it many times, and confirmed that the bug doesn't exist on newest 2.x-dev, only exist after apply patch 2997600-13.combined.patch

Maybe I can reproduced it through drupal core's entity: "comment" after today.

lawxen’s picture

@gabesullice
Aha, I have reproduced above bug on drupal core

Steps:

  1. Create an vocabulary: fruit
  2. Add a entity_reference field:field_node(multi values) on fruit, reference "node" and target to article and basic_page
  3. Create an article and and basic_page
  4. Create an taxonomy term of fruit, the field field_node set two values, one article and one basic_page
  5. GET /jsonapi/taxonomy_term/fruit?include=field_node,field_node.field_tags
    Error:
    "title": "Internal Server Error",
    "status": 500,
    "detail": "Field field_tags is unknown.",
wim leers’s picture

@gabesullice++
@mglaman++
@caseylau++

Per #20, the current patch doesn't resolve nested references.

gabesullice’s picture

Per #20, the current patch doesn't resolve nested references.

That is very unlikely because we have explicit test coverage for that.

I think this is a more subtle regression than that. Namely that more than one bundle will end up in the EntityCollection and that some of those bundles may not have all of the same fields.

@Wim Leers, remember this?

// @todo: see if we can live without this.
if (!$entity->hasField($field_name)) {
  continue;
}

My suspicion is that the answer is no, we can't :P

I'm going to try to replicate the issue in #20 in a moment.

gabesullice’s picture

StatusFileSize
new823 bytes
new76.15 KB

I was able to reproduce the bug described in #20.

It was indeed what I suspected in #22.

Here's a simple fix :)


#2996000: Deduplicate `meta.omitted.links` based on `href` (`via` link) landed! So we don't need a combined patch anymore.

gabesullice’s picture

@caseylau's example URL has something strange in it.

/jsonapi/taxonomy_term/fruit?include=field_node,field_node.field_tags

It needs both field_node and field_node.field_tags. But with this patch applied, it shouldn't need field_node to work. So, why is it needed? Because using field_node.field_tags alone does not work in HEAD. In fact, we already have an old issue for that: #2946537: Test coverage: Inclusion of intermediate resources when include is a multi-part relationship path

Manual testing should show that this patch does not require the intermediate field.

I tried to apply the test coverage from #2946537 and found out that it's incomplete. The actual results are correct, but the expectations are wrong.

gabesullice’s picture

@btully, just shared some results with me. While I can't share the details of project, I can generally summarize the initial results.

He and @steven.wichers's data is primarily focused on testing concurrent users. But out of that, we can extract average response times before and after the patch was applied.

The initial data that I've received is for requests with no includes. That means we should probably not expect much improvement there.

That said, we actually did see some improvement. Average response time dropped from 3.29 seconds to 3.14 (π!). That's 4.8% faster. It's not very significant, but it's useful because it tells us that it didn't make non-include requests slower 👍.

Other observations:

@btully shared a link to New Relic with me. That let me drill down and explore what PHP calls took the most time. Two things jumped out at me...

First, nearly half the time was spent in getAccessCheckedEntity. While we can't really do anything to help with that, we can perhaps make some recommendations in the module docs about it (e.g. make use of static caching wherever possible).

Second, the other half of the time was spent in the normalization process. Not surprising. JSON API is primarily a module that normalizes data into JSON. What was surprising is that a large part of that time was spent normalizing (and computing) the processed text property. Again, we can't do a ton about that, but we may want to consider a Drupal core issue to somehow make this a stored value instead a computed property (I realize that would be very difficult since one can have any number of different text formats). If not, suggest that users disable or exclude it if it's not necessary.

@btully is running a second set of benchmarks that do have includes. I'll report those results soon.

lawxen’s picture

Issue summary: View changes
StatusFileSize
new670.24 KB

@gabesullice The patch is so so so so so fast!!! I'm extremely surprised and excited,I can't wait to update.
before the patch the jsonapi:2.x-dev needs 2.7min to get the result, but after the patch just 13.38s,

  1. 12.1 times faster than "drupal/jsonapi": "2.x-dev",
  2. 3.2 times faster than "drupal/jsonapi": "1.x-dev#dd790e1fde267b121a4a34eb367d588019ea9c0c",

I test several times, each time was request after clear the cache, and the test result is same.

Test URL:

/jsonapi/group_content/business-price_list-default/abc79b29-efe8-4677-9d71-1ebb0dda8289?include=entity_id,entity_id.field_price_list_item,entity_id.field_price_list_item.purchased_entity,entity_id.field_price_list_item.purchased_entity.attribute_other_flavor,entity_id.field_price_list_item.purchased_entity.attribute_size,entity_id.field_price_list_item.purchased_entity.attribute_spicy,entity_id.field_price_list_item.purchased_entity.attribute_sweetness,entity_id.field_price_list_item.purchased_entity.attribute_temperature,entity_id.field_price_list_item.purchased_entity,entity_id.field_price_list_item.purchased_entity.product_id,entity_id.field_price_list_item.purchased_entity.product_id.field_product_vocabulary,entity_id.field_price_list_item.purchased_entity.product_id.field_product_img,entity_id.field_price_list_item.purchased_entity.bundle_items,entity_id.field_price_list_item.purchased_entity.field_bundle_vocabulary,entity_id.field_price_list_item.purchased_entity.field_product_bundle_img

gabesullice’s picture

@caseylau, thanks! And.. wow. I did not expect that!

It does raise the question, why is 2.x is so much slower than JSON API 1.x?

Regardless, that's a tremendous improvement. Thanks for sharing your results!

lawxen’s picture

It does raise the question, why is 2.x is so much slower than JSON API 1.x?

This is really true, and this is one of the reason that we are still use jsonapi-1.x, because I always get "502 bad getway" after update to jsonapi2.x(I haven't realized that it was the performance problem of not getting the result untill today).
I spend half an hour to adjust the nginx/php settings to make request not 502 on jsonapi-2.x-dev(no patch applied).

gabesullice’s picture

Status: Needs work » Needs review

@btully, shared his results with me last evening.

Prior to applying the patch, response times were 8.64s. After applying the patch, they dropped to 6.3s. That's 27% less response time or just over 37% faster. That roughly matches my results in #18. It's certainly not as dramatic as @caseylau's results, but that's somewhat logical, @caseylau's request has a lot more includes than @btully's and my request.

Another thing to point out... remember that without includes, the request took 3.14s in #27. Almost exactly half the time. Looking at @btully's query, I suspect that he has about an equal number of included resources as he does primary resources. Which means that the cost of includes has been dramatically reduced. What we're seeing here is a relatively linear scaling between the number of resources serialized and the response time. @caseylau's results show that without the patch, it's much worse than that.

IOW, with this patch, includes no longer bear a significant penalty.

gabesullice’s picture

Given all these results, I think this is a valuable change. Let's try to land it 🙂

Afterward, we can attack the normalization process and find improvements there.

Status: Needs review » Needs work

The last submitted patch, 23: 2997600-23.patch, failed testing. View results

e0ipso’s picture

This is an awesome improvement, as noted elsewhere. We already suspected this, but it's nice to validate the suspicions. I can't wait for this to get into 2.x

After reading #28 I suspect that we introduced a performance penalty somehwere between 1.x-dev and 2.x-dev. Even though this patch overcomes that problem, we should investigate it and solve it as well. I suspect we can get a juicy improvement as well.

@caseylau can you create a new ticket with the info you have so we can investigate the problem?

gabesullice’s picture

Status: Needs work » Needs review
lawxen’s picture

@e0ipso sure, because I can't public our company's code, so I will use some modules we used to build a clean drupal8 site for test on github, give me several hours

e0ipso’s picture

Status: Needs review » Needs work

Partial review. Sorry I had to drop the review mid patch. Hopefully these are good pointers. Overall this is a fantastic patch. I'm very excited!

  1. +++ b/src/Context/FieldResolver.php
    @@ -154,10 +154,9 @@ class FieldResolver {
    +    if (empty($path_parts) || empty($public_field_name = $path_parts[0])) {
    

    This is way too vernacular for my taste. Can we avoid assignments as parameters of function/method invocations?

  2. +++ b/src/Controller/EntityResource.php
    @@ -387,7 +399,7 @@ class EntityResource {
    -    $response = $this->respondWithCollection($entity_collection, $resource_type, $request);
    +    $response = $this->respondWithCollection($request, $resource_type, $entity_collection, $this->getIncludes($request, $entity_collection));
    

    Why the change in the order of params?

  3. +++ b/src/Controller/EntityResource.php
    @@ -458,9 +470,9 @@ class EntityResource {
    -    /* @var \Drupal\Core\Field\FieldItemListInterface $field_list */
    +    /* @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $field_list */
    

    What about LanguageItem, that is also a DataReference according to TypedData.

  4. +++ b/src/Controller/EntityResource.php
    @@ -804,6 +816,9 @@ class EntityResource {
    +   * @param \Drupal\jsonapi\JsonApiResource\EntityCollection|null $includes
    +   *   The resources to be included in the document, or NULL if there should
    +   *   be no included resources.
    
    @@ -816,25 +831,29 @@ class EntityResource {
    +    assert($includes instanceof EntityCollection || is_null($includes));
    

    I think we should implement a NullEntityCollection (the null design pattern) instead of polymorphic arguments.

  5. +++ b/src/Controller/EntityResource.php
    @@ -816,25 +831,29 @@ class EntityResource {
    -   * @param \Drupal\jsonapi\JsonApiResource\EntityCollection $entity_collection
    -   *   The collection of entites.
    -   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
    -   *   The base JSON API resource type for the request to be served.
    ...
    +   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
    +   *   The base JSON API resource type for the request to be served.
    +   * @param \Drupal\jsonapi\JsonApiResource\EntityCollection $entity_collection
    +   *   The collection of entites.
    +   * @param \Drupal\jsonapi\JsonApiResource\EntityCollection $includes
    +   *   (optional) The resources to be included in the document, or NULL if there
    +   *   should be no included resources.
    

    Again, the parameter reorder seems unnecessary. Specially if $entity_collection is no longer optional.

  6. +++ b/src/Controller/EntityResource.php
    @@ -893,6 +912,30 @@ class EntityResource {
    +    return $request->query->has('include') && !empty($include_parameter = $request->query->get('include', ''))
    

    Let's avoid assignments inside of empty() calls.

  7. +++ b/src/IncludeResolver.php
    @@ -0,0 +1,226 @@
    + * Resolves included resources for an entity or collection of entities.
    

    Let's try to simplify this service and make it deal only with collections of entities. We can wrap a single entity as a collection of one.

  8. +++ b/src/IncludeResolver.php
    @@ -0,0 +1,226 @@
    +    $entity_collection = $data instanceof EntityInterface ? new EntityCollection([$data], 1) : $data;
    

    Ha! you already did this :-D

  9. +++ b/src/IncludeResolver.php
    @@ -0,0 +1,226 @@
    +    return EntityCollection::deduplicate($this->resolveIncludeTree($include_tree, $entity_collection));
    

    Let's use https://secure.php.net/manual/en/class.ds-set.php instead.

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new12.06 KB
new77.17 KB

Partial review. Sorry I had to drop the review mid patch.

I'll bet you had a good excuse ;) Don't worry about it!

1. heh, that was a little too clever wasn't it? Honestly, that was a "leftover" from some bug hunting that I did. Fixed.
2. That's just what I thought made most sense when I was resolving conflict during a rebase. I'll reorder it to better respect HEAD. Having the NullEntityCollection you suggest will make it nicer since the optional arg will go away (meaning it doesn't need to be the final arg).
3. AFAICT, we always treat the langcode field as an attribute, not a relationship. That means that this function will never be called because there's not a related route for it.
4. Done. I really like that :)
5. Yep. Done.
6. Done.
7+8: :D
9. That's a PHP7 only thing. Can we make a followup issue to convert this using a polyfill? We could do that under the ForwardCompatibility namespace.

gabesullice’s picture

StatusFileSize
new77.47 KB

Eww, I generated that last patch with -M to make a smaller patch, but it's unreadable. Here's a more readable version.

The last submitted patch, 38: 2997600-38.patch, failed testing. View results

gabesullice’s picture

StatusFileSize
new2.42 KB
new77.92 KB

Fixed.

lawxen’s picture

@e0ipos @gabesullice
In order not to public our company's code, I use drupal8.6 and 4 contribute modules: commerce commerce_pricelist commerce_product_bundle group to build a clean test site, it doesn't has any other function except the test data.

https://github.com/caseylau/jsonapi-performance-test

Used for test performance between 1.x vs 2.x vs this patch

The last submitted patch, 39: 2997600-38.regenerated.patch, failed testing. View results

gabesullice’s picture

Thank you SO much @caseylau. That was incredibly useful!

I set up the site you provided then used git bisect to find the commit which introduced the performance regression. That gave me this: db8111aff492a7d7ff9ab1b14b10bd74ca6de49e is the first bad commit

So, that means that the regression was introduced in #2948666: Remove JSON API's use of $context['cacheable_metadata'].

Checking out that version and the one immediately prior to it, I isolated the issue. Before that commit, includes were rasterized in JsonApiDocumentTopLevelNormalizer. After that commit, rasterization of includes was moved into JsonApiDocumentTopLevelNormalizerValue::rasterizeValue().

Why did that cause an issue?

Because JsonApiDocumentTopLevelNormalizer::normalize() used to have these lines:

$value_extractor = $this->buildNormalizerValue($object->getData(), $format, $context);
if (isset($context['is_include_normalization']) && $context['is_include_normalization'] === TRUE) {
  return $value_extractor;
}

What those lines did was cause an early return just before includes are rasterized.

After #2948666, include rasterization was moved to JsonApiDocumentTopLevelNormalizerValue::rasterizeValue(). That method is called recursively from the top level object. This recursive call meant that nested includes were rasterized multiple times. At least as many times as there were levels of includes. IOW, an include 3 levels deep would be rasterized at least 3 separate times.


That is where I stopped.

This patch eliminates recursive rasterization and therefore eliminates that performance regression, explaining the 12x improvement @caseylau saw in #28.1.

The remaining 3x improvement is likely due to the fact that we're not normalizing duplicate includes and because we're aggregating entity loads rather than loading includes 1 by 1.

wim leers’s picture

StatusFileSize
new8.02 KB
new80.56 KB

#23: great — and thanks for that very helpful comment!
#27:

What was surprising is that a large part of that time was spent normalizing (and computing) the processed text property.

This makes sense: if you're dealing with lots of includes, that'd mean computing this property for lots of resources.
#28: 😲 🎉
#34:

After reading #28 I suspect that we introduced a performance penalty somehwere between 1.x-dev and 2.x-dev. Even though this patch overcomes that problem, we should investigate it and solve it as well. I suspect we can get a juicy improvement as well.

+1!
#37.9: 😍 I can't wait to use this! I didn't even know this was in PHP7!
#42: WOW! Thank you so much, @caseylau! 🙏 ❤️
#44: Thanks for doing a bisection! And an include 3 levels deep would be rasterized at least 3 separate times. makes me go 😱😱😭. Thanks for digging in! I don't yet see how exactly the recursion (which is intentional, since JSON API was designed years ago to have a tree of value objects that are rasterized at the last possible moment) causes the same rasterization to happen multiple times.


Patch review time!

I like that the diffstat is pretty much a net zero change, despite this increasing code clarity and adding quite a bit of code docs.

Thanks again for #5, that really helped a lot when reviewing this patch! It answered some of the questions I'd otherwise have asked.

There's some weird stuff in the patch though: deletions are omitted somehow?! EDIT: it seems dreditor is doing seriously weird things 🙃 It's omitting all deletions for me. Which makes it hard to comment on them…

  1. +++ b/jsonapi.services.yml
    @@ -113,6 +113,9 @@ services:
    +  jsonapi.include_resolver:
    +    class: Drupal\jsonapi\IncludeResolver
    +    arguments: ['@entity_type.manager']
    

    Let's make it private.

  2. +++ b/src/Controller/EntityResource.php
    @@ -893,6 +911,30 @@ class EntityResource {
    +   *   The response data from which to resolve includes.
    

    Nit: s/from/for/ I think?

  3. +++ b/src/IncludeResolver.php
    @@ -0,0 +1,226 @@
    +        $targeted_entities = $entity_storage->loadMultiple(array_unique($ids));
    

    This alone is a huge performance win! Far fewer DB queries. 🎉

  4. +++ b/src/IncludeResolver.php
    @@ -0,0 +1,226 @@
    +   * @param string $related_field
    +   *   A relationship field name if the includes are being resolved on a
    ...
    +      $resolved_paths = FieldResolver::resolveInternalIncludePath($base_resource_type, $related_field ? array_merge([$related_field], $exploded_path) : $exploded_path);
    

    Nit: Doesn't this mean that $related_field can be NULL?

    EDIT: yep, by tracing the call possible callers, I confirmed this.

  5. +++ b/src/JsonApiResource/EntityCollection.php
    @@ -144,4 +144,42 @@ class EntityCollection implements \IteratorAggregate, \Countable {
    +  public static function merge(EntityCollection $a, EntityCollection $b) {
    ...
    +  public static function deduplicate(EntityCollection $collection) {
    

    New meaningful actions/helpers on the value object! 👌

  6. +++ b/src/JsonApiResource/JsonApiDocumentTopLevel.php
    @@ -11,7 +11,7 @@ use Drupal\Component\Assertion\Inspector;
    - * @todo Add support for the missing optional members: 'jsonapi' and 'included' or document why not.
    + * @todo Add support for the missing optional 'jsonapi' member or document why not.
    

    🎉

  7. +++ b/src/JsonApiResource/JsonApiDocumentTopLevel.php
    @@ -36,22 +36,34 @@ class JsonApiDocumentTopLevel {
    +   *   response document or NULL if there should not be includes.
    

    NullEntityCollection

  8. +++ b/src/JsonApiResource/JsonApiDocumentTopLevel.php
    @@ -36,22 +36,34 @@ class JsonApiDocumentTopLevel {
    +    assert(!$data instanceof ErrorCollection || $includes instanceof NullEntityCollection);
    

    I had to re-read this a few times. It makes sense though: Either the primary data is NOT an error collection, or it is, and if it is, then the includes must be the NULL collection. (Since errors can't have includes.)

  9. +++ b/src/JsonApiResource/JsonApiDocumentTopLevel.php
    @@ -86,4 +98,26 @@ class JsonApiDocumentTopLevel {
    +  public function hasIncludes() {
    ...
    +  /**
    +   * Gets an EntityCollection of resources to be included in the response.
    +   *
    +   * @return \Drupal\jsonapi\JsonApiResource\EntityCollection|null
    +   *   An EntityCollection object or NULL if there are none. Callers should
    +   *   guard this method by first calling self::hasIncludes().
    +   */
    +  public function getIncludes() {
    +    assert($this->hasIncludes());
    +    return $this->includes;
    +  }
    

    Why not allow NullEntityCollection to be returned? Then we don't need any of this logic? NULL can then not ever be returned?

    Then hasIncludes() becomes obsolete?

    I think this is something you just forgot to do in #38 :)

  10. +++ b/src/JsonApiResource/ResourceIdentifierInterface.php
    @@ -0,0 +1,33 @@
    + * Implement this interface when an object is a stand-in for an Entity object.
    

    I like this!

  11. +++ b/src/Normalizer/JsonApiDocumentTopLevelNormalizer.php
    @@ -181,14 +181,28 @@ class JsonApiDocumentTopLevelNormalizer extends NormalizerBase implements Denorm
    +    $includes = $omissions = $object->hasIncludes() ? [] : FALSE;
    +    if ($includes !== FALSE) {
    +      foreach ($object->getIncludes() as $include) {
    +        $include instanceof EntityAccessDeniedHttpException
    +          ? $omissions[] = $serializer->normalize($include, $format, $context)
    +          : $includes[] = $serializer->normalize($include, $format, $context);
    +      }
    +    }
    

    😲🙈

    Why does it make sense for $omissions to ever be set to FALSE?

    I think you meant to update this too in #38 but just forgot :)

  12. RelationshipItemNormalizer became so much simpler! No more "subcontext" stuff! 🎉 Your changes made me notice one more weird bug/leftover in that area: RelationshipItemNormalizerValue's $resource constructor parameter. Fixed.
  13. EntityNormalizerValue and FieldNormalizerValue became SOOO much simpler too!
  14. No more rasterizeIncludes()! Excellent! There's only one left: \Drupal\jsonapi\Normalizer\Value\JsonApiDocumentTopLevelNormalizerValue::rasterizeIncludes().
  15. And RelationshipItemNormalizerValue no longer implements ValueExtractorInterface at all. Interesting … because a rasterizeValue() implementation is still there. This seems wrong?

Attached patch addresses all points except for the last one.

Status: Needs review » Needs work

The last submitted patch, 45: 2997600-45.patch, failed testing. View results

wim leers’s picture

Status: Needs work » Needs review
StatusFileSize
new603 bytes
new80.54 KB

Gah, I can't wait for Drupal to update its Symfony version so we can finally do https://www.tomasvotruba.cz/blog/2018/05/17/how-to-test-private-services....

gabesullice’s picture

StatusFileSize
new670 bytes
new78.19 KB

Thanks for the thorough review!

I agree with all your changes. (And I also lament that fact that private services are such a PITA!)

#45.15 was a good catch. Restored the implementation.


IMHO, this is ready for commit. We've had thorough review and approval by all three maintainers, more community review and validation than any other issue in the module's history, and we've simplified and made the codebase faster. YEAH!

wim leers’s picture

Status: Needs review » Reviewed & tested by the community

Agreed. Let's get this in :)

  • Wim Leers committed 4c23dce on 8.x-2.x authored by gabesullice
    Issue #2997600 by gabesullice, Wim Leers, caseylau, e0ipso, btully,...
wim leers’s picture

Status: Reviewed & tested by the community » Fixed

  • Wim Leers committed c75de09 on 8.x-2.x authored by gabesullice
    Issue #3001564 by gabesullice, Wim Leers: Follow-up to #2997600: Clean...

Status: Fixed » Closed (fixed)

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