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
6 changes: 6 additions & 0 deletions .changeset/redis-usage-timestamps.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/package-docs-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@
"tasks-core",
"tasks-qstash",
"tenant-core",
"testing",
"transports-cloudflare-workers",
"transports-graphql",
"triggers-core",
Expand Down
3 changes: 2 additions & 1 deletion docs/package-docs-report.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`[]\>
8 changes: 0 additions & 8 deletions packages/docs/src/content/docs/api/testing/index.md

This file was deleted.

1 change: 1 addition & 0 deletions packages/metering-core/src/libs/RedisClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface RedisClient {
* Sorted Set에서 점수 범위로 멤버 조회
*/
zrangebyscore(key: string, min: number, max: number): Promise<string[]>;
zrangebyscore(key: string, min: number, max: number, withScores: "WITHSCORES"): Promise<string[]>;

/**
* 키 설정 (NX: 존재하지 않을 때만, EX: TTL)
Expand Down
47 changes: 43 additions & 4 deletions packages/metering-core/src/libs/RedisUsageStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,17 +176,17 @@ return { exceeded and 1 or 0, newUsage }

async fetchUsageRecords(options: UsageQueryOptions): Promise<UsageRecord[]> {
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 {
id: parsed.id,
tenantId: options.tenantId,
meterId: options.meterId,
value: parsed.value,
timestamp: new Date(), // Score에서 복원해야 하지만 단순화
timestamp: this.restoreUsageTimestamp(score),
idempotencyKey: parsed.id,
metadata: parsed.metadata,
};
Expand Down Expand Up @@ -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 };
}
Expand Down Expand Up @@ -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<string, unknown> | undefined {
try {
const decoded = decodeURIComponent(encodedMetadata);
Expand Down
45 changes: 40 additions & 5 deletions packages/metering-core/src/tests/RedisUsageStorage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,29 +208,47 @@ 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);

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({
Expand All @@ -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",
Expand All @@ -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]);

Expand Down
12 changes: 10 additions & 2 deletions packages/metering-upstash/src/libs/UpstashRedisClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,16 @@ export class UpstashRedisClient implements RedisClient {
/**
* Sorted Set에서 점수 범위로 멤버 조회
*/
async zrangebyscore(key: string, min: number, max: number): Promise<string[]> {
const result = await this.redis.zrange(key, min, max, { byScore: true });
async zrangebyscore(
key: string,
min: number,
max: number,
withScores?: "WITHSCORES",
): Promise<string[]> {
const result = await this.redis.zrange(key, min, max, {
byScore: true,
...(withScores === "WITHSCORES" ? { withScores: true } : {}),
});
return result.map((item) => String(item));
}

Expand Down
12 changes: 12 additions & 0 deletions packages/metering-upstash/src/tests/UpstashRedisClient.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);

Expand Down
Loading