Skip to content

feat(gql): Introduce lookahead to eager load relationships (N+1) #3695

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

Merged
merged 1 commit into from
May 28, 2025

Conversation

julienbourdeau
Copy link
Contributor

@julienbourdeau julienbourdeau commented May 21, 2025

Context

By design, GraphQL can create a lot of N+1. The graphql gem provides a way to inspect the query and eager load accordingly.

This is generally not an issue because Lago's vast majority of the load is on the REST API, not the dashboard. But still, it can lead to slow page loading.

Description

In the SubscriptionAlertResolver, a query can trigger 2 extra requests per item, one for thresholds, one for billable metric.

With lookahead, we check if billableMetric or thresholds are request end ensure we call includes(:billable_metric) or includes(:thresholds) respectively.

Another useful technique is to also expose ids alongside the relationships, like in the Alert Object.

How to use

  • Add extras Resolver configuration
  • Make sure you receive the lookahead: named argument
  • Explore lookahead object with selection and select?
class SomethingResolver < Resolvers::BaseResolver
  REQUIRED_PERMISSION = "something:view"

  # Explicitly tell GraphQL that you want to use `lookahead`
  extras [:lookahead]

  argument :limit, Integer, required: false
  argument :page, Integer, required: false

  type Types::Something::Object.collection_type, null: false

  def resolve(lookahead:, limit: nil, page: nil) # Automatically receive lookahead
    scope = Something.all

    # Notice the :collection outer object
    if lookahead.selection(:collection).selects?(:organization)
      scope = scope.includes(:organization)
    end

    if lookahead.selection(:collection).selection(:organization).selects?(:billing_entity)
      scope = scope.includes(organization: :billing_entity)
    end

    scope
  end
end

Links:

Testing

This PR also sets up bullet gem for tests, until now it was only a dev dependency.

Notice the with_bullet: true example metadata.

context "when doing something" do
  let(:model) { ... }

  it "does something", with_bullet: true do
    #...

    # Must be start manually for maximum flexibility
    Bullet.start_request

    subject # Service.call, gql query, api requests, function call...

    expect(Bullet.notification?).to eq false # Ensure no N+1 !
  end
end

@julienbourdeau julienbourdeau self-assigned this May 21, 2025
Copy link
Contributor

@mariohd mariohd left a comment

Choose a reason for hiding this comment

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

Looks very very good! Good job @julienbourdeau !

@julienbourdeau julienbourdeau merged commit 5537c8e into main May 28, 2025
14 checks passed
@julienbourdeau julienbourdeau deleted the feat/gql-lookahead branch May 28, 2025 07:24
diegocharles pushed a commit that referenced this pull request Jun 2, 2025
## Context

By design, GraphQL can create a lot of N+1. The `graphql` gem provides
[a way to inspect the
query](https://graphql-ruby.org/queries/lookahead.html) and eager load
accordingly.

This is generally not an issue because Lago's vast majority of the load
is on the REST API, not the dashboard. But still, it can lead to slow
page loading.

## Description

In the SubscriptionAlertResolver, a query can trigger 2 extra requests
per item, one for thresholds, one for billable metric.

With lookahead, we check if billableMetric or thresholds are request end
ensure we call `includes(:billable_metric)` or `includes(:thresholds)`
respectively.

Another useful technique is to also [expose ids alongside the
relationships](#3692), like in
the Alert Object.

### How to use

* Add `extras` Resolver configuration
* Make sure you receive the `lookahead:` named argument
* Explore lookahead object with `selection` and `select?`

```ruby
class SomethingResolver < Resolvers::BaseResolver
  REQUIRED_PERMISSION = "something:view"

  # Explicitly tell GraphQL that you want to use `lookahead`
  extras [:lookahead]

  argument :limit, Integer, required: false
  argument :page, Integer, required: false

  type Types::Something::Object.collection_type, null: false

  def resolve(lookahead:, limit: nil, page: nil) # Automatically receive lookahead
    scope = Something.all

    # Notice the :collection outer object
    if lookahead.selection(:collection).selects?(:organization)
      scope = scope.includes(:organization)
    end

    if lookahead.selection(:collection).selection(:organization).selects?(:billing_entity)
      scope = scope.includes(organization: :billing_entity)
    end

    scope
  end
end
```

Links:
* https://graphql-ruby.org/queries/lookahead.html
*
https://gist.github.com/DmitryTsepelev/d0d4f52b1d0a0f6acf3c5894b11a52ca

### Testing

This PR also sets up [`bullet` gem for
tests](https://github.com/flyerhzm/bullet?tab=readme-ov-file#run-in-tests),
until now it was only a dev dependency.

Notice the `with_bullet: true` example metadata.

```ruby
context "when doing something" do
  let(:model) { ... }

  it "does something", with_bullet: true do
    #...

    # Must be start manually for maximum flexibility
    Bullet.start_request

    subject # Service.call, gql query, api requests, function call...

    expect(Bullet.notification?).to eq false # Ensure no N+1 !
  end
end
```
julienbourdeau added a commit that referenced this pull request Jun 2, 2025
We see unexpected memory consumption. We disable lookahead to see if
it's the origin of the issue.

See #3695 for original work
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants