Code Monkey home page Code Monkey logo

cache's Introduction

mercurius-cache

Adds an in-process caching layer to Mercurius. Federation is fully supported.

Based on preliminary testing, it is possible to achieve a significant throughput improvement at the expense of the freshness of the data. Setting the ttl accordingly and/or a good invalidation strategy is of critical importance.

Under the covers, it uses async-cache-dedupe which will also deduplicate the calls.

Install

npm i fastify mercurius mercurius-cache graphql

Quickstart

'use strict'

const fastify = require('fastify')
const mercurius = require('mercurius')
const cache = require('mercurius-cache')

const app = fastify({ logger: true })

const schema = `
  type Query {
    add(x: Int, y: Int): Int
    hello: String
  }
`

const resolvers = {
  Query: {
    async add (_, { x, y }, { reply }) {
      reply.log.info('add called')
      for (let i = 0; i < 10000000; i++) {} // something that takes time
      return x + y
    }
  }
}

app.register(mercurius, {
  schema,
  resolvers
})


// cache query "add" responses for 10 seconds
app.register(cache, {
  ttl: 10,
  policy: {
    Query: {
      add: true
      // note: it cache "add" but it doesn't cache "hello"
    }
  }
})

app.listen(3000)

// Use the following to test
// curl -X POST -H 'content-type: application/json' -d '{ "query": "{ add(x: 2, y: 2) }" }' localhost:3000/graphql

Options

  • ttl

a number or a function that returns a number of the maximum time a cache entry can live in seconds; default is 0, which means that the cache is disabled. The ttl function reveives the result of the original function as the first argument.

Example(s)

  ttl: 10
  ttl: (result) => !!result.importantProp ? 10 : 0
  • stale

the time in seconds after the ttl to serve stale data while the cache values are re-validated. Has no effect if ttl is not configured.

Example

  stale: 5
  • all

use the cache in all resolvers; default is false. Use either policy or all but not both.
Example

  all: true
  • storage

default cache is in memory, but a redis storage can be used for a larger and shared cache.
Storage options are:

  • type: memory (default) or redis
  • options: by storage type
    • for memory

      • size: maximum number of items to store in the cache per resolver. Default is 1024.
      • invalidation: enable invalidation, see documentation. Default is disabled.
      • log: logger instance pino compatible, default is the app.log instance.

      Example

        storage: {
          type: 'memory',
          options: {
            size: 2048
          }
        }
    • for redis

      • client: a redis client instance, mandatory. Should be an ioredis client or compatible.
      • invalidation: enable invalidation, see documentation. Default is disabled.
      • invalidation.referencesTTL: references TTL in seconds. Default is the max static ttl between the main one and policies. If all ttls specified are functions then referencesTTL will need to be specified explictly.
      • log: logger instance pino compatible, default is the app.log instance.

      Example

        storage: {
          type: 'redis',
          options: {
            client: new Redis(),
            invalidation: {
              referencesTTL: 60
            }
          }
        }

See https://github.com/mercurius-js/mercurius-cache-example for a complete complex use case.

  • policy

specify queries to cache; default is empty.
Set it to true to cache using main ttl and stale if configured. Example

  policy: {
    Query: {
      add: true
    }
  }
  • policy~ttl

use a specific ttl for the policy, instead of the main one.
Example

  ttl: 10,
  policy: {
    Query: {
      welcome: {
        ttl: 5 // Query "welcome" will be cached for 5 seconds
      },
      bye: true, // Query "bye" will be cached for 10 seconds
      hello: (result) => result.shouldCache ? 15 : 0 // function that determines the ttl for how long the item should be cached
    }
  }
  • policy~stale

use a specific stale value for the policy, instead of the main one.
Example

  ttl: 10,
  stale: 10,
  policy: {
    Query: {
      welcome: {
        ttl: 5 // Query "welcome" will be cached for 5 seconds
        stale: 5 // Query "welcome" will available for 5 seconds after the ttl has expired
      },
      bye: true // Query "bye" will be cached for 10 seconds and available for 10 seconds after the ttl is expired
    }
  }
  • policy~storage

use specific storage for the policy, instead of the main one.
Can be useful to have, for example, in-memory storage for small data set along with the redis storage.
See https://github.com/mercurius-js/mercurius-cache-example for a complete complex use case.
Example

  storage: {
    type: 'redis',
    options: { client: new Redis() }
  },
  policy: {
    Query: {
      countries: {
        ttl: 86400, // Query "countries" will be cached for 1 day
        storage: { type: 'memory' }
      }
    }
  }
  • policy~skip

skip cache use for a specific condition, onSkip will be triggered.
Example

  skip (self, arg, ctx, info) {
    if (ctx.reply.request.headers.authorization) {
      return true
    }
    return false
  }
  • policy~key

To improve performance, we can define a custom key serializer. Example

  const schema = `
  type Query {
    getUser (id: ID!): User
  }`

  // ...

  policy: {
    Query: {
      getUser: { key ({ self, arg, info, ctx, fields }) { return `${arg.id}` } }
    }
  }

Please note that the key function must return a string, otherwise the result will be stringified, losing the performance advantage of custom serialization.

  • policy~extendKey

extend the key to cache responses by different requests, for example, to enable custom cache per user.
See examples/cache-per-user.js. Example

  policy: {
    Query: {
      welcome: {
        extendKey: function (source, args, context, info) {
          return context.userId ? `user:${context.userId}` : undefined
        }
      }
    }
  }
  • policy~references

function to set the references for the query, see invalidation to know how to use references, and https://github.com/mercurius-js/mercurius-cache-example for a complete use case.
Example

  policy: {
    Query: {
      user: {
        references: ({source, args, context, info}, key, result) => {
          if(!result) { return }
          return [`user:${result.id}`]
        }
      },
      users: {
        references: ({source, args, context, info}, key, result) => {
          if(!result) { return }
          const references = result.map(user => (`user:${user.id}`))
          references.push('users')
          return references
        }
      }
    }
  }
  • policy~invalidate

function to invalidate for the query by references, see invalidation to know how to use references, and https://github.com/mercurius-js/mercurius-cache-example for a complete use case.
invalidate function can be sync or async. Example

  policy: {
    Mutation: {
      addUser: {
        invalidate: (self, arg, ctx, info, result) => ['users']
      }
    }
  }
  • policy~__options

should be used in case of conflicts with nested fields with the same name as policy fields (ttl, skip, storage....).
Example

policy: {
	Query: {
	  welcome: {
	    // no __options key present, so policy options are considered as it is
	    ttl: 6
	  },
	  hello: {
	    // since "hello" query has a ttl property
	    __options: {
	      ttl: 6
	    },
	    ttl: {
	      // here we can use both __options or list policy options
	      skip: () { /* .. */ }
	    }
	  }
	}
}
  • skip

skip cache use for a specific condition, onSkip will be triggered.
Example

  skip (self, arg, ctx, info) {
    if (ctx.reply.request.headers.authorization) {
      return true
    }
    return false
  }
  • onDedupe

called when a request is deduped. When multiple requests arrive at the same time, the dedupe system calls the resolver only once and serve all the request with the result of the first request - and after the result is cached.
Example

  onDedupe (type, fieldName) {
    console.log(`dedupe ${type} ${fieldName}`) 
  }
  • onHit

called when a cached value is returned.
Example

  onHit (type, fieldName) {
    console.log(`hit ${type} ${fieldName}`) 
  }
  • onMiss

called when there is no value in the cache; it is not called if a resolver is skipped.
Example

  onMiss (type, fieldName) {
    console.log(`miss ${type} ${fieldName}`)
  }
  • onSkip

called when the resolver is skipped, both by skip or policy.skip. Example

  onSkip (type, fieldName) {
    console.log(`skip ${type} ${fieldName}`)
  }
  • onError

called when an error occurred on the caching operation. Example

  onError (type, fieldName, error) {
    console.error(`error on ${type} ${fieldName}`, error)
  }
  • logInterval

This option enables cache report with hit/miss/dedupes/skips count for all queries specified in the policy; default is disabled. The value of the interval is in seconds.

Example

  logInterval: 3
  • logReport

custom function for logging cache hits/misses. called every logInterval seconds when the cache report is logged.

Example

  logReport (report) {
    console.log('Periodic cache report')
    console.table(report)
  }

// console table output

┌───────────────┬─────────┬──────┬────────┬───────┐
     (index)    dedupes  hits  misses  skips 
├───────────────┼─────────┼──────┼────────┼───────┤
   Query.add       0      8      1       0   
   Query.sub       0      2      6       0   
└───────────────┴─────────┴──────┴────────┴───────┘

// report format
{
  "Query.add": {
    "dedupes": 0,
    "hits": 8,
    "misses": 1,
    "skips": 0
  },
  "Query.sub": {
    "dedupes": 0,
    "hits": 2,
    "misses": 6,
    "skips": 0
  },
}

Methods

  • invalidate

cache.invalidate(references, [storage])

cache.invalidate perform invalidation over the whole storage.
To specify the storage to operate invalidation, it needs to be the name of a policy, for example Query.getUser.
Note that invalidation must be enabled on storage.

references can be:

  • a single reference
  • an array of references (without wildcard)
  • a matching reference with wildcard, same logic for memory and redis

Example

const app = fastify()

await app.register(cache, {
  ttl: 60,
  storage: {
    type: 'redis',
    options: { client: redisClient, invalidation: true    }
  },
  policy: { 
    Query: {
      getUser: {
        references: (args, key, result) => result ? [`user:${result.id}`] : null
      }
    }
  }
})

// ...

// invalidate all users
await app.graphql.cache.invalidate('user:*')

// invalidate user 1
await app.graphql.cache.invalidate('user:1')

// invalidate user 1 and user 2
await app.graphql.cache.invalidate(['user:1', 'user:2'])

See example for a complete example.

  • clear

clear method allows to pragmatically clear the cache entries, for example

const app = fastify()

await app.register(cache, {
  ttl: 60,
  policy: { 
    // ...
  }
})

// ...

await app.graphql.cache.clear()

Invalidation

Along with time to live invalidation of the cache entries, we can use invalidation by keys.
The concept behind invalidation by keys is that entries have an auxiliary key set that explicitly links requests along with their result. These auxiliary keys are called here references.
The use case is common. Let's say we have an entry user {id: 1, name: "Alice"}, it may change often or rarely, the ttl system is not accurate:

  • it can be updated before ttl expiration, in this case the old value is shown until expiration by ttl.
    It may also be in more queries, for example, getUser and findUsers, so we need to keep their responses consistent
  • it's not been updated during ttl expiration, so in this case, we don't need to reload the value, because it's not changed

To solve this common problem, we can use references.
We can say that the result of query getUser(id: 1) has reference user~1, and the result of query findUsers, containing {id: 1, name: "Alice"},{id: 2, name: "Bob"} has references [user~1,user~2]. So we can find the results in the cache by their references, independently of the request that generated them, and we can invalidate by references.

When the mutation updateUser involves user {id: 1} we can remove all the entries in the cache that have references to user~1, so the result of getUser(id: 1) and findUsers, and they will be reloaded at the next request with the new data - but not the result of getUser(id: 2).

However, the operations required to do that could be expensive and not worthing it, for example, is not recommendable to cache frequently updating data by queries of find that have pagination/filtering/sorting.

Explicit invalidation is disabled by default, you have to enable in storage settings.

See mercurius-cache-example for a complete example.

Redis

Using a redis storage is the best choice for a shared cache for a cluster of a service instance.
However, using the invalidation system need to keep references updated, and remove the expired ones: while expired references do not compromise the cache integrity, they slow down the I/O operations.

So, redis storage has the gc function, to perform garbage collection.

See this example in mercurius-cache-example/plugins/cache.js about how to run gc on a single instance service.

Another example:

const { createStorage } = require('async-cache-dedupe')
const client = new Redis(connection)

const storage = createStorage('redis', { log, client, invalidation: true })

// run in lazy mode, doing a full db iteration / but not a full clean up
let cursor = 0
do {
  const report = await storage.gc('lazy', { lazy: { chunk: 200, cursor } })
  cursor = report.cursor
} while (cursor !== 0)

// run in strict mode
const report = await storage.gc('strict', { chunk: 250 })

In lazy mode, only options.max references are scanned every time, picking keys to check randomly; this operation is lighter while does not ensure references full clean up

In strict mode, all references and keys are checked and cleaned; this operation scans the whole db and is slow, while it ensures full references clean up.

gc options are:

  • chunk the chunk size of references analyzed per loops, default 64
  • lazy~chunk the chunk size of references analyzed per loops in lazy mode, default 64; if both chunk and lazy.chunk is set, the maximum one is taken
  • lazy~cursor the cursor offset, default zero; cursor should be set at report.cursor to continue scanning from the previous operation

storage.gc function returns the report of the job, like

"report":{
  "references":{
      "scanned":["r:user:8", "r:group:11", "r:group:16"],
      "removed":["r:user:8", "r:group:16"]
  },
  "keys":{
      "scanned":["users~1"],
      "removed":["users~1"]
  },
  "loops":4,
  "cursor":0,
  "error":null
}

An effective strategy is to run often lazy cleans and a strict clean sometimes.
The report contains useful information about the gc cycle, use them to adjust params of the gc utility, settings depending on the size, and the mutability of cached data.

A way is to run it programmatically, as in https://github.com/mercurius-js/mercurius-cache-example or set up cronjobs as described in examples/redis-gc - this one is useful when there are many instances of the mercurius server.
See async-cache-dedupe#redis-garbage-collector for details.

Breaking Changes

  • version 0.11.0 -> 0.12.0
    • options.cacheSize is dropped in favor of storage
    • storage.get and storage.set are removed in favor of storage options

Benchmarks

We have experienced up to 10x performance improvements in real-world scenarios. This repository also includes a benchmark of a gateway and two federated services that shows that adding a cache with 10ms TTL can improve the performance by 4x:

$ sh bench.sh
===============================
= Gateway Mode (not cache)    =
===============================
Running 10s test @ http://localhost:3000/graphql
100 connections

┌─────────┬───────┬───────┬───────┬───────┬──────────┬─────────┬────────┐
│ Stat    │ 2.5%  │ 50%   │ 97.5% │ 99%   │ Avg      │ Stdev   │ Max    │
├─────────┼───────┼───────┼───────┼───────┼──────────┼─────────┼────────┤
│ Latency │ 28 ms │ 31 ms │ 57 ms │ 86 ms │ 33.47 ms │ 12.2 ms │ 238 ms │
└─────────┴───────┴───────┴───────┴───────┴──────────┴─────────┴────────┘
┌───────────┬────────┬────────┬─────────┬─────────┬─────────┬────────┬────────┐
│ Stat      │ 1%     │ 2.5%   │ 50%     │ 97.5%   │ Avg     │ Stdev  │ Min    │
├───────────┼────────┼────────┼─────────┼─────────┼─────────┼────────┼────────┤
│ Req/Sec   │ 1291   │ 1291   │ 3201    │ 3347    │ 2942.1  │ 559.51 │ 1291   │
├───────────┼────────┼────────┼─────────┼─────────┼─────────┼────────┼────────┤
│ Bytes/Sec │ 452 kB │ 452 kB │ 1.12 MB │ 1.17 MB │ 1.03 MB │ 196 kB │ 452 kB │
└───────────┴────────┴────────┴─────────┴─────────┴─────────┴────────┴────────┘

Req/Bytes counts sampled once per second.

32k requests in 11.03s, 11.3 MB read

===============================
= Gateway Mode (0s TTL)       =
===============================
Running 10s test @ http://localhost:3000/graphql
100 connections

┌─────────┬──────┬──────┬───────┬───────┬─────────┬─────────┬────────┐
│ Stat    │ 2.5% │ 50%  │ 97.5% │ 99%   │ Avg     │ Stdev   │ Max    │
├─────────┼──────┼──────┼───────┼───────┼─────────┼─────────┼────────┤
│ Latency │ 6 ms │ 7 ms │ 12 ms │ 17 ms │ 7.29 ms │ 3.32 ms │ 125 ms │
└─────────┴──────┴──────┴───────┴───────┴─────────┴─────────┴────────┘
┌───────────┬─────────┬─────────┬─────────┬─────────┬─────────┬─────────┬─────────┐
│ Stat      │ 1%      │ 2.5%    │ 50%     │ 97.5%   │ Avg     │ Stdev   │ Min     │
├───────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤
│ Req/Sec   │ 7403    │ 7403    │ 13359   │ 13751   │ 12759   │ 1831.94 │ 7400    │
├───────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤
│ Bytes/Sec │ 2.59 MB │ 2.59 MB │ 4.68 MB │ 4.81 MB │ 4.47 MB │ 642 kB  │ 2.59 MB │
└───────────┴─────────┴─────────┴─────────┴─────────┴─────────┴─────────┴─────────┘

Req/Bytes counts sampled once per second.

128k requests in 10.03s, 44.7 MB read

===============================
= Gateway Mode (1s TTL)       =
===============================
Running 10s test @ http://localhost:3000/graphql
100 connections

┌─────────┬──────┬──────┬───────┬───────┬─────────┬─────────┬────────┐
│ Stat    │ 2.5% │ 50%  │ 97.5% │ 99%   │ Avg     │ Stdev   │ Max    │
├─────────┼──────┼──────┼───────┼───────┼─────────┼─────────┼────────┤
│ Latency │ 7 ms │ 7 ms │ 13 ms │ 19 ms │ 7.68 ms │ 4.01 ms │ 149 ms │
└─────────┴──────┴──────┴───────┴───────┴─────────┴─────────┴────────┘
┌───────────┬─────────┬─────────┬─────────┬─────────┬─────────┬─────────┬─────────┐
│ Stat      │ 1%      │ 2.5%    │ 50%     │ 97.5%   │ Avg     │ Stdev   │ Min     │
├───────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤
│ Req/Sec   │ 6735    │ 6735    │ 12879   │ 12951   │ 12173   │ 1828.86 │ 6735    │
├───────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤
│ Bytes/Sec │ 2.36 MB │ 2.36 MB │ 4.51 MB │ 4.53 MB │ 4.26 MB │ 640 kB  │ 2.36 MB │
└───────────┴─────────┴─────────┴─────────┴─────────┴─────────┴─────────┴─────────┘

Req/Bytes counts sampled once per second.

122k requests in 10.03s, 42.6 MB read

===============================
= Gateway Mode (10s TTL)      =
===============================
Running 10s test @ http://localhost:3000/graphql
100 connections

┌─────────┬──────┬──────┬───────┬───────┬─────────┬─────────┬────────┐
│ Stat    │ 2.5% │ 50%  │ 97.5% │ 99%   │ Avg     │ Stdev   │ Max    │
├─────────┼──────┼──────┼───────┼───────┼─────────┼─────────┼────────┤
│ Latency │ 7 ms │ 7 ms │ 13 ms │ 18 ms │ 7.51 ms │ 3.22 ms │ 121 ms │
└─────────┴──────┴──────┴───────┴───────┴─────────┴─────────┴────────┘
┌───────────┬────────┬────────┬─────────┬─────────┬─────────┬─────────┬────────┐
│ Stat      │ 1%     │ 2.5%   │ 50%     │ 97.5%   │ Avg     │ Stdev   │ Min    │
├───────────┼────────┼────────┼─────────┼─────────┼─────────┼─────────┼────────┤
│ Req/Sec   │ 7147   │ 7147   │ 13231   │ 13303   │ 12498.2 │ 1807.01 │ 7144   │
├───────────┼────────┼────────┼─────────┼─────────┼─────────┼─────────┼────────┤
│ Bytes/Sec │ 2.5 MB │ 2.5 MB │ 4.63 MB │ 4.66 MB │ 4.37 MB │ 633 kB  │ 2.5 MB │
└───────────┴────────┴────────┴─────────┴─────────┴─────────┴─────────┴────────┘

Req/Bytes counts sampled once per second.

125k requests in 10.03s, 43.7 MB read

More info about how this plugin works

This plugin caches the result of the resolver, but if the resolver returns a type incompatible with the schema return type, the plugin will cache the invalid return value. When you call the resolver again, the plugin will return the cached value, thereby caching the validation error.

This issue may be exacerbated in a federation setup when you don't have full control over the implementation of federated schema and resolvers.

Here you can find an example of the problem.

'use strict'

const fastify = require('fastify')
const mercurius = require('mercurius')
const cache = require('mercurius-cache')

const app = fastify({ logger: true })

const schema = `
  type Query {
    getNumber: Int
  }
`

const resolvers = {
  Query: {
    async getNumber(_, __, { reply }) {
      return "hello";
    }
  }
}

app.register(mercurius, {
  schema,
  resolvers
})

app.register(cache, {
  ttl: 10,
  policy: {
    Query: {
      getNumber: true
    }
  }
})

If you come across this problem, you will first need to fix your code. Then you have two options:

  1. If you are you using an in-memory cache, it will be cleared at the next start of the application, so the impact of this issue will be limited
  2. If you are you using the Redis cache, you will need to manually invalidate the cache in Redis or wait for the TTL to expire

License

MIT

cache's People

Contributors

agubler avatar anapaulalemos avatar dependabot[bot] avatar fdawgs avatar gomah avatar jhonrocha avatar luke88jones avatar maksimovicdanijel avatar marco-ippolito avatar mcollina avatar ndintenfass avatar puppo avatar radomird avatar ramonmulia avatar sameer-coder avatar simone-sanfratello avatar simoneb avatar tiagoheliob avatar tomastm avatar wodcz avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar

cache's Issues

enable cache on federated service

at the moment, we can only cache queries defined in Query gql schema; in a federated service, we may have the follow scenario

const fastify = require('fastify')
const mercurius = require('mercurius')
const cache = require('mercurius-cache')

const app = fastify({ logger: true })

const schema = `
type User @key(fields: "id") {
  id: ID!
  name: String
}
`

const resolvers = {
  Query: {},

  User: {
    __resolveReference: async (source, args, context, info) => {
      return { id: source.id, name: `user #${source.id}` }
    }
  }

}

app.register(mercurius, {
  schema,
  resolvers,
  federationMetadata: true,
})

app.register(cache, {
  ttl: 10,
  policy: {
    Query: {},
    User: true
  }
})

app.listen(3000)

const query = `query ($representations: [_Any!]!) {
  _entities(representations: $representations) {
    ... on User { id, name }
  }
}`

const variables = {
  representations: [
    {
      __typename: 'User',
      id: 123,
    },
  ],
}

// run the following to test

console.log(`curl -X POST -H 'content-type: application/json' `
`-d '`+JSON.stringify({query, variables}) + `' `+
`localhost:3000/graphql`)

and we want to cache User entities

Also note, we need policy: { Query: {} in options, or we get an obscure error

UnhandledPromiseRejectionWarning: TypeError: Cannot convert undefined or null to object

Log a report of miss and hit calls

A log of the missed and hit call is useful to check if the cache is working properly and covers the case specified.

A proposal is to collect the hit/miss results and log a report every X seconds/minutes.

Eg.

policy Hit Miss
foo 120 230
bar 200 2

A further improvement can be highlight the parameters used in the query that are missed.

adopt cache for loaders

I'd like to use cache for loaders as well as for resolvers, for example

'use strict'

const Fastify = require('fastify')
const mercurius = require('mercurius')
const cache = require('mercurius-cache')

const app = Fastify()

const dogs = [{ name: 'Max' },
{ name: 'Charlie' },
{ name: 'Buddy' },
{ name: 'Max' }]

const owners = {
  Max: { name: 'Jennifer' },
  Charlie: { name: 'Sarah' },
  Buddy: { name: 'Tracy' }
}

const schema = `
  type Human {
    name: String!
  }

  type Dog {
    name: String!
    owner: Human
  }

  type Query {
    dogs: [Dog]
  }
`

const resolvers = {
  Query: {
    dogs(_, params, { reply }) {
      return dogs
    }
  }
}

const loaders = {
  Dog: {
    async owner(queries, { reply }) {
      return queries.map(({ obj }) => owners[obj.name])
    }
  }
}

app.register(mercurius, {
  schema,
  resolvers,
  loaders,
})

app.register(cache, {
  ttl: 9,
  onHit(type, fieldName) {
    console.log(`hit ${type} ${fieldName}`)
  },
  onMiss(type, fieldName) {
    console.log(`miss ${type} ${fieldName}`)
  },
  policy: {
    resolvers: {
      Query: {
        dogs: {
          ttl: 5,
          // ...
        }
      }
    },
    loaders: {
      Dog: {
        owner: {
          ttl: 10,
          // ...
        }
      }
    }
  }
})

app.listen(3000)

Exception not properly handled

Most likely linked to #110.

The resulting error I get from GraphQL is:
Cannot return null for non-nullable field

When it should be:
Unauthorized

Explanation (and my investigation):
I have a JWT guard that throws an UnauthorizedError when a user is not properly authenticated. This guard seems to be called the first time a GraphQL field is resolved (i.e. from originalFieldResolver). This exception is not caught and rethrown by mercurius-cache, which causes it to be caught by async-cache-dedupe which then calls the onError callback without rethrowing the error (the query promise is chained, and has a catch error). Because of this, GraphQL continue its execution as if nothing happened and therefore throws another error later (due to having no data).

Dependencies:

fastify: 4.9.2
mercurius: 11.1.0
mercurius-cache: 3.0.1
Node.js: 16.13.2

500 Internal Issue when Mercurius Cache is used with the NestJS Auth Guard

Hi All,

Did anyone faced following issue while using Mercurius Cache with NestJS auth where it throws 500 Internal Issue.

Use Case:
I have global auth applied in NestJS and when auth token becomes invalid or auth token is not provided then it throws 500 Internal Issue rather than correct exception which should be Forbidden or Authentication Required.

I tried skip property of Mercurius Cache which addressed one scenario where if Authorization token is passed in the http header then it works as intended but when token is not present in http headers it throws 500 instead of 403 Authentication not required.

Thanks

add `policy.key` option

add option to define a custom serialize function for any policy, or one for all, for example

const schema = `
  type Query {
    add(x: Int, y: Int): Int
  }
`
// ...

app.register(cache, {
  ttl: 10,
  policy: {
    Query: {
      add: { 
        key: ({ fields, self, args, info, context }) => `x:${args.x}-y:${args.y}`
    }
  }
})

note: extendKey option is ignored, should be thrown an error on policy validation if key and extendKey are both used

Invalid string length at StorageRedis.clearReferences

[11:22:07.302] ERROR (28): acd/storage/redis.clearReferences error
err: {
"type": "RangeError",
"message": "Invalid string length",
"stack":
RangeError: Invalid string length
at Object.write (/usr/app/node_modules/ioredis/built/Pipeline.js:310:29)
at EventEmitter.sendCommand (/usr/app/node_modules/ioredis/built/Redis.js:387:28)
at execPipeline (/usr/app/node_modules/ioredis/built/Pipeline.js:330:25)
at Pipeline.exec (/usr/app/node_modules/ioredis/built/Pipeline.js:282:5)
at StorageRedis.clearReferences (/usr/app/node_modules/async-cache-dedupe/src/storage/redis.js:323:56)
at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
at async StorageRedis._invalidateReferences (/usr/app/node_modules/async-cache-dedupe/src/storage/redis.js:227:5)
at async StorageRedis.invalidate (/usr/app/node_modules/async-cache-dedupe/src/storage/redis.js:193:16)
at async Cache.invalidateAll (/usr/app/node_modules/async-cache-dedupe/src/cache.js:185:5)
at async Promise.all (index 0)
}

When I try to invalidate array of references, catching this error
Using chunks, maximum array of 10, if we had a lot, but it's even crushes with 4

https://github.com/11laflame/cache-bug-setup

check please, follow readme steps

It's imitating reference functionality in mercurius-cache

policy: {
test: { references: generateReferences, key: generateCacheKey },
}

generateReferences returning array ["model:sdj3-3dsd3-sddsd", "model:2dkd3-33dsdd2-2d2"]

This is what async-cache-dedupe creators answered:

The problem in your example is that you are setting the model directly in https://github.com/11laflame/cache-bug-setup/blob/e630b47da14594664ad7de4ea1b88da097cbfe51/src/services/fill.js#L11, without using the methods from async-cache-dedupe to set the cache. This creates a bad data structure in redis, making the server crash happen.

Skip the cache if the request is a mutation

When a mutation is done, the user can add a query in the request.

It will be useful to verify the type of request and skip the cache when is a mutation.

In this way the user will get always data up to date after a modify action.

Can't cache custom scalar Date data

Mercurius-cache works well with Graphql built-in types, but when I used it to cache a field which has a custom scalar Date sub-field, error occurred.


Uncaught (in promise) Error: value.getTime is not a function

The scalar Date is defined like this : https://www.apollographql.com/docs/apollo-server/schema/custom-scalars/#example-the-date-scalar, and I used redis as storage.

I guess the reason is that when cache is hit, Date type data fetched from redis as a string, but it is not converted to js Date type.

How to cache custom scalar Date, please help, thanks.

Allow skip rules for specific policy

Some policies can have a different rule to define the skip action.

Add an option at policy level to define the skip.

  skip: () => { ...globalSkiprule}
  policy: {
    Query: {
      welcome: {
        skip: () => { ...skipRules}
      }
    }
  }

Responses which fail GraphQL schema validation are stored in the cache.

Hello,

I'd like to start by acknowledging that I understand that this might be "working as-designed". I'd still like to know if you have any suggestions on how to deal with the following situation.

The issue: mercurius-cache will store a value in the cache before the value goes through GraphQL validation, and this causes subsequent queries that use the same cache key to get a cached error response. The only option I see to prevent this is to set an onResolution hook in Mercurius that invalidates the cache for a given query.

To reproduce this, please install the following dependencies:

$ cat package.json 
{
  "name": "mercurius-cache-skip-cache-on-graphql-error",
  "version": "1.0.0",
  "main": "index.js",
  "dependencies": {
    "fastify": "4.13.0",
    "graphql": "16.6.0",
    "mercurius": "12.2.0",
    "mercurius-cache": "4.0.0"
  }
}

And execute the following script:

$ cat index.js 
'use strict'

const fastify = require('fastify')
const mercurius = require('mercurius')
const cache = require('mercurius-cache')

const app = fastify({ logger: true })

const schema = `
  type Query {
    add(x: Int, y: Int): Int
    hello: String
  }
`

const resolvers = {
  Query: {
    async add (_, { x, y }, { reply }) {
      reply.log.info('add called')

      return 'not a number'
    }
  }
}

app.register(mercurius, {
  schema,
  resolvers
})


// cache query "add" responses for 10 seconds
app.register(cache, {
  ttl: 10,
  policy: {
    Query: {
      add: true
      // note: it cache "add" but it doesn't cache "hello"
    }
  }
})

app.listen({ port: 3000, host: '127.0.0.1' })

In a separate Bash shell, execute:

for i in {1..2}; do curl -X POST -H 'content-type: application/json' -d '{ "query": "{ add(x: 2, y: 2) }" }' localhost:3000/graphql; echo; done

These are the observed results:

ebustamante@debian:~/mercurius-cache-skip-cache-on-graphql-error$ node index.js 
{"level":30,"time":1677525493784,"pid":655752,"hostname":"debian","msg":"Server listening at http://127.0.0.1:3000"}
{"level":30,"time":1677525499460,"pid":655752,"hostname":"debian","reqId":"req-1","req":{"method":"POST","url":"/graphql","hostname":"localhost:3000","remoteAddress":"127.0.0.1","remotePort":44568},"msg":"incoming request"}
{"level":30,"time":1677525499468,"pid":655752,"hostname":"debian","reqId":"req-1","msg":"add called"} 
{"level":30,"time":1677525499469,"pid":655752,"hostname":"debian","reqId":"req-1","err":{"type":"GraphQLError","message":"Int cannot represent non-integer value: \"not a number\"","stack":"GraphQLError: Int cannot represent non-integer value: \"not a number\"\n    at GraphQLScalarType.serialize (/home/ebustamante/mercurius-cache-skip-cache-on-graphql-error/node_modules/graphql/type/scalars.js:61:13)\n    at completeLeafValue (/home/ebustamante/mercurius-cache-skip-cache-on-graphql-error/node_modules/graphql/execution/execute.js:738:39)\n    at completeValue (/home/ebustamante/mercurius-cache-skip-cache-on-graphql-error/node_modules/graphql/execution/execute.js:619:12)\n    at /home/ebustamante/mercurius-cache-skip-cache-on-graphql-error/node_modules/graphql/execution/execute.js:486:9\n    at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n    at async Promise.all (index 0)\n    at async Object.fastifyGraphQl [as graphql] (/home/ebustamante/mercurius-cache-skip-cache-on-graphql-error/node_modules/mercurius/index.js:531:23)","path":["add"],"locations":[{"line":1,"column":3}],"extensions":{}},"msg":"Int cannot represent non-integer value: \"not a number\""}
{"level":30,"time":1677525499472,"pid":655752,"hostname":"debian","reqId":"req-1","res":{"statusCode":200},"responseTime":12.557700157165527,"msg":"request completed"}
{"level":30,"time":1677525499480,"pid":655752,"hostname":"debian","reqId":"req-2","req":{"method":"POST","url":"/graphql","hostname":"localhost:3000","remoteAddress":"127.0.0.1","remotePort":44584},"msg":"incoming request"}
{"level":30,"time":1677525499482,"pid":655752,"hostname":"debian","reqId":"req-2","err":{"type":"GraphQLError","message":"Int cannot represent non-integer value: \"not a number\"","stack":"GraphQLError: Int cannot represent non-integer value: \"not a number\"\n    at GraphQLScalarType.serialize (/home/ebustamante/mercurius-cache-skip-cache-on-graphql-error/node_modules/graphql/type/scalars.js:61:13)\n    at completeLeafValue (/home/ebustamante/mercurius-cache-skip-cache-on-graphql-error/node_modules/graphql/execution/execute.js:738:39)\n    at completeValue (/home/ebustamante/mercurius-cache-skip-cache-on-graphql-error/node_modules/graphql/execution/execute.js:619:12)\n    at /home/ebustamante/mercurius-cache-skip-cache-on-graphql-error/node_modules/graphql/execution/execute.js:486:9\n    at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n    at async Promise.all (index 0)\n    at async Object.fastifyGraphQl [as graphql] (/home/ebustamante/mercurius-cache-skip-cache-on-graphql-error/node_modules/mercurius/index.js:531:23)","path":["add"],"locations":[{"line":1,"column":3}],"extensions":{}},"msg":"Int cannot represent non-integer value: \"not a number\""}
{"level":30,"time":1677525499482,"pid":655752,"hostname":"debian","reqId":"req-2","res":{"statusCode":200},"responseTime":1.6182003021240234,"msg":"request completed"}

And

ebustamante@debian:~/mercurius-cache-skip-cache-on-graphql-error$ for i in {1..2}; do curl -X POST -H 'content-type: application/json' -d '{ "query": "{ add(x: 2, y: 2) }" }' localhost:3000/graphql; echo; done 
{"data":{"add":null},"errors":[{"message":"Int cannot represent non-integer value: \"not a number\"","locations":[{"line":1,"column":3}],"path":["add"]}]}
{"data":{"add":null},"errors":[{"message":"Int cannot represent non-integer value: \"not a number\"","locations":[{"line":1,"column":3}],"path":["add"]}]}

Notice that for the second request add called is not logged, as it's read from the cache.

As per my initial comment, I understand that this is perhaps working as designed. mercurius-cache wraps resolvers individually, so when graphql-js resolves a value (in node_modules/graphql/execution/execute.js, see snippet below), mercurius-cache doesn't know early enough about type validation errors.

  try {
    // Build a JS object of arguments from the field.arguments AST, using the
    // variables scope to fulfill any variable references.
    // TODO: find a way to memoize, in case this field is within a List type.
    const args = (0, _values.getArgumentValues)(
      fieldDef,
      fieldNodes[0],
      exeContext.variableValues,
    ); // The resolve function's optional third argument is a context value that
    // is provided to every resolve function within an execution. It is commonly
    // used to represent an authenticated user, or request-specific caches.

    const contextValue = exeContext.contextValue;
    const result = resolveFn(source, args, contextValue, info);   /* Ed's note: mercurius-cache will store the value in the cache here */
    let completed;

    if ((0, _isPromise.isPromise)(result)) {
      completed = result.then((resolved) =>
        completeValue(exeContext, returnType, fieldNodes, info, path, resolved), /* Ed's note: Graphql-js throws an error here, because the string value does not serialize to an integer type */
      );
    } else {
      completed = completeValue(
        exeContext,
        returnType,
        fieldNodes,
        info,
        path,
        result,
      );
    }

To give an example situation where this might be an issue: Suppose we have a GraphQL service that assembles responses from multiple upstream services. If any of these upstream services introduces a bug that causes a breaking change in their response format, that may lead to type validation errors. Without mercurius-cache, this issue will be resolved as soon as the upstream service reverts the breaking change. But with mercurius-cache, the cache will keep these invalid values for the duration of the TTL.

In the example scenario, there are several strategies that could help reduce the impact: (a) Ensure TTLs are low enough to allow for quick recovery after such breaking change, (b) validate input from the upstream service at the resolver error and throw an error if the validation fails. I don't like (a), because it reduces the effectiveness of the cache. I don't like (b), because we're essentially duplicating the validation that we already get from graphql-js.

Without introducing changes to mercurius-cache / async-cache-dedupe, I believe the only option that we have to avoid caching values that fail GraphQL validation is to setup an onResolution hook that invalidates the cache on GraphQL errors. This seems a bit expensive.

Another option that does require changes to async-cache-dedupe and mercurius-cache would be to add the ability to defer calls to storage.set in _wrapFunction by accepting a sort of "cache flush" callback. The caller can then decide at which point during the request lifecycle to execute the call to storage.set. This seems a bit complicated to me, and potentially error prone. But I can't think of any other options.

TypeScript types

Would be wonderful if there were typescript types included. Understand if it's not a priority, if so just keep it as a placeholder/reminder?

Incorrect storage invalidation option

I believe there is a bug in the storage options object. The documentation references options.invalidation, the code base expects options.invalidate (also referenced in the TS types), and the underlying createCache() method from async-cache-dedupe expects options.invalidation.

References to the code points
Storage object validation - https://github.com/mercurius-js/cache/blob/main/lib/validation.js#L152
Storage object passed directly to async-cache-dedupe - https://github.com/mercurius-js/cache/blob/main/index.js#L43
Usage in async-cache-dedupe - https://github.com/mcollina/async-cache-dedupe/blob/main/src/storage/memory.js#L30

I'm happy to raise a PR for this, just wanted to check I'm not missing something first

Add options validation

Add options validation, it should check:

  • options values
  • mercurius is already registered
  • validate policy entries are consistent to gql schema

not working with fastify-cli project.

I have a project setup using fastify-cli and bob-ts for typescript.

Mercurius cache is not getting registered when running project in dev/prod mode.
But when i ran fastify-fli export command and run generated server.js. Cache is working as expected.

I put some console.log's in lib code and found out 'onReady' hook is never being called. Possible issue with cli itself

library code cache.js
`
app.log.warn('mercurius-cache is ready')

app.addHook('onReady', async () => {
app.log.warn('mercurius-cache is loaded');

app.graphql.cache.refresh()
report.refresh()

})

`

Original resolver executed twice when an error is thrown

When a resolver throws an error, the original resolver is executed twice.

const resolvers = {
  Query: {
    hello(parent, args, context) {
      console.log("query hello", context.count++);
      throw new Error("oops");
    },
  },
};

const app = Fastify();

app.register(mercurius, {
  schema,
  resolvers,
  context: () => ({ count: 0 }),
  graphiql: true,
});

app.register(mercuriusCache, {
  ttl: 2, // seconds
  all: true,
  storage: {
    type: "memory",
    options: {
      size: 2048,
    },
  },
});

Actual output

query hello 0
query hello 1

Expected output

query hello 0

I was able to debug the code and find the second location where the original resolver gets called:

  1. first call https://github.com/mercurius-js/cache/blob/v2.0.0/index.js#L184
  2. second call https://github.com/mercurius-js/cache/blob/v2.0.0/index.js#L257

I've also made a reproduction repository here: https://github.com/mfellner/mercurius-cache-bug

Dependencies:

  • fastify: 4.5.3
  • mercurius: 10.5.0
  • mercurius-cache: 2.0.0
  • Node.js: 18.8.0

FastifyError [Error]: onGatewayReplaceSchema hook not supported!

Latest update to mercurius v12.0.0 is causing this error, rolling back to 11.5.0 eliminates the error

FastifyError [Error]: onGatewayReplaceSchema hook not supported! at Hooks.validate (node_modules/mercurius/lib/hooks.js:33:11) at Hooks.add (node_modules/mercurius/lib/hooks.js:38:8) at fastifyGraphQl.addHook (node_modules/mercurius/index.js:383:18) at module.exports.fp.fastify (node_modules/mercurius-cache/index.js:40:15) at Plugin.exec (node_modules/avvio/plugin.js:130:19) at Boot.loadPlugin (node_modules/avvio/plugin.js:272:10) at process.processTicksAndRejections (node:internal/process/task_queues:82:21) { code: 'MER_ERR_HOOK_UNSUPPORTED_HOOK', statusCode: 500 }

Enabled Mercurius Plugins

  • Cache

add a specific key for policy options

at the moment, the definition for policy options is

  policy: {
    Query: {
      welcome: {
        ttl: 5
      }
    }
  }

this could lead to potential conflicts if there are nested entities with the same name of policy options (ttl, skip, storage and so on), for example

  policy: {
    Query: {
      welcome: {
        ttl: {
           ttl: 1,
           skip: () { /* .. */ }
        }
      }
    }
  }

the proposed solution would be to support the current configuration and introducing a specific key, for example '__options', to be used in case of conflicts, as follow

  policy: {
    Query: {
      welcome: {
        // no __options key present, so policy options are considered as it is
        ttl: 6
      },
      hello: {
        // since "hello" query has a ttl property
        __options: {
          ttl: 6
        },
        ttl: {
          // here we can use both __options or list policy options
          skip: () { /* .. */ }
        }
      }
    }
  }

also, adding validation for nested policies here https://github.com/mercurius-js/cache/blob/main/lib/validation.js#L82

cc @codeflyer

Refactor error handling

cache/index.js

Lines 139 to 182 in 3cebe49

return async function (self, arg, ctx, info) {
let result
try {
// dont use cache on mutation and subscriptions
if (info.operation && (info.operation.operation === 'mutation' || info.operation.operation === 'subscription')) {
// TODO if originalFieldResolver throws, we should not go into the catch
result = await originalFieldResolver(self, arg, ctx, info)
} else if (
(skip && (await skip(self, arg, ctx, info))) ||
(policy && policy.skip && (await policy.skip(self, arg, ctx, info)))
) {
// dont use cache on skip by policy or by general skip
report[name].onSkip()
// TODO if originalFieldResolver throws, we should not go into the catch
result = await originalFieldResolver(self, arg, ctx, info)
} else {
// use cache to get the result
// Ignore execptions, 'onError' is already in place
result = await cache[name]({ self, arg, ctx, info }).catch((err) => {
// TODO this should not be needed
err._onErrorCalled = true
throw err
})
}
if (invalidate) {
// invalidate is async but no await, and never throws
// since the result is already got, either by cache or original resolver
// in case of error, onError is called in the invalidation function to avoid await
invalidation(invalidate, cache, name, self, arg, ctx, info, result, onError)
}
} catch (err) {
// TODO remove this logic, find a better way to handle errors
if (!err._onErrorCalled) {
onError(err)
} else {
delete err._onErrorCalled
}
return originalFieldResolver(self, arg, ctx, info)
}
return result
}
}
includes quite a few TODOs. In a few cases we are invoking the original resolver twice.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.