Files
outline/server/queues/tasks/base/CronTask.ts
T
Tom Moor 57308c46af chore: resolve lint warnings (no-explicit-any, no-redundant-type-constituents, no-base-to-string) (#12209)
* chore: resolve no-redundant-type-constituents and test/mock no-explicit-any warnings

Clears 36 lint warnings: all 5 no-redundant-type-constituents, 6
no-misused-spread (via narrowing getPartitionWhereClause's return type
to WhereAttributeHash), and 25 no-explicit-any in test/mock files.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: resolve no-base-to-string warnings in tests

Convert userProvisioner try/catch error assertions to Jest's
.rejects.toThrow() idiom, and cast webhook test body to string.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: resolve no-explicit-any warnings in cancan and tracing

Tighten types in the cancan policy framework and tracing decorators.
Constructor / generic-function upper bounds keep `any` where TypeScript
variance requires it, scoped to single-line oxlint-disable comments.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 22:55:30 -04:00

196 lines
6.3 KiB
TypeScript

import type { WhereAttributeHash } from "sequelize";
import { Op } from "sequelize";
import { Minute } from "@shared/utils/time";
import { BaseTask } from "./BaseTask";
export enum TaskInterval {
Day = "daily",
Hour = "hourly",
}
/**
* Stagger windows per interval to spread task start times and prevent
* concurrent heavy database operations from saturating PostgreSQL.
*/
const staggerWindows: Record<TaskInterval, number> = {
[TaskInterval.Hour]: 10 * Minute.ms,
[TaskInterval.Day]: 30 * Minute.ms,
};
export type TaskSchedule = {
/** The interval at which to run this task */
interval: TaskInterval;
/**
* An optional time window (in milliseconds) over which to spread the START time
* of this task when triggered by cron.
*
* **Important**: This only delays when tasks START - it does NOT partition the work.
* To distribute work across multiple workers, tasks must also use the `partition`
* prop and implement partitioned queries using `getPartitionWhereClause()`.
*
* When set, each task gets a deterministic delay based on its name, ensuring
* consistent scheduling across runs and preventing all tasks from starting
* simultaneously.
*
* @example
* // Run hourly, but spread task start times over 10 minutes
* interval: TaskInterval.Hour,
* partitionWindow: 10 * Minute.ms // 10 minutes
*
* @example
* // Run daily, but spread task start times over 1 hour
* interval: TaskInterval.Day,
* partitionWindow: 60 * Minute.ms // 1 hour
*/
partitionWindow?: number;
};
/**
* Partition information for distributing work across multiple worker instances.
*/
export type PartitionInfo = {
/**
* The partition number for this task instance (0-based).
*/
partitionIndex: number;
/**
* The total number of partitions.
*/
partitionCount: number;
};
/**
* Properties for cron-scheduled tasks.
*/
export type Props = {
limit: number;
partition: PartitionInfo;
};
export abstract class CronTask extends BaseTask<Props> {
/** The schedule configuration for this cron task */
public abstract get cron(): TaskSchedule;
/**
* Compute a deterministic delay for a task based on its name and interval.
* Different tasks are likely to get different offsets within the stagger
* window for their interval, reducing concurrent heavy database operations.
*
* @param taskName the name of the task class.
* @param interval the task interval used to select the stagger window.
* @returns a delay in milliseconds.
*/
public static getStaggerDelay(
taskName: string,
interval: TaskInterval
): number {
const windowMs = staggerWindows[interval];
let hash = 0;
for (let i = 0; i < taskName.length; i++) {
hash = ((hash << 5) - hash + taskName.charCodeAt(i)) | 0;
}
return Math.abs(hash) % windowMs;
}
/**
* Optimized partitioning method for UUID primary keys using range-based distribution.
* Divides the UUID space into N equal ranges and assigns each partition a range.
*
* The UUID space (0x00000000-... to 0xffffffff-...) is divided into N equal ranges.
* For example, with 3 partitions:
* - Partition 0: '00000000-0000-4000-8000-000000000000' to '55555554-ffff-4fff-bfff-ffffffffffff'
* - Partition 1: '55555555-0000-4000-8000-000000000000' to 'aaaaaaaa-ffff-4fff-bfff-ffffffffffff'
* - Partition 2: 'aaaaaaab-0000-4000-8000-000000000000' to 'ffffffff-ffff-4fff-bfff-ffffffffffff'
*
* @param partitionInfo The partition information
* @returns The start and end UUID bounds for the partition
*/
protected getPartitionBounds(
partitionInfo: PartitionInfo | undefined
): [string, string] {
if (!partitionInfo) {
return [
"00000000-0000-4000-8000-000000000000",
"ffffffff-ffff-4fff-bfff-ffffffffffff",
];
}
const { partitionIndex, partitionCount } = partitionInfo;
if (
partitionCount <= 0 ||
partitionIndex < 0 ||
partitionIndex >= partitionCount
) {
throw new Error(
`Invalid partition info: index ${partitionIndex}, count ${partitionCount}`
);
}
// 2^32 total possible values for the first 32 bits (4.3 billion)
const TOTAL_VALUES = 0x100000000;
// The maximum possible integer value (0xFFFFFFFF)
const MAX_VALUE = TOTAL_VALUES - 1;
// Ensure even distribution of values by calculating exact range size
const rangeSize = Math.floor(TOTAL_VALUES / partitionCount);
const rangeStart = partitionIndex * rangeSize;
let rangeEnd: number;
if (partitionIndex === partitionCount - 1) {
// The last partition takes any remainder and goes up to the max value
rangeEnd = MAX_VALUE;
} else {
// The end is the start of the *next* partition minus 1
rangeEnd = (partitionIndex + 1) * rangeSize - 1;
}
// Use Number.prototype.toString(16) and padStart(8, '0') for the 32-bit hex prefix
const startHex = rangeStart.toString(16).padStart(8, "0");
const endHex = rangeEnd.toString(16).padStart(8, "0");
// Start: First 32 bits (prefix) followed by the lowest possible values for the rest
// Ensures correct UUID v4 version (4xxx) and variant (8|9|a|bxxx) bits
const startUuid = `${startHex}-0000-4000-8000-000000000000`;
// End: First 32 bits (prefix) followed by the highest possible values for the rest
// Ensures correct UUID v4 version (4xxx) and variant (8|9|a|bxxx) bits
const endUuid = `${endHex}-ffff-4fff-bfff-ffffffffffff`;
return [startUuid, endUuid];
}
/**
* Optimized partitioning method for UUID primary keys using range-based distribution.
* Divides the UUID space into N equal ranges and assigns each partition a range.
*
* @param idField The name of the UUID primary key field to partition on
* @param partitionInfo The partition information
* @returns A WHERE clause for partitioned queries
*
* @example
* const where = {
* deletedAt: { [Op.lt]: someDate },
* ...this.getPartitionWhereClause("id", props.partition)
* };
*/
protected getPartitionWhereClause(
idField: string,
partitionInfo: PartitionInfo | undefined
): WhereAttributeHash {
if (!partitionInfo) {
return {};
}
const [startUuid, endUuid] = this.getPartitionBounds(partitionInfo);
return {
[idField]: {
[Op.gte]: startUuid,
[Op.lte]: endUuid,
},
};
}
}