diff --git a/docs/howto/directory.conf b/docs/howto/directory.conf index 32f3f697..0ff864cf 100644 --- a/docs/howto/directory.conf +++ b/docs/howto/directory.conf @@ -1,4 +1,5 @@ laika.title = How-to Guides laika.navigationOrder = [ interfaces-across-tables.md + parser-caching.md ] diff --git a/docs/howto/parser-caching.md b/docs/howto/parser-caching.md new file mode 100644 index 00000000..d14c14f3 --- /dev/null +++ b/docs/howto/parser-caching.md @@ -0,0 +1,92 @@ +# Parser Caching + +`CachingQueryCompiler` caches the parse of a GraphQL query. It skips the parse on repeat requests where only the variables change. This is useful for long-running servers or applications. + +## Quick start + +Build the compiler _once_, at server startup. Reuse it for every request. + +```scala +import cats.effect.{IO, IOApp} +import grackle.CachingQueryCompiler + +object Server extends IOApp.Simple { + + def run: IO[Unit] = + for { + compiler <- CachingQueryCompiler[IO](myMapping.compiler) // built once + _ <- serve(compiler) + } yield () +} +``` + +Pass the compiler into your handler: + +```scala +def handle(compiler: CachingQueryCompiler[IO], document: String, variables: Json, requestEnv: Env): IO[Json] = + for { + op <- compiler.compile(document, untypedVars = Some(variables), env = requestEnv) + res <- op.flatTraverse(o => myMapping.interpreter.run(o.query, o.rootTpe, requestEnv).compile.lastOrError) + json <- myMapping.mkResponse(res) + } yield json +``` + +`.compile.lastOrError` fits a one-shot query. For subscriptions, use the `Stream` that `Mapping.compileAndRun` returns instead. + +By default, the cache holds 1024 documents in memory. When the cache is full, a new document replaces the oldest document. + +## What gets cached + +The cache key is the document text, matched exactly. Whitespace differences create separate entries. + +The cache holds the result of the parse, as a `Result[ParsedDocument]`. `ParsedDocument` is a type alias for a pair of the operations and the fragments of the document. + +Parse failures are also cached. A repeat of a malformed document costs one lookup, and the compiler does not parse it again. + +The compiler does all other work on each request. This work includes validation, variable coercion, and elaboration. + +## Change the size limit + +```scala +import grackle.{CachingQueryCompiler, QueryCache} + +for { + cache <- QueryCache[IO](maxSize = 4096) +} yield CachingQueryCompiler[IO](myMapping.compiler, cache) +``` + +The size limit counts documents, not bytes. + +## Use your own store + +Implement `QueryCache` to use a different store, for example a store with an expiry time or a size limit in bytes. + +```scala +import grackle.{CachingQueryCompiler, QueryCache, Result} +import grackle.QueryParser.ParsedDocument + +val myCache: QueryCache[IO] = + new QueryCache[IO] { + def get(key: String): IO[Option[Result[ParsedDocument]]] = ??? + def put(key: String, value: Result[ParsedDocument]): IO[Unit] = ??? + } + +val compiler = CachingQueryCompiler[IO](myMapping.compiler, myCache) +``` + +Rules for a custom store: + +- The parse does not use the schema. Compilers with different schemas can share a store if they use the same parser configuration. +- `CachingQueryCompiler` does not store an internal error. + +## Do not use a remote store + +A remote store, for example Redis or Valkey, is possible. Grackle does not supply a serialization of `ParsedDocument`, so you must write one. + +We recommend against a remote store. A remote cache hit almost always costs much more than a new parse, typically 2x to 70x as much: + +- A network round trip to a cache server takes about 100 to 300 µs. A parse of a typical query takes 3 to 250 µs. +- On each hit, the client must also decode the entry. In our benchmarks, a JSON decode costs 35% to 60% the time of a parse. +- On each miss, the client must also encode the entry and do a second round trip. + +If more than one server must share cached documents, give each server its own in-memory cache instead. diff --git a/modules/core/src/main/scala/cache.scala b/modules/core/src/main/scala/cache.scala new file mode 100644 index 00000000..0440843a --- /dev/null +++ b/modules/core/src/main/scala/cache.scala @@ -0,0 +1,112 @@ +// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA) +// Copyright (c) 2016-2025 Grackle Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package grackle + +import scala.collection.immutable.VectorMap + +import cats.Monad +import cats.effect.kernel.{Concurrent, Ref} +import cats.implicits._ +import io.circe.Json + +import grackle.QueryCompiler.IntrospectionLevel +import grackle.QueryCompiler.IntrospectionLevel.Full +import grackle.QueryParser.ParsedDocument + +/** + * Store of parsed GraphQL documents, keyed on the raw document text. + * + * The store holds both parse successes and parse failures, as a `Result`. + */ +trait QueryCache[F[_]] { + def get(key: String): F[Option[Result[ParsedDocument]]] + def put(key: String, value: Result[ParsedDocument]): F[Unit] +} + +object QueryCache { + + /** + * A simple in-memory store that holds up to `maxSize` documents (default 1024). When the + * store is full, a new document replaces the oldest document. + * + * `maxSize` must be greater than zero. + */ + def apply[F[_]: Concurrent](maxSize: Int = 1024): F[QueryCache[F]] = { + require(maxSize > 0, "maxSize must be greater than zero") + + Ref.of(VectorMap.empty[String, Result[ParsedDocument]]).map { ref => + new QueryCache[F] { + + def get(key: String): F[Option[Result[ParsedDocument]]] = + ref.get.map(_.get(key)) + + def put(key: String, value: Result[ParsedDocument]): F[Unit] = + ref.update { entries => + val room = + if (entries.sizeIs < maxSize || entries.contains(key)) entries + else entries.tail + room.updated(key, value) + } + } + } + } +} + +/** + * A `QueryCompiler` with a cache in front of the parser. + * + * A repeat request with the same document text skips the parse. + * + * Build one instance for the life of the server. + */ +final class CachingQueryCompiler[F[_]: Monad](compiler: QueryCompiler, cache: QueryCache[F]) { + + /** + * Compiles the GraphQL document `text` to a query algebra term that can be directly executed. + * Skips the parse if the same `text` was compiled before. + */ + def compile( + text: String, + name: Option[String] = None, + untypedVars: Option[Json] = None, + introspectionLevel: IntrospectionLevel = Full, + reportUnused: Boolean = true, + env: Env = Env.empty): F[Result[Operation]] = + cache + .get(text) + .flatMap { + case Some(parsed) => + parsed.pure[F] + case None => + val parsed = compiler.parser.parseText(text) + // An internal error is a fault in the parser, not a property of the document. + if (parsed.isInternalError) parsed.pure[F] + else cache.put(text, parsed).as(parsed) + } + .map(_.flatMap( + compiler.compileParsed(_, name, untypedVars, introspectionLevel, reportUnused, env))) +} + +object CachingQueryCompiler { + + def apply[F[_]: Concurrent](compiler: QueryCompiler): F[CachingQueryCompiler[F]] = + QueryCache[F]().map(new CachingQueryCompiler(compiler, _)) + + def apply[F[_]: Monad]( + compiler: QueryCompiler, + cache: QueryCache[F]): CachingQueryCompiler[F] = + new CachingQueryCompiler(compiler, cache) +} diff --git a/modules/core/src/main/scala/compiler.scala b/modules/core/src/main/scala/compiler.scala index cc46ce44..b438d020 100644 --- a/modules/core/src/main/scala/compiler.scala +++ b/modules/core/src/main/scala/compiler.scala @@ -26,6 +26,7 @@ import org.tpolecat.typename.{typeName, TypeName} import grackle.Predicate._ import grackle.Query._ import grackle.QueryCompiler._ +import grackle.QueryParser.ParsedDocument import grackle.ScalarType._ import grackle.UntypedOperation._ import grackle.Value._ @@ -41,17 +42,23 @@ trait QueryParser { * * GraphQL errors and warnings are accumulated in the result. */ - def parseText(text: String): Result[(List[UntypedOperation], List[UntypedFragment])] + def parseText(text: String): Result[ParsedDocument] /** * Parse a document AST to query algebra operations and fragments. * * GraphQL errors and warnings are accumulated in the result. */ - def parseDocument(doc: Ast.Document): Result[(List[UntypedOperation], List[UntypedFragment])] + def parseDocument(doc: Ast.Document): Result[ParsedDocument] } object QueryParser { + + /** + * The query algebra operations and fragments of a parsed GraphQL document. + */ + type ParsedDocument = (List[UntypedOperation], List[UntypedFragment]) + def apply(parser: GraphQLParser): QueryParser = new Impl(parser) @@ -65,7 +72,7 @@ object QueryParser { * * GraphQL errors and warnings are accumulated in the result. */ - def parseText(text: String): Result[(List[UntypedOperation], List[UntypedFragment])] = + def parseText(text: String): Result[ParsedDocument] = for { doc <- parser.parseText(text) res <- parseDocument(doc) @@ -77,8 +84,7 @@ object QueryParser { * * GraphQL errors and warnings are accumulated in the result. */ - def parseDocument( - doc: Document): Result[(List[UntypedOperation], List[UntypedFragment])] = { + def parseDocument(doc: Document): Result[ParsedDocument] = { val ops0 = doc.collect { case op: OperationDefinition => op } val fragments0 = doc.collect { case frag: FragmentDefinition => frag } @@ -430,7 +436,7 @@ object VariableUsage { * transformation phases in sequence, yielding a query algebra term which can be directly * interpreted. */ -class QueryCompiler(parser: QueryParser, schema: Schema, phases: List[Phase]) { +class QueryCompiler(val parser: QueryParser, schema: Schema, phases: List[Phase]) { import IntrospectionLevel._ /** @@ -445,35 +451,50 @@ class QueryCompiler(parser: QueryParser, schema: Schema, phases: List[Phase]) { introspectionLevel: IntrospectionLevel = Full, reportUnused: Boolean = true, env: Env = Env.empty): Result[Operation] = - parser.parseText(text).flatMap { - case (ops, frags) => - for { - _ <- Result.fromProblems(validateVariablesAndFragments(ops, frags, reportUnused)) - _ <- Result.fromProblems(validateFieldMergeability(ops, frags)) - ops0 <- ops.traverse(op => - compileOperation(op, untypedVars, frags, introspectionLevel, env) - .map(op0 => (op.name, op0))) - res <- (ops0, name) match { - case (List((_, op)), None) => + parser + .parseText(text) + .flatMap(compileParsed(_, name, untypedVars, introspectionLevel, reportUnused, env)) + + /** + * Compiles a parsed GraphQL document to a query algebra term that can be directly executed. + * + * GraphQL errors and warnings are accumulated in the result. + */ + def compileParsed( + doc: ParsedDocument, + name: Option[String] = None, + untypedVars: Option[Json] = None, + introspectionLevel: IntrospectionLevel = Full, + reportUnused: Boolean = true, + env: Env = Env.empty): Result[Operation] = { + val (ops, frags) = doc + for { + _ <- Result.fromProblems(validateVariablesAndFragments(ops, frags, reportUnused)) + _ <- Result.fromProblems(validateFieldMergeability(ops, frags)) + ops0 <- ops.traverse(op => + compileOperation(op, untypedVars, frags, introspectionLevel, env).map(op0 => + (op.name, op0))) + res <- (ops0, name) match { + case (List((_, op)), None) => + op.success + case (Nil, _) => + Result.failure("At least one operation required") + case (_, None) => + Result.failure("Operation name required to select unique operation") + case (ops, _) if ops.lengthCompare(1) > 0 && ops.exists(_._1.isEmpty) => + Result.failure("Query shorthand cannot be combined with multiple operations") + case (ops, on @ Some(name)) => + ops.filter(_._1 == on) match { + case List((_, op)) => op.success - case (Nil, _) => - Result.failure("At least one operation required") - case (_, None) => - Result.failure("Operation name required to select unique operation") - case (ops, _) if ops.lengthCompare(1) > 0 && ops.exists(_._1.isEmpty) => - Result.failure("Query shorthand cannot be combined with multiple operations") - case (ops, on @ Some(name)) => - ops.filter(_._1 == on) match { - case List((_, op)) => - op.success - case Nil => - Result.failure(s"No operation named '$name'") - case _ => - Result.failure(s"Multiple operations named '$name'") - } + case Nil => + Result.failure(s"No operation named '$name'") + case _ => + Result.failure(s"Multiple operations named '$name'") } - } yield res - } + } + } yield res + } /** * Compiles the provided operation AST to a query algebra term which can be directly executed. diff --git a/modules/core/src/test/scala/cache/CachingQueryCompilerSuite.scala b/modules/core/src/test/scala/cache/CachingQueryCompilerSuite.scala new file mode 100644 index 00000000..77dcd372 --- /dev/null +++ b/modules/core/src/test/scala/cache/CachingQueryCompilerSuite.scala @@ -0,0 +1,187 @@ +// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA) +// Copyright (c) 2016-2025 Grackle Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package grackle + +import cats.effect.IO +import io.circe.literal.* +import munit.CatsEffectSuite + +import grackle.Query.UntypedFragment +import grackle.QueryCompiler.IntrospectionLevel + +/** + * A parser that counts the documents it parses. + */ +final class CountingQueryParser(underlying: QueryParser) extends QueryParser { + var count: Int = 0 + + def parseText(text: String): Result[(List[UntypedOperation], List[UntypedFragment])] = { + count += 1 + underlying.parseText(text) + } + + def parseDocument( + doc: Ast.Document): Result[(List[UntypedOperation], List[UntypedFragment])] = + underlying.parseDocument(doc) +} + +final class CachingQueryCompilerSuite extends CatsEffectSuite { + + def counting: (CountingQueryParser, QueryCompiler) = { + val parser = new CountingQueryParser(CacheTestMapping.queryParser) + val compiler = + new QueryCompiler(parser, CacheTestMapping.schema, CacheTestMapping.compilerPhases) + (parser, compiler) + } + + /** + * Runs `body` against a fresh counting parser and a caching compiler with the default store. + */ + def withCache(body: (CountingQueryParser, CachingQueryCompiler[IO]) => IO[Unit]): IO[Unit] = { + val (parser, compiler) = counting + CachingQueryCompiler[IO](compiler).flatMap(body(parser, _)) + } + + test("repeated documents parse once") { + withCache { (parser, cached) => + for { + first <- cached.compile("query { foo }") + second <- cached.compile("query { foo }") + } yield { + assert(first.hasValue) + assert(second.hasValue) + assertEquals(parser.count, 1) + } + } + } + + test("two different documents parse twice") { + withCache { (parser, cached) => + for { + _ <- cached.compile("query { foo }") + _ <- cached.compile("query { bar }") + } yield assertEquals(parser.count, 2) + } + } + + test("the same document with two different whitespace layouts parses twice") { + withCache { (parser, cached) => + for { + _ <- cached.compile("query { foo }") + _ <- cached.compile("query { foo }") + } yield assertEquals(parser.count, 2) + } + } + + test("one cached document serves two different variable values") { + val doc = "query ($n: Int!) { withArg(n: $n) }" + withCache { (parser, cached) => + for { + one <- cached.compile(doc, untypedVars = Some(json"""{ "n": 1 }""")) + two <- cached.compile(doc, untypedVars = Some(json"""{ "n": 2 }""")) + } yield { + assertEquals(parser.count, 1) + assert(one.hasValue) + assert(two.hasValue) + assertNotEquals(one.toOption.map(_.query), two.toOption.map(_.query)) + } + } + } + + test("one cached document does not leak the Env of the first request") { + val doc = "query { secret }" + withCache { (parser, cached) => + for { + alice <- cached.compile(doc, env = Env("user" -> "alice")) + bob <- cached.compile(doc, env = Env("user" -> "bob")) + aliceAgain <- cached.compile(doc, env = Env("user" -> "alice")) + } yield { + assertEquals(parser.count, 1) + assert(alice.hasValue, "the permitted user must compile") + assert(!bob.hasValue, "the other user must not compile") + assert(aliceAgain.hasValue, "a rejection must not poison the entry") + } + } + } + + test("one cached document serves two different values of reportUnused") { + val doc = "query ($unused: Int) { foo }" + withCache { (parser, cached) => + for { + reported <- cached.compile(doc, reportUnused = true) + quiet <- cached.compile(doc, reportUnused = false) + } yield { + assertEquals(parser.count, 1) + assert(reported.toProblems.exists(_.message.contains("is unused"))) + assert(!quiet.toProblems.exists(_.message.contains("is unused"))) + } + } + } + + test("a malformed document parses once and fails twice with the same problems") { + val doc = "query { foo" + withCache { (parser, cached) => + for { + first <- cached.compile(doc) + second <- cached.compile(doc) + } yield { + assertEquals(parser.count, 1) + assert(!first.hasValue) + assertEquals(first.toProblems.toList, second.toProblems.toList) + } + } + } + + test("a cached result matches the result of the uncached compiler") { + val (_, compiler) = counting + val doc = "query ($n: Int!) { withArg(n: $n) }" + val vars = json"""{ "n": 7 }""" + for { + cached <- CachingQueryCompiler[IO](compiler) + _ <- cached.compile(doc, untypedVars = Some(vars)) + hot <- cached.compile(doc, untypedVars = Some(vars)) + } yield assertEquals(hot, compiler.compile(doc, untypedVars = Some(vars))) + } + + test("CachingQueryCompiler built with a caller-supplied store repeats a document once") { + val (parser, compiler) = counting + for { + cache <- QueryCache[IO](maxSize = 4) + cached = CachingQueryCompiler[IO](compiler, cache) + first <- cached.compile("query { foo }") + second <- cached.compile("query { foo }") + } yield { + assert(first.hasValue) + assert(second.hasValue) + assertEquals(parser.count, 1) + } + } + + test("one cached document does not fix the introspection level") { + val doc = "query { __schema { queryType { name } } }" + withCache { (parser, cached) => + for { + full <- cached.compile(doc, introspectionLevel = IntrospectionLevel.Full) + restricted <- cached.compile(doc, introspectionLevel = IntrospectionLevel.TypenameOnly) + } yield { + assertEquals(parser.count, 1) + assert(full.hasValue, "introspection must succeed when the level is Full") + assert(!restricted.hasValue, "introspection must fail when the level is TypenameOnly") + assert(restricted.toProblems.exists(_.message.contains("Introspection is disabled"))) + } + } + } +} diff --git a/modules/core/src/test/scala/cache/QueryCacheSuite.scala b/modules/core/src/test/scala/cache/QueryCacheSuite.scala new file mode 100644 index 00000000..025feca3 --- /dev/null +++ b/modules/core/src/test/scala/cache/QueryCacheSuite.scala @@ -0,0 +1,155 @@ +// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA) +// Copyright (c) 2016-2025 Grackle Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package grackle + +import cats.effect.IO +import cats.implicits.* +import compiler.TestMapping +import munit.CatsEffectSuite + +import grackle.QueryCompiler.* +import grackle.QueryParser.ParsedDocument +import grackle.syntax.* + +final class QueryCacheSuite extends CatsEffectSuite { + + def parse(text: String): Result[ParsedDocument] = + CacheTestMapping.queryParser.parseText(text) + + val docA: Result[ParsedDocument] = parse("query { foo }") + val docB: Result[ParsedDocument] = parse("query { bar }") + val docC: Result[ParsedDocument] = parse("query { baz }") + val malformed: Result[ParsedDocument] = parse("query { foo") + + test("stored document comes back") { + for { + cache <- QueryCache[IO](maxSize = 4) + _ <- cache.put("a", docA) + hit <- cache.get("a") + } yield assertEquals(hit, Option(docA)) + } + + test("stored parse failure comes back") { + assert(malformed.isFailure) + for { + cache <- QueryCache[IO](maxSize = 4) + _ <- cache.put("m", malformed) + hit <- cache.get("m") + } yield assertEquals(hit, Option(malformed)) + } + + test("absent document misses") { + for { + cache <- QueryCache[IO](maxSize = 4) + miss <- cache.get("a") + } yield assertEquals(miss, None) + } + + test("write past the size limit evicts the oldest entry") { + for { + cache <- QueryCache[IO](maxSize = 2) + _ <- cache.put("a", docA) + _ <- cache.put("b", docB) + _ <- cache.put("c", docC) + a <- cache.get("a") + b <- cache.get("b") + c <- cache.get("c") + } yield { + assertEquals(a, None, "the oldest entry must go") + assertEquals(b, Option(docB)) + assertEquals(c, Option(docC), "the new entry must be present") + } + } + + test("read does not change which entry is the oldest") { + for { + cache <- QueryCache[IO](maxSize = 2) + _ <- cache.put("a", docA) + _ <- cache.put("b", docB) + _ <- cache.get("a") + _ <- cache.put("c", docC) + a <- cache.get("a") + b <- cache.get("b") + } yield { + assertEquals(a, None, "a read must not keep an entry") + assertEquals(b, Option(docB)) + } + } + + test("write to a present key replaces the value and does not evict another entry") { + for { + cache <- QueryCache[IO](maxSize = 2) + _ <- cache.put("a", docA) + _ <- cache.put("b", docB) + _ <- cache.put("b", docC) + a <- cache.get("a") + b <- cache.get("b") + } yield { + assertEquals(a, Option(docA)) + assertEquals(b, Option(docC)) + } + } + + test("write to a present key does not change which entry is the oldest") { + for { + cache <- QueryCache[IO](maxSize = 2) + _ <- cache.put("a", docA) + _ <- cache.put("b", docB) + _ <- cache.put("a", docC) + _ <- cache.put("c", docC) + a <- cache.get("a") + b <- cache.get("b") + } yield { + assertEquals(a, None, "a write must not move an entry") + assertEquals(b, Option(docB)) + } + } + + test("store with many more writes than its size limit keeps the newest entries") { + val keys = (0 until 1000).toList.map(i => s"k$i") + for { + cache <- QueryCache[IO](maxSize = 10) + _ <- keys.traverse_(cache.put(_, docA)) + hits <- keys.traverse(cache.get(_)) + } yield assertEquals(hits.map(_.isDefined), List.fill(990)(false) ++ List.fill(10)(true)) + } +} + +object CacheTestMapping extends TestMapping { + val schema = + schema""" + type Query { + foo: Int + bar: Int + baz: Int + withArg(n: Int!): Int + secret: Int + } + """ + + val QueryType = schema.ref("Query") + + override val selectElaborator = SelectElaborator { + case (QueryType, "secret", Nil) => + Elab.env[String]("user").flatMap { + case Some("alice") => Elab.unit + case other => Elab.liftR(Result.failure(s"Not permitted for $other")) + } + + case (QueryType, "withArg", List(Query.Binding("n", Value.IntValue(n)))) => + Elab.env("n" -> n) + } +}