Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,8 @@ lazy val core = crossProject(JVMPlatform, JSPlatform, NativePlatform)
"io.circe" %%% "circe-core" % circeVersion,
"org.tpolecat" %%% "typename" % typenameVersion,
"org.tpolecat" %%% "sourcepos" % sourcePosVersion,
"co.fs2" %%% "fs2-core" % fs2Version
"co.fs2" %%% "fs2-core" % fs2Version,
"org.typelevel" %%% "cats-effect-testkit" % catsEffectVersion % "test"
)
)
.jsSettings(
Expand Down
1 change: 1 addition & 0 deletions docs/howto/directory.conf
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
laika.title = How-to Guides
laika.navigationOrder = [
interfaces-across-tables.md
parser-validation-caching.md
]
78 changes: 78 additions & 0 deletions docs/howto/parser-validation-caching.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Parser/Validation Caching

`CachingQueryCompiler` caches the parse and the document-level validation of a GraphQL query. It skips this work 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. It drops a document one hour after its last use, and it drops the least recently used document when full.

## What gets cached

The cache key is the document text, matched exactly. Whitespace differences create separate entries.

Cached: parsing, fragment and variable validation, field mergeability, compiled variable definitions, and root type checks.

Not cached: variable coercion, directive validation, and elaboration. These depend on the per-request `Env` and variable values, so caching them would leak state between requests.

Parse failures are also cached, so repeat malformed documents cost one lookup and are not re-parsed.

## Change the size limit or TTL

```scala
import scala.concurrent.duration._
import grackle.{CachingQueryCompiler, QueryCache}

for {
cache <- QueryCache[IO](maxSize = 4096, ttl = 15.minutes)
} yield CachingQueryCompiler[IO](myMapping.compiler, cache)
```

The size limit counts documents, not bytes.

## Use your own store

```scala
import grackle.{CachingQueryCompiler, PreparedDocument, QueryCache, Result}

val myCache: QueryCache[IO] =
new QueryCache[IO] {
def get(key: String): IO[Option[Result[PreparedDocument]]] = ???
def put(key: String, value: Result[PreparedDocument]): IO[Unit] = ???
}

val compiler = CachingQueryCompiler[IO](myMapping.compiler, myCache)
```

Rules for a custom store:

- Use one store per compiler. Compilers with different schemas must not share a store.
- `PreparedDocument` holds references to the compiler and thus cannot be serialized.
147 changes: 147 additions & 0 deletions modules/core/src/main/scala/cache.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
// 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.concurrent.duration.*

import cats.Monad
import cats.effect.kernel.{Clock, Ref, Temporal}
import cats.implicits.*
import io.circe.Json

import grackle.QueryCompiler.IntrospectionLevel
import grackle.QueryCompiler.IntrospectionLevel.Full

/**
* Store of prepared GraphQL documents, keyed on the raw document text.
*
* A store belongs to one `QueryCompiler`, because a `PreparedDocument` depends on the schema of
* the compiler that produced it. Do not share one store across two compilers with different
* schemas.
*
* The store holds both parse successes and failures, as a `Result`. Repeat of malformed
* documents therefore costs one lookup.
*/
trait QueryCache[F[_]] {
def get(key: String): F[Option[Result[PreparedDocument]]]
def put(key: String, value: Result[PreparedDocument]): F[Unit]
}

object QueryCache {

private final case class Entry(doc: Result[PreparedDocument], expiry: FiniteDuration)

/**
* An in-memory store that holds up to `maxSize` documents (default 1024), each for `ttl`
* (default one hour) after its last use (sliding-window).
*
* `maxSize` must be greater than zero.
*/
def apply[F[_]: Temporal](
maxSize: Int = 1024,
ttl: FiniteDuration = 1.hour): F[QueryCache[F]] = {
require(maxSize > 0, "maxSize must be greater than zero")

Ref.of[F, Map[String, Entry]](Map.empty).map { ref =>
new QueryCache[F] {

def get(key: String): F[Option[Result[PreparedDocument]]] =
Clock[F].monotonic.flatMap { now =>
ref.modify { entries =>
entries.get(key) match {
case Some(Entry(doc, expiry)) if expiry > now =>
(entries.updated(key, Entry(doc, now + ttl)), Some(doc))
case _ =>
(entries - key, None)
}
}
}

def put(key: String, value: Result[PreparedDocument]): F[Unit] =
Clock[F].monotonic.flatMap { now =>
ref.update { entries =>
val room =
if (entries.sizeIs < maxSize || entries.contains(key)) entries
else evict(entries, now)
room.updated(key, Entry(value, now + ttl))
}
}

private def evict(
entries: Map[String, Entry],
now: FiniteDuration): Map[String, Entry] = {
val (live, oldest) =
entries.foldLeft((Map.empty[String, Entry], Option.empty[(String, Entry)])) {
case ((live, oldest), kv @ (k, entry)) =>
if (entry.expiry <= now) (live, oldest)
else
(
live.updated(k, entry),
if (oldest.forall(_._2.expiry > entry.expiry)) Some(kv) else oldest)
}

if (live.sizeIs < entries.size) live
else oldest.fold(live)(kv => live - kv._1)
}
}
}
}
}

/**
* A `QueryCompiler` with a cache in front of the variable-free half of compilation.
*
* A repeat request with the same document text skips the parse and the document-level
* validation.
*
* Build one instance for the life of the server, and one per `QueryCompiler`.
*/
final class CachingQueryCompiler[F[_]: Monad](compiler: QueryCompiler, cache: QueryCache[F]) {

/**
* Compiles the GraphQL document `text` to a query algebra term which can be directly
* executed. Skips the parse and validation if the same `text` has been 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(prepared) =>
prepared.pure[F]
case None =>
val prepared = compiler.prepare(text)
cache.put(text, prepared).as(prepared)
}
.map(_.flatMap(
compiler.compilePrepared(_, name, untypedVars, introspectionLevel, reportUnused, env)))
}

object CachingQueryCompiler {

def apply[F[_]: Temporal](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)
}
109 changes: 77 additions & 32 deletions modules/core/src/main/scala/compiler.scala
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,21 @@ object VariableUsage {
class QueryCompiler(parser: QueryParser, schema: Schema, phases: List[Phase]) {
import IntrospectionLevel._

/**
* Compiles the GraphQL document `text` as far as the variable values allow.
*
* Depends on the document text and on the schema only. It does not depend on the variable
* values, on the `Env`, on the operation name, or on the introspection level, so a caller can
* cache it under the document text. See `CachingQueryCompiler`.
*
* GraphQL errors and warnings are accumulated in the result.
*/
def prepare(text: String): Result[PreparedDocument] =
parser.parseText(text).map {
case (ops, frags) =>
new PreparedDocument(this, ops.map(op => prepareOperation(op, frags)), frags)
}

/**
* Compiles the GraphQL query `text` to a query algebra term which can be directly executed.
*
Expand All @@ -445,35 +460,46 @@ 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) =>
prepare(text).flatMap(
compilePrepared(_, name, untypedVars, introspectionLevel, reportUnused, env))

/**
* Compiles a prepared document to a query algebra term which can be directly executed.
*/
def compilePrepared(
prepared: PreparedDocument,
name: Option[String] = None,
untypedVars: Option[Json] = None,
introspectionLevel: IntrospectionLevel = Full,
reportUnused: Boolean = true,
env: Env = Env.empty): Result[Operation] =
for {
_ <- Result.fromProblems(prepared.varAndFragProblems(reportUnused))
_ <- Result.fromProblems(prepared.mergeProblems)
ops0 <- prepared
.ops
.traverse(op =>
compileOperation(op, untypedVars, introspectionLevel, env).tupleLeft(op.name))
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.
Expand All @@ -485,17 +511,31 @@ class QueryCompiler(parser: QueryParser, schema: Schema, phases: List[Phase]) {
untypedVars: Option[Json],
frags: List[UntypedFragment],
introspectionLevel: IntrospectionLevel = Full,
env: Env = Env.empty): Result[Operation] = {
env: Env = Env.empty): Result[Operation] =
compileOperation(prepareOperation(op, frags), untypedVars, introspectionLevel, env)

/**
* Completes a prepared operation to a query algebra term which can be directly executed.
*
* GraphQL errors and warnings are accumulated in the result.
*/
private def compileOperation(
prepared: PreparedOperation,
untypedVars: Option[Json],
introspectionLevel: IntrospectionLevel,
env: Env): Result[Operation] = {
val op = prepared.op
val frags = prepared.frags
val allPhases =
IntrospectionElaborator(
introspectionLevel).toList ++ (VariablesSkipAndFragmentElaborator :: MergeFields :: phases)

for {
varDefs <- compileVarDefs(op.variables)
varDefs <- prepared.varDefs
vars <- compileVars(varDefs, untypedVars)
_ <- Directive.validateDirectivesForQuery(schema, op, frags, vars)
rootTpe <- op.rootTpe(schema)
_ <- VariableUsage.validateVariableUsages(schema, rootTpe, op, frags, varDefs)
rootTpe <- prepared.rootTpe
_ <- prepared.usages
res <- (
for {
query <- allPhases.foldLeftM(op.query) { (acc, phase) =>
Expand All @@ -508,7 +548,7 @@ class QueryCompiler(parser: QueryParser, schema: Schema, phases: List[Phase]) {
schema,
Context(rootTpe),
vars,
frags.map(f => (f.name, f)).toMap,
prepared.fragMap,
op.query,
env,
List.empty,
Expand All @@ -518,6 +558,11 @@ class QueryCompiler(parser: QueryParser, schema: Schema, phases: List[Phase]) {
} yield res
}

private def prepareOperation(
op: UntypedOperation,
frags: List[UntypedFragment]): PreparedOperation =
new PreparedOperation(this, schema, op, frags)

/**
* Compiles variable definition ASTs to variable definitions for the target schema.
*
Expand Down
Loading
Loading