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

Dy/pm 15994 query optimization #294

Closed
wants to merge 11 commits into from

Conversation

davidyeeprocore1
Copy link

@davidyeeprocore1 davidyeeprocore1 commented Jul 26, 2022

Currently, blueprinter does not attempt to eager load associations for collections of activerecords (ActiveRecordRelation). This results in n+1 queries being performed. The way this is resolved at the moment is the client (application that ingests blueprinter) must perform preloading. This PR aims to solve the n+1 query issue from within blueprinter rather than placing the burden on the client.

object_to_hash(obj,
view_name: view_name,
local_options: local_options)
if object.respond_to?(:reflect_on_all_associations) && preload_associations?
Copy link
Contributor

Choose a reason for hiding this comment

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

Would you flip this comparison so that it is
if preload_associations? && object.respond_to?(:reflect_on_all_associations)
While overall impact is small, we should put the less performant method after the && as it is not always needed to execute.

# Example: object_relation is an Account that has_one :user and has_many :subscriptions.
# The hash returned would be {:user => :user, :subscriptions => :subscriptions,
# :subscription_ids => :subscriptions}. Then the calling method would preload
# :subscriptions and :user
Copy link
Contributor

@philipqnguyen philipqnguyen Aug 11, 2022

Choose a reason for hiding this comment

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

We've been using yardoc meta tags to generate our documentation on https://rubydoc.info/gems/blueprinter/0.25.3/Blueprinter/Base. To keep it consistent, would you change these docs to use yardoc tags. And same where you have the other method docs as well.

collection_associated_hash
end
end
end
Copy link
Contributor

Choose a reason for hiding this comment

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

missing newline

# The hash returned would be {:user => :user, :subscriptions => :subscriptions,
# :subscription_ids => :subscriptions}. Then the calling method would preload
# :subscriptions and :user
def get_field_to_association_hash(object_relation)
Copy link
Contributor

@philipqnguyen philipqnguyen Aug 11, 2022

Choose a reason for hiding this comment

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

instead of get_field_to_association_hash it should be field_to_association_hash or field_to_association. Ruby methods typically are nouns representing the return value.

@@ -6,6 +6,8 @@ def self.included(base)

module SingletonMethods
include TypeHelpers
include AssociationHelpers
@@should_preload_associations = false
Copy link
Contributor

Choose a reason for hiding this comment

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

Two things:

  1. Instead of should_preload_associations, it should just be preload_associations. Otherwise it seems unsure and somewhat like a test file.
  2. This should be in configurations.rb https://github.com/procore/blueprinter/blob/60b8f94616e18aa0346fa3c697b9e30c66c4ec8c/lib/blueprinter/configuration.rb#L3-L18 so that users can configure their options like everything else.

Copy link
Author

@davidyeeprocore1 davidyeeprocore1 Aug 15, 2022

Choose a reason for hiding this comment

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

I see what you are saying here @philipqnguyen . One thing to note is that the preload_associations variable you are referencing is not meant to be a permanent field. It would only exist until we are done testing this feature. After that time, blueprinter will try to eager load associations that would otherwise not be eager loaded. My main concerns are:

  • I will need to introduce more code into the client.
  • Developers in the client may start using this option which will could result in blueprinter continuing to user eager loading even if we turn the flag off. This would be a problem if a bug arises and we want to roll back the eager loading changes in blueprinter. We will not be able to do so without a redeploy

Copy link
Contributor

Choose a reason for hiding this comment

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

Blueprinter is a public gem and changes to Blueprinter should be ready for use by the greater Ruby community.
If the goal in this PR is just to test this feature, I suggest 2 things:
a) Either have the monolith's Gemfile reference this branch
b) Or directly override the monolith's Blueprinter with these changes (basically a monkey patch).

When you have completed the test in the monolith, then we can introduce the change into Blueprinter itself.

Copy link
Author

@davidyeeprocore1 davidyeeprocore1 Aug 19, 2022

Choose a reason for hiding this comment

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

I like the idea of pointing the Gemfile to this branch. I would also like to be able to toggle this feature with a feature flag, as opposed to a config that would allow developers to use the feature in other places as we test. The idea being that we would only scope the change to a narrow part of the code (extended view of tasks controller).

I would propose:

  1. Pointing the client repo to this local branch via the Gemfile as you suggested.
  2. Using the feature flag as is currently implemented on this branch
  3. Remove the feature flag once we're done testing then merge into master

This approach would decrease the likelihood of needing a redeploy in case something goes wrong because we would be able to toggle the feature flag off.

Please let me know what you think. Thank you

object_to_hash(obj,
view_name: view_name,
local_options: local_options)
if object.respond_to?(:reflect_on_all_associations) && preload_associations?
Copy link
Contributor

Choose a reason for hiding this comment

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

What happens if the object given is already eager loaded?

Copy link
Author

Choose a reason for hiding this comment

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

Good call. I'll add a check for this

collection_associated_hash[association_name_as_symbol] = association_name_as_symbol
end
# do our best to determine the autogenerated method name. Does not account for custom names
collection_ids = association_name_as_symbol.to_s[0..-2] + '_ids'
Copy link
Contributor

@philipqnguyen philipqnguyen Aug 18, 2022

Choose a reason for hiding this comment

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

This took me some time to understand why you were doing [0..-2]. You can instead use association_name_as_symbol.to_s.singularize, which is part of ActiveSupport in Rails. This section of the code should only be executing inside Rails, so you should have access to that method.

collection_associated_hash = Hash.new
if object_relation[0].respond_to?(association_name_as_symbol)
collection_associated_hash[association_name_as_symbol] = association_name_as_symbol
end
Copy link
Contributor

Choose a reason for hiding this comment

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

You might be able to DRY up line 56 - 59 because these are identical to line 39 - 43 in the singular_associated_fields_hash method above

list_of_associations_to_preload << field_to_association_hash[field.name]
end
end
object_relation_with_associations_loaded = object.preload(list_of_associations_to_preload)
Copy link
Contributor

Choose a reason for hiding this comment

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

Does this preload nested associations?
For example, User.preload(posts: :comments)?

Copy link
Author

Choose a reason for hiding this comment

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

No, it does not

@lessthanjacob
Copy link
Contributor

Thanks for opening this PR @davidyeeprocore1, and apologies for the extended delay here.

Looking through the proposed changes, while there's certainly benefit here, my fear is that it over-extends the responsibilities of Blueprinter, which is primarily focused on the serialization of data and less about how the data is aggregated beforehand. Moving forward, ensuring that Blueprinter isn't directly tied to any persistence layer will help improve maintainability and enable more use cases for applications that do not use ActiveRecord.

From my perspective, to ensure clear, enforceable boundaries in application code, any required preloading should be handled prior to invoking Blueprinter.

@njbbaer njbbaer closed this Sep 13, 2023
@njbbaer njbbaer reopened this Sep 13, 2023
@njbbaer njbbaer requested a review from a team as a code owner September 13, 2023 23:00
@ritikesh
Copy link
Collaborator

ritikesh commented Sep 14, 2023

@njbbaer are you considering taking this proposal forward? I agree with @lessthanjacob's take that this definitely extends the responsibilities for a serializer. Also, when you consider supporting things like Arrays, hashes and other objects like Mongo docs, this could mean adding additional overhead just for handling one type of data. ActiveRecord's internal API can change with versions and we have internally burnt our fingers trying to conditional eager_load outside of core AR. It gets really tricky to manage this across rails versions etc. So I would definitely recommend to let end-users optimise for stuff like eager-loading instead of the serializer trying to do it magically.

@njbbaer
Copy link
Contributor

njbbaer commented Sep 14, 2023

@ritikesh I agree with you and @lessthanjacob, this expands Blueprinter's scope probably more than we want. I reached out to @davidyeeprocore1 directly to see if he wants to make a case for it, but if not I'll close this PR shortly.

@jhollinger
Copy link
Contributor

I can understand why this isn't being merged, but Blueprinter should at least expose the introspection capabilities necessary for other tools do this. Otherwise it's just an "N+1 query generator". Would you all be open to accepting a PR like that?

@ritikesh
Copy link
Collaborator

@jhollinger - would something like Bullet be of help?

@jhollinger
Copy link
Contributor

jhollinger commented Sep 19, 2023

@ritikesh No, never found Bullet very useful. Dev/staging data is never complete enough to find all N+1's outside of production. Blueprint already has the info necessary to do eager loading (with a little help from AR reflections maybe). If Blueprint just exposed its definitions for other tools to hook into, it would be so much more powerful (e.g. a blueprinter-activerecord gem for AR eager loading).

@davidyeeprocore1
Copy link
Author

davidyeeprocore1 commented Sep 19, 2023

Hello! Just catching up on this. I like the approach proposed by @jhollinger regarding a blueprinter-activerecord gem. Are you suggesting that we make a separate gem? That could work nicely and reduce the risk of breaking existing code that depends on blueprinter. I completely agree that blueprinter is capable of handling eager loading and should remove this responsibility from the client

Perhaps we can use this PR as inspiration/reference for building the blueprinter-activerecord gem

@njbbaer njbbaer closed this Oct 2, 2023
@ritikesh ritikesh deleted the dy/PM-15994-query-optimization branch November 20, 2023 08:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request needs code review
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants