Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

APIv3: add more generic fields #11205

Merged
merged 27 commits into from Apr 16, 2024
Merged

Conversation

humitos
Copy link
Member

@humitos humitos commented Mar 11, 2024

This PR re-structures the Addons API response by removing specific fields and converting them into more general purpose fields. All the new fields render the whole object using the same serializers than APIv3 (https://docs.readthedocs.io/en/stable/api/v3.html)

Fields added

  • version.aliases (added to VersionSerializer) is a list of versions the version points to: known as "original stable/latest version"
  • projects.translations is a list of projects that are translation of the current one
  • versions.active is a list of "active, built and not hidden" versions (basically, the useful ones) 1

Fields removed

  • addons.non_latest_version_warning.versions
  • addons.flyout.downloads
  • addons.flyout.translations
  • addons.flyout.versions

These fields removed in this PR are not required anymore since the same information is into the new fields (they render the full object instead of just a selection of fields). This makes them great for general purpose data and untied them from a specific addon in particular.

Related issues

Requires

Required by

Footnotes

  1. I'm happy to give this field a different name if you have a better suggestion here.

Add `aliases` field to the `VersionSerializer` that's used to serialize
`Version` objects. This field is a list of versions the current version "points
to". Example `latest` version "points to" `origin/main`, and `stable` version
"points to" `v3.12`.

This is known in the code as "original stable/latest version".

Besides, this PR adds `versions.stable` and `versions.latest` fields to the
addons API which will be useful to solve front-end issues in a more generic way.

This comment was marked as off-topic.

@humitos
Copy link
Member Author

humitos commented Mar 21, 2024

It seems I ended up wanting the versions.active field again that lists all the "active non-hidden" versions. It may be a good opportunity to add them since it's a pretty generic query we will share among a few addons. This will be pretty similar to APIv3 response for Project details: https://docs.readthedocs.io/en/stable/api/v3.html#get--api-v3-projects-

This is the pattern I want to have here, serializing the full object instead of
a few small set of fields. This is a lot more generic, simplifies the
interaction between the API and JS Addons.

Besides, it exposes the same objects as the regular APIv3 which won't change
over time.
@humitos
Copy link
Member Author

humitos commented Mar 26, 2024

I ended up implementing exactly what I want here: projects.translations and versions.active. This gives us a lot of flexibility and expose all the fields we need for addons.

However, this shows the problem we have been having with the resolver for the URLs which makes a lot of queries to the DB:

test_number_of_queries_project_version_slug - AssertionError: 492 != 26 : 492 queries executed, 26 expected
test_number_of_queries_url - AssertionError: 494 != 27 : 494 queries executed, 27 expected
test_number_of_queries_url_subproject - AssertionError: 500 != 33 : 500 queries executed, 33 expected
test_number_of_queries_url_translations - AssertionError: 139 != 31 : 139 queries executed, 31 expected

I'd like to work on the performance of this because it's currently unacceptable for project with ~30 versions.

Related:

@humitos
Copy link
Member Author

humitos commented Mar 27, 2024

I was able to optimize this a lot 😄 . I remove the .only() we were using before since we weren't rendering full objects here. I'm down to ~100 now.

I found it's pretty expensive for us to resolve a Version URL. Mainly because it needs to check if the project as a custom domain, and also if it's from a subproject. So, for each version we want to resolve the documentation URL, we need to perform these queries:

E   92. SELECT "projects_projectrelationship"."id", "projects_projectrelationship"."parent_id", "projects_projectrelationship"."child_id", "projects_projectrelationship"."alias", T3."id", T3."pub_date", T3."modified_date", T3."name", T3."slug", T3."description", T3."repo", T3."repo_type", T3."project_url", T3."canonical_url", T3."versioning_scheme", T3."single_version", T3."default_version", T3."default_branch", T3."requirements_file", T3."custom_prefix", T3."custom_subproject_prefix", T3."external_builds_enabled", T3."external_builds_privacy_level", T3."cdn_enabled", T3."analytics_code", T3."analytics_disabled", T3."container_image", T3."container_mem_limit", T3."container_time_limit", T3."build_queue", T3."max_concurrent_builds", T3."allow_promos", T3."ad_free", T3."is_spam", T3."show_version_warning", T3."readthedocs_yaml_path", T3."featured", T3."skip", T3."delisted", T3."privacy_level", T3."language", T3."programming_language", T3."main_language_project_id", T3."has_valid_webhook", T3."has_valid_clone", T3."remote_repository_id", T3."documentation_type" FROM "projects_projectrelationship" INNER JOIN "projects_project" T3 ON ("projects_projectrelationship"."parent_id" = T3."id") WHERE "projects_projectrelationship"."child_id" = 1 ORDER BY "projects_projectrelationship"."id" ASC LIMIT 1
E   93. SELECT "projects_domain"."id", "projects_domain"."modified", "projects_domain"."created", "projects_domain"."project_id", "projects_domain"."domain", "projects_domain"."machine", "projects_domain"."cname", "projects_domain"."canonical", "projects_domain"."https", "projects_domain"."count", "projects_domain"."ssl_status", "projects_domain"."skip_validation", "projects_domain"."validation_process_start", "projects_domain"."hsts_max_age", "projects_domain"."hsts_include_subdomains", "projects_domain"."hsts_preload" FROM "projects_domain" WHERE ("projects_domain"."project_id" = 1 AND "projects_domain"."canonical") ORDER BY "projects_domain"."canonical" DESC, "projects_domain"."machine" DESC, "projects_domain"."domain" ASC LIMIT 1

They could be cached (and probably the DB is already doing this but we are still hitting the DB, tho) since it's based on the project and not the version and it will always return the same for all the versions of the project. I will take a deeper look at the code and see if I can optimize them.

@humitos
Copy link
Member Author

humitos commented Mar 27, 2024

Yeap, basically those extra ~80 queries are because of this resolve:

def get_documentation(self, obj):
return Resolver().resolve_version(
project=obj.project,
version=obj,
)

@humitos
Copy link
Member Author

humitos commented Mar 27, 2024

I was able to share a Resolver() instance between the rendering of the serializer. Actually, now we are returning more data than before with a reduced number of queries with this improvement 💯 (note that I lowered down the numbers in the tests)

There is only one that I had to increase, the test for translations. This is because I'm not sharing the resolver with Project.get_docs_url. I don't want to touch that code just yet and I put a TODO comment there to come back later. We don't have a many projects with lot of translations so, it's not a huge priority for now.

@humitos humitos marked this pull request as ready for review April 8, 2024 14:43
@humitos humitos requested a review from a team as a code owner April 8, 2024 14:43
@humitos humitos requested a review from agjohnson April 8, 2024 14:43
humitos added a commit to readthedocs/cpython that referenced this pull request Apr 9, 2024
@humitos
Copy link
Member Author

humitos commented Apr 10, 2024

It would be awesome to get a review on this PR. It will unblock a few other PRs that are related to addons. Ideally, I'd like to deploy all of this next week.

humitos added a commit to readthedocs/addons that referenced this pull request Apr 11, 2024
Implements the changes in the API made in
readthedocs/readthedocs.org#11205. We need to
merge that PR and deploy addons together with the changes in this PR.

In this PR there are mainly no changes in the logic. The most important
thing to mention here is that we are standardizing the usage of the API
response and pinning to `v1` now (we were using `v0-alpha`). This means:

- we will be exposing the `v1` to users via the js custom event (see
#64)
- we are using standard/shared APIv3 serializers for resources for this
`/addons/` endpoint
- any breaking change we have to do in the future it will increase the
API response version


I'm not sure there are too much to review here, but I'd appreciate at
least a quick look at these changes. I'm happy to answer some questions
you may have here or on the underlying PR about the API response
changes.


### Related:
* Closes #132 
* Unblocks #265
* Unblocks theme integration (our theme and CPython's one)
* Requires readthedocs/readthedocs.org#11205

---------

Co-authored-by: Anthony <aj@ohess.org>
@@ -322,13 +323,15 @@ class VersionURLsSerializer(BaseLinksSerializer, serializers.Serializer):
dashboard = VersionDashboardURLsSerializer(source="*")

def get_documentation(self, obj):
return Resolver().resolve_version(
resolver = getattr(self.parent, "resolver", Resolver())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we pass this to the constructor? That way is more explicit, rather than relying on the attribute being present in the parent serialzer.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe at this line

urls = VersionURLsSerializer(source="*")

However, that would be a new instance of the Resolver since it's at the class definition and we want to share the instance the parent serializer is using due to caching purposes.

Are you thinking something different here?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like you could pass this in the context of the serializer, something like:

class ChildSerializer:
    def get_documentation(self, obj):
        resolver = self.context.get('resolver')
        ...

class ParentSerializer:
    child = ChildSerializer()
    def __init__(self, resolver):
        self.fields['child'].context['resolver'] = resolver

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. It sounds similar to what I did but more standardized. I found some documentation about this at https://www.django-rest-framework.org/api-guide/serializers/#including-extra-context

I opened #11280 to track this refactor as it's not a blocker for now and it may not be required in the future anyways.

readthedocs/api/v3/serializers.py Outdated Show resolved Hide resolved
def get_documentation(self, obj):
version_slug = getattr(self.parent, "version_slug", None)
resolver = getattr(self.parent, "resolver", Resolver())
return obj.get_docs_url(version_slug=version_slug, resolver=resolver)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we already have the version object, we can just call resolver.resolve_version, so it doesn't query the version object from the DB.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made this change, but for some reason the amount of queries increased using resolver.resolve_version:

TestReadTheDocsConfigJson::test_number_of_queries_url_subproject - AssertionError: 32 != 31 : 32 queries executed, 31 expected
TestReadTheDocsConfigJson::test_number_of_queries_url_translations - AssertionError: 60 != 56 : 60 queries executed, 56 expected

This is the diff applied:

diff --git a/common b/common
--- a/common
+++ b/common
@@ -1 +1 @@
-Subproject commit 4af0fffd2cbeeb40f0a71b875beb99d6dc88a355
+Subproject commit 4af0fffd2cbeeb40f0a71b875beb99d6dc88a355-dirty
diff --git a/readthedocs/api/v3/serializers.py b/readthedocs/api/v3/serializers.py
index f0eb9237c..03c4d9a03 100644
--- a/readthedocs/api/v3/serializers.py
+++ b/readthedocs/api/v3/serializers.py
@@ -475,9 +475,9 @@ class ProjectURLsSerializer(BaseLinksSerializer, serializers.Serializer):
         return self._absolute_url(path)
 
     def get_documentation(self, obj):
-        version_slug = getattr(self.parent, "version_slug", None)
+        version = getattr(self.parent, "version", None)
         resolver = getattr(self.parent, "resolver", Resolver())
-        return obj.get_docs_url(version_slug=version_slug, resolver=resolver)
+        return resolver.resolve_version(project=obj, version=version)
 
 
 class RepositorySerializer(serializers.Serializer):
@@ -801,8 +801,8 @@ class ProjectSerializer(FlexFieldsModelSerializer):
         }
 
     def __init__(self, *args, **kwargs):
-        # Receive a `Version.slug` here to build URLs properly
-        self.version_slug = kwargs.pop("version_slug", None)
+        # Receive a `Version` here to build URLs properly
+        self.version = kwargs.pop("version", None)
 
         # Use a shared resolver to reduce the amount of DB queries while
         # resolving version URLs.
diff --git a/readthedocs/proxito/views/hosting.py b/readthedocs/proxito/views/hosting.py
index f70638632..52a477ead 100644
--- a/readthedocs/proxito/views/hosting.py
+++ b/readthedocs/proxito/views/hosting.py
@@ -354,12 +354,12 @@ class AddonsResponse:
                 "current": ProjectSerializerNoLinks(
                     project,
                     resolver=resolver,
-                    version_slug=version.slug if version else None,
+                    version=version,
                 ).data,
                 "translations": ProjectSerializerNoLinks(
                     project_translations,
                     resolver=resolver,
-                    version_slug=version.slug if version else None,
+                    version=version,
                     many=True,
                 ).data,
             },

I will leave the code as-is for now. Let me know if you understand why or feel free to open an issue to track this performance improvement.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My two guesses:

  • Maybe the version doesn't have all fields (e.g, "only" was used).
  • A method needs to be cached.

readthedocs/proxito/urls.py Outdated Show resolved Hide resolved
readthedocs/proxito/views/hosting.py Outdated Show resolved Hide resolved
@humitos humitos requested a review from stsewd April 15, 2024 09:22
@humitos
Copy link
Member Author

humitos commented Apr 15, 2024

@stsewd I made all the requested changes and applied the suggestions, thanks! Let me know if this is ready to merge and deploy in your opinion.

humitos added a commit that referenced this pull request Apr 15, 2024
Installs a version of our theme from a pull request that has support for the
Addons integration using the `CustomEvent`.

Related: readthedocs/sphinx_rtd_theme#1526
Requires: #11205
Copy link
Member

@stsewd stsewd left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking, feel free to do the other improvements in another PR or open some issues.

@humitos humitos merged commit d7f3b08 into main Apr 16, 2024
7 checks passed
@humitos humitos deleted the humitos/addons-api-versions-aliases branch April 16, 2024 08:27
humitos added a commit that referenced this pull request Apr 16, 2024
Installs a version of our theme from a pull request that has support for the
Addons integration using the `CustomEvent`.

Related: readthedocs/sphinx_rtd_theme#1526
Requires: #11205
humitos added a commit that referenced this pull request Apr 18, 2024
…11279)

* Docs: use the `sphinx-rtd-theme` with support for addons integration

Installs a version of our theme from a pull request that has support for the
Addons integration using the `CustomEvent`.

Related: readthedocs/sphinx_rtd_theme#1526
Requires: #11205

* Disable fail on warning temporary

* Delete doc-diff since it's included in addons now
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Archived in project
Development

Successfully merging this pull request may close these issues.

None yet

2 participants