615 lines
17 KiB
TypeScript
615 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 type { Callback, Result } from "ioredis";
|
|
import { randomUUID } from "node:crypto";
|
|
import type { RedisClient, RedisWithClusterOptions } from "~/redis.server";
|
|
import { createRedisClient } from "~/redis.server";
|
|
import { longPollingFetch } from "~/utils/longPollingFetch";
|
|
import { logger } from "./logger.server";
|
|
import { jumpHash } from "@trigger.dev/core/v3/serverOnly";
|
|
import type { Cache } from "@unkey/cache";
|
|
import { createCache, DefaultStatefulContext, Namespace } from "@unkey/cache";
|
|
import { createLRUMemoryStore } from "@internal/cache";
|
|
import { RedisCacheStore } from "./unkey/redisCacheStore.server";
|
|
import { env } from "~/env.server";
|
|
import type { API_VERSIONS } from "~/api/versions";
|
|
import { 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 [];
|
|
}
|