Optimizing Performance in Ruby on Rails Applications with GraphQL Layer

 

Wroclaw, Poland

April 13, 2024

 

  • Brazil
  • Ruby On Rails since 2008
  • https://ca.ios.ba
  • @caiosba
  • San Francisco - California
  • Meedan Software Engineer since 2011      
  • https://meedan.com
  • @meedan

Check

meedan.com/check

   github.com/meedan/check-api

   github.com/meedan/check

The focus of this talk is on...

GraphQL

Ruby On Rails

... but it doesn't need to be!

Many concepts and architectures are applied to other frameworks and technical stacks.

More disclaimers!

  • There is no silver bullet
  • Premature Optimization
  • The problems and solutions presented here worked for me, but it depends on different factors (e.g., database) and needs - and remember, every decision has a trade off (maintainability, readability, dependencies, etc.)

GraphQL

"GraphQL is a query language for your API, and a server-side runtime for executing queries by using a type system you define for your data"

 

 

REST

Media

Comment

Tag

1

*

User

*

*

*

1

GET /api/v1/medias/1
GET /api/v1/medias/1/comments
GET /api/v1/medias/1/tags
GET /api/v1/medias/1/comments/1
GET /api/v1/users/1?fields=avatar,name
GET /api/v1/users/2?fields=avatar,name
GET /api/v1/users/3?fields=avatar,name
...

Reusable endpoints

GET /api/v1/medias/1?include=comments&count=5

GET /api/v1/medias/1?include=comments,tags
    &comments_count=5&tags_count=5

GET /api/v1/medias/1?fields=comments(text,date)
    &tags(tag)


...


GET /api/v1/media_and_comments/1
GET /api/v1/media_comments_and_tags/1
GET /api/v1/media_comments_tags_and_users/1
GET /api/v1/medias/1?include=comments&count=5

GET /api/v1/medias/1?include=comments,tags
    &comments_count=5&tags_count=5

GET /api/v1/medias/1?fields=comments(text,date)
    &tags(tag)


...


GET /api/v1/media_and_comments/1
GET /api/v1/media_comments_and_tags/1
GET /api/v1/media_comments_tags_and_users/1

Too many requests!

GraphQL

One endpoint to rule them all

POST /graphql

POST /api/graphql?query=
{
  media(id: 1) {
    title
    embed
    tags(first: 3) {
      tag
    }
    comments(first: 5) {
      created_at
      text
      user {
        name,
        avatar
      }
    }
  }
}
POST /api/graphql?query=
{
  media(id: 1) {
    title
    embed
    tags(first: 3) {
      tag
    }
    comments(first: 5) {
      created_at
      text
      user {
        name,
        avatar
      }
    }
  }
}

Media

Comment

Tag

1

*

User

*

*

*

1

~

POST /api/graphql?query=
{
  media(id: 1) {
    title
    embed
    tags(first: 3) {
      tag
    }
    comments(first: 5) {
      created_at
      text
      user {
        name,
        avatar
      }
    }
  }
}
{
  "media": {
    "title": "Avangers Hulk Smash",
    "embed": "<iframe src=\"...\"></iframe>",
    "tags": [
      { "tag": "avengers" },
      { "tag": "operation" }
    ],
    "comments": [
      {
        "text": "This is true",
        "created_at": "2016-09-18 15:04:39",
        "user": {
          "name": "Ironman",
          "avatar": "http://[...].png"
        }
      },
      ...
    ]
  }
}

GraphQL

Ruby On Rails

Types

  • Custom types
  • Arguments
  • Fields
  • Connections

Mutations

mutation { 
  createMedia(
    input: {
      url: "http://youtu.be/7a_insd29fk"
      clientMutationId: "1"
    }
   )
   {
     media {
       id
     }
   }
}

Mutations make changes on your server side.

CRUD:

Queries: Read

Mutations:

  • Create
  • Update
  • Delete
# mutation { 
     createMedia(
#     input: {
        url: "http://youtu.be/7a_insd29fk"
#       clientMutationId: "1"
#     }
#    )
     {
       media {
         id
       }
     }
# }

Mutation name

Input parameters

Desired output

So flexible!

😊

 

Too flexible!

😔

 

query {
  teams(first: 1000) {
    name
    profile_image
    users(first: 1000) {
      name
      email
      posts(first: 1000) {
        title
        body
        tags(first: 1000) {
          tag_name
        }
      }
    }
  }
}

Nested queries can become a real problem.

 

The actual complexity of a query and cost of some fields can get hidden by the expressiveness of the language.

 

Let's see some strategies to handle this.

But first things first... You can't really fix what you can't test

# Some controller test

gql_query = 'query { posts(first: 10) { title, user { name } } }'
assert_queries 5 do
  post :create, params: { query: gql_query }
end

Keep track if some refactoring or code change introduces regressions on how some GraphQL queries are executed.

# Some test helper

def assert_queries(max, &block)
  query_cache_enabled = ApplicationRecord.connection.query_cache_enabled
  ApplicationRecord.connection.enable_query_cache!
  queries  = []
  callback = lambda { |_name, _start, _finish, _id, payload|
    if payload[:sql] =~ /^SELECT|UPDATE|INSERT/ && !payload[:cached]
      queries << payload[:sql]
    end
  } 
  ActiveSupport::Notifications.subscribed(callback, "sql.active_record", &block)
  queries
ensure
  ApplicationRecord.connection.disable_query_cache! unless query_cache_enabled
  message = "#{queries.size} expected to be less or equal to #{max}."
  assert queries.size <= max, message
end

Under the hood, one way is:

Alright, tests are passing! Time to push, deploy and... monitor

Regular HTTP request monitoring is not enough...

  • Things can get better with more structured logging
     
  • Useful for integrating with other alerting tools (Uptime, etc.)

We know how long GraphQL HTTP requests are taking, but how long the GraphQL query actually takes?

OpenTelemetry / Honeycomb

Great! Now we can see how long each step of the GraphQL actually takes... but which fields?

Apollo GraphQL Studio

Apollo GraphQL Studio

Apollo GraphQL Studio

Other tools

  • graphql-metrics
  • Grafana
  • Stellate
  • Hasura
  • ...

Now that we can track and monitor GraphQL requests, how to actually improve them?

Avoid N+1 Queries

query {
  posts(first: 5) {
    id
    author {
      name
    }
  }
}
Post Load (0.9ms)  SELECT "posts".* FROM "posts"
User Load (0.3ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
User Load (0.3ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 2], ["LIMIT", 1]]
User Load (0.3ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 3], ["LIMIT", 1]]
User Load (0.3ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 4], ["LIMIT", 1]]
User Load (0.3ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 5], ["LIMIT", 1]]
class User < ApplicationRecord
  has_many :posts
end

class Post < ApplicationRecord
  belongs_to :user
  has_many :tags
end

In a REST endpoint, you'd typically predict returning both posts and their authors, prompting eager-loading in your query. However, since we can't anticipate what the client will request here, we can't always preload the owner.

graphql-batch gem

# app/graphql/types/post_type.rb
field :author, Types::UserType do
  resolve -> (post, _args, _context) {
    RecordLoader.for(User).load(post.user_id)
  }
end
Post Load (0.5ms)  SELECT  "posts".* FROM "posts" ORDER BY "posts"."id" DESC LIMIT $1  [["LIMIT", 5]]
User Load (0.4ms)  SELECT "users".* FROM "users" WHERE "users"."id" IN (1, 2, 3, 4, 5)

BatchLoader could be used as well.

This works well for belongs_to

relationships, but what about has_many?

graphql-preload gem

# app/graphql/types/post_type.rb
field :tags, !types[Types::TagType] do
  preload :tags
  resolve -> (post, _args, _ctx) { post.tags }
end

But still, it can suffer when dealing with more complex queries... What if we could predict queried data precisely and create a dynamic hash to preload associations?

graphql gem: lookahead

field :users, [Types::UserType], null: false, extras: [:lookahead]

def users(lookahead:)
  # Do something with lookahead
end
query
└── users
    ├── id
    ├── name
    └── posts
        ├── id
        └── title

The lookahead object is like a tree structure that represents the information you need in order to optimize your query. In practice, it's way more complicated than this.

Avoid complex queries

query {
  teams(first: 1000) {
    users(first: 1000) {
      name
      posts(first: 1000) {
        tags(first: 1000) {
          tag_name
        }
        author {
          posts(first: 1000) {
            title
          }
        }
      }
    }
  }
}
  • Queries can easily get too nested, too deep and even circular

graphql gem: max_depth

# app/graphql/your_schema.rb
YourSchema = GraphQL::Schema.define do
  max_depth 4 # adjust as required
  use GraphQL::Batch
  enable_preloading
  mutation(Types::MutationType)
  query(Types::QueryType)
end

Applying timeouts

# Added to the bottom of app/graphql/your_schema.rb
YourSchema.middleware <<
  GraphQL::Schema::TimeoutMiddleware.new(max_seconds: 5) do |e, q|
    Rails.logger.info("GraphQL Timeout: #{q.query_string}")
  end

graphql-ruby-fragment_cache gem

class PostType < BaseObject
  field :id, ID, null: false
  field :title, String, null: false, cache_fragment: true
end
class QueryType < BaseObject
  field :post, PostType, null: true do
    argument :id, ID, required: true
  end

  def post(id:)
    last_updated_at = Post.select(:updated_at).find_by(id: id)&.updated_at
    cache_fragment(last_updated_at, expires_in: 5.minutes) { Post.find(id) }
  end
end

Our own approach to caching fields for high-demanding fields (events-based, meta-programming)

# app/models/media_rb
cached_field :last_seen,
  start_as: proc { |media| media.created_at },
  update_es: true,
  expires_in: 1.hour,
  update_on: [
    {   
      model: TiplineRequest,
      if: proc { |request| request.associated_type == 'Media' },
      affected_ids: proc { |request| request.associated.medias },
      events: {
        create: proc { |request| request.created_at },
      }   
    },  
    {   
      model: Relationship,
      if: proc { |relationship| relationship.is_confirmed? },
      affected_ids: proc { |relationship| relationship.parent.medias },
      events: {
        save: proc { |relationship| relationship.created_at },
        destroy: :recalculate
      }   
    }   
  ]

Single queries are handled... now what if many queries are sent at the same time?

For example, Apollo's client query batching or even React's Relay:

Then, the backend is able to process those concurrently, using graphql gem's multiplex

# Prepare the context for each query:
context = {
  current_user: current_user,
}

# Prepare the query options:
queries = [
  {
   query: "query postsList { posts { title } }",
   variables: {},
   operation_name: 'postsList',
   context: context,
 },
 {
   query: "query latestPosts ($num: Int) { posts(last: $num) }",
   variables: { num: 3 },
   operation_name: 'latestsPosts',
   context: context,
 }
]

# Execute concurrently
results = YourSchema.multiplex(queries)

Multiplex:

  • Saves time with authorization and HTTP overhead
  • When enabled, even one single query is executed this way (so, "multiplex of one")
  • Multiplex runs have their own context, analyzers and instrumentation
  • Each query is validated and analyzed independently. The results array may include a mix of successful results and failed results.
  • Again, monitoring is important to be sure that one specific query is not delaying the others

This is a lot for GraphQL queries, what about mutations?

A couple of quick tips for that:

  • Background jobs (Sidekiq, Active Job, etc.)
     
  • Bulk-operations (upsert_all, insert_all etc.)

Conclusion:

"With great systems comes great responsibility"

No, seriously, conclusion:

  • Prioritize performance optimization to enhance user experience and application scalability.

  • Identify and Address Bottlenecks: Regularly monitor and profile your application to identify performance bottlenecks, focusing on database queries, Ruby code execution, and GraphQL query optimization.

  • Optimization is an Ongoing Process: Performance optimization is not a one-time task; it's an ongoing process that requires continuous monitoring, analysis, and improvement.

Dziękuję! :)

https://ca.ios.ba

@caiosba

Optimizing Performance in Ruby on Rails Applications with GraphQL Layer

By Caio Sacramento

Optimizing Performance in Ruby on Rails Applications with GraphQL Layer

  • 157