diff --git a/.changeset/curly-monkeys-follow.md b/.changeset/curly-monkeys-follow.md new file mode 100644 index 0000000..aabbda2 --- /dev/null +++ b/.changeset/curly-monkeys-follow.md @@ -0,0 +1,5 @@ +--- +'bentocache': minor +--- + +Add `getMany()` method to retrieve multiple cache keys at once. diff --git a/docs/content/docs/methods.md b/docs/content/docs/methods.md index bcf678a..da96f22 100644 --- a/docs/content/docs/methods.md +++ b/docs/content/docs/methods.md @@ -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 @@ -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(options: GetPojoOptions) +#### get(options: GetOptions) 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(options: GetManyOptions) + +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 @@ -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 @@ -98,7 +143,7 @@ cache.getOrSet({ } return item - } + }, }) ``` @@ -120,7 +165,7 @@ cache.getOrSet({ } return item - } + }, }) ``` @@ -128,7 +173,6 @@ cache.getOrSet({ `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', @@ -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 @@ -161,7 +204,7 @@ const products = await bento.getOrSet({ } return 'bar' - } + }, }) ``` @@ -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 @@ -247,7 +290,7 @@ 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 @@ -255,7 +298,7 @@ await bento.clear(); 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 @@ -263,5 +306,5 @@ await bento.prune(); Disconnect from the cache. This will close the connection to the cache server, if applicable. ```ts -await bento.disconnect(); +await bento.disconnect() ``` diff --git a/packages/bentocache/src/bento_cache.ts b/packages/bentocache/src/bento_cache.ts index 63f88e4..95f087f 100644 --- a/packages/bentocache/src/bento_cache.ts +++ b/packages/bentocache/src/bento_cache.ts @@ -17,6 +17,7 @@ import type { DeleteManyOptions, ExpireOptions, DeleteByTagOptions, + GetManyOptions, } from './types/main.js' export class BentoCache> implements CacheProvider { @@ -148,6 +149,13 @@ export class BentoCache> implemen return this.use().get(options) } + /** + * Get multiple values from the cache + */ + async getMany(options: GetManyOptions): Promise<(T | undefined | null)[]> { + return this.use().getMany(options) + } + /** * Put a value in the cache * Returns true if the value was set, false otherwise diff --git a/packages/bentocache/src/cache/cache.ts b/packages/bentocache/src/cache/cache.ts index 8c01274..f7bea82 100644 --- a/packages/bentocache/src/cache/cache.ts +++ b/packages/bentocache/src/cache/cache.ts @@ -18,6 +18,7 @@ import type { GetOrSetForeverOptions, ExpireOptions, DeleteByTagOptions, + GetManyOptions, } from '../types/main.js' export class Cache implements CacheProvider { @@ -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(rawOptions: GetManyOptions): 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 diff --git a/packages/bentocache/src/cache/facades/local_cache.ts b/packages/bentocache/src/cache/facades/local_cache.ts index 40e7dfb..1dd24de 100644 --- a/packages/bentocache/src/cache/facades/local_cache.ts +++ b/packages/bentocache/src/cache/facades/local_cache.ts @@ -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 */ diff --git a/packages/bentocache/src/cache/facades/remote_cache.ts b/packages/bentocache/src/cache/facades/remote_cache.ts index f70d3e7..476770e 100644 --- a/packages/bentocache/src/cache/facades/remote_cache.ts +++ b/packages/bentocache/src/cache/facades/remote_cache.ts @@ -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 */ diff --git a/packages/bentocache/src/drivers/database/adapters/knex.ts b/packages/bentocache/src/drivers/database/adapters/knex.ts index 4ae84c2..fecdf06 100644 --- a/packages/bentocache/src/drivers/database/adapters/knex.ts +++ b/packages/bentocache/src/drivers/database/adapters/knex.ts @@ -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 { const result = await this.#connection.from(this.#tableName).where('key', key).delete() return result > 0 diff --git a/packages/bentocache/src/drivers/database/adapters/kysely.ts b/packages/bentocache/src/drivers/database/adapters/kysely.ts index 04c3de4..7fea9f8 100644 --- a/packages/bentocache/src/drivers/database/adapters/kysely.ts +++ b/packages/bentocache/src/drivers/database/adapters/kysely.ts @@ -54,6 +54,20 @@ export class KyselyAdapter implements DatabaseAdapter { return { value: result.value, expiresAt: result.expires_at } } + async getMany(keys: string[]): Promise<{ key: string; value: any; expiresAt: number | null }[]> { + const results = await this.#connection + .selectFrom(this.#tableName) + .select(['key', 'value', 'expires_at as expiresAt']) + .where('key', 'in', keys) + .execute() + + return results.map((result) => ({ + key: result.key, + value: result.value, + expiresAt: result.expiresAt, + })) + } + async delete(key: string): Promise { const result = await this.#connection .deleteFrom(this.#tableName) diff --git a/packages/bentocache/src/drivers/database/adapters/orchid.ts b/packages/bentocache/src/drivers/database/adapters/orchid.ts index 320457c..7a562c0 100644 --- a/packages/bentocache/src/drivers/database/adapters/orchid.ts +++ b/packages/bentocache/src/drivers/database/adapters/orchid.ts @@ -51,6 +51,16 @@ export class OrchidAdapter implements DatabaseAdapter { return { value: result.value, expiresAt: result.expires_at } } + async getMany(keys: string[]): Promise<{ key: string; value: any; expiresAt: number | null }[]> { + const results = await this.getTable().whereIn('key', keys).select('key', 'value', 'expires_at') + + return results.map((result) => ({ + key: result.key, + value: result.value, + expiresAt: result.expires_at, + })) + } + async delete(key: string): Promise { const count = await this.getTable().where({ key }).delete() return count > 0 diff --git a/packages/bentocache/src/drivers/database/database.ts b/packages/bentocache/src/drivers/database/database.ts index ffd52b1..5244d6f 100644 --- a/packages/bentocache/src/drivers/database/database.ts +++ b/packages/bentocache/src/drivers/database/database.ts @@ -1,8 +1,13 @@ import { asyncNoop, once } from '@julr/utils/functions' +import type { Logger } from '../../logger.js' import { resolveTtl } from '../../helpers.js' import { BaseDriver } from '../base_driver.js' import type { DatabaseConfig, CacheDriver, DatabaseAdapter } from '../../types/main.js' +import type { + DriverCommonOptions, + DriverCommonInternalOptions, +} from '../../types/options/drivers_options.js' /** * A store that use a database to store cache entries @@ -10,6 +15,7 @@ import type { DatabaseConfig, CacheDriver, DatabaseAdapter } from '../../types/m * You should provide an adapter that will handle the database interactions */ export class DatabaseDriver extends BaseDriver implements CacheDriver { + declare protected config: DriverCommonOptions & DriverCommonInternalOptions type = 'l2' as const /** @@ -22,15 +28,26 @@ export class DatabaseDriver extends BaseDriver implements CacheDriver { */ #initializer: () => Promise + /** + * Logger + */ + protected logger?: Logger + /** * Pruning interval */ #pruneInterval?: NodeJS.Timeout - constructor(adapter: DatabaseAdapter, config: DatabaseConfig, isNamespace = false) { + constructor( + adapter: DatabaseAdapter, + config: DatabaseConfig & DriverCommonInternalOptions, + isNamespace = false, + ) { super(config) this.#adapter = adapter + this.logger = config.logger + if (isNamespace) { this.#initializer = asyncNoop return @@ -55,9 +72,9 @@ export class DatabaseDriver extends BaseDriver implements CacheDriver { #startPruneInterval(interval: number) { this.#pruneInterval = setInterval(async () => { await this.#initializer() - await this.#adapter - .pruneExpiredEntries() - .catch((err) => console.error('[bentocache] failed to prune expired entries', err)) + await this.#adapter.pruneExpiredEntries().catch((error) => { + this.logger?.error('Failed to prune expired entries', { error }) + }) }, interval) } @@ -98,6 +115,64 @@ export class DatabaseDriver extends BaseDriver implements CacheDriver { return result.value } + /** + * Get multiple values from the cache + */ + async getMany(keys: string[]) { + if (keys.length === 0) return [] + await this.#initializer() + + const prefixedKeys = keys.map((key) => this.getItemKey(key)) + /** + * Deduplicate keys to avoid unnecessary DB calls. + */ + const uniquePrefixedKeys = [...new Set(prefixedKeys)] + let results: Array<{ key: string; value: any; expiresAt: number | null } | undefined> = [] + + if (typeof this.#adapter.getMany === 'function') { + results = (await this.#adapter.getMany(uniquePrefixedKeys)) ?? [] + } else { + /** + * If the adapter doesn't implement getMany, we'll batch the requests + * to avoid flooding the database with too many concurrent queries. + */ + const batchSize = 10 + + for (let i = 0; i < uniquePrefixedKeys.length; i += batchSize) { + const batchKeys = uniquePrefixedKeys.slice(i, i + batchSize) + const batchResults = await Promise.all( + batchKeys.map(async (k) => { + const r = await this.#adapter.get(k) + return r ? { key: k, value: r.value, expiresAt: r.expiresAt } : undefined + }), + ) + + results.push(...batchResults) + } + } + + const resultsMap = new Map() + for (const r of results) { + if (r !== undefined) { + resultsMap.set(r.key, r) + } + } + + return prefixedKeys.map((prefixedKey) => { + const result = resultsMap.get(prefixedKey) + if (!result) return undefined + + if (this.#isExpired(result.expiresAt)) { + this.#adapter.delete(prefixedKey).catch((error) => { + this.config.logger?.error({ error, key: prefixedKey }, 'Failed to delete expired key') + }) + return undefined + } + + return result.value + }) + } + /** * Get the value of a key and delete it * diff --git a/packages/bentocache/src/drivers/dynamodb.ts b/packages/bentocache/src/drivers/dynamodb.ts index da2b35f..ffb9523 100644 --- a/packages/bentocache/src/drivers/dynamodb.ts +++ b/packages/bentocache/src/drivers/dynamodb.ts @@ -4,14 +4,21 @@ import { GetItemCommand, PutItemCommand, DeleteItemCommand, + BatchGetItemCommand, BatchWriteItemCommand, ScanCommand, type AttributeValue, ConditionalCheckFailedException, } from '@aws-sdk/client-dynamodb' +import type { Logger } from '../logger.js' import { BaseDriver } from './base_driver.js' -import type { CacheDriver, CreateDriverResult, DynamoDBConfig } from '../types/main.js' +import type { + CacheDriver, + CreateDriverResult, + DynamoDBConfig, + DriverCommonInternalOptions, +} from '../types/main.js' /** * Create a new DynamoDB driver @@ -19,7 +26,7 @@ import type { CacheDriver, CreateDriverResult, DynamoDBConfig } from '../types/m export function dynamoDbDriver(options: DynamoDBConfig): CreateDriverResult { return { options, - factory: (config: DynamoDBConfig) => new DynamoDbDriver(config), + factory: (config: DynamoDBConfig & DriverCommonInternalOptions) => new DynamoDbDriver(config), } } @@ -39,15 +46,22 @@ export class DynamoDbDriver extends BaseDriver implements CacheDriver { */ declare config: DynamoDBConfig + /** + * Logger + */ + protected logger?: Logger + /** * Name of the table to use * Defaults to `cache` */ #tableName: string - constructor(config: DynamoDBConfig & { client?: DynamoDBClient }) { + constructor(config: DynamoDBConfig & DriverCommonInternalOptions & { client?: DynamoDBClient }) { super(config) + this.logger = config.logger + this.#tableName = this.config.table.name ?? 'cache' if (config.client) { @@ -169,6 +183,151 @@ export class DynamoDbDriver extends BaseDriver implements CacheDriver { return data.Item.value.S ?? data.Item.value.N } + /** + * Get multiple values in order. Expired items return undefined. + * + * DynamoDB has a limit of 100 items per BatchGetItem request, so we chunk + * the keys and make multiple parallel requests. Implements retry logic for + * UnprocessedKeys with exponential backoff. + * + * Returns the values in the same order as the keys were requested. + */ + async getMany(keys: string[]) { + if (keys.length === 0) return [] + + const prefixedKeys = keys.map((key) => this.getItemKey(key)) + /** + * DynamoDB will throw a ValidationException if we request the same key twice + * in the same batch, so we need to deduplicate them. + */ + const uniqueKeys = [...new Set(prefixedKeys)] + const chunks = Array.from(chunkify(uniqueKeys, 100)) + const keyToValueMap: Record = {} + + try { + /** + * We'll fetch all chunks in parallel to speed up the process. + */ + const chunkPromises = chunks.map((chunk: string[]) => this.#getBatchWithRetry(chunk)) + const allItemsArrays = await Promise.all(chunkPromises) + const allItems = allItemsArrays.flat() + + const deletePromises: Promise[] = [] + + for (const item of allItems) { + if (!item.key?.S) { + if (this.logger) { + this.logger.warn('DynamoDB item missing key attribute', { item }) + } + continue + } + + if (!this.#isItemExpired(item)) { + /** + * DynamoDB stores values differently depending on their type, so we + * need to extract them accordingly. + */ + let value: string | undefined + if (item.value?.S) { + value = item.value.S + } else if (item.value?.N) { + value = item.value.N + } + + if (value !== undefined) { + keyToValueMap[item.key.S] = value + } + } else { + /** + * If we encounter an expired item, we'll delete it in the background + * and log any errors that occur. + * + * We need to strip the prefix because the delete method will append it again. + */ + const logicalKey = this.prefix ? item.key.S.slice(this.prefix.length + 1) : item.key.S + + deletePromises.push( + this.delete(logicalKey).catch((err) => { + if (this.logger) { + this.logger.warn('Failed to delete expired key', { + key: item.key.S, + error: err.message, + }) + } + }), + ) + } + } + } catch (error) { + if (this.logger) { + this.logger.error('Failed to fetch items from DynamoDB', { + keyCount: keys.length, + error, + }) + } + throw error + } + + return prefixedKeys.map((key) => keyToValueMap[key]) + } + + /** + * Fetch a batch of keys with retry logic for UnprocessedKeys + */ + async #getBatchWithRetry(keys: string[], maxRetries = 3): Promise[]> { + let unprocessedKeys = keys + const allItems: Record[] = [] + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + if (unprocessedKeys.length === 0) break + + const requestItems = { + [this.#tableName]: { + Keys: unprocessedKeys.map((key) => ({ key: { S: key } })), + }, + } + + const command = new BatchGetItemCommand({ RequestItems: requestItems }) + const data = await this.#client.send(command) + + const items = data.Responses?.[this.#tableName] ?? [] + allItems.push(...items) + + const unprocessed = data.UnprocessedKeys?.[this.#tableName]?.Keys + if (!unprocessed || unprocessed.length === 0) { + break + } + + unprocessedKeys = unprocessed.map((k) => k.key.S).filter((k): k is string => !!k) + + if (unprocessedKeys.length > 0 && attempt < maxRetries) { + /** + * If we have unprocessed keys, we wait for a bit before retrying. + * We use exponential backoff to avoid hammering DynamoDB. + */ + const backoffMs = Math.pow(2, attempt) * 100 + await new Promise((resolve) => setTimeout(resolve, backoffMs)) + + if (this.logger) { + this.logger.debug('Retrying unprocessed keys', { + attempt: attempt + 1, + unprocessedCount: unprocessedKeys.length, + backoffMs, + }) + } + } + } + + if (unprocessedKeys.length > 0 && this.logger) { + this.logger.warn('Failed to process all keys after retries', { + unprocessedCount: unprocessedKeys.length, + maxRetries, + }) + } + + return allItems + } + /** * Get the value of a key and delete it * diff --git a/packages/bentocache/src/drivers/file/file.ts b/packages/bentocache/src/drivers/file/file.ts index dbc4ace..cf66472 100644 --- a/packages/bentocache/src/drivers/file/file.ts +++ b/packages/bentocache/src/drivers/file/file.ts @@ -162,6 +162,19 @@ export class FileDriver extends BaseDriver implements CacheDriver { return value as string } + /** + * Get multiple values from the cache + */ + async getMany(keys: string[]) { + if (keys.length === 0) return [] + const results: (string | undefined)[] = [] + for (const key of keys) { + const value = await this.get(key) + results.push(value) + } + return results + } + /** * Get the value of a key and delete it * diff --git a/packages/bentocache/src/drivers/memory.ts b/packages/bentocache/src/drivers/memory.ts index f8c3c67..6859f0e 100644 --- a/packages/bentocache/src/drivers/memory.ts +++ b/packages/bentocache/src/drivers/memory.ts @@ -72,6 +72,14 @@ export class MemoryDriver extends BaseDriver implements L1CacheDriver { return this.#cache.get(this.getItemKey(key)) } + /** + * Get multiple values from the cache + */ + getMany(keys: string[]) { + if (keys.length === 0) return [] + return keys.map((key) => this.get(key)) + } + /** * Get the value of a key and delete it * diff --git a/packages/bentocache/src/drivers/redis.ts b/packages/bentocache/src/drivers/redis.ts index 997a736..79b2b68 100644 --- a/packages/bentocache/src/drivers/redis.ts +++ b/packages/bentocache/src/drivers/redis.ts @@ -91,6 +91,16 @@ export class RedisDriver extends BaseDriver implements L2CacheDriver { return result ?? undefined } + /** + * Get multiple values from the cache + */ + async getMany(keys: string[]) { + if (keys.length === 0) return [] + const prefixedKeys = keys.map((key) => this.getItemKey(key)) + const values = await this.#connection.mget(prefixedKeys) + return values.map((value) => value ?? undefined) + } + /** * Get the value of a key and delete it * diff --git a/packages/bentocache/src/types/driver.ts b/packages/bentocache/src/types/driver.ts index f088f67..7356706 100644 --- a/packages/bentocache/src/types/driver.ts +++ b/packages/bentocache/src/types/driver.ts @@ -11,6 +11,12 @@ export interface CacheDriver { */ get(key: string): PromiseOr + /** + * Get multiple values from the cache + * Returns an array of values (or undefined for missing keys) in the same order as the input keys + */ + getMany(keys: string[]): PromiseOr<(string | undefined)[], Async> + /** * Get the value of a key and delete it * @@ -70,6 +76,11 @@ export interface DatabaseAdapter { */ get(key: string): Promise<{ value: any; expiresAt: number | null } | undefined> + /** + * Get multiple entries from the database + */ + getMany(keys: string[]): Promise<{ key: string; value: any; expiresAt: number | null }[]> + /** * Delete an entry from the database * diff --git a/packages/bentocache/src/types/options/methods_options.ts b/packages/bentocache/src/types/options/methods_options.ts index 6e0d45e..ebaff7a 100644 --- a/packages/bentocache/src/types/options/methods_options.ts +++ b/packages/bentocache/src/types/options/methods_options.ts @@ -48,6 +48,14 @@ export type GetOptions = { key: string; defaultValue?: Factory } & Pick< 'grace' | 'graceBackoff' | 'suppressL2Errors' > +/** + * Options accepted by the `getMany` method + */ +export type GetManyOptions = { keys: string[]; defaultValue?: Factory } & Pick< + RawCommonOptions, + 'grace' | 'graceBackoff' | 'suppressL2Errors' +> + /** * Options accepted by the `delete` method */ diff --git a/packages/bentocache/src/types/provider.ts b/packages/bentocache/src/types/provider.ts index 3143c64..491502d 100644 --- a/packages/bentocache/src/types/provider.ts +++ b/packages/bentocache/src/types/provider.ts @@ -8,6 +8,7 @@ import type { HasOptions, SetOptions, DeleteByTagOptions, + GetManyOptions, } from './main.js' /** @@ -100,6 +101,11 @@ export interface CacheProvider { */ namespace(namespace: string): CacheProvider + /** + * Get multiple values from the cache + */ + getMany(options: GetManyOptions): Promise<(T | undefined | null)[]> + /** * Closes the connection to the cache */ diff --git a/packages/bentocache/tests/cache/one_tier_local.spec.ts b/packages/bentocache/tests/cache/one_tier_local.spec.ts index 66d10c8..26d0d84 100644 --- a/packages/bentocache/tests/cache/one_tier_local.spec.ts +++ b/packages/bentocache/tests/cache/one_tier_local.spec.ts @@ -38,6 +38,16 @@ test.group('One tier tests', () => { assert.equal(r2, 'default') }) + test('get() should assume array as a value and not a factory', async ({ assert }) => { + const { cache } = new CacheFactory().withMemoryL1().create() + + const r1 = await cache.get({ key: 'key1', defaultValue: ['a', 'b'] }) + const r2 = await cache.get({ key: 'key2', defaultValue: () => ['a', 'b'] }) + + assert.deepEqual(r1, ['a', 'b']) + assert.deepEqual(r2, ['a', 'b']) + }) + test('get() with fallback but item found should return item', async ({ assert }) => { const { cache } = new CacheFactory().withMemoryL1().create() @@ -186,6 +196,285 @@ test.group('One tier tests', () => { assert.isFalse(await cache.has({ key: 'key2' })) }) + test('getMany() should return values for multiple keys in order', async ({ assert }) => { + const { cache } = new CacheFactory().withMemoryL1().create() + + await cache.set({ key: 'key1', value: 'value1' }) + await cache.set({ key: 'key2', value: 'value2' }) + + const results = await cache.getMany({ keys: ['key1', 'key2', 'key3'] }) + assert.deepEqual(results, ['value1', 'value2', undefined]) + + const resultsWithDefault = await cache.getMany({ keys: ['key1', 'key3'], defaultValue: 'def' }) + assert.deepEqual(resultsWithDefault, ['value1', 'def']) + }) + + test('getMany() should return empty array for empty keys', async ({ assert }) => { + const { cache } = new CacheFactory().withMemoryL1().create() + const results = await cache.getMany({ keys: [] }) + assert.deepEqual(results, []) + }) + + test('getMany() should return all undefined for missing keys', async ({ assert }) => { + const { cache } = new CacheFactory().withMemoryL1().create() + const results = await cache.getMany({ keys: ['key1', 'key2'] }) + assert.deepEqual(results, [undefined, undefined]) + }) + + test('getMany() should return defaults for all missing keys when defaultValue provided', async ({ + assert, + }) => { + const { cache } = new CacheFactory().withMemoryL1().create() + const results = await cache.getMany({ keys: ['key1', 'key2'], defaultValue: 'default' }) + assert.deepEqual(results, ['default', 'default']) + }) + + test('getMany() should treat array defaultValue as a single value for each key', async ({ + assert, + }) => { + const { cache } = new CacheFactory().withMemoryL1().create() + + // 1. Array as value + const r1 = await cache.getMany({ + keys: ['key1', 'key2'], + defaultValue: ['a', 'b'], + }) + + // 2. Factory returning array + const r2 = await cache.getMany({ + keys: ['key1', 'key2'], + defaultValue: () => ['a', 'b'], + }) + + assert.deepEqual(r1, [ + ['a', 'b'], + ['a', 'b'], + ]) + assert.deepEqual(r2, [ + ['a', 'b'], + ['a', 'b'], + ]) + }) + + test('getMany() should preserve order regardless of storage order', async ({ assert }) => { + const { cache } = new CacheFactory().withMemoryL1().create() + + await cache.set({ key: 'keyC', value: 'valueC' }) + await cache.set({ key: 'keyA', value: 'valueA' }) + await cache.set({ key: 'keyB', value: 'valueB' }) + + const results = await cache.getMany({ keys: ['keyA', 'keyB', 'keyC'] }) + assert.deepEqual(results, ['valueA', 'valueB', 'valueC']) + + const resultsReverse = await cache.getMany({ keys: ['keyC', 'keyB', 'keyA'] }) + assert.deepEqual(resultsReverse, ['valueC', 'valueB', 'valueA']) + + const resultsMixed = await cache.getMany({ keys: ['keyB', 'keyA', 'keyC', 'keyA'] }) + assert.deepEqual(resultsMixed, ['valueB', 'valueA', 'valueC', 'valueA']) + }) + + test('getMany() should handle duplicate keys in request', async ({ assert }) => { + const { cache } = new CacheFactory().withMemoryL1().create() + + await cache.set({ key: 'key1', value: 'value1' }) + await cache.set({ key: 'key2', value: 'value2' }) + + const results = await cache.getMany({ keys: ['key1', 'key2', 'key1', 'key2', 'key1'] }) + assert.deepEqual(results, ['value1', 'value2', 'value1', 'value2', 'value1']) + }) + + test('getMany() should handle different data types correctly', async ({ assert }) => { + const { cache } = new CacheFactory().withMemoryL1().create() + + await cache.set({ key: 'string', value: 'text' }) + await cache.set({ key: 'number', value: 42 }) + await cache.set({ key: 'boolean', value: true }) + await cache.set({ key: 'array', value: [1, 2, 3] }) + await cache.set({ key: 'object', value: { foo: 'bar' } }) + await cache.set({ key: 'null', value: null }) + + const results = await cache.getMany({ + keys: ['string', 'number', 'boolean', 'array', 'object', 'null', 'missing'], + }) + + assert.equal(results[0], 'text') + assert.equal(results[1], 42) + assert.equal(results[2], true) + assert.deepEqual(results[3], [1, 2, 3]) + assert.deepEqual(results[4], { foo: 'bar' }) + assert.isNull(results[5]) + assert.isUndefined(results[6]) + }) + + test('getMany() should call factory function for each missing key when used as defaultValue', async ({ + assert, + }) => { + const { cache } = new CacheFactory().withMemoryL1().create() + + await cache.set({ key: 'key1', value: 'value1' }) + + let callCount = 0 + const factory = () => { + callCount++ + return `default-${callCount}` + } + + const results = await cache.getMany({ + keys: ['key1', 'key2', 'key3'], + defaultValue: factory, + }) + + assert.deepEqual(results, ['value1', 'default-1', 'default-2']) + assert.equal(callCount, 2) + }) + + test('getMany() should respect TTL and return undefined for expired items', async ({ + assert, + }) => { + const { cache } = new CacheFactory().withMemoryL1().create() + + await cache.set({ key: 'key1', value: 'value1' }) + await cache.set({ key: 'key2', value: 'value2', ttl: '50ms' }) + await cache.set({ key: 'key3', value: 'value3' }) + + const results1 = await cache.getMany({ keys: ['key1', 'key2', 'key3'] }) + assert.deepEqual(results1, ['value1', 'value2', 'value3']) + + await sleep(100) + + const results2 = await cache.getMany({ + keys: ['key1', 'key2', 'key3'], + defaultValue: 'expired', + }) + assert.deepEqual(results2, ['value1', 'expired', 'value3']) + }) + + test('getMany() should handle grace period correctly with individual keys', async ({ + assert, + }) => { + const { cache } = new CacheFactory().withMemoryL1().merge({ grace: '500ms' }).create() + + await cache.set({ key: 'key1', value: 'value1', ttl: '50ms' }) + + const results1 = await cache.getMany({ keys: ['key1'] }) + assert.deepEqual(results1, ['value1']) + + await sleep(100) + + const results2 = await cache.getMany({ keys: ['key1'] }) + assert.deepEqual(results2, ['value1']) + + await sleep(500) + + const results3 = await cache.getMany({ keys: ['key1'], defaultValue: 'default' }) + assert.deepEqual(results3, ['default']) + }) + + test('getMany() should return undefined after expiration when grace is disabled', async ({ + assert, + }) => { + const { cache } = new CacheFactory().withMemoryL1().merge({ grace: false }).create() + + await cache.set({ key: 'key1', value: 'value1' }) + await cache.set({ key: 'key2', value: 'value2', ttl: '50ms' }) + + await sleep(100) + + const results = await cache.getMany({ + keys: ['key1', 'key2'], + defaultValue: 'default', + }) + + assert.deepEqual(results, ['value1', 'default']) + }) + + test('getMany() should isolate results between namespaces', async ({ assert }) => { + const { cache } = new CacheFactory().withMemoryL1().create() + + await cache.set({ key: 'key1', value: 'root-value1' }) + await cache.namespace('users').set({ key: 'key1', value: 'users-value1' }) + await cache.namespace('users').set({ key: 'key2', value: 'users-value2' }) + + const rootResults = await cache.getMany({ keys: ['key1', 'key2'] }) + assert.deepEqual(rootResults, ['root-value1', undefined]) + + const usersResults = await cache.namespace('users').getMany({ keys: ['key1', 'key2'] }) + assert.deepEqual(usersResults, ['users-value1', 'users-value2']) + }) + + test('getMany() should handle large number of keys efficiently', async ({ assert }) => { + const { cache } = new CacheFactory().withMemoryL1().create() + + const numKeys = 100 + const keys = Array.from({ length: numKeys }, (_, i) => `key${i}`) + + for (let i = 0; i < numKeys / 2; i++) { + await cache.set({ key: `key${i}`, value: `value${i}` }) + } + + const results = await cache.getMany({ keys }) + + for (let i = 0; i < numKeys / 2; i++) { + assert.equal(results[i], `value${i}`) + } + for (let i = numKeys / 2; i < numKeys; i++) { + assert.isUndefined(results[i]) + } + + assert.lengthOf(results, numKeys) + }) + + test('getMany() should work correctly with concurrent set operations', async ({ assert }) => { + const { cache } = new CacheFactory().withMemoryL1().create() + + await cache.set({ key: 'key1', value: 'initial1' }) + await cache.set({ key: 'key2', value: 'initial2' }) + + const [getResults] = await Promise.all([ + cache.getMany({ keys: ['key1', 'key2', 'key3'] }), + (async () => { + await cache.set({ key: 'key3', value: 'concurrent3' }) + })(), + ]) + + assert.isArray(getResults) + assert.lengthOf(getResults, 3) + + const finalResults = await cache.getMany({ keys: ['key1', 'key2', 'key3'] }) + assert.deepEqual(finalResults, ['initial1', 'initial2', 'concurrent3']) + }) + + test('getMany() should handle mixed valid and expired keys correctly', async ({ assert }) => { + const { cache } = new CacheFactory().withMemoryL1().create() + + await cache.set({ key: 'key1', value: 'value1' }) + await cache.set({ key: 'key2', value: 'value2', ttl: '50ms' }) + await cache.set({ key: 'key3', value: 'value3' }) + await cache.set({ key: 'key4', value: 'value4', ttl: '50ms' }) + + await sleep(100) + + const results = await cache.getMany({ + keys: ['key1', 'key2', 'key3', 'key4'], + defaultValue: 'default', + }) + + assert.deepEqual(results, ['value1', 'default', 'value3', 'default']) + }) + + test('getMany() should not mutate input keys array', async ({ assert }) => { + const { cache } = new CacheFactory().withMemoryL1().create() + + await cache.set({ key: 'key1', value: 'value1' }) + + const keys = ['key1', 'key2'] + const keysCopy = [...keys] + + await cache.getMany({ keys }) + + assert.deepEqual(keys, keysCopy) + }) + test('deleteMany should delete multiple keys', async ({ assert }) => { const { cache } = new CacheFactory().withMemoryL1().merge({ grace: '500ms' }).create() diff --git a/packages/bentocache/tests/cache/two_tier.spec.ts b/packages/bentocache/tests/cache/two_tier.spec.ts index 73aee14..dfc91d2 100644 --- a/packages/bentocache/tests/cache/two_tier.spec.ts +++ b/packages/bentocache/tests/cache/two_tier.spec.ts @@ -57,6 +57,10 @@ test.group('Cache', () => { get(): any { assert.fail('should not be called') } + + getMany(_keys: string[]): any { + assert.fail('should not be called') + } } const { cache, local, stack } = new CacheFactory() @@ -827,6 +831,40 @@ test.group('Cache', () => { assert.equal(l2Value?.entry.getValue(), 'updated') }) + test('getMany() should correctly handle L1/L2 hits and misses', async ({ assert }) => { + const { cache, local, remote, stack } = new CacheFactory().withL1L2Config().create() + + // Setup: + // key1: in L1 only + // key2: in L2 only + // key3: in neither + await local.set('key1', JSON.stringify({ value: 'value1' }), stack.defaultOptions) + await remote.set('key2', JSON.stringify({ value: 'value2' }), stack.defaultOptions) + + const results = await cache.getMany({ keys: ['key1', 'key2', 'key3'] }) + + assert.deepEqual(results, ['value1', 'value2', undefined]) + + // Verify backfill (key2 should now be in L1) + const key2Local = local.get('key2', stack.defaultOptions) + assert.equal(key2Local?.entry.getValue(), 'value2') + }) + + test('getMany() should return default values for missing keys in two-tier setup', async ({ + assert, + }) => { + const { cache, local, stack } = new CacheFactory().withL1L2Config().create() + + await local.set('key1', JSON.stringify({ value: 'value1' }), stack.defaultOptions) + + const results = await cache.getMany({ + keys: ['key1', 'key2'], + defaultValue: 'default', + }) + + assert.deepEqual(results, ['value1', 'default']) + }) + test('getOrSet() should execute factory even if value exists in L2 when forceFresh is true', async ({ assert, }) => { diff --git a/packages/bentocache/tests/drivers/dynamodb.spec.ts b/packages/bentocache/tests/drivers/dynamodb.spec.ts index 789f931..02bb0d8 100644 --- a/packages/bentocache/tests/drivers/dynamodb.spec.ts +++ b/packages/bentocache/tests/drivers/dynamodb.spec.ts @@ -37,10 +37,21 @@ async function deleteTable() { } test.group('DynamoDB driver', (group) => { + let driver: DynamoDbDriver + group.setup(async () => { await createTable().catch((e) => console.error('Could not create table', e)) + driver = new DynamoDbDriver({ + prefix: 'dynamo-test', + region: 'eu-west-3', + endpoint: 'http://localhost:8000', + credentials: { accessKeyId: 'foo', secretAccessKey: 'foo' }, + table: { name: 'cache' }, + }) + return async () => { + await driver.disconnect() await deleteTable().catch((e) => console.error('Could not delete table', e)) } }) @@ -60,4 +71,85 @@ test.group('DynamoDB driver', (group) => { }) }, }) + + test('getMany() should handle more than 100 items', async ({ assert }) => { + const keys = Array.from({ length: 150 }, (_, i) => `key${i}`) + + await Promise.all(keys.map((key, i) => driver.set(key, `value${i}`))) + + const results = await driver.getMany(keys) + + assert.lengthOf(results, 150) + assert.equal(results[0], 'value0') + assert.equal(results[149], 'value149') + }) + + test('getMany() should retry with unprocessed keys', async ({ assert }) => { + const mockCalls: { command: any; input: any }[] = [] + + const mockSend = (command: { input: any }) => { + const callCount = mockCalls.length + 1 + mockCalls.push({ + command, + input: command.input, + }) + + // First call returns with unprocessed keys + if (callCount === 1) { + return Promise.resolve({ + Responses: { + cache: [{ key: { S: 'dynamo-test:key1' }, value: { S: 'value1' } }], + }, + UnprocessedKeys: { + cache: { + Keys: [{ key: { S: 'dynamo-test:key2' } }, { key: { S: 'dynamo-test:key3' } }], + }, + }, + }) + } + // Second call returns the remaining items + return Promise.resolve({ + Responses: { + cache: [ + { key: { S: 'dynamo-test:key2' }, value: { S: 'value2' } }, + { key: { S: 'dynamo-test:key3' }, value: { S: 'value3' } }, + ], + }, + UnprocessedKeys: {}, + }) + } + + const mockClient = { send: mockSend } as any + + const testDriver = new DynamoDbDriver({ + prefix: 'dynamo-test', + client: mockClient, + table: { name: 'cache' }, + region: 'eu-west-3', + endpoint: 'http://localhost:8000', + credentials: { accessKeyId: 'foo', secretAccessKey: 'foo' }, + }) + + const results = await testDriver.getMany(['key1', 'key2', 'key3']) + + assert.deepEqual(results, ['value1', 'value2', 'value3']) + + // Verify the client was called twice (initial + retry) + assert.equal(mockCalls.length, 2) + + // Verify the first call was with all keys + const firstCall = mockCalls[0].input + assert.deepEqual(firstCall.RequestItems['cache'].Keys, [ + { key: { S: 'dynamo-test:key1' } }, + { key: { S: 'dynamo-test:key2' } }, + { key: { S: 'dynamo-test:key3' } }, + ]) + + // Verify the second call was with unprocessed keys only + const secondCall = mockCalls[1].input + assert.deepEqual(secondCall.RequestItems['cache'].Keys, [ + { key: { S: 'dynamo-test:key2' } }, + { key: { S: 'dynamo-test:key3' } }, + ]) + }) }) diff --git a/packages/bentocache/tests/helpers/chaos/chaos_cache.ts b/packages/bentocache/tests/helpers/chaos/chaos_cache.ts index e35e6f7..8b0c9b1 100644 --- a/packages/bentocache/tests/helpers/chaos/chaos_cache.ts +++ b/packages/bentocache/tests/helpers/chaos/chaos_cache.ts @@ -72,6 +72,11 @@ export class ChaosCache implements return this.#innerCache.get(key) } + async getMany(keys: string[]) { + await this.#chaosInjector.injectChaos() + return this.#innerCache.getMany(keys) + } + async pull(key: string) { await this.#chaosInjector.injectChaos() return this.#innerCache.pull(key) diff --git a/packages/bentocache/tests/helpers/driver_test_suite.ts b/packages/bentocache/tests/helpers/driver_test_suite.ts index b21e6bb..3f08aeb 100644 --- a/packages/bentocache/tests/helpers/driver_test_suite.ts +++ b/packages/bentocache/tests/helpers/driver_test_suite.ts @@ -42,6 +42,47 @@ export function registerCacheDriverTestSuite(options: { assert.deepEqual(await cache.get('key'), 'value') }) + test('getMany() should return values for multiple keys in order', async ({ assert }) => { + await cache.set('key1', 'value1') + await cache.set('key3', 'value3') + + const results = await cache.getMany(['key1', 'key2', 'key3']) + assert.deepEqual(results, ['value1', undefined, 'value3']) + }) + + test('getMany() should return undefined for missing keys', async ({ assert }) => { + const results = await cache.getMany(['missing1', 'missing2']) + assert.deepEqual(results, [undefined, undefined]) + }) + + test('getMany() should handle duplicate keys', async ({ assert }) => { + await cache.set('key1', 'value1') + const results = await cache.getMany(['key1', 'key1']) + assert.deepEqual(results, ['value1', 'value1']) + }) + + test('getMany() should handle expired items', async ({ assert }) => { + /** + * Using second-level TTL values (1000ms+) because MySQL and some other databases + * only support second-precision for expiration timestamps. Sub-second TTLs cause + * flaky tests due to unpredictable rounding behavior. + */ + await cache.set('expired1', 'value1', 1000) + await cache.set('expired2', 'value2', 1000) + await cache.set('valid', 'value3', 5000) + + await sleep(1500) + + const results = await cache.getMany(['expired1', 'expired2', 'valid', 'missing']) + + assert.isUndefined(results[0], 'Expired item 1 should be undefined') + assert.isUndefined(results[1], 'Expired item 2 should be undefined') + assert.equal(results[2], 'value3', 'Valid item should be returned') + assert.isUndefined(results[3], 'Missing item should be undefined') + + await Promise.all([cache.delete('expired1'), cache.delete('expired2'), cache.delete('valid')]) + }) + test('set() store a value', async ({ assert }) => { await cache.set('key', 'value') assert.deepEqual(await cache.get('key'), 'value') diff --git a/packages/bentocache/tests/helpers/null/null_driver.ts b/packages/bentocache/tests/helpers/null/null_driver.ts index 07a1ec0..339c0f7 100644 --- a/packages/bentocache/tests/helpers/null/null_driver.ts +++ b/packages/bentocache/tests/helpers/null/null_driver.ts @@ -13,6 +13,10 @@ export class NullDriver extends BaseDriver implements CacheDriver { return undefined } + getMany(_keys: string[]): any { + return [] + } + pull(_key: string): any { return undefined } diff --git a/packages/bentocache/tests/typings.spec.ts b/packages/bentocache/tests/typings.spec.ts index 0b12cc5..0b57fae 100644 --- a/packages/bentocache/tests/typings.spec.ts +++ b/packages/bentocache/tests/typings.spec.ts @@ -150,6 +150,66 @@ test.group('Typings', () => { expectTypeOf(bento.get).parameter(0).exclude(undefined).not.toHaveProperty('timeout') }) + test('getMany() typings on cache', async ({ expectTypeOf }) => { + const { cache } = new CacheFactory().create() + + const r1 = await cache.getMany({ keys: ['key1', 'key2'] }) + const r2 = await cache.getMany({ keys: ['key1', 'key2'], defaultValue: 'default' }) + const r3 = await cache.getMany({ keys: ['key1', 'key2'], defaultValue: () => 'default' }) + const r4 = await cache.getMany({ keys: ['key1', 'key2'], defaultValue: () => 10 }) + const r5 = await cache.getMany({ keys: ['key1', 'key2'], defaultValue: () => ({ foo: 'bar' }) }) + const r6 = await cache.getMany({ keys: ['key1', 'key2'], defaultValue: { bar: 'foo' } }) + const r7 = await cache.getMany({ keys: ['key1', 'key2'] }) + + expectTypeOf(r1).toEqualTypeOf<(string | null | undefined)[]>() + expectTypeOf(r2).toEqualTypeOf<(string | null | undefined)[]>() + expectTypeOf(r3).toEqualTypeOf<(string | null | undefined)[]>() + expectTypeOf(r4).toEqualTypeOf<(number | null | undefined)[]>() + expectTypeOf(r5).toEqualTypeOf<({ foo: string } | null | undefined)[]>() + expectTypeOf(r6).toEqualTypeOf<({ bar: string } | null | undefined)[]>() + expectTypeOf(r7).toEqualTypeOf<(any | null | undefined)[]>() + }) + + test('getMany() typings on bento', async ({ expectTypeOf }) => { + const { bento } = new BentoCacheFactory().create() + + const r1 = await bento.getMany({ keys: ['key1', 'key2'] }) + const r2 = await bento.getMany({ keys: ['key1', 'key2'], defaultValue: 'default' }) + const r3 = await bento.getMany({ keys: ['key1', 'key2'], defaultValue: () => 'default' }) + const r4 = await bento.getMany({ keys: ['key1', 'key2'], defaultValue: () => 10 }) + const r5 = await bento.getMany({ + keys: ['key1', 'key2'], + defaultValue: () => ({ foo: 'bar' }), + }) + const r6 = await bento.getMany({ + keys: ['key1', 'key2'], + defaultValue: { bar: 'foo' }, + }) + const r7 = await bento.getMany({ keys: ['key1', 'key2'] }) + + expectTypeOf(r1).toEqualTypeOf<(string | null | undefined)[]>() + expectTypeOf(r2).toEqualTypeOf<(string | null | undefined)[]>() + expectTypeOf(r3).toEqualTypeOf<(string | null | undefined)[]>() + expectTypeOf(r4).toEqualTypeOf<(number | null | undefined)[]>() + expectTypeOf(r5).toEqualTypeOf<({ foo: string } | null | undefined)[]>() + expectTypeOf(r6).toEqualTypeOf<({ bar: string } | null | undefined)[]>() + expectTypeOf(r7).toEqualTypeOf<(any | null | undefined)[]>() + }) + + test('getMany() options parameters typings', async ({ expectTypeOf }) => { + const { bento } = new BentoCacheFactory().create() + + expectTypeOf(bento.getMany).parameter(0).exclude(undefined).not.toHaveProperty('lockTimeout') + expectTypeOf(bento.getMany).parameter(0).exclude(undefined).not.toHaveProperty('timeout') + expectTypeOf(bento.getMany).parameter(0).exclude(undefined).toMatchTypeOf<{ + keys: string[] + defaultValue?: any + grace?: any + graceBackoff?: any + suppressL2Errors?: boolean + }>() + }) + test('delete() options parameters typings', async ({ expectTypeOf }) => { const { bento } = new BentoCacheFactory().create() diff --git a/simulator/start/chaos/chaos_cache.ts b/simulator/start/chaos/chaos_cache.ts index 2c5a347..40a7860 100644 --- a/simulator/start/chaos/chaos_cache.ts +++ b/simulator/start/chaos/chaos_cache.ts @@ -72,6 +72,17 @@ export class ChaosCache implements return this.#innerCache.get(key) } + async getMany(keys: string[]): Promise { + await this.#chaosInjector.injectChaos() + + if ('getMany' in this.#innerCache && typeof (this.#innerCache as any).getMany === 'function') { + return (this.#innerCache as any).getMany(keys) + } + + const results = await Promise.all(keys.map((k) => (this.#innerCache as any).get(k))) + return results + } + async pull(key: string) { await this.#chaosInjector.injectChaos() return this.#innerCache.pull(key)