Files
triggerdotdev--trigger.dev/apps/webapp/app/services/requestIdempotency.server.ts
Eric Allam eeab6bdeac fix(run-engine): fix queue cache memory leak and replace MemoryStore with LRU cache (#2945)
- Fix memory leak in RunAttemptSystem queue cache - was keying by runId
instead of queue identifier
- Replace `@unkey/cache` MemoryStore with new LRUMemoryStore for O(1)
operations and better memory bounds

## Problem

### Cache Key Bug
The queue cache in `#resolveTaskRunExecutionQueue` was keyed by `runId`,
creating one cache entry per run instead of per queue. With 1-2 hour
TTLs and 5000 entry soft cap, these accumulated causing memory growth.

### MemoryStore Performance
The `@unkey/cache` MemoryStore uses O(n) synchronous iteration for
eviction, blocking the event loop at high throughput.

## Solution

### Cache Key Fix
Changed cache key from `params.runId` to queue identifier:

```typescript
const cacheKey = params.lockedQueueId ?? `${params.runtimeEnvironmentId}:${params.queueName}`;
```

LRU Cache

Created LRUMemoryStore adapter using lru-cache package:
- O(1) get/set/delete operations
- Strict memory bounds (hard max vs soft cap)
- No event loop blocking

Test Results:

| Metric | Before Fix | After Fix |
|---|---|---|
| Queue cache entries (per 1000 runs) | ~1000 | 1 |
| Old space growth | 32.27 MB | 5.45 MB |
| Heap growth | 7.21 MB (3.9%) | 4.81 MB (2.6%) |


<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2945">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-01-26 22:23:23 +00:00

125 lines
3.7 KiB
TypeScript

import { Logger, LogLevel } from "@trigger.dev/core/logger";
import { createCache, DefaultStatefulContext, Namespace, Cache as UnkeyCache } from "@unkey/cache";
import { createLRUMemoryStore } from "@internal/cache";
import { RedisCacheStore } from "./unkey/redisCacheStore.server";
import { RedisWithClusterOptions } from "~/redis.server";
import { validate as uuidValidate, version as uuidVersion } from "uuid";
import { startActiveSpan } from "~/v3/tracer.server";
export type RequestIdempotencyServiceOptions<TTypes extends string> = {
types: TTypes[];
redis: RedisWithClusterOptions;
logger?: Logger;
logLevel?: LogLevel;
ttlInMs?: number;
};
const DEFAULT_TTL_IN_MS = 60_000 * 60 * 24;
type RequestIdempotencyCacheEntry = {
id: string;
};
export class RequestIdempotencyService<TTypes extends string> {
private readonly logger: Logger;
private readonly cache: UnkeyCache<{ requests: RequestIdempotencyCacheEntry }>;
constructor(private readonly options: RequestIdempotencyServiceOptions<TTypes>) {
this.logger =
options.logger ?? new Logger("RequestIdempotencyService", options.logLevel ?? "info");
const keyPrefix = options.redis.keyPrefix
? `request-idempotency:${options.redis.keyPrefix}`
: "request-idempotency:";
const ctx = new DefaultStatefulContext();
const memory = createLRUMemoryStore(1000);
const redisCacheStore = new RedisCacheStore({
name: "request-idempotency",
connection: {
keyPrefix: keyPrefix,
...options.redis,
},
});
// This cache holds the rate limit configuration for each org, so we don't have to fetch it every request
const cache = createCache({
requests: new Namespace<RequestIdempotencyCacheEntry>(ctx, {
stores: [memory, redisCacheStore],
fresh: options.ttlInMs ?? DEFAULT_TTL_IN_MS,
stale: options.ttlInMs ?? DEFAULT_TTL_IN_MS,
}),
});
this.cache = cache;
}
async checkRequest(type: TTypes, requestIdempotencyKey: string) {
if (!this.#validateRequestId(requestIdempotencyKey)) {
this.logger.warn("RequestIdempotency: invalid requestIdempotencyKey", {
requestIdempotencyKey,
});
return undefined;
}
return startActiveSpan("RequestIdempotency.checkRequest()", async (span) => {
span.setAttribute("request_id", requestIdempotencyKey);
span.setAttribute("type", type);
const key = `${type}:${requestIdempotencyKey}`;
const result = await this.cache.requests.get(key);
this.logger.debug("RequestIdempotency: checking request", {
type,
requestIdempotencyKey,
key,
result,
});
return result.val ? result.val : undefined;
});
}
async saveRequest(
type: TTypes,
requestIdempotencyKey: string,
value: RequestIdempotencyCacheEntry
) {
if (!this.#validateRequestId(requestIdempotencyKey)) {
this.logger.warn("RequestIdempotency: invalid requestIdempotencyKey", {
requestIdempotencyKey,
});
return undefined;
}
const key = `${type}:${requestIdempotencyKey}`;
const result = await this.cache.requests.set(key, value);
if (result.err) {
this.logger.error("RequestIdempotency: error saving request", {
key,
error: result.err,
});
} else {
this.logger.debug("RequestIdempotency: saved request", {
type,
requestIdempotencyKey,
key,
value,
});
}
return result;
}
// The requestIdempotencyKey should be a valid UUID
#validateRequestId(requestIdempotencyKey: string): boolean {
return isValidV4UUID(requestIdempotencyKey);
}
}
function isValidV4UUID(uuid: string): boolean {
return uuidValidate(uuid) && uuidVersion(uuid) === 4;
}