Skip to content
Open
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/safe-cache-resource-bounds.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@croco/cache-core": minor
"@croco/problems-core": patch
---

Reject unsafe in-memory cache capacity and cleanup interval values before allocating runtime resources.
34 changes: 32 additions & 2 deletions docs/problem-code-registry.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"version": "croco.problem-code-registry.v1",
"problemCount": 584,
"problemCount": 585,
"problems": [
{
"code": "ACCESS_DENIED",
Expand Down Expand Up @@ -3062,6 +3062,36 @@
}
]
},
{
"code": "cache-core/invalid-configuration",
"category": "InternalServerError",
"status": 500,
"title": "Internal Server Error",
"cookbookPath": "/reference/problem-recovery-cookbook/#cache-core-invalid-configuration",
"recovery": {
"cause": "Croco or an upstream dependency failed after accepting the request.",
"userAction": "Retry later only when the operation is idempotent or the caller owns retry safety.",
"operatorAction": "Use traces, logs, and upstream diagnostics to isolate the failing boundary.",
"retryability": "conditional",
"redactionPolicy": "operator-only",
"telemetry": {
"eventName": "croco.problem.error",
"severity": "error",
"attributes": ["problem.code", "problem.category", "problem.status"]
}
},
"lifecycle": {
"status": "active"
},
"sources": [
{
"file": "packages/cache-core/src/libs/problems/CacheStoreProblems.ts",
"line": 12,
"column": 3,
"kind": "problem-class"
}
]
},
{
"code": "cache-core/invalid-decorator-config",
"category": "InternalServerError",
Expand Down Expand Up @@ -3116,7 +3146,7 @@
"sources": [
{
"file": "packages/cache-core/src/libs/problems/CacheStoreProblems.ts",
"line": 7,
"line": 35,
"column": 3,
"kind": "problem-class"
}
Expand Down
4 changes: 4 additions & 0 deletions packages/cache-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ const value = await cache.get("user:1");
const stats = cache.getStats();
```

`maxEntries`는 1부터 `Number.MAX_SAFE_INTEGER` 사이의 정수여야 합니다. `cleanupIntervalMs`를 지정하면 Node.js가
clamp하지 않는 1부터 2,147,483,647 사이의 정수 밀리초여야 합니다. 잘못된 값은 정리 타이머를 만들기 전에
`InvalidCacheConfigurationProblem`으로 거부됩니다.

### getOrSet으로 singleflight 로딩

```typescript
Expand Down
8 changes: 7 additions & 1 deletion packages/cache-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,10 @@ export {
UnknownCacheInvalidationEventProblem,
UnsupportedCacheInvalidationCapabilityProblem,
} from "./libs/problems/CacheDecoratorProblems";
export { InvalidCacheTtlProblem } from "./libs/problems/CacheStoreProblems";
export {
InvalidCacheConfigurationProblem,
InvalidCacheTtlProblem,
MAX_CACHE_ENTRIES,
MAX_CACHE_TIMER_DELAY_MS,
} from "./libs/problems/CacheStoreProblems";
export type { CacheNumericOption } from "./libs/problems/CacheStoreProblems";
17 changes: 14 additions & 3 deletions packages/cache-core/src/libs/InMemoryCacheStore.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ILogger } from "@croco/framework-context";
import { assertValidCacheNumericOption } from "./numericValidation";
import { InvalidCacheTtlProblem } from "./problems/CacheStoreProblems";
import {
type CacheGetOrSetOptions,
Expand All @@ -21,7 +22,9 @@ type InFlightLoad<V> = {
};

export type InMemoryCacheStoreOptions = {
/** Positive safe integer. Defaults to 1000. */
maxEntries?: number;
/** Integer milliseconds from 1 through 2,147,483,647. Disabled by default. */
cleanupIntervalMs?: number;
};

Expand Down Expand Up @@ -61,12 +64,20 @@ export class InMemoryCacheStore<V = unknown> extends CacheStore<string, V> {
super();

void logger;
this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
const maxEntries = options.maxEntries === undefined ? DEFAULT_MAX_ENTRIES : options.maxEntries;
assertValidCacheNumericOption("maxEntries", maxEntries);

if (options.cleanupIntervalMs !== undefined) {
const cleanupIntervalMs = options.cleanupIntervalMs;
if (cleanupIntervalMs !== undefined) {
assertValidCacheNumericOption("cleanupIntervalMs", cleanupIntervalMs);
}

this.maxEntries = maxEntries;

if (cleanupIntervalMs !== undefined) {
this.cleanupTimer = setInterval(() => {
this.pruneExpiredSync();
}, options.cleanupIntervalMs);
}, cleanupIntervalMs);

this.cleanupTimer.unref?.();
}
Expand Down
17 changes: 17 additions & 0 deletions packages/cache-core/src/libs/numericValidation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import {
InvalidCacheConfigurationProblem,
MAX_CACHE_ENTRIES,
MAX_CACHE_TIMER_DELAY_MS,
} from "./problems/CacheStoreProblems";
import type { CacheNumericOption } from "./problems/CacheStoreProblems";

const CACHE_NUMERIC_OPTION_MAXIMUMS: Readonly<Record<CacheNumericOption, number>> = {
maxEntries: MAX_CACHE_ENTRIES,
cleanupIntervalMs: MAX_CACHE_TIMER_DELAY_MS,
};

export function assertValidCacheNumericOption(option: CacheNumericOption, value: number): void {
if (!Number.isSafeInteger(value) || value <= 0 || value > CACHE_NUMERIC_OPTION_MAXIMUMS[option]) {
throw new InvalidCacheConfigurationProblem(option, value);
}
}
28 changes: 28 additions & 0 deletions packages/cache-core/src/libs/problems/CacheStoreProblems.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
import { Problem, ProblemCategory } from "@croco/problems-core";

/** Largest cache capacity that preserves exact integer eviction semantics. */
export const MAX_CACHE_ENTRIES = Number.MAX_SAFE_INTEGER;
/** Largest cleanup interval that Node.js timers accept without clamping. */
export const MAX_CACHE_TIMER_DELAY_MS = 2_147_483_647;

export type CacheNumericOption = "maxEntries" | "cleanupIntervalMs";

/** In-memory cache numeric configuration cannot be represented with safe runtime semantics. */
export class InvalidCacheConfigurationProblem extends Problem {
readonly code = "cache-core/invalid-configuration";
readonly category = ProblemCategory.InternalServerError;

constructor(
readonly option: CacheNumericOption,
readonly value: number,
) {
const constraint =
option === "maxEntries"
? `an integer between 1 and ${MAX_CACHE_ENTRIES}`
: `an integer between 1 and ${MAX_CACHE_TIMER_DELAY_MS} milliseconds`;
super(
undefined,
undefined,
`Invalid in-memory cache configuration: ${option} must be ${constraint}; received ${value}`,
);
}
}

/**
* RFC 7807 형식의 유효하지 않은 캐시 TTL 검증 문제입니다.
*/
Expand Down
11 changes: 11 additions & 0 deletions packages/cache-core/src/tests/CacheExports.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,17 @@ import {
invalidateCacheKey,
invalidateCacheTag,
InMemoryCacheStore,
InvalidCacheConfigurationProblem,
InvalidCacheTtlProblem,
MAX_CACHE_ENTRIES,
MAX_CACHE_TIMER_DELAY_MS,
serializeCacheInvalidationManifest,
} from "../index";
import type {
CacheGetOrSetOptions,
CacheInvalidationAdapter,
CacheInvalidationManifest,
CacheNumericOption,
CachePattern,
CacheStats,
CacheWarmupEntry,
Expand Down Expand Up @@ -92,12 +96,19 @@ class RootCache extends Cache<string, string> {
describe("cache-core public exports", () => {
it("exports README-documented cache contracts from the package root", async () => {
const cache = new RootCache();
const numericOption: CacheNumericOption = "maxEntries";
const distributedLock: DistributedCacheLock | undefined = undefined;

expect(cache).toBeInstanceOf(Cache);
expect(DistributedCacheStore.prototype).toBeInstanceOf(CacheStore);
expect(new InMemoryCacheStore<string>()).toBeInstanceOf(CacheStore);
expect(new InvalidCacheConfigurationProblem("maxEntries", 0).code).toBe(
"cache-core/invalid-configuration",
);
expect(new InvalidCacheTtlProblem(-1).code).toBe("cache-core/invalid-ttl");
expect(MAX_CACHE_ENTRIES).toBe(Number.MAX_SAFE_INTEGER);
expect(MAX_CACHE_TIMER_DELAY_MS).toBe(2_147_483_647);
expect(numericOption).toBe("maxEntries");
expect(distributedLock).toBeUndefined();
});

Expand Down
95 changes: 94 additions & 1 deletion packages/cache-core/src/tests/InMemoryCacheStore.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { InMemoryCacheStore } from "../libs/InMemoryCacheStore";
import { InvalidCacheTtlProblem } from "../libs/problems/CacheStoreProblems";
import {
InvalidCacheConfigurationProblem,
InvalidCacheTtlProblem,
MAX_CACHE_ENTRIES,
MAX_CACHE_TIMER_DELAY_MS,
} from "../libs/problems/CacheStoreProblems";

describe("InMemoryCacheStore", () => {
let cache!: InMemoryCacheStore<string>;
Expand Down Expand Up @@ -230,6 +235,45 @@ describe("InMemoryCacheStore", () => {
});

describe("capacity management", () => {
it.each([
Number.NaN,
Number.POSITIVE_INFINITY,
Number.NEGATIVE_INFINITY,
null as unknown as number,
-1,
0,
1.5,
MAX_CACHE_ENTRIES + 1,
])("rejects invalid maxEntries %s before allocating a cleanup timer", (maxEntries) => {
vi.useFakeTimers();
const setIntervalSpy = vi.spyOn(globalThis, "setInterval");

try {
expect(() => new InMemoryCacheStore({ maxEntries, cleanupIntervalMs: 1 })).toThrow(
InvalidCacheConfigurationProblem,
);

try {
new InMemoryCacheStore({ maxEntries, cleanupIntervalMs: 1 });
} catch (error) {
expect(error).toMatchObject({
code: "cache-core/invalid-configuration",
option: "maxEntries",
value: maxEntries,
});
}

expect(setIntervalSpy).not.toHaveBeenCalled();
} finally {
setIntervalSpy.mockRestore();
vi.useRealTimers();
}
});

it.each([1, MAX_CACHE_ENTRIES])("accepts maxEntries boundary %s", (maxEntries) => {
expect(() => new InMemoryCacheStore({ maxEntries })).not.toThrow();
});

it("should apply default maxEntries of 1000 when not set", async () => {
const defaultCache = new InMemoryCacheStore<string>();

Expand Down Expand Up @@ -735,6 +779,55 @@ describe("InMemoryCacheStore", () => {
});

describe("periodic cleanup", () => {
it.each([
Number.NaN,
Number.POSITIVE_INFINITY,
Number.NEGATIVE_INFINITY,
null as unknown as number,
-1,
0,
1.5,
MAX_CACHE_TIMER_DELAY_MS + 1,
])("rejects invalid cleanupIntervalMs %s before allocating a timer", (cleanupIntervalMs) => {
vi.useFakeTimers();
const setIntervalSpy = vi.spyOn(globalThis, "setInterval");

try {
expect(() => new InMemoryCacheStore({ cleanupIntervalMs })).toThrow(
InvalidCacheConfigurationProblem,
);

try {
new InMemoryCacheStore({ cleanupIntervalMs });
} catch (error) {
expect(error).toMatchObject({
code: "cache-core/invalid-configuration",
option: "cleanupIntervalMs",
value: cleanupIntervalMs,
});
}

expect(setIntervalSpy).not.toHaveBeenCalled();
} finally {
setIntervalSpy.mockRestore();
vi.useRealTimers();
}
});

it.each([1, MAX_CACHE_TIMER_DELAY_MS])(
"accepts cleanupIntervalMs boundary %s without timer clamping",
(cleanupIntervalMs) => {
vi.useFakeTimers();

try {
const periodicCache = new InMemoryCacheStore({ cleanupIntervalMs });
periodicCache.close();
} finally {
vi.useRealTimers();
}
},
);

it("removes expired entries on cleanup interval without reads", async () => {
vi.useFakeTimers();

Expand Down
Loading
Loading