Files
triggerdotdev--trigger.dev/apps/webapp/app/services/betterstack/betterstack.server.ts
T
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

89 lines
2.4 KiB
TypeScript

import { type ApiResult, wrapZodFetch } from "@trigger.dev/core/v3/zodfetch";
import { createCache, DefaultStatefulContext, Namespace } from "@unkey/cache";
import { createLRUMemoryStore } from "@internal/cache";
import { z } from "zod";
import { env } from "~/env.server";
const IncidentSchema = z.object({
data: z.object({
id: z.string(),
type: z.string(),
attributes: z.object({
aggregate_state: z.string(),
}),
}),
});
export type Incident = z.infer<typeof IncidentSchema>;
const ctx = new DefaultStatefulContext();
const memory = createLRUMemoryStore(100);
const cache = createCache({
query: new Namespace<ApiResult<Incident>>(ctx, {
stores: [memory],
fresh: 15_000,
stale: 30_000,
}),
});
export class BetterStackClient {
private readonly baseUrl = "https://uptime.betterstack.com/api/v2";
async getIncidents() {
const apiKey = env.BETTERSTACK_API_KEY;
if (!apiKey) {
return { success: false as const, error: "BETTERSTACK_API_KEY is not set" };
}
const statusPageId = env.BETTERSTACK_STATUS_PAGE_ID;
if (!statusPageId) {
return { success: false as const, error: "BETTERSTACK_STATUS_PAGE_ID is not set" };
}
const cachedResult = await cache.query.swr("betterstack", async () => {
try {
const result = await wrapZodFetch(
IncidentSchema,
`${this.baseUrl}/status-pages/${statusPageId}`,
{
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
},
{
retry: {
maxAttempts: 3,
minTimeoutInMs: 1000,
maxTimeoutInMs: 5000,
},
}
);
return result;
} catch (error) {
console.error("Failed to fetch incidents from BetterStack:", error);
return {
success: false as const,
error: error instanceof Error ? error.message : "Unknown error",
};
}
});
if (cachedResult.err) {
return { success: false as const, error: cachedResult.err };
}
if (!cachedResult.val) {
return { success: false as const, error: "No result from BetterStack" };
}
if (!cachedResult.val.success) {
return { success: false as const, error: cachedResult.val.error };
}
return { success: true as const, data: cachedResult.val.data.data };
}
}