Digging deeper
Cache
Keep the result of slow work, such as a heavy query or a call to another service, in the cache, and read it back quickly on the next request.
Introduction#
Some of the data your app reads or computes takes a while to get, and stays the same for a time. Store it in a cache and later requests read it from there. Oriel's cache has one API over several stores: your database, files, Redis, or your app's memory.
Configuration#
The cache's settings are in config/. default names the store that the Cache facade uses, and a new app
uses the database store:
import { dirname, join } from 'node:path'import { env, Str } from '@orieljs/framework'const basePath = dirname(import.meta.dirname)export default { default: env('CACHE_STORE', 'database'), stores: { array: { driver: 'array' }, database: { driver: 'database', table: 'cache', lockTable: 'cache_locks' }, file: { driver: 'file', path: join(basePath, 'storage/framework/cache/data') }, redis: { driver: 'redis', connection: 'cache' }, }, prefix: env('CACHE_PREFIX', Str.slug(String(env('APP_NAME', 'oriel')), '_') + '_cache_'),}Each store has a driver:
database: in thecachetable of your database.file: one file per key, instorage/.framework/ cache/ data redis: in Redis.array: in memory, in your app's process. Your tests use it.memory: in memory too, with an optionalmaxItems: past that number of entries, the least recently used one is dropped. A new app's config doesn't list amemorystore; add one to use it:memory: { driver: 'memory', maxItems: 1000 }.
prefix starts every key, so that two apps can share a store: blog_cache_ for an app called Blog. A store can set
its own prefix.
Driver prerequisites#
Database#
The database store needs the cache table, and its locks the cache_locks table. A new app's
0001_01_01_000001_create_cache_table.ts migration creates both:
await Schema.create('cache', (t) => { t.string('key').primary() t.text('value') t.integer('expiration')})await Schema.create('cache_locks', (t) => { t.string('key').primary() t.string('owner') t.integer('expiration')})A store's connection names the database connection to use, the default one when it has none, and table and
lockTable the tables.
Redis#
The redis store talks to the Redis server of config/: connection names an entry of database.redis.
A new app's cache entry is its own Redis database, number 1 unless you set REDIS_CACHE_DB, so that flushing the
cache leaves your sessions and queued jobs alone. Oriel's Redis client, ioredis, comes with the framework, and the
connection is closed when your app shuts down.
Cache usage#
Obtaining a cache instance#
The Cache facade reaches the default store. Every method is async, so await it:
import { Cache } from '@orieljs/framework'import { Controller } from './Controller'export class UserController extends Controller { /** Show a list of all users of the application. */ async index() { const value = await Cache.get('key') return { value } }}Accessing multiple cache stores#
Cache.store() returns another store of config/ by its name:
const value = await Cache.store('file').get('foo')await Cache.store('redis').put('bar', 'baz', 600) // 10 MinutesA name that isn't in stores is an error: "Cache store [mongo] is not configured. Add it to config('cache.stores').".
Retrieving items from the cache#
get() returns the value of a key, or undefined when the cache has no such key. A second argument is the default,
returned in its place:
const value = await Cache.get('key')const valueOrDefault = await Cache.get('key', 'default')The default may be a function, which is only called when the key is missing, so a costly default isn't computed for nothing:
const users = await Cache.get('users', () => DB.table('users').get())get() takes a type for the value, which is a cast, not a check: Cache.get<string>('name').
many() reads several keys at once, and returns an object with a value, or undefined, for each:
const values = await Cache.many(['name', 'email'])Determining item existence#
has() tells you whether a key is in the cache, and missing() whether it isn't. A key stored with a null value
is in the cache:
if (await Cache.has('key')) { // ...}Incrementing / decrementing values#
increment() and decrement() change the number stored under a key, by 1 or by the amount you give, and return the
new value. A missing key counts as 0, and is then stored with no expiry; a key that has one keeps it:
// Initialize the value if it does not exist...await Cache.add('key', 0, 4 * 60 * 60)// Increment or decrement the value...await Cache.increment('key')await Cache.increment('key', amount)await Cache.decrement('key')await Cache.decrement('key', amount)Retrieve and store#
remember() returns the value of a key, or, when it is missing, calls your function, stores what it returns for the
number of seconds you give, and returns that:
const users = await Cache.remember('users', seconds, () => DB.table('users').get())rememberForever() does the same, and stores the value with no expiry:
const users = await Cache.rememberForever('users', () => DB.table('users').get())The function may be async. A function that returns undefined stores nothing, so it is called again next time.
Retrieve and delete#
pull() returns a value and removes it from the cache. Like get(), it returns undefined, or the default you give,
when the key is missing:
const value = await Cache.pull('key')const valueOrDefault = await Cache.pull('key', 'default')Storing items in the cache#
put() stores a value for a number of seconds:
await Cache.put('key', 'value', 10)Without a number of seconds, it stores the value with no expiry:
await Cache.put('key', 'value')Times are always in seconds, and may have a fraction: Cache.put('key', 'value', 0.5) keeps the value for half a
second, though the database store rounds it up to whole seconds.
What a value can be#
A value is stored as JSON, in every store, the array one included, so it comes back the same whichever store you
use. Store plain data: strings, numbers, booleans, null, and arrays and plain objects of those. Anything else comes
back changed:
- a
Datecomes back as its ISO string,'2026-01-02T03:04:05.000Z'; read it withnew Date(value); - a class instance, a model for one, comes back as a plain object with its fields, without its methods;
- a
Mapor aSetcomes back as{}; undefinedas a value is stored asnull, andNaNandInfinityasnull.
To cache a model, cache what you need from it, such as its attributes, and use them as plain data:
const post = await Cache.remember('post:1', 60, async () => (await Post.findOrFail(1)).toArray())Store if not present#
add() stores a value only when the key is missing, and tells you whether it did. It is atomic in every store, so
two requests that race for the same key can't both add it:
const added = await Cache.add('key', 'value', seconds)Storing items forever#
forever() stores a value with no expiry. Remove it with forget():
await Cache.forever('key', 'value')On the database store, "forever" is ten years.
Removing items from the cache#
forget() removes a key:
await Cache.forget('key')So does put() with zero or a negative number of seconds:
await Cache.put('key', 'value', 0)await Cache.put('key', 'value', -5)flush() removes every key of the store:
await Cache.flush()When the store has a prefix, as a new app's do, flush() removes only the keys that start with it, in the database
and redis stores. The file store deletes its folder, and the array store empties itself.
The cache helper#
cache() reads and writes the default store too. With a key, it returns the key's value. With an object, it stores
each of its entries, for the number of seconds you give, or with no expiry:
const value = await cache('key')await cache({ key: 'value' }, seconds)With nothing, it returns the cache, whose methods are the facade's:
const users = await cache().remember('users', seconds, () => DB.table('users').get())Cache tags#
Tags group related keys so you can remove them together. Only the redis store has them: on any other store,
tags() throws "The [DatabaseStore] cache store does not support tags. Only the redis store does.".
Storing tagged cache items#
tags() takes one tag or a list, and returns a cache whose keys belong to those tags:
await Cache.tags(['people', 'artists']).put('John', john, seconds)await Cache.tags(['people', 'authors']).put('Anne', anne, seconds)Accessing tagged cache items#
A tagged key is read with the same tags it was stored with, in any order:
const john = await Cache.tags(['people', 'artists']).get('John')const anne = await Cache.tags(['people', 'authors']).get('Anne')Removing tagged cache items#
flush() on a tagged cache removes every key stored with any of its tags. This removes both John and Anne:
await Cache.tags(['people', 'authors']).flush()This removes only Anne, the one tagged authors:
await Cache.tags('authors').flush()Atomic locks#
A lock lets one process at a time do a piece of work, across every process and server that shares the store. Every
store has locks. The database store keeps them in the cache_locks table, and the redis store in Redis; the
file store's work for the processes of one machine, and the array store's only within one process.
Managing locks#
Cache.lock() takes the lock's name and how many seconds it lasts at most. get() tries once to take it, and tells
you whether it did:
const lock = Cache.lock('foo', 10)if (await lock.get()) { // Lock acquired for 10 seconds... await lock.release()}get() also takes a function. When the lock is taken, it runs the function, releases the lock, and returns what the
function returned; when it isn't, it returns false:
await Cache.lock('foo', 10).get(async () => { // Lock acquired for 10 seconds and automatically released...})block() waits for the lock, up to a number of seconds. When the time is up, it throws a LockTimeoutError,
"Unable to acquire lock [foo].":
const lock = Cache.lock('foo', 10)try { await lock.block(5) // Lock acquired after waiting a maximum of 5 seconds...} catch (error) { if (!(error instanceof LockTimeoutError)) throw error // Unable to acquire lock...} finally { await lock.release()}Given a function, block() runs it once the lock is taken, and releases the lock after:
await Cache.lock('foo', 10).block(5, async () => { // Lock acquired for 10 seconds after waiting a maximum of 5 seconds...})A lock without a number of seconds lasts until it is released. release() releases a lock only for its owner, the
lock object that took it, and returns whether it did.
Managing locks across processes#
A lock can be taken in one process and released in another, such as a job the request dispatches. Each lock has an
owner, a random string that owner() returns. Pass it to the job:
const podcast = await Podcast.findOrFail(id)const lock = Cache.lock('processing', 120)if (await lock.get()) { await ProcessPodcast.dispatch(podcast, lock.owner())}In the job, Cache.lock() takes the owner as its third argument, and the lock it returns can release the one the
request took:
import { Cache, Queueable } from '@orieljs/framework'import type { Podcast } from '../Models/Podcast'export class ProcessPodcast extends Queueable { constructor( public podcast: Podcast, public owner: string, ) { super() } async handle(): Promise<void> { // ... await Cache.lock('processing', 120, this.owner).release() }}forceRelease() releases a lock whoever owns it:
await Cache.lock('processing').forceRelease()Rate limiting#
The RateLimiter counts how often something happens within a window of time, in the cache. The throttle route
middleware uses it, and you can use it for anything else your app needs to limit. It keeps its counts in the store
that limiter in config/ names, or in the default store.
hit() adds one to a key's count, and starts the key's window, 60 seconds or the number you give, when the count
starts. tooManyAttempts() tells you whether the count has reached a maximum within the window:
import { RateLimiter, request, Route } from '@orieljs/framework'import type { User } from '../app/Models/User'Route.post('/messages', async () => { const user = await request().user<User>() const key = `send-message:${user?.id}` if (await RateLimiter.tooManyAttempts(key, 5)) { const seconds = await RateLimiter.availableIn(key) return `You may try again in ${seconds} seconds.` } await RateLimiter.hit(key, 60) // Send the message... return 'Sent.'})Once the window ends, the count starts again from 0. The other methods:
attempts(key): the count so far;remaining(key, max): how many are left before the maximum;availableIn(key): the seconds until the window ends;clear(key): forgets the count and the window.
A key's accents don't count: zoë and zoe share a count.
Adding custom cache drivers#
Writing the driver#
A driver is a store, which keeps the values, wrapped in a repository, which gives it the cache's API. Write the store
as a class with the methods of the CacheStore type. This one keeps its values in a Map, as JSON; a store over
another service has the same methods:
import type { CacheStore } from '@orieljs/framework'interface Entry { json: string expiresAt: number}export class MapStore implements CacheStore { private readonly entries = new Map<string, Entry>() async get(key: string): Promise<unknown> { const entry = this.live(key) return entry === undefined ? undefined : JSON.parse(entry.json) } async many(keys: string[]): Promise<Record<string, unknown>> { const found: Record<string, unknown> = {} for (const key of keys) { const entry = this.live(key) if (entry !== undefined) found[key] = JSON.parse(entry.json) } return found } async put(key: string, value: unknown, seconds: number): Promise<boolean> { this.entries.set(key, { json: JSON.stringify(value) ?? 'null', expiresAt: Date.now() + seconds * 1000 }) return true } async putForever(key: string, value: unknown): Promise<boolean> { this.entries.set(key, { json: JSON.stringify(value) ?? 'null', expiresAt: Infinity }) return true } async add(key: string, value: unknown, seconds?: number): Promise<boolean> { if (this.live(key) !== undefined) return false const expiresAt = seconds === undefined ? Infinity : Date.now() + seconds * 1000 this.entries.set(key, { json: JSON.stringify(value) ?? 'null', expiresAt }) return true } async increment(key: string, by: number): Promise<number> { const entry = this.live(key) const next = (entry === undefined ? 0 : Number(JSON.parse(entry.json))) + by this.entries.set(key, { json: String(next), expiresAt: entry?.expiresAt ?? Infinity }) return next } async decrement(key: string, by: number): Promise<number> { return this.increment(key, -by) } async forget(key: string): Promise<boolean> { return this.entries.delete(key) } async flush(): Promise<boolean> { this.entries.clear() return true } /** The key's entry, unless it is missing or has expired. */ private live(key: string): Entry | undefined { const entry = this.entries.get(key) return entry !== undefined && entry.expiresAt > Date.now() ? entry : undefined }}get() returns undefined for a missing or expired key, and many() leaves those keys out. add() must be atomic,
since locks are built on it: this one checks and writes with no await in between. increment() starts a missing
key at 0 and keeps an existing key's expiry. The repository deals with a missing number of seconds, and with zero or
less, before your put() is called.
Registering the driver#
Cache.extend() registers the driver, with a function that receives the store's config and returns the repository.
Do it in a service provider's boot():
import { Cache, CacheRepositoryImpl, ServiceProvider } from '@orieljs/framework'import { MapStore } from '../Extensions/MapStore'export class AppServiceProvider extends ServiceProvider { override boot(): void { Cache.extend('map', () => new CacheRepositoryImpl(new MapStore())) }}Then add a store with that driver to config/, map: { driver: 'map' }, and name it in CACHE_STORE or in
Cache.store('map'). The function receives the store's entry of config/, with the cache's prefix when
the entry has none of its own: a store that keeps other apps' keys too should start its keys with it. Locks on the
new store are built on its add().