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