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

Lazy resolvers #4919

Open
gmcgibbon opened this issue Apr 17, 2024 · 3 comments
Open

Lazy resolvers #4919

gmcgibbon opened this issue Apr 17, 2024 · 3 comments

Comments

@gmcgibbon
Copy link
Contributor

Is your feature request related to a problem? Please describe.

Right now, resolver classes are defined like this:

# app/graphql/types/query_type.rb
module Types
  class QueryType < Types::BaseObject
    field :test_field, resolver: TestFieldResolver
  end
end
# app/graphql/types/test_field_resolver.rb
module Types
  class TestFieldResolver < GraphQL::Schema::Resolver
    description "Test"

    type String, null: false

    def resolve
      "Test"
    end
  end
end

In Rails applications with lots of resolvers, this can trigger a lot of autoloads, and contribute to loading a lot of files we don't actually need to execute a query in development mode.

Describe the solution you'd like

GraphQL types have solved this problem already by wrapping type defs in procs like this:

# app/graphql/types/test_field_resolver.rb
module Types
  class TestFieldResolver < GraphQL::Schema::Resolver
    description "Test"

    type(proc { String }, null: false)

    def resolve
      "Test"
    end
  end
end

I think it would be great if we could have this syntax to delay loading resolvers until actually used:

# app/graphql/types/query_type.rb
module Types
  class QueryType < Types::BaseObject
    field :test_field, resolver: proc { TestFieldResolver }
  end
end

But it raises an error:

/Users/gannon/.gem/ruby/3.2.0/gems/graphql-2.3.0/lib/graphql/schema/field.rb:158:in `scoped?': undefined method `type_expr' for #<Proc:0x0000000106aa7ec0 /Users/gannon/lazy_demo/app/graphql/types/query_type.rb:6> (NoMethodError)

          resolver_type = @resolver_class.type_expr
                                         ^^^^^^^^^^
	from /Users/gannon/.gem/ruby/3.2.0/gems/graphql-2.3.0/lib/graphql/schema/field.rb:314:in `initialize'
	from /Users/gannon/.gem/ruby/3.2.0/gems/graphql-2.3.0/lib/graphql/schema/field.rb:125:in `new'
	from /Users/gannon/.gem/ruby/3.2.0/gems/graphql-2.3.0/lib/graphql/schema/field.rb:125:in `from_options'
	from /Users/gannon/.gem/ruby/3.2.0/gems/graphql-2.3.0/lib/graphql/schema/member/has_fields.rb:12:in `field'
	from /Users/gannon/lazy_demo/app/graphql/types/query_type.rb:6:in `<class:QueryType>'
	from /Users/gannon/lazy_demo/app/graphql/types/query_type.rb:5:in `<module:Types>'
	from /Users/gannon/lazy_demo/app/graphql/types/query_type.rb:4:in `<main>'
	from <internal:/opt/rubies/3.2.0/lib/ruby/site_ruby/3.2.0/rubygems/core_ext/kernel_require.rb>:37:in `require'
	from <internal:/opt/rubies/3.2.0/lib/ruby/site_ruby/3.2.0/rubygems/core_ext/kernel_require.rb>:37:in `require'
	from /Users/gannon/.gem/ruby/3.2.0/gems/bootsnap-1.18.3/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:30:in `require'
	from /Users/gannon/.gem/ruby/3.2.0/gems/zeitwerk-2.6.13/lib/zeitwerk/kernel.rb:26:in `require'
	from /Users/gannon/lazy_demo/app/graphql/lazy_demo_schema.rb:3:in `<class:LazyDemoSchema>'
	from /Users/gannon/lazy_demo/app/graphql/lazy_demo_schema.rb:1:in `<main>'
	from <internal:/opt/rubies/3.2.0/lib/ruby/site_ruby/3.2.0/rubygems/core_ext/kernel_require.rb>:37:in `require'
	from <internal:/opt/rubies/3.2.0/lib/ruby/site_ruby/3.2.0/rubygems/core_ext/kernel_require.rb>:37:in `require'
	from /Users/gannon/.gem/ruby/3.2.0/gems/bootsnap-1.18.3/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:30:in `require'
...

Describe alternatives you've considered

I considered opening an issue for a feature that lazily loads the root query or mutation, but that seems a little more difficult from a public API perspective, and doesn't really solve the problem as granularly as I'd like. It would defer loading all mutation code until the first mutation, and all query code until the first query. Lazy resolvers should theoretically only load the code and types needed to execute a query/mutation while keeping the root types intact.

I also thought about creating a schema for queries and a different one for mutations to split loading the root query and mutation. This would work, but it seems like a hack that doesn't really address the root concern in the GraphQL gem.

Additional context

I noticed this problem when profiling my application and seeing that AppNameSchema.execute(...) would load both query and mutation roots (and subsequent resolvers). My application's schema is really big and takes ~3 seconds to load the mutation root and ~2 seconds to load the query root, so this feature would really help the time to first query in autoloaded environments.

cc @swalkinshaw

@gmcgibbon gmcgibbon changed the title Lazy enumerator class Lazy resolvers Apr 17, 2024
@rmosolgo
Copy link
Owner

Hey, thanks for the detailed write-up. I'm definitely open to exploring options for loading the schema as-needed.

Currently, any GraphQL query loads the entire schema, because, as you noticed, query(...) and mutation(...) traverse their given types and add everything to the schema's registry of types. (subscription(...) does the same thing.) Those methods also resolve any types given as Procs -- the Procs change the order that types are loaded in (Proc-based types are loaded last), but they're all still loaded during the mutation(...) or query(...) call.

I think making a GraphQL::Schema load types as-needed would be a good bit of work, something like:

  • Modify query(...)/mutation(...)/subscription(...) to store their inputs but not traverse them immediately
  • Update the schema loading code (Schema.add_type_and_traverse) to only load code as-needed (or not at all? and instead add codepaths which populate the schema's type registry as they run...)
  • Modify validation, analysis, and runtime code to avoid calls to GraphQL::Schema.types, which returns the entire type registry; instead make sure they only ever ask for the minimum required types.

If we did all that, we'd probably want to keep the old code too, so that in Production, the whole schema could be loaded upfront -- before the first request comes in, to reduce latency, and before any forking, to improve memory sharing in copy-on-write situations.

So, it's all possible -- but I think it will be a bit more than accepting procs for resolver: 😅

@gmcgibbon
Copy link
Contributor Author

gmcgibbon commented Apr 18, 2024

Thanks for mapping out the process @rmosolgo. I'll try to roughly implement this and let you know if I get stuck. Once we have something working I imagine it will be easier to accept a patch.

@gmcgibbon
Copy link
Contributor Author

gmcgibbon commented Apr 19, 2024

GraphQL types have solved this problem already

Looks like I'm wrong about this. As soon as the schema is loaded everything is loaded (which also explains my particularly flamegraphs). Proc typed fields just defer loading when you load them in isolation (eg. MyGraphqlSchema => loads all proc types but MyGraphqlObject => doesn't load proc types.) It might be easier to work on lazily evaluating types first, since we're already halfway there. It looks like there's a ton of work behind lazy resolvers.

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

No branches or pull requests

2 participants