Skip to content
5 changes: 5 additions & 0 deletions .changeset/curly-monkeys-follow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'bentocache': minor
---

Add `getMany()` method to retrieve multiple cache keys at once.
85 changes: 64 additions & 21 deletions docs/content/docs/methods.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
summary: "Comprehensive list of all methods available when using BentoCache"
summary: 'Comprehensive list of all methods available when using BentoCache'
---

# Methods
Expand All @@ -11,28 +11,73 @@ Below is a list of all the methods available when using BentoCache.
Returns a new instance of the driver namespace. See [Namespaces](./namespaces.md) for more information.

```ts
const usersNamespace = bento.namespace('users');
const usersNamespace = bento.namespace('users')

usersNamespace.set('1', { name: 'John' });
usersNamespace.set('2', { name: 'Jane' });
usersNamespace.set('3', { name: 'Doe' });
usersNamespace.set('1', { name: 'John' })
usersNamespace.set('2', { name: 'Jane' })
usersNamespace.set('3', { name: 'Doe' })

usersNamespace.clear();
usersNamespace.clear()
```

## get
## get

`get` allows you to retrieve a value from the cache. It returns `undefined` if the key does not exist.

#### get<T>(options: GetPojoOptions<T>)
#### get<T>(options: GetOptions<T>)

Returns the value of the key, or `undefined` if the key does not exist.

You can also provide a `defaultValue` that will be returned if the key is missing. It can be a value of any type or a factory function.

```ts
const products = await bento.get({
key: 'products',
defaultValue: [],
});
})

/**
* You can also use a factory function (lazy evaluation).
* This is useful if the default value is expensive to compute,
* as the function will ONLY be executed if the key is missing.
*/
const products = await bento.get({
key: 'products',
defaultValue: () => fetchProduct(),
})
```

## getMany

`getMany` allows you to retrieve multiple values from the cache at once.

#### getMany<T>(options: GetManyOptions<T>)

Returns an array of values corresponding to the keys. If a key is missing, the value will be `undefined` (or the default value if provided).

The `defaultValue` option can be used to provide a fallback value for **each** missing key. It can be a value of any type or a factory function.

```ts
// basic usage
const products = await bento.getMany({
keys: ['product1', 'product2', 'product3'],
})

// with options (defaultValue)
const products = await bento.getMany({
keys: ['product1', 'product2', 'product3'],
defaultValue: 'Bento',
})

/**
* You can also use a factory function (lazy evaluation).
* This is useful if the default value is expensive to compute,
* as the function will ONLY be executed if the key is missing.
*/
const products = await bento.getMany({
keys: ['product1', 'product2', 'product3'],
defaultValue: () => fetchProducts(),
})
```

## set
Expand Down Expand Up @@ -69,7 +114,7 @@ It will try to get the value in the cache. If it exists, it will return it. If i
// basic usage
const products = await bento.getOrSet({
key: 'products',
factory: () => fetchProducts()
factory: () => fetchProducts(),
})

// with options
Expand Down Expand Up @@ -98,7 +143,7 @@ cache.getOrSet({
}

return item
}
},
})
```

Expand All @@ -120,15 +165,14 @@ cache.getOrSet({
}

return item
}
},
})
```

### ctx.setOptions

`setOptions` allows you to update the options of the cache entry. This is useful when you want to update the TTL, grace period, or tags and when it depends on the value itself.


```ts
const products = await bento.getOrSet({
key: 'token',
Expand All @@ -141,11 +185,10 @@ const products = await bento.getOrSet({
})

return token
}
},
})
```


Auth tokens are a perfect example of this use case. The cached token should expire when the token itself expires. And we know the expiration time only after fetching the token. See [Adaptive Caching docs](./adaptive_caching.md) for more information.

### ctx.gracedEntry
Expand All @@ -161,7 +204,7 @@ const products = await bento.getOrSet({
}

return 'bar'
}
},
})
```

Expand Down Expand Up @@ -218,10 +261,10 @@ When we delete a key, it is completely removed and forgotten. This means that ev

```ts
// Set a value with a grace period of 6 minutes
await cache.set({
await cache.set({
key: 'hello',
value: 'world',
grace: '6m'
grace: '6m',
})

// Expire the value. It is kept in the cache but marked as STALE for 6 minutes
Expand All @@ -247,21 +290,21 @@ await bento.deleteMany({ keys: ['products', 'users'] })
Clear the cache. This will delete all the keys in the cache if called from the "root" instance. If called from a namespace, it will only delete the keys in that namespace.

```ts
await bento.clear();
await bento.clear()
```

## prune

Prunes the cache by removing expired entries. This is useful for drivers that do not have native TTL support, such as File and Database drivers. On drivers with native TTL support, this is typically a noop.

```ts
await bento.prune();
await bento.prune()
```

## disconnect

Disconnect from the cache. This will close the connection to the cache server, if applicable.

```ts
await bento.disconnect();
await bento.disconnect()
```
8 changes: 8 additions & 0 deletions packages/bentocache/src/bento_cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
DeleteManyOptions,
ExpireOptions,
DeleteByTagOptions,
GetManyOptions,
} from './types/main.js'

export class BentoCache<KnownCaches extends Record<string, BentoStore>> implements CacheProvider {
Expand Down Expand Up @@ -148,6 +149,13 @@ export class BentoCache<KnownCaches extends Record<string, BentoStore>> implemen
return this.use().get<T>(options)
}

/**
* Get multiple values from the cache
*/
async getMany<T = any>(options: GetManyOptions<T>): Promise<(T | undefined | null)[]> {
return this.use().getMany<T>(options)
}

/**
* Put a value in the cache
* Returns true if the value was set, false otherwise
Expand Down
93 changes: 93 additions & 0 deletions packages/bentocache/src/cache/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
GetOrSetForeverOptions,
ExpireOptions,
DeleteByTagOptions,
GetManyOptions,
} from '../types/main.js'

export class Cache implements CacheProvider {
Expand Down Expand Up @@ -94,6 +95,98 @@ export class Cache implements CacheProvider {
return this.#resolveDefaultValue(defaultValueFn)
}

/**
* Batch get many values from the cache, minimizing roundtrips to L2 if possible.
*
* This method will:
* - Try to get all keys from L1.
* - Identify missing keys.
* - Fetch missing keys from L2 in a single batch.
* - Backfill L1 with valid L2 results (fire-and-forget).
* - Fallback to grace periods or default values if needed.
*
* Returns an array of values (or default/undefined for missing keys) in the same order as the input keys.
*/
async getMany<T = any>(rawOptions: GetManyOptions<T>): Promise<(T | undefined | null)[]> {
const keys = rawOptions.keys
const options = this.#stack.defaultOptions.cloneWith(rawOptions)
this.#options.logger.logMethod({ method: 'getMany', key: keys, cacheName: this.name, options })

const l1Results = this.#stack.l1
? await this.#stack.l1.getMany(keys, options)
: (Array.from({ length: keys.length }) as undefined[])

const resultVector = Array.from({ length: keys.length })
const missingIndices: number[] = []
const missingKeys: string[] = []

for (const [i, key] of keys.entries()) {
const item = l1Results[i]
const isValid = await this.#stack.isEntryValid(item)

if (isValid && item) {
resultVector[i] = item.entry.getValue()
this.#stack.emit(cacheEvents.hit(key, resultVector[i], this.name))
this.#options.logger.logL1Hit({ cacheName: this.name, key, options })
} else {
missingIndices.push(i)
missingKeys.push(key)
}
}

if (missingKeys.length === 0) return resultVector as (T | undefined | null)[]

const l2Results = this.#stack.l2
? await this.#stack.l2.getMany(missingKeys, options)
: (Array.from({ length: missingKeys.length }) as undefined[])

for (const [i, key] of missingKeys.entries()) {
const originalIdx = missingIndices[i]
const l2Item = l2Results[i] as any
const l1Item = l1Results[originalIdx] as any

const isL2Valid = await this.#stack.isEntryValid(l2Item)

if (isL2Valid) {
const value = l2Item!.entry.getValue()
resultVector[originalIdx] = value

this.#stack.l1?.set(key, l2Item!.entry.serialize(), options)

this.#stack.emit(cacheEvents.hit(key, value, this.name))
this.#options.logger.logL2Hit({ cacheName: this.name, key, options })
continue
}

if (options.isGraceEnabled()) {
if (l2Item?.isGraced) {
const value = l2Item.entry.getValue()
resultVector[originalIdx] = value

this.#stack.l1?.set(key, l2Item.entry.serialize(), options)

this.#stack.emit(cacheEvents.hit(key, value, this.name, 'l2', true))
this.#options.logger.logL2Hit({ cacheName: this.name, key, options, graced: true })
continue
}

if (l1Item?.isGraced) {
const value = l1Item.entry.getValue()
resultVector[originalIdx] = value

this.#stack.emit(cacheEvents.hit(key, value, this.name, 'l1', true))
this.#options.logger.logL1Hit({ cacheName: this.name, key, options, graced: true })
continue
}
}

resultVector[originalIdx] = this.#resolveDefaultValue(rawOptions.defaultValue)
this.#stack.emit(cacheEvents.miss(key, this.name))
}

return resultVector as (T | undefined | null)[]
}

/**
* Set a value in the cache
* Returns true if the value was set, false otherwise
Expand Down
8 changes: 8 additions & 0 deletions packages/bentocache/src/cache/facades/local_cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ export class LocalCache {
return { entry, isGraced }
}

/**
* Batch get many items from the local cache
*/
getMany(keys: string[], options: CacheEntryOptions) {
this.#logger.debug({ keys, opId: options.id }, 'batch getting items from l1 cache')
return keys.map((key) => this.get(key, options))
}

/**
* Set a new item in the local cache
*/
Expand Down
33 changes: 33 additions & 0 deletions packages/bentocache/src/cache/facades/remote_cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,39 @@ export class RemoteCache {
})
}

/**
* Batch get many items from the remote cache
*/
async getMany(keys: string[], options: CacheEntryOptions) {
return await this.#tryCacheOperation('getMany', options, [], async () => {
this.#logger.debug({ keys, opId: options.id }, 'batch getting items from l2 cache')
if (typeof this.#driver.getMany === 'function') {
const values = await this.#driver.getMany(keys)
return values.map((value, i) => {
if (value === undefined) return undefined
const entry = CacheEntry.fromDriver(keys[i], value, this.#options.serializer)
return {
entry,
isGraced: entry.isLogicallyExpired(),
}
})
}

const results = await Promise.all(
keys.map(async (key) => {
const value = await this.#driver.get(key)
if (value === undefined) return undefined
const entry = CacheEntry.fromDriver(key, value, this.#options.serializer)
return {
entry,
isGraced: entry.isLogicallyExpired(),
}
}),
)
return results
})
}

/**
* Set a new item in the remote cache
*/
Expand Down
17 changes: 17 additions & 0 deletions packages/bentocache/src/drivers/database/adapters/knex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,23 @@ export class KnexAdapter implements DatabaseAdapter {
return { value: result.value, expiresAt: result.expires_at }
}

async getMany(
keys: string[],
): Promise<{ key: string; value: string; expiresAt: number | null }[]> {
if (keys.length === 0) return []

const results = await this.#connection
.from(this.#tableName)
.select(['key', 'value', 'expires_at as expiresAt'])
.whereIn('key', keys)

return results.map((result) => ({
key: result.key,
value: result.value,
expiresAt: result.expiresAt,
}))
}

async delete(key: string): Promise<boolean> {
const result = await this.#connection.from(this.#tableName).where('key', key).delete()
return result > 0
Expand Down
Loading