Compare commits

..
7 changed files with 63 additions and 179 deletions

No files matched your search

@@ -1,6 +1,13 @@
import { CacheLoadResult, CacheStatus } from '@expo/entity'; import {
CacheLoadResult,
CacheStatus,
EntityConfiguration,
transformCacheObjectToFields,
transformFieldsToCacheObject,
} from '@expo/entity';
import { Redis } from 'ioredis'; import { Redis } from 'ioredis';
import { redisTransformerMap } from './RedisCommon';
import wrapNativeRedisCallAsync from './errors/wrapNativeRedisCallAsync'; import wrapNativeRedisCallAsync from './errors/wrapNativeRedisCallAsync';
// Sentinel value we store in Redis to negatively cache a database miss. // Sentinel value we store in Redis to negatively cache a database miss.
@@ -26,12 +33,15 @@ export interface GenericRedisCacheContext {
ttlSecondsNegative: number; ttlSecondsNegative: number;
} }
export default class GenericRedisCacher { export default class GenericRedisCacher<TFields> {
constructor(private readonly context: GenericRedisCacheContext) {} constructor(
private readonly context: GenericRedisCacheContext,
private readonly entityConfiguration: EntityConfiguration<TFields>
) {}
public async loadManyAsync( public async loadManyAsync(
keys: readonly string[] keys: readonly string[]
): Promise<ReadonlyMap<string, CacheLoadResult>> { ): Promise<ReadonlyMap<string, CacheLoadResult<TFields>>> {
if (keys.length === 0) { if (keys.length === 0) {
return new Map(); return new Map();
} }
@@ -40,7 +50,7 @@ export default class GenericRedisCacher {
this.context.redisClient.mget(...keys) this.context.redisClient.mget(...keys)
); );
const results = new Map<string, CacheLoadResult>(); const results = new Map<string, CacheLoadResult<TFields>>();
for (let i = 0; i < keys.length; i++) { for (let i = 0; i < keys.length; i++) {
const key = keys[i]!; const key = keys[i]!;
const redisResult = redisResults[i]; const redisResult = redisResults[i];
@@ -52,7 +62,11 @@ export default class GenericRedisCacher {
} else if (redisResult) { } else if (redisResult) {
results.set(key, { results.set(key, {
status: CacheStatus.HIT, status: CacheStatus.HIT,
item: JSON.parse(redisResult), item: transformCacheObjectToFields(
this.entityConfiguration,
redisTransformerMap,
JSON.parse(redisResult)
),
}); });
} else { } else {
results.set(key, { results.set(key, {
@@ -63,7 +77,7 @@ export default class GenericRedisCacher {
return results; return results;
} }
public async cacheManyAsync(objectMap: ReadonlyMap<string, object>): Promise<void> { public async cacheManyAsync(objectMap: ReadonlyMap<string, Readonly<TFields>>): Promise<void> {
if (objectMap.size === 0) { if (objectMap.size === 0) {
return; return;
} }
@@ -72,7 +86,9 @@ export default class GenericRedisCacher {
objectMap.forEach((object, key) => { objectMap.forEach((object, key) => {
redisTransaction = redisTransaction.set( redisTransaction = redisTransaction.set(
key, key,
JSON.stringify(object), JSON.stringify(
transformFieldsToCacheObject(this.entityConfiguration, redisTransformerMap, object)
),
'EX', 'EX',
this.context.ttlSecondsPositive this.context.ttlSecondsPositive
); );
@@ -1,18 +1,8 @@
import { import { EntityCacheAdapter, EntityConfiguration, CacheLoadResult, mapKeys } from '@expo/entity';
EntityCacheAdapter,
EntityConfiguration,
CacheLoadResult,
FieldTransformerMap,
mapKeys,
transformCacheObjectToFields,
CacheStatus,
transformFieldsToCacheObject,
} from '@expo/entity';
import invariant from 'invariant'; import invariant from 'invariant';
import { Redis } from 'ioredis'; import { Redis } from 'ioredis';
import GenericRedisCacher from './GenericRedisCacher'; import GenericRedisCacher from './GenericRedisCacher';
import { redisTransformerMap } from './RedisCommon';
export interface RedisCacheAdapterContext { export interface RedisCacheAdapterContext {
/** /**
@@ -52,51 +42,33 @@ export interface RedisCacheAdapterContext {
} }
export default class RedisCacheAdapter<TFields> extends EntityCacheAdapter<TFields> { export default class RedisCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
/** private readonly genericRedisCacher: GenericRedisCacher<TFields>;
* Transformer definitions for field types. Used to modify values as they are read from or written to
* the cache. Override in concrete subclasses to change transformation behavior.
* If a field type is not present in the map, then fields of that type will not be transformed.
*/
private readonly redisFieldTransformer: FieldTransformerMap = redisTransformerMap;
private readonly genericRedisCacher: GenericRedisCacher;
constructor( constructor(
entityConfiguration: EntityConfiguration<TFields>, entityConfiguration: EntityConfiguration<TFields>,
private readonly context: RedisCacheAdapterContext private readonly context: RedisCacheAdapterContext
) { ) {
super(entityConfiguration); super(entityConfiguration);
this.genericRedisCacher = new GenericRedisCacher({ this.genericRedisCacher = new GenericRedisCacher(
redisClient: context.redisClient, {
ttlSecondsNegative: context.ttlSecondsNegative, redisClient: context.redisClient,
ttlSecondsPositive: context.ttlSecondsPositive, ttlSecondsNegative: context.ttlSecondsNegative,
}); ttlSecondsPositive: context.ttlSecondsPositive,
},
entityConfiguration
);
} }
public async loadManyAsync<N extends keyof TFields>( public async loadManyAsync<N extends keyof TFields>(
fieldName: N, fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[] fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult>> { ): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult<TFields>>> {
const redisCacheKeyToFieldValueMapping = new Map( const redisCacheKeyToFieldValueMapping = new Map(
fieldValues.map((fieldValue) => [this.makeCacheKey(fieldName, fieldValue), fieldValue]) fieldValues.map((fieldValue) => [this.makeCacheKey(fieldName, fieldValue), fieldValue])
); );
const rawCacheResults = await this.genericRedisCacher.loadManyAsync( const cacheResults = await this.genericRedisCacher.loadManyAsync(
Array.from(redisCacheKeyToFieldValueMapping.keys()) Array.from(redisCacheKeyToFieldValueMapping.keys())
); );
const cacheResults = new Map<string, CacheLoadResult>();
for (const [redisCacheKey, rawCacheResult] of rawCacheResults) {
if (rawCacheResult.status === CacheStatus.HIT) {
cacheResults.set(redisCacheKey, {
status: CacheStatus.HIT,
item: transformCacheObjectToFields(
this.entityConfiguration,
this.redisFieldTransformer,
rawCacheResult.item
),
});
} else {
cacheResults.set(redisCacheKey, rawCacheResult);
}
}
return mapKeys(cacheResults, (redisCacheKey) => { return mapKeys(cacheResults, (redisCacheKey) => {
const fieldValue = redisCacheKeyToFieldValueMapping.get(redisCacheKey); const fieldValue = redisCacheKeyToFieldValueMapping.get(redisCacheKey);
@@ -111,21 +83,10 @@ export default class RedisCacheAdapter<TFields> extends EntityCacheAdapter<TFiel
public async cacheManyAsync<N extends keyof TFields>( public async cacheManyAsync<N extends keyof TFields>(
fieldName: N, fieldName: N,
objectMap: ReadonlyMap<NonNullable<TFields[N]>, object> objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>
): Promise<void> { ): Promise<void> {
const cacheObjectMap = new Map<NonNullable<TFields[N]>, object>();
for (const [fieldValue, object] of objectMap) {
cacheObjectMap.set(
fieldValue,
transformFieldsToCacheObject(
this.entityConfiguration,
this.redisFieldTransformer,
object as Readonly<TFields>
)
);
}
await this.genericRedisCacher.cacheManyAsync( await this.genericRedisCacher.cacheManyAsync(
mapKeys(cacheObjectMap, (fieldValue) => this.makeCacheKey(fieldName, fieldValue)) mapKeys(objectMap, (fieldValue) => this.makeCacheKey(fieldName, fieldValue))
); );
} }
@@ -1,4 +1,4 @@
import { CacheStatus, UUIDField, EntityConfiguration, DateField } from '@expo/entity'; import { CacheStatus, UUIDField, EntityConfiguration } from '@expo/entity';
import { Redis, Pipeline } from 'ioredis'; import { Redis, Pipeline } from 'ioredis';
import { mock, when, instance, anything, verify } from 'ts-mockito'; import { mock, when, instance, anything, verify } from 'ts-mockito';
@@ -6,7 +6,6 @@ import RedisCacheAdapter from '../RedisCacheAdapter';
type BlahFields = { type BlahFields = {
id: string; id: string;
date: Date | null;
}; };
const entityConfiguration = new EntityConfiguration<BlahFields>({ const entityConfiguration = new EntityConfiguration<BlahFields>({
@@ -14,7 +13,6 @@ const entityConfiguration = new EntityConfiguration<BlahFields>({
tableName: 'blah', tableName: 'blah',
schema: { schema: {
id: new UUIDField({ columnName: 'id', cache: true }), id: new UUIDField({ columnName: 'id', cache: true }),
date: new DateField({ columnName: 'date' }),
}, },
databaseAdapterFlavor: 'postgres', databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis', cacheAdapterFlavor: 'redis',
@@ -64,40 +62,6 @@ describe(RedisCacheAdapter, () => {
const results = await cacheAdapter.loadManyAsync('id', []); const results = await cacheAdapter.loadManyAsync('id', []);
expect(results).toEqual(new Map()); expect(results).toEqual(new Map());
}); });
it('transforms a cache object to an Entity object', async () => {
const redisResults = new Map();
const mockRedisClient = mock<Redis>();
// need to have one anything() for each element of spread, in this case 1
when(mockRedisClient.mget(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,
});
const date = new Date();
redisResults.set(
cacheAdapter['makeCacheKey']('id', 'wat'),
JSON.stringify({ id: 'wat', date: date.toISOString() })
);
const results = await cacheAdapter.loadManyAsync('id', ['wat']);
expect(results.get('wat')).toMatchObject({
status: CacheStatus.HIT,
item: { id: 'wat', date },
});
expect(results.size).toBe(1);
});
}); });
describe('cacheManyAsync', () => { describe('cacheManyAsync', () => {
@@ -134,41 +98,6 @@ describe(RedisCacheAdapter, () => {
ttl: 1, ttl: 1,
}); });
}); });
it('transforms an Entity object to a cache object', 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,
});
const date = new Date();
await cacheAdapter.cacheManyAsync('id', new Map([['wat', { id: 'wat', date }]]));
const cacheKey = cacheAdapter['makeCacheKey']('id', 'wat');
expect(redisResults.get(cacheKey)).toMatchObject({
value: JSON.stringify({ id: 'wat', date: date.toISOString() }),
code: 'EX',
ttl: 1,
});
});
}); });
describe('cacheDBMissesAsync', () => { describe('cacheDBMissesAsync', () => {
@@ -3,15 +3,9 @@ import {
EntityConfiguration, EntityConfiguration,
filterMap, filterMap,
ISecondaryEntityCache, ISecondaryEntityCache,
transformCacheObjectToFields,
transformFieldsToCacheObject,
zipToMap, zipToMap,
} from '@expo/entity'; } from '@expo/entity';
import { import { GenericRedisCacheContext, GenericRedisCacher } from '@expo/entity-cache-adapter-redis';
GenericRedisCacheContext,
GenericRedisCacher,
redisTransformerMap,
} from '@expo/entity-cache-adapter-redis';
import invariant from 'invariant'; import invariant from 'invariant';
/** /**
@@ -22,14 +16,14 @@ import invariant from 'invariant';
export default class RedisSecondaryEntityCache<TFields, TLoadParams> export default class RedisSecondaryEntityCache<TFields, TLoadParams>
implements ISecondaryEntityCache<TFields, TLoadParams> implements ISecondaryEntityCache<TFields, TLoadParams>
{ {
private readonly genericRedisCacher: GenericRedisCacher; private readonly genericRedisCacher: GenericRedisCacher<TFields>;
constructor( constructor(
private readonly entityConfiguration: EntityConfiguration<TFields>, entityConfiguration: EntityConfiguration<TFields>,
genericRedisCacheContext: GenericRedisCacheContext, genericRedisCacheContext: GenericRedisCacheContext,
private readonly constructRedisKey: (params: Readonly<TLoadParams>) => string private readonly constructRedisKey: (params: Readonly<TLoadParams>) => string
) { ) {
this.genericRedisCacher = new GenericRedisCacher(genericRedisCacheContext); this.genericRedisCacher = new GenericRedisCacher(genericRedisCacheContext, entityConfiguration);
} }
/** /**
@@ -74,14 +68,7 @@ export default class RedisSecondaryEntityCache<TFields, TLoadParams>
if (cacheLoadResult.status === CacheStatus.HIT) { if (cacheLoadResult.status === CacheStatus.HIT) {
const loadParams = redisKeyToLoadParamsMap.get(redisKey); const loadParams = redisKeyToLoadParamsMap.get(redisKey);
invariant(loadParams !== undefined, 'load params should be in redis key map'); invariant(loadParams !== undefined, 'load params should be in redis key map');
results.set( results.set(loadParams, cacheLoadResult.item);
loadParams,
transformCacheObjectToFields(
this.entityConfiguration,
redisTransformerMap,
cacheLoadResult.item
)
);
} }
}); });
@@ -103,13 +90,10 @@ export default class RedisSecondaryEntityCache<TFields, TLoadParams>
results.set(fetchMiss, null); results.set(fetchMiss, null);
} }
const objectsToCache: Map<string, object> = new Map(); const objectsToCache: Map<string, Readonly<TFields>> = new Map();
for (const [loadParams, object] of fetchResults.entries()) { for (const [loadParams, object] of fetchResults.entries()) {
if (object) { if (object) {
objectsToCache.set( objectsToCache.set(this.constructRedisKey(loadParams), object);
this.constructRedisKey(loadParams),
transformFieldsToCacheObject(this.entityConfiguration, redisTransformerMap, object)
);
results.set(loadParams, object); results.set(loadParams, object);
} }
} }
+2 -2
View File
@@ -17,7 +17,7 @@ export default abstract class EntityCacheAdapter<TFields> {
public abstract loadManyAsync<N extends keyof TFields>( public abstract loadManyAsync<N extends keyof TFields>(
fieldName: N, fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[] fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult>>; ): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult<TFields>>>;
/** /**
* Cache many objects fetched from the DB. * Cache many objects fetched from the DB.
@@ -26,7 +26,7 @@ export default abstract class EntityCacheAdapter<TFields> {
*/ */
public abstract cacheManyAsync<N extends keyof TFields>( public abstract cacheManyAsync<N extends keyof TFields>(
fieldName: N, fieldName: N,
objectMap: ReadonlyMap<NonNullable<TFields[N]>, object> objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>
): Promise<void>; ): Promise<void>;
/** /**
@@ -10,10 +10,10 @@ export enum CacheStatus {
NEGATIVE, NEGATIVE,
} }
export type CacheLoadResult = export type CacheLoadResult<TFields> =
| { | {
status: CacheStatus.HIT; status: CacheStatus.HIT;
item: Readonly<object>; item: Readonly<TFields>;
} }
| { | {
status: CacheStatus.MISS; status: CacheStatus.MISS;
@@ -82,7 +82,7 @@ export default class ReadThroughEntityCache<TFields> {
const results: Map<NonNullable<TFields[N]>, readonly Readonly<TFields>[]> = new Map(); const results: Map<NonNullable<TFields[N]>, readonly Readonly<TFields>[]> = new Map();
cacheLoadResults.forEach((cacheLoadResult, fieldValue) => { cacheLoadResults.forEach((cacheLoadResult, fieldValue) => {
if (cacheLoadResult.status === CacheStatus.HIT) { if (cacheLoadResult.status === CacheStatus.HIT) {
results.set(fieldValue, [cacheLoadResult.item as Readonly<TFields>]); results.set(fieldValue, [cacheLoadResult.item]);
} }
}); });
@@ -95,7 +95,7 @@ export default class ReadThroughEntityCache<TFields> {
return !objectsFromFulfillerForFv || objectsFromFulfillerForFv.length === 0; return !objectsFromFulfillerForFv || objectsFromFulfillerForFv.length === 0;
}); });
const objectsToCache: Map<NonNullable<TFields[N]>, object> = new Map(); const objectsToCache: Map<NonNullable<TFields[N]>, Readonly<TFields>> = new Map();
for (const [fieldValue, objects] of dbFetchResults.entries()) { for (const [fieldValue, objects] of dbFetchResults.entries()) {
if (objects.length > 1) { if (objects.length > 1) {
// multiple objects received for what was supposed to be a unique query, don't add to return map nor cache // multiple objects received for what was supposed to be a unique query, don't add to return map nor cache
@@ -3,7 +3,6 @@ import invariant from 'invariant';
import EntityCacheAdapter from '../../EntityCacheAdapter'; import EntityCacheAdapter from '../../EntityCacheAdapter';
import EntityConfiguration from '../../EntityConfiguration'; import EntityConfiguration from '../../EntityConfiguration';
import IEntityCacheAdapterProvider from '../../IEntityCacheAdapterProvider'; import IEntityCacheAdapterProvider from '../../IEntityCacheAdapterProvider';
import { FieldTransformerMap } from '../../internal/EntityFieldTransformationUtils';
import { CacheStatus, CacheLoadResult } from '../../internal/ReadThroughEntityCache'; import { CacheStatus, CacheLoadResult } from '../../internal/ReadThroughEntityCache';
export class NoCacheStubCacheAdapterProvider implements IEntityCacheAdapterProvider { export class NoCacheStubCacheAdapterProvider implements IEntityCacheAdapterProvider {
@@ -15,15 +14,11 @@ export class NoCacheStubCacheAdapterProvider implements IEntityCacheAdapterProvi
} }
export class NoCacheStubCacheAdapter<TFields> extends EntityCacheAdapter<TFields> { export class NoCacheStubCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
public getFieldTransformerMap(): FieldTransformerMap {
return new Map();
}
public async loadManyAsync<N extends keyof TFields>( public async loadManyAsync<N extends keyof TFields>(
_fieldName: N, _fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[] fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult>> { ): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult<TFields>>> {
return fieldValues.reduce((acc: Map<NonNullable<TFields[N]>, CacheLoadResult>, v) => { return fieldValues.reduce((acc: Map<NonNullable<TFields[N]>, CacheLoadResult<TFields>>, v) => {
acc.set(v, { acc.set(v, {
status: CacheStatus.MISS, status: CacheStatus.MISS,
}); });
@@ -33,7 +28,7 @@ export class NoCacheStubCacheAdapter<TFields> extends EntityCacheAdapter<TFields
public async cacheManyAsync<N extends keyof TFields>( public async cacheManyAsync<N extends keyof TFields>(
_fieldName: N, _fieldName: N,
_objectMap: ReadonlyMap<NonNullable<TFields[N]>, object> _objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>
): Promise<void> {} ): Promise<void> {}
public async cacheDBMissesAsync<N extends keyof TFields>( public async cacheDBMissesAsync<N extends keyof TFields>(
@@ -53,27 +48,26 @@ export class InMemoryFullCacheStubCacheAdapterProvider implements IEntityCacheAd
getCacheAdapter<TFields>( getCacheAdapter<TFields>(
entityConfiguration: EntityConfiguration<TFields> entityConfiguration: EntityConfiguration<TFields>
): EntityCacheAdapter<TFields> { ): EntityCacheAdapter<TFields> {
return new InMemoryFullCacheStubCacheAdapter(entityConfiguration, this.cache); return new InMemoryFullCacheStubCacheAdapter(
entityConfiguration,
this.cache as Map<string, Readonly<TFields>>
);
} }
} }
export class InMemoryFullCacheStubCacheAdapter<TFields> extends EntityCacheAdapter<TFields> { export class InMemoryFullCacheStubCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
constructor( constructor(
entityConfiguration: EntityConfiguration<TFields>, entityConfiguration: EntityConfiguration<TFields>,
readonly cache: Map<string, Readonly<object>> readonly cache: Map<string, Readonly<TFields>>
) { ) {
super(entityConfiguration); super(entityConfiguration);
} }
public getFieldTransformerMap(): FieldTransformerMap {
return new Map();
}
public async loadManyAsync<N extends keyof TFields>( public async loadManyAsync<N extends keyof TFields>(
fieldName: N, fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[] fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult>> { ): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult<TFields>>> {
const results = new Map<NonNullable<TFields[N]>, CacheLoadResult>(); const results = new Map<NonNullable<TFields[N]>, CacheLoadResult<TFields>>();
fieldValues.forEach((fieldValue) => { fieldValues.forEach((fieldValue) => {
const cacheKey = this.createCacheKey(fieldName, fieldValue); const cacheKey = this.createCacheKey(fieldName, fieldValue);
if (!this.cache.has(cacheKey)) { if (!this.cache.has(cacheKey)) {
@@ -94,7 +88,7 @@ export class InMemoryFullCacheStubCacheAdapter<TFields> extends EntityCacheAdapt
public async cacheManyAsync<N extends keyof TFields>( public async cacheManyAsync<N extends keyof TFields>(
fieldName: N, fieldName: N,
objectMap: ReadonlyMap<NonNullable<TFields[N]>, object> objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>
): Promise<void> { ): Promise<void> {
objectMap.forEach((obj, fieldValue) => { objectMap.forEach((obj, fieldValue) => {
const cacheKey = this.createCacheKey(fieldName, fieldValue); const cacheKey = this.createCacheKey(fieldName, fieldValue);