Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve API for LoadParams #347

Merged
merged 7 commits into from
Jan 3, 2025
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 8 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ It is also possible to inline datasource definition:
const loader = new Loader<string>({
// this cache will be checked first
inMemoryCache: {
cacheType: 'lru-object', // you can choose between lru and fifo caches, fifo being 10% slightly faster
cacheType: 'lru-object', // you can choose between lru and fifo caches, fifo being ~10% faster
ttlInMsecs: 1000 * 60,
maxItems: 100,
},
Expand Down Expand Up @@ -205,19 +205,21 @@ Loader has the following config parameters:
- `throwIfLoadError: boolean` - if true, error will be thrown if any Loader throws an error;
- `cacheUpdateErrorHandler: LoaderErrorHandler` - error handler to use when cache throws an error during update;
- `loadErrorHandler: LoaderErrorHandler` - error handler to use when non-last data source throws an error during data retrieval.
- `cacheKeyFromLoadParamsResolver: CacheKeyResolver<LoadParams>` - mapper from LoadParams to a cache key. Defaults to a simple string passthrough when LoadParams are just a string key to begin with (which is the default)
- `cacheKeyFromValueResolver: CacheKeyResolver<LoadParams>` - mapper from entity to be cached to a cache key. Defaults to a dummy resolver which throws an error when methods that depend on it are used. Make sure to provide a real resolver if you are using the bulk API (getMany/getManyFromGroup)

Loader provides following methods:

- `invalidateCacheFor(key: string): Promise<void>` - expunge all entries for given key from all caches of this Loader;
- `invalidateCacheForMany(keys: string[]): Promise<void>` - expunge all entries for given keys from all caches of this Loader;
- `invalidateCache(): Promise<void>` - expunge all entries from all caches of this Loader;
- `get(key: string, loadParams?: P): Promise<T>` - sequentially attempt to retrieve data for specified key from all caches and loaders, in an order in which those data sources passed to the Loader constructor.
- `get(loadParams: LoadParams = string): Promise<T>` - sequentially attempt to retrieve data for specified key from all caches and loaders, in an order in which those data sources passed to the Loader constructor.
- `getMany(keys: string[], idResolver: (entity: T) => string, loadParams?: P): Promise<T>` - sequentially attempt to retrieve data for specified keys from all caches and data sources, in an order in which those data sources were passed to the Loader constructor. Note that this retrieval mode doesn't support neither fetch deduplication nor the preemptive background refresh.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it intentional to use string keys in getMany?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, primarily for perf and memory reasons. e. g. if you are resolving 1k entries, you do not need to pass same JWT key 1k times

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess I need to document it better


## Parametrized loading

Sometimes you need to pass additional parameters for loader in case it will need to refill the cache, such as JWT token (for external calls) or additional query parameters (for a DB call).
You can use optional parameter `loadParams` for that:
You can use optional generic `LoadParams` for that:

```ts
import type { DataSource } from 'layered-loader'
Expand All @@ -228,11 +230,7 @@ export type MyLoaderParams = {
}

class MyParametrizedDataSource implements DataSource<string, MyLoaderParams> {
async get(_key: string, params?: MyLoaderParams): Promise<string | undefined | null> {
if (!params) {
throw new Error('Params were not passed')
}

async get(params: MyLoaderParams): Promise<string | undefined | null> {
const resolvedValue = await someResolutionLogic(params.entityId, params.jwtToken)
return resolvedValue
}
Expand All @@ -241,8 +239,9 @@ class MyParametrizedDataSource implements DataSource<string, MyLoaderParams> {
const loader = new Loader<string, MyLoaderParams>({
inMemoryCache: IN_MEMORY_CACHE_CONFIG,
dataSources: [new MyParametrizedDataSource()],
cacheKeyFromLoadParamsResolver: (params) => params.entityId
})
await operation.get('key', { jwtToken: 'someTokenValue', entityId: 'key' })
await operation.get({ jwtToken: 'someTokenValue', entityId: 'key' })
```

## Update notifications
Expand Down
5 changes: 2 additions & 3 deletions index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
export { Loader } from './lib/Loader'
export { GroupLoader } from './lib/GroupLoader'
export { ManualCache } from './lib/ManualCache'
export { ManualGroupCache } from './lib/ManualGroupCache'
export { RedisCache } from './lib/redis/RedisCache'
export { RedisGroupCache } from './lib/redis/RedisGroupCache'
Expand All @@ -21,14 +20,14 @@ export type { InMemoryCacheConfiguration } from './lib/memory/InMemoryCache'
export type { RedisCacheConfiguration } from './lib/redis/AbstractRedisCache'
export type { RedisGroupCacheConfiguration } from './lib/redis/RedisGroupCache'
export type { LoaderConfig } from './lib/Loader'
export type { CommonCacheConfig } from './lib/AbstractCache'
export type { CommonCacheConfig, CacheKeyResolver, IdHolder } from './lib/AbstractCache'
export { DEFAULT_FROM_STRING_RESOLVER, DEFAULT_FROM_ID_RESOLVER } from './lib/AbstractCache'
export type { GroupLoaderConfig } from './lib/GroupLoader'
export type { ManualGroupCacheConfig } from './lib/ManualGroupCache'
export type {
DataSource,
Cache,
CommonCacheConfiguration,
IdResolver,
} from './lib/types/DataSources'
export type { Logger, LogFn } from './lib/util/Logger'

Expand Down
26 changes: 25 additions & 1 deletion lib/AbstractCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,20 @@ export const DEFAULT_CACHE_ERROR_HANDLER: LoaderErrorHandler = (err, key, cache,
logger.error(`Error while caching "${key}" with ${cache.name}: ${err.message}`)
}

export type CacheKeyResolver<SourceData> = (sourceData: SourceData) => string

export type IdHolder = { id: string }

export const DEFAULT_FROM_STRING_RESOLVER: CacheKeyResolver<string> = (source) => {
if (!(typeof source === 'string')) {
throw new Error('Please define cacheKeyFromLoadParamsResolver in your loader config if you are using composite loadParams and not just string keys')
}
return source
}

export const DEFAULT_FROM_ID_RESOLVER: CacheKeyResolver<IdHolder> = (source: IdHolder) => source.id
kibertoad marked this conversation as resolved.
Show resolved Hide resolved
export const DEFAULT_UNDEFINED_FROM_VALUE_RESOLVER: CacheKeyResolver<unknown> = () => { throw new Error('Please define cacheKeyFromValueResolver in your loader config if you want to use getMany operations') }

export type CommonCacheConfig<
LoadedValue,
CacheType extends Cache<LoadedValue> | GroupCache<LoadedValue> = Cache<LoadedValue>,
Expand All @@ -38,6 +52,7 @@ export type CommonCacheConfig<
NotificationPublisherType extends
| NotificationPublisher<LoadedValue>
| GroupNotificationPublisher<LoadedValue> = NotificationPublisher<LoadedValue>,
LoadParams = string
> = {
logger?: Logger
cacheUpdateErrorHandler?: LoaderErrorHandler
Expand All @@ -46,6 +61,8 @@ export type CommonCacheConfig<
asyncCache?: CacheType
notificationConsumer?: AbstractNotificationConsumer<LoadedValue, InMemoryCacheType>
notificationPublisher?: NotificationPublisherType
cacheKeyFromLoadParamsResolver?: CacheKeyResolver<LoadParams>
cacheKeyFromValueResolver?: CacheKeyResolver<LoadedValue>
}

export abstract class AbstractCache<
Expand All @@ -61,9 +78,12 @@ export abstract class AbstractCache<
NotificationPublisherType extends
| NotificationPublisher<LoadedValue>
| GroupNotificationPublisher<LoadedValue> = NotificationPublisher<LoadedValue>,
LoadParams = string
> {
protected readonly inMemoryCache: InMemoryCacheType
protected readonly asyncCache?: CacheType
public readonly cacheKeyFromLoadParamsResolver: CacheKeyResolver<LoadParams>
public readonly cacheKeyFromValueResolver: CacheKeyResolver<LoadedValue>

protected readonly logger: Logger
protected readonly cacheUpdateErrorHandler: LoaderErrorHandler
Expand All @@ -83,10 +103,14 @@ export abstract class AbstractCache<
CacheType,
InMemoryCacheConfigType,
InMemoryCacheType,
NotificationPublisherType
NotificationPublisherType,
LoadParams
>,
) {
this.initPromises = []
// @ts-expect-error By default we assume simple string params
this.cacheKeyFromLoadParamsResolver = config.cacheKeyFromLoadParamsResolver ?? DEFAULT_FROM_STRING_RESOLVER
this.cacheKeyFromValueResolver = config.cacheKeyFromValueResolver ?? DEFAULT_UNDEFINED_FROM_VALUE_RESOLVER

if (config.inMemoryCache) {
if (this.isGroupCache()) {
Expand Down
40 changes: 23 additions & 17 deletions lib/AbstractFlatCache.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,29 @@
import { AbstractCache } from './AbstractCache'
import type { Cache, IdResolver } from './types/DataSources'
import {AbstractCache, CommonCacheConfig} from './AbstractCache'
import type { Cache } from './types/DataSources'
import type { GetManyResult, SynchronousCache } from './types/SyncDataSources'
import {InMemoryCacheConfiguration} from "./memory/InMemoryCache";
import {NotificationPublisher} from "./notifications/NotificationPublisher";

export abstract class AbstractFlatCache<LoadedValue, LoadParams = undefined> extends AbstractCache<
export abstract class AbstractFlatCache<LoadedValue, LoadParams = string> extends AbstractCache<
LoadedValue,
Promise<LoadedValue | undefined | null> | undefined,
Cache<LoadedValue>,
SynchronousCache<LoadedValue>
SynchronousCache<LoadedValue>,
InMemoryCacheConfiguration,
NotificationPublisher<LoadedValue>,
LoadParams
> {

override isGroupCache() {
return false
}

public getInMemoryOnly(key: string, loadParams?: LoadParams): LoadedValue | undefined | null {
public getInMemoryOnly(loadParams: LoadParams): LoadedValue | undefined | null {
const key = this.cacheKeyFromLoadParamsResolver(loadParams)
if (this.inMemoryCache.ttlLeftBeforeRefreshInMsecs && !this.runningLoads.has(key)) {
const expirationTime = this.inMemoryCache.getExpirationTime(key)
if (expirationTime && expirationTime - Date.now() < this.inMemoryCache.ttlLeftBeforeRefreshInMsecs) {
void this.getAsyncOnly(key, loadParams)
void this.getAsyncOnly(loadParams)
}
}

Expand All @@ -28,7 +35,8 @@ export abstract class AbstractFlatCache<LoadedValue, LoadParams = undefined> ext
return this.inMemoryCache.getMany(keys)
}

public getAsyncOnly(key: string, loadParams?: LoadParams): Promise<LoadedValue | undefined | null> {
public getAsyncOnly(loadParams: LoadParams): Promise<LoadedValue | undefined | null> {
const key = this.cacheKeyFromLoadParamsResolver(loadParams)
const existingLoad = this.runningLoads.get(key)
if (existingLoad) {
return existingLoad
Expand All @@ -53,44 +61,43 @@ export abstract class AbstractFlatCache<LoadedValue, LoadParams = undefined> ext

public getManyAsyncOnly(
keys: string[],
idResolver: IdResolver<LoadedValue>,
loadParams?: LoadParams,
): Promise<GetManyResult<LoadedValue>> {
// This doesn't support deduplication, and never might, as that would affect perf strongly. Maybe as an opt-in option in the future?
const loadingPromise = this.resolveManyValues(keys, idResolver, loadParams)
const loadingPromise = this.resolveManyValues(keys, loadParams)

return loadingPromise.then((result) => {
for (let i = 0; i < result.resolvedValues.length; i++) {
const resolvedValue = result.resolvedValues[i]
const id = idResolver(resolvedValue)
const id = this.cacheKeyFromValueResolver(resolvedValue)
this.inMemoryCache.set(id, resolvedValue)
}
return result
})
}

public get(key: string, loadParams?: LoadParams): Promise<LoadedValue | undefined | null> {
const inMemoryValue = this.getInMemoryOnly(key, loadParams)
public get(loadParams: LoadParams): Promise<LoadedValue | undefined | null> {
const inMemoryValue = this.getInMemoryOnly(loadParams)
if (inMemoryValue !== undefined) {
return Promise.resolve(inMemoryValue)
}

return this.getAsyncOnly(key, loadParams)
return this.getAsyncOnly(loadParams)
}

public getMany(keys: string[], idResolver: IdResolver<LoadedValue>, loadParams?: LoadParams): Promise<LoadedValue[]> {
public getMany(keys: string[], loadParams?: LoadParams): Promise<LoadedValue[]> {
const inMemoryValues = this.getManyInMemoryOnly(keys)
// everything is in memory, hurray
if (inMemoryValues.unresolvedKeys.length === 0) {
return Promise.resolve(inMemoryValues.resolvedValues)
}

return this.getManyAsyncOnly(inMemoryValues.unresolvedKeys, idResolver, loadParams).then((asyncRetrievedValues) => {
return this.getManyAsyncOnly(inMemoryValues.unresolvedKeys, loadParams).then((asyncRetrievedValues) => {
return [...inMemoryValues.resolvedValues, ...asyncRetrievedValues.resolvedValues]
})
}

protected async resolveValue(key: string, _loadParams?: LoadParams): Promise<LoadedValue | undefined | null> {
protected async resolveValue(key: string, _loadParams: LoadParams): Promise<LoadedValue | undefined | null> {
if (this.asyncCache) {
const cachedValue = await this.asyncCache.get(key).catch((err) => {
this.loadErrorHandler(err, key, this.asyncCache!, this.logger)
Expand All @@ -104,7 +111,6 @@ export abstract class AbstractFlatCache<LoadedValue, LoadParams = undefined> ext

protected async resolveManyValues(
keys: string[],
_idResolver: IdResolver<LoadedValue>,
_loadParams?: LoadParams,
): Promise<GetManyResult<LoadedValue>> {
if (this.asyncCache) {
Expand Down
31 changes: 16 additions & 15 deletions lib/AbstractGroupCache.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import { AbstractCache } from './AbstractCache'
import type { InMemoryGroupCacheConfiguration } from './memory/InMemoryGroupCache'
import type { GroupNotificationPublisher } from './notifications/GroupNotificationPublisher'
import type { GroupCache, IdResolver } from './types/DataSources'
import type { GroupCache } from './types/DataSources'
import type { GetManyResult, SynchronousGroupCache } from './types/SyncDataSources'

export abstract class AbstractGroupCache<LoadedValue, LoadParams = undefined> extends AbstractCache<
export abstract class AbstractGroupCache<LoadedValue, LoadParams = string> extends AbstractCache<
LoadedValue,
Map<string, Promise<LoadedValue | undefined | null> | undefined>,
GroupCache<LoadedValue>,
SynchronousGroupCache<LoadedValue>,
InMemoryGroupCacheConfiguration,
GroupNotificationPublisher<LoadedValue>
GroupNotificationPublisher<LoadedValue>,
LoadParams
> {
override isGroupCache() {
return true
Expand All @@ -33,13 +34,14 @@ export abstract class AbstractGroupCache<LoadedValue, LoadParams = undefined> ex
}
}

public getInMemoryOnly(key: string, group: string, loadParams?: LoadParams): LoadedValue | undefined | null {
public getInMemoryOnly(loadParams: LoadParams, group: string): LoadedValue | undefined | null {
const key = this.cacheKeyFromLoadParamsResolver(loadParams)
if (this.inMemoryCache.ttlLeftBeforeRefreshInMsecs) {
const groupLoads = this.resolveGroupLoads(group)
if (!groupLoads.has(key)) {
const expirationTime = this.inMemoryCache.getExpirationTimeFromGroup(key, group)
if (expirationTime && expirationTime - Date.now() < this.inMemoryCache.ttlLeftBeforeRefreshInMsecs) {
void this.getAsyncOnly(key, group, loadParams)
void this.getAsyncOnly(loadParams, group)
}
}
}
Expand All @@ -52,7 +54,8 @@ export abstract class AbstractGroupCache<LoadedValue, LoadParams = undefined> ex
return this.inMemoryCache.getManyFromGroup(keys, group)
}

public getAsyncOnly(key: string, group: string, loadParams?: LoadParams): Promise<LoadedValue | undefined | null> {
public getAsyncOnly(loadParams: LoadParams, group: string): Promise<LoadedValue | undefined | null> {
const key = this.cacheKeyFromLoadParamsResolver(loadParams)
const groupLoads = this.resolveGroupLoads(group)
const existingLoad = groupLoads.get(key)
if (existingLoad) {
Expand All @@ -79,33 +82,32 @@ export abstract class AbstractGroupCache<LoadedValue, LoadParams = undefined> ex
public getManyAsyncOnly(
keys: string[],
group: string,
idResolver: IdResolver<LoadedValue>,
loadParams?: LoadParams,
): Promise<GetManyResult<LoadedValue>> {
// This doesn't support deduplication, and never might, as that would affect perf strongly. Maybe as an opt-in option in the future?
return this.resolveManyGroupValues(keys, group, idResolver, loadParams).then((result) => {
return this.resolveManyGroupValues(keys, group, loadParams).then((result) => {
for (let i = 0; i < result.resolvedValues.length; i++) {
const resolvedValue = result.resolvedValues[i]
const id = idResolver(resolvedValue)
const id = this.cacheKeyFromValueResolver(resolvedValue)
this.inMemoryCache.setForGroup(id, resolvedValue, group)
}
return result
})
}

public get(key: string, group: string, loadParams?: LoadParams): Promise<LoadedValue | undefined | null> {
const inMemoryValue = this.getInMemoryOnly(key, group, loadParams)
public get(loadParams: LoadParams, group: string): Promise<LoadedValue | undefined | null> {
const key = this.cacheKeyFromLoadParamsResolver(loadParams)
const inMemoryValue = this.getInMemoryOnly(loadParams, group)
if (inMemoryValue !== undefined) {
return Promise.resolve(inMemoryValue)
}

return this.getAsyncOnly(key, group, loadParams)
return this.getAsyncOnly(loadParams, group)
}

public getMany(
keys: string[],
group: string,
idResolver: IdResolver<LoadedValue>,
loadParams?: LoadParams,
): Promise<LoadedValue[]> {
const inMemoryValues = this.getManyInMemoryOnly(keys, group)
Expand All @@ -114,7 +116,7 @@ export abstract class AbstractGroupCache<LoadedValue, LoadParams = undefined> ex
return Promise.resolve(inMemoryValues.resolvedValues)
}

return this.getManyAsyncOnly(inMemoryValues.unresolvedKeys, group, idResolver, loadParams).then(
return this.getManyAsyncOnly(inMemoryValues.unresolvedKeys, group, loadParams).then(
(asyncRetrievedValues) => {
return [...inMemoryValues.resolvedValues, ...asyncRetrievedValues.resolvedValues]
},
Expand Down Expand Up @@ -158,7 +160,6 @@ export abstract class AbstractGroupCache<LoadedValue, LoadParams = undefined> ex
protected async resolveManyGroupValues(
keys: string[],
group: string,
_idResolver: IdResolver<LoadedValue>,
_loadParams?: LoadParams,
) {
if (this.asyncCache) {
Expand Down
8 changes: 4 additions & 4 deletions lib/GeneratedDataSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ import type { DataSource } from './types/DataSources'

export type GeneratedDataSourceParams<LoadedValue, LoaderParams = undefined> = {
name?: string
dataSourceGetOneFn?: (key: string, loadParams?: LoaderParams) => Promise<LoadedValue | undefined | null>
dataSourceGetOneFn?: (loadParams: LoaderParams) => Promise<LoadedValue | undefined | null>
dataSourceGetManyFn?: (keys: string[], loadParams?: LoaderParams) => Promise<LoadedValue[]>
}

export class GeneratedDataSource<LoadedValue, LoadParams = undefined> implements DataSource<LoadedValue, LoadParams> {
private readonly getOneFn: (key: string, loadParams?: LoadParams) => Promise<LoadedValue | undefined | null>
private readonly getOneFn: (loadParams: LoadParams) => Promise<LoadedValue | undefined | null>
private readonly getManyFn: (keys: string[], loadParams?: LoadParams) => Promise<LoadedValue[]>
public readonly name: string
constructor(params: GeneratedDataSourceParams<LoadedValue, LoadParams>) {
Expand All @@ -25,8 +25,8 @@ export class GeneratedDataSource<LoadedValue, LoadParams = undefined> implements
})
}

get(key: string, loadParams: LoadParams | undefined): Promise<LoadedValue | undefined | null> {
return this.getOneFn(key, loadParams)
get(loadParams: LoadParams): Promise<LoadedValue | undefined | null> {
return this.getOneFn(loadParams)
}

getMany(keys: string[], loadParams: LoadParams | undefined): Promise<LoadedValue[]> {
Expand Down
Loading
Loading