UNPKG

typed-tasks

Version:

A type-safe abstraction for Google Cloud Tasks

275 lines (269 loc) 9.99 kB
import { z } from "zod"; import { onTaskDispatched } from "firebase-functions/tasks"; import { got } from "get-or-throw"; import crypto from "node:crypto"; import pRetry, { AbortError } from "p-retry"; //#region src/constants.ts const MINUTE_SECONDS = 60; const HOUR_SECONDS = 60 * MINUTE_SECONDS; const DAY_SECONDS = 24 * HOUR_SECONDS; /** Default options for Task handlers */ const defaultHandlerOptions = { memory: "512MiB", timeoutSeconds: 30 * MINUTE_SECONDS, vpcConnector: void 0, cpu: 1, rateLimits: { maxDispatchesPerSecond: 500, maxConcurrentDispatches: 1e3 }, retryConfig: { maxAttempts: 10, minBackoffSeconds: 10, maxBackoffSeconds: HOUR_SECONDS, maxRetrySeconds: 0 } }; //#endregion //#region src/handler.ts /** * Creates a factory function for generating type-safe task handlers * * @param schemas - Extracted schemas from task definitions * @param region - GCP region * @param globalOptions - Default options for all handlers * @param taskRegistry - Registry to store task configurations that need to be * shared with the scheduler * @returns A factory function for creating handlers that returns an object with * the queueName as the property name and the handler function as the value */ function createTaskHandlerFactory(schemas, region, globalOptions = defaultHandlerOptions) { return ({ queueName, options = {}, handler }) => { return onTaskDispatched({ ...defaultHandlerOptions, ...globalOptions, ...options, rateLimits: { ...defaultHandlerOptions.rateLimits, ...globalOptions.rateLimits, ...options.rateLimits }, retryConfig: { ...defaultHandlerOptions.retryConfig, ...globalOptions.retryConfig, ...options.retryConfig }, region }, async ({ data }) => { const result = got(schemas, queueName).safeParse(data); if (!result.success) { console.error(/* @__PURE__ */ new Error(`Zod validation error for queue ${queueName}`), result.error.flatten()); return; } return handler(result.data); }); }; } //#endregion //#region src/scheduler.ts /** * Generates a deterministic task name from payload data using MD5 hash When * deduplication window is set, includes a time window boundary suffix to * prevent collisions across different time windows * * @param data - The payload data to hash * @param deduplicationWindowSeconds - Optional deduplication window in seconds * @returns A string containing the MD5 hash of the stringified data, with * optional time window suffix */ function generateTaskNameFromPayload(data, deduplicationWindowSeconds) { const dataString = typeof data === "string" ? data : JSON.stringify(data); const baseHash = crypto.createHash("md5").update(dataString).digest("hex"); if (deduplicationWindowSeconds && deduplicationWindowSeconds > 0) { const currentTime = Date.now(); return `${baseHash}-${Math.floor(currentTime / (deduplicationWindowSeconds * 1e3))}`; } return baseHash; } function getErrorMessage(error) { return error instanceof Error ? error.message : String(error); } /** * Creates a factory function that produces type-safe task schedulers for * specific tasks * * @param tasksClient - Google Cloud Tasks client * @param projectId - Google Cloud project ID * @param region - GCP region for the Cloud Tasks * @param taskRegistry - Registry containing task configurations from task * definitions * @returns A factory function for creating task schedulers */ function createSchedulerFactory(tasksClient, projectId, region, taskRegistry) { return (queueName) => { /** * Schedules a task to be executed * * @param data - The data to schedule as a task, must conform to the task's * schema * @param options - Optional configuration options including taskName for * deduplication and delaySeconds for custom delays * @returns Promise that resolves when the task is scheduled */ return async (data, options) => { const taskConfig = taskRegistry.get(queueName); const deduplicationWindowSeconds = taskConfig?.deduplicationWindowSeconds; const targetRegion = region; const parent = tasksClient.queuePath(projectId, targetRegion, queueName); const serviceAccountEmail = `${projectId}@appspot.gserviceaccount.com`; let scheduleTimeSeconds; const useDeduplication = !!taskConfig?.useDeduplication || !!deduplicationWindowSeconds && deduplicationWindowSeconds > 0; /** Generate a task name if needed or add time window suffix */ let finalTaskName = options?.taskName; if (useDeduplication && !finalTaskName) finalTaskName = generateTaskNameFromPayload(data, deduplicationWindowSeconds); else if (finalTaskName && deduplicationWindowSeconds && deduplicationWindowSeconds > 0) { const currentTime = Date.now(); const windowBoundary = Math.floor(currentTime / (deduplicationWindowSeconds * 1e3)); finalTaskName = `${finalTaskName}-${windowBoundary}`; } try { /** * Priority: deduplicationWindowSeconds > delaySeconds If a * deduplication window is configured, use that delay Otherwise, use * delaySeconds if provided */ if (deduplicationWindowSeconds && deduplicationWindowSeconds > 0) scheduleTimeSeconds = Math.floor(Date.now() / 1e3) + deduplicationWindowSeconds; else if (options?.delaySeconds && options.delaySeconds > 0) scheduleTimeSeconds = Math.floor(Date.now() / 1e3) + options.delaySeconds; /** * The body HAS to contain the payload in the "data" key for the cloud * functions onTaskDispatched to accept/parse the body. It also needs to * be encoded with base64. */ const body = Buffer.from(JSON.stringify({ data })).toString("base64"); const task = { httpRequest: { httpMethod: "POST", url: `https://${targetRegion}-${projectId}.cloudfunctions.net/${queueName}`, oidcToken: { serviceAccountEmail }, headers: { "content-type": "application/json" }, body } }; /** * Set the task name property if we have a taskName (either provided or * generated) */ if (finalTaskName) task.name = tasksClient.taskPath(projectId, targetRegion, queueName, finalTaskName); /** Set schedule time if delay is configured */ if (scheduleTimeSeconds) task.scheduleTime = { seconds: scheduleTimeSeconds }; /** * Use p-retry to handle transient failures when creating tasks with * exponential backoff and jitter */ await pRetry(async () => { try { return await tasksClient.createTask({ parent, task }); } catch (error) { if (error instanceof Error && error.message.includes("ALREADY_EXISTS")) throw new AbortError(error.message); throw error; } }, { retries: 5, factor: 2, minTimeout: 1e3, maxTimeout: 1e4, randomize: true, onFailedAttempt: ({ error, attemptNumber, retriesLeft }) => { if (!(error instanceof AbortError)) console.warn(`Task scheduling attempt ${attemptNumber} failed for ${queueName}. ${retriesLeft} retries left.`, getErrorMessage(error)); } }); } catch (error) { if (error instanceof Error && error.message.includes("ALREADY_EXISTS")) { console.info(`Skipping task ${finalTaskName}`, { data }); return; } const errorMessage = getErrorMessage(error); console.error(/* @__PURE__ */ new Error(`Failed to schedule task ${queueName} after multiple retries: ${errorMessage}`)); throw error; } }; }; } //#endregion //#region src/task-registry.ts /** * Creates a new queue registry instance * * This registry maps queue names to their task configurations which includes * deduplication settings */ function createTaskRegistry() { return /* @__PURE__ */ new Map(); } //#endregion //#region src/factory.ts /** * Utility to check if a task definition is a direct schema or an object with * schema */ function isSchemaDefinition(definition) { return typeof definition === "function" || "parse" in definition; } /** * Creates a type-safe Tasks client for handling and scheduling tasks with * schema validation * * @param options - Options object containing client configuration * @param options.tasksClient - Google Cloud Tasks client instance * @param options.taskDefinitions - Object containing schema and options for * each task * @param options.projectId - GCP project ID * @param options.region - GCP region for the Cloud Tasks * @param options.options - Optional configuration options for all tasks * @returns Type-safe Tasks client with scheduler and handler factories */ function createTypedTasks({ client, definitions, projectId, region, options = {} }) { const globalHandlerOptions = { ...defaultHandlerOptions, ...options, rateLimits: { ...defaultHandlerOptions.rateLimits, ...options.rateLimits }, retryConfig: { ...defaultHandlerOptions.retryConfig, ...options.retryConfig } }; const taskRegistry = createTaskRegistry(); const schemas = Object.fromEntries(Object.entries(definitions).map(([key, value]) => { if (isSchemaDefinition(value)) return [key, value]; else return [key, value.schema]; })); Object.entries(definitions).forEach(([queueName, definition]) => { if (!isSchemaDefinition(definition) && definition.options) { const deduplicationWindowSeconds = definition.options.deduplicationWindowSeconds; const useDeduplication = !!definition.options.useDeduplication || !!deduplicationWindowSeconds && deduplicationWindowSeconds > 0; taskRegistry.set(queueName, { deduplicationWindowSeconds, useDeduplication }); } }); const schedulerFactory = createSchedulerFactory(client, projectId, region, taskRegistry); const handlerFactory = createTaskHandlerFactory(schemas, region, globalHandlerOptions); const tasksProxy = { createScheduler: schedulerFactory, createHandler: (config) => { return handlerFactory(config); } }; Object.keys(definitions).forEach((queueName) => { tasksProxy[queueName] = true; }); return tasksProxy; } //#endregion export { createTypedTasks }; //# sourceMappingURL=index.mjs.map