koatty_schedule 3.3.1 → 3.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/README.md +307 -6
- package/dist/README.md +307 -6
- package/dist/index.d.ts +323 -14
- package/dist/index.js +596 -274
- package/dist/index.mjs +594 -275
- package/dist/package.json +4 -4
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/*!
|
|
2
2
|
* @Author: richen
|
|
3
|
-
* @Date: 2025-
|
|
3
|
+
* @Date: 2025-10-31 17:50:55
|
|
4
4
|
* @License: BSD (3-Clause)
|
|
5
5
|
* @Copyright (c) - <richenlin(at)gmail.com>
|
|
6
6
|
* @HomePage: https://koatty.org/
|
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
'use strict';
|
|
9
9
|
|
|
10
10
|
var koatty_container = require('koatty_container');
|
|
11
|
-
var koatty_lib = require('koatty_lib');
|
|
12
11
|
var redlock = require('@sesamecare-oss/redlock');
|
|
13
|
-
var ioredis = require('ioredis');
|
|
14
12
|
var koatty_logger = require('koatty_logger');
|
|
13
|
+
var Redis = require('ioredis');
|
|
14
|
+
var koatty_lib = require('koatty_lib');
|
|
15
15
|
var cron = require('cron');
|
|
16
16
|
|
|
17
17
|
/*
|
|
@@ -24,7 +24,6 @@ var cron = require('cron');
|
|
|
24
24
|
* @Copyright (c): <richenlin(at)gmail.com>
|
|
25
25
|
*/
|
|
26
26
|
const COMPONENT_SCHEDULED = 'COMPONENT_SCHEDULED';
|
|
27
|
-
const COMPONENT_REDLOCK = 'COMPONENT_REDLOCK';
|
|
28
27
|
/**
|
|
29
28
|
* Decorator types supported by the system
|
|
30
29
|
*/
|
|
@@ -34,45 +33,110 @@ var DecoratorType;
|
|
|
34
33
|
DecoratorType["REDLOCK"] = "REDLOCK";
|
|
35
34
|
})(DecoratorType || (DecoratorType = {}));
|
|
36
35
|
/**
|
|
37
|
-
* Validate cron expression format
|
|
36
|
+
* Validate cron expression format (supports both 5-part and 6-part formats)
|
|
37
|
+
*
|
|
38
|
+
* 6-part format: second minute hour day month weekday
|
|
39
|
+
* 5-part format: minute hour day month weekday
|
|
40
|
+
*
|
|
38
41
|
* @param cron - Cron expression to validate
|
|
39
42
|
* @throws {Error} When cron expression is invalid
|
|
40
43
|
*/
|
|
41
44
|
function validateCronExpression(cron) {
|
|
42
45
|
if (!cron || typeof cron !== 'string') {
|
|
43
|
-
throw new Error('Cron expression must be a non-empty string');
|
|
46
|
+
throw new Error('Cron 表达式必须是非空字符串 (Cron expression must be a non-empty string)');
|
|
44
47
|
}
|
|
45
48
|
const cronParts = cron.trim().split(/\s+/);
|
|
46
49
|
// Cron expressions should have 5 or 6 parts (with or without seconds)
|
|
47
50
|
if (cronParts.length < 5 || cronParts.length > 6) {
|
|
48
|
-
throw new Error(`Invalid cron
|
|
51
|
+
throw new Error(`Cron 表达式格式无效。期望 5 或 6 部分,实际得到 ${cronParts.length} 部分 (Invalid cron format. Expected 5 or 6 parts, got ${cronParts.length})`);
|
|
49
52
|
}
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
// Determine if this is a 6-part (with seconds) or 5-part expression
|
|
54
|
+
const hasSecs = cronParts.length === 6;
|
|
55
|
+
const offset = hasSecs ? 0 : -1;
|
|
56
|
+
// Extract parts with proper indexing
|
|
57
|
+
const seconds = hasSecs ? cronParts[0] : null;
|
|
58
|
+
const minutes = cronParts[offset + 1];
|
|
59
|
+
const hours = cronParts[offset + 2];
|
|
60
|
+
const dayOfMonth = cronParts[offset + 3];
|
|
61
|
+
const month = cronParts[offset + 4];
|
|
62
|
+
const dayOfWeek = cronParts[offset + 5];
|
|
63
|
+
// Validate seconds (0-59) if present
|
|
64
|
+
if (seconds !== null) {
|
|
65
|
+
validateCronField(seconds, 0, 59, 'seconds', '秒');
|
|
66
|
+
}
|
|
67
|
+
// Validate minutes (0-59)
|
|
68
|
+
validateCronField(minutes, 0, 59, 'minutes', '分钟');
|
|
69
|
+
// Validate hours (0-23)
|
|
70
|
+
validateCronField(hours, 0, 23, 'hours', '小时');
|
|
71
|
+
// Validate day of month (1-31)
|
|
72
|
+
validateCronField(dayOfMonth, 1, 31, 'day of month', '日期');
|
|
73
|
+
// Validate month (1-12 or JAN-DEC)
|
|
74
|
+
validateCronField(month, 1, 12, 'month', '月份', ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC']);
|
|
75
|
+
// Validate day of week (0-7 or SUN-SAT, where 0 and 7 both represent Sunday)
|
|
76
|
+
validateCronField(dayOfWeek, 0, 7, 'day of week', '星期', ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT']);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Validate individual cron field
|
|
80
|
+
* @param field - Field value to validate
|
|
81
|
+
* @param min - Minimum allowed value
|
|
82
|
+
* @param max - Maximum allowed value
|
|
83
|
+
* @param fieldName - English field name for error messages
|
|
84
|
+
* @param fieldNameCN - Chinese field name for error messages
|
|
85
|
+
* @param allowedStrings - Optional array of allowed string values (e.g., month/weekday names)
|
|
86
|
+
*/
|
|
87
|
+
function validateCronField(field, min, max, fieldName, fieldNameCN, allowedStrings) {
|
|
88
|
+
// Allow wildcard
|
|
89
|
+
if (field === '*') {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
// Allow question mark (for day of month / day of week)
|
|
93
|
+
if (field === '?') {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
// Check for allowed string values (month/weekday names)
|
|
97
|
+
if (allowedStrings && allowedStrings.some(str => field.toUpperCase().includes(str))) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
// Step values (e.g., */5, 0-30/5)
|
|
101
|
+
if (field.includes('/')) {
|
|
102
|
+
const [range, step] = field.split('/');
|
|
103
|
+
const stepValue = parseInt(step);
|
|
104
|
+
if (isNaN(stepValue) || stepValue <= 0) {
|
|
105
|
+
throw new Error(`${fieldNameCN}字段的步长值无效: ${step} (Invalid step value for ${fieldName}: ${step})`);
|
|
106
|
+
}
|
|
107
|
+
if (range !== '*') {
|
|
108
|
+
validateCronField(range, min, max, fieldName, fieldNameCN, allowedStrings);
|
|
56
109
|
}
|
|
57
|
-
|
|
58
|
-
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
// Range values (e.g., 1-5)
|
|
113
|
+
if (field.includes('-')) {
|
|
114
|
+
const [start, end] = field.split('-');
|
|
115
|
+
const startValue = parseInt(start);
|
|
116
|
+
const endValue = parseInt(end);
|
|
117
|
+
if (isNaN(startValue) || startValue < min || startValue > max) {
|
|
118
|
+
throw new Error(`${fieldNameCN}字段的范围起始值无效: ${start},必须在 ${min}-${max} 之间 (Invalid range start for ${fieldName}: ${start}, must be between ${min}-${max})`);
|
|
59
119
|
}
|
|
60
|
-
if (
|
|
61
|
-
throw new Error(
|
|
120
|
+
if (isNaN(endValue) || endValue < min || endValue > max) {
|
|
121
|
+
throw new Error(`${fieldNameCN}字段的范围结束值无效: ${end},必须在 ${min}-${max} 之间 (Invalid range end for ${fieldName}: ${end}, must be between ${min}-${max})`);
|
|
62
122
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if (!isNaN(secondsValue) && (secondsValue < 0 || secondsValue > 59)) {
|
|
66
|
-
throw new Error('Seconds value must be between 0 and 59');
|
|
123
|
+
if (startValue > endValue) {
|
|
124
|
+
throw new Error(`${fieldNameCN}字段的范围无效: ${start}-${end},起始值不能大于结束值 (Invalid range for ${fieldName}: ${start}-${end}, start cannot be greater than end)`);
|
|
67
125
|
}
|
|
126
|
+
return;
|
|
68
127
|
}
|
|
69
|
-
//
|
|
70
|
-
if (
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
throw new Error('Invalid time value: 60 is not valid for any time field');
|
|
128
|
+
// List values (e.g., 1,3,5)
|
|
129
|
+
if (field.includes(',')) {
|
|
130
|
+
const values = field.split(',');
|
|
131
|
+
for (const value of values) {
|
|
132
|
+
validateCronField(value.trim(), min, max, fieldName, fieldNameCN, allowedStrings);
|
|
75
133
|
}
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
// Single numeric value
|
|
137
|
+
const numValue = parseInt(field);
|
|
138
|
+
if (isNaN(numValue) || numValue < min || numValue > max) {
|
|
139
|
+
throw new Error(`${fieldNameCN}字段的值无效: ${field},必须在 ${min}-${max} 之间 (Invalid ${fieldName} value: ${field}, must be between ${min}-${max})`);
|
|
76
140
|
}
|
|
77
141
|
}
|
|
78
142
|
/**
|
|
@@ -110,13 +174,6 @@ function validateRedLockMethodOptions(options) {
|
|
|
110
174
|
* Global configuration storage
|
|
111
175
|
*/
|
|
112
176
|
let globalScheduledOptions = {};
|
|
113
|
-
/**
|
|
114
|
-
* Set global scheduled options
|
|
115
|
-
* @param options - Global scheduled options
|
|
116
|
-
*/
|
|
117
|
-
function setGlobalScheduledOptions(options) {
|
|
118
|
-
globalScheduledOptions = { ...options };
|
|
119
|
-
}
|
|
120
177
|
/**
|
|
121
178
|
* Get global scheduled options
|
|
122
179
|
* @returns Global scheduled options
|
|
@@ -129,8 +186,8 @@ function getGlobalScheduledOptions() {
|
|
|
129
186
|
* @param userTimezone - User specified timezone
|
|
130
187
|
* @returns Effective timezone
|
|
131
188
|
*/
|
|
132
|
-
function getEffectiveTimezone(userTimezone) {
|
|
133
|
-
return userTimezone ||
|
|
189
|
+
function getEffectiveTimezone(options, userTimezone) {
|
|
190
|
+
return userTimezone || options.timezone || 'Asia/Beijing';
|
|
134
191
|
}
|
|
135
192
|
/**
|
|
136
193
|
* Get effective RedLock method options with priority: method options > global options > defaults
|
|
@@ -148,147 +205,247 @@ function getEffectiveRedLockOptions(methodOptions) {
|
|
|
148
205
|
}
|
|
149
206
|
|
|
150
207
|
/*
|
|
151
|
-
* @Description:
|
|
208
|
+
* @Description: Abstract interfaces for Redis client and distributed lock
|
|
152
209
|
* @Usage:
|
|
153
210
|
* @Author: richen
|
|
154
|
-
* @Date: 2025-
|
|
155
|
-
* @LastEditTime: 2025-
|
|
211
|
+
* @Date: 2025-10-30 12:00:00
|
|
212
|
+
* @LastEditTime: 2025-10-30 12:00:00
|
|
156
213
|
* @License: BSD (3-Clause)
|
|
157
214
|
* @Copyright (c): <richenlin(at)gmail.com>
|
|
158
215
|
*/
|
|
159
216
|
/**
|
|
160
|
-
* Redis
|
|
161
|
-
*
|
|
162
|
-
* @export
|
|
163
|
-
* @param {string} [name] - The locker name. If name is duplicated, lock sharing contention will result.
|
|
164
|
-
* If not provided, a unique name will be auto-generated using method name + random suffix.
|
|
165
|
-
* IMPORTANT: Auto-generated names are unique per method deployment and not predictable.
|
|
166
|
-
* @param {RedLockMethodOptions} [options] - Lock configuration options for this method
|
|
167
|
-
*
|
|
168
|
-
* @returns {MethodDecorator}
|
|
169
|
-
* @throws {Error} When decorator is used on wrong class type or invalid configuration
|
|
170
|
-
*
|
|
171
|
-
* @example
|
|
172
|
-
* ```typescript
|
|
173
|
-
* class UserService {
|
|
174
|
-
* @RedLock('user_update_lock', { lockTimeOut: 5000, maxRetries: 2 })
|
|
175
|
-
* async updateUser(id: string, data: any) {
|
|
176
|
-
* // This method will be protected by a distributed lock with predictable name
|
|
177
|
-
* }
|
|
178
|
-
*
|
|
179
|
-
* @RedLock() // Auto-generated unique name like "deleteUser_abc123_xyz789"
|
|
180
|
-
* async deleteUser(id: string) {
|
|
181
|
-
* // This method will be protected by a distributed lock with auto-generated unique name
|
|
182
|
-
* }
|
|
183
|
-
* }
|
|
184
|
-
* ```
|
|
217
|
+
* Redis connection mode
|
|
185
218
|
*/
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if (componentType !== "SERVICE" && componentType !== "COMPONENT") {
|
|
193
|
-
throw Error("@RedLock decorator can only be used on SERVICE or COMPONENT classes.");
|
|
194
|
-
}
|
|
195
|
-
// 验证方法名
|
|
196
|
-
if (!methodName || typeof methodName !== 'string') {
|
|
197
|
-
throw Error("Method name is required for @RedLock decorator");
|
|
198
|
-
}
|
|
199
|
-
// 验证方法描述符
|
|
200
|
-
if (!descriptor || typeof descriptor.value !== 'function') {
|
|
201
|
-
throw Error("@RedLock decorator can only be applied to methods");
|
|
202
|
-
}
|
|
203
|
-
// 生成唯一的锁名称:用户指定的 > 自动生成的唯一名称
|
|
204
|
-
if (!lockName || lockName.trim() === '') {
|
|
205
|
-
const randomSuffix = Math.random().toString(36).substring(2, 8); // 6位随机字符
|
|
206
|
-
const timestamp = Date.now().toString(36); // 时间戳转36进制
|
|
207
|
-
lockName = `${methodName}_${randomSuffix}_${timestamp}`;
|
|
208
|
-
}
|
|
209
|
-
// 验证选项
|
|
210
|
-
if (options) {
|
|
211
|
-
validateRedLockMethodOptions(options);
|
|
212
|
-
}
|
|
213
|
-
// 保存类到IOC容器
|
|
214
|
-
koatty_container.IOCContainer.saveClass("COMPONENT", targetClass, targetClass.name);
|
|
215
|
-
// 保存RedLock元数据到 IOC 容器(lockName已确定)
|
|
216
|
-
koatty_container.IOCContainer.attachClassMetadata(COMPONENT_REDLOCK, DecoratorType.REDLOCK, {
|
|
217
|
-
method: methodName,
|
|
218
|
-
name: lockName, // 确定的锁名称,不会为undefined
|
|
219
|
-
options
|
|
220
|
-
}, target, methodName);
|
|
221
|
-
};
|
|
222
|
-
}
|
|
219
|
+
exports.RedisMode = void 0;
|
|
220
|
+
(function (RedisMode) {
|
|
221
|
+
RedisMode["STANDALONE"] = "standalone";
|
|
222
|
+
RedisMode["SENTINEL"] = "sentinel";
|
|
223
|
+
RedisMode["CLUSTER"] = "cluster"; // 集群模式
|
|
224
|
+
})(exports.RedisMode || (exports.RedisMode = {}));
|
|
223
225
|
|
|
224
226
|
/*
|
|
225
|
-
* @Description:
|
|
227
|
+
* @Description: Redis client factory supporting multiple modes
|
|
226
228
|
* @Usage:
|
|
227
229
|
* @Author: richen
|
|
228
|
-
* @Date: 2025-
|
|
229
|
-
* @LastEditTime: 2025-
|
|
230
|
+
* @Date: 2025-10-30 12:00:00
|
|
231
|
+
* @LastEditTime: 2025-10-30 12:00:00
|
|
230
232
|
* @License: BSD (3-Clause)
|
|
231
233
|
* @Copyright (c): <richenlin(at)gmail.com>
|
|
232
234
|
*/
|
|
233
235
|
/**
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
* @export
|
|
237
|
-
* @param {string} cron - Cron expression for task scheduling
|
|
238
|
-
* @param {string} [timezone='Asia/Beijing'] - Timezone for the schedule
|
|
239
|
-
*
|
|
240
|
-
* Cron expression format:
|
|
241
|
-
* * Seconds: 0-59
|
|
242
|
-
* * Minutes: 0-59
|
|
243
|
-
* * Hours: 0-23
|
|
244
|
-
* * Day of Month: 1-31
|
|
245
|
-
* * Months: 1-12 (Jan-Dec)
|
|
246
|
-
* * Day of Week: 1-7 (Sun-Sat)
|
|
247
|
-
*
|
|
248
|
-
* @returns {MethodDecorator}
|
|
249
|
-
* @throws {Error} When cron expression is invalid or decorator is used on wrong class type
|
|
236
|
+
* Redis client wrapper that implements IRedisClient interface
|
|
237
|
+
* Wraps ioredis client to provide unified interface
|
|
250
238
|
*/
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
239
|
+
class RedisClientAdapter {
|
|
240
|
+
client;
|
|
241
|
+
constructor(client) {
|
|
242
|
+
this.client = client;
|
|
255
243
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
validateCronExpression(cron);
|
|
244
|
+
get status() {
|
|
245
|
+
return this.client.status;
|
|
259
246
|
}
|
|
260
|
-
|
|
261
|
-
|
|
247
|
+
async call(command, ...args) {
|
|
248
|
+
return this.client.call(command, ...args);
|
|
262
249
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
250
|
+
async set(key, value, mode, duration) {
|
|
251
|
+
if (mode && duration) {
|
|
252
|
+
// Use type assertion for mode parameter due to ioredis strict typing
|
|
253
|
+
return this.client.set(key, value, mode, duration);
|
|
254
|
+
}
|
|
255
|
+
return this.client.set(key, value);
|
|
266
256
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
257
|
+
async get(key) {
|
|
258
|
+
return this.client.get(key);
|
|
259
|
+
}
|
|
260
|
+
async del(...keys) {
|
|
261
|
+
return this.client.del(...keys);
|
|
262
|
+
}
|
|
263
|
+
async exists(key) {
|
|
264
|
+
return this.client.exists(key);
|
|
265
|
+
}
|
|
266
|
+
async eval(script, numKeys, ...args) {
|
|
267
|
+
return this.client.eval(script, numKeys, ...args);
|
|
268
|
+
}
|
|
269
|
+
async quit() {
|
|
270
|
+
return this.client.quit();
|
|
271
|
+
}
|
|
272
|
+
disconnect() {
|
|
273
|
+
this.client.disconnect();
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Get underlying Redis/Cluster instance
|
|
277
|
+
* Used for RedLock initialization
|
|
278
|
+
*/
|
|
279
|
+
getClient() {
|
|
280
|
+
return this.client;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Redis client factory
|
|
285
|
+
* Creates appropriate Redis client based on configuration
|
|
286
|
+
*/
|
|
287
|
+
class RedisFactory {
|
|
288
|
+
/**
|
|
289
|
+
* Create Redis client based on configuration mode
|
|
290
|
+
* @param config - Redis configuration
|
|
291
|
+
* @returns Redis client adapter
|
|
292
|
+
*/
|
|
293
|
+
static createClient(config) {
|
|
294
|
+
const mode = config.mode || exports.RedisMode.STANDALONE;
|
|
295
|
+
koatty_logger.DefaultLogger.Debug(`Creating Redis client in ${mode} mode`);
|
|
296
|
+
switch (mode) {
|
|
297
|
+
case exports.RedisMode.STANDALONE:
|
|
298
|
+
return this.createStandaloneClient(config);
|
|
299
|
+
case exports.RedisMode.SENTINEL:
|
|
300
|
+
return this.createSentinelClient(config);
|
|
301
|
+
case exports.RedisMode.CLUSTER:
|
|
302
|
+
return this.createClusterClient(config);
|
|
303
|
+
default:
|
|
304
|
+
throw new Error(`不支持的 Redis 模式: ${mode} (Unsupported Redis mode: ${mode})`);
|
|
273
305
|
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Create standalone Redis client
|
|
309
|
+
* @param config - Standalone configuration
|
|
310
|
+
*/
|
|
311
|
+
static createStandaloneClient(config) {
|
|
312
|
+
koatty_logger.DefaultLogger.Debug(`Creating standalone Redis client: ${config.host}:${config.port}`);
|
|
313
|
+
const options = {
|
|
314
|
+
host: config.host,
|
|
315
|
+
port: config.port,
|
|
316
|
+
password: config.password || undefined,
|
|
317
|
+
db: config.db || 0,
|
|
318
|
+
keyPrefix: config.keyPrefix || '',
|
|
319
|
+
connectTimeout: config.connectTimeout || 10000,
|
|
320
|
+
commandTimeout: config.commandTimeout || 5000,
|
|
321
|
+
maxRetriesPerRequest: config.maxRetriesPerRequest || 3,
|
|
322
|
+
retryStrategy: (times) => {
|
|
323
|
+
const delay = Math.min(times * 50, 2000);
|
|
324
|
+
koatty_logger.DefaultLogger.Debug(`Redis reconnecting, attempt ${times}, delay ${delay}ms`);
|
|
325
|
+
return delay;
|
|
326
|
+
},
|
|
327
|
+
reconnectOnError: (err) => {
|
|
328
|
+
koatty_logger.DefaultLogger.Warn('Redis connection error, attempting reconnect:', err.message);
|
|
329
|
+
return true;
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
const client = new Redis(options);
|
|
333
|
+
client.on('connect', () => {
|
|
334
|
+
koatty_logger.DefaultLogger.Info('Redis standalone client connected successfully');
|
|
335
|
+
});
|
|
336
|
+
client.on('error', (err) => {
|
|
337
|
+
koatty_logger.DefaultLogger.Error('Redis standalone client error:', err);
|
|
338
|
+
});
|
|
339
|
+
return new RedisClientAdapter(client);
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Create sentinel Redis client
|
|
343
|
+
* @param config - Sentinel configuration
|
|
344
|
+
*/
|
|
345
|
+
static createSentinelClient(config) {
|
|
346
|
+
koatty_logger.DefaultLogger.Debug(`Creating sentinel Redis client for master: ${config.name}`);
|
|
347
|
+
const options = {
|
|
348
|
+
sentinels: config.sentinels,
|
|
349
|
+
name: config.name,
|
|
350
|
+
password: config.password || undefined,
|
|
351
|
+
sentinelPassword: config.sentinelPassword || undefined,
|
|
352
|
+
db: config.db || 0,
|
|
353
|
+
keyPrefix: config.keyPrefix || '',
|
|
354
|
+
connectTimeout: config.connectTimeout || 10000,
|
|
355
|
+
commandTimeout: config.commandTimeout || 5000,
|
|
356
|
+
maxRetriesPerRequest: config.maxRetriesPerRequest || 3,
|
|
357
|
+
retryStrategy: (times) => {
|
|
358
|
+
const delay = Math.min(times * 50, 2000);
|
|
359
|
+
koatty_logger.DefaultLogger.Debug(`Sentinel Redis reconnecting, attempt ${times}, delay ${delay}ms`);
|
|
360
|
+
return delay;
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
const client = new Redis(options);
|
|
364
|
+
client.on('connect', () => {
|
|
365
|
+
koatty_logger.DefaultLogger.Info(`Redis sentinel client connected to master: ${config.name}`);
|
|
366
|
+
});
|
|
367
|
+
client.on('error', (err) => {
|
|
368
|
+
koatty_logger.DefaultLogger.Error('Redis sentinel client error:', err);
|
|
369
|
+
});
|
|
370
|
+
return new RedisClientAdapter(client);
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Create cluster Redis client
|
|
374
|
+
* @param config - Cluster configuration
|
|
375
|
+
*/
|
|
376
|
+
static createClusterClient(config) {
|
|
377
|
+
koatty_logger.DefaultLogger.Debug(`Creating cluster Redis client with ${config.nodes.length} nodes`);
|
|
378
|
+
const clusterOptions = {
|
|
379
|
+
redisOptions: {
|
|
380
|
+
password: config.redisOptions?.password || config.password || undefined,
|
|
381
|
+
db: config.redisOptions?.db || config.db || 0,
|
|
382
|
+
keyPrefix: config.keyPrefix || '',
|
|
383
|
+
connectTimeout: config.connectTimeout || 10000,
|
|
384
|
+
commandTimeout: config.commandTimeout || 5000,
|
|
385
|
+
maxRetriesPerRequest: config.maxRetriesPerRequest || 3
|
|
386
|
+
},
|
|
387
|
+
clusterRetryStrategy: (times) => {
|
|
388
|
+
const delay = Math.min(times * 50, 2000);
|
|
389
|
+
koatty_logger.DefaultLogger.Debug(`Cluster Redis reconnecting, attempt ${times}, delay ${delay}ms`);
|
|
390
|
+
return delay;
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
const cluster = new Redis.Cluster(config.nodes, clusterOptions);
|
|
394
|
+
cluster.on('connect', () => {
|
|
395
|
+
koatty_logger.DefaultLogger.Info('Redis cluster client connected successfully');
|
|
396
|
+
});
|
|
397
|
+
cluster.on('error', (err) => {
|
|
398
|
+
koatty_logger.DefaultLogger.Error('Redis cluster client error:', err);
|
|
399
|
+
});
|
|
400
|
+
cluster.on('node error', (err, address) => {
|
|
401
|
+
koatty_logger.DefaultLogger.Error(`Redis cluster node error at ${address}:`, err);
|
|
402
|
+
});
|
|
403
|
+
return new RedisClientAdapter(cluster);
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Validate Redis configuration
|
|
407
|
+
* @param config - Redis configuration to validate
|
|
408
|
+
*/
|
|
409
|
+
static validateConfig(config) {
|
|
410
|
+
if (!config) {
|
|
411
|
+
throw new Error('Redis 配置不能为空 (Redis configuration cannot be empty)');
|
|
278
412
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
413
|
+
const mode = config.mode || exports.RedisMode.STANDALONE;
|
|
414
|
+
switch (mode) {
|
|
415
|
+
case exports.RedisMode.STANDALONE:
|
|
416
|
+
this.validateStandaloneConfig(config);
|
|
417
|
+
break;
|
|
418
|
+
case exports.RedisMode.SENTINEL:
|
|
419
|
+
this.validateSentinelConfig(config);
|
|
420
|
+
break;
|
|
421
|
+
case exports.RedisMode.CLUSTER:
|
|
422
|
+
this.validateClusterConfig(config);
|
|
423
|
+
break;
|
|
424
|
+
default:
|
|
425
|
+
throw new Error(`不支持的 Redis 模式: ${mode} (Unsupported Redis mode: ${mode})`);
|
|
282
426
|
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
}
|
|
291
|
-
}
|
|
427
|
+
}
|
|
428
|
+
static validateStandaloneConfig(config) {
|
|
429
|
+
if (!config.host) {
|
|
430
|
+
throw new Error('单机模式需要 host 配置 (Standalone mode requires host configuration)');
|
|
431
|
+
}
|
|
432
|
+
if (!config.port) {
|
|
433
|
+
throw new Error('单机模式需要 port 配置 (Standalone mode requires port configuration)');
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
static validateSentinelConfig(config) {
|
|
437
|
+
if (!config.sentinels || config.sentinels.length === 0) {
|
|
438
|
+
throw new Error('哨兵模式需要至少一个哨兵节点配置 (Sentinel mode requires at least one sentinel node)');
|
|
439
|
+
}
|
|
440
|
+
if (!config.name) {
|
|
441
|
+
throw new Error('哨兵模式需要 master name 配置 (Sentinel mode requires master name)');
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
static validateClusterConfig(config) {
|
|
445
|
+
if (!config.nodes || config.nodes.length === 0) {
|
|
446
|
+
throw new Error('集群模式需要至少一个节点配置 (Cluster mode requires at least one node)');
|
|
447
|
+
}
|
|
448
|
+
}
|
|
292
449
|
}
|
|
293
450
|
|
|
294
451
|
/*
|
|
@@ -308,12 +465,8 @@ const defaultRedLockConfig = {
|
|
|
308
465
|
clockDriftFactor: 0.01,
|
|
309
466
|
maxRetries: 3,
|
|
310
467
|
retryDelayMs: 200,
|
|
311
|
-
driftFactor: 0.01,
|
|
312
|
-
retryCount: 3,
|
|
313
|
-
retryDelay: 200,
|
|
314
|
-
retryJitter: 200,
|
|
315
|
-
automaticExtensionThreshold: 500,
|
|
316
468
|
redisConfig: {
|
|
469
|
+
mode: exports.RedisMode.STANDALONE,
|
|
317
470
|
host: '127.0.0.1',
|
|
318
471
|
port: 6379,
|
|
319
472
|
password: '',
|
|
@@ -321,16 +474,27 @@ const defaultRedLockConfig = {
|
|
|
321
474
|
keyPrefix: 'redlock:'
|
|
322
475
|
}
|
|
323
476
|
};
|
|
477
|
+
/**
|
|
478
|
+
* Default Redlock Settings for @sesamecare-oss/redlock
|
|
479
|
+
*/
|
|
480
|
+
const defaultRedlockSettings = {
|
|
481
|
+
driftFactor: 0.01,
|
|
482
|
+
retryCount: 3,
|
|
483
|
+
retryDelay: 200,
|
|
484
|
+
retryJitter: 200,
|
|
485
|
+
automaticExtensionThreshold: 500
|
|
486
|
+
};
|
|
324
487
|
/**
|
|
325
488
|
* RedLock distributed lock manager
|
|
326
489
|
* Integrated with koatty IOC container
|
|
327
490
|
* Implements singleton pattern for safe instance management
|
|
491
|
+
* Implements IDistributedLock interface for abstraction
|
|
328
492
|
*/
|
|
329
493
|
class RedLocker {
|
|
330
494
|
static instance = null;
|
|
331
495
|
static instanceLock = Symbol('RedLocker.instanceLock');
|
|
332
496
|
redlock = null;
|
|
333
|
-
|
|
497
|
+
redisClient = null;
|
|
334
498
|
config;
|
|
335
499
|
isInitialized = false;
|
|
336
500
|
initializationPromise = null;
|
|
@@ -424,8 +588,12 @@ class RedLocker {
|
|
|
424
588
|
await this.initializationPromise;
|
|
425
589
|
}
|
|
426
590
|
catch (error) {
|
|
427
|
-
//
|
|
591
|
+
// 初始化失败时完整清理状态,允许重试
|
|
428
592
|
this.initializationPromise = null;
|
|
593
|
+
this.isInitialized = false;
|
|
594
|
+
this.redlock = null;
|
|
595
|
+
// 注意:不清理 redis 连接,因为它可能来自 IOC 容器
|
|
596
|
+
koatty_logger.DefaultLogger.Warn('RedLocker initialization failed, state has been reset for retry');
|
|
429
597
|
throw error;
|
|
430
598
|
}
|
|
431
599
|
}
|
|
@@ -435,34 +603,54 @@ class RedLocker {
|
|
|
435
603
|
*/
|
|
436
604
|
async performInitialization() {
|
|
437
605
|
try {
|
|
606
|
+
// Validate Redis configuration
|
|
607
|
+
if (this.config.redisConfig) {
|
|
608
|
+
RedisFactory.validateConfig(this.config.redisConfig);
|
|
609
|
+
}
|
|
438
610
|
// Try to get Redis instance from IOC container first
|
|
439
611
|
try {
|
|
440
|
-
|
|
441
|
-
|
|
612
|
+
const existingRedis = koatty_container.IOCContainer.get('Redis', 'COMPONENT');
|
|
613
|
+
// If Redis instance exists in container, wrap it
|
|
614
|
+
if (existingRedis) {
|
|
615
|
+
// Check if it's already a RedisClientAdapter
|
|
616
|
+
if (existingRedis instanceof RedisClientAdapter) {
|
|
617
|
+
this.redisClient = existingRedis;
|
|
618
|
+
}
|
|
619
|
+
else {
|
|
620
|
+
// Wrap raw Redis/Cluster instance (type assertion needed)
|
|
621
|
+
this.redisClient = new RedisClientAdapter(existingRedis);
|
|
622
|
+
}
|
|
623
|
+
koatty_logger.DefaultLogger.Debug('Using Redis instance from IOC container');
|
|
624
|
+
}
|
|
442
625
|
}
|
|
443
626
|
catch {
|
|
444
|
-
//
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
db: this.config.redisConfig.db || 0,
|
|
450
|
-
keyPrefix: this.config.redisConfig.keyPrefix,
|
|
451
|
-
maxRetriesPerRequest: 3
|
|
452
|
-
});
|
|
627
|
+
// IOC container doesn't have Redis, create new connection
|
|
628
|
+
}
|
|
629
|
+
// Create new Redis connection if not available in container
|
|
630
|
+
if (!this.redisClient && this.config.redisConfig) {
|
|
631
|
+
this.redisClient = RedisFactory.createClient(this.config.redisConfig);
|
|
453
632
|
koatty_logger.DefaultLogger.Debug('Created new Redis connection for RedLocker');
|
|
454
633
|
}
|
|
455
|
-
if (!this.
|
|
456
|
-
throw new Error('Failed to initialize Redis connection');
|
|
634
|
+
if (!this.redisClient) {
|
|
635
|
+
throw new Error('Failed to initialize Redis connection: no configuration provided');
|
|
457
636
|
}
|
|
637
|
+
// Get underlying client for Redlock
|
|
638
|
+
const underlyingClient = this.redisClient.getClient();
|
|
639
|
+
// Merge default settings with user configuration
|
|
640
|
+
// Extract Settings properties from config (which extends Partial<Settings>)
|
|
641
|
+
const userSettings = this.config;
|
|
642
|
+
const redlockSettings = {
|
|
643
|
+
...defaultRedlockSettings,
|
|
644
|
+
...(userSettings.driftFactor !== undefined && { driftFactor: userSettings.driftFactor }),
|
|
645
|
+
...(userSettings.retryCount !== undefined && { retryCount: userSettings.retryCount }),
|
|
646
|
+
...(userSettings.retryDelay !== undefined && { retryDelay: userSettings.retryDelay }),
|
|
647
|
+
...(userSettings.retryJitter !== undefined && { retryJitter: userSettings.retryJitter }),
|
|
648
|
+
...(userSettings.automaticExtensionThreshold !== undefined && {
|
|
649
|
+
automaticExtensionThreshold: userSettings.automaticExtensionThreshold
|
|
650
|
+
})
|
|
651
|
+
};
|
|
458
652
|
// Initialize Redlock with the Redis instance
|
|
459
|
-
this.redlock = new redlock.Redlock([
|
|
460
|
-
driftFactor: this.config.driftFactor,
|
|
461
|
-
retryCount: this.config.retryCount,
|
|
462
|
-
retryDelay: this.config.retryDelay,
|
|
463
|
-
retryJitter: this.config.retryJitter,
|
|
464
|
-
automaticExtensionThreshold: this.config.automaticExtensionThreshold
|
|
465
|
-
});
|
|
653
|
+
this.redlock = new redlock.Redlock([underlyingClient], redlockSettings);
|
|
466
654
|
// Set up error handlers
|
|
467
655
|
this.redlock.on('clientError', (err) => {
|
|
468
656
|
koatty_logger.DefaultLogger.Error('Redis client error in RedLock:', err);
|
|
@@ -563,7 +751,7 @@ class RedLocker {
|
|
|
563
751
|
* @returns true if initialized, false otherwise
|
|
564
752
|
*/
|
|
565
753
|
isReady() {
|
|
566
|
-
return this.isInitialized && !!this.redlock && !!this.
|
|
754
|
+
return this.isInitialized && !!this.redlock && !!this.redisClient;
|
|
567
755
|
}
|
|
568
756
|
/**
|
|
569
757
|
* Get current configuration
|
|
@@ -591,11 +779,11 @@ class RedLocker {
|
|
|
591
779
|
*/
|
|
592
780
|
async close() {
|
|
593
781
|
try {
|
|
594
|
-
if (this.
|
|
595
|
-
await this.
|
|
782
|
+
if (this.redisClient && this.redisClient.status === 'ready') {
|
|
783
|
+
await this.redisClient.quit();
|
|
596
784
|
koatty_logger.DefaultLogger.Debug('Redis connection closed');
|
|
597
785
|
}
|
|
598
|
-
this.
|
|
786
|
+
this.redisClient = null;
|
|
599
787
|
this.redlock = null;
|
|
600
788
|
this.isInitialized = false;
|
|
601
789
|
}
|
|
@@ -629,13 +817,14 @@ class RedLocker {
|
|
|
629
817
|
async healthCheck() {
|
|
630
818
|
try {
|
|
631
819
|
await this.initialize();
|
|
632
|
-
const redisStatus = this.
|
|
820
|
+
const redisStatus = this.redisClient?.status || 'unknown';
|
|
633
821
|
const isReady = this.isReady();
|
|
634
822
|
return {
|
|
635
823
|
status: isReady ? 'healthy' : 'unhealthy',
|
|
636
824
|
details: {
|
|
637
825
|
initialized: this.isInitialized,
|
|
638
826
|
redisStatus,
|
|
827
|
+
redisMode: this.config.redisConfig?.mode || 'unknown',
|
|
639
828
|
redlockReady: !!this.redlock,
|
|
640
829
|
containerRegistered: this.getContainerInfo().registered
|
|
641
830
|
}
|
|
@@ -662,18 +851,22 @@ class RedLocker {
|
|
|
662
851
|
* @License: BSD (3-Clause)
|
|
663
852
|
* @Copyright (c): <richenlin(at)gmail.com>
|
|
664
853
|
*/
|
|
665
|
-
/**
|
|
666
|
-
* 返回一个 Promise,在指定时间后 reject
|
|
667
|
-
* @param ms
|
|
668
|
-
* @returns
|
|
669
|
-
*/
|
|
670
854
|
function timeoutPromise(ms) {
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
855
|
+
let timeoutId = null;
|
|
856
|
+
const promise = new Promise((resolve, reject) => {
|
|
857
|
+
timeoutId = setTimeout(() => {
|
|
858
|
+
timeoutId = null;
|
|
674
859
|
reject(new Error('TIME_OUT_ERROR'));
|
|
675
860
|
}, ms);
|
|
676
861
|
});
|
|
862
|
+
// 添加取消方法,防止内存泄漏
|
|
863
|
+
promise.cancel = () => {
|
|
864
|
+
if (timeoutId !== null) {
|
|
865
|
+
clearTimeout(timeoutId);
|
|
866
|
+
timeoutId = null;
|
|
867
|
+
}
|
|
868
|
+
};
|
|
869
|
+
return promise;
|
|
677
870
|
}
|
|
678
871
|
|
|
679
872
|
/*
|
|
@@ -688,6 +881,8 @@ function timeoutPromise(ms) {
|
|
|
688
881
|
/**
|
|
689
882
|
* Initiation schedule locker client.
|
|
690
883
|
*
|
|
884
|
+
* @param {RedLockOptions} options - RedLock 配置选项
|
|
885
|
+
* @param {Koatty} app - Koatty 应用实例
|
|
691
886
|
* @returns {Promise<void>}
|
|
692
887
|
*/
|
|
693
888
|
async function initRedLock(options, app) {
|
|
@@ -695,11 +890,12 @@ async function initRedLock(options, app) {
|
|
|
695
890
|
koatty_logger.DefaultLogger.Warn(`RedLock initialization skipped: Koatty app not available or not initialized`);
|
|
696
891
|
return;
|
|
697
892
|
}
|
|
698
|
-
app.once("
|
|
893
|
+
app.once("appReady", async function () {
|
|
699
894
|
try {
|
|
700
895
|
if (koatty_lib.Helper.isEmpty(options)) {
|
|
701
896
|
throw Error(`Missing RedLock configuration. Please write a configuration item with the key name 'RedLock' in the db.ts file.`);
|
|
702
897
|
}
|
|
898
|
+
// 获取RedLocker实例,在首次使用时自动初始化
|
|
703
899
|
const redLocker = RedLocker.getInstance(options);
|
|
704
900
|
await redLocker.initialize();
|
|
705
901
|
koatty_logger.DefaultLogger.Info('RedLock initialized successfully');
|
|
@@ -710,72 +906,12 @@ async function initRedLock(options, app) {
|
|
|
710
906
|
}
|
|
711
907
|
});
|
|
712
908
|
}
|
|
713
|
-
/**
|
|
714
|
-
* 批量注入RedLock锁 - 从IOC容器读取类元数据并应用所有RedLock装饰器
|
|
715
|
-
*
|
|
716
|
-
* @param {RedLockOptions} options - RedLock 配置选项
|
|
717
|
-
* @param {Koatty} app - Koatty 应用实例
|
|
718
|
-
*/
|
|
719
|
-
async function injectRedLock(_options, _app) {
|
|
720
|
-
try {
|
|
721
|
-
koatty_logger.DefaultLogger.Debug('Starting batch RedLock injection...');
|
|
722
|
-
const componentList = koatty_container.IOCContainer.listClass("COMPONENT");
|
|
723
|
-
for (const component of componentList) {
|
|
724
|
-
const classMetadata = koatty_container.IOCContainer.getClassMetadata(COMPONENT_REDLOCK, DecoratorType.REDLOCK, component.target);
|
|
725
|
-
if (!classMetadata) {
|
|
726
|
-
continue;
|
|
727
|
-
}
|
|
728
|
-
let redlockCount = 0;
|
|
729
|
-
for (const [className, metadata] of classMetadata) {
|
|
730
|
-
try {
|
|
731
|
-
const instance = koatty_container.IOCContainer.get(className);
|
|
732
|
-
if (!instance) {
|
|
733
|
-
continue;
|
|
734
|
-
}
|
|
735
|
-
// 查找所有RedLock方法的元数据
|
|
736
|
-
for (const [key, value] of Object.entries(metadata)) {
|
|
737
|
-
if (key.startsWith('REDLOCK')) {
|
|
738
|
-
const redlockData = value;
|
|
739
|
-
const targetMethod = instance[redlockData.method];
|
|
740
|
-
if (!koatty_lib.Helper.isFunction(targetMethod)) {
|
|
741
|
-
koatty_logger.DefaultLogger.Warn(`RedLock injection skipped: method ${redlockData.method} is not a function in ${className}`);
|
|
742
|
-
continue;
|
|
743
|
-
}
|
|
744
|
-
// 生成有效的RedLock选项:方法级别 > 全局配置 > 默认值
|
|
745
|
-
const effectiveOptions = getEffectiveRedLockOptions(redlockData.options);
|
|
746
|
-
// 应用RedLock增强描述符
|
|
747
|
-
const originalDescriptor = {
|
|
748
|
-
value: targetMethod,
|
|
749
|
-
writable: true,
|
|
750
|
-
enumerable: false,
|
|
751
|
-
configurable: true
|
|
752
|
-
};
|
|
753
|
-
const enhancedDescriptor = redLockerDescriptor(originalDescriptor, redlockData.name, // 使用装饰器中确定的锁名称
|
|
754
|
-
redlockData.method, effectiveOptions);
|
|
755
|
-
// 替换原方法
|
|
756
|
-
Object.defineProperty(instance, redlockData.method, enhancedDescriptor);
|
|
757
|
-
redlockCount++;
|
|
758
|
-
koatty_logger.DefaultLogger.Debug(`RedLock applied to ${className}.${redlockData.method} with lock name: ${redlockData.name}`);
|
|
759
|
-
}
|
|
760
|
-
}
|
|
761
|
-
}
|
|
762
|
-
catch (error) {
|
|
763
|
-
koatty_logger.DefaultLogger.Error(`Failed to process class ${className}:`, error);
|
|
764
|
-
}
|
|
765
|
-
}
|
|
766
|
-
koatty_logger.DefaultLogger.Info(`Batch RedLock injection completed. ${redlockCount} locks applied.`);
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
catch (error) {
|
|
770
|
-
koatty_logger.DefaultLogger.Error('Failed to inject RedLocks:', error);
|
|
771
|
-
}
|
|
772
|
-
}
|
|
773
909
|
/**
|
|
774
910
|
* Create redLocker Descriptor with improved error handling and type safety
|
|
775
911
|
* @param descriptor - Property descriptor
|
|
776
912
|
* @param name - Lock name
|
|
777
913
|
* @param method - Method name
|
|
778
|
-
* @param
|
|
914
|
+
* @param methodOptions - Method-level RedLock options
|
|
779
915
|
* @returns Enhanced property descriptor
|
|
780
916
|
*/
|
|
781
917
|
function redLockerDescriptor(descriptor, name, method, methodOptions) {
|
|
@@ -794,13 +930,6 @@ function redLockerDescriptor(descriptor, name, method, methodOptions) {
|
|
|
794
930
|
if (typeof value !== 'function') {
|
|
795
931
|
throw new Error('Descriptor value must be a function');
|
|
796
932
|
}
|
|
797
|
-
// 设置默认选项,合并方法级别的选项
|
|
798
|
-
const lockOptions = {
|
|
799
|
-
lockTimeOut: methodOptions?.lockTimeOut,
|
|
800
|
-
clockDriftFactor: methodOptions?.clockDriftFactor,
|
|
801
|
-
maxRetries: methodOptions?.maxRetries,
|
|
802
|
-
retryDelayMs: methodOptions?.retryDelayMs
|
|
803
|
-
};
|
|
804
933
|
/**
|
|
805
934
|
* Enhanced function wrapper with proper lock renewal and safety
|
|
806
935
|
*/
|
|
@@ -811,15 +940,21 @@ function redLockerDescriptor(descriptor, name, method, methodOptions) {
|
|
|
811
940
|
let extensionCount = 0;
|
|
812
941
|
try {
|
|
813
942
|
while (remainingTime > 0 && extensionCount < maxExtensions) {
|
|
943
|
+
// 创建可取消的超时 Promise
|
|
944
|
+
const timeoutHandler = timeoutPromise(remainingTime);
|
|
814
945
|
try {
|
|
815
946
|
// 执行业务方法,与超时竞争
|
|
816
947
|
const result = await Promise.race([
|
|
817
948
|
value.apply(self, props),
|
|
818
|
-
|
|
949
|
+
timeoutHandler
|
|
819
950
|
]);
|
|
820
|
-
|
|
951
|
+
// 成功执行,取消超时定时器防止内存泄漏
|
|
952
|
+
timeoutHandler.cancel();
|
|
953
|
+
return result;
|
|
821
954
|
}
|
|
822
955
|
catch (error) {
|
|
956
|
+
// 无论什么错误,都要取消超时定时器
|
|
957
|
+
timeoutHandler.cancel();
|
|
823
958
|
// 处理超时错误,尝试续期锁
|
|
824
959
|
if (error instanceof Error && error.message === 'TIME_OUT_ERROR') {
|
|
825
960
|
extensionCount++;
|
|
@@ -864,6 +999,7 @@ function redLockerDescriptor(descriptor, name, method, methodOptions) {
|
|
|
864
999
|
async value(...props) {
|
|
865
1000
|
try {
|
|
866
1001
|
const redlock = RedLocker.getInstance();
|
|
1002
|
+
const lockOptions = getEffectiveRedLockOptions(methodOptions);
|
|
867
1003
|
// Acquire a lock.
|
|
868
1004
|
const lockTime = lockOptions.lockTimeOut || 10000;
|
|
869
1005
|
if (lockTime <= 200) {
|
|
@@ -881,6 +1017,169 @@ function redLockerDescriptor(descriptor, name, method, methodOptions) {
|
|
|
881
1017
|
},
|
|
882
1018
|
};
|
|
883
1019
|
}
|
|
1020
|
+
/**
|
|
1021
|
+
* Generate lock name for RedLock decorator
|
|
1022
|
+
*/
|
|
1023
|
+
function generateLockName(configName, methodName, target) {
|
|
1024
|
+
if (configName) {
|
|
1025
|
+
return configName;
|
|
1026
|
+
}
|
|
1027
|
+
try {
|
|
1028
|
+
const targetObj = target;
|
|
1029
|
+
const identifier = koatty_container.IOCContainer.getIdentifier(targetObj);
|
|
1030
|
+
if (identifier) {
|
|
1031
|
+
return `${identifier}_${methodName}`;
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
catch {
|
|
1035
|
+
// Fallback if IOC container is not available
|
|
1036
|
+
}
|
|
1037
|
+
const targetWithConstructor = target;
|
|
1038
|
+
const className = targetWithConstructor.constructor?.name || 'Unknown';
|
|
1039
|
+
return `${className}_${methodName}`;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/*
|
|
1043
|
+
* @Description:
|
|
1044
|
+
* @Usage:
|
|
1045
|
+
* @Author: richen
|
|
1046
|
+
* @Date: 2025-06-09 16:00:00
|
|
1047
|
+
* @LastEditTime: 2025-06-09 16:00:00
|
|
1048
|
+
* @License: BSD (3-Clause)
|
|
1049
|
+
* @Copyright (c): <richenlin(at)gmail.com>
|
|
1050
|
+
*/
|
|
1051
|
+
/**
|
|
1052
|
+
* Redis-based distributed lock decorator
|
|
1053
|
+
*
|
|
1054
|
+
* @export
|
|
1055
|
+
* @param {string} [name] - The locker name. If name is duplicated, lock sharing contention will result.
|
|
1056
|
+
* If not provided, a unique name will be auto-generated using method name + random suffix.
|
|
1057
|
+
* IMPORTANT: Auto-generated names are unique per method deployment and not predictable.
|
|
1058
|
+
* @param {RedLockMethodOptions} [options] - Lock configuration options for this method
|
|
1059
|
+
*
|
|
1060
|
+
* @returns {MethodDecorator}
|
|
1061
|
+
* @throws {Error} When decorator is used on wrong class type or invalid configuration
|
|
1062
|
+
*
|
|
1063
|
+
* @example
|
|
1064
|
+
* ```typescript
|
|
1065
|
+
* class UserService {
|
|
1066
|
+
* @RedLock('user_update_lock', { lockTimeOut: 5000, maxRetries: 2 })
|
|
1067
|
+
* async updateUser(id: string, data: any) {
|
|
1068
|
+
* // This method will be protected by a distributed lock with predictable name
|
|
1069
|
+
* }
|
|
1070
|
+
*
|
|
1071
|
+
* @RedLock() // Auto-generated unique name like "deleteUser_abc123_xyz789"
|
|
1072
|
+
* async deleteUser(id: string) {
|
|
1073
|
+
* // This method will be protected by a distributed lock with auto-generated unique name
|
|
1074
|
+
* }
|
|
1075
|
+
* }
|
|
1076
|
+
* ```
|
|
1077
|
+
*/
|
|
1078
|
+
function RedLock(lockName, options) {
|
|
1079
|
+
return (target, propertyKey, descriptor) => {
|
|
1080
|
+
const methodName = propertyKey.toString();
|
|
1081
|
+
// 验证装饰器使用的类型(从原型对象获取类构造函数)
|
|
1082
|
+
const targetClass = target.constructor;
|
|
1083
|
+
const componentType = koatty_container.IOCContainer.getType(targetClass);
|
|
1084
|
+
if (componentType !== "SERVICE" && componentType !== "COMPONENT") {
|
|
1085
|
+
throw Error("@RedLock decorator can only be used on SERVICE or COMPONENT classes.");
|
|
1086
|
+
}
|
|
1087
|
+
// 验证方法名
|
|
1088
|
+
if (!methodName || typeof methodName !== 'string') {
|
|
1089
|
+
throw Error("Method name is required for @RedLock decorator");
|
|
1090
|
+
}
|
|
1091
|
+
// 验证方法描述符
|
|
1092
|
+
if (!descriptor || typeof descriptor.value !== 'function') {
|
|
1093
|
+
throw Error("@RedLock decorator can only be applied to methods");
|
|
1094
|
+
}
|
|
1095
|
+
// 生成锁名称:用户指定的 > 基于类名和方法名生成
|
|
1096
|
+
const finalLockName = lockName || generateLockName(lockName, methodName, target);
|
|
1097
|
+
// 验证选项
|
|
1098
|
+
if (options) {
|
|
1099
|
+
validateRedLockMethodOptions(options);
|
|
1100
|
+
}
|
|
1101
|
+
// 保存类到IOC容器
|
|
1102
|
+
koatty_container.IOCContainer.saveClass("COMPONENT", targetClass, targetClass.name);
|
|
1103
|
+
try {
|
|
1104
|
+
// 直接在装饰器中包装方法,而不是延迟处理
|
|
1105
|
+
const enhancedDescriptor = redLockerDescriptor(descriptor, finalLockName, methodName, options);
|
|
1106
|
+
return enhancedDescriptor;
|
|
1107
|
+
}
|
|
1108
|
+
catch (error) {
|
|
1109
|
+
throw new Error(`Failed to apply RedLock to ${methodName}: ${error.message}`);
|
|
1110
|
+
}
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/*
|
|
1115
|
+
* @Description:
|
|
1116
|
+
* @Usage:
|
|
1117
|
+
* @Author: richen
|
|
1118
|
+
* @Date: 2025-06-09 16:00:00
|
|
1119
|
+
* @LastEditTime: 2025-06-09 16:00:00
|
|
1120
|
+
* @License: BSD (3-Clause)
|
|
1121
|
+
* @Copyright (c): <richenlin(at)gmail.com>
|
|
1122
|
+
*/
|
|
1123
|
+
/**
|
|
1124
|
+
* Schedule task decorator with optimized preprocessing
|
|
1125
|
+
*
|
|
1126
|
+
* @export
|
|
1127
|
+
* @param {string} cron - Cron expression for task scheduling
|
|
1128
|
+
* @param {string} [timezone='Asia/Beijing'] - Timezone for the schedule
|
|
1129
|
+
*
|
|
1130
|
+
* Cron expression format:
|
|
1131
|
+
* * Seconds: 0-59
|
|
1132
|
+
* * Minutes: 0-59
|
|
1133
|
+
* * Hours: 0-23
|
|
1134
|
+
* * Day of Month: 1-31
|
|
1135
|
+
* * Months: 1-12 (Jan-Dec)
|
|
1136
|
+
* * Day of Week: 1-7 (Sun-Sat)
|
|
1137
|
+
*
|
|
1138
|
+
* @returns {MethodDecorator}
|
|
1139
|
+
* @throws {Error} When cron expression is invalid or decorator is used on wrong class type
|
|
1140
|
+
*/
|
|
1141
|
+
function Scheduled(cron, timezone = 'Asia/Beijing') {
|
|
1142
|
+
// 参数验证
|
|
1143
|
+
if (koatty_lib.Helper.isEmpty(cron)) {
|
|
1144
|
+
throw Error("Cron expression is required and cannot be empty");
|
|
1145
|
+
}
|
|
1146
|
+
// 验证cron表达式格式
|
|
1147
|
+
try {
|
|
1148
|
+
validateCronExpression(cron);
|
|
1149
|
+
}
|
|
1150
|
+
catch (error) {
|
|
1151
|
+
throw Error(`Invalid cron expression: ${error.message}`);
|
|
1152
|
+
}
|
|
1153
|
+
// 验证时区
|
|
1154
|
+
if (timezone && typeof timezone !== 'string') {
|
|
1155
|
+
throw Error("Timezone must be a string");
|
|
1156
|
+
}
|
|
1157
|
+
return (target, propertyKey, descriptor) => {
|
|
1158
|
+
// 验证装饰器使用的类型(从原型对象获取类构造函数)
|
|
1159
|
+
const targetClass = target.constructor;
|
|
1160
|
+
const componentType = koatty_container.IOCContainer.getType(targetClass);
|
|
1161
|
+
if (componentType !== "SERVICE" && componentType !== "COMPONENT") {
|
|
1162
|
+
throw Error("@Scheduled decorator can only be used on SERVICE or COMPONENT classes.");
|
|
1163
|
+
}
|
|
1164
|
+
// 验证方法名
|
|
1165
|
+
const methodName = propertyKey.toString();
|
|
1166
|
+
if (!methodName || typeof methodName !== 'string') {
|
|
1167
|
+
throw Error("Method name is required for @Scheduled decorator");
|
|
1168
|
+
}
|
|
1169
|
+
// 验证方法描述符
|
|
1170
|
+
if (!descriptor || typeof descriptor.value !== 'function') {
|
|
1171
|
+
throw Error("@Scheduled decorator can only be applied to methods");
|
|
1172
|
+
}
|
|
1173
|
+
// 保存类到IOC容器
|
|
1174
|
+
koatty_container.IOCContainer.saveClass("COMPONENT", targetClass, targetClass.name);
|
|
1175
|
+
// 保存调度元数据到 IOC 容器
|
|
1176
|
+
koatty_container.IOCContainer.attachClassMetadata(COMPONENT_SCHEDULED, DecoratorType.SCHEDULED, {
|
|
1177
|
+
method: methodName,
|
|
1178
|
+
cron,
|
|
1179
|
+
timezone // 保存确定的时区值
|
|
1180
|
+
}, target, methodName);
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
884
1183
|
|
|
885
1184
|
/**
|
|
886
1185
|
* @ author: richen
|
|
@@ -888,6 +1187,29 @@ function redLockerDescriptor(descriptor, name, method, methodOptions) {
|
|
|
888
1187
|
* @ license: MIT
|
|
889
1188
|
* @ version: 2020-07-06 10:29:20
|
|
890
1189
|
*/
|
|
1190
|
+
/**
|
|
1191
|
+
* 初始化调度任务系统
|
|
1192
|
+
* 在appReady时触发批量注入调度任务,确保所有初始化工作完成
|
|
1193
|
+
*
|
|
1194
|
+
* @param {Koatty} app - Koatty 应用实例
|
|
1195
|
+
* @param {any} options - 调度任务配置
|
|
1196
|
+
*/
|
|
1197
|
+
async function initSchedule(options, app) {
|
|
1198
|
+
if (!app || !koatty_lib.Helper.isFunction(app.once)) {
|
|
1199
|
+
koatty_logger.DefaultLogger.Warn(`Schedule initialization skipped: Koatty app not available or not initialized`);
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
app.once("appReady", async function () {
|
|
1203
|
+
try {
|
|
1204
|
+
await injectSchedule(options);
|
|
1205
|
+
koatty_logger.DefaultLogger.Info('Schedule system initialized successfully');
|
|
1206
|
+
}
|
|
1207
|
+
catch (error) {
|
|
1208
|
+
koatty_logger.DefaultLogger.Error('Failed to initialize Schedule system:', error);
|
|
1209
|
+
throw error;
|
|
1210
|
+
}
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
891
1213
|
/**
|
|
892
1214
|
* Inject schedule job with enhanced error handling and validation
|
|
893
1215
|
*
|
|
@@ -898,11 +1220,8 @@ function redLockerDescriptor(descriptor, name, method, methodOptions) {
|
|
|
898
1220
|
*/
|
|
899
1221
|
/**
|
|
900
1222
|
* 批量注入调度任务 - 从IOC容器读取类元数据并创建所有CronJob
|
|
901
|
-
*
|
|
902
|
-
* @param {RedLockOptions} options - RedLock 配置选项
|
|
903
|
-
* @param {Koatty} app - Koatty 应用实例
|
|
904
1223
|
*/
|
|
905
|
-
async function injectSchedule(
|
|
1224
|
+
async function injectSchedule(options) {
|
|
906
1225
|
try {
|
|
907
1226
|
koatty_logger.DefaultLogger.Debug('Starting batch schedule injection...');
|
|
908
1227
|
const componentList = koatty_container.IOCContainer.listClass("COMPONENT");
|
|
@@ -928,7 +1247,7 @@ async function injectSchedule(_options, _app) {
|
|
|
928
1247
|
continue;
|
|
929
1248
|
}
|
|
930
1249
|
const taskName = `${className}_${scheduleData.method}`;
|
|
931
|
-
const tz = getEffectiveTimezone(scheduleData.timezone);
|
|
1250
|
+
const tz = getEffectiveTimezone(options, scheduleData.timezone);
|
|
932
1251
|
new cron.CronJob(scheduleData.cron, () => {
|
|
933
1252
|
koatty_logger.DefaultLogger.Debug(`The schedule job ${taskName} started.`);
|
|
934
1253
|
Promise.resolve(targetMethod.call(instance))
|
|
@@ -979,6 +1298,7 @@ const defaultOptions = {
|
|
|
979
1298
|
maxRetries: 3,
|
|
980
1299
|
retryDelayMs: 200,
|
|
981
1300
|
redisConfig: {
|
|
1301
|
+
mode: exports.RedisMode.STANDALONE,
|
|
982
1302
|
host: "localhost",
|
|
983
1303
|
port: 6379,
|
|
984
1304
|
password: "",
|
|
@@ -992,14 +1312,16 @@ const defaultOptions = {
|
|
|
992
1312
|
*/
|
|
993
1313
|
async function KoattyScheduled(options, app) {
|
|
994
1314
|
options = { ...defaultOptions, ...options };
|
|
995
|
-
//
|
|
996
|
-
setGlobalScheduledOptions(options);
|
|
1315
|
+
// 初始化RedLock(appReady时触发,确保所有依赖就绪)
|
|
997
1316
|
await initRedLock(options, app);
|
|
998
|
-
|
|
999
|
-
await
|
|
1317
|
+
// 初始化调度任务系统(appReady时触发,确保所有组件都已初始化)
|
|
1318
|
+
await initSchedule(options, app);
|
|
1000
1319
|
}
|
|
1001
1320
|
|
|
1002
1321
|
exports.KoattyScheduled = KoattyScheduled;
|
|
1003
1322
|
exports.RedLock = RedLock;
|
|
1323
|
+
exports.RedLocker = RedLocker;
|
|
1324
|
+
exports.RedisClientAdapter = RedisClientAdapter;
|
|
1325
|
+
exports.RedisFactory = RedisFactory;
|
|
1004
1326
|
exports.Scheduled = Scheduled;
|
|
1005
1327
|
exports.SchedulerLock = SchedulerLock;
|