diff --git a/.changeset/redis-usage-timestamps.md b/.changeset/redis-usage-timestamps.md new file mode 100644 index 000000000..56e06714d --- /dev/null +++ b/.changeset/redis-usage-timestamps.md @@ -0,0 +1,6 @@ +--- +"@croco/metering-core": patch +"@croco/metering-upstash": patch +--- + +Restore Redis usage record timestamps from sorted set scores when records are fetched. diff --git a/README.md b/README.md index a28fe0d1b..716acbb19 100644 --- a/README.md +++ b/README.md @@ -434,7 +434,7 @@ Croco가 **완전한 SaaS 프레임워크**가 되기 위해 계획 중인 기 | `@croco/preset-cloudflare` | Tooling | `packages/preset-cloudflare` | tests | | `@croco/preset-lambda` | Tooling | `packages/preset-lambda` | tests | | `@croco/preset-node` | Tooling | `packages/preset-node` | tests | -| `@croco/testing` | Tooling | `packages/testing` | README, API, tests | +| `@croco/testing` | Tooling | `packages/testing` | README, tests | | `@croco/transports-graphql` | Transport | `packages/transports-graphql` | tests | ### 🔴 alpha/WIP diff --git a/docs/package-docs-baseline.json b/docs/package-docs-baseline.json index cfdf4e860..761708b5b 100644 --- a/docs/package-docs-baseline.json +++ b/docs/package-docs-baseline.json @@ -106,6 +106,7 @@ "tasks-core", "tasks-qstash", "tenant-core", + "testing", "transports-cloudflare-workers", "transports-graphql", "triggers-core", diff --git a/docs/package-docs-report.md b/docs/package-docs-report.md index 124044740..e51459d52 100644 --- a/docs/package-docs-report.md +++ b/docs/package-docs-report.md @@ -9,7 +9,7 @@ | Public packages | 98 | | Private packages skipped | 1 | | Missing package README | 24 | -| Missing generated API docs | 85 | +| Missing generated API docs | 86 | | Missing package test directory | 1 | New public packages must not add missing README, API docs, or test coverage unless the gap is explicitly listed in `docs/package-docs-baseline.json`. @@ -122,6 +122,7 @@ New public packages must not add missing README, API docs, or test coverage unle - `@croco/tasks-core` (`packages/tasks-core`) — legacy baseline - `@croco/tasks-qstash` (`packages/tasks-qstash`) — legacy baseline - `@croco/tenant-core` (`packages/tenant-core`) — legacy baseline +- `@croco/testing` (`packages/testing`) — legacy baseline - `@croco/transports-cloudflare-workers` (`packages/transports-cloudflare-workers`) — legacy baseline - `@croco/transports-graphql` (`packages/transports-graphql`) — legacy baseline - `@croco/triggers-core` (`packages/triggers-core`) — legacy baseline diff --git a/packages/docs/src/content/docs/api/metering-core/src/interfaces/RedisClient.md b/packages/docs/src/content/docs/api/metering-core/src/interfaces/RedisClient.md index 3abb41e81..f0f19dd33 100644 --- a/packages/docs/src/content/docs/api/metering-core/src/interfaces/RedisClient.md +++ b/packages/docs/src/content/docs/api/metering-core/src/interfaces/RedisClient.md @@ -103,24 +103,52 @@ Sorted Set에 멤버 추가 ### zrangebyscore() +#### Call Signature + > **zrangebyscore**(`key`, `min`, `max`): `Promise`\<`string`[]\> Sorted Set에서 점수 범위로 멤버 조회 -#### Parameters +##### Parameters -##### key +###### key `string` -##### min +###### min `number` -##### max +###### max `number` -#### Returns +##### Returns + +`Promise`\<`string`[]\> + +#### Call Signature + +> **zrangebyscore**(`key`, `min`, `max`, `withScores`): `Promise`\<`string`[]\> + +##### Parameters + +###### key + +`string` + +###### min + +`number` + +###### max + +`number` + +###### withScores + +`"WITHSCORES"` + +##### Returns `Promise`\<`string`[]\> diff --git a/packages/docs/src/content/docs/api/testing/index.md b/packages/docs/src/content/docs/api/testing/index.md deleted file mode 100644 index d4e51dae4..000000000 --- a/packages/docs/src/content/docs/api/testing/index.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -editUrl: false -next: false -prev: false -title: "@croco/testing" ---- - -`@croco/testing` provides Croco application test harness helpers for in-memory HTTP request injection, DI reset, Problem Details assertions, OpenAPI route assertions, and generated RPC client round trips. diff --git a/packages/metering-core/src/libs/RedisClient.ts b/packages/metering-core/src/libs/RedisClient.ts index deb4baa77..7a38eef23 100644 --- a/packages/metering-core/src/libs/RedisClient.ts +++ b/packages/metering-core/src/libs/RedisClient.ts @@ -11,6 +11,7 @@ export interface RedisClient { * Sorted Set에서 점수 범위로 멤버 조회 */ zrangebyscore(key: string, min: number, max: number): Promise; + zrangebyscore(key: string, min: number, max: number, withScores: "WITHSCORES"): Promise; /** * 키 설정 (NX: 존재하지 않을 때만, EX: TTL) diff --git a/packages/metering-core/src/libs/RedisUsageStorage.ts b/packages/metering-core/src/libs/RedisUsageStorage.ts index 8d5206eb2..6e1056f82 100644 --- a/packages/metering-core/src/libs/RedisUsageStorage.ts +++ b/packages/metering-core/src/libs/RedisUsageStorage.ts @@ -176,9 +176,9 @@ return { exceeded and 1 or 0, newUsage } async fetchUsageRecords(options: UsageQueryOptions): Promise { try { - const { members } = await this.readUsageMembers(options); + const { members } = await this.readUsageMembers(options, "WITHSCORES"); - return members.map((member) => { + return this.parseScoredUsageMembers(members).map(({ member, score }) => { const parsed = this.parseUsageMember(member); return { @@ -186,7 +186,7 @@ return { exceeded and 1 or 0, newUsage } tenantId: options.tenantId, meterId: options.meterId, value: parsed.value, - timestamp: new Date(), // Score에서 복원해야 하지만 단순화 + timestamp: this.restoreUsageTimestamp(score), idempotencyKey: parsed.id, metadata: parsed.metadata, }; @@ -226,13 +226,17 @@ return { exceeded and 1 or 0, newUsage } private async readUsageMembers( options: UsageQueryOptions, + withScores?: "WITHSCORES", ): Promise<{ key: string; members: string[] }> { const { tenantId, meterId, period, startDate, endDate } = options; const { min, max } = this.getTimeRange(period, startDate, endDate); const candidates = this.getUsageKeyCandidates(tenantId, meterId, new Date(min), period); for (const key of candidates) { - const members = await this.redis.zrangebyscore(key, min, max); + const members = + withScores === "WITHSCORES" + ? await this.redis.zrangebyscore(key, min, max, withScores) + : await this.redis.zrangebyscore(key, min, max); if (members.length > 0) { return { key, members }; } @@ -295,6 +299,41 @@ return { exceeded and 1 or 0, newUsage } }; } + private parseScoredUsageMembers( + membersWithScores: string[], + ): Array<{ member: string; score: number }> { + if (membersWithScores.length % 2 !== 0) { + throw new Error("Redis ZRANGEBYSCORE WITHSCORES returned an odd number of values"); + } + + const members: Array<{ member: string; score: number }> = []; + + for (let index = 0; index < membersWithScores.length; index += 2) { + const member = membersWithScores[index] ?? ""; + const score = Number(membersWithScores[index + 1]); + + if (!Number.isFinite(score)) { + throw new Error( + `Redis ZRANGEBYSCORE WITHSCORES returned invalid score '${membersWithScores[index + 1]}'`, + ); + } + + members.push({ member, score }); + } + + return members; + } + + private restoreUsageTimestamp(score: number): Date { + const timestamp = new Date(score); + + if (Number.isNaN(timestamp.getTime())) { + throw new Error(`Redis usage timestamp score '${score}' is not a valid Date`); + } + + return timestamp; + } + private decodeMetadata(encodedMetadata: string): Record | undefined { try { const decoded = decodeURIComponent(encodedMetadata); diff --git a/packages/metering-core/src/tests/RedisUsageStorage.spec.ts b/packages/metering-core/src/tests/RedisUsageStorage.spec.ts index a76b9fa1b..ed1a75272 100644 --- a/packages/metering-core/src/tests/RedisUsageStorage.spec.ts +++ b/packages/metering-core/src/tests/RedisUsageStorage.spec.ts @@ -208,15 +208,24 @@ describe("RedisUsageStorage", () => { }); describe("fetchUsageRecords", () => { - it("should return parsed usage records", async () => { - vi.mocked(mockRedis.zrangebyscore).mockResolvedValue(["usage-1:5", "usage-2:3"]); + it("should return parsed usage records with timestamps restored from Redis scores", async () => { + const firstTimestamp = new Date("2024-01-15T10:30:00Z"); + const secondTimestamp = new Date("2024-01-15T11:45:00Z"); + vi.mocked(mockRedis.zrangebyscore).mockResolvedValue([ + "usage-1:5", + String(firstTimestamp.getTime()), + "usage-2:3", + String(secondTimestamp.getTime()), + ]); + const startDate = new Date("2024-01-15T00:00:00Z"); + const endDate = new Date("2024-01-15T23:59:59Z"); const options: UsageQueryOptions = { tenantId: "tenant-1", meterId: "api_calls", period: "day", - startDate: new Date("2024-01-15T00:00:00Z"), - endDate: new Date("2024-01-15T23:59:59Z"), + startDate, + endDate, }; const result = await storage.fetchUsageRecords(options); @@ -224,13 +233,22 @@ describe("RedisUsageStorage", () => { expect(result).toHaveLength(2); expect(result[0].id).toBe("usage-1"); expect(result[0].value).toBe(5); + expect(result[0].timestamp).toEqual(firstTimestamp); expect(result[1].id).toBe("usage-2"); expect(result[1].value).toBe(3); + expect(result[1].timestamp).toEqual(secondTimestamp); + expect(mockRedis.zrangebyscore).toHaveBeenCalledWith( + "usage:tenant-1:api_calls:2024-01-15", + startDate.getTime(), + endDate.getTime(), + "WITHSCORES", + ); }); it("should preserve metadata when fetching usage records", async () => { vi.mocked(mockRedis.zrangebyscore).mockResolvedValue([ "usage-1:5:%7B%22endpoint%22%3A%22%2Fusers%22%2C%22nested%22%3A%7B%22active%22%3Atrue%7D%7D", + String(new Date("2024-01-15T10:30:00Z").getTime()), ]); const result = await storage.fetchUsageRecords({ @@ -248,7 +266,10 @@ describe("RedisUsageStorage", () => { it("should preserve metadata when records are fetched", async () => { const metadata = encodeURIComponent(JSON.stringify({ source: "api", version: "v2" })); - vi.mocked(mockRedis.zrangebyscore).mockResolvedValue([`usage-1:5:${metadata}`]); + vi.mocked(mockRedis.zrangebyscore).mockResolvedValue([ + `usage-1:5:${metadata}`, + String(new Date("2024-01-15T10:30:00Z").getTime()), + ]); const result = await storage.fetchUsageRecords({ tenantId: "tenant-1", @@ -275,6 +296,20 @@ describe("RedisUsageStorage", () => { expect(result).toEqual([]); }); + it("should throw RedisProblem when Redis omits scores for fetched records", async () => { + vi.mocked(mockRedis.zrangebyscore).mockResolvedValue(["usage-1:5"]); + + await expect( + storage.fetchUsageRecords({ + tenantId: "tenant-1", + meterId: "api_calls", + period: "day", + startDate: new Date("2024-01-15T00:00:00Z"), + endDate: new Date("2024-01-15T23:59:59Z"), + }), + ).rejects.toThrow(RedisProblem); + }); + it("should remove flushed records from Redis", async () => { vi.mocked(mockRedis.eval).mockResolvedValue([1]); diff --git a/packages/metering-upstash/src/libs/UpstashRedisClient.ts b/packages/metering-upstash/src/libs/UpstashRedisClient.ts index f7cb78a30..8699e98e0 100644 --- a/packages/metering-upstash/src/libs/UpstashRedisClient.ts +++ b/packages/metering-upstash/src/libs/UpstashRedisClient.ts @@ -18,8 +18,16 @@ export class UpstashRedisClient implements RedisClient { /** * Sorted Set에서 점수 범위로 멤버 조회 */ - async zrangebyscore(key: string, min: number, max: number): Promise { - const result = await this.redis.zrange(key, min, max, { byScore: true }); + async zrangebyscore( + key: string, + min: number, + max: number, + withScores?: "WITHSCORES", + ): Promise { + const result = await this.redis.zrange(key, min, max, { + byScore: true, + ...(withScores === "WITHSCORES" ? { withScores: true } : {}), + }); return result.map((item) => String(item)); } diff --git a/packages/metering-upstash/src/tests/UpstashRedisClient.spec.ts b/packages/metering-upstash/src/tests/UpstashRedisClient.spec.ts index 694394d2b..36642c1b7 100644 --- a/packages/metering-upstash/src/tests/UpstashRedisClient.spec.ts +++ b/packages/metering-upstash/src/tests/UpstashRedisClient.spec.ts @@ -59,6 +59,18 @@ describe("UpstashRedisClient", () => { expect(result).toEqual(["123", "456"]); }); + it("should request scores when WITHSCORES is passed", async () => { + vi.mocked(mockRedis.zrange).mockResolvedValue(["member1", 100, "member2", 200]); + + const result = await client.zrangebyscore("test-key", 100, 200, "WITHSCORES"); + + expect(mockRedis.zrange).toHaveBeenCalledWith("test-key", 100, 200, { + byScore: true, + withScores: true, + }); + expect(result).toEqual(["member1", "100", "member2", "200"]); + }); + it("should return empty array when no results", async () => { vi.mocked(mockRedis.zrange).mockResolvedValue([]);