Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
231 changes: 231 additions & 0 deletions src/FastCache.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,237 @@ describe('FastCache', () => {
});
});

describe('sortedSet', () => {
describe('add', () => {
test('값을 추가하면 정상적으로 저장된다', async () => {
const sortedSet = cache.sortedSet('hello');
sortedSet.add(100, 'foo');
sortedSet.add(200, 'bar');
sortedSet.add(300, 'baz');

const result = await sortedSet.range({ start: 0, stop: 2, withScores: true });
expect(result).toEqual([
{ value: 'foo', score: 100 },
{ value: 'bar', score: 200 },
{ value: 'baz', score: 300 },
]);
});
});

describe('addAll', () => {
test('여러 값을 순서와 상관없이 넣어도 정렬된 값으로 저장된다', async () => {
const sortedSet = cache.sortedSet('hello');
await sortedSet.addAll([
{ score: 300, value: 'baz' },
{ score: 200, value: 'bar' },
{ score: 100, value: 'foo' },
{ score: 400, value: 'qux' },
{ score: 500, value: 'quux' },
]);

const result1 = await sortedSet.range({ start: 0, stop: 2, withScores: true });
const result2 = await sortedSet.range({ start: 3, stop: 4, withScores: true });

expect(result1).toEqual([
{ value: 'foo', score: 100 },
{ value: 'bar', score: 200 },
{ value: 'baz', score: 300 },
]);
expect(result2).toEqual([
{ value: 'qux', score: 400 },
{ value: 'quux', score: 500 },
]);
});
});

describe('remove', () => {
test('값을 삭제하면 해당 값이 사라진다', async () => {
const sortedSet = cache.sortedSet('hello');

await sortedSet.addAll([
{ score: 100, value: 'foo' },
{ score: 200, value: 'bar' },
{ score: 300, value: 'baz' },
]);
await sortedSet.remove('foo');

const result = await sortedSet.range({ start: 0, stop: 2, withScores: true });

expect(result).toEqual([
{ value: 'bar', score: 200 },
{ value: 'baz', score: 300 },
]);
});
});

describe('range', () => {
test('score 없이 조회하면 값만 반환된다', async () => {
const sortedSet = cache.sortedSet('hello');

await sortedSet.addAll([
{ score: 100, value: 'foo' },
{ score: 200, value: 'bar' },
{ score: 300, value: 'baz' },
]);

const result = await sortedSet.range({ start: 0, stop: 2, withScores: false });

expect(result).toEqual(['foo', 'bar', 'baz']);
});

test('score와 함께 조회하면 값과 score가 반환된다', async () => {
const sortedSet = cache.sortedSet('hello');
await sortedSet.addAll([
{ score: 100, value: 'foo' },
{ score: 200, value: 'bar' },
{ score: 300, value: 'baz' },
]);

const result = await sortedSet.range({ start: 0, stop: 2, withScores: true });

expect(result).toEqual([
{ value: 'foo', score: 100 },
{ value: 'bar', score: 200 },
{ value: 'baz', score: 300 },
]);
});

test('reverse 옵션을 주면 역순으로 조회된다', async () => {
const sortedSet = cache.sortedSet('hello');
await sortedSet.addAll([
{ score: 100, value: 'foo' },
{ score: 200, value: 'bar' },
{ score: 300, value: 'baz' },
]);

const result = await sortedSet.range({ start: 0, stop: 2, withScores: true, reverse: true });

expect(result).toEqual([
{ value: 'baz', score: 300 },
{ value: 'bar', score: 200 },
{ value: 'foo', score: 100 },
]);
});
});

describe('rangeByScore', () => {
test('score 없이 score 범위로 조회하면 값만 반환된다', async () => {
const sortedSet = cache.sortedSet('hello');
await sortedSet.addAll([
{ score: 100, value: 'foo' },
{ score: 200, value: 'bar' },
{ score: 300, value: 'baz' },
]);
const result = await sortedSet.rangeByScore({ min: 150, max: 250 });
expect(result).toEqual(['bar']);
});

test('score와 함께 score 범위로 조회하면 값과 score가 반환된다', async () => {
const sortedSet = cache.sortedSet('hello');
await sortedSet.addAll([
{ score: 100, value: 'foo' },
{ score: 200, value: 'bar' },
{ score: 300, value: 'baz' },
]);
const result = await sortedSet.rangeByScore({ min: 150, max: 250, withScores: true });
expect(result).toEqual([{ value: 'bar', score: 200 }]);
});
});

describe('score', () => {
test('특정 값의 score를 조회할 수 있다', async () => {
const sortedSet = cache.sortedSet('hello');
await sortedSet.addAll([
{ score: 100, value: 'foo' },
{ score: 200, value: 'bar' },
]);

const result1 = await sortedSet.score('foo');
const result2 = await sortedSet.score('bar');
const result3 = await sortedSet.score('__not_found__');

expect(result1).toBe(100);
expect(result2).toBe(200);
expect(result3).toBeNull();
});
});

describe('length', () => {
test('전체 값의 개수를 조회할 수 있다', async () => {
const sortedSet = cache.sortedSet('hello');
await sortedSet.addAll([
{ score: 100, value: 'foo' },
{ score: 200, value: 'bar' },
{ score: 300, value: 'baz' },
]);

const result = await sortedSet.length();

expect(result).toBe(3);
});
});

describe('clear', () => {
test('전체 값을 삭제하면 길이가 0이 된다', async () => {
const sortedSet = cache.sortedSet('hello');
await sortedSet.addAll([
{ score: 100, value: 'foo' },
{ score: 200, value: 'bar' },
{ score: 300, value: 'baz' },
]);
await sortedSet.clear();

const result = await sortedSet.length();
expect(result).toBe(0);
});
});

describe('replaceAll', () => {
test('전체 값을 새로운 값으로 교체할 수 있다', async () => {
const sortedSet = cache.sortedSet('hello');
await sortedSet.addAll([
{ score: 100, value: 'foo' },
{ score: 200, value: 'bar' },
{ score: 300, value: 'baz' },
]);
const newEntries = [
{ score: 400, value: 'qux' },
{ score: 500, value: 'quux' },
];

await sortedSet.replaceAll(newEntries);

const [values, tempKeys] = await Promise.all([
sortedSet.range({ start: 0, stop: -1, withScores: true }),
client.keys('hello:temp:*'),
]);

expect(values).toEqual([
{ value: 'qux', score: 400 },
{ value: 'quux', score: 500 },
]);
// tempKeys는 존재하지 않아야 한다.
expect(tempKeys).toHaveLength(0);
});

test('score가 number가 아니면 예외가 발생하고 임시키가 정리된다', async () => {
const sortedSet = cache.sortedSet('hello');
const invalidEntries = [{ score: 'invalid' as any, value: 'qux' }];

await expect(sortedSet.replaceAll(invalidEntries)).rejects.toThrow('score is not a number');

const [tempKeys, values] = await Promise.all([
client.keys('hello:temp:*'),
sortedSet.range({ start: 0, stop: -1 }),
]);

// tempKeys는 존재하지 않아야 한다.
expect(tempKeys).toHaveLength(0);
expect(values).toHaveLength(0);
});
});
});

describe('withCache', () => {
test('should be set after next tick', (done) => {
const a = { foo: 100 };
Expand Down
136 changes: 136 additions & 0 deletions src/FastCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,39 @@ export interface SetOperations {
length(): Promise<number>;
}

export interface SortedSetOperations {
key: string;
add(score: number, value: string): Promise<void>;
addAll(entries: Array<{ score: number; value: string }>): Promise<void>;
remove(...values: Array<string>): Promise<void>;
range({
start,
stop,
withScores,
reverse,
}: {
start: number;
stop: number;
withScores?: boolean;
reverse?: boolean;
}): Promise<Array<string | { score: number; value: string }>>;
rangeByScore({
min,
max,
withScores,
reverse,
}: {
min: number;
max: number;
withScores?: boolean;
reverse?: boolean;
}): Promise<Array<string | { score: number; value: string }>>;
score(value: string): Promise<number | null>;
length(): Promise<number>;
clear(): Promise<void>;
replaceAll(entries: Array<{ score: number; value: string }>): Promise<void>;
}

// todo: rename fastCache to redisCache
export class FastCache {
static create(opts?: FastCacheOpts): FastCache {
Expand Down Expand Up @@ -182,6 +215,109 @@ export class FastCache {
};
}

sortedSet(key: string): SortedSetOperations {
return {
key,
add: async (score: number, value: string): Promise<void> => {
await this.client.zadd(key, score, value);
},
addAll: async (entries: Array<{ score: number; value: string }>): Promise<void> => {
if (entries.length === 0) {
return;
}

const args = entries.flatMap((entry) => [entry.score, entry.value]);
await this.client.zadd(key, ...args);
},
remove: async (...values: Array<string>): Promise<void> => {
await this.client.zrem(key, ...values);
},
range: async ({
start,
stop,
withScores = false,
reverse = false,
}: {
start: number;
stop: number;
withScores?: boolean;
reverse?: boolean;
}): Promise<Array<string | { score: number; value: string }>> => {
const method = reverse ? 'zrevrange' : 'zrange';
const result = withScores
? await this.client[method](key, start, stop, 'WITHSCORES')
: await this.client[method](key, start, stop);

if (!withScores) {
return result;
}

const entries: Array<{ score: number; value: string }> = [];
for (let i = 0; i < result.length; i += 2) {
entries.push({
value: result[i],
score: parseFloat(result[i + 1]),
});
}
return entries;
},
rangeByScore: async ({
min,
max,
withScores = false,
reverse = false,
}: {
min: number;
max: number;
withScores?: boolean;
reverse?: boolean;
}): Promise<Array<string | { score: number; value: string }>> => {
const method = reverse ? 'zrevrangebyscore' : 'zrangebyscore';
const result = withScores
? await this.client[method](key, min, max, 'WITHSCORES')
: await this.client[method](key, min, max);

if (!withScores) {
return result;
}

const entries: Array<{ score: number; value: string }> = [];
for (let i = 0; i < result.length; i += 2) {
entries.push({
value: result[i],
score: parseFloat(result[i + 1]),
});
}
return entries;
},
score: async (value: string): Promise<number | null> => {
const score = await this.client.zscore(key, value);
return score !== null ? parseFloat(score) : null;
},
length: async (): Promise<number> => this.client.zcard(key),
clear: async (): Promise<void> => {
await this.client.del(key);
},
replaceAll: async (entries: Array<{ score: number; value: string }>): Promise<void> => {
if (entries.length === 0) {
await this.client.del(key);
return;
}

if (entries.some((entry) => typeof entry.score !== 'number')) {
throw new Error('score is not a number');
}

const tempKey = `${key}:temp:${Date.now()}`;

const args = entries.flatMap((entry) => [entry.score, entry.value]);
await this.client.zadd(tempKey, ...args);
await this.client.expire(tempKey, 60);
await this.client.rename(tempKey, key);
},
};
}

//---------------------------------------------------------

public async withCache(key: string, executor: () => Promise<unknown>): Promise<unknown> {
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ export {
ListOperations,
MapOperations,
SetOperations,
SortedSetOperations,
} from './FastCache';
export { InMemoryCache } from './InMemoryCache';