mirror of
https://github.com/expo/entity.git
synced 2026-11-13 20:28:06 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7715b8e5f9 | ||
|
|
45991b2cc1 |
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)
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
+25
-26
@@ -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()),
|
||||
]);
|
||||
+115
-1
@@ -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', []);
|
||||
});
|
||||
});
|
||||
});
|
||||
-107
@@ -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);
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -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')
|
||||
+4
-17
@@ -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();
|
||||
|
||||
+147
@@ -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';
|
||||
+1
-1
@@ -6,7 +6,7 @@ import {
|
||||
StubDatabaseAdapterProvider,
|
||||
} from '@expo/entity';
|
||||
|
||||
import { RedisCacheAdapterContext } from '../RedisCacheAdapter';
|
||||
import { RedisCacheAdapterContext } from '../GenericRedisCacher';
|
||||
import RedisCacheAdapterProvider from '../RedisCacheAdapterProvider';
|
||||
|
||||
export const createRedisIntegrationTestEntityCompanionProvider = (
|
||||
|
||||
+38
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
@@ -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))
|
||||
);
|
||||
}
|
||||
}
|
||||
+5
-8
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {}
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user