Compare commits

...
39 changed files with 1406 additions and 709 deletions

No files matched your search

@@ -1,4 +1,10 @@
import { CacheLoadResult, CacheStatus, IEntityGenericCacher } from '@expo/entity';
import {
CacheLoadResult,
CacheStatus,
EntityConfiguration,
IEntityGenericCacher,
} from '@expo/entity';
import invariant from 'invariant';
import LRUCache from 'lru-cache';
// Sentinel value we store in local memory to negatively cache a database miss.
@@ -8,7 +14,10 @@ type LocalMemoryCacheValue<TFields> = Readonly<TFields> | typeof DOES_NOT_EXIST_
export type LocalMemoryCache<TFields> = LRUCache<string, LocalMemoryCacheValue<TFields>>;
export default class GenericLocalMemoryCacher<TFields> implements IEntityGenericCacher<TFields> {
constructor(private readonly localMemoryCache: LocalMemoryCache<TFields>) {}
constructor(
private readonly entityConfiguration: EntityConfiguration<TFields>,
private readonly localMemoryCache: LocalMemoryCache<TFields>
) {}
static createLRUCache<TFields>(
options: { maxSize?: number; ttlSeconds?: number } = {}
@@ -72,7 +81,18 @@ export default class GenericLocalMemoryCacher<TFields> implements IEntityGeneric
}
}
public makeCacheKey(parts: string[]): string {
public makeCacheKey<N extends keyof TFields>(
fieldName: N,
fieldValue: NonNullable<TFields[N]>
): string {
const columnName = this.entityConfiguration.entityToDBFieldsKeyMapping.get(fieldName);
invariant(columnName, `database field mapping missing for ${String(fieldName)}`);
const parts = [
this.entityConfiguration.tableName,
`${this.entityConfiguration.cacheKeyVersion}`,
columnName,
String(fieldValue),
];
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`)
@@ -1,80 +0,0 @@
import { EntityCacheAdapter, CacheLoadResult, EntityConfiguration, mapKeys } from '@expo/entity';
import invariant from 'invariant';
import GenericLocalMemoryCacher, { LocalMemoryCache } from './GenericLocalMemoryCacher';
export default class LocalMemoryCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
private readonly genericLocalMemoryCacher: GenericLocalMemoryCacher<TFields>;
constructor(
entityConfiguration: EntityConfiguration<TFields>,
localMemoryCache: LocalMemoryCache<TFields>
) {
super(entityConfiguration);
this.genericLocalMemoryCacher = new GenericLocalMemoryCacher(localMemoryCache);
}
public async loadManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult<TFields>>> {
const localMemoryCacheKeyToFieldValueMapping = new Map(
fieldValues.map((fieldValue) => [this.makeCacheKey(fieldName, fieldValue), fieldValue])
);
const cacheResults = await this.genericLocalMemoryCacher.loadManyAsync(
Array.from(localMemoryCacheKeyToFieldValueMapping.keys())
);
return mapKeys(cacheResults, (cacheKey) => {
const fieldValue = localMemoryCacheKeyToFieldValueMapping.get(cacheKey);
invariant(
fieldValue !== undefined,
'Unspecified cache key %s returned from generic local memory cacher',
cacheKey
);
return fieldValue;
});
}
public async cacheManyAsync<N extends keyof TFields>(
fieldName: N,
objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>
): Promise<void> {
await this.genericLocalMemoryCacher.cacheManyAsync(
mapKeys(objectMap, (fieldValue) => this.makeCacheKey(fieldName, fieldValue))
);
}
public async cacheDBMissesAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<void> {
await this.genericLocalMemoryCacher.cacheDBMissesAsync(
fieldValues.map((fieldValue) => this.makeCacheKey(fieldName, fieldValue))
);
}
public async invalidateManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<void> {
await this.genericLocalMemoryCacher.invalidateManyAsync(
fieldValues.map((fieldValue) => this.makeCacheKey(fieldName, fieldValue))
);
}
private makeCacheKey<N extends keyof TFields>(
fieldName: N,
fieldValue: NonNullable<TFields[N]>
): string {
const columnName = this.entityConfiguration.entityToDBFieldsKeyMapping.get(fieldName);
invariant(columnName, `database field mapping missing for ${String(fieldName)}`);
const parts = [
this.entityConfiguration.tableName,
`${this.entityConfiguration.cacheKeyVersion}`,
columnName,
String(fieldValue),
];
return this.genericLocalMemoryCacher.makeCacheKey(parts);
}
}
@@ -1,16 +1,16 @@
import {
computeIfAbsent,
EntityCacheAdapter,
IEntityCacheAdapter,
GenericEntityCacheAdapter,
EntityConfiguration,
IEntityCacheAdapterProvider,
} from '@expo/entity';
import GenericLocalMemoryCacher, { LocalMemoryCache } from './GenericLocalMemoryCacher';
import LocalMemoryCacheAdapter from './LocalMemoryCacheAdapter';
export default class LocalMemoryCacheAdapterProvider implements IEntityCacheAdapterProvider {
// local memory cache adapters should be shared/reused across requests
static localMemoryCacheAdapterMap = new Map<string, LocalMemoryCacheAdapter<any>>();
private static localMemoryCacheAdapterMap = new Map<string, GenericEntityCacheAdapter<any>>();
static getNoOpProvider(): IEntityCacheAdapterProvider {
return new LocalMemoryCacheAdapterProvider(<TFields>() =>
@@ -32,13 +32,15 @@ export default class LocalMemoryCacheAdapterProvider implements IEntityCacheAdap
public getCacheAdapter<TFields>(
entityConfiguration: EntityConfiguration<TFields>
): EntityCacheAdapter<TFields> {
): IEntityCacheAdapter<TFields> {
return computeIfAbsent(
LocalMemoryCacheAdapterProvider.localMemoryCacheAdapterMap,
entityConfiguration.tableName,
() => {
const localMemoryCache = this.localMemoryCacheCreator<TFields>();
return new LocalMemoryCacheAdapter(entityConfiguration, localMemoryCache);
return new GenericEntityCacheAdapter(
new GenericLocalMemoryCacher(entityConfiguration, localMemoryCache)
);
}
);
}
@@ -2,7 +2,6 @@ import { CacheStatus, ViewerContext } from '@expo/entity';
import { v4 as uuidv4 } from 'uuid';
import GenericLocalMemoryCacher from '../GenericLocalMemoryCacher';
import LocalMemoryCacheAdapter from '../LocalMemoryCacheAdapter';
import LocalMemoryCacheAdapterProvider from '../LocalMemoryCacheAdapterProvider';
import LocalMemoryTestEntity from '../testfixtures/LocalMemoryTestEntity';
import {
@@ -10,20 +9,18 @@ import {
createNoopLocalMemoryIntegrationTestEntityCompanionProvider,
} from '../testfixtures/createLocalMemoryTestEntityCompanionProvider';
class TestViewerContext extends ViewerContext {}
describe(LocalMemoryCacheAdapter, () => {
describe(GenericLocalMemoryCacher, () => {
beforeEach(async () => {
LocalMemoryCacheAdapterProvider.localMemoryCacheAdapterMap.clear();
LocalMemoryCacheAdapterProvider['localMemoryCacheAdapterMap'].clear();
});
it('has correct caching behavior', async () => {
const viewerContext = new TestViewerContext(createLocalMemoryTestEntityCompanionProvider());
const cacheAdapter = viewerContext.entityCompanionProvider.getCompanionForEntity(
const viewerContext = new ViewerContext(createLocalMemoryTestEntityCompanionProvider());
const genericCacher = viewerContext.entityCompanionProvider.getCompanionForEntity(
LocalMemoryTestEntity,
LocalMemoryTestEntity.getCompanionDefinition()
)['tableDataCoordinator']['cacheAdapter'];
const cacheKeyMaker = cacheAdapter['makeCacheKey'].bind(cacheAdapter);
)['tableDataCoordinator']['cacheAdapter']['genericCacher'];
const cacheKeyMaker = genericCacher['makeCacheKey'].bind(genericCacher);
const date = new Date();
const entity1Created = await LocalMemoryTestEntity.creator(viewerContext)
@@ -36,10 +33,11 @@ describe(LocalMemoryCacheAdapter, () => {
.enforcing()
.loadByIDAsync(entity1Created.getID());
const entitySpecificGenericCacher =
LocalMemoryCacheAdapterProvider.localMemoryCacheAdapterMap.get(
LocalMemoryTestEntity.getCompanionDefinition().entityConfiguration.tableName
)!['genericLocalMemoryCacher'];
const entitySpecificGenericCacher = LocalMemoryCacheAdapterProvider[
'localMemoryCacheAdapterMap'
].get(LocalMemoryTestEntity.getCompanionDefinition().entityConfiguration.tableName)![
'genericCacher'
];
const cachedResult = await entitySpecificGenericCacher.loadManyAsync([
cacheKeyMaker('id', entity1.getID()),
]);
@@ -88,7 +86,7 @@ describe(LocalMemoryCacheAdapter, () => {
GenericLocalMemoryCacher.prototype as unknown as any,
'loadManyAsync'
);
const viewerContext = new TestViewerContext(createLocalMemoryTestEntityCompanionProvider());
const viewerContext = new ViewerContext(createLocalMemoryTestEntityCompanionProvider());
const date = new Date();
const entity1Created = await LocalMemoryTestEntity.creator(viewerContext)
@@ -102,16 +100,16 @@ describe(LocalMemoryCacheAdapter, () => {
.loadByIDAsync(entity1Created.getID());
// load entity with a different request
const viewerContext2 = new TestViewerContext(createLocalMemoryTestEntityCompanionProvider());
const viewerContext2 = new ViewerContext(createLocalMemoryTestEntityCompanionProvider());
const entity1WithVc2 = await LocalMemoryTestEntity.loader(viewerContext2)
.enforcing()
.loadByIDAsync(entity1Created.getID());
const cacheAdapter = viewerContext.entityCompanionProvider.getCompanionForEntity(
const genericCacher = viewerContext.entityCompanionProvider.getCompanionForEntity(
LocalMemoryTestEntity,
LocalMemoryTestEntity.getCompanionDefinition()
)['tableDataCoordinator']['cacheAdapter'];
const cacheKeyMaker = cacheAdapter['makeCacheKey'].bind(cacheAdapter);
)['tableDataCoordinator']['cacheAdapter']['genericCacher'];
const cacheKeyMaker = genericCacher['makeCacheKey'].bind(genericCacher);
expect(entity1WithVc2.getAllFields()).toMatchObject({
id: entity1WithVc2.getID(),
name: 'blah',
@@ -124,14 +122,14 @@ describe(LocalMemoryCacheAdapter, () => {
});
it('respects the parameters of a noop cache', async () => {
const viewerContext = new TestViewerContext(
const viewerContext = new ViewerContext(
createNoopLocalMemoryIntegrationTestEntityCompanionProvider()
);
const cacheAdapter = viewerContext.entityCompanionProvider.getCompanionForEntity(
const genericCacher = viewerContext.entityCompanionProvider.getCompanionForEntity(
LocalMemoryTestEntity,
LocalMemoryTestEntity.getCompanionDefinition()
)['tableDataCoordinator']['cacheAdapter'];
const cacheKeyMaker = cacheAdapter['makeCacheKey'].bind(cacheAdapter);
)['tableDataCoordinator']['cacheAdapter']['genericCacher'];
const cacheKeyMaker = genericCacher['makeCacheKey'].bind(genericCacher);
const date = new Date();
const entity1Created = await LocalMemoryTestEntity.creator(viewerContext)
@@ -144,10 +142,11 @@ describe(LocalMemoryCacheAdapter, () => {
.enforcing()
.loadByIDAsync(entity1Created.getID());
const entitySpecificGenericCacher =
LocalMemoryCacheAdapterProvider.localMemoryCacheAdapterMap.get(
LocalMemoryTestEntity.getCompanionDefinition().entityConfiguration.tableName
)!['genericLocalMemoryCacher'];
const entitySpecificGenericCacher = LocalMemoryCacheAdapterProvider[
'localMemoryCacheAdapterMap'
].get(LocalMemoryTestEntity.getCompanionDefinition().entityConfiguration.tableName)![
'genericCacher'
];
const cachedResult = await entitySpecificGenericCacher.loadManyAsync([
cacheKeyMaker('id', entity1.getID()),
]);
@@ -1,4 +1,27 @@
import GenericLocalMemoryCacher from '../GenericLocalMemoryCacher';
import {
CacheStatus,
UUIDField,
EntityConfiguration,
GenericEntityCacheAdapter,
} from '@expo/entity';
import GenericLocalMemoryCacher, {
DOES_NOT_EXIST_LOCAL_MEMORY_CACHE,
} from '../GenericLocalMemoryCacher';
type BlahFields = {
id: string;
};
const entityConfiguration = new EntityConfiguration<BlahFields>({
idField: 'id',
tableName: 'blah',
schema: {
id: new UUIDField({ columnName: 'id', cache: true }),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'local-memory',
});
describe(GenericLocalMemoryCacher, () => {
describe(GenericLocalMemoryCacher.createLRUCache, () => {
@@ -26,3 +49,94 @@ describe(GenericLocalMemoryCacher, () => {
});
});
});
describe('Use within GenericEntityCacheAdapter', () => {
describe('loadManyAsync', () => {
it('returns appropriate cache results', async () => {
const cacheAdapter = new GenericEntityCacheAdapter(
new GenericLocalMemoryCacher(entityConfiguration, GenericLocalMemoryCacher.createLRUCache())
);
const cacheHits = new Map<string, Readonly<BlahFields>>([['test-id-1', { id: 'test-id-1' }]]);
await cacheAdapter.cacheManyAsync('id', cacheHits);
await cacheAdapter.cacheDBMissesAsync('id', ['test-id-2']);
const results = await cacheAdapter.loadManyAsync('id', [
'test-id-1',
'test-id-2',
'test-id-3',
]);
expect(results.get('test-id-1')).toMatchObject({
status: CacheStatus.HIT,
item: { id: 'test-id-1' },
});
expect(results.get('test-id-2')).toMatchObject({ status: CacheStatus.NEGATIVE });
expect(results.get('test-id-3')).toMatchObject({ status: CacheStatus.MISS });
expect(results.size).toBe(3);
});
it('returns empty map when passed empty array of fieldValues', async () => {
const cacheAdapter = new GenericEntityCacheAdapter(
new GenericLocalMemoryCacher(entityConfiguration, GenericLocalMemoryCacher.createLRUCache())
);
const results = await cacheAdapter.loadManyAsync('id', []);
expect(results).toEqual(new Map());
});
});
describe('cacheManyAsync', () => {
it('correctly caches all objects', async () => {
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({});
const localMemoryCacher = new GenericLocalMemoryCacher(entityConfiguration, localMemoryCache);
const cacheAdapter = new GenericEntityCacheAdapter(localMemoryCacher);
await cacheAdapter.cacheManyAsync('id', new Map([['test-id-1', { id: 'test-id-1' }]]));
const cacheKey = localMemoryCacher['makeCacheKey']('id', 'test-id-1');
expect(localMemoryCache.get(cacheKey)).toMatchObject({
id: 'test-id-1',
});
});
});
describe('cacheDBMissesAsync', () => {
it('correctly caches misses', async () => {
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({});
const localMemoryCacher = new GenericLocalMemoryCacher(entityConfiguration, localMemoryCache);
const cacheAdapter = new GenericEntityCacheAdapter(localMemoryCacher);
await cacheAdapter.cacheDBMissesAsync('id', ['test-id-1']);
const cacheKey = localMemoryCacher['makeCacheKey']('id', 'test-id-1');
expect(localMemoryCache.get(cacheKey)).toEqual(DOES_NOT_EXIST_LOCAL_MEMORY_CACHE);
});
});
describe('invalidateManyAsync', () => {
it('invalidates correctly', async () => {
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({});
const cacheAdapter = new GenericEntityCacheAdapter(
new GenericLocalMemoryCacher(entityConfiguration, localMemoryCache)
);
await cacheAdapter.cacheManyAsync('id', new Map([['test-id-1', { id: 'test-id-1' }]]));
await cacheAdapter.cacheDBMissesAsync('id', ['test-id-2']);
await cacheAdapter.invalidateManyAsync('id', ['test-id-1', 'test-id-2']);
const results = await cacheAdapter.loadManyAsync('id', ['test-id-1', 'test-id-2']);
expect(results.get('test-id-1')).toMatchObject({ status: CacheStatus.MISS });
expect(results.get('test-id-2')).toMatchObject({ status: CacheStatus.MISS });
});
it('returns when passed empty array of fieldValues', async () => {
const cacheAdapter = new GenericEntityCacheAdapter(
new GenericLocalMemoryCacher(
entityConfiguration,
GenericLocalMemoryCacher.createLRUCache<BlahFields>({})
)
);
await cacheAdapter.invalidateManyAsync('id', []);
});
});
});
@@ -1,107 +0,0 @@
import { CacheStatus, UUIDField, EntityConfiguration } from '@expo/entity';
import GenericLocalMemoryCacher, {
DOES_NOT_EXIST_LOCAL_MEMORY_CACHE,
} from '../GenericLocalMemoryCacher';
import LocalMemoryCacheAdapter from '../LocalMemoryCacheAdapter';
type BlahFields = {
id: string;
};
const entityConfiguration = new EntityConfiguration<BlahFields>({
idField: 'id',
tableName: 'blah',
schema: {
id: new UUIDField({ columnName: 'id', cache: true }),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'local-memory',
});
describe(LocalMemoryCacheAdapter, () => {
describe('loadManyAsync', () => {
it('returns appropriate cache results', async () => {
const cacheAdapter = new LocalMemoryCacheAdapter(
entityConfiguration,
GenericLocalMemoryCacher.createLRUCache()
);
const cacheHits = new Map<string, Readonly<BlahFields>>([['test-id-1', { id: 'test-id-1' }]]);
await cacheAdapter.cacheManyAsync('id', cacheHits);
await cacheAdapter.cacheDBMissesAsync('id', ['test-id-2']);
const results = await cacheAdapter.loadManyAsync('id', [
'test-id-1',
'test-id-2',
'test-id-3',
]);
expect(results.get('test-id-1')).toMatchObject({
status: CacheStatus.HIT,
item: { id: 'test-id-1' },
});
expect(results.get('test-id-2')).toMatchObject({ status: CacheStatus.NEGATIVE });
expect(results.get('test-id-3')).toMatchObject({ status: CacheStatus.MISS });
expect(results.size).toBe(3);
});
it('returns empty map when passed empty array of fieldValues', async () => {
const cacheAdapter = new LocalMemoryCacheAdapter(
entityConfiguration,
GenericLocalMemoryCacher.createLRUCache()
);
const results = await cacheAdapter.loadManyAsync('id', []);
expect(results).toEqual(new Map());
});
});
describe('cacheManyAsync', () => {
it('correctly caches all objects', async () => {
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({});
const cacheAdapter = new LocalMemoryCacheAdapter(entityConfiguration, localMemoryCache);
await cacheAdapter.cacheManyAsync('id', new Map([['test-id-1', { id: 'test-id-1' }]]));
const cacheKey = cacheAdapter['makeCacheKey']('id', 'test-id-1');
expect(localMemoryCache.get(cacheKey)).toMatchObject({
id: 'test-id-1',
});
});
});
describe('cacheDBMissesAsync', () => {
it('correctly caches misses', async () => {
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({});
const cacheAdapter = new LocalMemoryCacheAdapter(entityConfiguration, localMemoryCache);
await cacheAdapter.cacheDBMissesAsync('id', ['test-id-1']);
const cacheKey = cacheAdapter['makeCacheKey']('id', 'test-id-1');
expect(localMemoryCache.get(cacheKey)).toEqual(DOES_NOT_EXIST_LOCAL_MEMORY_CACHE);
});
});
describe('invalidateManyAsync', () => {
it('invalidates correctly', async () => {
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({});
const cacheAdapter = new LocalMemoryCacheAdapter(entityConfiguration, localMemoryCache);
await cacheAdapter.cacheManyAsync('id', new Map([['test-id-1', { id: 'test-id-1' }]]));
await cacheAdapter.cacheDBMissesAsync('id', ['test-id-2']);
await cacheAdapter.invalidateManyAsync('id', ['test-id-1', 'test-id-2']);
const results = await cacheAdapter.loadManyAsync('id', ['test-id-1', 'test-id-2']);
expect(results.get('test-id-1')).toMatchObject({ status: CacheStatus.MISS });
expect(results.get('test-id-2')).toMatchObject({ status: CacheStatus.MISS });
});
it('returns when passed empty array of fieldValues', async () => {
const cacheAdapter = new LocalMemoryCacheAdapter(
entityConfiguration,
GenericLocalMemoryCacher.createLRUCache<BlahFields>({})
);
await cacheAdapter.invalidateManyAsync('id', []);
});
});
});
@@ -6,5 +6,4 @@
export { default as GenericLocalMemoryCacher } from './GenericLocalMemoryCacher';
export * from './GenericLocalMemoryCacher';
export { default as LocalMemoryCacheAdapter } from './LocalMemoryCacheAdapter';
export { default as LocalMemoryCacheAdapterProvider } from './LocalMemoryCacheAdapterProvider';
@@ -1,124 +1,62 @@
import {
CacheLoadResult,
CacheStatus,
EntityConfiguration,
transformCacheObjectToFields,
transformFieldsToCacheObject,
IEntityGenericCacher,
} from '@expo/entity';
import { Redis } from 'ioredis';
import { CacheLoadResult, EntityConfiguration, IEntityGenericCacher } from '@expo/entity';
import invariant from 'invariant';
import Redis from 'ioredis';
import { redisTransformerMap } from './RedisCommon';
import wrapNativeRedisCallAsync from './errors/wrapNativeRedisCallAsync';
import ShardedGenericRedisCacher, {
ShardedRedisCacheAdapterContext,
} from './ShardedGenericRedisCacher';
// Sentinel value we store in Redis to negatively cache a database miss.
// The sentinel value is distinct from any (positively) cached value.
const DOES_NOT_EXIST_REDIS = '';
export interface GenericRedisCacheContext {
export type RedisCacheAdapterContext = Pick<
ShardedRedisCacheAdapterContext,
'cacheKeyPrefix' | 'cacheKeyVersion' | 'makeKeyFn' | 'ttlSecondsNegative' | 'ttlSecondsPositive'
> & {
/**
* Instance of ioredis.Redis
*/
redisClient: Redis;
/**
* TTL for caching database hits. Successive entity loads within this TTL
* will be read from cache (unless invalidated).
*/
ttlSecondsPositive: number;
/**
* TTL for negatively caching database misses. Successive entity loads within
* this TTL will be assumed not present in the database (unless invalidated).
*/
ttlSecondsNegative: number;
}
};
export default class GenericRedisCacher<TFields> implements IEntityGenericCacher<TFields> {
private readonly shardedGenericRedisCacher: ShardedGenericRedisCacher<TFields>;
constructor(
private readonly context: GenericRedisCacheContext,
private readonly entityConfiguration: EntityConfiguration<TFields>
) {}
context: RedisCacheAdapterContext,
entityConfiguration: EntityConfiguration<TFields>
) {
this.shardedGenericRedisCacher = new ShardedGenericRedisCacher(
{
...context,
getShardGroupForKeysFn: (keys) => new Map(keys.map((k) => [k, 1])),
getRedisInstanceForShardGroup: (shardGroup) => {
invariant(shardGroup === 1, 'invalid shard group');
return context.redisClient;
},
shardingSchemeVersion: 1,
},
entityConfiguration
);
}
public async loadManyAsync(
keys: readonly string[]
): Promise<ReadonlyMap<string, CacheLoadResult<TFields>>> {
if (keys.length === 0) {
return new Map();
}
const redisResults = await wrapNativeRedisCallAsync(() =>
this.context.redisClient.mget(...keys)
);
const results = new Map<string, CacheLoadResult<TFields>>();
for (let i = 0; i < keys.length; i++) {
const key = keys[i]!;
const redisResult = redisResults[i];
if (redisResult === DOES_NOT_EXIST_REDIS) {
results.set(key, {
status: CacheStatus.NEGATIVE,
});
} else if (redisResult) {
results.set(key, {
status: CacheStatus.HIT,
item: transformCacheObjectToFields(
this.entityConfiguration,
redisTransformerMap,
JSON.parse(redisResult)
),
});
} else {
results.set(key, {
status: CacheStatus.MISS,
});
}
}
return results;
return this.shardedGenericRedisCacher.loadManyAsync(keys);
}
public async cacheManyAsync(objectMap: ReadonlyMap<string, Readonly<TFields>>): Promise<void> {
if (objectMap.size === 0) {
return;
}
let redisTransaction = this.context.redisClient.multi();
objectMap.forEach((object, key) => {
redisTransaction = redisTransaction.set(
key,
JSON.stringify(
transformFieldsToCacheObject(this.entityConfiguration, redisTransformerMap, object)
),
'EX',
this.context.ttlSecondsPositive
);
});
await wrapNativeRedisCallAsync(() => redisTransaction.exec());
return this.shardedGenericRedisCacher.cacheManyAsync(objectMap);
}
public async cacheDBMissesAsync(keys: readonly string[]): Promise<void> {
if (keys.length === 0) {
return;
}
let redisTransaction = this.context.redisClient.multi();
keys.forEach((key) => {
redisTransaction = redisTransaction.set(
key,
DOES_NOT_EXIST_REDIS,
'EX',
this.context.ttlSecondsNegative
);
});
await wrapNativeRedisCallAsync(() => redisTransaction.exec());
return this.shardedGenericRedisCacher.cacheDBMissesAsync(keys);
}
public async invalidateManyAsync(keys: readonly string[]): Promise<void> {
if (keys.length === 0) {
return;
}
return this.shardedGenericRedisCacher.invalidateManyAsync(keys);
}
await wrapNativeRedisCallAsync(() => this.context.redisClient.del(...keys));
public makeCacheKey<N extends keyof TFields>(
fieldName: N,
fieldValue: NonNullable<TFields[N]>
): string {
return this.shardedGenericRedisCacher.makeCacheKey(fieldName, fieldValue);
}
}
@@ -1,125 +0,0 @@
import { EntityCacheAdapter, EntityConfiguration, CacheLoadResult, mapKeys } from '@expo/entity';
import invariant from 'invariant';
import type { Redis } from 'ioredis';
import GenericRedisCacher from './GenericRedisCacher';
export interface RedisCacheAdapterContext {
/**
* Instance of ioredis.Redis
*/
redisClient: Redis;
/**
* Create a key string for key parts (cache key prefix, versions, entity name, etc).
* Most commonly a simple `parts.join(':')`. See integration test for example.
*/
makeKeyFn: (...parts: string[]) => string;
/**
* Global cache version for the entity framework. Bumping this version will
* invalidate the cache for all entities at once.
*/
cacheKeyVersion: number;
/**
* Prefix prepended to all entity cache keys. Useful for adding a short, human-readable
* distintion for entity keys, e.g. `ent-`
*/
cacheKeyPrefix: string;
/**
* TTL for caching database hits. Successive entity loads within this TTL
* will be read from cache (unless invalidated).
*/
ttlSecondsPositive: number;
/**
* TTL for negatively caching database misses. Successive entity loads within
* this TTL will be assumed not present in the database (unless invalidated).
*/
ttlSecondsNegative: number;
}
export default class RedisCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
private readonly genericRedisCacher: GenericRedisCacher<TFields>;
constructor(
entityConfiguration: EntityConfiguration<TFields>,
private readonly context: RedisCacheAdapterContext
) {
super(entityConfiguration);
this.genericRedisCacher = new GenericRedisCacher(
{
redisClient: context.redisClient,
ttlSecondsNegative: context.ttlSecondsNegative,
ttlSecondsPositive: context.ttlSecondsPositive,
},
entityConfiguration
);
}
public async loadManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult<TFields>>> {
const redisCacheKeyToFieldValueMapping = new Map(
fieldValues.map((fieldValue) => [this.makeCacheKey(fieldName, fieldValue), fieldValue])
);
const cacheResults = await this.genericRedisCacher.loadManyAsync(
Array.from(redisCacheKeyToFieldValueMapping.keys())
);
return mapKeys(cacheResults, (redisCacheKey) => {
const fieldValue = redisCacheKeyToFieldValueMapping.get(redisCacheKey);
invariant(
fieldValue !== undefined,
'Unspecified cache key %s returned from generic Redis cacher',
redisCacheKey
);
return fieldValue;
});
}
public async cacheManyAsync<N extends keyof TFields>(
fieldName: N,
objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>
): Promise<void> {
await this.genericRedisCacher.cacheManyAsync(
mapKeys(objectMap, (fieldValue) => this.makeCacheKey(fieldName, fieldValue))
);
}
public async cacheDBMissesAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<void> {
await this.genericRedisCacher.cacheDBMissesAsync(
fieldValues.map((fieldValue) => this.makeCacheKey(fieldName, fieldValue))
);
}
public async invalidateManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<void> {
await this.genericRedisCacher.invalidateManyAsync(
fieldValues.map((fieldValue) => this.makeCacheKey(fieldName, fieldValue))
);
}
private makeCacheKey<N extends keyof TFields>(
fieldName: N,
fieldValue: NonNullable<TFields[N]>
): string {
const columnName = this.entityConfiguration.entityToDBFieldsKeyMapping.get(fieldName);
invariant(columnName, `database field mapping missing for ${String(fieldName)}`);
return this.context.makeKeyFn(
this.context.cacheKeyPrefix,
this.entityConfiguration.tableName,
`v2.${this.entityConfiguration.cacheKeyVersion}`,
columnName,
String(fieldValue)
);
}
}
@@ -1,13 +1,19 @@
import { IEntityCacheAdapterProvider, EntityConfiguration, EntityCacheAdapter } from '@expo/entity';
import {
IEntityCacheAdapterProvider,
EntityConfiguration,
IEntityCacheAdapter,
GenericEntityCacheAdapter,
} from '@expo/entity';
import RedisCacheAdapter, { RedisCacheAdapterContext } from './RedisCacheAdapter';
import GenericRedisCacher, { RedisCacheAdapterContext } from './GenericRedisCacher';
export default class RedisCacheAdapterProvider implements IEntityCacheAdapterProvider {
constructor(private readonly context: RedisCacheAdapterContext) {}
getCacheAdapter<TFields>(
entityConfiguration: EntityConfiguration<TFields>
): EntityCacheAdapter<TFields> {
return new RedisCacheAdapter(entityConfiguration, this.context);
): IEntityCacheAdapter<TFields> {
const genericCacher = new GenericRedisCacher(this.context, entityConfiguration);
return new GenericEntityCacheAdapter(genericCacher);
}
}
@@ -0,0 +1,222 @@
import {
CacheLoadResult,
CacheStatus,
EntityConfiguration,
transformCacheObjectToFields,
transformFieldsToCacheObject,
IEntityGenericCacher,
mapMapAsync,
filterMap,
computeIfAbsent,
} from '@expo/entity';
import invariant from 'invariant';
import { Redis } from 'ioredis';
import { redisTransformerMap } from './RedisCommon';
import wrapNativeRedisCallAsync from './errors/wrapNativeRedisCallAsync';
// Sentinel value we store in Redis to negatively cache a database miss.
// The sentinel value is distinct from any (positively) cached value.
const DOES_NOT_EXIST_REDIS = '';
export type ShardGroup = number;
export interface ShardedRedisCacheAdapterContext {
/**
* Shard computation function.
*/
getShardGroupForKeysFn: (keys: readonly string[]) => ReadonlyMap<string, ShardGroup>;
/**
* Get Redis instance for shard group.
*/
getRedisInstanceForShardGroup: (shardGroup: ShardGroup) => Redis;
/**
* Sharding scheme version. Increment when sharding scheme is updated.
*/
shardingSchemeVersion: number;
/**
* Create a key string for key parts (cache key prefix, versions, entity name, etc).
* Most commonly a simple `parts.join(':')`. See integration test for example.
*/
makeKeyFn: (...parts: string[]) => string;
/**
* Global cache version for the entity framework. Bumping this version will
* invalidate the cache for all entities at once.
*/
cacheKeyVersion: number;
/**
* Prefix prepended to all entity cache keys. Useful for adding a short, human-readable
* distintion for entity keys, e.g. `ent-`
*/
cacheKeyPrefix: string;
/**
* TTL for caching database hits. Successive entity loads within this TTL
* will be read from cache (unless invalidated).
*/
ttlSecondsPositive: number;
/**
* TTL for negatively caching database misses. Successive entity loads within
* this TTL will be assumed not present in the database (unless invalidated).
*/
ttlSecondsNegative: number;
}
export default class ShardedGenericRedisCacher<TFields> implements IEntityGenericCacher<TFields> {
constructor(
private readonly context: ShardedRedisCacheAdapterContext,
private readonly entityConfiguration: EntityConfiguration<TFields>
) {}
private getRedisClientsForKeys(
keys: readonly string[]
): Map<ShardGroup, { keys: string[]; redisClient: Redis }> {
const shardGroupsForKeys = this.context.getShardGroupForKeysFn(keys);
const redisClientsForKeys = new Map<ShardGroup, { keys: string[]; redisClient: Redis }>();
for (const [key, shardGroup] of shardGroupsForKeys) {
const entry = computeIfAbsent(redisClientsForKeys, shardGroup, (currShardGroup) => ({
keys: [],
redisClient: this.context.getRedisInstanceForShardGroup(currShardGroup),
}));
entry.keys.push(key);
}
return redisClientsForKeys;
}
public async loadManyAsync(
keys: readonly string[]
): Promise<ReadonlyMap<string, CacheLoadResult<TFields>>> {
if (keys.length === 0) {
return new Map();
}
const redisClientsForKeys = this.getRedisClientsForKeys(keys);
const allShardResults = (
await mapMapAsync(redisClientsForKeys, async ({ redisClient, keys }) => {
return await this.getCacheResultsForKeysAsync(redisClient, keys);
})
).values();
const results = new Map<string, CacheLoadResult<TFields>>();
for (const shardResults of allShardResults) {
for (const [shardResultKey, shardResult] of shardResults) {
results.set(shardResultKey, shardResult);
}
}
return results;
}
private async getCacheResultsForKeysAsync(
redisClient: Redis,
keys: readonly string[]
): Promise<ReadonlyMap<string, CacheLoadResult<TFields>>> {
const redisResults = await wrapNativeRedisCallAsync(() => redisClient.mget(...keys));
const results = new Map<string, CacheLoadResult<TFields>>();
for (let i = 0; i < keys.length; i++) {
const key = keys[i]!;
const redisResult = redisResults[i];
if (redisResult === DOES_NOT_EXIST_REDIS) {
results.set(key, {
status: CacheStatus.NEGATIVE,
});
} else if (redisResult) {
results.set(key, {
status: CacheStatus.HIT,
item: transformCacheObjectToFields(
this.entityConfiguration,
redisTransformerMap,
JSON.parse(redisResult)
),
});
} else {
results.set(key, {
status: CacheStatus.MISS,
});
}
}
return results;
}
public async cacheManyAsync(objectMap: ReadonlyMap<string, Readonly<TFields>>): Promise<void> {
if (objectMap.size === 0) {
return;
}
const keys = [...objectMap.keys()];
const redisClientsForKeys = this.getRedisClientsForKeys(keys);
await mapMapAsync(redisClientsForKeys, async ({ redisClient, keys }) => {
const keySet = new Set(keys);
const objectMapForKeys = filterMap(objectMap, (_, k) => keySet.has(k));
let redisTransaction = redisClient.multi();
objectMapForKeys.forEach((object, key) => {
redisTransaction = redisTransaction.set(
key,
JSON.stringify(
transformFieldsToCacheObject(this.entityConfiguration, redisTransformerMap, object)
),
'EX',
this.context.ttlSecondsPositive
);
});
await wrapNativeRedisCallAsync(() => redisTransaction.exec());
});
}
public async cacheDBMissesAsync(allKeys: readonly string[]): Promise<void> {
if (allKeys.length === 0) {
return;
}
const redisClientsForKeys = this.getRedisClientsForKeys(allKeys);
await mapMapAsync(redisClientsForKeys, async ({ redisClient, keys }) => {
let redisTransaction = redisClient.multi();
keys.forEach((key) => {
redisTransaction = redisTransaction.set(
key,
DOES_NOT_EXIST_REDIS,
'EX',
this.context.ttlSecondsNegative
);
});
await wrapNativeRedisCallAsync(() => redisTransaction.exec());
});
}
public async invalidateManyAsync(allKeys: readonly string[]): Promise<void> {
if (allKeys.length === 0) {
return;
}
const redisClientsForKeys = this.getRedisClientsForKeys(allKeys);
await mapMapAsync(redisClientsForKeys, async ({ redisClient, keys }) => {
await wrapNativeRedisCallAsync(() => redisClient.del(...keys));
});
}
public makeCacheKey<N extends keyof TFields>(
fieldName: N,
fieldValue: NonNullable<TFields[N]>
): string {
const columnName = this.entityConfiguration.entityToDBFieldsKeyMapping.get(fieldName);
invariant(columnName, `database field mapping missing for ${String(fieldName)}`);
return this.context.makeKeyFn(
this.context.cacheKeyPrefix,
`v${this.context.shardingSchemeVersion}`,
this.entityConfiguration.tableName,
`v2.${this.entityConfiguration.cacheKeyVersion}`,
columnName,
String(fieldValue)
);
}
}
@@ -0,0 +1,21 @@
import {
IEntityCacheAdapterProvider,
EntityConfiguration,
IEntityCacheAdapter,
GenericEntityCacheAdapter,
} from '@expo/entity';
import ShardedGenericRedisCacher, {
ShardedRedisCacheAdapterContext,
} from './ShardedGenericRedisCacher';
export default class ShardedRedisCacheAdapterProvider implements IEntityCacheAdapterProvider {
constructor(private readonly context: ShardedRedisCacheAdapterContext) {}
getCacheAdapter<TFields>(
entityConfiguration: EntityConfiguration<TFields>
): IEntityCacheAdapter<TFields> {
const genericCacher = new ShardedGenericRedisCacher(this.context, entityConfiguration);
return new GenericEntityCacheAdapter(genericCacher);
}
}
@@ -4,13 +4,13 @@ import Redis from 'ioredis';
import { URL } from 'url';
import { v4 as uuidv4 } from 'uuid';
import RedisCacheAdapter, { RedisCacheAdapterContext } from '../RedisCacheAdapter';
import GenericRedisCacher, { RedisCacheAdapterContext } from '../GenericRedisCacher';
import RedisTestEntity from '../testfixtures/RedisTestEntity';
import { createRedisIntegrationTestEntityCompanionProvider } from '../testfixtures/createRedisIntegrationTestEntityCompanionProvider';
class TestViewerContext extends ViewerContext {}
describe(RedisCacheAdapter, () => {
describe(GenericRedisCacher, () => {
let redisCacheAdapterContext: RedisCacheAdapterContext;
beforeAll(() => {
@@ -41,11 +41,11 @@ describe(RedisCacheAdapter, () => {
const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
);
const cacheAdapter = viewerContext.entityCompanionProvider.getCompanionForEntity(
const genericCacher = viewerContext.entityCompanionProvider.getCompanionForEntity(
RedisTestEntity,
RedisTestEntity.getCompanionDefinition()
)['tableDataCoordinator']['cacheAdapter'];
const cacheKeyMaker = cacheAdapter['makeCacheKey'].bind(cacheAdapter);
)['tableDataCoordinator']['cacheAdapter']['genericCacher'];
const cacheKeyMaker = genericCacher['makeCacheKey'].bind(genericCacher);
const entity1Created = await RedisTestEntity.creator(viewerContext)
.setField('name', 'blah')
@@ -2,8 +2,7 @@ import { CacheStatus, ViewerContext } from '@expo/entity';
import Redis from 'ioredis';
import { URL } from 'url';
import GenericRedisCacher from '../GenericRedisCacher';
import { RedisCacheAdapterContext } from '../RedisCacheAdapter';
import GenericRedisCacher, { RedisCacheAdapterContext } from '../GenericRedisCacher';
import RedisTestEntity, {
redisTestEntityConfiguration,
RedisTestEntityFields,
@@ -44,11 +43,7 @@ describe(GenericRedisCacher, () => {
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
);
const genericRedisCacher = new GenericRedisCacher(
{
redisClient: redisCacheAdapterContext.redisClient,
ttlSecondsNegative: redisCacheAdapterContext.ttlSecondsNegative,
ttlSecondsPositive: redisCacheAdapterContext.ttlSecondsPositive,
},
redisCacheAdapterContext,
redisTestEntityConfiguration
);
const date = new Date();
@@ -79,11 +74,7 @@ describe(GenericRedisCacher, () => {
});
it('has correct negative caching behaviour', async () => {
const genericRedisCacher = new GenericRedisCacher(
{
redisClient: redisCacheAdapterContext.redisClient,
ttlSecondsNegative: redisCacheAdapterContext.ttlSecondsNegative,
ttlSecondsPositive: redisCacheAdapterContext.ttlSecondsPositive,
},
redisCacheAdapterContext,
redisTestEntityConfiguration
);
@@ -98,11 +89,7 @@ describe(GenericRedisCacher, () => {
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
);
const genericRedisCacher = new GenericRedisCacher(
{
redisClient: redisCacheAdapterContext.redisClient,
ttlSecondsNegative: redisCacheAdapterContext.ttlSecondsNegative,
ttlSecondsPositive: redisCacheAdapterContext.ttlSecondsPositive,
},
redisCacheAdapterContext,
redisTestEntityConfiguration
);
const date = new Date();
@@ -0,0 +1,147 @@
import { ViewerContext } from '@expo/entity';
import Redis from 'ioredis';
import nullthrows from 'nullthrows';
import { URL } from 'url';
import ShardedGenericRedisCacher, {
ShardedRedisCacheAdapterContext,
} from '../ShardedGenericRedisCacher';
import RedisTestEntity from '../testfixtures/RedisTestEntity';
import { createShardedRedisIntegrationTestEntityCompanionProvider } from '../testfixtures/createShardedRedisIntegrationTestEntityCompanionProvider';
const ids = {
entity1: '7345999c-b97d-47af-873c-bb1e9c1876f3', // shard 0
nonexistent: 'c207c238-70b5-41bb-8f33-130a57a6f6c5', // shard 1
};
function getShardForKey(key: string): 0 | 1 {
if (key.includes(ids.entity1)) {
return 0;
}
if (key.includes(ids.nonexistent)) {
return 1;
}
// entity1
if (key.includes('name:blah')) {
return 1; // put other field cache for entity1 on different server than the id field cached value
}
throw Error('unknown id encountered: ' + key);
}
describe(ShardedGenericRedisCacher, () => {
let redis1: Redis;
let redis2: Redis;
let redisCacheAdapterContext: ShardedRedisCacheAdapterContext;
beforeAll(() => {
redis1 = new Redis(new URL(process.env['REDIS_URL']!).toString());
redis2 = new Redis(new URL(process.env['REDIS_URL_2']!).toString());
redisCacheAdapterContext = {
getShardGroupForKeysFn: (keys) => new Map(keys.map((k) => [k, getShardForKey(k)])),
getRedisInstanceForShardGroup: (shardGroup) => (shardGroup === 0 ? redis1 : redis2),
shardingSchemeVersion: 1,
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`)
);
return escapedParts.join(delimiter);
},
cacheKeyPrefix: 'test-',
cacheKeyVersion: 1,
ttlSecondsPositive: 86400, // 1 day
ttlSecondsNegative: 600, // 10 minutes
};
});
beforeEach(async () => {
await Promise.all([redis1?.flushdb(), redis2?.flushdb()]);
});
afterAll(async () => {
redis1.disconnect();
redis2.disconnect();
});
it('has correct caching behavior', async () => {
const viewerContext = new ViewerContext(
createShardedRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
);
const genericCacher = viewerContext.entityCompanionProvider.getCompanionForEntity(
RedisTestEntity,
RedisTestEntity.getCompanionDefinition()
)['tableDataCoordinator']['cacheAdapter']['genericCacher'];
const cacheKeyMaker = genericCacher['makeCacheKey'].bind(genericCacher);
const entity1Created = await RedisTestEntity.creator(viewerContext)
.setField('id', ids.entity1)
.setField('name', 'blah')
.enforceCreateAsync();
// loading an entity should put it in cache. load by both cached fields (which are on separate redis instances).
const cacheKeyEntity1 = cacheKeyMaker('id', entity1Created.getID());
const cacheKeyEntity1ShardGroup = nullthrows(
redisCacheAdapterContext.getShardGroupForKeysFn([cacheKeyEntity1]).get(cacheKeyEntity1)
);
const cacheRedisClientForEntity =
redisCacheAdapterContext.getRedisInstanceForShardGroup(cacheKeyEntity1ShardGroup);
const entity1 = await RedisTestEntity.loader(viewerContext)
.enforcing()
.loadByIDAsync(entity1Created.getID());
const cachedJSON = await cacheRedisClientForEntity.get(cacheKeyEntity1);
const cachedValue = JSON.parse(cachedJSON!);
expect(cachedValue).toMatchObject({
id: entity1.getID(),
name: 'blah',
});
const cacheKeyEntity1NameField = cacheKeyMaker('name', entity1Created.getField('name'));
const cacheKeyEntity1NameFieldShardGroup = nullthrows(
redisCacheAdapterContext
.getShardGroupForKeysFn([cacheKeyEntity1NameField])
.get(cacheKeyEntity1NameField)
);
const cacheRedisClientForEntityNameField =
redisCacheAdapterContext.getRedisInstanceForShardGroup(cacheKeyEntity1NameFieldShardGroup);
await RedisTestEntity.loader(viewerContext)
.enforcing()
.loadByFieldEqualingAsync('name', entity1Created.getField('name'));
await expect(cacheRedisClientForEntityNameField.get(cacheKeyEntity1NameField)).resolves.toEqual(
cachedJSON
);
expect(cacheKeyEntity1ShardGroup).not.toEqual(cacheKeyEntity1NameFieldShardGroup);
// simulate non existent db fetch, should write negative result ('') to cache
const nonExistentId = ids.nonexistent;
const entityNonExistentResult = await RedisTestEntity.loader(viewerContext).loadByIDAsync(
nonExistentId
);
expect(entityNonExistentResult.ok).toBe(false);
const cacheKeyNonExistent = cacheKeyMaker('id', nonExistentId);
const cacheRedisClientForNonExistent = redisCacheAdapterContext.getRedisInstanceForShardGroup(
nullthrows(
redisCacheAdapterContext
.getShardGroupForKeysFn([cacheKeyNonExistent])
.get(cacheKeyNonExistent)
)
);
const nonExistentCachedValue = await cacheRedisClientForNonExistent.get(cacheKeyNonExistent);
expect(nonExistentCachedValue).toEqual('');
// load again through entities framework to ensure it reads negative result
const entityNonExistentResult2 = await RedisTestEntity.loader(viewerContext).loadByIDAsync(
nonExistentId
);
expect(entityNonExistentResult2.ok).toBe(false);
// invalidate from cache to ensure it invalidates correctly in both caches
await RedisTestEntity.loader(viewerContext).invalidateFieldsAsync(entity1.getAllFields());
await expect(cacheRedisClientForEntity.get(cacheKeyEntity1)).resolves.toBeNull();
await expect(
cacheRedisClientForEntityNameField.get(cacheKeyEntity1NameField)
).resolves.toBeNull();
});
});
@@ -2,13 +2,13 @@ import { EntityCacheAdapterTransientError, ViewerContext } from '@expo/entity';
import Redis from 'ioredis';
import { URL } from 'url';
import RedisCacheAdapter, { RedisCacheAdapterContext } from '../RedisCacheAdapter';
import GenericRedisCacher, { RedisCacheAdapterContext } from '../GenericRedisCacher';
import RedisTestEntity from '../testfixtures/RedisTestEntity';
import { createRedisIntegrationTestEntityCompanionProvider } from '../testfixtures/createRedisIntegrationTestEntityCompanionProvider';
class TestViewerContext extends ViewerContext {}
describe(RedisCacheAdapter, () => {
describe(GenericRedisCacher, () => {
let redisCacheAdapterContext: RedisCacheAdapterContext;
beforeAll(() => {
@@ -0,0 +1,199 @@
import { CacheStatus, UUIDField, EntityConfiguration } from '@expo/entity';
import { Redis, Pipeline } from 'ioredis';
import { mock, when, instance, anything, verify } from 'ts-mockito';
import GenericRedisCacher from '../GenericRedisCacher';
type BlahFields = {
id: string;
};
const entityConfiguration = new EntityConfiguration<BlahFields>({
idField: 'id',
tableName: 'blah',
schema: {
id: new UUIDField({ columnName: 'id', cache: true }),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
describe(GenericRedisCacher, () => {
describe('loadManyAsync', () => {
it('returns appropriate cache results', async () => {
const redisResults = new Map();
const mockRedisClient = mock<Redis>();
// need to have one anything() for each element of spread, in this case 3
when(mockRedisClient.mget(anything(), anything(), anything())).thenCall(async (...keys) =>
keys.map((k) => redisResults.get(k) ?? null)
);
const genericCacher = new GenericRedisCacher(
{
redisClient: instance(mockRedisClient),
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
},
entityConfiguration
);
const cacheKeyWat = genericCacher['makeCacheKey']('id', 'wat');
const cacheKeyWho = genericCacher['makeCacheKey']('id', 'who');
const cacheKeyWhy = genericCacher['makeCacheKey']('id', 'why');
redisResults.set(cacheKeyWat, JSON.stringify({ id: 'wat' }));
redisResults.set(cacheKeyWho, '');
const results = await genericCacher.loadManyAsync([cacheKeyWat, cacheKeyWho, cacheKeyWhy]);
expect(results.get(cacheKeyWat)).toMatchObject({
status: CacheStatus.HIT,
item: { id: 'wat' },
});
expect(results.get(cacheKeyWho)).toMatchObject({ status: CacheStatus.NEGATIVE });
expect(results.get(cacheKeyWhy)).toMatchObject({ status: CacheStatus.MISS });
expect(results.size).toBe(3);
});
it('returns empty map when passed empty array of fieldValues', async () => {
const genericCacher = new GenericRedisCacher(
{
redisClient: instance(mock<Redis>()),
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
},
entityConfiguration
);
const results = await genericCacher.loadManyAsync([]);
expect(results).toEqual(new Map());
});
});
describe('cacheManyAsync', () => {
it('correctly caches all objects', async () => {
const redisResults = new Map();
const mockPipeline = mock<Pipeline>();
when(mockPipeline.set(anything(), anything(), anything(), anything())).thenCall(
(key, value, code, ttl) => {
redisResults.set(key, { value, code, ttl });
return pipeline;
}
);
when(mockPipeline.exec()).thenResolve({} as any);
const pipeline = instance(mockPipeline);
const mockRedisClient = mock<Redis>();
when(mockRedisClient.multi()).thenReturn(pipeline);
const genericCacher = new GenericRedisCacher(
{
redisClient: instance(mockRedisClient),
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
},
entityConfiguration
);
const cacheKey = genericCacher['makeCacheKey']('id', 'wat');
await genericCacher.cacheManyAsync(new Map([[cacheKey, { id: 'wat' }]]));
expect(redisResults.get(cacheKey)).toMatchObject({
value: JSON.stringify({ id: 'wat' }),
code: 'EX',
ttl: 1,
});
});
});
describe('cacheDBMissesAsync', () => {
it('correctly caches misses', async () => {
const redisResults = new Map();
const mockPipeline = mock<Pipeline>();
when(mockPipeline.set(anything(), anything(), anything(), anything())).thenCall(
(key, value, code, ttl) => {
redisResults.set(key, { value, code, ttl });
return pipeline;
}
);
when(mockPipeline.exec()).thenResolve({} as any);
const pipeline = instance(mockPipeline);
const mockRedisClient = mock<Redis>();
when(mockRedisClient.multi()).thenReturn(pipeline);
const genericCacher = new GenericRedisCacher(
{
redisClient: instance(mockRedisClient),
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
},
entityConfiguration
);
const cacheKey = genericCacher['makeCacheKey']('id', 'wat');
await genericCacher.cacheDBMissesAsync([cacheKey]);
expect(redisResults.get(cacheKey)).toMatchObject({
value: '',
code: 'EX',
ttl: 2,
});
});
});
describe('invalidateManyAsync', () => {
it('invalidates correctly', async () => {
const mockRedisClient = mock<Redis>();
when(mockRedisClient.del()).thenResolve(1);
const genericCacher = new GenericRedisCacher(
{
redisClient: instance(mockRedisClient),
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
},
entityConfiguration
);
const cacheKey = genericCacher['makeCacheKey']('id', 'wat');
await genericCacher.invalidateManyAsync([cacheKey]);
verify(mockRedisClient.del(cacheKey)).once();
});
it('returns when passed empty array of fieldValues', async () => {
const genericCacher = new GenericRedisCacher(
{
redisClient: instance(mock<Redis>()),
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
},
entityConfiguration
);
await genericCacher.invalidateManyAsync([]);
});
});
});
@@ -1,169 +0,0 @@
import { CacheStatus, UUIDField, EntityConfiguration } from '@expo/entity';
import { Redis, Pipeline } from 'ioredis';
import { mock, when, instance, anything, verify } from 'ts-mockito';
import RedisCacheAdapter from '../RedisCacheAdapter';
type BlahFields = {
id: string;
};
const entityConfiguration = new EntityConfiguration<BlahFields>({
idField: 'id',
tableName: 'blah',
schema: {
id: new UUIDField({ columnName: 'id', cache: true }),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
describe(RedisCacheAdapter, () => {
describe('loadManyAsync', () => {
it('returns appropriate cache results', async () => {
const redisResults = new Map();
const mockRedisClient = mock<Redis>();
// need to have one anything() for each element of spread, in this case 3
when(mockRedisClient.mget(anything(), anything(), anything())).thenCall(async (...keys) =>
keys.map((k) => redisResults.get(k) ?? null)
);
const cacheAdapter = new RedisCacheAdapter(entityConfiguration, {
redisClient: instance(mockRedisClient),
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
});
redisResults.set(cacheAdapter['makeCacheKey']('id', 'wat'), JSON.stringify({ id: 'wat' }));
redisResults.set(cacheAdapter['makeCacheKey']('id', 'who'), '');
const results = await cacheAdapter.loadManyAsync('id', ['wat', 'who', 'why']);
expect(results.get('wat')).toMatchObject({ status: CacheStatus.HIT, item: { id: 'wat' } });
expect(results.get('who')).toMatchObject({ status: CacheStatus.NEGATIVE });
expect(results.get('why')).toMatchObject({ status: CacheStatus.MISS });
expect(results.size).toBe(3);
});
it('returns empty map when passed empty array of fieldValues', async () => {
const cacheAdapter = new RedisCacheAdapter(entityConfiguration, {
redisClient: instance(mock<Redis>()),
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
});
const results = await cacheAdapter.loadManyAsync('id', []);
expect(results).toEqual(new Map());
});
});
describe('cacheManyAsync', () => {
it('correctly caches all objects', async () => {
const redisResults = new Map();
const mockPipeline = mock<Pipeline>();
when(mockPipeline.set(anything(), anything(), anything(), anything())).thenCall(
(key, value, code, ttl) => {
redisResults.set(key, { value, code, ttl });
return pipeline;
}
);
when(mockPipeline.exec()).thenResolve({} as any);
const pipeline = instance(mockPipeline);
const mockRedisClient = mock<Redis>();
when(mockRedisClient.multi()).thenReturn(pipeline);
const cacheAdapter = new RedisCacheAdapter(entityConfiguration, {
redisClient: instance(mockRedisClient),
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
});
await cacheAdapter.cacheManyAsync('id', new Map([['wat', { id: 'wat' }]]));
const cacheKey = cacheAdapter['makeCacheKey']('id', 'wat');
expect(redisResults.get(cacheKey)).toMatchObject({
value: JSON.stringify({ id: 'wat' }),
code: 'EX',
ttl: 1,
});
});
});
describe('cacheDBMissesAsync', () => {
it('correctly caches misses', async () => {
const redisResults = new Map();
const mockPipeline = mock<Pipeline>();
when(mockPipeline.set(anything(), anything(), anything(), anything())).thenCall(
(key, value, code, ttl) => {
redisResults.set(key, { value, code, ttl });
return pipeline;
}
);
when(mockPipeline.exec()).thenResolve({} as any);
const pipeline = instance(mockPipeline);
const mockRedisClient = mock<Redis>();
when(mockRedisClient.multi()).thenReturn(pipeline);
const cacheAdapter = new RedisCacheAdapter(entityConfiguration, {
redisClient: instance(mockRedisClient),
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
});
await cacheAdapter.cacheDBMissesAsync('id', ['wat']);
const cacheKey = cacheAdapter['makeCacheKey']('id', 'wat');
expect(redisResults.get(cacheKey)).toMatchObject({
value: '',
code: 'EX',
ttl: 2,
});
});
});
describe('invalidateManyAsync', () => {
it('invalidates correctly', async () => {
const mockRedisClient = mock<Redis>();
when(mockRedisClient.del()).thenResolve(1);
const cacheAdapter = new RedisCacheAdapter(entityConfiguration, {
redisClient: instance(mockRedisClient),
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
});
await cacheAdapter.invalidateManyAsync('id', ['wat']);
const cacheKey = cacheAdapter['makeCacheKey']('id', 'wat');
verify(mockRedisClient.del(cacheKey)).once();
});
it('returns when passed empty array of fieldValues', async () => {
const cacheAdapter = new RedisCacheAdapter(entityConfiguration, {
redisClient: instance(mock<Redis>()),
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
});
await cacheAdapter.invalidateManyAsync('id', []);
});
});
});
@@ -0,0 +1,316 @@
import { CacheStatus, UUIDField, EntityConfiguration } from '@expo/entity';
import { Redis, Pipeline } from 'ioredis';
import { mock, when, instance, anything, verify } from 'ts-mockito';
import ShardedGenericRedisCacher from '../ShardedGenericRedisCacher';
type BlahFields = {
id: string;
};
const entityConfiguration = new EntityConfiguration<BlahFields>({
idField: 'id',
tableName: 'blah',
schema: {
id: new UUIDField({ columnName: 'id', cache: true }),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
describe(ShardedGenericRedisCacher, () => {
describe('loadManyAsync', () => {
it('returns appropriate cache results', async () => {
const makeMockRedis = (): { mockRedisClient: Redis; redisResults: Map<string, string> } => {
const redisResults = new Map();
const mockRedisClient = mock<Redis>();
// need to have one anything() for each element of spread, in this case 3
when(mockRedisClient.mget(anything(), anything(), anything())).thenCall(async (...keys) =>
keys.map((k) => redisResults.get(k) ?? null)
);
return { mockRedisClient, redisResults };
};
const [
{ mockRedisClient: mockRedisClient1, redisResults: redisResults1 },
{ mockRedisClient: mockRedisClient2, redisResults: redisResults2 },
] = [makeMockRedis(), makeMockRedis()];
const redisClient1 = instance(mockRedisClient1);
const redisClient2 = instance(mockRedisClient2);
const genericCacher = new ShardedGenericRedisCacher(
{
getShardGroupForKeysFn: (keys) =>
new Map(keys.map((k) => [k, k.includes('ins-1') ? 1 : 2])),
getRedisInstanceForShardGroup: (shardGroup) =>
shardGroup === 1 ? redisClient1 : redisClient2,
shardingSchemeVersion: 1,
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
},
entityConfiguration
);
const cacheKeyWat1 = genericCacher['makeCacheKey']('id', 'wat-ins-1');
const cacheKeyWho1 = genericCacher['makeCacheKey']('id', 'who-ins-1');
const cacheKeyWhy1 = genericCacher['makeCacheKey']('id', 'why-ins-1');
const cacheKeyWat2 = genericCacher['makeCacheKey']('id', 'wat-ins-2');
const cacheKeyWho2 = genericCacher['makeCacheKey']('id', 'who-ins-2');
const cacheKeyWhy2 = genericCacher['makeCacheKey']('id', 'why-ins-2');
redisResults1.set(cacheKeyWat1, JSON.stringify({ id: 'wat-ins-1' }));
redisResults1.set(cacheKeyWho1, '');
redisResults2.set(cacheKeyWat2, JSON.stringify({ id: 'wat-ins-2' }));
redisResults2.set(cacheKeyWho2, '');
const results = await genericCacher.loadManyAsync([
cacheKeyWat1,
cacheKeyWho1,
cacheKeyWhy1,
cacheKeyWat2,
cacheKeyWho2,
cacheKeyWhy2,
]);
expect(results.get(cacheKeyWat1)).toMatchObject({
status: CacheStatus.HIT,
item: { id: 'wat-ins-1' },
});
expect(results.get(cacheKeyWho1)).toMatchObject({ status: CacheStatus.NEGATIVE });
expect(results.get(cacheKeyWhy1)).toMatchObject({ status: CacheStatus.MISS });
expect(results.get(cacheKeyWat2)).toMatchObject({
status: CacheStatus.HIT,
item: { id: 'wat-ins-2' },
});
expect(results.get(cacheKeyWho2)).toMatchObject({ status: CacheStatus.NEGATIVE });
expect(results.get(cacheKeyWhy2)).toMatchObject({ status: CacheStatus.MISS });
expect(results.size).toBe(6);
});
it('returns empty map when passed empty array of fieldValues', async () => {
const mockRedisClient = mock<Redis>();
const redisClient = instance(mockRedisClient);
const genericCacher = new ShardedGenericRedisCacher(
{
getShardGroupForKeysFn: (keys) => new Map(keys.map((k) => [k, 1])),
getRedisInstanceForShardGroup: () => redisClient,
shardingSchemeVersion: 1,
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
},
entityConfiguration
);
const results = await genericCacher.loadManyAsync([]);
expect(results).toEqual(new Map());
});
});
describe('cacheManyAsync', () => {
it('correctly caches all objects', async () => {
const makeMockRedis = (): { mockRedisClient: Redis; redisResults: Map<string, string> } => {
const redisResults = new Map();
const mockPipeline = mock<Pipeline>();
when(mockPipeline.set(anything(), anything(), anything(), anything())).thenCall(
(key, value, code, ttl) => {
redisResults.set(key, { value, code, ttl });
return pipeline;
}
);
when(mockPipeline.exec()).thenResolve({} as any);
const pipeline = instance(mockPipeline);
const mockRedisClient = mock<Redis>();
when(mockRedisClient.multi()).thenReturn(pipeline);
return { mockRedisClient, redisResults };
};
const [
{ mockRedisClient: mockRedisClient1, redisResults: redisResults1 },
{ mockRedisClient: mockRedisClient2, redisResults: redisResults2 },
] = [makeMockRedis(), makeMockRedis()];
const redisClient1 = instance(mockRedisClient1);
const redisClient2 = instance(mockRedisClient2);
const genericCacher = new ShardedGenericRedisCacher(
{
getShardGroupForKeysFn: (keys) =>
new Map(keys.map((k) => [k, k.includes('ins-1') ? 1 : 2])),
getRedisInstanceForShardGroup: (shardGroup) =>
shardGroup === 1 ? redisClient1 : redisClient2,
shardingSchemeVersion: 1,
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
},
entityConfiguration
);
const cacheKey1 = genericCacher['makeCacheKey']('id', 'wat-ins-1');
const cacheKey2 = genericCacher['makeCacheKey']('id', 'wat-ins-2');
await genericCacher.cacheManyAsync(
new Map([
[cacheKey1, { id: 'wat-ins-1' }],
[cacheKey2, { id: 'wat-ins-2' }],
])
);
expect(redisResults1.get(cacheKey1)).toMatchObject({
value: JSON.stringify({ id: 'wat-ins-1' }),
code: 'EX',
ttl: 1,
});
expect(redisResults2.get(cacheKey2)).toMatchObject({
value: JSON.stringify({ id: 'wat-ins-2' }),
code: 'EX',
ttl: 1,
});
expect(redisResults1.get(cacheKey2)).toBeUndefined();
expect(redisResults2.get(cacheKey1)).toBeUndefined();
});
});
describe('cacheDBMissesAsync', () => {
it('correctly caches misses', async () => {
const makeMockRedis = (): { mockRedisClient: Redis; redisResults: Map<string, string> } => {
const redisResults = new Map();
const mockPipeline = mock<Pipeline>();
when(mockPipeline.set(anything(), anything(), anything(), anything())).thenCall(
(key, value, code, ttl) => {
redisResults.set(key, { value, code, ttl });
return pipeline;
}
);
when(mockPipeline.exec()).thenResolve({} as any);
const pipeline = instance(mockPipeline);
const mockRedisClient = mock<Redis>();
when(mockRedisClient.multi()).thenReturn(pipeline);
return { mockRedisClient, redisResults };
};
const [
{ mockRedisClient: mockRedisClient1, redisResults: redisResults1 },
{ mockRedisClient: mockRedisClient2, redisResults: redisResults2 },
] = [makeMockRedis(), makeMockRedis()];
const redisClient1 = instance(mockRedisClient1);
const redisClient2 = instance(mockRedisClient2);
const genericCacher = new ShardedGenericRedisCacher(
{
getShardGroupForKeysFn: (keys) =>
new Map(keys.map((k) => [k, k.includes('ins-1') ? 1 : 2])),
getRedisInstanceForShardGroup: (shardGroup) =>
shardGroup === 1 ? redisClient1 : redisClient2,
shardingSchemeVersion: 1,
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
},
entityConfiguration
);
const cacheKey1 = genericCacher['makeCacheKey']('id', 'wat-ins-1');
const cacheKey2 = genericCacher['makeCacheKey']('id', 'wat-ins-2');
await genericCacher.cacheDBMissesAsync([cacheKey1, cacheKey2]);
expect(redisResults1.get(cacheKey1)).toMatchObject({
value: '',
code: 'EX',
ttl: 2,
});
expect(redisResults2.get(cacheKey2)).toMatchObject({
value: '',
code: 'EX',
ttl: 2,
});
expect(redisResults1.get(cacheKey2)).toBeUndefined();
expect(redisResults2.get(cacheKey1)).toBeUndefined();
});
});
describe('invalidateManyAsync', () => {
it('invalidates correctly', async () => {
const makeMockRedis = (): { mockRedisClient: Redis } => {
const mockRedisClient = mock<Redis>();
return { mockRedisClient };
};
const [{ mockRedisClient: mockRedisClient1 }, { mockRedisClient: mockRedisClient2 }] = [
makeMockRedis(),
makeMockRedis(),
];
const redisClient1 = instance(mockRedisClient1);
const redisClient2 = instance(mockRedisClient2);
const genericCacher = new ShardedGenericRedisCacher(
{
getShardGroupForKeysFn: (keys) =>
new Map(keys.map((k) => [k, k.includes('ins-1') ? 1 : 2])),
getRedisInstanceForShardGroup: (shardGroup) =>
shardGroup === 1 ? redisClient1 : redisClient2,
shardingSchemeVersion: 1,
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
},
entityConfiguration
);
const cacheKey1 = genericCacher['makeCacheKey']('id', 'wat-ins-1');
const cacheKey2 = genericCacher['makeCacheKey']('id', 'wat-ins-2');
await genericCacher.invalidateManyAsync([cacheKey1, cacheKey2]);
verify(mockRedisClient1.del(cacheKey1)).once();
verify(mockRedisClient2.del(cacheKey2)).once();
verify(mockRedisClient1.del(cacheKey2)).never();
verify(mockRedisClient2.del(cacheKey1)).never();
});
it('returns when passed empty array of fieldValues', async () => {
const mockRedisClient = mock<Redis>();
const redisClient = instance(mockRedisClient);
const genericCacher = new ShardedGenericRedisCacher(
{
getShardGroupForKeysFn: (keys) => new Map(keys.map((k) => [k, 1])),
getRedisInstanceForShardGroup: () => redisClient,
shardingSchemeVersion: 1,
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
},
entityConfiguration
);
await genericCacher.invalidateManyAsync([]);
});
});
});
@@ -4,9 +4,10 @@
* @module @expo/entity-cache-adapter-redis
*/
export { default as RedisCacheAdapter } from './RedisCacheAdapter';
export * from './RedisCacheAdapter';
export { default as RedisCacheAdapterProvider } from './RedisCacheAdapterProvider';
export * from './RedisCommon';
export { default as GenericRedisCacher } from './GenericRedisCacher';
export * from './GenericRedisCacher';
export { default as ShardedRedisCacheAdapterProvider } from './ShardedRedisCacheAdapterProvider';
export { default as ShardedGenericRedisCacher } from './ShardedGenericRedisCacher';
export * from './ShardedGenericRedisCacher';
@@ -6,7 +6,7 @@ import {
StubDatabaseAdapterProvider,
} from '@expo/entity';
import { RedisCacheAdapterContext } from '../RedisCacheAdapter';
import { RedisCacheAdapterContext } from '../GenericRedisCacher';
import RedisCacheAdapterProvider from '../RedisCacheAdapterProvider';
export const createRedisIntegrationTestEntityCompanionProvider = (
@@ -0,0 +1,38 @@
import {
NoOpEntityMetricsAdapter,
IEntityMetricsAdapter,
EntityCompanionProvider,
StubQueryContextProvider,
StubDatabaseAdapterProvider,
} from '@expo/entity';
import { ShardedRedisCacheAdapterContext } from '../ShardedGenericRedisCacher';
import ShardedRedisCacheAdapterProvider from '../ShardedRedisCacheAdapterProvider';
export const createShardedRedisIntegrationTestEntityCompanionProvider = (
shardedRedisCacheAdapterContext: ShardedRedisCacheAdapterContext,
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
): EntityCompanionProvider => {
return new EntityCompanionProvider(
metricsAdapter,
new Map([
[
'postgres',
{
adapterProvider: new StubDatabaseAdapterProvider(),
queryContextProvider: StubQueryContextProvider,
},
],
]),
new Map([
[
'redis',
{
cacheAdapterProvider: new ShardedRedisCacheAdapterProvider(
shardedRedisCacheAdapterContext
),
},
],
])
);
};
@@ -1,4 +1,4 @@
import { GenericSecondaryEntityCache } from '@expo/entity';
import { EntityConfiguration, GenericSecondaryEntityCache } from '@expo/entity';
import {
GenericLocalMemoryCacher,
LocalMemoryCache,
@@ -15,7 +15,12 @@ export default class LocalMemorySecondaryEntityCache<
TFields,
TLoadParams
> extends GenericSecondaryEntityCache<TFields, TLoadParams> {
constructor(localMemoryCache: LocalMemoryCache<TFields>) {
super(new GenericLocalMemoryCacher(localMemoryCache), (params) => JSON.stringify(params));
constructor(
entityConfiguration: EntityConfiguration<TFields>,
localMemoryCache: LocalMemoryCache<TFields>
) {
super(new GenericLocalMemoryCacher(entityConfiguration, localMemoryCache), (params) =>
JSON.stringify(params)
);
}
}
@@ -180,6 +180,7 @@ describe(LocalMemorySecondaryEntityCache, () => {
const secondaryCacheLoader = new TestSecondaryLocalMemoryCacheLoader(
new LocalMemorySecondaryEntityCache(
localMemoryTestEntityConfiguration,
GenericLocalMemoryCacher.createLRUCache<LocalMemoryTestEntityFields>({})
),
LocalMemoryTestEntity.loader(viewerContext)
@@ -215,6 +216,7 @@ describe(LocalMemorySecondaryEntityCache, () => {
const secondaryCacheLoader = new TestSecondaryLocalMemoryCacheLoader(
new LocalMemorySecondaryEntityCache(
localMemoryTestEntityConfiguration,
GenericLocalMemoryCacher.createLRUCache<LocalMemoryTestEntityFields>({})
),
LocalMemoryTestEntity.loader(viewerContext)
@@ -1,5 +1,5 @@
import { EntityConfiguration, GenericSecondaryEntityCache } from '@expo/entity';
import { GenericRedisCacheContext, GenericRedisCacher } from '@expo/entity-cache-adapter-redis';
import { RedisCacheAdapterContext, GenericRedisCacher } from '@expo/entity-cache-adapter-redis';
/**
* A redis GenericSecondaryEntityCache.
@@ -10,7 +10,7 @@ export default class RedisSecondaryEntityCache<
> extends GenericSecondaryEntityCache<TFields, TLoadParams> {
constructor(
entityConfiguration: EntityConfiguration<TFields>,
genericRedisCacheContext: GenericRedisCacheContext,
genericRedisCacheContext: RedisCacheAdapterContext,
constructRedisKey: (params: Readonly<TLoadParams>) => string
) {
super(new GenericRedisCacher(genericRedisCacheContext, entityConfiguration), constructRedisKey);
@@ -1,26 +1,19 @@
import nullthrows from 'nullthrows';
import EntityCacheAdapter from './EntityCacheAdapter';
import EntityConfiguration from './EntityConfiguration';
import IEntityCacheAdapter from './IEntityCacheAdapter';
import { CacheStatus, CacheLoadResult } from './internal/ReadThroughEntityCache';
/**
* A EntityCacheAdapter that composes other EntityCacheAdapter instances.
* An IEntityCacheAdapter that composes other IEntityCacheAdapter instances.
*/
export default class ComposedEntityCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
export default class ComposedEntityCacheAdapter<TFields> implements IEntityCacheAdapter<TFields> {
/**
* @param entityConfiguration - configuration for entity being loaded
* @param cacheAdapters - list of cache adapters to compose in order of precedence.
* Earlier cache adapters are read from first and written to (including invalidations) last.
* Typically, caches closer to the application should be ordered before caches closer to the database.
* A lower layer cache is closer to the database, while a higher layer cache is closer to the application.
*/
constructor(
entityConfiguration: EntityConfiguration<TFields>,
private readonly cacheAdapters: EntityCacheAdapter<TFields>[]
) {
super(entityConfiguration);
}
constructor(private readonly cacheAdapters: IEntityCacheAdapter<TFields>[]) {}
public async loadManyAsync<N extends keyof TFields>(
fieldName: N,
@@ -0,0 +1,65 @@
import invariant from 'invariant';
import IEntityCacheAdapter from './IEntityCacheAdapter';
import IEntityGenericCacher from './IEntityGenericCacher';
import { CacheLoadResult } from './internal/ReadThroughEntityCache';
import { mapKeys } from './utils/collections/maps';
/**
* A standard IEntityCacheAdapter that coordinates caching through an IEntityGenericCacher.
*/
export default class GenericEntityCacheAdapter<TFields> implements IEntityCacheAdapter<TFields> {
constructor(private readonly genericCacher: IEntityGenericCacher<TFields>) {}
public async loadManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult<TFields>>> {
const redisCacheKeyToFieldValueMapping = new Map(
fieldValues.map((fieldValue) => [
this.genericCacher.makeCacheKey(fieldName, fieldValue),
fieldValue,
])
);
const cacheResults = await this.genericCacher.loadManyAsync(
Array.from(redisCacheKeyToFieldValueMapping.keys())
);
return mapKeys(cacheResults, (redisCacheKey) => {
const fieldValue = redisCacheKeyToFieldValueMapping.get(redisCacheKey);
invariant(
fieldValue !== undefined,
'Unspecified cache key %s returned from generic cacher',
redisCacheKey
);
return fieldValue;
});
}
public async cacheManyAsync<N extends keyof TFields>(
fieldName: N,
objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>
): Promise<void> {
await this.genericCacher.cacheManyAsync(
mapKeys(objectMap, (fieldValue) => this.genericCacher.makeCacheKey(fieldName, fieldValue))
);
}
public async cacheDBMissesAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<void> {
await this.genericCacher.cacheDBMissesAsync(
fieldValues.map((fieldValue) => this.genericCacher.makeCacheKey(fieldName, fieldValue))
);
}
public async invalidateManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<void> {
await this.genericCacher.invalidateManyAsync(
fieldValues.map((fieldValue) => this.genericCacher.makeCacheKey(fieldName, fieldValue))
);
}
}
@@ -1,20 +1,17 @@
import EntityConfiguration from './EntityConfiguration';
import { CacheLoadResult } from './internal/ReadThroughEntityCache';
/**
* A cache adapter is an interface by which objects can be
* cached, fetched from cache, and removed from cache (invalidated).
*/
export default abstract class EntityCacheAdapter<TFields> {
constructor(protected readonly entityConfiguration: EntityConfiguration<TFields>) {}
export default interface IEntityCacheAdapter<TFields> {
/**
* Load many objects from cache.
* @param fieldName - object field being queried
* @param fieldValues - fieldName field values being queried
* @returns map from all field values to a CacheLoadResult for each input value
*/
public abstract loadManyAsync<N extends keyof TFields>(
loadManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult<TFields>>>;
@@ -24,7 +21,7 @@ export default abstract class EntityCacheAdapter<TFields> {
* @param fieldName - object field being queried
* @param objectMap - map from field value to object to cache
*/
public abstract cacheManyAsync<N extends keyof TFields>(
cacheManyAsync<N extends keyof TFields>(
fieldName: N,
objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>
): Promise<void>;
@@ -35,7 +32,7 @@ export default abstract class EntityCacheAdapter<TFields> {
* @param fieldValues - fieldValues for objects reported as CacheStatus.NEGATIVE
* in the cache and not found in the DB.
*/
public abstract cacheDBMissesAsync<N extends keyof TFields>(
cacheDBMissesAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<void>;
@@ -45,7 +42,7 @@ export default abstract class EntityCacheAdapter<TFields> {
* @param fieldName - object field being queried
* @param fieldValues - fieldName field values to be invalidated
*/
public abstract invalidateManyAsync<N extends keyof TFields>(
invalidateManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<void>;
@@ -1,5 +1,5 @@
import EntityCacheAdapter from './EntityCacheAdapter';
import EntityConfiguration from './EntityConfiguration';
import IEntityCacheAdapter from './IEntityCacheAdapter';
/**
* A cache adapter provider vends cache adapters for a particular cache adapter type.
@@ -11,5 +11,5 @@ export default interface IEntityCacheAdapterProvider {
*/
getCacheAdapter<TFields>(
entityConfiguration: EntityConfiguration<TFields>
): EntityCacheAdapter<TFields>;
): IEntityCacheAdapter<TFields>;
}
@@ -12,4 +12,6 @@ export default interface IEntityGenericCacher<TFields> {
cacheDBMissesAsync(keys: readonly string[]): Promise<void>;
invalidateManyAsync(keys: readonly string[]): Promise<void>;
makeCacheKey<N extends keyof TFields>(fieldName: N, fieldValue: NonNullable<TFields[N]>): string;
}
@@ -1,9 +1,9 @@
import invariant from 'invariant';
import ComposedEntityCacheAdapter from '../ComposedEntityCacheAdapter';
import EntityCacheAdapter from '../EntityCacheAdapter';
import EntityConfiguration from '../EntityConfiguration';
import { UUIDField } from '../EntityFields';
import IEntityCacheAdapter from '../IEntityCacheAdapter';
import { CacheLoadResult, CacheStatus } from '../internal/ReadThroughEntityCache';
type BlahFields = {
@@ -23,13 +23,11 @@ const entityConfiguration = new EntityConfiguration<BlahFields>({
export const DOES_NOT_EXIST_LOCAL_MEMORY_CACHE = Symbol('doesNotExist');
type LocalMemoryCacheValue<TFields> = Readonly<TFields> | typeof DOES_NOT_EXIST_LOCAL_MEMORY_CACHE;
class TestLocalCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
class TestLocalCacheAdapter<TFields> implements IEntityCacheAdapter<TFields> {
constructor(
entityConfiguration: EntityConfiguration<TFields>,
private readonly entityConfiguration: EntityConfiguration<TFields>,
private readonly cache: Map<string, LocalMemoryCacheValue<TFields>>
) {
super(entityConfiguration);
}
) {}
public async loadManyAsync<N extends keyof TFields>(
fieldName: N,
@@ -123,10 +121,7 @@ function makeTestCacheAdapters(): {
const fallbackCache = new Map();
const fallbackCacheAdapter = new TestLocalCacheAdapter(entityConfiguration, fallbackCache);
const cacheAdapter = new ComposedEntityCacheAdapter(entityConfiguration, [
primaryCacheAdapter,
fallbackCacheAdapter,
]);
const cacheAdapter = new ComposedEntityCacheAdapter([primaryCacheAdapter, fallbackCacheAdapter]);
return {
primaryCache,
@@ -229,7 +224,7 @@ describe(ComposedEntityCacheAdapter, () => {
});
it('handles 0 cache adapter compose case', async () => {
const cacheAdapter = new ComposedEntityCacheAdapter(entityConfiguration, []);
const cacheAdapter = new ComposedEntityCacheAdapter<any>([]);
const results = await cacheAdapter.loadManyAsync('id', []);
expect(results).toEqual(new Map());
});
@@ -0,0 +1,102 @@
import { mock, when, instance, anything, verify, anyString, deepEqual } from 'ts-mockito';
import GenericEntityCacheAdapter from '../GenericEntityCacheAdapter';
import IEntityGenericCacher from '../IEntityGenericCacher';
import { CacheStatus } from '../internal/ReadThroughEntityCache';
type BlahFields = {
id: string;
};
describe(GenericEntityCacheAdapter, () => {
describe('loadManyAsync', () => {
it('returns appropriate cache results', async () => {
const mockGenericCacher = mock<IEntityGenericCacher<BlahFields>>();
when(mockGenericCacher.makeCacheKey('id', anyString())).thenCall((fieldName, fieldValue) => {
return `${fieldName}.${fieldValue}`;
});
when(mockGenericCacher.loadManyAsync(deepEqual(['id.wat', 'id.who', 'id.why']))).thenResolve(
new Map([
['id.wat', { status: CacheStatus.HIT, item: { id: 'wat' } }],
['id.who', { status: CacheStatus.NEGATIVE }],
['id.why', { status: CacheStatus.MISS }],
])
);
const cacheAdapter = new GenericEntityCacheAdapter(instance(mockGenericCacher));
const results = await cacheAdapter.loadManyAsync('id', ['wat', 'who', 'why']);
expect(results.get('wat')).toMatchObject({ status: CacheStatus.HIT, item: { id: 'wat' } });
expect(results.get('who')).toMatchObject({ status: CacheStatus.NEGATIVE });
expect(results.get('why')).toMatchObject({ status: CacheStatus.MISS });
expect(results.size).toBe(3);
verify(mockGenericCacher.loadManyAsync(anything())).once();
});
it('returns empty map when passed empty array of fieldValues', async () => {
const mockGenericCacher = mock<IEntityGenericCacher<BlahFields>>();
when(mockGenericCacher.loadManyAsync(deepEqual([]))).thenResolve(new Map([]));
const cacheAdapter = new GenericEntityCacheAdapter(instance(mockGenericCacher));
const results = await cacheAdapter.loadManyAsync('id', []);
expect(results).toEqual(new Map());
});
});
describe('cacheManyAsync', () => {
it('correctly caches all objects', async () => {
const mockGenericCacher = mock<IEntityGenericCacher<BlahFields>>();
when(mockGenericCacher.makeCacheKey('id', anyString())).thenCall((fieldName, fieldValue) => {
return `${fieldName}.${fieldValue}`;
});
const cacheAdapter = new GenericEntityCacheAdapter(instance(mockGenericCacher));
await cacheAdapter.cacheManyAsync('id', new Map([['wat', { id: 'wat' }]]));
verify(
mockGenericCacher.cacheManyAsync(deepEqual(new Map([['id.wat', { id: 'wat' }]])))
).once();
});
});
describe('cacheDBMissesAsync', () => {
it('correctly caches misses', async () => {
const mockGenericCacher = mock<IEntityGenericCacher<BlahFields>>();
when(mockGenericCacher.makeCacheKey('id', anyString())).thenCall((fieldName, fieldValue) => {
return `${fieldName}.${fieldValue}`;
});
const cacheAdapter = new GenericEntityCacheAdapter(instance(mockGenericCacher));
await cacheAdapter.cacheDBMissesAsync('id', ['wat']);
verify(mockGenericCacher.cacheDBMissesAsync(deepEqual(['id.wat']))).once();
});
});
describe('invalidateManyAsync', () => {
it('invalidates correctly', async () => {
const mockGenericCacher = mock<IEntityGenericCacher<BlahFields>>();
when(mockGenericCacher.makeCacheKey('id', anyString())).thenCall((fieldName, fieldValue) => {
return `${fieldName}.${fieldValue}`;
});
const cacheAdapter = new GenericEntityCacheAdapter(instance(mockGenericCacher));
await cacheAdapter.invalidateManyAsync('id', ['wat']);
verify(mockGenericCacher.invalidateManyAsync(deepEqual(['id.wat']))).once();
});
it('returns when passed empty array of fieldValues', async () => {
const mockGenericCacher = mock<IEntityGenericCacher<BlahFields>>();
when(mockGenericCacher.makeCacheKey('id', anyString())).thenCall((fieldName, fieldValue) => {
return `${fieldName}.${fieldValue}`;
});
const cacheAdapter = new GenericEntityCacheAdapter(instance(mockGenericCacher));
await cacheAdapter.invalidateManyAsync('id', []);
verify(mockGenericCacher.invalidateManyAsync(deepEqual([]))).once();
});
});
});
+2 -1
View File
@@ -12,7 +12,8 @@ export { default as Entity } from './Entity';
export * from './Entity';
export { default as EntityAssociationLoader } from './EntityAssociationLoader';
export * from './EntityAssociationLoader';
export { default as EntityCacheAdapter } from './EntityCacheAdapter';
export { default as IEntityCacheAdapter } from './IEntityCacheAdapter';
export { default as GenericEntityCacheAdapter } from './GenericEntityCacheAdapter';
export { default as EntityCompanion } from './EntityCompanion';
export * from './EntityCompanion';
export { default as EntityCompanionProvider } from './EntityCompanionProvider';
@@ -1,7 +1,7 @@
import EntityCacheAdapter from '../EntityCacheAdapter';
import EntityConfiguration from '../EntityConfiguration';
import EntityDatabaseAdapter from '../EntityDatabaseAdapter';
import EntityQueryContextProvider from '../EntityQueryContextProvider';
import IEntityCacheAdapter from '../IEntityCacheAdapter';
import IEntityCacheAdapterProvider from '../IEntityCacheAdapterProvider';
import IEntityDatabaseAdapterProvider from '../IEntityDatabaseAdapterProvider';
import IEntityMetricsAdapter from '../metrics/IEntityMetricsAdapter';
@@ -15,7 +15,7 @@ import ReadThroughEntityCache from './ReadThroughEntityCache';
*/
export default class EntityTableDataCoordinator<TFields> {
readonly databaseAdapter: EntityDatabaseAdapter<TFields>;
readonly cacheAdapter: EntityCacheAdapter<TFields>;
readonly cacheAdapter: IEntityCacheAdapter<TFields>;
readonly dataManager: EntityDataManager<TFields>;
constructor(
@@ -1,7 +1,7 @@
import invariant from 'invariant';
import EntityCacheAdapter from '../EntityCacheAdapter';
import EntityConfiguration from '../EntityConfiguration';
import IEntityCacheAdapter from '../IEntityCacheAdapter';
import { filterMap } from '../utils/collections/maps';
export enum CacheStatus {
@@ -29,7 +29,7 @@ export type CacheLoadResult<TFields> =
export default class ReadThroughEntityCache<TFields> {
constructor(
private readonly entityConfiguration: EntityConfiguration<TFields>,
private readonly entityCacheAdapter: EntityCacheAdapter<TFields>
private readonly entityCacheAdapter: IEntityCacheAdapter<TFields>
) {}
private isFieldCacheable<N extends keyof TFields>(fieldName: N): boolean {
@@ -1,8 +1,8 @@
import { verify, deepEqual, mock, instance, when, anything } from 'ts-mockito';
import EntityCacheAdapter from '../../EntityCacheAdapter';
import EntityConfiguration from '../../EntityConfiguration';
import { UUIDField } from '../../EntityFields';
import IEntityCacheAdapter from '../../IEntityCacheAdapter';
import ReadThroughEntityCache, { CacheStatus } from '../ReadThroughEntityCache';
type BlahFields = {
@@ -39,7 +39,7 @@ const createIdFetcher =
describe(ReadThroughEntityCache, () => {
describe('readManyThroughAsync', () => {
it('fetches from DB upon cache miss and caches the result', async () => {
const cacheAdapterMock = mock<EntityCacheAdapter<BlahFields>>();
const cacheAdapterMock = mock<IEntityCacheAdapter<BlahFields>>();
const cacheAdapter = instance(cacheAdapterMock);
const entityCache = new ReadThroughEntityCache(makeEntityConfiguration(true), cacheAdapter);
const fetcher = createIdFetcher(['wat', 'who']);
@@ -75,7 +75,7 @@ describe(ReadThroughEntityCache, () => {
});
it('does not fetch from the DB or cache results when all cache fetches are hits', async () => {
const cacheAdapterMock = mock<EntityCacheAdapter<BlahFields>>();
const cacheAdapterMock = mock<IEntityCacheAdapter<BlahFields>>();
const cacheAdapter = instance(cacheAdapterMock);
const entityCache = new ReadThroughEntityCache(makeEntityConfiguration(true), cacheAdapter);
const fetcher = createIdFetcher(['wat', 'who']);
@@ -111,7 +111,7 @@ describe(ReadThroughEntityCache, () => {
});
it('negatively caches db misses', async () => {
const cacheAdapterMock = mock<EntityCacheAdapter<BlahFields>>();
const cacheAdapterMock = mock<IEntityCacheAdapter<BlahFields>>();
const cacheAdapter = instance(cacheAdapterMock);
const entityCache = new ReadThroughEntityCache(makeEntityConfiguration(true), cacheAdapter);
@@ -131,7 +131,7 @@ describe(ReadThroughEntityCache, () => {
});
it('does not return or fetch negatively cached results from DB', async () => {
const cacheAdapterMock = mock<EntityCacheAdapter<BlahFields>>();
const cacheAdapterMock = mock<IEntityCacheAdapter<BlahFields>>();
const cacheAdapter = instance(cacheAdapterMock);
const entityCache = new ReadThroughEntityCache(makeEntityConfiguration(true), cacheAdapter);
const fetcher = createIdFetcher([]);
@@ -148,7 +148,7 @@ describe(ReadThroughEntityCache, () => {
});
it('does a mix and match of hit, miss, and negative', async () => {
const cacheAdapterMock = mock<EntityCacheAdapter<BlahFields>>();
const cacheAdapterMock = mock<IEntityCacheAdapter<BlahFields>>();
const cacheAdapter = instance(cacheAdapterMock);
const entityCache = new ReadThroughEntityCache(makeEntityConfiguration(true), cacheAdapter);
const fetcher = createIdFetcher(['wat', 'who', 'why']);
@@ -183,7 +183,7 @@ describe(ReadThroughEntityCache, () => {
});
it('does not call into cache for field that is not cacheable', async () => {
const cacheAdapterMock = mock<EntityCacheAdapter<BlahFields>>();
const cacheAdapterMock = mock<IEntityCacheAdapter<BlahFields>>();
const cacheAdapter = instance(cacheAdapterMock);
const entityCache = new ReadThroughEntityCache(makeEntityConfiguration(false), cacheAdapter);
const fetcher = createIdFetcher(['wat']);
@@ -195,7 +195,7 @@ describe(ReadThroughEntityCache, () => {
describe('invalidateManyAsync', () => {
it('calls cache adapter invalidate', async () => {
const cacheAdapterMock = mock<EntityCacheAdapter<BlahFields>>();
const cacheAdapterMock = mock<IEntityCacheAdapter<BlahFields>>();
const cacheAdapter = instance(cacheAdapterMock);
const entityCache = new ReadThroughEntityCache(makeEntityConfiguration(true), cacheAdapter);
await entityCache.invalidateManyAsync('id', ['wat']);
@@ -1,19 +1,19 @@
import invariant from 'invariant';
import EntityCacheAdapter from '../../EntityCacheAdapter';
import EntityConfiguration from '../../EntityConfiguration';
import IEntityCacheAdapter from '../../IEntityCacheAdapter';
import IEntityCacheAdapterProvider from '../../IEntityCacheAdapterProvider';
import { CacheStatus, CacheLoadResult } from '../../internal/ReadThroughEntityCache';
export class NoCacheStubCacheAdapterProvider implements IEntityCacheAdapterProvider {
getCacheAdapter<TFields>(
entityConfiguration: EntityConfiguration<TFields>
): EntityCacheAdapter<TFields> {
return new NoCacheStubCacheAdapter(entityConfiguration);
_entityConfiguration: EntityConfiguration<TFields>
): IEntityCacheAdapter<TFields> {
return new NoCacheStubCacheAdapter();
}
}
export class NoCacheStubCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
export class NoCacheStubCacheAdapter<TFields> implements IEntityCacheAdapter<TFields> {
public async loadManyAsync<N extends keyof TFields>(
_fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
@@ -47,7 +47,7 @@ export class InMemoryFullCacheStubCacheAdapterProvider implements IEntityCacheAd
getCacheAdapter<TFields>(
entityConfiguration: EntityConfiguration<TFields>
): EntityCacheAdapter<TFields> {
): IEntityCacheAdapter<TFields> {
return new InMemoryFullCacheStubCacheAdapter(
entityConfiguration,
this.cache as Map<string, Readonly<TFields>>
@@ -55,13 +55,11 @@ export class InMemoryFullCacheStubCacheAdapterProvider implements IEntityCacheAd
}
}
export class InMemoryFullCacheStubCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
export class InMemoryFullCacheStubCacheAdapter<TFields> implements IEntityCacheAdapter<TFields> {
constructor(
entityConfiguration: EntityConfiguration<TFields>,
private readonly entityConfiguration: EntityConfiguration<TFields>,
readonly cache: Map<string, Readonly<TFields>>
) {
super(entityConfiguration);
}
) {}
public async loadManyAsync<N extends keyof TFields>(
fieldName: N,
+7
View File
@@ -6,6 +6,12 @@ services:
- "${REDIS_PORT}:6379"
volumes:
- redis_data:/data
redis2:
image: redis
ports:
- "${REDIS_PORT_2}:6379"
volumes:
- redis_data_2:/data
postgres:
image: postgres:14
ports:
@@ -20,3 +26,4 @@ services:
volumes:
postgres_data: {}
redis_data: {}
redis_data_2: {}
+2
View File
@@ -3,7 +3,9 @@
export NODE_ENV=test
export REDIS_PORT="16379"
export REDIS_PORT_2="17379"
export REDIS_URL="redis://localhost:${REDIS_PORT}/0"
export REDIS_URL_2="redis://localhost:${REDIS_PORT_2}/0"
export PGUSER="postgresuser"
export PGPASSWORD="handkerchief-break-popular-population"