diff --git a/docs/docs/services.md b/docs/docs/services.md index 473bf8f5eed5..5424852d06bd 100644 --- a/docs/docs/services.md +++ b/docs/docs/services.md @@ -805,6 +805,30 @@ An easier solution to this problem would be to add some kind of version number t And this key is our final form: a unique, but flexible key that allows us to expire the cache on demand (change the version) or automatically expire it when the record itself changes. +:::info + +One more case: what if the underlying `Product` model itself changes, adding a new field, for example? Each product will now have new data, but no changes will occur to `updatedAt` as a result of adding this column. There are a couple things you could do here: + +* Increment the version on the key, if you have one: `v1` => `v2` +* "Touch" all of the Product records in a script, forcing them to have their `updatedAt` timestamp changed +* Incorporate a hash of all the keys of a `product` into the cache key + +How does that last one work? We get a list of all the keys and then apply a hashing algorithm like MD5 to get a string that's unique based on that list of database columns. Then if one is ever added or removed, the hash will change, which will change the key, which will bust the cache: + +```javascript +const product = db.product.findUnique({ where: { id } }) +const columns = Object.keys(product) // ['id', 'name', 'sku', ...] +const hash = md5(columns.join(',')) // "e4d7f1b4ed2e42d15898f4b27b019da4" + +cache(`v1-product-${hash}-${id}-${updatedAt}`, () => { + // ... +}) +``` + +Note that this has the side effect of having to select at least one record from the database so that you know what the column names are, but presumably this is much less overhead that whatever computation you're trying to avoid by caching: the slow work that happens inside of the function passed to `cache()` will still be avoided on subsequent calls (and selecting a single record from the database by an indexed column like `id` should be very fast). + +::: + #### Expiration-based Keys You can skirt these issues about what data is changing and what to include or not include in the key by just setting an expiration time on this cache entry. You may decide that if a change is made to a product, it's okay if users don't see the change for, say, an hour. In this case just set the expiration time to 3600 seconds and it will automatically be re-built, whether something changed in the record or not: @@ -844,7 +868,7 @@ cache(`recommended-${context.currentUser.id}`, () => { }) ``` -If every page the user visits has a different list of recommended products then creating this cache may not be worth it: how often does the user revisit the same product page more than once? Conversely, if you show the *same* recommended products on every page then this cache would definitely improve the user's experience. +If every page the user visits has a different list of recommended products for every page (meaning that the full computation will need to run at least once, before it's cached) then creating this cache may not be worth it: how often does the user revisit the same product page more than once? Conversely, if you show the *same* recommended products on every page then this cache would definitely improve the user's experience. The *key* to writing a good key (!) is to think carefully about the circumstances in which the key needs to expire, and include those bits of information into the key string/array. Adding caching can lead to weird bugs you don't expect, but in these cases the root cause will usually be the cache key not containing enough bits of information to expire it correctly. When in doubt, restart the app with the cache server (memcached or redis) disabled and see if the same behavior is still present. If not, the cache key is the culprit! @@ -1016,6 +1040,33 @@ const post = ({ id }) => { ::: +### `deleteCacheKey()` + +There may be instances where you want to explictly remove something from the cache so that it gets re-created with the same cache key. A good example is caching a single user, using only their `id` as the cache key. By default, the cache would never bust because a user's `id` is not going to change, no matter how many other fields on user are updated. With `deleteCacheKey()` you can choose to delete the key, for example, when the `updateUser()` service is called. The next time `user()` is called, it will be re-cached with the same key, but it will now contain whatever data was updated. + +```javascript +import { cache, deleteCacheKey } from 'src/lib/cache' + +const user = ({ id }) => { + return cache(`user-${id}`, () => { + return db.user.findUnique({ where: { id } }) + }) +}) + +const updateUser = async ({ id, input }) => { + await deleteCacheKey(`user-${id}`) + return db.user.update({ where: { id }, data: { input } }) +}) +``` + +:::caution + +When explictly deleting cache keys like this you could find yourself going down a rabbit hole. What if there is another service somewhere that also updates user? Or another service that updates an organization, as well as all of its underlying child users at the same time? You'll need to be sure to call `deleteCacheKey()` in these places as well. As a general guideline, it's better to come up with a cache key that encapsulates any triggers for when the data has changed (like the `updatedAt` timestamp, which will change no matter who updates the user, anywhere in your codebase). + +Scenarios like this are what people are talking about when they say that caching is hard! + +::: + ### Testing what you cache We wouldn't just give you all of these caching APIs and not show you how to test it right? You'll find all the details in the [Caching section in the testing doc](testing.md#testing-caching). diff --git a/packages/api/src/cache/__tests__/deleteCacheKey.test.js b/packages/api/src/cache/__tests__/deleteCacheKey.test.js new file mode 100644 index 000000000000..a53fef6c9534 --- /dev/null +++ b/packages/api/src/cache/__tests__/deleteCacheKey.test.js @@ -0,0 +1,37 @@ +import InMemoryClient from '../clients/InMemoryClient' +import { createCache } from '../index' + +describe('deleteCacheKey', () => { + it('deletes a key from the cache', async () => { + const client = new InMemoryClient({ + test: { expires: 1977175194415, value: '{"foo":"bar"}' }, + }) + const { deleteCacheKey } = createCache(client) + + await deleteCacheKey('test') + + expect(client.storage['test']).toEqual(undefined) + }) + + it('returns true if key was deleted', async () => { + const client = new InMemoryClient({ + test: { expires: 1977175194415, value: '{"foo":"bar"}' }, + }) + const { deleteCacheKey } = createCache(client) + + const result = await deleteCacheKey('test') + + expect(result).toEqual(true) + }) + + it('returns false if key did not exist', async () => { + const client = new InMemoryClient({ + test: { expires: 1977175194415, value: '{"foo":"bar"}' }, + }) + const { deleteCacheKey } = createCache(client) + + const result = await deleteCacheKey('foobar') + + expect(result).toEqual(false) + }) +}) diff --git a/packages/api/src/cache/clients/BaseClient.ts b/packages/api/src/cache/clients/BaseClient.ts index 5246a9a74bad..9cf6d3382ee0 100644 --- a/packages/api/src/cache/clients/BaseClient.ts +++ b/packages/api/src/cache/clients/BaseClient.ts @@ -16,4 +16,7 @@ export default abstract class BaseClient { value: unknown, options: { expires?: number } ): Promise | any // types are tightened in the child classes + + // Removes a value by its key + abstract del(key: string): Promise | any } diff --git a/packages/api/src/cache/clients/InMemoryClient.ts b/packages/api/src/cache/clients/InMemoryClient.ts index 441e4794b6f5..0668ff7f0e15 100644 --- a/packages/api/src/cache/clients/InMemoryClient.ts +++ b/packages/api/src/cache/clients/InMemoryClient.ts @@ -52,6 +52,15 @@ export default class InMemoryClient extends BaseClient { return true } + async del(key: string) { + if (this.storage[key]) { + delete this.storage[key] + return true + } else { + return false + } + } + /** * Special functions for testing, only available in InMemoryClient */ diff --git a/packages/api/src/cache/clients/MemcachedClient.ts b/packages/api/src/cache/clients/MemcachedClient.ts index 2fb5cc1641bd..9c5ea1fa6bef 100644 --- a/packages/api/src/cache/clients/MemcachedClient.ts +++ b/packages/api/src/cache/clients/MemcachedClient.ts @@ -44,4 +44,13 @@ export default class MemcachedClient extends BaseClient { return this.client?.set(key, JSON.stringify(value), options) } + + async del(key: string) { + if (!this.client) { + await this.connect() + } + + // memcached returns true/false natively + return this.client?.delete(key) + } } diff --git a/packages/api/src/cache/clients/RedisClient.ts b/packages/api/src/cache/clients/RedisClient.ts index 473099c71dd5..19f89c7f5ebe 100644 --- a/packages/api/src/cache/clients/RedisClient.ts +++ b/packages/api/src/cache/clients/RedisClient.ts @@ -66,4 +66,13 @@ export default class RedisClient extends BaseClient { return this.client?.set(key, JSON.stringify(value), setOptions) } + + async del(key: string) { + if (!this.client) { + await this.connect() + } + + // Redis client returns 0 or 1, so convert to true/false manually + return !!(await this.client?.del([key])) + } } diff --git a/packages/api/src/cache/index.ts b/packages/api/src/cache/index.ts index 0e9d6ce96ab0..7e818ea21ff3 100644 --- a/packages/api/src/cache/index.ts +++ b/packages/api/src/cache/index.ts @@ -185,8 +185,28 @@ export const createCache = ( return cache(latestCacheKey, () => model.findMany(conditions), rest) } + const deleteCacheKey = async (key: CacheKey) => { + let result + + try { + await Promise.race([ + (result = client.del(key as string)), + wait(timeout).then(() => { + throw new CacheTimeoutError() + }), + ]) + + logger?.debug(`[Cache] DEL '${key}'`) + return result + } catch (e: any) { + logger?.error(`[Cache] Error DEL '${key}': ${e.message}`) + return false + } + } + return { cache, cacheFindMany, + deleteCacheKey, } }