import type { LogLevel } from "@trigger.dev/core/logger"; import { Logger } from "@trigger.dev/core/logger"; import type { Cache as UnkeyCache } from "@unkey/cache"; import { createCache, DefaultStatefulContext, Namespace } from "@unkey/cache"; import { createLRUMemoryStore } from "@internal/cache"; import { RedisCacheStore } from "./unkey/redisCacheStore.server"; import type { RedisWithClusterOptions } from "~/redis.server"; import { validate as uuidValidate, version as uuidVersion } from "uuid"; import { startActiveSpan } from "~/v3/tracer.server"; export type RequestIdempotencyServiceOptions = { 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 { private readonly logger: Logger; private readonly cache: UnkeyCache<{ requests: RequestIdempotencyCacheEntry }>; constructor(private readonly options: RequestIdempotencyServiceOptions) { 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(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; }