Skip to content

Commit

Permalink
feat: add example of redis mget batching using @expo/batcher (#199)
Browse files Browse the repository at this point in the history
  • Loading branch information
wschurman authored Oct 6, 2022
1 parent e381f5b commit 684f91b
Show file tree
Hide file tree
Showing 4 changed files with 181 additions and 8 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"barrelsby": "lerna run barrelsby"
},
"devDependencies": {
"@expo/batcher": "^1.0.0",
"@expo/entity": "file:packages/entity",
"@expo/results": "^1.0.0",
"@types/invariant": "^2.2.33",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { Batcher } from '@expo/batcher';
import { ViewerContext, zipToMap } from '@expo/entity';
import invariant from 'invariant';
import Redis from 'ioredis';
import nullthrows from 'nullthrows';
import { URL } from 'url';
import { v4 as uuidv4 } from 'uuid';

import { IRedis, IRedisTransaction } from '../GenericRedisCacher';
import RedisCacheAdapter, { RedisCacheAdapterContext } from '../RedisCacheAdapter';
import RedisTestEntity from '../testfixtures/RedisTestEntity';
import { createRedisIntegrationTestEntityCompanionProvider } from '../testfixtures/createRedisIntegrationTestEntityCompanionProvider';

class BatchedRedis implements IRedis {
private readonly mgetBatcher = new Batcher<string[], (string | null)[]>(
this.batchMgetAsync.bind(this),
{
maxBatchInterval: 0,
}
);

constructor(private readonly redis: Redis) {}

private async batchMgetAsync(keySets: string[][]): Promise<(string | null)[][]> {
// ordered distinct keys to fetch
const allKeysToFetch = [...new Set(keySets.flat())];

// fetch the distinct keys
const allResults = await this.redis.mget(...allKeysToFetch);

// put them into a map for fast lookup
const keysToResults = zipToMap(allKeysToFetch, allResults);

// re-associate them with original key sets
return keySets.map((keySet) =>
keySet.map((key) => {
const result = keysToResults.get(key);
invariant(result !== undefined, 'result should not be undefined');
return result;
})
);
}

async mget(...args: string[]): Promise<(string | null)[]> {
return await this.mgetBatcher.batchAsync(args);
}

multi(): IRedisTransaction {
return this.redis.multi();
}

async del(...args: string[]): Promise<void> {
await this.redis.del(...args);
}
}

describe(RedisCacheAdapter, () => {
const redis = new Redis(new URL(process.env['REDIS_URL']!).toString());
const redisClient = new BatchedRedis(redis);

let redisCacheAdapterContext: RedisCacheAdapterContext;

beforeAll(() => {
redisCacheAdapterContext = {
redisClient,
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 redis.flushdb();
});

afterAll(async () => {
redis.disconnect();
});

it('has correct caching behavior', async () => {
// simulate two requests
const viewerContext = new ViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
);

const mgetSpy = jest.spyOn(redis, 'mget');

const cacheAdapter = viewerContext.entityCompanionProvider.getCompanionForEntity(
RedisTestEntity,
RedisTestEntity.getCompanionDefinition()
)['tableDataCoordinator']['cacheAdapter'];
const cacheKeyMaker = cacheAdapter['makeCacheKey'].bind(cacheAdapter);

const entity1Created = await RedisTestEntity.creator(viewerContext)
.setField('name', 'blah')
.enforceCreateAsync();

// loading an entity should put it in cache. load by multiple requests and multiple fields in same tick to ensure batch works
mgetSpy.mockClear();
const viewerContext1 = new ViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
);
const viewerContext2 = new ViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
);
const viewerContext3 = new ViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
);
const [entity1, entity2, entity3] = await Promise.all([
RedisTestEntity.loader(viewerContext1).enforcing().loadByIDAsync(entity1Created.getID()),
RedisTestEntity.loader(viewerContext2).enforcing().loadByIDAsync(entity1Created.getID()),
RedisTestEntity.loader(viewerContext3)
.enforcing()
.loadByFieldEqualingAsync('name', entity1Created.getField('name')),
]);

expect(mgetSpy).toHaveBeenCalledTimes(1);
expect(mgetSpy.mock.calls[0]).toHaveLength(2); // should dedupe the first two loads
expect(entity1.getID()).toEqual(entity2.getID());
expect(entity2.getID()).toEqual(nullthrows(entity3).getID());

const cacheKeyEntity1 = cacheKeyMaker('id', entity1Created.getID());
const cachedJSON = await redis.get(cacheKeyEntity1);
const cachedValue = JSON.parse(cachedJSON!);
expect(cachedValue).toMatchObject({
id: entity1.getID(),
name: 'blah',
});

const cacheKeyEntity1NameField = cacheKeyMaker('name', entity1Created.getField('name'));
await RedisTestEntity.loader(viewerContext)
.enforcing()
.loadByFieldEqualingAsync('name', entity1Created.getField('name'));
await expect(redis.get(cacheKeyEntity1NameField)).resolves.toEqual(cachedJSON);

// simulate non existent db fetch, should write negative result ('') to cache
const nonExistentId = uuidv4();
const entityNonExistentResult = await RedisTestEntity.loader(viewerContext).loadByIDAsync(
nonExistentId
);
expect(entityNonExistentResult.ok).toBe(false);
const cacheKeyNonExistent = cacheKeyMaker('id', nonExistentId);
const nonExistentCachedValue = await redis.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(redis.get(cacheKeyEntity1)).resolves.toBeNull();
await expect(redis.get(cacheKeyEntity1NameField)).resolves.toBeNull();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import {
import { RedisCacheAdapterContext } from '../RedisCacheAdapter';
import RedisCacheAdapterProvider from '../RedisCacheAdapterProvider';

// share across all in calls in test to simulate postgres
const adapterProvider = new StubDatabaseAdapterProvider();

export const createRedisIntegrationTestEntityCompanionProvider = (
redisCacheAdapterContext: RedisCacheAdapterContext,
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
Expand All @@ -19,7 +22,7 @@ export const createRedisIntegrationTestEntityCompanionProvider = (
[
'postgres',
{
adapterProvider: new StubDatabaseAdapterProvider(),
adapterProvider,
queryContextProvider: StubQueryContextProvider,
},
],
Expand Down
19 changes: 12 additions & 7 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -367,32 +367,37 @@
minimatch "^3.0.4"
strip-json-comments "^3.1.1"

"@expo/batcher@^1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@expo/batcher/-/batcher-1.0.0.tgz#325f4d36e2c846de6e72cdc024dbb45136508646"
integrity sha512-reebgVwjf8VfZxSXU7e+UjpXGwcUTIMpWR9FY54Oh70ulhXrQiZei62B4D9bH3SVYMwnDGzifHJ8INRrJ+0L1g==

"@expo/entity-cache-adapter-local-memory@file:packages/entity-cache-adapter-local-memory":
version "0.26.1"
version "0.28.0"
dependencies:
lru-cache "^6.0.0"

"@expo/entity-cache-adapter-redis@file:packages/entity-cache-adapter-redis":
version "0.26.1"
version "0.28.0"

"@expo/entity-database-adapter-knex@file:packages/entity-database-adapter-knex":
version "0.26.1"
version "0.28.0"
dependencies:
knex "^1.0.2"

"@expo/entity-ip-address-field@file:packages/entity-ip-address-field":
version "0.26.1"
version "0.28.0"
dependencies:
ip-address "^8.1.0"

"@expo/entity-secondary-cache-local-memory@file:packages/entity-secondary-cache-local-memory":
version "0.26.1"
version "0.28.0"

"@expo/entity-secondary-cache-redis@file:packages/entity-secondary-cache-redis":
version "0.26.1"
version "0.28.0"

"@expo/entity@file:packages/entity":
version "0.26.1"
version "0.28.0"
dependencies:
"@expo/results" "^1.0.0"
dataloader "^2.0.0"
Expand Down

0 comments on commit 684f91b

Please sign in to comment.