Skip to content

Commit

Permalink
Implement support for async cache refresh (#227)
Browse files Browse the repository at this point in the history
  • Loading branch information
kibertoad committed Mar 29, 2023
1 parent 38203fd commit 6fa326a
Show file tree
Hide file tree
Showing 18 changed files with 267 additions and 11 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,3 +193,4 @@ It has following configuration options:
- `json: boolean` - if false, all passed data will be sent to Redis and returned from it as-is. If true, it will be serialized using `JSON.stringify` and deserialized, using `JSON.parse`;
- `timeoutInMsecs?: number` - if set, Redis operations will automatically fail after specified execution threshold in milliseconds is exceeded. Next data source in the sequence will be used instead.
- `separator?: number` - What text should be used between different parts of the key prefix. Default is `':'`
- `ttlLeftBeforeRefreshInMsecs?: number` - if set within a LoadingOperation or GroupedLoadingOperation, when remaining ttl is equal or lower to specified value, loading will be started in background, and all caches will be updated. It is recommended to set this value for heavy loaded system, to prevent requests from stalling while cache refresh is happening.
24 changes: 21 additions & 3 deletions lib/GroupedLoadingOperation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,30 @@ export class GroupedLoadingOperation<LoadedValue, LoaderParams = undefined> exte
): Promise<LoadedValue | undefined | null> {
const cachedValue = await super.resolveGroupValue(key, group)
if (cachedValue !== undefined) {
if (this.asyncCache?.ttlLeftBeforeRefreshInMsecs) {
const expirationTime = await this.asyncCache.getExpirationTimeFromGroup(key, group)
if (expirationTime && expirationTime - Date.now() < this.asyncCache.ttlLeftBeforeRefreshInMsecs) {
this.loadFromLoaders(key, group, loadParams).catch((err) => {
this.logger.error(err.message)
})
}
}

return cachedValue
}

const finalValue = await this.loadFromLoaders(key, group, loadParams)
if (finalValue !== undefined) {
return finalValue
}

if (this.throwIfUnresolved) {
throw new Error(`Failed to resolve value for key "${key}", group "${group}"`)
}
return undefined
}

private async loadFromLoaders(key: string, group: string, loadParams?: LoaderParams) {
for (let index = 0; index < this.loaders.length; index++) {
const resolvedValue = await this.loaders[index].getFromGroup(key, group, loadParams).catch((err) => {
this.loadErrorHandler(err, key, this.loaders[index], this.logger)
Expand All @@ -55,9 +76,6 @@ export class GroupedLoadingOperation<LoadedValue, LoaderParams = undefined> exte
}
}

if (this.throwIfUnresolved) {
throw new Error(`Failed to resolve value for key "${key}", group "${group}"`)
}
return undefined
}
}
24 changes: 21 additions & 3 deletions lib/LoadingOperation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,30 @@ export class LoadingOperation<LoadedValue, LoaderParams = undefined> extends Abs
): Promise<LoadedValue | undefined | null> {
const cachedValue = await super.resolveValue(key, loadParams)
if (cachedValue !== undefined) {
if (this.asyncCache?.ttlLeftBeforeRefreshInMsecs) {
const expirationTime = await this.asyncCache.getExpirationTime(key)
if (expirationTime && expirationTime - Date.now() < this.asyncCache.ttlLeftBeforeRefreshInMsecs) {
this.loadFromLoaders(key, loadParams).catch((err) => {
this.logger.error(err.message)
})
}
}

return cachedValue
}

const finalValue = await this.loadFromLoaders(key, loadParams)
if (finalValue !== undefined) {
return finalValue
}

if (this.throwIfUnresolved) {
throw new Error(`Failed to resolve value for key "${key}"`)
}
return undefined
}

private async loadFromLoaders(key: string, loadParams?: LoaderParams) {
for (let index = 0; index < this.loaders.length; index++) {
const resolvedValue = await this.loaders[index].get(key, loadParams).catch((err) => {
this.loadErrorHandler(err, key, this.loaders[index], this.logger)
Expand All @@ -59,9 +80,6 @@ export class LoadingOperation<LoadedValue, LoaderParams = undefined> extends Abs
}
}

if (this.throwIfUnresolved) {
throw new Error(`Failed to resolve value for key "${key}"`)
}
return undefined
}
}
1 change: 0 additions & 1 deletion lib/memory/InMemoryCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ export interface InMemoryCacheConfiguration extends CacheConfiguration {
maxItems?: number
maxGroups?: number
maxItemsPerGroup?: number
ttlLeftBeforeRefreshInMsecs?: number
}

const DEFAULT_CONFIGURATION = {
Expand Down
2 changes: 2 additions & 0 deletions lib/redis/RedisCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const DefaultConfiguration: RedisCacheConfiguration = {
export class RedisCache<T> implements GroupedCache<T>, Cache<T>, Loader<T> {
private readonly redis: Redis
private readonly config: RedisCacheConfiguration
public readonly ttlLeftBeforeRefreshInMsecs: number | undefined
name = 'Redis cache'
isCache = true

Expand All @@ -33,6 +34,7 @@ export class RedisCache<T> implements GroupedCache<T>, Cache<T>, Loader<T> {
...DefaultConfiguration,
...config,
}
this.ttlLeftBeforeRefreshInMsecs = config.ttlLeftBeforeRefreshInMsecs
this.redis.defineCommand('getOrSetZeroWithTtl', {
lua: GET_OR_SET_ZERO_WITH_TTL,
numberOfKeys: 1,
Expand Down
2 changes: 2 additions & 0 deletions lib/types/DataSources.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
export interface CacheConfiguration {
ttlLeftBeforeRefreshInMsecs?: number
ttlInMsecs: number | undefined
}

export interface Cache<LoadedValue> {
readonly ttlLeftBeforeRefreshInMsecs?: number
get: (key: string) => Promise<LoadedValue | undefined | null>
set: (key: string, value: LoadedValue | null) => Promise<void>
getExpirationTime: (key: string) => Promise<number | undefined>
Expand Down
2 changes: 1 addition & 1 deletion lib/types/SyncDataSources.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export interface SynchronousCache<T> {
readonly ttlLeftBeforeRefreshInMsecs: number | undefined
readonly ttlLeftBeforeRefreshInMsecs?: number
get: (key: string) => T | undefined | null
set: (key: string, value: T | null) => void
getExpirationTime: (key: string) => number | undefined
Expand Down
95 changes: 94 additions & 1 deletion test/GroupedLoadingOperation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import { CountingGroupedLoader } from './fakes/CountingGroupedLoader'
import { DummyLoaderParams } from './fakes/DummyLoaderWithParams'
import { DummyGroupedLoaderWithParams } from './fakes/DummyGroupedLoaderWithParams'
import { setTimeout } from 'timers/promises'
import { RedisCache } from '../lib/redis'
import Redis from 'ioredis'
import { redisOptions } from './fakes/TestRedisConfig'

const IN_MEMORY_CACHE_CONFIG = { ttlInMsecs: 9999999 } satisfies InMemoryCacheConfiguration

Expand Down Expand Up @@ -44,7 +47,7 @@ const userValuesUndefined = {
}

describe('GroupedLoadingOperation', () => {
beforeEach(() => {
beforeEach(async () => {
jest.resetAllMocks()
})

Expand Down Expand Up @@ -107,6 +110,7 @@ describe('GroupedLoadingOperation', () => {
await Promise.resolve()
await Promise.resolve()
expect(loader.counter).toBe(2)
await Promise.resolve()
// @ts-ignore
const expirationTimePost = operation.inMemoryCache.getExpirationTimeFromGroup(user1.userId, user1.companyId)

Expand All @@ -119,6 +123,95 @@ describe('GroupedLoadingOperation', () => {
})
})

describe('background async refresh', () => {
let redis: Redis
beforeEach(async () => {
redis = new Redis(redisOptions)
await redis.flushall()
})

afterEach(async () => {
await redis.disconnect()
})

it('triggers async background refresh when threshold is set and reached', async () => {
const loader = new CountingGroupedLoader(userValues)
const asyncCache = new RedisCache<User>(redis, {
ttlInMsecs: 150,
json: true,
ttlLeftBeforeRefreshInMsecs: 75,
})

const operation = new GroupedLoadingOperation<User>({
asyncCache,
loaders: [loader],
})

// @ts-ignore
expect(await operation.asyncCache.get(user1.userId, user1.companyId)).toBeUndefined()
expect(loader.counter).toBe(0)
expect(await operation.get(user1.userId, user1.companyId)).toEqual(user1)
expect(loader.counter).toBe(1)
// @ts-ignore
const expirationTimePre = await operation.asyncCache.getExpirationTimeFromGroup(user1.userId, user1.companyId)

await setTimeout(100)
expect(loader.counter).toBe(1)
// kick off the refresh
expect(await operation.get(user1.userId, user1.companyId)).toEqual(user1)
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
expect(loader.counter).toBe(2)
await Promise.resolve()
await Promise.resolve()
// @ts-ignore
const expirationTimePost = await operation.asyncCache.getExpirationTimeFromGroup(user1.userId, user1.companyId)

expect(await operation.get(user1.userId, user1.companyId)).toEqual(user1)
await Promise.resolve()
expect(loader.counter).toBe(2)
expect(expirationTimePre).toBeDefined()
expect(expirationTimePost).toBeDefined()
expect(expirationTimePost! > expirationTimePre!).toBe(true)
})

it('async background refresh errors do not crash app', async () => {
const loader = new CountingGroupedLoader(userValues)
const asyncCache = new RedisCache<User>(redis, {
ttlInMsecs: 150,
json: true,
ttlLeftBeforeRefreshInMsecs: 75,
})

const operation = new GroupedLoadingOperation<User>({
asyncCache,
loaders: [loader],
throwIfUnresolved: true,
})

// @ts-ignore
expect(await operation.asyncCache.get(user1.userId, user1.companyId)).toBeUndefined()
expect(loader.counter).toBe(0)
expect(await operation.get(user1.userId, user1.companyId)).toEqual(user1)
expect(loader.counter).toBe(1)

await setTimeout(100)
expect(loader.counter).toBe(1)
loader.groupValues = userValuesUndefined
// kick off the refresh
expect(await operation.get(user1.userId, user1.companyId)).toEqual(user1)

await setTimeout(100)
await expect(() => operation.get(user1.userId, user1.companyId)).rejects.toThrow(
/Failed to resolve value for key "1", group "1"/
)
await Promise.resolve()
expect(loader.counter).toBe(3)
})
})

describe('get', () => {
it('returns undefined when fails to resolve value', async () => {
const operation = new GroupedLoadingOperation({})
Expand Down
77 changes: 76 additions & 1 deletion test/LoadingOperation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,22 @@ import { ThrowingCache } from './fakes/ThrowingCache'
import { TemporaryThrowingLoader } from './fakes/TemporaryThrowingLoader'
import { DummyCache } from './fakes/DummyCache'
import { DummyLoaderParams, DummyLoaderWithParams } from './fakes/DummyLoaderWithParams'
import { RedisCache } from '../lib/redis'
import Redis from 'ioredis'
import { redisOptions } from './fakes/TestRedisConfig'

const IN_MEMORY_CACHE_CONFIG = { ttlInMsecs: 999 } satisfies InMemoryCacheConfiguration

describe('LoadingOperation', () => {
beforeEach(() => {
let redis: Redis
beforeEach(async () => {
jest.resetAllMocks()
redis = new Redis(redisOptions)
await redis.flushall()
})

afterEach(async () => {
await redis.disconnect()
})

describe('getInMemoryOnly', () => {
Expand Down Expand Up @@ -82,6 +92,71 @@ describe('LoadingOperation', () => {
expect(expirationTimePost).toBeDefined()
expect(expirationTimePost! > expirationTimePre!).toBe(true)
})

it('triggers async background refresh when threshold is set and reached', async () => {
const loader = new CountingLoader('value')
const asyncCache = new RedisCache<string>(redis, {
ttlInMsecs: 150,
ttlLeftBeforeRefreshInMsecs: 75,
})

const operation = new LoadingOperation<string>({
asyncCache,
loaders: [loader],
})
// @ts-ignore
expect(await operation.asyncCache.get('key')).toBeUndefined()
expect(loader.counter).toBe(0)
expect(await operation.get('key')).toBe('value')
expect(loader.counter).toBe(1)
// @ts-ignore
const expirationTimePre = await operation.asyncCache.getExpirationTime('key')

await setTimeout(100)
expect(loader.counter).toBe(1)
// kick off the refresh
expect(await operation.get('key')).toBe('value')
await Promise.resolve()
expect(loader.counter).toBe(2)
// @ts-ignore
const expirationTimePost = await operation.asyncCache.getExpirationTime('key')

expect(await operation.get('key')).toBe('value')
await Promise.resolve()
expect(loader.counter).toBe(2)
expect(expirationTimePre).toBeDefined()
expect(expirationTimePost).toBeDefined()
expect(expirationTimePost! > expirationTimePre!).toBe(true)
})

it('async background refresh errors do not crash app', async () => {
const loader = new CountingLoader('value')
const asyncCache = new RedisCache<string>(redis, {
ttlInMsecs: 150,
ttlLeftBeforeRefreshInMsecs: 75,
})

const operation = new LoadingOperation<string>({
asyncCache,
loaders: [loader],
throwIfUnresolved: true,
})
// @ts-ignore
expect(await operation.asyncCache.get('key')).toBeUndefined()
expect(loader.counter).toBe(0)
expect(await operation.get('key')).toBe('value')
expect(loader.counter).toBe(1)

await setTimeout(100)
expect(loader.counter).toBe(1)
loader.value = undefined
// kick off the refresh
expect(await operation.get('key')).toBe('value')
await setTimeout(100)
await expect(() => operation.get('key')).rejects.toThrow(/Failed to resolve value for key "key"/)
await Promise.resolve()
expect(loader.counter).toBe(3)
})
})

describe('get', () => {
Expand Down
4 changes: 4 additions & 0 deletions test/fakes/CountingCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,8 @@ export class CountingCache implements Cache<string> {
this.value = value ?? undefined
return Promise.resolve(undefined)
}

getExpirationTime(): Promise<number> {
return Promise.resolve(99999)
}
}
8 changes: 8 additions & 0 deletions test/fakes/CountingGroupedCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,12 @@ export class CountingGroupedCache implements GroupedCache<User> {
this.value = value ?? undefined
return Promise.resolve(undefined)
}

getExpirationTimeFromGroup(): Promise<number> {
return Promise.resolve(99999)
}

getExpirationTime(): Promise<number> {
return Promise.resolve(99999)
}
}
2 changes: 1 addition & 1 deletion test/fakes/CountingLoader.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Loader } from '../../lib/types/DataSources'

export class CountingLoader implements Loader<string> {
private readonly value: string | undefined
public value: string | undefined
public counter = 0
name = 'Counting loader'
isCache = false
Expand Down
4 changes: 4 additions & 0 deletions test/fakes/DummyCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,8 @@ export class DummyCache implements Cache<string> {
this.value = value ?? undefined
return Promise.resolve(undefined)
}

getExpirationTime(): Promise<number> {
return Promise.resolve(99999)
}
}
Loading

0 comments on commit 6fa326a

Please sign in to comment.