Files
triggerdotdev--trigger.dev/apps/webapp/app/services/realtimeClient.server.ts
Eric Allam 5693b62cfb fix(webapp): propagate abort signal through realtime proxy fetch (#3442)
## Summary

Fixes an RSS-only memory leak in the three realtime proxy routes
(`/realtime/v1/runs`, `/realtime/v1/runs/:id`,
`/realtime/v1/batches/:id`). Client disconnects during an in-flight
long-poll would leave the upstream fetch to Electric running with no way
to abort it, so undici kept the socket open and buffered response chunks
that would never be consumed.

## Root cause

All three routes flow through
`RealtimeClient.streamRun/streamRuns/streamBatch` → `#streamRunsWhere` →
`#performElectricRequest` → `longPollingFetch(url, { signal })`. The
chain was already signal-aware, but `#streamRunsWhere` hardcoded
`signal=undefined` when calling `#performElectricRequest`, so no signal
ever reached `longPollingFetch`.

When a downstream client aborts a long-poll mid-flight:
1. Express tears down the downstream response socket.
2. The `longPollingFetch` promise has already resolved (it returns as
soon as upstream headers arrive) and handed back `new
Response(upstream.body, {...})`.
3. `undici` keeps the upstream socket open and continues buffering
chunks into the `ReadableStream` that nothing will ever read from.
4. The upstream connection is eventually closed by Electric's own poll
timeout (~20s). During that window the per-request buffers stay in
native memory.

These buffers live below V8's accounting — no `heapUsed` or `external`
growth, no sign in heap snapshots, only RSS. An isolated standalone
reproducer (`fetch` against a slow-streaming upstream, discard the
`Response` before consuming its body) measures **~44 KB retained per
leaked request** after GC. That's consistent with the undici socket +
receive buffer + HTTP parser state for a long-lived chunked response.
The pattern is the shape documented in
[nodejs/undici#1108](https://github.com/nodejs/undici/issues/1108) and
[#2143](https://github.com/nodejs/undici/issues/2143).

## What changed

- **`realtimeClient.server.ts`** — add optional `signal` parameter to
`streamRun`, `streamRuns`, `streamBatch`, and the shared
`#streamRunsWhere`; thread it through to `#performElectricRequest`
instead of hardcoding `undefined`.
- **`realtime.v1.runs.$runId.ts`, `realtime.v1.runs.ts`,
`realtime.v1.batches.$batchId.ts`** — pass `getRequestAbortSignal()`
(from `httpAsyncStorage.server.ts`) at the call site. This is the signal
wired to `res.on('close')` and fires reliably on downstream disconnect.
- **`longPollingFetch.ts`** — belt-and-suspenders: cancel the upstream
body explicitly in the error path, and treat `AbortError` as a clean
`499` instead of a `500`. This both releases undici's buffers
deterministically on error and avoids spurious 500s in request logs when
a client legitimately walks away.

## Verification

Standalone reproducer: slow upstream server streams 32 KB chunks every
100 ms for 5 seconds per request. The proxy does `fetch(url)` with
varying signal/cancel strategies, creates `new Response(upstream.body,
...)`, and discards it without consuming the body (simulating the leak
path).

Results from 1 000 parallel fetches per variant, measured post-GC:

| variant | Δ heap | Δ external | Δ RSS |
| --- | --- | --- | --- |
| A. no signal, body never consumed (the bug) | +0.3 MB | 0 MB | **+59.4
MB** |
| B. signal propagated, aborted after headers (this fix) | −0.1 MB | 0
MB | +15.4 MB |
| C. no signal, explicit `res.body.cancel()` | 0 MB | 0 MB | −25.4 MB |

10-round sustained test of variant B to distinguish accumulating
retention from one-time allocator overhead:
```
round  1/10  Δ=+3.2 MB     round  6/10  Δ=-12.5 MB
round  2/10  Δ=-7.6 MB     round  7/10  Δ=-11.9 MB
round  3/10  Δ=-11.7 MB    round  8/10  Δ=-2.6 MB
round  4/10  Δ=+3.2 MB     round  9/10  Δ=-8.0 MB
round  5/10  Δ=-1.2 MB     round 10/10  Δ=-12.6 MB
```
RSS oscillates in a 49-65 MB band with no upward trend — signal
propagation fully releases the buffers.

## Risk

- Behavior change only on aborted long-polls: the upstream fetch now
cancels promptly instead of running to its natural timeout. This saves
both memory and outbound traffic to Electric.
- `AbortError` now surfaces as `499` rather than `500`. Any dashboard or
alert that counts 500s in request logs will see slightly fewer of them;
this is the intended behavior.
- Signal-aware parameter is optional on
`RealtimeClient.streamRun/streamRuns/streamBatch`, so callers that don't
opt in get the previous behavior.

## Test plan

- [ ] Existing realtime integration tests pass
- [ ] Dashboard realtime views (runs list, batch details) continue
working normally across tab open/close cycles
- [ ] Under a burst of aborted long-polls, server RSS returns to
baseline rather than climbing
2026-04-24 16:00:02 +01:00

612 lines
17 KiB
TypeScript

import { json } from "@remix-run/server-runtime";
import { tryCatch } from "@trigger.dev/core/utils";
import { safeParseNaturalLanguageDurationAgo } from "@trigger.dev/core/v3/isomorphic";
import { Callback, Result } from "ioredis";
import { randomUUID } from "node:crypto";
import { createRedisClient, RedisClient, RedisWithClusterOptions } from "~/redis.server";
import { longPollingFetch } from "~/utils/longPollingFetch";
import { logger } from "./logger.server";
import { jumpHash } from "@trigger.dev/core/v3/serverOnly";
import { Cache, createCache, DefaultStatefulContext, Namespace } from "@unkey/cache";
import { createLRUMemoryStore } from "@internal/cache";
import { RedisCacheStore } from "./unkey/redisCacheStore.server";
import { env } from "~/env.server";
import { API_VERSIONS, CURRENT_API_VERSION } from "~/api/versions";
export interface CachedLimitProvider {
getCachedLimit: (organizationId: string, defaultValue: number) => Promise<number | undefined>;
}
const DEFAULT_ELECTRIC_COLUMNS = [
"id",
"taskIdentifier",
"createdAt",
"updatedAt",
"startedAt",
"delayUntil",
"queuedAt",
"expiredAt",
"completedAt",
"friendlyId",
"number",
"isTest",
"status",
"usageDurationMs",
"costInCents",
"baseCostInCents",
"ttl",
"payload",
"payloadType",
"metadata",
"metadataType",
"output",
"outputType",
"runTags",
"error",
"realtimeStreams",
];
const RESERVED_COLUMNS = ["id", "taskIdentifier", "friendlyId", "status", "createdAt"];
const RESERVED_SEARCH_PARAMS = ["createdAt", "tags", "skipColumns"];
export type RealtimeClientOptions = {
electricOrigin: string | string[];
redis: RedisWithClusterOptions;
cachedLimitProvider: CachedLimitProvider;
keyPrefix: string;
expiryTimeInSeconds?: number;
};
export type RealtimeEnvironment = {
id: string;
organizationId: string;
};
export type RealtimeRunsParams = {
tags?: string[];
createdAt?: string;
};
export type RealtimeRequestOptions = {
skipColumns?: string[];
};
export class RealtimeClient {
private redis: RedisClient;
private expiryTimeInSeconds: number;
private cachedLimitProvider: CachedLimitProvider;
private cache: Cache<{ createdAtFilter: string }>;
constructor(private options: RealtimeClientOptions) {
this.redis = createRedisClient("trigger:realtime", options.redis);
this.expiryTimeInSeconds = options.expiryTimeInSeconds ?? 60 * 5; // default to 5 minutes
this.cachedLimitProvider = options.cachedLimitProvider;
this.#registerCommands();
const ctx = new DefaultStatefulContext();
const memory = createLRUMemoryStore(1000);
const redisCacheStore = new RedisCacheStore({
connection: {
keyPrefix: "tr:cache:realtime",
port: options.redis.port,
host: options.redis.host,
username: options.redis.username,
password: options.redis.password,
tlsDisabled: options.redis.tlsDisabled,
clusterMode: options.redis.clusterMode,
},
});
// This cache holds the limits fetched from the platform service
const cache = createCache({
createdAtFilter: new Namespace<string>(ctx, {
stores: [memory, redisCacheStore],
fresh: 60_000 * 60 * 24 * 7, // 1 week
stale: 60_000 * 60 * 24 * 14, // 2 weeks
}),
});
this.cache = cache;
}
async streamRun(
url: URL | string,
environment: RealtimeEnvironment,
runId: string,
apiVersion: API_VERSIONS,
requestOptions?: RealtimeRequestOptions,
clientVersion?: string,
signal?: AbortSignal
) {
return this.#streamRunsWhere(
url,
environment,
`id='${runId}'`,
apiVersion,
requestOptions,
clientVersion,
signal
);
}
async streamBatch(
url: URL | string,
environment: RealtimeEnvironment,
batchId: string,
apiVersion: API_VERSIONS,
requestOptions?: RealtimeRequestOptions,
clientVersion?: string,
signal?: AbortSignal
) {
const whereClauses: string[] = [
`"runtimeEnvironmentId"='${environment.id}'`,
`"batchId"='${batchId}'`,
];
const whereClause = whereClauses.join(" AND ");
return this.#streamRunsWhere(
url,
environment,
whereClause,
apiVersion,
requestOptions,
clientVersion,
signal
);
}
async streamRuns(
url: URL | string,
environment: RealtimeEnvironment,
params: RealtimeRunsParams,
apiVersion: API_VERSIONS,
requestOptions?: RealtimeRequestOptions,
clientVersion?: string,
signal?: AbortSignal
) {
const whereClauses: string[] = [`"runtimeEnvironmentId"='${environment.id}'`];
if (params.tags) {
whereClauses.push(`"runTags" @> ARRAY[${params.tags.map((t) => `'${t}'`).join(",")}]`);
}
const createdAtFilter = await this.#calculateCreatedAtFilter(url, params.createdAt);
if (createdAtFilter) {
whereClauses.push(`"createdAt" > '${createdAtFilter.toISOString()}'`);
}
const whereClause = whereClauses.join(" AND ");
const response = await this.#streamRunsWhere(
url,
environment,
whereClause,
apiVersion,
requestOptions,
clientVersion,
signal
);
if (createdAtFilter) {
const [setCreatedAtFilterError] = await tryCatch(
this.#setCreatedAtFilterFromResponse(response, createdAtFilter)
);
if (setCreatedAtFilterError) {
logger.error("[realtimeClient] Failed to set createdAt filter", {
error: setCreatedAtFilterError,
createdAtFilter,
responseHeaders: Object.fromEntries(response.headers.entries()),
responseStatus: response.status,
});
}
}
return response;
}
async #calculateCreatedAtFilter(url: URL | string, createdAt?: string) {
const duration = createdAt ?? "24h";
const $url = new URL(url.toString());
const shapeId = extractShapeId($url);
if (!shapeId) {
// This means we need to calculate the createdAt filter and store it in redis after we get back the response
const createdAtFilter = safeParseNaturalLanguageDurationAgo(duration);
// Validate that the createdAt filter is in the past, and not more than the maximum age in the past.
// if it's more than the maximum age in the past, just return the maximum age in the past Date
if (
createdAtFilter &&
createdAtFilter < new Date(Date.now() - env.REALTIME_MAXIMUM_CREATED_AT_FILTER_AGE_IN_MS)
) {
return new Date(Date.now() - env.REALTIME_MAXIMUM_CREATED_AT_FILTER_AGE_IN_MS);
}
return createdAtFilter;
} else {
// We need to get the createdAt filter value from redis, if there is none we need to return undefined
const [createdAtFilterError, createdAtFilter] = await tryCatch(
this.#getCreatedAtFilter(shapeId)
);
if (createdAtFilterError) {
logger.error("[realtimeClient] Failed to get createdAt filter", {
shapeId,
error: createdAtFilterError,
});
return;
}
return createdAtFilter;
}
}
async #getCreatedAtFilter(shapeId: string) {
const createdAtFilterCacheResult = await this.cache.createdAtFilter.get(shapeId);
if (createdAtFilterCacheResult.err) {
logger.error("[realtimeClient] Failed to get createdAt filter", {
shapeId,
error: createdAtFilterCacheResult.err,
});
return;
}
if (!createdAtFilterCacheResult.val) {
return;
}
return new Date(createdAtFilterCacheResult.val);
}
async #setCreatedAtFilterFromResponse(response: Response, createdAtFilter: Date) {
const shapeId = extractShapeIdFromResponse(response);
if (!shapeId) {
return;
}
await this.cache.createdAtFilter.set(shapeId, createdAtFilter.toISOString());
}
async #streamRunsWhere(
url: URL | string,
environment: RealtimeEnvironment,
whereClause: string,
apiVersion: API_VERSIONS,
requestOptions?: RealtimeRequestOptions,
clientVersion?: string,
signal?: AbortSignal
) {
const electricUrl = this.#constructRunsElectricUrl(
url,
environment,
whereClause,
requestOptions,
clientVersion
);
return this.#performElectricRequest(
electricUrl,
environment,
apiVersion,
signal,
clientVersion
);
}
#constructRunsElectricUrl(
url: URL | string,
environment: RealtimeEnvironment,
whereClause: string,
requestOptions?: RealtimeRequestOptions,
clientVersion?: string
): URL {
const $url = new URL(url.toString());
const electricOrigin = this.#resolveElectricOrigin($url, whereClause, environment.id);
const electricUrl = new URL(`${electricOrigin}/v1/shape`);
// Copy over all the url search params to the electric url
$url.searchParams.forEach((value, key) => {
if (RESERVED_SEARCH_PARAMS.includes(key)) {
return;
}
electricUrl.searchParams.set(key, value);
});
electricUrl.searchParams.set("where", whereClause);
electricUrl.searchParams.set("table", 'public."TaskRun"');
if (!clientVersion) {
// If the client version is not provided, that means we're using an older client
// This means the client will be sending shape_id instead of handle
electricUrl.searchParams.set("handle", electricUrl.searchParams.get("shape_id") ?? "");
}
let skipColumns = getSkipColumns($url.searchParams, requestOptions);
if (skipColumns.length > 0) {
skipColumns = skipColumns.filter((c) => c !== "" && !RESERVED_COLUMNS.includes(c));
electricUrl.searchParams.set(
"columns",
DEFAULT_ELECTRIC_COLUMNS.filter((c) => !skipColumns.includes(c))
.map((c) => `"${c}"`)
.join(",")
);
} else {
electricUrl.searchParams.set(
"columns",
DEFAULT_ELECTRIC_COLUMNS.map((c) => `"${c}"`).join(",")
);
}
return electricUrl;
}
async #performElectricRequest(
url: URL,
environment: RealtimeEnvironment,
apiVersion: API_VERSIONS,
signal?: AbortSignal,
clientVersion?: string
) {
const shapeId = extractShapeId(url);
logger.debug("[realtimeClient] request", {
url: url.toString(),
});
const rewriteResponseHeaders: Record<string, string> = clientVersion
? {}
: { "electric-handle": "electric-shape-id", "electric-offset": "electric-chunk-last-offset" };
if (!shapeId) {
// If the shapeId is not present, we're just getting the initial value
return this.#doLongPollingFetch(url, apiVersion, signal, rewriteResponseHeaders);
}
const isLive = isLiveRequestUrl(url);
if (!isLive) {
return this.#doLongPollingFetch(url, apiVersion, signal, rewriteResponseHeaders);
}
const requestId = randomUUID();
// We now need to wrap the longPollingFetch in a concurrency tracker
const concurrencyLimit = await this.cachedLimitProvider.getCachedLimit(
environment.organizationId,
100_000
);
if (!concurrencyLimit) {
logger.error("Failed to get concurrency limit", {
organizationId: environment.organizationId,
});
return json({ error: "Failed to get concurrency limit" }, { status: 500 });
}
logger.debug("[realtimeClient] increment and check", {
concurrencyLimit,
shapeId,
requestId,
environment: {
id: environment.id,
organizationId: environment.organizationId,
},
});
const canProceed = await this.#incrementAndCheck(environment.id, requestId, concurrencyLimit);
if (!canProceed) {
logger.debug("[realtimeClient] too many concurrent requests", {
requestId,
environmentId: environment.id,
});
return json({ error: "Too many concurrent requests" }, { status: 429 });
}
try {
// ... (rest of your existing code for the long polling request)
const response = await this.#doLongPollingFetch(
url,
apiVersion,
signal,
rewriteResponseHeaders
);
// If this is the initial request, the response.headers['electric-handle'] will be the shapeId
// And we may need to set the "createdAt" filter timestamp keyed by the shapeId
// Then in the next request, we will get the createdAt timestamp value via the shapeId and use it to filter the results
// Decrement the counter after the long polling request is complete
await this.#decrementConcurrency(environment.id, requestId);
return response;
} catch (error) {
// Decrement the counter if the request fails
await this.#decrementConcurrency(environment.id, requestId);
throw error;
}
}
async #doLongPollingFetch(
url: URL,
apiVersion: API_VERSIONS,
signal?: AbortSignal,
rewriteResponseHeaders?: Record<string, string>
) {
if (apiVersion === CURRENT_API_VERSION) {
return longPollingFetch(url.toString(), { signal }, rewriteResponseHeaders);
}
const response = await longPollingFetch(url.toString(), { signal }, rewriteResponseHeaders);
return this.#rewriteResponseForNoneApiVersion(response);
}
async #rewriteResponseForNoneApiVersion(response: Response) {
// Get the raw response body
const responseBody = await response.text();
// Rewrite the response body
const rewrittenResponseBody = this.#rewriteResponseBodyForNoneApiVersion(responseBody);
// Return the rewritten response
return new Response(rewrittenResponseBody, {
status: response.status,
headers: response.headers,
});
}
// Rewrites "status":"DEQUEUED" to "status":"EXECUTING"
#rewriteResponseBodyForNoneApiVersion(responseBody: string) {
return responseBody.replace(/"status":"DEQUEUED"/g, '"status":"EXECUTING"');
}
async #incrementAndCheck(environmentId: string, requestId: string, limit: number) {
const key = this.#getKey(environmentId);
const now = Date.now();
const result = await this.redis.incrementAndCheckConcurrency(
key,
now.toString(),
requestId,
this.expiryTimeInSeconds.toString(), // expiry time
(now - this.expiryTimeInSeconds * 1000).toString(), // cutoff time
limit.toString()
);
return result === 1;
}
async #decrementConcurrency(environmentId: string, requestId: string) {
logger.debug("[realtimeClient] decrement", {
requestId,
environmentId,
});
const key = this.#getKey(environmentId);
await this.redis.zrem(key, requestId);
}
#getKey(environmentId: string): string {
return `${this.options.keyPrefix}:${environmentId}`;
}
#resolveElectricOrigin(url: URL, whereClause: string, environmentId: string) {
if (typeof this.options.electricOrigin === "string") {
return this.options.electricOrigin;
}
const shardKey = this.#getShardKey(whereClause, environmentId);
const index = jumpHash(shardKey, this.options.electricOrigin.length);
const origin = this.options.electricOrigin[index] ?? this.options.electricOrigin[0];
logger.debug("[realtimeClient] resolveElectricOrigin", {
whereClause,
environmentId,
shardKey,
index,
electricOrigin: origin,
});
return origin;
}
#getShardKey(whereClause: string, environmentId: string) {
return [environmentId, whereClause].join(":");
}
#registerCommands() {
this.redis.defineCommand("incrementAndCheckConcurrency", {
numberOfKeys: 1,
lua: /* lua */ `
local concurrencyKey = KEYS[1]
local timestamp = tonumber(ARGV[1])
local requestId = ARGV[2]
local expiryTime = tonumber(ARGV[3])
local cutoffTime = tonumber(ARGV[4])
local limit = tonumber(ARGV[5])
-- Remove expired entries
redis.call('ZREMRANGEBYSCORE', concurrencyKey, '-inf', cutoffTime)
-- Add the new request to the sorted set
redis.call('ZADD', concurrencyKey, timestamp, requestId)
-- Set the expiry time on the key
redis.call('EXPIRE', concurrencyKey, expiryTime)
-- Get the total number of concurrent requests
local totalRequests = redis.call('ZCARD', concurrencyKey)
-- Check if the limit has been exceeded
if totalRequests > limit then
-- Remove the request we just added
redis.call('ZREM', concurrencyKey, requestId)
return 0
end
-- Return 1 to indicate success
return 1
`,
});
}
}
function extractShapeId(url: URL) {
return url.searchParams.get("handle") ?? url.searchParams.get("shape_id");
}
function extractShapeIdFromResponse(response: Response) {
return response.headers.get("electric-handle");
}
function isLiveRequestUrl(url: URL) {
return url.searchParams.has("live") && url.searchParams.get("live") === "true";
}
declare module "ioredis" {
interface RedisCommander<Context> {
incrementAndCheckConcurrency(
key: string,
timestamp: string,
requestId: string,
expiryTime: string,
cutoffTime: string,
limit: string,
callback?: Callback<number>
): Result<number, Context>;
}
}
function getSkipColumns(searchParams: URLSearchParams, requestOptions?: RealtimeRequestOptions) {
if (requestOptions?.skipColumns) {
return requestOptions.skipColumns;
}
const skipColumnsRaw = searchParams.get("skipColumns");
if (skipColumnsRaw) {
return skipColumnsRaw.split(",").map((c) => c.trim());
}
return [];
}