Skip to content

Commit 8e8d844

Browse files
committed
feat: sorted set 추가
1 parent 9898147 commit 8e8d844

3 files changed

Lines changed: 368 additions & 0 deletions

File tree

src/FastCache.spec.ts

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,237 @@ describe('FastCache', () => {
202202
});
203203
});
204204

205+
describe('sortedSet', () => {
206+
describe('add', () => {
207+
test('값을 추가하면 정상적으로 저장된다', async () => {
208+
const sortedSet = cache.sortedSet('hello');
209+
sortedSet.add(100, 'foo');
210+
sortedSet.add(200, 'bar');
211+
sortedSet.add(300, 'baz');
212+
213+
const result = await sortedSet.range({ start: 0, stop: 2, withScores: true });
214+
expect(result).toEqual([
215+
{ value: 'foo', score: 100 },
216+
{ value: 'bar', score: 200 },
217+
{ value: 'baz', score: 300 },
218+
]);
219+
});
220+
});
221+
222+
describe('addAll', () => {
223+
test('여러 값을 순서와 상관없이 넣어도 정렬된 값으로 저장된다', async () => {
224+
const sortedSet = cache.sortedSet('hello');
225+
await sortedSet.addAll([
226+
{ score: 300, value: 'baz' },
227+
{ score: 200, value: 'bar' },
228+
{ score: 100, value: 'foo' },
229+
{ score: 400, value: 'qux' },
230+
{ score: 500, value: 'quux' },
231+
]);
232+
233+
const result1 = await sortedSet.range({ start: 0, stop: 2, withScores: true });
234+
const result2 = await sortedSet.range({ start: 3, stop: 4, withScores: true });
235+
236+
expect(result1).toEqual([
237+
{ value: 'foo', score: 100 },
238+
{ value: 'bar', score: 200 },
239+
{ value: 'baz', score: 300 },
240+
]);
241+
expect(result2).toEqual([
242+
{ value: 'qux', score: 400 },
243+
{ value: 'quux', score: 500 },
244+
]);
245+
});
246+
});
247+
248+
describe('remove', () => {
249+
test('값을 삭제하면 해당 값이 사라진다', async () => {
250+
const sortedSet = cache.sortedSet('hello');
251+
252+
await sortedSet.addAll([
253+
{ score: 100, value: 'foo' },
254+
{ score: 200, value: 'bar' },
255+
{ score: 300, value: 'baz' },
256+
]);
257+
await sortedSet.remove('foo');
258+
259+
const result = await sortedSet.range({ start: 0, stop: 2, withScores: true });
260+
261+
expect(result).toEqual([
262+
{ value: 'bar', score: 200 },
263+
{ value: 'baz', score: 300 },
264+
]);
265+
});
266+
});
267+
268+
describe('range', () => {
269+
test('score 없이 조회하면 값만 반환된다', async () => {
270+
const sortedSet = cache.sortedSet('hello');
271+
272+
await sortedSet.addAll([
273+
{ score: 100, value: 'foo' },
274+
{ score: 200, value: 'bar' },
275+
{ score: 300, value: 'baz' },
276+
]);
277+
278+
const result = await sortedSet.range({ start: 0, stop: 2, withScores: false });
279+
280+
expect(result).toEqual(['foo', 'bar', 'baz']);
281+
});
282+
283+
test('score와 함께 조회하면 값과 score가 반환된다', async () => {
284+
const sortedSet = cache.sortedSet('hello');
285+
await sortedSet.addAll([
286+
{ score: 100, value: 'foo' },
287+
{ score: 200, value: 'bar' },
288+
{ score: 300, value: 'baz' },
289+
]);
290+
291+
const result = await sortedSet.range({ start: 0, stop: 2, withScores: true });
292+
293+
expect(result).toEqual([
294+
{ value: 'foo', score: 100 },
295+
{ value: 'bar', score: 200 },
296+
{ value: 'baz', score: 300 },
297+
]);
298+
});
299+
300+
test('reverse 옵션을 주면 역순으로 조회된다', async () => {
301+
const sortedSet = cache.sortedSet('hello');
302+
await sortedSet.addAll([
303+
{ score: 100, value: 'foo' },
304+
{ score: 200, value: 'bar' },
305+
{ score: 300, value: 'baz' },
306+
]);
307+
308+
const result = await sortedSet.range({ start: 0, stop: 2, withScores: true, reverse: true });
309+
310+
expect(result).toEqual([
311+
{ value: 'baz', score: 300 },
312+
{ value: 'bar', score: 200 },
313+
{ value: 'foo', score: 100 },
314+
]);
315+
});
316+
});
317+
318+
describe('rangeByScore', () => {
319+
test('score 없이 score 범위로 조회하면 값만 반환된다', async () => {
320+
const sortedSet = cache.sortedSet('hello');
321+
await sortedSet.addAll([
322+
{ score: 100, value: 'foo' },
323+
{ score: 200, value: 'bar' },
324+
{ score: 300, value: 'baz' },
325+
]);
326+
const result = await sortedSet.rangeByScore({ min: 150, max: 250 });
327+
expect(result).toEqual(['bar']);
328+
});
329+
330+
test('score와 함께 score 범위로 조회하면 값과 score가 반환된다', async () => {
331+
const sortedSet = cache.sortedSet('hello');
332+
await sortedSet.addAll([
333+
{ score: 100, value: 'foo' },
334+
{ score: 200, value: 'bar' },
335+
{ score: 300, value: 'baz' },
336+
]);
337+
const result = await sortedSet.rangeByScore({ min: 150, max: 250, withScores: true });
338+
expect(result).toEqual([{ value: 'bar', score: 200 }]);
339+
});
340+
});
341+
342+
describe('score', () => {
343+
test('특정 값의 score를 조회할 수 있다', async () => {
344+
const sortedSet = cache.sortedSet('hello');
345+
await sortedSet.addAll([
346+
{ score: 100, value: 'foo' },
347+
{ score: 200, value: 'bar' },
348+
]);
349+
350+
const result1 = await sortedSet.score('foo');
351+
const result2 = await sortedSet.score('bar');
352+
const result3 = await sortedSet.score('__not_found__');
353+
354+
expect(result1).toBe(100);
355+
expect(result2).toBe(200);
356+
expect(result3).toBeNull();
357+
});
358+
});
359+
360+
describe('length', () => {
361+
test('전체 값의 개수를 조회할 수 있다', async () => {
362+
const sortedSet = cache.sortedSet('hello');
363+
await sortedSet.addAll([
364+
{ score: 100, value: 'foo' },
365+
{ score: 200, value: 'bar' },
366+
{ score: 300, value: 'baz' },
367+
]);
368+
369+
const result = await sortedSet.length();
370+
371+
expect(result).toBe(3);
372+
});
373+
});
374+
375+
describe('clear', () => {
376+
test('전체 값을 삭제하면 길이가 0이 된다', async () => {
377+
const sortedSet = cache.sortedSet('hello');
378+
await sortedSet.addAll([
379+
{ score: 100, value: 'foo' },
380+
{ score: 200, value: 'bar' },
381+
{ score: 300, value: 'baz' },
382+
]);
383+
await sortedSet.clear();
384+
385+
const result = await sortedSet.length();
386+
expect(result).toBe(0);
387+
});
388+
});
389+
390+
describe('replaceAll', () => {
391+
test('전체 값을 새로운 값으로 교체할 수 있다', async () => {
392+
const sortedSet = cache.sortedSet('hello');
393+
await sortedSet.addAll([
394+
{ score: 100, value: 'foo' },
395+
{ score: 200, value: 'bar' },
396+
{ score: 300, value: 'baz' },
397+
]);
398+
const newEntries = [
399+
{ score: 400, value: 'qux' },
400+
{ score: 500, value: 'quux' },
401+
];
402+
403+
await sortedSet.replaceAll(newEntries);
404+
405+
const [values, tempKeys] = await Promise.all([
406+
sortedSet.range({ start: 0, stop: -1, withScores: true }),
407+
client.keys('hello:temp:*'),
408+
]);
409+
410+
expect(values).toEqual([
411+
{ value: 'qux', score: 400 },
412+
{ value: 'quux', score: 500 },
413+
]);
414+
// tempKeys는 존재하지 않아야 한다.
415+
expect(tempKeys).toHaveLength(0);
416+
});
417+
418+
test('score가 number가 아니면 예외가 발생하고 임시키가 정리된다', async () => {
419+
const sortedSet = cache.sortedSet('hello');
420+
const invalidEntries = [{ score: 'invalid' as any, value: 'qux' }];
421+
422+
await expect(sortedSet.replaceAll(invalidEntries)).rejects.toThrow('score is not a number');
423+
424+
const [tempKeys, values] = await Promise.all([
425+
client.keys('hello:temp:*'),
426+
sortedSet.range({ start: 0, stop: -1 }),
427+
]);
428+
429+
// tempKeys는 존재하지 않아야 한다.
430+
expect(tempKeys).toHaveLength(0);
431+
expect(values).toHaveLength(0);
432+
});
433+
});
434+
});
435+
205436
describe('withCache', () => {
206437
test('should be set after next tick', (done) => {
207438
const a = { foo: 100 };

src/FastCache.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,39 @@ export interface SetOperations {
4242
length(): Promise<number>;
4343
}
4444

45+
export interface SortedSetOperations {
46+
key: string;
47+
add(score: number, value: string): Promise<void>;
48+
addAll(entries: Array<{ score: number; value: string }>): Promise<void>;
49+
remove(...values: Array<string>): Promise<void>;
50+
range({
51+
start,
52+
stop,
53+
withScores,
54+
reverse,
55+
}: {
56+
start: number;
57+
stop: number;
58+
withScores?: boolean;
59+
reverse?: boolean;
60+
}): Promise<Array<string | { score: number; value: string }>>;
61+
rangeByScore({
62+
min,
63+
max,
64+
withScores,
65+
reverse,
66+
}: {
67+
min: number;
68+
max: number;
69+
withScores?: boolean;
70+
reverse?: boolean;
71+
}): Promise<Array<string | { score: number; value: string }>>;
72+
score(value: string): Promise<number | null>;
73+
length(): Promise<number>;
74+
clear(): Promise<void>;
75+
replaceAll(entries: Array<{ score: number; value: string }>): Promise<void>;
76+
}
77+
4578
// todo: rename fastCache to redisCache
4679
export class FastCache {
4780
static create(opts?: FastCacheOpts): FastCache {
@@ -182,6 +215,109 @@ export class FastCache {
182215
};
183216
}
184217

218+
sortedSet(key: string): SortedSetOperations {
219+
return {
220+
key,
221+
add: async (score: number, value: string): Promise<void> => {
222+
await this.client.zadd(key, score, value);
223+
},
224+
addAll: async (entries: Array<{ score: number; value: string }>): Promise<void> => {
225+
if (entries.length === 0) {
226+
return;
227+
}
228+
229+
const args = entries.flatMap((entry) => [entry.score, entry.value]);
230+
await this.client.zadd(key, ...args);
231+
},
232+
remove: async (...values: Array<string>): Promise<void> => {
233+
await this.client.zrem(key, ...values);
234+
},
235+
range: async ({
236+
start,
237+
stop,
238+
withScores = false,
239+
reverse = false,
240+
}: {
241+
start: number;
242+
stop: number;
243+
withScores?: boolean;
244+
reverse?: boolean;
245+
}): Promise<Array<string | { score: number; value: string }>> => {
246+
const method = reverse ? 'zrevrange' : 'zrange';
247+
const result = withScores
248+
? await this.client[method](key, start, stop, 'WITHSCORES')
249+
: await this.client[method](key, start, stop);
250+
251+
if (!withScores) {
252+
return result;
253+
}
254+
255+
const entries: Array<{ score: number; value: string }> = [];
256+
for (let i = 0; i < result.length; i += 2) {
257+
entries.push({
258+
value: result[i],
259+
score: parseFloat(result[i + 1]),
260+
});
261+
}
262+
return entries;
263+
},
264+
rangeByScore: async ({
265+
min,
266+
max,
267+
withScores = false,
268+
reverse = false,
269+
}: {
270+
min: number;
271+
max: number;
272+
withScores?: boolean;
273+
reverse?: boolean;
274+
}): Promise<Array<string | { score: number; value: string }>> => {
275+
const method = reverse ? 'zrevrangebyscore' : 'zrangebyscore';
276+
const result = withScores
277+
? await this.client[method](key, min, max, 'WITHSCORES')
278+
: await this.client[method](key, min, max);
279+
280+
if (!withScores) {
281+
return result;
282+
}
283+
284+
const entries: Array<{ score: number; value: string }> = [];
285+
for (let i = 0; i < result.length; i += 2) {
286+
entries.push({
287+
value: result[i],
288+
score: parseFloat(result[i + 1]),
289+
});
290+
}
291+
return entries;
292+
},
293+
score: async (value: string): Promise<number | null> => {
294+
const score = await this.client.zscore(key, value);
295+
return score !== null ? parseFloat(score) : null;
296+
},
297+
length: async (): Promise<number> => this.client.zcard(key),
298+
clear: async (): Promise<void> => {
299+
await this.client.del(key);
300+
},
301+
replaceAll: async (entries: Array<{ score: number; value: string }>): Promise<void> => {
302+
if (entries.length === 0) {
303+
await this.client.del(key);
304+
return;
305+
}
306+
307+
if (entries.some((entry) => typeof entry.score !== 'number')) {
308+
throw new Error('score is not a number');
309+
}
310+
311+
const tempKey = `${key}:temp:${Date.now()}`;
312+
313+
const args = entries.flatMap((entry) => [entry.score, entry.value]);
314+
await this.client.zadd(tempKey, ...args);
315+
await this.client.expire(tempKey, 60);
316+
await this.client.rename(tempKey, key);
317+
},
318+
};
319+
}
320+
185321
//---------------------------------------------------------
186322

187323
public async withCache(key: string, executor: () => Promise<unknown>): Promise<unknown> {

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@ export {
66
ListOperations,
77
MapOperations,
88
SetOperations,
9+
SortedSetOperations,
910
} from './FastCache';
1011
export { InMemoryCache } from './InMemoryCache';

0 commit comments

Comments
 (0)