typed-tasks
Version:
A type-safe abstraction for Google Cloud Tasks
275 lines (269 loc) • 9.99 kB
JavaScript
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