From 8e8d844b2cd596259f3882cd17d71ec34da3bc82 Mon Sep 17 00:00:00 2001 From: kooks7 Date: Tue, 20 May 2025 09:56:08 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20sorted=20set=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/FastCache.spec.ts | 231 ++++++++++++++++++++++++++++++++++++++++++ src/FastCache.ts | 136 +++++++++++++++++++++++++ src/index.ts | 1 + 3 files changed, 368 insertions(+) diff --git a/src/FastCache.spec.ts b/src/FastCache.spec.ts index a1e10a9..8f957c3 100644 --- a/src/FastCache.spec.ts +++ b/src/FastCache.spec.ts @@ -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 }; diff --git a/src/FastCache.ts b/src/FastCache.ts index 7f85082..f8774d9 100644 --- a/src/FastCache.ts +++ b/src/FastCache.ts @@ -42,6 +42,39 @@ export interface SetOperations { length(): Promise; } +export interface SortedSetOperations { + key: string; + add(score: number, value: string): Promise; + addAll(entries: Array<{ score: number; value: string }>): Promise; + remove(...values: Array): Promise; + range({ + start, + stop, + withScores, + reverse, + }: { + start: number; + stop: number; + withScores?: boolean; + reverse?: boolean; + }): Promise>; + rangeByScore({ + min, + max, + withScores, + reverse, + }: { + min: number; + max: number; + withScores?: boolean; + reverse?: boolean; + }): Promise>; + score(value: string): Promise; + length(): Promise; + clear(): Promise; + replaceAll(entries: Array<{ score: number; value: string }>): Promise; +} + // todo: rename fastCache to redisCache export class FastCache { static create(opts?: FastCacheOpts): FastCache { @@ -182,6 +215,109 @@ export class FastCache { }; } + sortedSet(key: string): SortedSetOperations { + return { + key, + add: async (score: number, value: string): Promise => { + await this.client.zadd(key, score, value); + }, + addAll: async (entries: Array<{ score: number; value: string }>): Promise => { + if (entries.length === 0) { + return; + } + + const args = entries.flatMap((entry) => [entry.score, entry.value]); + await this.client.zadd(key, ...args); + }, + remove: async (...values: Array): Promise => { + await this.client.zrem(key, ...values); + }, + range: async ({ + start, + stop, + withScores = false, + reverse = false, + }: { + start: number; + stop: number; + withScores?: boolean; + reverse?: boolean; + }): Promise> => { + 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> => { + 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 => { + const score = await this.client.zscore(key, value); + return score !== null ? parseFloat(score) : null; + }, + length: async (): Promise => this.client.zcard(key), + clear: async (): Promise => { + await this.client.del(key); + }, + replaceAll: async (entries: Array<{ score: number; value: string }>): Promise => { + 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): Promise { diff --git a/src/index.ts b/src/index.ts index b50f519..947ae50 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,5 +6,6 @@ export { ListOperations, MapOperations, SetOperations, + SortedSetOperations, } from './FastCache'; export { InMemoryCache } from './InMemoryCache';