diff --git a/guides/code_loading.md b/guides/code_loading.md deleted file mode 100644 index 4d5ddd0ba8..0000000000 --- a/guides/code_loading.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -layout: guide -doc_stub: false -search: true -title: Code Loading -section: Other -desc: Read this before deploying GraphQL to production. ---- - -## Autoloading and Eager Loading - -GraphQL Ruby is autoloaded, which means most code won't be loaded until it is referenced. This is optimal for development and test environments where you want to boot your application as fast as possible. However, this is not optimal for production enviromnets. - -Production environments typically include multiple workers, and need to load an application upfront as much as possible. This ensures requests are as fast as possible at the cost of increased boot time, and forked processes don't need to load additional code. Unfortunately, there is no approach to eager code loading that is accepted by all web application frameworks. - -- For Rails applications, a Railtie is included that automatically eager-loads the GraphQL Ruby library for you. No action is required by the developer to opt into this behaviour. - -- For Sinatra applications, please put `configure(:production) { GraphQL.eager_load! }` in your application file. - -- For Hanami applications, please put `environment(:production) { GraphQL.eager_load! }` in your application file. - -- Other frameworks need to manually call `GraphQL.eager_load!` when their application is booting in production mode. If this is not done properly, GraphQL Ruby will log an warning. diff --git a/guides/parser_cache.md b/guides/parser_cache.md deleted file mode 100644 index b0c9a58913..0000000000 --- a/guides/parser_cache.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -layout: guide -doc_stub: false -search: true -title: GraphQL Parser cache -section: Other -desc: How to make parsing GraphQL faster with caching ---- - -Parser caching may be optionally enabled by setting `config.graphql.parser_cache = true` in your Rails application. The cache may be manually built by assigning `GraphQL::Language::Parser.cache = GraphQL::Language::Cache.new("some_dir")`. This will create a directory (`tmp/cache/graphql` by default) that stores a cache of parsed files. - -Much like [bootsnap](https://github.com/Shopify/bootsnap), the parser cache needs to be cleaned up manually. You will need to clear the cache directory for each new deployment of your application. Also note that the parser cache will grow as your schema is loaded, so the cache directory must be writable. diff --git a/guides/schema/definition.md b/guides/schema/definition.md index d7a66aac55..36ce3180a9 100644 --- a/guides/schema/definition.md +++ b/guides/schema/definition.md @@ -40,9 +40,8 @@ For defining GraphQL types, see the guides for those types: {% internal_link "ob ## Types in the Schema -{{ "Schema.query" | api_doc }}, {{ "Schema.mutation" | api_doc }}, and {{ "Schema.subscription" | api_doc}} declare the [entry-point types](https://graphql.org/learn/schema/#the-query-and-mutation-types) of the schema. - -{{ "Schema.orphan_types" | api_doc }} declares object types which implement {% internal_link "Interfaces", "/type_definitions/interfaces" %} but aren't used as field return types in the schema. For more about this specific scenario, see {% internal_link "Orphan Types", "/type_definitions/interfaces#orphan-types" %} +- {{ "Schema.query" | api_doc }}, {{ "Schema.mutation" | api_doc }}, and {{ "Schema.subscription" | api_doc}} declare the [entry-point types](https://graphql.org/learn/schema/#the-query-and-mutation-types) of the schema. +- {{ "Schema.orphan_types" | api_doc }} declares object types which implement {% internal_link "Interfaces", "/type_definitions/interfaces" %} but aren't used as field return types in the schema. For more about this specific scenario, see {% internal_link "Orphan Types", "/type_definitions/interfaces#orphan-types" %} ### Lazy-loading types @@ -88,42 +87,45 @@ Additionally, {{ "Schema.resolve_type" | api_doc }} is called by GraphQL-Ruby to ## Error Handling -{{ "Schema.type_error" | api_doc }} handles type errors at runtime, read more in the {% internal_link "Type errors guide", "/errors/type_errors" %}. - -{{ "Schema.rescue_from" | api_doc }} defines error handlers for application errors. See the {% internal_link "error handling guide", "/errors/error_handling" %} for more. - -{{ "Schema.parse_error" | api_doc }} and {{ "Schema.query_stack_error" | api_doc }} provide hooks for reporting errors to your bug tracker. +- {{ "Schema.type_error" | api_doc }} handles type errors at runtime, read more in the {% internal_link "Type errors guide", "/errors/type_errors" %}. +- {{ "Schema.rescue_from" | api_doc }} defines error handlers for application errors. See the {% internal_link "error handling guide", "/errors/error_handling" %} for more. +- {{ "Schema.parse_error" | api_doc }} and {{ "Schema.query_stack_error" | api_doc }} provide hooks for reporting errors to your bug tracker. ## Default Limits -{{ "Schema.max_depth" | api_doc }} and {{ "Schema.max_complexity" | api_doc }} apply some limits to incoming queries. See {% internal_link "Complexity and Depth", "/queries/complexity_and_depth" %} for more. - -{{ "Schema.default_max_page_size" | api_doc }} applies limits to {% internal_link "connection fields", "/pagination/overview" %}. - -{{ "Schema.validate_timeout" | api_doc }}, {{ "Schema.validate_max_errors" | api_doc }} and {{ "Schema.max_query_string_tokens" | api_doc }} all apply limits to query execution. See {% internal_link "Timeout", "/queries/timeout" %} for more. +- {{ "Schema.max_depth" | api_doc }} and {{ "Schema.max_complexity" | api_doc }} apply some limits to incoming queries. See {% internal_link "Complexity and Depth", "/queries/complexity_and_depth" %} for more. +- {{ "Schema.default_max_page_size" | api_doc }} applies limits to {% internal_link "connection fields", "/pagination/overview" %}. +- {{ "Schema.validate_timeout" | api_doc }}, {{ "Schema.validate_max_errors" | api_doc }} and {{ "Schema.max_query_string_tokens" | api_doc }} all apply limits to query execution. See {% internal_link "Timeout", "/queries/timeout" %} for more. ## Introspection -{{ "Schema.extra_types" | api_doc }} declares types which should be printed in the SDL and returned in introspection queries, but aren't otherwise used in the schema. - -{{ "Schema.introspection" | api_doc }} can attach a {% internal_link "custom introspection system", "/schema/introspection" %} to the schema. +- {{ "Schema.extra_types" | api_doc }} declares types which should be printed in the SDL and returned in introspection queries, but aren't otherwise used in the schema. +- {{ "Schema.introspection" | api_doc }} can attach a {% internal_link "custom introspection system", "/schema/introspection" %} to the schema. ## Authorization -{{ "Schema.unauthorized_object" | api_doc }} and {{ "Schema.unauthorized_field" | api_doc }} are called when {% internal_link "authorization hooks", "/authorization/authorization" %} return `false` during query execution. +- {{ "Schema.unauthorized_object" | api_doc }} and {{ "Schema.unauthorized_field" | api_doc }} are called when {% internal_link "authorization hooks", "/authorization/authorization" %} return `false` during query execution. ## Execution Configuration -{{ "Schema.trace_with" | api_doc }} attaches tracer modules. See {% internal_link "Tracing", "/queries/tracing" %} for more. +- {{ "Schema.trace_with" | api_doc }} attaches tracer modules. See {% internal_link "Tracing", "/queries/tracing" %} for more. +- {{ "Schema.query_analyzer" | api_doc }} and {{ "Schema.multiplex_analyzer" }} accept processors for ahead-of-time query analysis, see {% internal_link "Analysis", "/queries/ast_analysis" %} for more. +- {{ "Schema.default_logger" | api_doc }} configures a logger for runtime. See {% internal_link "Logging", "/queries/logging" %}. +- {{ "Schema.context_class" | api_doc }} and {{ "Schema.query_class" | api_doc }} attach custom subclasses to your schema to use during execution. +- {{ "Schema.lazy_resolve" | api_doc }} registers classes with {% internal_link "lazy execution", "/schema/lazy_execution" %}. -{{ "Schema.query_analyzer" | api_doc }} and {{ "Schema.multiplex_analyzer" }} accept processors for ahead-of-time query analysis, see {% internal_link "Analysis", "/queries/ast_analysis" %} for more. +## Plugins -{{ "Schema.default_logger" | api_doc }} configures a logger for runtime. See {% internal_link "Logging", "/queries/logging" %}. +- {{ "Schema.use" | api_doc }} adds plugins to your schema. For example, {{ "GraphQL::Dataloader" | api_doc }} and {{ "GraphQL::Schema::Visibility" | api_doc }} are installed this way. -{{ "Schema.context_class" | api_doc }} and {{ "Schema.query_class" | api_doc }} attach custom subclasses to your schema to use during execution. +## Production Considerations -{{ "Schema.lazy_resolve" | api_doc }} registers classes with {% internal_link "lazy execution", "/schema/lazy_execution" %}. +- __Parser caching__: if your application parses GraphQL _files_ (queries or schema definition), it may benefit from enabling {{ "GraphQL::Parser::Cache" | api_doc }}. +- __Eager loading the library__: by default, GraphQL-Ruby autoloads its constants as-needed. In production, they should be autoloaded instead, using `GraphQL.eager_load!`. -## Plugins + - Rails: enabled automatically. (ActiveSupport calls `.eager_load!`.) + - Sinatra: add `configure(:production) { GraphQL.eager_load! }` to your application file. + - Hanami: add `environment(:production) { GraphQL.eager_load! }` to your application file. + - Other frameworks: call `GraphQL.eager_load!` when your application is booting in production mode. -{{ "Schema.use" | api_doc }} adds plugins to your schema. For example, {{ "GraphQL::Dataloader" | api_doc }} and {{ "GraphQL::Schema::Visibility" | api_doc }} are installed this way. + See {{"GraphQL::Autoload#eager_load!" | api_doc }} for more details. diff --git a/lib/graphql.rb b/lib/graphql.rb index 653b7bef1c..e2824ad19e 100644 --- a/lib/graphql.rb +++ b/lib/graphql.rb @@ -10,6 +10,13 @@ module GraphQL extend Autoload + # Load all `autoload`-configured classes, and also eager-load dependents who have autoloads of their own. + def self.eager_load! + super + Query.eager_load! + Types.eager_load! + end + class Error < StandardError end @@ -74,24 +81,39 @@ module EmptyObjects class << self # If true, the parser should raise when an integer or float is followed immediately by an identifier (instead of a space or punctuation) attr_accessor :reject_numbers_followed_by_names - end - self.reject_numbers_followed_by_names = false - - class << self + # If `production?` is detected but `eager_load!` wasn't called, emit a warning. + # @return [void] def ensure_eager_load! if production? && !eager_loading? - warn "GraphQL should be eager loaded in production environments!" + warn <<~WARNING + GraphQL-Ruby thinks this is a production deployment but didn't eager-load its constants. Address this by: + + - Calling `GraphQL.eager_load!` in a production-only initializer or setup hook + - Assign `GraphQL.env = "..."` to something _other_ than `"production"` (for example, `GraphQL.env = "development"`) + + More details: https://graphql-ruby.org/schema/definition#production-considerations + WARNING end end + attr_accessor :env + private + # Detect whether this is a production deployment or not def production? - (env = ENV["RACK_ENV"] || ENV["RAILS_ENV"] || ENV["HANAMI_ENV"] || ENV["APP_ENV"]) && env.to_s.downcase == "production" + if env + # Manually assigned to production? + env == "production" + else + (detected_env = ENV["RACK_ENV"] || ENV["RAILS_ENV"] || ENV["HANAMI_ENV"] || ENV["APP_ENV"]) && detected_env.to_s.downcase == "production" + end end end + self.reject_numbers_followed_by_names = false + autoload :ExecutionError, "graphql/execution_error" autoload :RuntimeTypeError, "graphql/runtime_type_error" autoload :UnresolvedTypeError, "graphql/unresolved_type_error" diff --git a/lib/graphql/autoload.rb b/lib/graphql/autoload.rb index 05789819e8..506812b2f7 100644 --- a/lib/graphql/autoload.rb +++ b/lib/graphql/autoload.rb @@ -2,6 +2,11 @@ module GraphQL module Autoload + # Register a constant named `const_name` to be loaded from `path`. + # This is like `Kernel#autoload` but it tracks the constants so they can be eager-loaded with {#eager_load!} + # @param const_name [Symbol] + # @param path [String] + # @return [void] def autoload(const_name, path) @_eagerloaded_constants ||= [] @_eagerloaded_constants << const_name @@ -9,18 +14,22 @@ def autoload(const_name, path) super const_name, path end + # Call this to load this constant's `autoload` dependents and continue calling recursively + # @return [void] def eager_load! @_eager_loading = true if @_eagerloaded_constants @_eagerloaded_constants.each { |const_name| const_get(const_name) } @_eagerloaded_constants = nil end + nil ensure @_eager_loading = false end private + # @return [Boolean] `true` if GraphQL-Ruby is currently eager-loading its constants def eager_loading? @_eager_loading ||= false end diff --git a/lib/graphql/language/cache.rb b/lib/graphql/language/cache.rb index ed4a881e5e..5f30ad2f90 100644 --- a/lib/graphql/language/cache.rb +++ b/lib/graphql/language/cache.rb @@ -5,12 +5,25 @@ module GraphQL module Language + # This cache is used by {GraphQL::Language::Parser.parse_file} when it's enabled. + # + # With Rails, parser caching may enabled by setting `config.graphql.parser_cache = true` in your Rails application. + # + # The cache may be manually built by assigning `GraphQL::Language::Parser.cache = GraphQL::Language::Cache.new("some_dir")`. + # This will create a directory (`tmp/cache/graphql` by default) that stores a cache of parsed files. + # + # Much like [bootsnap](https://github.com/Shopify/bootsnap), the parser cache needs to be cleaned up manually. + # You will need to clear the cache directory for each new deployment of your application. + # Also note that the parser cache will grow as your schema is loaded, so the cache directory must be writable. + # + # @see GraphQL::Railtie for simple Rails integration class Cache def initialize(path) @path = path end DIGEST = Digest::SHA256.new << GraphQL::VERSION + def fetch(filename) hash = DIGEST.dup << filename begin diff --git a/lib/graphql/railtie.rb b/lib/graphql/railtie.rb index 2fdb1dadae..60bb5893cb 100644 --- a/lib/graphql/railtie.rb +++ b/lib/graphql/railtie.rb @@ -1,6 +1,12 @@ # frozen_string_literal: true module GraphQL + # Support {GraphQL::Parser::Cache} + # + # @example Enable the parser cache with default directory + # + # config.graphql.parser_cache = true + # class Railtie < Rails::Railtie config.graphql = ActiveSupport::OrderedOptions.new config.graphql.parser_cache = false diff --git a/spec/fixtures/eager_module/nested_eager_module.rb b/spec/fixtures/eager_module/nested_eager_module.rb new file mode 100644 index 0000000000..2a2b613bf4 --- /dev/null +++ b/spec/fixtures/eager_module/nested_eager_module.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true +module EagerModule + module NestedEagerModule + extend GraphQL::Autoload + autoload(:NestedEagerClass, "fixtures/eager_module/nested_eager_module/nested_eager_class") + end +end diff --git a/spec/fixtures/eager_module/nested_eager_module/nested_eager_class.rb b/spec/fixtures/eager_module/nested_eager_module/nested_eager_class.rb new file mode 100644 index 0000000000..214ac3bf4b --- /dev/null +++ b/spec/fixtures/eager_module/nested_eager_module/nested_eager_class.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true +module EagerModule + module NestedEagerModule + class NestedEagerClass + end + end +end diff --git a/spec/graphql/autoload_spec.rb b/spec/graphql/autoload_spec.rb index ca637d60d5..cfb78aabfd 100644 --- a/spec/graphql/autoload_spec.rb +++ b/spec/graphql/autoload_spec.rb @@ -12,10 +12,18 @@ module EagerModule extend GraphQL::Autoload autoload(:EagerClass, "fixtures/eager_module/eager_class") autoload(:OtherEagerClass, "fixtures/eager_module/other_eager_class") + autoload(:NestedEagerModule, "fixtures/eager_module/nested_eager_module") + + def self.eager_load! + super + + NestedEagerModule.eager_load! + end end describe "#autoload" do it "sets autoload" do + assert LazyModule.const_defined?(:LazyClass) assert_equal("fixtures/lazy_module/lazy_class", LazyModule.autoload?(:LazyClass)) LazyModule::LazyClass assert_nil(LazyModule.autoload?(:LazyClass)) @@ -24,10 +32,57 @@ module EagerModule describe "#eager_load!" do it "eagerly loads autoload entries" do + assert EagerModule.autoload?(:EagerClass) + assert EagerModule.autoload?(:OtherEagerClass) + assert EagerModule.autoload?(:NestedEagerModule) + EagerModule.eager_load! assert_nil(EagerModule.autoload?(:EagerClass)) assert_nil(EagerModule.autoload?(:OtherEagerClass)) + assert_nil(EagerModule.autoload?(:NestedEagerModule)) + assert_nil(EagerModule::NestedEagerModule.autoload?(:NestedEagerClass)) + assert EagerModule::NestedEagerModule::NestedEagerClass + end + end + + + describe "warning in production" do + before do + @prev_env = ENV.to_hash + ENV.update("HANAMI_ENV" => "production") + end + + after do + ENV.update(@prev_env) + end + + it "emits a warning when not eager-loading" do + stdout, stderr = capture_io do + GraphQL.ensure_eager_load! + end + + assert_equal "", stdout + expected_warning = "GraphQL-Ruby thinks this is a production deployment but didn't eager-load its constants. Address this by: + + - Calling `GraphQL.eager_load!` in a production-only initializer or setup hook + - Assign `GraphQL.env = \"...\"` to something _other_ than `\"production\"` (for example, `GraphQL.env = \"development\"`) + +More details: https://graphql-ruby.org/schema/definition#production-considerations +" + assert_equal expected_warning, stderr + end + + it "silences the warning when GraphQL.env is assigned" do + prev_env = GraphQL.env + GraphQL.env = "staging" + stdout, stderr = capture_io do + GraphQL.ensure_eager_load! + end + assert_equal "", stdout + assert_equal "", stderr + ensure + GraphQL.env = prev_env end end end