UNPKG

typed-tasks

Version:

A type-safe abstraction for Google Cloud Tasks

232 lines (214 loc) 8.09 kB
import type { CloudTasksClient } from "@google-cloud/tasks"; import crypto from "node:crypto"; import pRetry, { AbortError } from "p-retry"; import type { z } from "zod"; import type { TaskRegistry } from "./task-registry"; import type { ExtractSchema, TaskDefinitionRecord } from "./types"; /** * 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: unknown, deduplicationWindowSeconds?: number ): string { const dataString = typeof data === "string" ? data : JSON.stringify(data); const baseHash = crypto.createHash("md5").update(dataString).digest("hex"); // If we have a deduplication window, add a time window suffix if (deduplicationWindowSeconds && deduplicationWindowSeconds > 0) { // Round the current timestamp to the nearest window boundary const currentTime = Date.now(); const windowBoundary = Math.floor( currentTime / (deduplicationWindowSeconds * 1000) ); return `${baseHash}-${windowBoundary}`; } return baseHash; } /** * 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 */ export function createSchedulerFactory< Defs extends TaskDefinitionRecord<string>, >( tasksClient: CloudTasksClient, projectId: string, region: string, taskRegistry: TaskRegistry ) { return <T extends keyof Defs & string>(queueName: T) => { /** * 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: z.infer<ExtractSchema<Defs[T]>>, options?: { taskName?: string; delaySeconds?: number } ): Promise<void> => { const taskConfig = taskRegistry.get(queueName); const deduplicationWindowSeconds = taskConfig?.deduplicationWindowSeconds; const targetRegion = region; // Get the parent queue path const parent = tasksClient.queuePath(projectId, targetRegion, queueName); const serviceAccountEmail = `${projectId}@appspot.gserviceaccount.com`; let scheduleTimeSeconds: number | undefined; 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) { // No taskName provided, generate one with window suffix if needed finalTaskName = generateTaskNameFromPayload( data, deduplicationWindowSeconds ); } else if ( finalTaskName && deduplicationWindowSeconds && deduplicationWindowSeconds > 0 ) { // TaskName was provided, but we need to add time window suffix const currentTime = Date.now(); const windowBoundary = Math.floor( currentTime / (deduplicationWindowSeconds * 1000) ); 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() / 1000) + deduplicationWindowSeconds; } else if (options?.delaySeconds && options.delaySeconds > 0) { scheduleTimeSeconds = Math.floor(Date.now() / 1000) + 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: { name?: string; httpRequest: { httpMethod: "POST"; url: string; oidcToken: { serviceAccountEmail: string }; headers: { "content-type": string }; body: string; }; scheduleTime?: { seconds: number }; } = { 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 task already exists, abort retry (part of deduplication) if ( error instanceof Error && error.message.includes("ALREADY_EXISTS") ) { throw new AbortError(error.message); } throw error; // Let other errors be retried } }, { retries: 5, // Maximum number of retry attempts factor: 2, // Exponential backoff factor minTimeout: 1000, // Initial retry delay (1 second) maxTimeout: 10000, // Maximum retry delay (10 seconds) randomize: true, // Add jitter to prevent thundering herd onFailedAttempt: (error) => { // Only log if not aborted due to ALREADY_EXISTS if (!(error instanceof AbortError)) { console.warn( `Task scheduling attempt ${String(error.attemptNumber)} failed for ${String(queueName)}. ${String(error.retriesLeft)} retries left.`, error.message ); } }, } ); } catch (error) { if ( error instanceof Error && error.message.includes("ALREADY_EXISTS") ) { // Task already exists, which is expected with deduplication console.info(`Skipping task ${finalTaskName}`, { data }); return; } // For other errors, log and rethrow const errorMessage = error instanceof Error ? error.message : String(error); console.error( new Error( `Failed to schedule task ${String(queueName)} after multiple retries: ${errorMessage}` ) ); throw error; } }; }; }