koatty_schedule 3.3.2 → 3.3.4
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/.turbo/turbo-build.log +75 -0
- package/CHANGELOG.md +36 -35
- package/README.md +304 -5
- package/dist/README.md +304 -5
- package/dist/index.d.ts +323 -14
- package/dist/index.js +460 -106
- package/dist/index.mjs +458 -107
- package/dist/package.json +14 -19
- package/package.json +25 -31
- package/rollup.config-1769572274826.cjs +67 -0
package/dist/index.mjs
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/*!
|
|
2
2
|
* @Author: richen
|
|
3
|
-
* @Date:
|
|
3
|
+
* @Date: 2026-01-28 13:35:25
|
|
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
9
|
import { Redlock } from '@sesamecare-oss/redlock';
|
|
10
|
-
import { Redis } from 'ioredis';
|
|
11
10
|
import { DefaultLogger } from 'koatty_logger';
|
|
11
|
+
import Redis, { Cluster } from 'ioredis';
|
|
12
12
|
import { Helper } from 'koatty_lib';
|
|
13
13
|
import { CronJob } from 'cron';
|
|
14
14
|
|
|
@@ -31,45 +31,110 @@ var DecoratorType;
|
|
|
31
31
|
DecoratorType["REDLOCK"] = "REDLOCK";
|
|
32
32
|
})(DecoratorType || (DecoratorType = {}));
|
|
33
33
|
/**
|
|
34
|
-
* 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
|
+
*
|
|
35
39
|
* @param cron - Cron expression to validate
|
|
36
40
|
* @throws {Error} When cron expression is invalid
|
|
37
41
|
*/
|
|
38
42
|
function validateCronExpression(cron) {
|
|
39
43
|
if (!cron || typeof cron !== 'string') {
|
|
40
|
-
throw new Error('Cron expression must be a non-empty string');
|
|
44
|
+
throw new Error('Cron 表达式必须是非空字符串 (Cron expression must be a non-empty string)');
|
|
41
45
|
}
|
|
42
46
|
const cronParts = cron.trim().split(/\s+/);
|
|
43
47
|
// Cron expressions should have 5 or 6 parts (with or without seconds)
|
|
44
48
|
if (cronParts.length < 5 || cronParts.length > 6) {
|
|
45
|
-
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})`);
|
|
50
|
+
}
|
|
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;
|
|
46
97
|
}
|
|
47
|
-
//
|
|
48
|
-
if (
|
|
49
|
-
const [
|
|
50
|
-
|
|
51
|
-
if (
|
|
52
|
-
throw new Error(
|
|
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})`);
|
|
53
104
|
}
|
|
54
|
-
if (
|
|
55
|
-
|
|
105
|
+
if (range !== '*') {
|
|
106
|
+
validateCronField(range, min, max, fieldName, fieldNameCN, allowedStrings);
|
|
56
107
|
}
|
|
57
|
-
|
|
58
|
-
|
|
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})`);
|
|
59
117
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
if (!isNaN(secondsValue) && (secondsValue < 0 || secondsValue > 59)) {
|
|
63
|
-
throw new Error('Seconds value must be between 0 and 59');
|
|
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})`);
|
|
64
120
|
}
|
|
121
|
+
if (startValue > endValue) {
|
|
122
|
+
throw new Error(`${fieldNameCN}字段的范围无效: ${start}-${end},起始值不能大于结束值 (Invalid range for ${fieldName}: ${start}-${end}, start cannot be greater than end)`);
|
|
123
|
+
}
|
|
124
|
+
return;
|
|
65
125
|
}
|
|
66
|
-
//
|
|
67
|
-
if (
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
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);
|
|
72
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})`);
|
|
73
138
|
}
|
|
74
139
|
}
|
|
75
140
|
/**
|
|
@@ -130,13 +195,257 @@ function getEffectiveTimezone(options, userTimezone) {
|
|
|
130
195
|
function getEffectiveRedLockOptions(methodOptions) {
|
|
131
196
|
const globalOptions = getGlobalScheduledOptions();
|
|
132
197
|
return {
|
|
133
|
-
lockTimeOut: methodOptions
|
|
134
|
-
clockDriftFactor: methodOptions
|
|
135
|
-
maxRetries: methodOptions
|
|
136
|
-
retryDelayMs: methodOptions
|
|
198
|
+
lockTimeOut: (methodOptions === null || methodOptions === void 0 ? void 0 : methodOptions.lockTimeOut) || globalOptions.lockTimeOut || 10000,
|
|
199
|
+
clockDriftFactor: (methodOptions === null || methodOptions === void 0 ? void 0 : methodOptions.clockDriftFactor) || globalOptions.clockDriftFactor || 0.01,
|
|
200
|
+
maxRetries: (methodOptions === null || methodOptions === void 0 ? void 0 : methodOptions.maxRetries) || globalOptions.maxRetries || 3,
|
|
201
|
+
retryDelayMs: (methodOptions === null || methodOptions === void 0 ? void 0 : methodOptions.retryDelayMs) || globalOptions.retryDelayMs || 200
|
|
137
202
|
};
|
|
138
203
|
}
|
|
139
204
|
|
|
205
|
+
/*
|
|
206
|
+
* @Description: Abstract interfaces for Redis client and distributed lock
|
|
207
|
+
* @Usage:
|
|
208
|
+
* @Author: richen
|
|
209
|
+
* @Date: 2025-10-30 12:00:00
|
|
210
|
+
* @LastEditTime: 2025-10-30 12:00:00
|
|
211
|
+
* @License: BSD (3-Clause)
|
|
212
|
+
* @Copyright (c): <richenlin(at)gmail.com>
|
|
213
|
+
*/
|
|
214
|
+
/**
|
|
215
|
+
* Redis connection mode
|
|
216
|
+
*/
|
|
217
|
+
var RedisMode;
|
|
218
|
+
(function (RedisMode) {
|
|
219
|
+
RedisMode["STANDALONE"] = "standalone";
|
|
220
|
+
RedisMode["SENTINEL"] = "sentinel";
|
|
221
|
+
RedisMode["CLUSTER"] = "cluster"; // 集群模式
|
|
222
|
+
})(RedisMode || (RedisMode = {}));
|
|
223
|
+
|
|
224
|
+
/*
|
|
225
|
+
* @Description: Redis client factory supporting multiple modes
|
|
226
|
+
* @Usage:
|
|
227
|
+
* @Author: richen
|
|
228
|
+
* @Date: 2025-10-30 12:00:00
|
|
229
|
+
* @LastEditTime: 2025-10-30 12:00:00
|
|
230
|
+
* @License: BSD (3-Clause)
|
|
231
|
+
* @Copyright (c): <richenlin(at)gmail.com>
|
|
232
|
+
*/
|
|
233
|
+
/**
|
|
234
|
+
* Redis client wrapper that implements IRedisClient interface
|
|
235
|
+
* Wraps ioredis client to provide unified interface
|
|
236
|
+
*/
|
|
237
|
+
class RedisClientAdapter {
|
|
238
|
+
constructor(client) {
|
|
239
|
+
this.client = client;
|
|
240
|
+
}
|
|
241
|
+
get status() {
|
|
242
|
+
return this.client.status;
|
|
243
|
+
}
|
|
244
|
+
async call(command, ...args) {
|
|
245
|
+
return this.client.call(command, ...args);
|
|
246
|
+
}
|
|
247
|
+
async set(key, value, mode, duration) {
|
|
248
|
+
if (mode && duration) {
|
|
249
|
+
// Use type assertion for mode parameter due to ioredis strict typing
|
|
250
|
+
return this.client.set(key, value, mode, duration);
|
|
251
|
+
}
|
|
252
|
+
return this.client.set(key, value);
|
|
253
|
+
}
|
|
254
|
+
async get(key) {
|
|
255
|
+
return this.client.get(key);
|
|
256
|
+
}
|
|
257
|
+
async del(...keys) {
|
|
258
|
+
return this.client.del(...keys);
|
|
259
|
+
}
|
|
260
|
+
async exists(key) {
|
|
261
|
+
return this.client.exists(key);
|
|
262
|
+
}
|
|
263
|
+
async eval(script, numKeys, ...args) {
|
|
264
|
+
return this.client.eval(script, numKeys, ...args);
|
|
265
|
+
}
|
|
266
|
+
async quit() {
|
|
267
|
+
return this.client.quit();
|
|
268
|
+
}
|
|
269
|
+
disconnect() {
|
|
270
|
+
this.client.disconnect();
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Get underlying Redis/Cluster instance
|
|
274
|
+
* Used for RedLock initialization
|
|
275
|
+
*/
|
|
276
|
+
getClient() {
|
|
277
|
+
return this.client;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Redis client factory
|
|
282
|
+
* Creates appropriate Redis client based on configuration
|
|
283
|
+
*/
|
|
284
|
+
class RedisFactory {
|
|
285
|
+
/**
|
|
286
|
+
* Create Redis client based on configuration mode
|
|
287
|
+
* @param config - Redis configuration
|
|
288
|
+
* @returns Redis client adapter
|
|
289
|
+
*/
|
|
290
|
+
static createClient(config) {
|
|
291
|
+
const mode = config.mode || RedisMode.STANDALONE;
|
|
292
|
+
DefaultLogger.Debug(`Creating Redis client in ${mode} mode`);
|
|
293
|
+
switch (mode) {
|
|
294
|
+
case RedisMode.STANDALONE:
|
|
295
|
+
return this.createStandaloneClient(config);
|
|
296
|
+
case RedisMode.SENTINEL:
|
|
297
|
+
return this.createSentinelClient(config);
|
|
298
|
+
case RedisMode.CLUSTER:
|
|
299
|
+
return this.createClusterClient(config);
|
|
300
|
+
default:
|
|
301
|
+
throw new Error(`不支持的 Redis 模式: ${mode} (Unsupported Redis mode: ${mode})`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Create standalone Redis client
|
|
306
|
+
* @param config - Standalone configuration
|
|
307
|
+
*/
|
|
308
|
+
static createStandaloneClient(config) {
|
|
309
|
+
DefaultLogger.Debug(`Creating standalone Redis client: ${config.host}:${config.port}`);
|
|
310
|
+
const options = {
|
|
311
|
+
host: config.host,
|
|
312
|
+
port: config.port,
|
|
313
|
+
password: config.password || undefined,
|
|
314
|
+
db: config.db || 0,
|
|
315
|
+
keyPrefix: config.keyPrefix || '',
|
|
316
|
+
connectTimeout: config.connectTimeout || 10000,
|
|
317
|
+
commandTimeout: config.commandTimeout || 5000,
|
|
318
|
+
maxRetriesPerRequest: config.maxRetriesPerRequest || 3,
|
|
319
|
+
retryStrategy: (times) => {
|
|
320
|
+
const delay = Math.min(times * 50, 2000);
|
|
321
|
+
DefaultLogger.Debug(`Redis reconnecting, attempt ${times}, delay ${delay}ms`);
|
|
322
|
+
return delay;
|
|
323
|
+
},
|
|
324
|
+
reconnectOnError: (err) => {
|
|
325
|
+
DefaultLogger.Warn('Redis connection error, attempting reconnect:', err.message);
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
const client = new Redis(options);
|
|
330
|
+
client.on('connect', () => {
|
|
331
|
+
DefaultLogger.Info('Redis standalone client connected successfully');
|
|
332
|
+
});
|
|
333
|
+
client.on('error', (err) => {
|
|
334
|
+
DefaultLogger.Error('Redis standalone client error:', err);
|
|
335
|
+
});
|
|
336
|
+
return new RedisClientAdapter(client);
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Create sentinel Redis client
|
|
340
|
+
* @param config - Sentinel configuration
|
|
341
|
+
*/
|
|
342
|
+
static createSentinelClient(config) {
|
|
343
|
+
DefaultLogger.Debug(`Creating sentinel Redis client for master: ${config.name}`);
|
|
344
|
+
const options = {
|
|
345
|
+
sentinels: config.sentinels,
|
|
346
|
+
name: config.name,
|
|
347
|
+
password: config.password || undefined,
|
|
348
|
+
sentinelPassword: config.sentinelPassword || undefined,
|
|
349
|
+
db: config.db || 0,
|
|
350
|
+
keyPrefix: config.keyPrefix || '',
|
|
351
|
+
connectTimeout: config.connectTimeout || 10000,
|
|
352
|
+
commandTimeout: config.commandTimeout || 5000,
|
|
353
|
+
maxRetriesPerRequest: config.maxRetriesPerRequest || 3,
|
|
354
|
+
retryStrategy: (times) => {
|
|
355
|
+
const delay = Math.min(times * 50, 2000);
|
|
356
|
+
DefaultLogger.Debug(`Sentinel Redis reconnecting, attempt ${times}, delay ${delay}ms`);
|
|
357
|
+
return delay;
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
const client = new Redis(options);
|
|
361
|
+
client.on('connect', () => {
|
|
362
|
+
DefaultLogger.Info(`Redis sentinel client connected to master: ${config.name}`);
|
|
363
|
+
});
|
|
364
|
+
client.on('error', (err) => {
|
|
365
|
+
DefaultLogger.Error('Redis sentinel client error:', err);
|
|
366
|
+
});
|
|
367
|
+
return new RedisClientAdapter(client);
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Create cluster Redis client
|
|
371
|
+
* @param config - Cluster configuration
|
|
372
|
+
*/
|
|
373
|
+
static createClusterClient(config) {
|
|
374
|
+
var _a, _b;
|
|
375
|
+
DefaultLogger.Debug(`Creating cluster Redis client with ${config.nodes.length} nodes`);
|
|
376
|
+
const clusterOptions = {
|
|
377
|
+
redisOptions: {
|
|
378
|
+
password: ((_a = config.redisOptions) === null || _a === void 0 ? void 0 : _a.password) || config.password || undefined,
|
|
379
|
+
db: ((_b = config.redisOptions) === null || _b === void 0 ? void 0 : _b.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)');
|
|
410
|
+
}
|
|
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})`);
|
|
424
|
+
}
|
|
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
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
140
449
|
/*
|
|
141
450
|
* @Description: RedLock utility for distributed locks
|
|
142
451
|
* @Usage:
|
|
@@ -154,12 +463,8 @@ const defaultRedLockConfig = {
|
|
|
154
463
|
clockDriftFactor: 0.01,
|
|
155
464
|
maxRetries: 3,
|
|
156
465
|
retryDelayMs: 200,
|
|
157
|
-
driftFactor: 0.01,
|
|
158
|
-
retryCount: 3,
|
|
159
|
-
retryDelay: 200,
|
|
160
|
-
retryJitter: 200,
|
|
161
|
-
automaticExtensionThreshold: 500,
|
|
162
466
|
redisConfig: {
|
|
467
|
+
mode: RedisMode.STANDALONE,
|
|
163
468
|
host: '127.0.0.1',
|
|
164
469
|
port: 6379,
|
|
165
470
|
password: '',
|
|
@@ -167,21 +472,29 @@ const defaultRedLockConfig = {
|
|
|
167
472
|
keyPrefix: 'redlock:'
|
|
168
473
|
}
|
|
169
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
|
+
};
|
|
170
485
|
/**
|
|
171
486
|
* RedLock distributed lock manager
|
|
172
487
|
* Integrated with koatty IOC container
|
|
173
488
|
* Implements singleton pattern for safe instance management
|
|
489
|
+
* Implements IDistributedLock interface for abstraction
|
|
174
490
|
*/
|
|
175
491
|
class RedLocker {
|
|
176
|
-
static instance = null;
|
|
177
|
-
static instanceLock = Symbol('RedLocker.instanceLock');
|
|
178
|
-
redlock = null;
|
|
179
|
-
redis = null;
|
|
180
|
-
config;
|
|
181
|
-
isInitialized = false;
|
|
182
|
-
initializationPromise = null;
|
|
183
492
|
// 私有构造函数防止外部直接实例化
|
|
184
493
|
constructor(options) {
|
|
494
|
+
this.redlock = null;
|
|
495
|
+
this.redisClient = null;
|
|
496
|
+
this.isInitialized = false;
|
|
497
|
+
this.initializationPromise = null;
|
|
185
498
|
this.config = { ...defaultRedLockConfig, ...options };
|
|
186
499
|
// Register this instance in IOC container
|
|
187
500
|
this.registerInContainer();
|
|
@@ -270,8 +583,12 @@ class RedLocker {
|
|
|
270
583
|
await this.initializationPromise;
|
|
271
584
|
}
|
|
272
585
|
catch (error) {
|
|
273
|
-
//
|
|
586
|
+
// 初始化失败时完整清理状态,允许重试
|
|
274
587
|
this.initializationPromise = null;
|
|
588
|
+
this.isInitialized = false;
|
|
589
|
+
this.redlock = null;
|
|
590
|
+
// 注意:不清理 redis 连接,因为它可能来自 IOC 容器
|
|
591
|
+
DefaultLogger.Warn('RedLocker initialization failed, state has been reset for retry');
|
|
275
592
|
throw error;
|
|
276
593
|
}
|
|
277
594
|
}
|
|
@@ -281,34 +598,54 @@ class RedLocker {
|
|
|
281
598
|
*/
|
|
282
599
|
async performInitialization() {
|
|
283
600
|
try {
|
|
601
|
+
// Validate Redis configuration
|
|
602
|
+
if (this.config.redisConfig) {
|
|
603
|
+
RedisFactory.validateConfig(this.config.redisConfig);
|
|
604
|
+
}
|
|
284
605
|
// Try to get Redis instance from IOC container first
|
|
285
606
|
try {
|
|
286
|
-
|
|
287
|
-
|
|
607
|
+
const existingRedis = IOCContainer.get('Redis', 'COMPONENT');
|
|
608
|
+
// If Redis instance exists in container, wrap it
|
|
609
|
+
if (existingRedis) {
|
|
610
|
+
// Check if it's already a RedisClientAdapter
|
|
611
|
+
if (existingRedis instanceof RedisClientAdapter) {
|
|
612
|
+
this.redisClient = existingRedis;
|
|
613
|
+
}
|
|
614
|
+
else {
|
|
615
|
+
// Wrap raw Redis/Cluster instance (type assertion needed)
|
|
616
|
+
this.redisClient = new RedisClientAdapter(existingRedis);
|
|
617
|
+
}
|
|
618
|
+
DefaultLogger.Debug('Using Redis instance from IOC container');
|
|
619
|
+
}
|
|
288
620
|
}
|
|
289
621
|
catch {
|
|
290
|
-
//
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
db: this.config.redisConfig.db || 0,
|
|
296
|
-
keyPrefix: this.config.redisConfig.keyPrefix,
|
|
297
|
-
maxRetriesPerRequest: 3
|
|
298
|
-
});
|
|
622
|
+
// IOC container doesn't have Redis, create new connection
|
|
623
|
+
}
|
|
624
|
+
// Create new Redis connection if not available in container
|
|
625
|
+
if (!this.redisClient && this.config.redisConfig) {
|
|
626
|
+
this.redisClient = RedisFactory.createClient(this.config.redisConfig);
|
|
299
627
|
DefaultLogger.Debug('Created new Redis connection for RedLocker');
|
|
300
628
|
}
|
|
301
|
-
if (!this.
|
|
302
|
-
throw new Error('Failed to initialize Redis connection');
|
|
629
|
+
if (!this.redisClient) {
|
|
630
|
+
throw new Error('Failed to initialize Redis connection: no configuration provided');
|
|
303
631
|
}
|
|
632
|
+
// Get underlying client for Redlock
|
|
633
|
+
const underlyingClient = this.redisClient.getClient();
|
|
634
|
+
// Merge default settings with user configuration
|
|
635
|
+
// Extract Settings properties from config (which extends Partial<Settings>)
|
|
636
|
+
const userSettings = this.config;
|
|
637
|
+
const redlockSettings = {
|
|
638
|
+
...defaultRedlockSettings,
|
|
639
|
+
...(userSettings.driftFactor !== undefined && { driftFactor: userSettings.driftFactor }),
|
|
640
|
+
...(userSettings.retryCount !== undefined && { retryCount: userSettings.retryCount }),
|
|
641
|
+
...(userSettings.retryDelay !== undefined && { retryDelay: userSettings.retryDelay }),
|
|
642
|
+
...(userSettings.retryJitter !== undefined && { retryJitter: userSettings.retryJitter }),
|
|
643
|
+
...(userSettings.automaticExtensionThreshold !== undefined && {
|
|
644
|
+
automaticExtensionThreshold: userSettings.automaticExtensionThreshold
|
|
645
|
+
})
|
|
646
|
+
};
|
|
304
647
|
// Initialize Redlock with the Redis instance
|
|
305
|
-
this.redlock = new Redlock([
|
|
306
|
-
driftFactor: this.config.driftFactor,
|
|
307
|
-
retryCount: this.config.retryCount,
|
|
308
|
-
retryDelay: this.config.retryDelay,
|
|
309
|
-
retryJitter: this.config.retryJitter,
|
|
310
|
-
automaticExtensionThreshold: this.config.automaticExtensionThreshold
|
|
311
|
-
});
|
|
648
|
+
this.redlock = new Redlock([underlyingClient], redlockSettings);
|
|
312
649
|
// Set up error handlers
|
|
313
650
|
this.redlock.on('clientError', (err) => {
|
|
314
651
|
DefaultLogger.Error('Redis client error in RedLock:', err);
|
|
@@ -409,7 +746,7 @@ class RedLocker {
|
|
|
409
746
|
* @returns true if initialized, false otherwise
|
|
410
747
|
*/
|
|
411
748
|
isReady() {
|
|
412
|
-
return this.isInitialized && !!this.redlock && !!this.
|
|
749
|
+
return this.isInitialized && !!this.redlock && !!this.redisClient;
|
|
413
750
|
}
|
|
414
751
|
/**
|
|
415
752
|
* Get current configuration
|
|
@@ -437,11 +774,11 @@ class RedLocker {
|
|
|
437
774
|
*/
|
|
438
775
|
async close() {
|
|
439
776
|
try {
|
|
440
|
-
if (this.
|
|
441
|
-
await this.
|
|
777
|
+
if (this.redisClient && this.redisClient.status === 'ready') {
|
|
778
|
+
await this.redisClient.quit();
|
|
442
779
|
DefaultLogger.Debug('Redis connection closed');
|
|
443
780
|
}
|
|
444
|
-
this.
|
|
781
|
+
this.redisClient = null;
|
|
445
782
|
this.redlock = null;
|
|
446
783
|
this.isInitialized = false;
|
|
447
784
|
}
|
|
@@ -473,15 +810,17 @@ class RedLocker {
|
|
|
473
810
|
* @returns Health status
|
|
474
811
|
*/
|
|
475
812
|
async healthCheck() {
|
|
813
|
+
var _a, _b;
|
|
476
814
|
try {
|
|
477
815
|
await this.initialize();
|
|
478
|
-
const redisStatus = this.
|
|
816
|
+
const redisStatus = ((_a = this.redisClient) === null || _a === void 0 ? void 0 : _a.status) || 'unknown';
|
|
479
817
|
const isReady = this.isReady();
|
|
480
818
|
return {
|
|
481
819
|
status: isReady ? 'healthy' : 'unhealthy',
|
|
482
820
|
details: {
|
|
483
821
|
initialized: this.isInitialized,
|
|
484
822
|
redisStatus,
|
|
823
|
+
redisMode: ((_b = this.config.redisConfig) === null || _b === void 0 ? void 0 : _b.mode) || 'unknown',
|
|
485
824
|
redlockReady: !!this.redlock,
|
|
486
825
|
containerRegistered: this.getContainerInfo().registered
|
|
487
826
|
}
|
|
@@ -498,6 +837,8 @@ class RedLocker {
|
|
|
498
837
|
}
|
|
499
838
|
}
|
|
500
839
|
}
|
|
840
|
+
RedLocker.instance = null;
|
|
841
|
+
RedLocker.instanceLock = Symbol('RedLocker.instanceLock');
|
|
501
842
|
|
|
502
843
|
/*
|
|
503
844
|
* @Description:
|
|
@@ -508,18 +849,22 @@ class RedLocker {
|
|
|
508
849
|
* @License: BSD (3-Clause)
|
|
509
850
|
* @Copyright (c): <richenlin(at)gmail.com>
|
|
510
851
|
*/
|
|
511
|
-
/**
|
|
512
|
-
* 返回一个 Promise,在指定时间后 reject
|
|
513
|
-
* @param ms
|
|
514
|
-
* @returns
|
|
515
|
-
*/
|
|
516
852
|
function timeoutPromise(ms) {
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
853
|
+
let timeoutId = null;
|
|
854
|
+
const promise = new Promise((resolve, reject) => {
|
|
855
|
+
timeoutId = setTimeout(() => {
|
|
856
|
+
timeoutId = null;
|
|
520
857
|
reject(new Error('TIME_OUT_ERROR'));
|
|
521
858
|
}, ms);
|
|
522
859
|
});
|
|
860
|
+
// 添加取消方法,防止内存泄漏
|
|
861
|
+
promise.cancel = () => {
|
|
862
|
+
if (timeoutId !== null) {
|
|
863
|
+
clearTimeout(timeoutId);
|
|
864
|
+
timeoutId = null;
|
|
865
|
+
}
|
|
866
|
+
};
|
|
867
|
+
return promise;
|
|
523
868
|
}
|
|
524
869
|
|
|
525
870
|
/*
|
|
@@ -543,21 +888,19 @@ async function initRedLock(options, app) {
|
|
|
543
888
|
DefaultLogger.Warn(`RedLock initialization skipped: Koatty app not available or not initialized`);
|
|
544
889
|
return;
|
|
545
890
|
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
throw Error(`Missing RedLock configuration. Please write a configuration item with the key name 'RedLock' in the db.ts file.`);
|
|
550
|
-
}
|
|
551
|
-
// 获取RedLocker实例,在首次使用时自动初始化
|
|
552
|
-
const redLocker = RedLocker.getInstance(options);
|
|
553
|
-
await redLocker.initialize();
|
|
554
|
-
DefaultLogger.Info('RedLock initialized successfully');
|
|
555
|
-
}
|
|
556
|
-
catch (error) {
|
|
557
|
-
DefaultLogger.Error('Failed to initialize RedLock:', error);
|
|
558
|
-
throw error;
|
|
891
|
+
try {
|
|
892
|
+
if (Helper.isEmpty(options)) {
|
|
893
|
+
throw Error(`Missing RedLock configuration. Please write a configuration item with the key name 'RedLock' in the db.ts file.`);
|
|
559
894
|
}
|
|
560
|
-
|
|
895
|
+
// 获取RedLocker实例,在首次使用时自动初始化
|
|
896
|
+
const redLocker = RedLocker.getInstance(options);
|
|
897
|
+
await redLocker.initialize();
|
|
898
|
+
DefaultLogger.Info('RedLock initialized successfully');
|
|
899
|
+
}
|
|
900
|
+
catch (error) {
|
|
901
|
+
DefaultLogger.Error('Failed to initialize RedLock:', error);
|
|
902
|
+
throw error;
|
|
903
|
+
}
|
|
561
904
|
}
|
|
562
905
|
/**
|
|
563
906
|
* Create redLocker Descriptor with improved error handling and type safety
|
|
@@ -593,15 +936,21 @@ function redLockerDescriptor(descriptor, name, method, methodOptions) {
|
|
|
593
936
|
let extensionCount = 0;
|
|
594
937
|
try {
|
|
595
938
|
while (remainingTime > 0 && extensionCount < maxExtensions) {
|
|
939
|
+
// 创建可取消的超时 Promise
|
|
940
|
+
const timeoutHandler = timeoutPromise(remainingTime);
|
|
596
941
|
try {
|
|
597
942
|
// 执行业务方法,与超时竞争
|
|
598
943
|
const result = await Promise.race([
|
|
599
944
|
value.apply(self, props),
|
|
600
|
-
|
|
945
|
+
timeoutHandler
|
|
601
946
|
]);
|
|
602
|
-
|
|
947
|
+
// 成功执行,取消超时定时器防止内存泄漏
|
|
948
|
+
timeoutHandler.cancel();
|
|
949
|
+
return result;
|
|
603
950
|
}
|
|
604
951
|
catch (error) {
|
|
952
|
+
// 无论什么错误,都要取消超时定时器
|
|
953
|
+
timeoutHandler.cancel();
|
|
605
954
|
// 处理超时错误,尝试续期锁
|
|
606
955
|
if (error instanceof Error && error.message === 'TIME_OUT_ERROR') {
|
|
607
956
|
extensionCount++;
|
|
@@ -668,6 +1017,7 @@ function redLockerDescriptor(descriptor, name, method, methodOptions) {
|
|
|
668
1017
|
* Generate lock name for RedLock decorator
|
|
669
1018
|
*/
|
|
670
1019
|
function generateLockName(configName, methodName, target) {
|
|
1020
|
+
var _a;
|
|
671
1021
|
if (configName) {
|
|
672
1022
|
return configName;
|
|
673
1023
|
}
|
|
@@ -682,7 +1032,7 @@ function generateLockName(configName, methodName, target) {
|
|
|
682
1032
|
// Fallback if IOC container is not available
|
|
683
1033
|
}
|
|
684
1034
|
const targetWithConstructor = target;
|
|
685
|
-
const className = targetWithConstructor.constructor
|
|
1035
|
+
const className = ((_a = targetWithConstructor.constructor) === null || _a === void 0 ? void 0 : _a.name) || 'Unknown';
|
|
686
1036
|
return `${className}_${methodName}`;
|
|
687
1037
|
}
|
|
688
1038
|
|
|
@@ -846,16 +1196,14 @@ async function initSchedule(options, app) {
|
|
|
846
1196
|
DefaultLogger.Warn(`Schedule initialization skipped: Koatty app not available or not initialized`);
|
|
847
1197
|
return;
|
|
848
1198
|
}
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
}
|
|
858
|
-
});
|
|
1199
|
+
try {
|
|
1200
|
+
await injectSchedule(options);
|
|
1201
|
+
DefaultLogger.Info('Schedule system initialized successfully');
|
|
1202
|
+
}
|
|
1203
|
+
catch (error) {
|
|
1204
|
+
DefaultLogger.Error('Failed to initialize Schedule system:', error);
|
|
1205
|
+
throw error;
|
|
1206
|
+
}
|
|
859
1207
|
}
|
|
860
1208
|
/**
|
|
861
1209
|
* Inject schedule job with enhanced error handling and validation
|
|
@@ -945,6 +1293,7 @@ const defaultOptions = {
|
|
|
945
1293
|
maxRetries: 3,
|
|
946
1294
|
retryDelayMs: 200,
|
|
947
1295
|
redisConfig: {
|
|
1296
|
+
mode: RedisMode.STANDALONE,
|
|
948
1297
|
host: "localhost",
|
|
949
1298
|
port: 6379,
|
|
950
1299
|
password: "",
|
|
@@ -958,10 +1307,12 @@ const defaultOptions = {
|
|
|
958
1307
|
*/
|
|
959
1308
|
async function KoattyScheduled(options, app) {
|
|
960
1309
|
options = { ...defaultOptions, ...options };
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
1310
|
+
app.once("appReady", async function () {
|
|
1311
|
+
// 初始化RedLock
|
|
1312
|
+
await initRedLock(options, app);
|
|
1313
|
+
// 初始化调度任务系统
|
|
1314
|
+
await initSchedule(options, app);
|
|
1315
|
+
});
|
|
965
1316
|
}
|
|
966
1317
|
|
|
967
|
-
export { KoattyScheduled, RedLock, Scheduled, SchedulerLock };
|
|
1318
|
+
export { KoattyScheduled, RedLock, RedLocker, RedisClientAdapter, RedisFactory, RedisMode, Scheduled, SchedulerLock };
|