typed-tasks
Version:
A type-safe abstraction for Google Cloud Tasks
232 lines (214 loc) • 8.09 kB
text/typescript
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;
}
};
};
}