Problem/Motivation

There are some problems here that can be handled here or tracked in their designated issues.
If requested like so, I will move them there ant make this a tracking one...

This is happening when the list of includes that needs to be validates is (in my case) 84 items and average tree depth is 4. The request URL is essentially:

http://d8.test/jsonapi/node/article?include=field_image,field_image.field_media_image,field_content,field_content.field_custom_content,field_content.field_custom_content.field_image,field_content.field_custom_content.field_image.field_media_image,field_content.field_custom_content.field_image.field_media_provider_api_image,field_content.field_news,field_content.field_news.field_image,field_content.field_news.field_image.field_media_image,field_content.field_news.field_image.field_media_provider_api_image,field_content.field_gallery,field_content.field_person,field_content.field_person.field_image,field_content.field_person.field_image.field_media_image,field_content.field_person.field_image.field_media_provider_api_image,field_content.field_person.field_phone_number,field_content.field_files,field_content.field_files.field_media_file,field_content.field_products,field_content.field_products.field_product,field_content.field_left_video,field_content.field_left_image,field_content.field_left_image.field_media_image,field_content.field_left_image.field_media_provider_api_image,field_content.field_right_video,field_content.field_right_image,field_content.field_right_image.field_media_image,field_content.field_right_image.field_media_provider_api_image,field_content.field_list_topics,field_content.field_list_topics.field_topics,field_content.field_list_topics.field_topics.field_image,field_content.field_list_topics.field_topics.field_image.field_media_image,field_content.field_list_topics.field_topics.field_image.field_media_provider_api_image,field_content.field_list_topics.field_topics.field_image_icon,field_content.field_list_topics.field_topics.field_image_icon.field_media_image,field_content.field_list_topics.field_topics.field_image_icon.field_media_provider_api_image,field_content.field_questions,field_content.field_questions.field_image,field_content.field_questions.field_image.field_media_image,field_content.field_questions.field_image.field_media_provider_api_image,field_content.field_media_image,field_content.field_media_image.field_media_image,field_content.field_media_image.field_media_provider_api_image,field_content.field_media_video,field_content.field_form,field_content.field_articles,field_content.field_articles.field_image,field_content.field_articles.field_image.field_media_image,field_content.field_articles.field_image.field_media_provider_api_image,field_content.field_articles.field_media_image,field_content.field_articles.field_media_image.field_media_image,field_content.field_articles.field_media_image.field_media_provider_api_image,field_content.field_articles.field_article,field_content.field_articles.field_article.field_image,field_content.field_articles.field_article.field_image.field_media_image,field_content.field_articles.field_article.field_image.field_media_provider_api_image,field_content.field_articles.field_product,field_content.field_topics,field_content.field_topics.field_image,field_content.field_topics.field_image.field_media_image,field_content.field_topics.field_image.field_media_provider_api_image,field_content.field_topics.field_image_icon,field_content.field_topics.field_image_icon.field_media_image,field_content.field_topics.field_image_icon.field_media_provider_api_image,field_content.field_left_column,field_content.field_left_column.field_questions,field_content.field_left_column.field_media_image,field_content.field_left_column.field_media_image.field_media_image,field_content.field_left_column.field_media_image.field_media_provider_api_image,field_content.field_left_column.field_media_video,field_content.field_left_column.field_form,field_content.field_center_column,field_content.field_center_column.field_media_image,field_content.field_center_column.field_media_image.field_media_image,field_content.field_center_column.field_media_image.field_media_provider_api_image,field_content.field_center_column.field_media_video,field_content.field_right_column,field_content.field_right_column.field_questions,field_content.field_right_column.field_media_image,field_content.field_right_column.field_media_image.field_media_image,field_content.field_right_column.field_media_image.field_media_provider_api_image,field_content.field_right_column.field_media_video,field_content.field_right_column.field_form

Issue 1:
Relationships includes validations are not scaling well with reused / many relationships.
This is caused from the following structure of includes that are causing the explosion of the include tree size.
The thing is that a simple static cache can speed-up this algorithm a lot (suspecting).

Step 1: EntityType-1 -> Paragraph-1 -> Media-1 -> File
Step 2: EntityType-1 -> Paragraph-2 -> Media-1 -> File
Step 3: EntityType-1 -> Paragraph-3 -> Media-1 -> File

The overhead comes form the recursive calls in steps 2 and 3 that the processing for media 1 and all of it's relationships was completed.
This is problematic on entity level as well, as it's very common to have

EntityType-1 -> Paragraph-1 -> Media-1 -> File
EntityType-1 -> Paragraph-2 -> Media-1 -> File
EntityType-1 -> Paragraph-3 -> Media-1 -> File
... Imagine 40 more paragraphs that could nest each other and have many types of media in many places...

EntityType-2 -> Paragraph-1 -> Media-1 -> File
EntityType-2 -> Paragraph-2 -> Media-1 -> File
EntityType-2 -> Paragraph-3 -> Media-1 -> File
Here the overhead is for all paragraph types.
... Imagine 5-10 different entity types that all share the above list of paragraphs.
All of this is causing repeated computation withing the include tree.

It turned out that the overhead here was caused by 2 things
- DFS on the includes tree, executed based on the public includes count.
- Excessive use of exceptions for code flow and complex errors messages generation.

Issue 2:
Includes meta-data is re-calculated on every request :(, even though it is resource config bound and will never change unless there is a change to entity field or resource configuration changed at some point...

In my use-case this is exhausting 75%+ of the time for fetching a single term through the API.

Issue 3:
On listing responses this overhead is multiplied (per entity count) and will likely benefit from static cache. Or at least it seems that way.
On a particular terms list it is causing 90%+ overhead (28 terms).

Points 2 and 3 are the main reasons why this is marked as critical - 13 out of 14 seconds are spend in here (point 3) and it's a-lot-more than the core's guidelines. It is degrading based on configuration / data

Repeated code paths without added functional value.
Resolved in #14.

Proposed resolution

Point 1:
A smarter static cache that will allow for run-time trimming the include tree where possible will speed things in cold cache scenarios.
Located somewhere in FieldResolver...

Resolved in #14

Point 2:
I suspect that persistent cache here will help alleviate the pain a lot.
This should be around IncludeResolver::resolveInternalIncludePaths
Implemented in the PoC patch through a 2 level cache service in #3.

Point 3:
Static cache should resolve this one.
This should be around IncludeResolver::resolveInternalIncludePaths
Implemented in the PoC patch through a 2 level cache service in #3.

Loop implemented due to the internal iterator, caused repeated calculation for the same entity type because of different entity instances provided, that all share the same type. Resolved in #14

Remaining tasks

- PoC - done proven a valid issue.
- Review(s)
- Decide how to handle the cache tags setting in a jsonapi + jsonapi_extras compliant way if we go with the cache solution.
- Guidelines for final patch implementation.
- RTBC.
- Commit.

User interface changes

None expected.

API changes

None are expected, as we should aim to improve internals and add a cache layer(s).
Maybe add new internal methods to improve efficiency...

Data model changes

None.

Release notes snippet

TBD...

Comments

ndobromirov created an issue. See original summary.

ndobromirov’s picture

Issue summary: View changes
ndobromirov’s picture

Status: Active » Needs review
StatusFileSize
new3.55 KB
new2.43 KB
new90.7 KB
new66.8 KB
new89.04 KB
new63.56 KB

The PoC patch is somewhat related with #3018287: ResourceTypeRepository computes ResourceType value objects on *every request*.

There are two patches that:
- One that is standalone with the new service added to manage the static + persistent cache.
- One that depends on #3018287: ResourceTypeRepository computes ResourceType value objects on *every request* and is not adding the service ( I will be using this ).

The PoC follows the idea to maintain an always expanding list of valid include paths in cache for a resource type. Have the requested includes cross-checked against the cached ones and compute only the missing ones. The solution should cover point 2 and 3 from the issue summary.

XHProf runs for the change:

Single entity before:

Single entity after:

List 27 entities before:

List 27 entities after:

I will try to see what is possible to be done in point 1 at some point in near future (if anything).

ndobromirov’s picture

Issue summary: View changes

The last submitted patch, 3: issue-3039730-poc.patch, failed testing. View results

ndobromirov’s picture

Issue summary: View changes
Related issues: +#3018287: ResourceTypeRepository computes ResourceType value objects on *every request*
ndobromirov’s picture

Issue summary: View changes
ndobromirov’s picture

I've tried to speed the issue in point 1 a bit I am hitting a wall, as it seems it can not be made faster for the cold cache scenarios and this is purely on the computational complexity of the problem at hand.

I have the following includes added through jsonapi_defailts.

field_image
field_image.field_media_image
field_content
field_content.field_custom_content
field_content.field_custom_content.field_image
field_content.field_custom_content.field_image.field_media_image
field_content.field_custom_content.field_image.field_media_provider_api_image
field_content.field_news
field_content.field_news.field_image
field_content.field_news.field_image.field_media_image
field_content.field_news.field_image.field_media_provider_api_image
field_content.field_gallery
field_content.field_person
field_content.field_person.field_image
field_content.field_person.field_image.field_media_image
field_content.field_person.field_image.field_media_provider_api_image
field_content.field_person.field_phone_number
field_content.field_files
field_content.field_files.field_media_file
field_content.field_products
field_content.field_products.field_product
field_content.field_left_video
field_content.field_left_image
field_content.field_left_image.field_media_image
field_content.field_left_image.field_media_provider_api_image
field_content.field_right_video
field_content.field_right_image
field_content.field_right_image.field_media_image
field_content.field_right_image.field_media_provider_api_image
field_content.field_list_topics
field_content.field_list_topics.field_topics
field_content.field_list_topics.field_topics.field_image
field_content.field_list_topics.field_topics.field_image.field_media_image
field_content.field_list_topics.field_topics.field_image.field_media_provider_api_image
field_content.field_list_topics.field_topics.field_image_icon
field_content.field_list_topics.field_topics.field_image_icon.field_media_image
field_content.field_list_topics.field_topics.field_image_icon.field_media_provider_api_image
field_content.field_questions
field_content.field_questions.field_image
field_content.field_questions.field_image.field_media_image
field_content.field_questions.field_image.field_media_provider_api_image
field_content.field_media_image
field_content.field_media_image.field_media_image
field_content.field_media_image.field_media_provider_api_image
field_content.field_media_video
field_content.field_form
field_content.field_articles
field_content.field_articles.field_image
field_content.field_articles.field_image.field_media_image
field_content.field_articles.field_image.field_media_provider_api_image
field_content.field_articles.field_media_image
field_content.field_articles.field_media_image.field_media_image
field_content.field_articles.field_media_image.field_media_provider_api_image
field_content.field_articles.field_article
field_content.field_articles.field_article.field_image
field_content.field_articles.field_article.field_image.field_media_image
field_content.field_articles.field_article.field_image.field_media_provider_api_image
field_content.field_articles.field_product
field_content.field_topics
field_content.field_topics.field_image
field_content.field_topics.field_image.field_media_image
field_content.field_topics.field_image.field_media_provider_api_image
field_content.field_topics.field_image_icon
field_content.field_topics.field_image_icon.field_media_image
field_content.field_topics.field_image_icon.field_media_provider_api_image
field_content.field_left_column
field_content.field_left_column.field_questions
field_content.field_left_column.field_media_image
field_content.field_left_column.field_media_image.field_media_image
field_content.field_left_column.field_media_image.field_media_provider_api_image
field_content.field_left_column.field_media_video
field_content.field_left_column.field_form
field_content.field_center_column
field_content.field_center_column.field_media_image
field_content.field_center_column.field_media_image.field_media_image
field_content.field_center_column.field_media_image.field_media_provider_api_image
field_content.field_center_column.field_media_video
field_content.field_right_column
field_content.field_right_column.field_questions
field_content.field_right_column.field_media_image
field_content.field_right_column.field_media_image.field_media_image
field_content.field_right_column.field_media_image.field_media_provider_api_image
field_content.field_right_column.field_media_video
field_content.field_right_column.field_form

The problem comes from the fact that field_content is a paragraph reference field and it is referencing about 40 different bundles in there and the loop that goes and checks for connection in each of them, in FieldResolver::resolveInternalIncludePath

The only way I can see to currently optimize the cold cache scenario, that's currently running for half a second, is to reduce the list size of includes you can see above.

I see 2 options:
1. Renaming and tuning, so including 1 thing can actually generate 2 iternal includes. For example:

field_content.field_right_column.field_media_image.field_media_image
field_content.field_right_column.field_media_image.field_media_provider_api_image

Through jsonapi_extras - rename the field in the end to a common public name:
field_media_provider_api_image -> field_media_image

and then include only one line for that:

field_content.field_right_column.field_media_image.field_media_image

This way defaults will pass on half the items for inclusion and jsonapi will internally load 2 things on one iteration instead of looping 2 includes to load 1 thing each. Could have been nicer to know this sooner :D. In my case this re-configuration should speed things around 25-30% (hooray)


Is it possible at the moment in jsonapi to have...

field_content.field_custom_content.field_image.field_media_image

... interpreted as

field_content
field_content.field_custom_content
field_content.field_custom_content.field_image
field_content.field_custom_content.field_image.field_media_image
ndobromirov’s picture

From researching this, i see the overhead is mostly coming from the fact we are checking the include paths 1 by 1 and in a DFS manner. As a result any fields that have many possible related entities are processed multitude of times, returning a success only 1-2 out of 40 iterations.

In my case this is paragraph reference field that points to ~40 different types and I have ~80 includes that point to something through it. As a result there are ~40 * ~80 = ~2400 edges for traversal and it is going to get slower with more elements added to the system (in theory and practice). This can be mapped to the function calls count I see in the profiler for FIeldResolver::resolveInternalIncludePath()


We already have a utility to build a tree in IncludeResolver::buildTree(), so lets build the tree first from the requested include paths and have a BFS implemented on it to validate it and build the internal tree / list of paths from it in bulk.

The positives here are that we will be able to iterate over the nodes and traverse the edges in bulk. This way it will handle all paragraphs in bulk only 1 time, resulting in let's say 20 successes out of 40. Ideally the complexity for traversal should be based on the nodes in the relationships graph (about 50 in my case). Performance degradation should scale based on nodes in the graph linearly, compared to multiplication of edges and includes count (that increases with entities in the system).

I propose a new method FIeldResolver::resolveInternalIncludeTree() or FIeldResolver::resolveInternalIncludePaths() to be implemented based on the above description and see the change...

I suspect this should be able to drop some 3-4x+ the execution time for cold cache scenarios on point 1 for cases as complex as mine.


Points 2 and 3 are already alleviating the pain A LOT, so I am keeping the issue in needs review. The amount of work needed on point 1 will likely make it deserving it's own issue... Will see...

ndobromirov’s picture

After some more experimentation and tweaks...

The biggest overhead in that method is the generation of the error messages :(.
Just removing that it, by returning empty lists and not throwing exceptions accelerated it like 10-11x times. (450ms -> 40ms) for the whole validation tree.

I have a PoC implementation that behaves 99% the same based on the BFS solution described above that runs (with no exceptions for 5-6ms).

Here is the PoC code for it. Note that there is no error handling in there...

  public static function resolveInternalIncludeTree(ResourceType $resource_type, $includesTree) {
    $result = [];
    $lookup_list = [];

    $fill_lookup_list = function ($branch, ResourceType $resource_type, $path) use (&$lookup_list) {
      foreach (array_keys($branch) as $name) {
        $internal_field_name = $resource_type->getInternalName($name);
        if (!$resource_type->hasField($internal_field_name)) {
          continue;
        }

        $new_path = array_values($path);
        $new_path[] = $internal_field_name;

        $lookup_list[] = [
          'public_name' => $name,
          'resource_type' => $resource_type,
          'string_path' => implode('.', $new_path),
          'path' => $new_path,
          'branch' => $branch,
        ];
      }
    };

    $fill_lookup_list($includesTree, $resource_type, []);

    while($lookup_list) {
      $item = array_shift($lookup_list);

      $next_branch = $item['branch'][$item['public_name']];
      foreach ($item['resource_type']->getRelatableResourceTypesByField($item['public_name']) as $related_resource_type) {
        $fill_lookup_list($next_branch, $related_resource_type, $item['path']);
      }

      if (!isset($result[$item['string_path']])) {
        $result[$item['string_path']] = $item['path'];
      }
    }

    return array_values($result);
  }

... and now using that in IncludeResolver like so:

# Used to reduce duplicated things from the input...
$public_tree = static::buildTree($paths);
$internal_paths_from_tree = FieldResolver::resolveInternalIncludeTree($resource_type, $public_tree);

The only functional difference is that I am adding intermediary includes. If you have includes list passed as:

relation1.relation2.relation3

The BFS solution will generate the following things if all is correct:

relation1
relation1.relation2
relation1.relation2.relation3

This for me has the hidden benefit that actually configuring the includes on the resources in jsonapi_extras or clients gets easier to maintain in the long run, as you need to add only paths to leaf resources and not all of the sub-paths in there (like the example some comments above).

I am currently working on the errors handling in the method, but as I see it, it needs to be made in a scalable way. Current implementation that is causing 90%+ overhead is not acceptable.

ndobromirov’s picture

StatusFileSize
new6.5 KB
new5.43 KB

Here is a new patch that is implementing point 1.

Currently it is handling the cases for invalid includes passed in, but this is without the fancy messages like before.
I am expecting tests to fail because of exactly that :( if at all.

No time to tweak test at the moment. If the direction is OK, I will update them as well.

I will provide cold cache benchmarks as well shortly.
Teaser - currently this is running in 5-10 ms consistently in cold caches for a single term for the same list of includes.3

ndobromirov’s picture

StatusFileSize
new118.3 KB

Functionally everything is the same except:
- The exceptions... They are now handled in the IncludeResolver::resolveInternalIncludePaths
- Valid sub-paths are always added to the result.

Here is the old cold cache scenario for a single term entity (repost from #3).

Here is the promised benchmarks for cold cache scenario 8ms :D!

At this point it's debatable whether we will need the persistent cache on top of this, but in my case it shows a measurable speed-up of at least 5-6 ms per call.

On top of all that there is still a new method that will likely need tests...
Leaving as needs review. (PARTY)

ndobromirov’s picture

After sleeping on it for a couple of days - the only reason for the need of static cache in issue 3 is that the code was executed multiple times. Essentially in a simple loop. If we move the code to work on the first iteration only, we drop the need for static cache.

On that train of thought, this is only triggered on a per root entity cases.
If we want to have a persistent cache in here, we can move it's storage on the resource configuration objects directly. Have it lazy initialized as it currently is and stored on the value objects we already have. This way no additional cache management overhead will be introduced

If we consider that this is now fast-enough - 8ms on each request (that has that many includes defined, this can go without the need for persistent cache either.

Some tuning internally on the method can shed some of the internal overhead as well - the de-duplication logic. It is not that needed as in the end we will be creating a tree out of the result set either way. So it will be duplicated at that point in a much more efficient manner.

I will be implementing a new patch that aims at dropping the need for a cache layer fully at the cost of around 5-10 ms per request in my case.

If we then decide the cache is needed. It can be added as a follow-up. This will resolve one of the open TODOs I currently have in there - the cache tags management that are currently hard-coded and taken from jsonapi and jsonapi_extras.

ndobromirov’s picture

StatusFileSize
new5.54 KB
new4.57 KB

Here is the patch from solution proposed in #14.
- Generate and validate the includes list only one time during a request.
- Optimized algorithm - currently running for 8 ms in my case. (same as #12)
- No cache layers.

I consider this commitable.

wim leers’s picture

Priority: Critical » Major
Issue tags: +API-First Initiative

Impressive research, thank you so much, @ndobromirov!

However, this issue is definitely not "critical". Everything is working correctly. It's just working slowly for one particular entity type that has made design decisions (these decisions cause *many* performance problems, also outside of JSON:API). I'm fine with optimizing this, but marking this critical is not fair. See https://www.drupal.org/core/issue-priority#critical-bug. I understand it is critical *to you* though!

I will look into this in the coming week!

ndobromirov’s picture

Critical task based on the link you've provided:

- Over ~100ms or more savings with cold caches
- Gets measurably worse with lots of contrib modules or large data sets (e.g. non-indexed queries) and would have to be deferred to a minor version

This is not a bug - it works correctly (but slow).
I really do not care for the severity, as long as it's fixed :)

ndobromirov’s picture

I have some other MISC questions, that cropped up while debugging this.

All the methods I've had to change were static.
If they were on a service this could have been possible to decorate and be tuned from outside.
Are the static methods intentional to make it hard to customize as possible?

How about the errors?
Is it OK that we are now just listing all the invalid include paths in bulk?
- I think it is, as now only 1 exception is generated and only when there are invalid includes passed in. This saves a huge overhead.
- All invalid includes are shown in absolute manner so it should be fairly easy to reason the connections. The only regression compared to the solution present in the module now is that we are first not giving guidelines to what piece of the path is incorrect, but that it as a whole is such.

xjm’s picture

This might be considered a performance gate issue for core if JSON:API were in the critical path for normal site operation. However, since JSON:API is a new module that's off by default, let's call it a blocker for using JSON:API in a core install profile. :)

ndobromirov’s picture

Issue summary: View changes
ndobromirov’s picture

Issue summary: View changes
e0ipso’s picture

I value a lot these kind of issues @ndobromirov! Content modelling using paragraphs has many benefits in UX / editorial side, however it can lead to these performance issues you are flagging. This happens in decoupled and monolithic sites.

Thanks to these examples we are improving JSON:API's performance even over the monolith. This is fantastic! Thanks for this.

On the down side you'll need to be patient because this is considered an uncommon configuration, and we're onto more pressing matters at the moment.

ndobromirov’s picture

No problem, I have a patch that I can live with :). I can wait.

wim leers’s picture

Questions:

  1. The issue summary mentions https://www.drupal.org/project/paragraphs many times. I'd like to reproduce this with just Drupal core, i.e. without Paragraphs. Is that possible?
  2. Related: the numbers you cite in #10 ("points to ~40 different types", "~80 includes", "~2400 edges for traversal") are showing this is a very complex content model. What fraction of that is due to the Paragraphs-based content model?
  3. Excessive use of exceptions for code flow and complex errors messages generation. — can you elaborate on this?
  4. Includes meta-data is re-calculated on every request :(, even though it is resource config bound and will never change — can you elaborate on this? Are you talking about the meta as in https://jsonapi.org/format/#document-meta, or some other metadata?
  5. Your analyses in #10 and #11 don't make this conclusion, so I'll do so for you: JSON:API's current code base is optimized for a small number of include expressions, and hence has a simple DFS strategy and throws HTTP exceptions. It makes sense to optimize for this, because JSON:API optimizes for getting just the data you need. But in your scenario, with not only a highly complex content model but also an enormous number of default includes, this causes performance issues, because you're trying to fetch All The Data in a single request. That this is slow is then not very surprising :) I agree we should optimize JSON:API's implementation further to allow for very complex use cases like your own. But I feel compelled to point out that you're using JSON:API in an atypical way.
  6. Based on what I read here, I think we may want to persistently cache the result for an include expression for a given resource type, if that expression contains >=2 levels. (Since for a single level, there is no overhead: all necessary information already is in-memory.) Perhaps we even want to only do the persistent caching for >=3 levels. This is essentially what you're saying in point 2, and what you've implemented in #3.

    This seems to be the root cause of the performance problems you're encountering. Let us please keep this issue focused on that, and not address other things here.

wim leers’s picture

Assigned: Unassigned » wim leers
StatusFileSize
new599 bytes
new5.54 KB

#8: Wow, that is a lot of default includes you're adding… this quite clearly shows the consequences of using Paragraphs. That being said, there are a lot of includes you can omit. As https://jsonapi.org/format/#fetching-includes documents:

GET /articles/1?include=comments.author HTTP/1.1
Accept: application/vnd.api+json

Note: Because compound documents require full linkage (except when relationship linkage is excluded by sparse fieldsets), intermediate resources in a multi-part path must be returned along with the leaf nodes. For example, a response to a request for comments.author should include comments as well as the author of each of those comments.

So in your example,

field_content.field_center_column
field_content.field_center_column.field_media_image
field_content.field_center_column.field_media_image.field_media_image
field_content.field_center_column.field_media_image.field_media_provider_api_image
field_content.field_center_column.field_media_video

can be simplified to:

field_content.field_center_column.field_media_image.field_media_image
field_content.field_center_column.field_media_image.field_media_provider_api_image
field_content.field_center_column.field_media_video

You can easily check whether this is indeed working as expected, by doing this in the standard install profile: /jsonapi/node/article?include=field_tags.vid — this will result in all articles, all their tags and all their vocabularies being fetched, with a single include expression.

EDIT: hah, ironically, this is what you're also talking about in #11, but it already works this way :)


#12: this is where it becomes very confusing. Let's keep this issue about the patch in #3 only, if we're going to be talking about multiple patches and multiple problems in a single issue, it's going to be extremely confusing.


#18:

  • static methods are static because they don't rely on $this: they're static to minimize dependencies and avoid unwittingly introducing new dependencies.
  • Is it OK that we are now just listing all the invalid include paths in bulk?

    Will look into this in my review.


Now reviewing #15 in detail. Thanks again for all this work, it's SUPER useful, and very much appreciated! 👏👏👏👏🙏🙏🙏

While I'm doing that, here is already a reroll that fixes the CS violation and should result in a green patch :)

ndobromirov’s picture

#24

  1. You can reproduce with Drupal core in the same way. Just replace paragraphs with something else for example node types or block types. Have a field that references 40 different block types and some of them need to have to media entities sprinkled in there.
  2. There are 5 node types, 6 media types, and ~40 paragraph types.
    Paragraphs have 2 logical sub-types: layout ones and data ones.
    Note that this are not Drupal bundles, it's just how we treat them.
    - Data ones are only showing content.
    - Layout ones are having a multi-valued reference field to data paragraphs. They can reference most / all of the data paragraphs. We run a default like a single column and the layout paragraphs allow us to have 2 and 3 column grids on a per paragraph entry.

    Overall the content follows this structure:
    Node > data paragraph -> Media -> File.
    Node > Layout paragraph > data paragraph -> Media -> File.
    Node > Layout paragraph > data paragraph -> Node -> media -> file

  3. Well for 85 valid include paths (as a result) I had something like 2k exceptions thrown somewhere in the recursive function and cached somewhere in there as well. Based on my experiment in #11, just removing all exceptions-related code resulted in ~90% speed-up in the original code.
  4. The meta-data I am talking about is: List of valid include paths for the particular entity resource. If we have that available before-hand, this can be reduced to a simple array_diff. This is why I was focused on cache and cache merging initially in the patches before #15.
  5. I am not arguing there, but it seems it can go faster ;). We already discussing alternatives internally how to get away from this, so we can simplify the overall request complexity, but we are hitting walls :(. This might be a good candidate for a support issue to discuss / or in any chat channel (slack / IRC). Just to not side-track this issue.
  6. Yes, and there comes the overhead of managing the cache tags on the cache item. The responsibility is split between jsonapi and jsonapi_extras and encapsulated in resource type repository. This is why I've moved to drop the cache in later patches, as it simplifies the problem space a lot.

#25

Already simplified includes list today. As I am running with the last patch (#15 now), it's not getting much faster, if at all, from the reduced includes in the validation step. I've managed to shed some of the deepest includes and this helps to an extent on the loading step after that. A separate issue that I will maybe bother you in near future... :]


Thanks for the positive feedback and I am waiting any further comments / questions :).

wim leers’s picture

StatusFileSize
new1.29 KB
new5.55 KB

Attached is a reroll that fixes the last few CS failures (which phpcs doesn't show locally …)

  1. +++ b/src/Context/FieldResolver.php
    @@ -221,6 +221,75 @@ class FieldResolver {
    +   * Validates a multitude of paths present in the provided tree in bulk.
    +   * Only valid paths are returned. Error handling is offloaded to the
    +   * calling methods.
    ...
    +  public static function resolveInternalIncludeTree(ResourceType $resource_type, $includesTree) {
    ...
    +    // Traverse the tree in BFS fashion.
    

    I don't see why this would be faster. It's just different.

    BFS can be better (faster) than DFS when we're trying to actually … search for a particular value, and we know that the needle is less likely to be located deeply.

    But in this case, there is no searching, there is only processing. We need to process all of the data (all of the include paths).

    If I'm missing something: I'm looking forward to your explanation 🙂

  2. +++ b/src/IncludeResolver.php
    @@ -179,7 +181,11 @@ class IncludeResolver {
         foreach ($data as $resource_object) {
    -      $resolved_paths = array_merge($resolved_paths, static::resolveInternalIncludePaths($resource_object->getResourceType(), $exploded_paths));
    +      $resolved_paths = static::resolveInternalIncludePaths($resource_object->getResourceType(), $exploded_paths);
    +
    +      // We stop after the first iteration, as in case of collections this
    +      // will iterate over all items and return the same result every time.
    +      break;
         }
    

    This will not work for mixed-bundle collections. Yes, #2956414: Support mixed-bundle collections (e.g. `/jsonapi/node`) is not yet done, but mixed-bundle collections already occur today: when looking at the related route for a relationship on a resource, because an entity reference often allows linking to multiple bundles of a given entity type.

    For example: field_related_content may link both to article nodes and talk nodes. With this change, that'd result ?include=… only being evaluated for whichever resource comes first: a node--talk or a node--article resource.

    I think the correct way to do this is to not run this just once, but once per resource type in $data.

  3. +++ b/src/IncludeResolver.php
    @@ -199,14 +205,26 @@ class IncludeResolver {
    +      throw new CacheableBadRequestHttpException($cacheability, 'Invalid includes requested: ' . implode(', ', $invalid_paths));
    

    I'm not opposed to this in principle, but … it does result in a vastly worse DX, because now the much more helpful errors that \Drupal\jsonapi\Context\FieldResolver::resolveInternalIncludePath() throws that #2973681: Regression introduced by #2953207: Deep nested include on multi target entity type field fail introduced are no longer being created:

          $message = "`$public_field_name` is not a valid relationship field name.";
          if (!empty(($possible = implode(', ', array_keys($resource_type->getRelatableResourceTypes()))))) {
            $message .= " Possible values: $possible.";
          }
          throw new CacheableBadRequestHttpException($cacheability, $message);
    

    I don't think this is an acceptable regression.

wim leers’s picture

Assigned: wim leers » Unassigned
StatusFileSize
new1.25 KB

I think this simpler subset of this patch (which also fixes the bug I pointed out in #27.2) achieves the same performance improvement. Could you please test this? 🙏

wim leers’s picture

Title: Includes validation does not scale with relationships. » Include paths are resolved for every resource in a resource collection, instead of once per unique resource type

And I think this is a more accurate description of the performance problems @ndobromirov reported here.

wim leers’s picture

Oh, and having written that clearer title, I think I suddenly understand why @ndobromirov was talking about BFS: if >1 independent include path resolutions end up inspecting the same subtree, we're doing duplicate work.

#28 only ensures we're not evaluating the same "tree root evaluations" multiple times. It doesn't yet ensure we're not evaluating the same "subtree evaluations" multiple times.

Still, I think the tiny patch in #28 will actually yield a very significant performance improvement already :) Let's first see if #28 brings the majority of the speed boost: if it does, that may be sufficient. If it doesn't, I think I see a simpler way to achieve the same performance benefit as @ndobromirov's patch with less complexity.

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

wim leers’s picture

Issue summary: View changes

One clarification in the IS: the concrete request URL that is triggering the reported problem. That makes it much clearer how complex the test scenario is 🙂

ndobromirov’s picture

This is essentially same as the break; approach.

I will test this today...

We've already reduced the include lines to the bare minimum to keep same functionality, so I suspect the overhead to be minimal now. We dropped from 80+ default include lines to something like ~40 on all 5 content types so I suspect there will be a lowered overhead with this.

wim leers’s picture

I will test this today...

👍

We dropped from 80+ default include lines to something like ~40

👍 (Probably using the explanation I gave in #25, of not being able to simplify ?include=foo,foo.bar to just ?include=foo.bar?)

ndobromirov’s picture

I can say it is resolving the overhead for the multiple items case to a big extent.

With my current includes setup that has drastically improved compared to earlier benchmarks the recursive overhead has dropped from 500ms to ~200ms. Note all the XHProf things you see above are with xdebug and xhprof running. Though on the dev servers we were having 11-12 seconds of response times even without the 2 extensions...

The changes in the includes are that the following 2 have merged into only *.field_media_image, after the lattest commit in JSON:API.

*.field_media_image
*.field_media_provider_api_image

And the rest is just skipping the intermediary paths that were duplication like:

path1,path1.path2,path1.path2.path3 -> path1.path2.path3

Current list of includes (functionally equivalent):

field_image.field_media_image
field_content
field_content.field_custom_content.field_image.field_media_image
field_content.field_news.field_image.field_media_image
field_content.field_gallery
field_content.field_person.field_image.field_media_image
field_content.field_person.field_phone_number
field_content.field_files.field_media_file
field_content.field_products.field_product
field_content.field_left_video
field_content.field_left_image.field_media_image
field_content.field_right_video
field_content.field_right_image.field_media_image
field_content.field_list_topics.field_topics.field_image.field_media_image
field_content.field_list_topics.field_topics.field_image_icon.field_media_image
field_content.field_questions.field_image.field_media_image
field_content.field_media_image.field_media_image
field_content.field_media_video
field_content.field_form
field_content.field_articles.field_image.field_media_image
field_content.field_articles.field_media_image.field_media_image
field_content.field_articles.field_article.field_image.field_media_image
field_content.field_articles.field_product
field_content.field_topics.field_image.field_media_image
field_content.field_topics.field_image_icon.field_media_image
field_content.field_left_column.field_questions
field_content.field_left_column.field_media_image.field_media_image
field_content.field_left_column.field_media_video
field_content.field_left_column.field_form
field_content.field_center_column.field_media_image.field_media_image
field_content.field_center_column.field_media_video
field_content.field_right_column.field_questions
field_content.field_right_column.field_media_image.field_media_image
field_content.field_right_column.field_media_video
field_content.field_right_column.field_form

This is why I've made a black box test between the 2 approaches with xdebug and xhprof both disabled.
As both are equally solving the multiple entities handling in terms of call it only once (#28 is functionally correct...).

This is why I am testing only a single entity as essentially that's the same for this patch and reduce the normalization overhead.
All tests in this issue are done with no page dynamic or render cache.

Here are the benchmarks against the 2 (#15 & #28) patches on my local environment.
This is full request tests (AB) against a single entity. I am comparting Wim's patch from #28 and mine from #15.

There is a consistent 25-30% speed-up in the average / mean case and about 80-100% change in the worst case.

Wim's patch in #28 (ab -n 100 -c 1 http://HOSTNAME:81/api/taxonomy_term/sections/77d82ef5-3e45-4eb3-8490-85...)

Document Path:          /api/taxonomy_term/sections/77d82ef5-3e45-4eb3-8490-859cd950e79e
Document Length:        1028 bytes

Concurrency Level:      1
Time taken for tests:   12.677 seconds
Complete requests:      100
Failed requests:        0
Total transferred:      192900 bytes
HTML transferred:       102800 bytes
Requests per second:    7.89 [#/sec] (mean)
Time per request:       126.772 [ms] (mean)
Time per request:       126.772 [ms] (mean, across all concurrent requests)
Transfer rate:          14.86 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.1      0       1
Processing:   117  127   9.9    126     212
Waiting:      117  126   9.9    126     212
Total:        117  127   9.9    126     213

Percentage of the requests served within a certain time (ms)
  50%    126
  66%    128
  75%    129
  80%    130
  90%    134
  95%    135
  98%    138
  99%    213
 100%    213 (longest request)

Patch in #15 (ab -n 100 -c 1 http://HOSTNAME:81/api/taxonomy_term/sections/77d82ef5-3e45-4eb3-8490-85...)

Document Path:          /api/taxonomy_term/sections/77d82ef5-3e45-4eb3-8490-859cd950e79e
Document Length:        1028 bytes

Concurrency Level:      1
Time taken for tests:   9.903 seconds
Complete requests:      100
Failed requests:        0
Total transferred:      192900 bytes
HTML transferred:       102800 bytes
Requests per second:    10.10 [#/sec] (mean)
Time per request:       99.034 [ms] (mean)
Time per request:       99.034 [ms] (mean, across all concurrent requests)
Transfer rate:          19.02 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.0      0       0
Processing:    91   99   5.8     98     135
Waiting:       91   99   5.8     98     135
Total:         91   99   5.8     98     135

Percentage of the requests served within a certain time (ms)
  50%     98
  66%    101
  75%    102
  80%    102
  90%    104
  95%    109
  98%    111
  99%    135
 100%    135 (longest request)
ndobromirov’s picture

StatusFileSize
new5.89 KB
new1.82 KB

Here is the patch from 15 incorporating all the feedback from @Wim. (#28 + some code styles mentioned earlier).

This is the point where we need to decide are we introducing persistent cache on this or not. If we do - algorithm to implement the validation is not that important, as we will be validating only the non-cached paths and the DFS will be efficient to do that as it is. In that case, #3018287: ResourceTypeRepository computes ResourceType value objects on *every request* becomes a blocker.

If we steer away from the persistent cache, this might hit back in multi-resource type collections, as the overhead will be multiplied per resource type and we will be back here :).

wim leers’s picture

#35: can you do a similar ab test run without any patch applied? So that we can compare the numbers more clearly?

ndobromirov’s picture

This test with the AB was against a single entity, so patch in #28 should not add any meaningful overhead.

I will provide the same AB for a list of entities some time today.

ndobromirov’s picture

Here are the promised benchmarks.
No page, dynamic or render caches.
No xhprof or xdebug running.

No patch against list with 27 entities.

$ ab -n 100 -c 1 http://HOSTNAME:81/api/taxonomy_term/sections
Document Path:          /api/taxonomy_term/sections
Document Length:        34457 bytes

Concurrency Level:      1
Time taken for tests:   115.891 seconds
Complete requests:      100
Failed requests:        0
Total transferred:      3610600 bytes
HTML transferred:       3445700 bytes
Requests per second:    0.86 [#/sec] (mean)
Time per request:       1158.913 [ms] (mean)
Time per request:       1158.913 [ms] (mean, across all concurrent requests)
Transfer rate:          30.42 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.0      0       0
Processing:  1070 1159  57.8   1147    1332
Waiting:     1067 1157  57.8   1146    1330
Total:       1070 1159  57.8   1148    1333

Percentage of the requests served within a certain time (ms)
  50%   1148
  66%   1175
  75%   1189
  80%   1209
  90%   1255
  95%   1274
  98%   1297
  99%   1333
 100%   1333 (longest request)

Wim's patch from #28. Same endpoint. Same data.

$ ab -n 100 -c 1 http://HOSTNAME:81/api/taxonomy_term/sections
Document Path:          /api/taxonomy_term/sections
Document Length:        34457 bytes

Concurrency Level:      1
Time taken for tests:   48.563 seconds
Complete requests:      100
Failed requests:        0
Total transferred:      3610600 bytes
HTML transferred:       3445700 bytes
Requests per second:    2.06 [#/sec] (mean)
Time per request:       485.635 [ms] (mean)
Time per request:       485.635 [ms] (mean, across all concurrent requests)
Transfer rate:          72.61 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.0      0       0
Processing:   443  485  24.5    482     563
Waiting:      441  484  24.5    480     561
Total:        443  486  24.5    482     563

Percentage of the requests served within a certain time (ms)
  50%    482
  66%    490
  75%    494
  80%    503
  90%    520
  95%    537
  98%    562
  99%    563
 100%    563 (longest request)

My patch from #36 (BFS + Wim's changes in #28).

$ ab -n 100 -c 1 http://HOSTNAME:81/api/taxonomy_term/sections
Document Path:          /api/taxonomy_term/sections
Document Length:        34457 bytes

Concurrency Level:      1
Time taken for tests:   47.158 seconds
Complete requests:      100
Failed requests:        0
Total transferred:      3610600 bytes
HTML transferred:       3445700 bytes
Requests per second:    2.12 [#/sec] (mean)
Time per request:       471.578 [ms] (mean)
Time per request:       471.578 [ms] (mean, across all concurrent requests)
Transfer rate:          74.77 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.1      0       1
Processing:   429  471  28.8    465     588
Waiting:      428  470  28.8    464     587
Total:        429  472  28.8    465     588

Percentage of the requests served within a certain time (ms)
  50%    465
  66%    472
  75%    479
  80%    486
  90%    515
  95%    538
  98%    566
  99%    588
 100%    588 (longest request)

Some thoughts...

  1. There is a lot of normalization overhead here but we can still see the ~15-20 ms speed-up between #28 and #36 as seen in #35.
  2. #28 will degrade further with non-optimal includes passed in for validation.
wim leers’s picture

Great, thanks!

  • My 1.25 KB patch in #28: 115.9 -> 48.6 = 58.1% faster.
  • Your 5.89 KB patch in #36: 115.9 -> 47.2 = 59.3% faster.
  • Or put differently, 48.6 -> 47.2 = 3% faster.

I personally don't think your patch's additional complexity justifies the small extra performance gain. We always need to balance maintainability and features/performance. This is even more true now that JSON:API has landed in Drupal core (this issue will soon become a Drupal core issue).

Would you be okay with #28 getting committed? Or do you have a very strong reason to say that you don't want #28 to go in, but #36 instead?

I'm curious to read @e0ipso and @gabesullice's thoughts :)

ndobromirov’s picture

#40 I am +1 for 28 getting in, as it's a step in the right direction.

My only concern was that because I lacked knowledge on the jsonapi module and how includes worked I had a non-optimal configuration and I suspect this might be the case for many more people (not just me).

The main difference (ignore the 3% that may as well be 0) in there is that #36 does not degrade with non-optimal configs passed in for validation. If that is OK as a compromise on the module I can live with it.

At that point I will think of a follow-up to add persistent cache on the resource types for the valid list of includes and eliminate this overhead fully, once resource type definitions are loaded from cache. Intersect against the valid list and compute only the new ones. I think this can be a follow-up.

ndobromirov’s picture

Ping.

Let's get this in, so we can start on caching it (eventually).

webchick’s picture

@ndobromirov If you've tested the patch and it looks good to you (and it appears you have!), feel free to mark this issue's status "Reviewed & tested by the community." That will escalate it to the core maintainers' attention.

e0ipso’s picture

Issue tags: +Needs reroll
ndobromirov’s picture

Issue tags: -Needs reroll
StatusFileSize
new1.17 KB

Here is a re-roll of #28.

I've been using it for the last month on over 60 sites without issues, and it helps a ton.

Any further speed-ups (based on cache) can be done in follow-ups.

If the tests pass, I'll be marking as RTBC as per recommendation from #43.

ndobromirov’s picture

Should I Re-Roll this against core?

ndobromirov’s picture

Project: JSON:API » Drupal core
Version: 8.x-2.x-dev » 8.8.x-dev
Component: Code » jsonapi.module
Issue tags: +DevDaysTransylvania

Moving to core's issue queue.
Rebased patch coming shortly.

ndobromirov’s picture

StatusFileSize
new1.31 KB

Here is the new patch against core.
It's the same as #45 / #28.

l0ke’s picture

Status: Needs review » Needs work
Issue tags: +DevDaysCluj
+++ b/core/modules/jsonapi/src/IncludeResolver.php
@@ -176,11 +176,16 @@ protected static function toIncludeTree(ResourceObjectData $data, $include_param
+      $resource_type_name = $resource_object->getResourceType()->getTypeName();
...
+      $resolved_paths_per_resource_type[$resource_type_name] = static::resolveInternalIncludePaths($resource_object->getResourceType(), $exploded_paths);

Storing $resource_object->getResourceType() in the variable will help to avoid getResourceType() to be called twice.

For the rest, it significantly improves performance.

ndobromirov’s picture

Status: Needs work » Needs review
StatusFileSize
new1.33 KB
new1.24 KB

Here is a re-rolled version of the patch, implementing the proposal.
Note that there are not many gains expected in there, as there is already the internally cached property for the value.

l0ke’s picture

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

Status: Reviewed & tested by the community » Needs review

@ndobromirov I'm confused in #50 you say that

Note that there are not many gains expected in there, as there is already the internally cached property for the value.

Bt as far as I can work out in core we don't have any caching and the gains are still to had. Or are you referring to #3016733: Simplify ResourceTypeRepository; use a protected property in place of an in-memory cache bin.

Is there any chance we can repeat the tests in #39 so show that we have an improvement here? Thanks

ndobromirov’s picture

Status: Needs review » Reviewed & tested by the community

Note that there are not many gains expected in there, as there is already the internally cached property for the value

This is only for return from #49 - the fact that we are sparing a single php function call with the latest patch to a function that has a lazy initialized property in it, and storing the result in a local variable is not that big of a performance win (if any).

I do not expect to have any visible change compared to #48. No time to do benchmarks on this now.

Back to RTBC, as you can pick #48 or #50, as for me they are essentially the same. Sorry for the confusion my comment triggered. The overhead that is spared by both of them are worth the commit.

alexpott’s picture

Status: Reviewed & tested by the community » Fixed

Committed and pushed c1b9f95417 to 8.8.x and df5f3e75f2 to 8.7.x. Thanks!

@ndobromirov thanks for #53

I've chosen to backport this to 8.7.x because it only changes internals and is a significant performance boost. On review I consider alternate strategies for reducing the array and only processing unique resource types but nothing seemed better than #50.

  • alexpott committed c1b9f95 on 8.8.x
    Issue #3039730 by ndobromirov, Wim Leers, e0ipso, l0ke, webchick, xjm:...

  • alexpott committed df5f3e7 on 8.7.x
    Issue #3039730 by ndobromirov, Wim Leers, e0ipso, l0ke, webchick, xjm:...
ndobromirov’s picture

Awesome! :D

rosinegrean’s picture

Issue tags: -DevDaysCluj
wim leers’s picture

Right, in #50 @ndobromirov was only saying that the changes in his interdiff for #50 should not result in significant gains, not that the entire patch would not result in significant gains :)

It's awesome to have this committed! 🥳🚀

And I think backporting this to 8.7 was the right call — disruption is guaranteed to be zero.

Status: Fixed » Closed (fixed)

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