koatty_schedule
Version:
Schedule for koatty.
973 lines (962 loc) • 36.8 kB
JavaScript
/*!
* @Author: richen
* @Date: 2025-06-23 00:55:12
* @License: BSD (3-Clause)
* @Copyright (c) - <richenlin(at)gmail.com>
* @HomePage: https://koatty.org/
*/
;
var koatty_container = require('koatty_container');
var redlock = require('@sesamecare-oss/redlock');
var ioredis = require('ioredis');
var koatty_logger = require('koatty_logger');
var koatty_lib = require('koatty_lib');
var cron = require('cron');
/*
* @Description: Configuration management for koatty_schedule
* @Usage:
* @Author: richen
* @Date: 2024-01-17 15:30:00
* @LastEditTime: 2024-01-17 16:30:00
* @License: BSD (3-Clause)
* @Copyright (c): <richenlin(at)gmail.com>
*/
const COMPONENT_SCHEDULED = 'COMPONENT_SCHEDULED';
/**
* Decorator types supported by the system
*/
var DecoratorType;
(function (DecoratorType) {
DecoratorType["SCHEDULED"] = "SCHEDULED";
DecoratorType["REDLOCK"] = "REDLOCK";
})(DecoratorType || (DecoratorType = {}));
/**
* Validate cron expression format
* @param cron - Cron expression to validate
* @throws {Error} When cron expression is invalid
*/
function validateCronExpression(cron) {
if (!cron || typeof cron !== 'string') {
throw new Error('Cron expression must be a non-empty string');
}
const cronParts = cron.trim().split(/\s+/);
// Cron expressions should have 5 or 6 parts (with or without seconds)
if (cronParts.length < 5 || cronParts.length > 6) {
throw new Error(`Invalid cron expression format. Expected 5 or 6 parts, got ${cronParts.length}`);
}
// For 6-part cron (with seconds), validate each part
if (cronParts.length === 6) {
const [seconds, minutes, hours] = cronParts;
// Basic validation for obvious invalid values
if (!/^(\*|[0-9]|[0-5][0-9]|\*\/[0-9]+|[0-9]+-[0-9]+|[0-9]+(,[0-9]+)*)$/.test(seconds)) {
throw new Error('Invalid seconds field in cron expression');
}
if (!/^(\*|[0-9]|[0-5][0-9]|\*\/[0-9]+|[0-9]+-[0-9]+|[0-9]+(,[0-9]+)*)$/.test(minutes)) {
throw new Error('Invalid minutes field in cron expression');
}
if (!/^(\*|[0-9]|1[0-9]|2[0-3]|\*\/[0-9]+|[0-9]+-[0-9]+|[0-9]+(,[0-9]+)*)$/.test(hours)) {
throw new Error('Invalid hours field in cron expression');
}
// Check for simple out-of-range values
const secondsValue = parseInt(seconds);
if (!isNaN(secondsValue) && (secondsValue < 0 || secondsValue > 59)) {
throw new Error('Seconds value must be between 0 and 59');
}
}
// Additional basic checks for common invalid patterns
if (cron.includes('60')) {
// Check if 60 appears as a standalone number (not part of a larger number)
const parts = cron.split(/[\s,\-\/]/);
if (parts.some(part => part === '60')) {
throw new Error('Invalid time value: 60 is not valid for any time field');
}
}
}
/**
* Validate RedLock method-level options
* @param options - RedLock method options to validate
* @throws {Error} When options are invalid
*/
function validateRedLockMethodOptions(options) {
if (!options || typeof options !== 'object') {
throw new Error('RedLock method options must be an object');
}
if (options.lockTimeOut !== undefined) {
if (typeof options.lockTimeOut !== 'number' || options.lockTimeOut <= 0) {
throw new Error('lockTimeOut must be a positive number');
}
}
if (options.clockDriftFactor !== undefined) {
if (typeof options.clockDriftFactor !== 'number' || options.clockDriftFactor < 0 || options.clockDriftFactor > 1) {
throw new Error('clockDriftFactor must be a number between 0 and 1');
}
}
if (options.maxRetries !== undefined) {
if (typeof options.maxRetries !== 'number' || options.maxRetries < 0) {
throw new Error('maxRetries must be a non-negative number');
}
}
if (options.retryDelayMs !== undefined) {
if (typeof options.retryDelayMs !== 'number' || options.retryDelayMs < 0) {
throw new Error('retryDelayMs must be a non-negative number');
}
}
}
//==================== Global Configuration Management ====================
/**
* Global configuration storage
*/
let globalScheduledOptions = {};
/**
* Get global scheduled options
* @returns Global scheduled options
*/
function getGlobalScheduledOptions() {
return globalScheduledOptions;
}
/**
* Get effective timezone with priority: user specified > global > default
* @param userTimezone - User specified timezone
* @returns Effective timezone
*/
function getEffectiveTimezone(options, userTimezone) {
return userTimezone || options.timezone || 'Asia/Beijing';
}
/**
* Get effective RedLock method options with priority: method options > global options > defaults
* @param methodOptions - Method-level RedLock options
* @returns Effective RedLock method options with all defaults applied
*/
function getEffectiveRedLockOptions(methodOptions) {
const globalOptions = getGlobalScheduledOptions();
return {
lockTimeOut: methodOptions?.lockTimeOut || globalOptions.lockTimeOut || 10000,
clockDriftFactor: methodOptions?.clockDriftFactor || globalOptions.clockDriftFactor || 0.01,
maxRetries: methodOptions?.maxRetries || globalOptions.maxRetries || 3,
retryDelayMs: methodOptions?.retryDelayMs || globalOptions.retryDelayMs || 200
};
}
/*
* @Description: RedLock utility for distributed locks
* @Usage:
* @Author: richen
* @Date: 2021-11-17 16:18:33
* @LastEditTime: 2024-01-17 15:00:00
* @License: BSD (3-Clause)
* @Copyright (c): <richenlin(at)gmail.com>
*/
/**
* Default RedLock configuration
*/
const defaultRedLockConfig = {
lockTimeOut: 10000,
clockDriftFactor: 0.01,
maxRetries: 3,
retryDelayMs: 200,
driftFactor: 0.01,
retryCount: 3,
retryDelay: 200,
retryJitter: 200,
automaticExtensionThreshold: 500,
redisConfig: {
host: '127.0.0.1',
port: 6379,
password: '',
db: 0,
keyPrefix: 'redlock:'
}
};
/**
* RedLock distributed lock manager
* Integrated with koatty IOC container
* Implements singleton pattern for safe instance management
*/
class RedLocker {
static instance = null;
static instanceLock = Symbol('RedLocker.instanceLock');
redlock = null;
redis = null;
config;
isInitialized = false;
initializationPromise = null;
// 私有构造函数防止外部直接实例化
constructor(options) {
this.config = { ...defaultRedLockConfig, ...options };
// Register this instance in IOC container
this.registerInContainer();
}
/**
* Register RedLocker in IOC container
* @private
*/
registerInContainer() {
try {
// Register as a singleton component in IOC container
koatty_container.IOCContainer.reg('RedLocker', this, {
type: 'COMPONENT',
args: []
});
koatty_logger.DefaultLogger.Debug('RedLocker registered in IOC container');
}
catch (_error) {
koatty_logger.DefaultLogger.Warn('Failed to register RedLocker in IOC container:', _error);
}
}
/**
* Get RedLocker singleton instance with thread-safe initialization
* @static
* @param options - RedLock configuration options (only used for first initialization)
* @returns RedLocker singleton instance
*/
static getInstance(options) {
// 双重检查锁定模式确保线程安全
if (!RedLocker.instance) {
// 首次创建时使用选项,后续调用忽略选项参数
if (RedLocker.instance === null) {
try {
// 尝试从IOC容器获取已存在的实例
const containerInstance = koatty_container.IOCContainer.get('RedLocker', 'COMPONENT');
if (containerInstance) {
RedLocker.instance = containerInstance;
koatty_logger.DefaultLogger.Debug('Retrieved existing RedLocker instance from IOC container');
}
else {
// 创建新的单例实例
RedLocker.instance = new RedLocker(options);
koatty_logger.DefaultLogger.Debug('Created new RedLocker singleton instance');
}
}
catch {
// IOC容器不可用时直接创建
RedLocker.instance = new RedLocker(options);
koatty_logger.DefaultLogger.Debug('Created new RedLocker instance outside IOC container');
}
}
}
else if (options) {
// 如果实例已存在但传入了新选项,记录警告
koatty_logger.DefaultLogger.Warn('RedLocker instance already exists, ignoring new options. Use updateConfig() to change configuration.');
}
return RedLocker.instance;
}
/**
* Reset singleton instance (主要用于测试)
* @static
*/
static resetInstance() {
if (RedLocker.instance) {
RedLocker.instance.close().catch(err => koatty_logger.DefaultLogger.Warn('Error while closing RedLocker instance during reset:', err));
RedLocker.instance = null;
}
}
/**
* Initialize RedLock with Redis connection
* Uses cached promise to avoid duplicate initialization
* @private
*/
async initialize() {
// 如果已经初始化,直接返回
if (this.isInitialized) {
return;
}
// 如果正在初始化,等待现有的初始化完成
if (this.initializationPromise) {
return this.initializationPromise;
}
// 创建初始化Promise并缓存
this.initializationPromise = this.performInitialization();
try {
await this.initializationPromise;
}
catch (error) {
// 初始化失败时清理缓存,允许重试
this.initializationPromise = null;
throw error;
}
}
/**
* 执行实际的初始化操作
* @private
*/
async performInitialization() {
try {
// Try to get Redis instance from IOC container first
try {
this.redis = koatty_container.IOCContainer.get('Redis', 'COMPONENT');
koatty_logger.DefaultLogger.Debug('Using Redis instance from IOC container');
}
catch {
// Create new Redis connection if not available in container
this.redis = new ioredis.Redis({
host: this.config.redisConfig.host,
port: this.config.redisConfig.port,
password: this.config.redisConfig.password || undefined,
db: this.config.redisConfig.db || 0,
keyPrefix: this.config.redisConfig.keyPrefix,
maxRetriesPerRequest: 3
});
koatty_logger.DefaultLogger.Debug('Created new Redis connection for RedLocker');
}
if (!this.redis) {
throw new Error('Failed to initialize Redis connection');
}
// Initialize Redlock with the Redis instance
this.redlock = new redlock.Redlock([this.redis], {
driftFactor: this.config.driftFactor,
retryCount: this.config.retryCount,
retryDelay: this.config.retryDelay,
retryJitter: this.config.retryJitter,
automaticExtensionThreshold: this.config.automaticExtensionThreshold
});
// Set up error handlers
this.redlock.on('clientError', (err) => {
koatty_logger.DefaultLogger.Error('Redis client error in RedLock:', err);
});
this.isInitialized = true;
koatty_logger.DefaultLogger.Info('RedLocker initialized successfully');
}
catch (error) {
this.isInitialized = false;
koatty_logger.DefaultLogger.Error('Failed to initialize RedLocker:', error);
throw new Error(`RedLocker initialization failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Acquire a distributed lock
* @param resources - Resource identifiers to lock
* @param ttl - Time to live in milliseconds
* @returns Promise<Lock>
*/
async acquire(resources, ttl) {
if (!Array.isArray(resources) || resources.length === 0) {
throw new Error('Resources array cannot be empty');
}
const lockTtl = ttl || this.config.lockTimeOut;
if (lockTtl <= 0) {
throw new Error('Lock TTL must be positive');
}
// Ensure RedLocker is initialized
await this.initialize();
if (!this.redlock) {
throw new Error('RedLock is not initialized');
}
try {
// Add key prefix to resources
const prefixedResources = resources.map(resource => `${this.config.redisConfig.keyPrefix}${resource}`);
koatty_logger.DefaultLogger.Debug(`Acquiring lock for resources: ${prefixedResources.join(', ')} with TTL: ${lockTtl}ms`);
const lock = await this.redlock.acquire(prefixedResources, lockTtl);
koatty_logger.DefaultLogger.Debug(`Lock acquired successfully for resources: ${prefixedResources.join(', ')}`);
return lock;
}
catch (error) {
koatty_logger.DefaultLogger.Error(`Failed to acquire lock for resources: ${resources.join(', ')}`, error);
// 保留原始错误信息,避免过度包装
if (error instanceof Error) {
error.message = `Lock acquisition failed: ${error.message}`;
throw error;
}
throw new Error(`Lock acquisition failed: Unknown error`);
}
}
/**
* Release a lock
* @param lock - Lock instance to release
*/
async release(lock) {
if (!lock) {
throw new Error('Lock instance is required');
}
try {
await lock.release();
koatty_logger.DefaultLogger.Debug('Lock released successfully');
}
catch (error) {
koatty_logger.DefaultLogger.Error('Failed to release lock:', error);
// 保留原始错误信息
if (error instanceof Error) {
error.message = `Lock release failed: ${error.message}`;
throw error;
}
throw new Error(`Lock release failed: Unknown error`);
}
}
/**
* Extend a lock's TTL
* @param lock - Lock instance to extend
* @param ttl - New TTL in milliseconds
* @returns Extended lock
*/
async extend(lock, ttl) {
if (!lock) {
throw new Error('Lock instance is required');
}
if (ttl <= 0) {
throw new Error('TTL must be positive');
}
try {
const extendedLock = await lock.extend(ttl);
koatty_logger.DefaultLogger.Debug(`Lock extended successfully with TTL: ${ttl}ms`);
return extendedLock;
}
catch (error) {
koatty_logger.DefaultLogger.Error('Failed to extend lock:', error);
throw new Error(`Lock extension failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Check if RedLocker is initialized
* @returns true if initialized, false otherwise
*/
isReady() {
return this.isInitialized && !!this.redlock && !!this.redis;
}
/**
* Get current configuration
* @returns Current RedLock configuration
*/
getConfig() {
return { ...this.config };
}
/**
* Update configuration (requires reinitialization)
* @param options - New RedLock options
*/
updateConfig(options) {
if (options) {
this.config = { ...this.config, ...options };
}
// 清理初始化状态,强制重新初始化
this.isInitialized = false;
this.initializationPromise = null;
this.redlock = null;
koatty_logger.DefaultLogger.Debug('RedLocker configuration updated, will reinitialize on next use');
}
/**
* Close Redis connection and cleanup
*/
async close() {
try {
if (this.redis && this.redis.status === 'ready') {
await this.redis.quit();
koatty_logger.DefaultLogger.Debug('Redis connection closed');
}
this.redis = null;
this.redlock = null;
this.isInitialized = false;
}
catch (error) {
koatty_logger.DefaultLogger.Error('Error closing RedLocker:', error);
}
}
/**
* Get container registration status
* @returns Registration information
*/
getContainerInfo() {
try {
const instance = koatty_container.IOCContainer.get('RedLocker', 'COMPONENT');
return {
registered: !!instance,
identifier: 'RedLocker'
};
}
catch {
return {
registered: false,
identifier: 'RedLocker'
};
}
}
/**
* Health check for RedLocker
* @returns Health status
*/
async healthCheck() {
try {
await this.initialize();
const redisStatus = this.redis?.status || 'unknown';
const isReady = this.isReady();
return {
status: isReady ? 'healthy' : 'unhealthy',
details: {
initialized: this.isInitialized,
redisStatus,
redlockReady: !!this.redlock,
containerRegistered: this.getContainerInfo().registered
}
};
}
catch (error) {
return {
status: 'unhealthy',
details: {
error: error instanceof Error ? error.message : 'Unknown error',
initialized: this.isInitialized
}
};
}
}
}
/*
* @Description:
* @Usage:
* @Author: richen
* @Date: 2024-01-16 19:53:14
* @LastEditTime: 2024-11-07 16:47:58
* @License: BSD (3-Clause)
* @Copyright (c): <richenlin(at)gmail.com>
*/
/**
* 返回一个 Promise,在指定时间后 reject
* @param ms
* @returns
*/
function timeoutPromise(ms) {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
clearTimeout(timeoutId);
reject(new Error('TIME_OUT_ERROR'));
}, ms);
});
}
/*
* @Description: Decorator preprocessing mechanism for koatty_schedule
* @Usage:
* @Author: richen
* @Date: 2024-01-17 16:00:00
* @LastEditTime: 2024-01-17 16:00:00
* @License: BSD (3-Clause)
* @Copyright (c): <richenlin(at)gmail.com>
*/
/**
* Initiation schedule locker client.
*
* @param {RedLockOptions} options - RedLock 配置选项
* @param {Koatty} app - Koatty 应用实例
* @returns {Promise<void>}
*/
async function initRedLock(options, app) {
if (!app || !koatty_lib.Helper.isFunction(app.once)) {
koatty_logger.DefaultLogger.Warn(`RedLock initialization skipped: Koatty app not available or not initialized`);
return;
}
app.once("appReady", async function () {
try {
if (koatty_lib.Helper.isEmpty(options)) {
throw Error(`Missing RedLock configuration. Please write a configuration item with the key name 'RedLock' in the db.ts file.`);
}
// 获取RedLocker实例,在首次使用时自动初始化
const redLocker = RedLocker.getInstance(options);
await redLocker.initialize();
koatty_logger.DefaultLogger.Info('RedLock initialized successfully');
}
catch (error) {
koatty_logger.DefaultLogger.Error('Failed to initialize RedLock:', error);
throw error;
}
});
}
/**
* Create redLocker Descriptor with improved error handling and type safety
* @param descriptor - Property descriptor
* @param name - Lock name
* @param method - Method name
* @param methodOptions - Method-level RedLock options
* @returns Enhanced property descriptor
*/
function redLockerDescriptor(descriptor, name, method, methodOptions) {
// 参数验证
if (!descriptor) {
throw new Error('Property descriptor is required');
}
if (!name || typeof name !== 'string') {
throw new Error('Lock name must be a non-empty string');
}
if (!method || typeof method !== 'string') {
throw new Error('Method name must be a non-empty string');
}
const { value, configurable, enumerable } = descriptor;
// 验证原始函数
if (typeof value !== 'function') {
throw new Error('Descriptor value must be a function');
}
/**
* Enhanced function wrapper with proper lock renewal and safety
*/
const valueFunction = async (self, initialLock, lockTime, timeout, props) => {
let currentLock = initialLock;
let remainingTime = timeout;
const maxExtensions = 3; // 限制续期次数防止无限循环
let extensionCount = 0;
try {
while (remainingTime > 0 && extensionCount < maxExtensions) {
try {
// 执行业务方法,与超时竞争
const result = await Promise.race([
value.apply(self, props),
timeoutPromise(remainingTime)
]);
return result; // 成功执行,返回业务结果
}
catch (error) {
// 处理超时错误,尝试续期锁
if (error instanceof Error && error.message === 'TIME_OUT_ERROR') {
extensionCount++;
koatty_logger.DefaultLogger.Debug(`Method ${method} execution timeout, attempting lock extension ${extensionCount}/${maxExtensions}`);
try {
// 续期锁,获得新的锁实例
currentLock = await currentLock.extend(lockTime);
remainingTime = lockTime - 200; // 预留200ms用于锁操作
koatty_logger.DefaultLogger.Debug(`Lock extended for method: ${method}, remaining time: ${remainingTime}ms`);
// 继续循环,重新执行业务方法
continue;
}
catch (extendError) {
koatty_logger.DefaultLogger.Error(`Failed to extend lock for method: ${method}`, extendError);
throw new Error(`Lock extension failed: ${extendError instanceof Error ? extendError.message : 'Unknown error'}`);
}
}
else {
// 非超时错误,直接抛出
throw error;
}
}
}
// 达到最大续期次数或剩余时间不足
throw new Error(`Method ${method} execution timeout after ${extensionCount} lock extensions`);
}
finally {
// 确保锁被释放
try {
await currentLock.release();
koatty_logger.DefaultLogger.Debug(`Lock released for method: ${method}`);
}
catch (releaseError) {
koatty_logger.DefaultLogger.Warn(`Failed to release lock for method: ${method}`, releaseError);
}
}
};
return {
configurable,
enumerable,
writable: true,
async value(...props) {
try {
const redlock = RedLocker.getInstance();
const lockOptions = getEffectiveRedLockOptions(methodOptions);
// Acquire a lock.
const lockTime = lockOptions.lockTimeOut || 10000;
if (lockTime <= 200) {
throw new Error("Lock timeout must be greater than 200ms to allow for proper execution");
}
const lock = await redlock.acquire([method, name], lockTime);
const timeout = lockTime - 200;
koatty_logger.DefaultLogger.Debug(`Lock acquired for method: ${method}, timeout: ${timeout}ms`);
return await valueFunction(this, lock, lockTime, timeout, props);
}
catch (error) {
koatty_logger.DefaultLogger.Error(`RedLock operation failed for method: ${method}`, error);
throw error;
}
},
};
}
/**
* Generate lock name for RedLock decorator
*/
function generateLockName(configName, methodName, target) {
if (configName) {
return configName;
}
try {
const targetObj = target;
const identifier = koatty_container.IOCContainer.getIdentifier(targetObj);
if (identifier) {
return `${identifier}_${methodName}`;
}
}
catch {
// Fallback if IOC container is not available
}
const targetWithConstructor = target;
const className = targetWithConstructor.constructor?.name || 'Unknown';
return `${className}_${methodName}`;
}
/*
* @Description:
* @Usage:
* @Author: richen
* @Date: 2025-06-09 16:00:00
* @LastEditTime: 2025-06-09 16:00:00
* @License: BSD (3-Clause)
* @Copyright (c): <richenlin(at)gmail.com>
*/
/**
* Redis-based distributed lock decorator
*
* @export
* @param {string} [name] - The locker name. If name is duplicated, lock sharing contention will result.
* If not provided, a unique name will be auto-generated using method name + random suffix.
* IMPORTANT: Auto-generated names are unique per method deployment and not predictable.
* @param {RedLockMethodOptions} [options] - Lock configuration options for this method
*
* @returns {MethodDecorator}
* @throws {Error} When decorator is used on wrong class type or invalid configuration
*
* @example
* ```typescript
* class UserService {
* @RedLock('user_update_lock', { lockTimeOut: 5000, maxRetries: 2 })
* async updateUser(id: string, data: any) {
* // This method will be protected by a distributed lock with predictable name
* }
*
* @RedLock() // Auto-generated unique name like "deleteUser_abc123_xyz789"
* async deleteUser(id: string) {
* // This method will be protected by a distributed lock with auto-generated unique name
* }
* }
* ```
*/
function RedLock(lockName, options) {
return (target, propertyKey, descriptor) => {
const methodName = propertyKey.toString();
// 验证装饰器使用的类型(从原型对象获取类构造函数)
const targetClass = target.constructor;
const componentType = koatty_container.IOCContainer.getType(targetClass);
if (componentType !== "SERVICE" && componentType !== "COMPONENT") {
throw Error("@RedLock decorator can only be used on SERVICE or COMPONENT classes.");
}
// 验证方法名
if (!methodName || typeof methodName !== 'string') {
throw Error("Method name is required for @RedLock decorator");
}
// 验证方法描述符
if (!descriptor || typeof descriptor.value !== 'function') {
throw Error("@RedLock decorator can only be applied to methods");
}
// 生成锁名称:用户指定的 > 基于类名和方法名生成
const finalLockName = lockName || generateLockName(lockName, methodName, target);
// 验证选项
if (options) {
validateRedLockMethodOptions(options);
}
// 保存类到IOC容器
koatty_container.IOCContainer.saveClass("COMPONENT", targetClass, targetClass.name);
try {
// 直接在装饰器中包装方法,而不是延迟处理
const enhancedDescriptor = redLockerDescriptor(descriptor, finalLockName, methodName, options);
return enhancedDescriptor;
}
catch (error) {
throw new Error(`Failed to apply RedLock to ${methodName}: ${error.message}`);
}
};
}
/*
* @Description:
* @Usage:
* @Author: richen
* @Date: 2025-06-09 16:00:00
* @LastEditTime: 2025-06-09 16:00:00
* @License: BSD (3-Clause)
* @Copyright (c): <richenlin(at)gmail.com>
*/
/**
* Schedule task decorator with optimized preprocessing
*
* @export
* @param {string} cron - Cron expression for task scheduling
* @param {string} [timezone='Asia/Beijing'] - Timezone for the schedule
*
* Cron expression format:
* * Seconds: 0-59
* * Minutes: 0-59
* * Hours: 0-23
* * Day of Month: 1-31
* * Months: 1-12 (Jan-Dec)
* * Day of Week: 1-7 (Sun-Sat)
*
* @returns {MethodDecorator}
* @throws {Error} When cron expression is invalid or decorator is used on wrong class type
*/
function Scheduled(cron, timezone = 'Asia/Beijing') {
// 参数验证
if (koatty_lib.Helper.isEmpty(cron)) {
throw Error("Cron expression is required and cannot be empty");
}
// 验证cron表达式格式
try {
validateCronExpression(cron);
}
catch (error) {
throw Error(`Invalid cron expression: ${error.message}`);
}
// 验证时区
if (timezone && typeof timezone !== 'string') {
throw Error("Timezone must be a string");
}
return (target, propertyKey, descriptor) => {
// 验证装饰器使用的类型(从原型对象获取类构造函数)
const targetClass = target.constructor;
const componentType = koatty_container.IOCContainer.getType(targetClass);
if (componentType !== "SERVICE" && componentType !== "COMPONENT") {
throw Error("@Scheduled decorator can only be used on SERVICE or COMPONENT classes.");
}
// 验证方法名
const methodName = propertyKey.toString();
if (!methodName || typeof methodName !== 'string') {
throw Error("Method name is required for @Scheduled decorator");
}
// 验证方法描述符
if (!descriptor || typeof descriptor.value !== 'function') {
throw Error("@Scheduled decorator can only be applied to methods");
}
// 保存类到IOC容器
koatty_container.IOCContainer.saveClass("COMPONENT", targetClass, targetClass.name);
// 保存调度元数据到 IOC 容器
koatty_container.IOCContainer.attachClassMetadata(COMPONENT_SCHEDULED, DecoratorType.SCHEDULED, {
method: methodName,
cron,
timezone // 保存确定的时区值
}, target, methodName);
};
}
/**
* @ author: richen
* @ copyright: Copyright (c) - <richenlin(at)gmail.com>
* @ license: MIT
* @ version: 2020-07-06 10:29:20
*/
/**
* 初始化调度任务系统
* 在appReady时触发批量注入调度任务,确保所有初始化工作完成
*
* @param {Koatty} app - Koatty 应用实例
* @param {any} options - 调度任务配置
*/
async function initSchedule(options, app) {
if (!app || !koatty_lib.Helper.isFunction(app.once)) {
koatty_logger.DefaultLogger.Warn(`Schedule initialization skipped: Koatty app not available or not initialized`);
return;
}
app.once("appReady", async function () {
try {
await injectSchedule(options);
koatty_logger.DefaultLogger.Info('Schedule system initialized successfully');
}
catch (error) {
koatty_logger.DefaultLogger.Error('Failed to initialize Schedule system:', error);
throw error;
}
});
}
/**
* Inject schedule job with enhanced error handling and validation
*
* @param {unknown} target - Target class
* @param {string} method - Method name
* @param {string} cron - Cron expression
* @param {string} [timezone] - Timezone
*/
/**
* 批量注入调度任务 - 从IOC容器读取类元数据并创建所有CronJob
*/
async function injectSchedule(options) {
try {
koatty_logger.DefaultLogger.Debug('Starting batch schedule injection...');
const componentList = koatty_container.IOCContainer.listClass("COMPONENT");
for (const component of componentList) {
const classMetadata = koatty_container.IOCContainer.getClassMetadata(COMPONENT_SCHEDULED, DecoratorType.SCHEDULED, component.target);
if (!classMetadata) {
continue;
}
let scheduledCount = 0;
for (const [className, metadata] of classMetadata) {
try {
const instance = koatty_container.IOCContainer.get(className);
if (!instance) {
continue;
}
// 查找所有调度方法的元数据
for (const [key, value] of Object.entries(metadata)) {
if (key.startsWith('SCHEDULED')) {
const scheduleData = value;
const targetMethod = instance[scheduleData.method];
if (!koatty_lib.Helper.isFunction(targetMethod)) {
koatty_logger.DefaultLogger.Warn(`Schedule injection skipped: method ${scheduleData.method} is not a function in ${className}`);
continue;
}
const taskName = `${className}_${scheduleData.method}`;
const tz = getEffectiveTimezone(options, scheduleData.timezone);
new cron.CronJob(scheduleData.cron, () => {
koatty_logger.DefaultLogger.Debug(`The schedule job ${taskName} started.`);
Promise.resolve(targetMethod.call(instance))
.then(() => {
koatty_logger.DefaultLogger.Debug(`The schedule job ${taskName} completed.`);
})
.catch((error) => {
koatty_logger.DefaultLogger.Error(`The schedule job ${taskName} failed:`, error);
});
}, null, // onComplete
true, // start
tz // timeZone
);
scheduledCount++;
koatty_logger.DefaultLogger.Debug(`Schedule job ${taskName} registered with cron: ${scheduleData.cron}`);
}
}
}
catch (error) {
koatty_logger.DefaultLogger.Error(`Failed to process class ${className}:`, error);
}
}
koatty_logger.DefaultLogger.Info(`Batch schedule injection completed. ${scheduledCount} jobs registered.`);
}
}
catch (error) {
koatty_logger.DefaultLogger.Error('Failed to inject schedules:', error);
}
}
/**
* @ author: richen
* @ copyright: Copyright (c) - <richenlin(at)gmail.com>
* @ license: MIT
* @ version: 2020-07-06 10:30:11
*/
/**
* @deprecated Use RedLock instead. This will be removed in v3.0.0
*/
const SchedulerLock = RedLock;
/**
* defaultOptions
*/
const defaultOptions = {
timezone: "Asia/Beijing",
lockTimeOut: 10000,
clockDriftFactor: 0.01,
maxRetries: 3,
retryDelayMs: 200,
redisConfig: {
host: "localhost",
port: 6379,
password: "",
db: 0,
keyPrefix: "redlock:"
}
};
/**
* @param options - The options for the scheduled job
* @param app - The Koatty application instance
*/
async function KoattyScheduled(options, app) {
options = { ...defaultOptions, ...options };
// 初始化RedLock(appReady时触发,确保所有依赖就绪)
await initRedLock(options, app);
// 初始化调度任务系统(appReady时触发,确保所有组件都已初始化)
await initSchedule(options, app);
}
exports.KoattyScheduled = KoattyScheduled;
exports.RedLock = RedLock;
exports.Scheduled = Scheduled;
exports.SchedulerLock = SchedulerLock;