@fuzionx/framework 0.1.76 → 0.1.77
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/cli/templates/make/app-spa/views/default/spa/package.json +1 -1
- package/lib/core/Application.js +50 -5
- package/lib/schedule/Queue.js +58 -58
- package/lib/schedule/Scheduler.js +73 -3
- package/lib/schedule/drivers/MemoryQueueDriver.js +137 -0
- package/lib/schedule/drivers/RedisQueueDriver.js +285 -0
- package/package.json +2 -2
package/lib/core/Application.js
CHANGED
|
@@ -300,8 +300,50 @@ export default class Application {
|
|
|
300
300
|
await this.i18n.load();
|
|
301
301
|
|
|
302
302
|
// 3. Scheduler / Queue / Telegram / Storage / Cache 초기화
|
|
303
|
-
|
|
304
|
-
|
|
303
|
+
//
|
|
304
|
+
// yaml 구조:
|
|
305
|
+
// app.scheduler.enabled / .lock.{driver,redis_url,prefix}
|
|
306
|
+
// app.queue.{driver,redis_url,prefix}
|
|
307
|
+
// fuzionx 기존 코드는 top-level (config.get('queue')) 였지만 실제로는
|
|
308
|
+
// app.* 아래에 있음 — fallback 둘 다 지원.
|
|
309
|
+
const queueCfg = this.config.get('app.queue') || this.config.get('queue') || {};
|
|
310
|
+
const schedulerCfg = this.config.get('app.scheduler') || this.config.get('scheduler') || {};
|
|
311
|
+
const lockCfg = schedulerCfg.lock || {};
|
|
312
|
+
|
|
313
|
+
// Scheduler — leader election 용 redis URL
|
|
314
|
+
// 우선순위: app.scheduler.lock.redis_url → app.queue.redis_url 재사용
|
|
315
|
+
// lock.driver !== 'redis' 면 leader election 비활성 (fallback)
|
|
316
|
+
const schedulerLockEnabled = lockCfg.driver === 'redis';
|
|
317
|
+
const schedulerRedisUrl = schedulerLockEnabled
|
|
318
|
+
? (lockCfg.redis_url || queueCfg.redis_url || null)
|
|
319
|
+
: null;
|
|
320
|
+
this._scheduler = new Scheduler(this, {
|
|
321
|
+
url: schedulerRedisUrl,
|
|
322
|
+
prefix: lockCfg.prefix || 'fuzionx:scheduler',
|
|
323
|
+
});
|
|
324
|
+
this._schedulerEnabled = schedulerCfg.enabled !== false; // 기본 true
|
|
325
|
+
if (this._schedulerEnabled) {
|
|
326
|
+
try {
|
|
327
|
+
await this._scheduler.connect();
|
|
328
|
+
} catch (e) {
|
|
329
|
+
this.logger.warn(`[Scheduler] connect failed: ${e.message}`);
|
|
330
|
+
}
|
|
331
|
+
} else {
|
|
332
|
+
this.logger.info('[Scheduler] disabled (app.scheduler.enabled=false)');
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
this._queue = new Queue(this, {
|
|
336
|
+
driver: queueCfg.driver || 'memory',
|
|
337
|
+
url: queueCfg.redis_url,
|
|
338
|
+
prefix: queueCfg.prefix,
|
|
339
|
+
brpopTimeoutSec: queueCfg.brpop_timeout_sec,
|
|
340
|
+
promoterIntervalMs: queueCfg.promoter_interval_ms,
|
|
341
|
+
});
|
|
342
|
+
try {
|
|
343
|
+
await this._queue.start();
|
|
344
|
+
} catch (e) {
|
|
345
|
+
this.logger.error(`[Queue] start failed: ${e.message}`);
|
|
346
|
+
}
|
|
305
347
|
|
|
306
348
|
// 텔레그램 매니저 로드 (Queue 기반이므로 _queue 설정 직후 구성)
|
|
307
349
|
const telegramConfig = this.config.get('app.telegram');
|
|
@@ -531,8 +573,11 @@ export default class Application {
|
|
|
531
573
|
|
|
532
574
|
// Scheduler 시작 — primary 프로세스에서만 (워커 중복 실행 방지)
|
|
533
575
|
// fuzionx 상위 레이어가 cluster.fork()로 워커를 생성하므로
|
|
534
|
-
// cluster.isPrimary로 단일 프로세스 보장
|
|
535
|
-
|
|
576
|
+
// cluster.isPrimary로 단일 프로세스 보장 — multi-worker 차원 보호.
|
|
577
|
+
// 추가로 redis leader election (Scheduler.connect) 가 multi-server 차원 보호.
|
|
578
|
+
// app.scheduler.enabled=false 면 전체 스킵.
|
|
579
|
+
if (this._scheduler && this._scheduler._jobs.length > 0
|
|
580
|
+
&& !cluster.isWorker && this._schedulerEnabled !== false) {
|
|
536
581
|
this._scheduler.start();
|
|
537
582
|
}
|
|
538
583
|
|
|
@@ -1493,7 +1538,7 @@ export default class Application {
|
|
|
1493
1538
|
await this.emit('shutting-down', { signal });
|
|
1494
1539
|
|
|
1495
1540
|
// 1. 스케줄러 정지 (04-bootstrap-lifecycle.md)
|
|
1496
|
-
if (this._scheduler) this._scheduler.stop();
|
|
1541
|
+
if (this._scheduler) await this._scheduler.stop();
|
|
1497
1542
|
|
|
1498
1543
|
// 2. 큐 drain — 처리 중 Task 완료 대기 (doc 04 순서 3)
|
|
1499
1544
|
if (this._queue && this._queue.pending > 0) {
|
package/lib/schedule/Queue.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Queue — Task 큐 관리 (
|
|
2
|
+
* Queue — Task 큐 관리 (driver 분기: memory / redis)
|
|
3
3
|
*
|
|
4
4
|
* AutoLoader가 shared/jobs/ 에서 Task 클래스를 자동 스캔·등록.
|
|
5
5
|
* handle(data)은 메인 스레드에서 실행되므로 this.db, this.service() 등
|
|
@@ -7,97 +7,97 @@
|
|
|
7
7
|
*
|
|
8
8
|
* CPU-intensive 작업은 handle() 내에서 this.worker.run()으로 위임.
|
|
9
9
|
*
|
|
10
|
+
* Driver:
|
|
11
|
+
* - 'memory' (기본): 단일 process 내, 재시작 시 손실
|
|
12
|
+
* - 'redis': ioredis 기반 영구 큐, 멀티워커 안전
|
|
13
|
+
*
|
|
14
|
+
* 설정 (fuzionx.yaml):
|
|
15
|
+
* queue:
|
|
16
|
+
* driver: redis | memory
|
|
17
|
+
* redis_url: "redis://:password@localhost:6379" # redis 일 때만
|
|
18
|
+
* prefix: "fuzionx:queue" # 선택 (default 동일)
|
|
19
|
+
*
|
|
10
20
|
* @see docs/framework/10-scheduler-queue.md
|
|
21
|
+
* @see lib/schedule/drivers/MemoryQueueDriver.js
|
|
22
|
+
* @see lib/schedule/drivers/RedisQueueDriver.js
|
|
11
23
|
* @see lib/schedule/WorkerPool.js
|
|
12
24
|
*/
|
|
25
|
+
import MemoryQueueDriver from './drivers/MemoryQueueDriver.js';
|
|
26
|
+
import RedisQueueDriver from './drivers/RedisQueueDriver.js';
|
|
27
|
+
|
|
13
28
|
export default class Queue {
|
|
14
29
|
/**
|
|
15
30
|
* @param {import('../core/Application.js').default} app
|
|
16
31
|
* @param {object} [opts]
|
|
17
32
|
* @param {string} [opts.driver='memory'] - 'memory' | 'redis'
|
|
33
|
+
* @param {string} [opts.url] - redis driver 의 접속 URL
|
|
34
|
+
* @param {string} [opts.prefix] - redis driver 의 키 접두사
|
|
18
35
|
*/
|
|
19
36
|
constructor(app, opts = {}) {
|
|
20
37
|
this.app = app;
|
|
21
38
|
this.driver = opts.driver || 'memory';
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
this.
|
|
39
|
+
|
|
40
|
+
// driver 인스턴스 생성 — 외부 API 는 동일 (register/dispatch/pending)
|
|
41
|
+
if (this.driver === 'redis') {
|
|
42
|
+
this._impl = new RedisQueueDriver(app, opts);
|
|
43
|
+
} else {
|
|
44
|
+
this._impl = new MemoryQueueDriver(app, opts);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 드라이버 초기화 (Application.boot 에서 호출 권장).
|
|
50
|
+
* redis driver 는 connection + worker loop 시작.
|
|
51
|
+
*
|
|
52
|
+
* @returns {Promise<void>}
|
|
53
|
+
*/
|
|
54
|
+
async start() {
|
|
55
|
+
if (typeof this._impl.start === 'function') {
|
|
56
|
+
await this._impl.start();
|
|
57
|
+
}
|
|
25
58
|
}
|
|
26
59
|
|
|
27
60
|
/**
|
|
28
|
-
* Task 클래스 등록
|
|
61
|
+
* Task 클래스 등록 (AutoLoader 가 호출).
|
|
62
|
+
*
|
|
29
63
|
* @param {string} name
|
|
30
64
|
* @param {typeof import('./Task.js').default} TaskClass
|
|
31
65
|
*/
|
|
32
66
|
register(name, TaskClass) {
|
|
33
|
-
this.
|
|
67
|
+
this._impl.register(name, TaskClass);
|
|
34
68
|
}
|
|
35
69
|
|
|
36
70
|
/**
|
|
37
|
-
* Task 디스패치
|
|
71
|
+
* Task 디스패치 — 큐에 enqueue.
|
|
72
|
+
*
|
|
38
73
|
* @param {string|Function} taskOrName
|
|
39
74
|
* @param {object} data
|
|
40
75
|
* @param {object} [opts]
|
|
41
76
|
*/
|
|
42
77
|
dispatch(taskOrName, data, opts = {}) {
|
|
43
|
-
|
|
44
|
-
this._queue.push({ name, data, opts, retries: 0 });
|
|
45
|
-
|
|
46
|
-
// auto-process (비동기)
|
|
47
|
-
if (!this._processing) {
|
|
48
|
-
this._process();
|
|
49
|
-
}
|
|
78
|
+
this._impl.dispatch(taskOrName, data, opts);
|
|
50
79
|
}
|
|
51
80
|
|
|
52
81
|
/**
|
|
53
|
-
*
|
|
54
|
-
*
|
|
82
|
+
* 대기 중 작업 수 (graceful shutdown 의 drain 판정용).
|
|
83
|
+
* - memory driver: 정확한 in-memory 큐 길이
|
|
84
|
+
* - redis driver: 0 반환 (Application 의 drain 로직은 memory 기준 설계 —
|
|
85
|
+
* redis 는 별도 drain 필요. 운영자가 외부에서 wait)
|
|
86
|
+
*
|
|
87
|
+
* @returns {number}
|
|
55
88
|
*/
|
|
56
|
-
|
|
57
|
-
this.
|
|
58
|
-
|
|
59
|
-
while (this._queue.length > 0) {
|
|
60
|
-
const job = this._queue.shift();
|
|
61
|
-
const TaskClass = this._tasks.get(job.name);
|
|
62
|
-
|
|
63
|
-
if (!TaskClass) {
|
|
64
|
-
this.app?.logger?.error?.(`[Queue] Task '${job.name}' not registered`);
|
|
65
|
-
continue;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const timeout = TaskClass.timeout || 30000;
|
|
69
|
-
const task = new TaskClass(this.app);
|
|
70
|
-
|
|
71
|
-
try {
|
|
72
|
-
let timer;
|
|
73
|
-
await Promise.race([
|
|
74
|
-
task.handle(job.data).finally(() => clearTimeout(timer)),
|
|
75
|
-
new Promise((_, reject) => {
|
|
76
|
-
timer = setTimeout(() => reject(new Error(`Task timeout (${timeout}ms)`)), timeout);
|
|
77
|
-
}),
|
|
78
|
-
]);
|
|
79
|
-
} catch (err) {
|
|
80
|
-
job.retries++;
|
|
81
|
-
if (job.retries < (TaskClass.retries || 3)) {
|
|
82
|
-
const delay = TaskClass.retryDelay || 1000;
|
|
83
|
-
setTimeout(() => {
|
|
84
|
-
this._queue.push(job);
|
|
85
|
-
if (!this._processing) this._process();
|
|
86
|
-
}, delay);
|
|
87
|
-
} else {
|
|
88
|
-
try { await task.failed(job.data, err); } catch {}
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
this._processing = false;
|
|
89
|
+
get pending() {
|
|
90
|
+
return this._impl.pending;
|
|
94
91
|
}
|
|
95
92
|
|
|
96
93
|
/**
|
|
97
|
-
*
|
|
98
|
-
*
|
|
94
|
+
* graceful shutdown.
|
|
95
|
+
*
|
|
96
|
+
* @param {number} [timeoutMs=10000]
|
|
99
97
|
*/
|
|
100
|
-
|
|
101
|
-
|
|
98
|
+
async stop(timeoutMs = 10000) {
|
|
99
|
+
if (typeof this._impl.stop === 'function') {
|
|
100
|
+
await this._impl.stop(timeoutMs);
|
|
101
|
+
}
|
|
102
102
|
}
|
|
103
103
|
}
|
|
@@ -7,19 +7,67 @@
|
|
|
7
7
|
*
|
|
8
8
|
* CPU-intensive 작업은 handle() 내에서 this.worker.run()으로 위임.
|
|
9
9
|
*
|
|
10
|
+
* ## Redis Leader Election (멀티워커 안전)
|
|
11
|
+
*
|
|
12
|
+
* `opts.url` (redis URL) 가 주어지면 매 Job 실행 직전 SET NX EX 로 lock 획득.
|
|
13
|
+
* 성공한 워커만 실행 → 멀티 worker / 멀티 server 환경에서 **단 1번만 실행**.
|
|
14
|
+
* redis 미설정 시 fallback — 각 워커가 자체 실행 (단일 인스턴스 환경 가정).
|
|
15
|
+
*
|
|
16
|
+
* Lock key 구조:
|
|
17
|
+
* {prefix}:lock:{JobName}:{bucketTs}
|
|
18
|
+
* - bucketTs = floor(now / interval) * interval — 같은 bucket 에선 1번만 실행
|
|
19
|
+
* - TTL = interval × 0.9 (다음 bucket 진입 전 만료)
|
|
20
|
+
*
|
|
10
21
|
* @see docs/framework/10-scheduler-queue.md
|
|
11
22
|
* @see lib/schedule/WorkerPool.js
|
|
12
23
|
*/
|
|
13
24
|
export default class Scheduler {
|
|
14
25
|
/**
|
|
15
26
|
* @param {import('../core/Application.js').default} app
|
|
27
|
+
* @param {object} [opts]
|
|
28
|
+
* @param {string} [opts.url] - Redis URL (leader election 활성). 미지정 시 fallback (각 워커 실행)
|
|
29
|
+
* @param {string} [opts.prefix='fuzionx:scheduler'] - lock key 접두사
|
|
16
30
|
*/
|
|
17
|
-
constructor(app) {
|
|
31
|
+
constructor(app, opts = {}) {
|
|
18
32
|
this.app = app;
|
|
33
|
+
this._url = opts.url || null;
|
|
34
|
+
this._prefix = opts.prefix || 'fuzionx:scheduler';
|
|
35
|
+
|
|
19
36
|
/** @type {Array<{JobClass: typeof import('./Job.js').default}>} */
|
|
20
37
|
this._jobs = [];
|
|
21
38
|
/** @type {Array<NodeJS.Timeout>} */
|
|
22
39
|
this._timers = [];
|
|
40
|
+
|
|
41
|
+
/** @type {import('ioredis').default | null} leader election 용 redis client */
|
|
42
|
+
this._redis = null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Redis 연결 (leader election 용). opts.url 있을 때만.
|
|
47
|
+
* Application.boot 에서 호출 — 실패해도 fallback 동작.
|
|
48
|
+
*
|
|
49
|
+
* @returns {Promise<void>}
|
|
50
|
+
*/
|
|
51
|
+
async connect() {
|
|
52
|
+
if (!this._url) return;
|
|
53
|
+
try {
|
|
54
|
+
const { default: Redis } = await import('ioredis');
|
|
55
|
+
this._redis = new Redis(this._url, {
|
|
56
|
+
maxRetriesPerRequest: 3,
|
|
57
|
+
retryStrategy: (times) => {
|
|
58
|
+
if (times > 5) return null;
|
|
59
|
+
return Math.min(times * 200, 2000);
|
|
60
|
+
},
|
|
61
|
+
lazyConnect: false,
|
|
62
|
+
});
|
|
63
|
+
this._redis.on('error', (err) =>
|
|
64
|
+
this.app?.logger?.error?.(`[Scheduler:Redis] ${err.message}`),
|
|
65
|
+
);
|
|
66
|
+
this.app?.logger?.info?.(`[Scheduler] redis leader election enabled (prefix=${this._prefix})`);
|
|
67
|
+
} catch (e) {
|
|
68
|
+
this.app?.logger?.warn?.(`[Scheduler] redis connect failed — fallback to local: ${e.message}`);
|
|
69
|
+
this._redis = null;
|
|
70
|
+
}
|
|
23
71
|
}
|
|
24
72
|
|
|
25
73
|
/**
|
|
@@ -46,6 +94,24 @@ export default class Scheduler {
|
|
|
46
94
|
const timeout = JobClass.timeout || 30000;
|
|
47
95
|
|
|
48
96
|
const runJob = async () => {
|
|
97
|
+
// ── Redis leader election ──
|
|
98
|
+
// 같은 bucket 에선 1개 워커만 실행. redis 미연결 시 통과.
|
|
99
|
+
if (this._redis) {
|
|
100
|
+
const bucketTs = Math.floor(Date.now() / interval) * interval;
|
|
101
|
+
const lockKey = `${this._prefix}:lock:${JobClass.name}:${bucketTs}`;
|
|
102
|
+
const lockTtlMs = Math.max(Math.floor(interval * 0.9), 1000);
|
|
103
|
+
try {
|
|
104
|
+
const acquired = await this._redis.set(lockKey, '1', 'PX', lockTtlMs, 'NX');
|
|
105
|
+
if (acquired !== 'OK') {
|
|
106
|
+
// 다른 워커가 이미 실행 — skip
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
} catch (e) {
|
|
110
|
+
// redis 일시 장애 — fallback (실행 진행). 중복 가능하지만 실행 누락보단 안전
|
|
111
|
+
this.app?.logger?.warn?.(`[Scheduler] lock acquire failed for ${JobClass.name}: ${e.message}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
49
115
|
const job = new JobClass(this.app);
|
|
50
116
|
try {
|
|
51
117
|
let timer;
|
|
@@ -87,14 +153,18 @@ export default class Scheduler {
|
|
|
87
153
|
}
|
|
88
154
|
|
|
89
155
|
/**
|
|
90
|
-
* 모든 타이머 정지
|
|
156
|
+
* 모든 타이머 정지 + redis 연결 종료
|
|
91
157
|
*/
|
|
92
|
-
stop() {
|
|
158
|
+
async stop() {
|
|
93
159
|
for (const timer of this._timers) {
|
|
94
160
|
clearInterval(timer);
|
|
95
161
|
clearTimeout(timer);
|
|
96
162
|
}
|
|
97
163
|
this._timers = [];
|
|
164
|
+
if (this._redis) {
|
|
165
|
+
try { await this._redis.quit(); } catch {}
|
|
166
|
+
this._redis = null;
|
|
167
|
+
}
|
|
98
168
|
}
|
|
99
169
|
|
|
100
170
|
// ── Schedule 파싱 ──
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MemoryQueueDriver — 인메모리 Task 큐 드라이버
|
|
3
|
+
*
|
|
4
|
+
* Queue.js 의 기존 in-process 로직을 추출한 driver.
|
|
5
|
+
* 단일 process 내에서만 동작 — multi-worker / 재시작 잔존 불가.
|
|
6
|
+
* 개발/테스트 또는 단일 인스턴스 운영용.
|
|
7
|
+
*
|
|
8
|
+
* 큐 동작:
|
|
9
|
+
* - dispatch: Array.push → 비동기 _process 자동 시작
|
|
10
|
+
* - process loop: shift → Task 실행 → 실패 시 retry (TaskClass.retries 까지)
|
|
11
|
+
* - 최종 실패: Task.failed(data, error) 호출
|
|
12
|
+
*
|
|
13
|
+
* @see lib/schedule/Queue.js
|
|
14
|
+
*/
|
|
15
|
+
export default class MemoryQueueDriver {
|
|
16
|
+
/**
|
|
17
|
+
* @param {import('../../core/Application.js').default} app
|
|
18
|
+
* @param {object} [opts]
|
|
19
|
+
*/
|
|
20
|
+
constructor(app, opts = {}) {
|
|
21
|
+
this.app = app;
|
|
22
|
+
this.opts = opts;
|
|
23
|
+
|
|
24
|
+
/** @type {Map<string, typeof import('../Task.js').default>} Task 클래스 레지스트리 */
|
|
25
|
+
this._tasks = new Map();
|
|
26
|
+
|
|
27
|
+
/** @type {Array<{ name, data, opts, retries }>} 큐 배열 */
|
|
28
|
+
this._queue = [];
|
|
29
|
+
|
|
30
|
+
/** @type {boolean} process 루프 동작 중 여부 */
|
|
31
|
+
this._processing = false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 드라이버 초기화 (no-op for memory).
|
|
36
|
+
* Queue 가 boot 시점에 호출.
|
|
37
|
+
*
|
|
38
|
+
* @returns {Promise<void>}
|
|
39
|
+
*/
|
|
40
|
+
async start() {
|
|
41
|
+
// memory 는 별도 초기화 불필요
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Task 클래스 등록 (AutoLoader 에서 호출).
|
|
46
|
+
*
|
|
47
|
+
* @param {string} name - Task 이름 (보통 클래스명)
|
|
48
|
+
* @param {typeof import('../Task.js').default} TaskClass
|
|
49
|
+
*/
|
|
50
|
+
register(name, TaskClass) {
|
|
51
|
+
this._tasks.set(name, TaskClass);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Task 디스패치 — 큐에 추가 + 자동 처리.
|
|
56
|
+
*
|
|
57
|
+
* @param {string|Function} taskOrName - Task 이름 또는 클래스
|
|
58
|
+
* @param {object} data - handle(data) 에 전달
|
|
59
|
+
* @param {object} [opts]
|
|
60
|
+
*/
|
|
61
|
+
dispatch(taskOrName, data, opts = {}) {
|
|
62
|
+
const name = typeof taskOrName === 'string' ? taskOrName : taskOrName.name;
|
|
63
|
+
this._queue.push({ name, data, opts, retries: 0 });
|
|
64
|
+
|
|
65
|
+
if (!this._processing) {
|
|
66
|
+
this._process();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 큐 처리 루프 — 비어있을 때까지 순차 처리.
|
|
72
|
+
*
|
|
73
|
+
* @private
|
|
74
|
+
*/
|
|
75
|
+
async _process() {
|
|
76
|
+
this._processing = true;
|
|
77
|
+
|
|
78
|
+
while (this._queue.length > 0) {
|
|
79
|
+
const job = this._queue.shift();
|
|
80
|
+
const TaskClass = this._tasks.get(job.name);
|
|
81
|
+
|
|
82
|
+
if (!TaskClass) {
|
|
83
|
+
this.app?.logger?.error?.(`[Queue] Task '${job.name}' not registered`);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const timeout = TaskClass.timeout || 30000;
|
|
88
|
+
const task = new TaskClass(this.app);
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
let timer;
|
|
92
|
+
await Promise.race([
|
|
93
|
+
task.handle(job.data).finally(() => clearTimeout(timer)),
|
|
94
|
+
new Promise((_, reject) => {
|
|
95
|
+
timer = setTimeout(() => reject(new Error(`Task timeout (${timeout}ms)`)), timeout);
|
|
96
|
+
}),
|
|
97
|
+
]);
|
|
98
|
+
} catch (err) {
|
|
99
|
+
job.retries++;
|
|
100
|
+
if (job.retries < (TaskClass.retries || 3)) {
|
|
101
|
+
const delay = TaskClass.retryDelay || 1000;
|
|
102
|
+
setTimeout(() => {
|
|
103
|
+
this._queue.push(job);
|
|
104
|
+
if (!this._processing) this._process();
|
|
105
|
+
}, delay);
|
|
106
|
+
} else {
|
|
107
|
+
try { await task.failed(job.data, err); } catch {}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
this._processing = false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 남은 큐 길이 (대기 중인 Task 수).
|
|
117
|
+
*
|
|
118
|
+
* @returns {number}
|
|
119
|
+
*/
|
|
120
|
+
get pending() {
|
|
121
|
+
return this._queue.length;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* graceful shutdown — 처리 중 작업 완료 대기.
|
|
126
|
+
* memory driver 는 _processing 가 false 될 때까지 polling.
|
|
127
|
+
*
|
|
128
|
+
* @param {number} [timeoutMs=10000]
|
|
129
|
+
* @returns {Promise<void>}
|
|
130
|
+
*/
|
|
131
|
+
async stop(timeoutMs = 10000) {
|
|
132
|
+
const start = Date.now();
|
|
133
|
+
while (this._processing && Date.now() - start < timeoutMs) {
|
|
134
|
+
await new Promise(r => setTimeout(r, 100));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RedisQueueDriver — Redis 기반 Task 큐 드라이버
|
|
3
|
+
*
|
|
4
|
+
* ioredis 를 사용한 영구 큐. 멀티워커/재시작 안전.
|
|
5
|
+
*
|
|
6
|
+
* 자료구조 (prefix 기본 'fuzionx:queue'):
|
|
7
|
+
* {prefix}:{queueName}:pending ─ List (LPUSH/BRPOP) — 즉시 처리 대기열
|
|
8
|
+
* {prefix}:{queueName}:scheduled ─ Sorted Set (ZADD score=executeAtMs) — retry 지연 큐
|
|
9
|
+
* {prefix}:{queueName}:dead ─ List (LPUSH) — 최종 실패 (dead-letter)
|
|
10
|
+
* {prefix}:{queueName}:processing ─ List (RPOPLPUSH 으로 atomic 이동) — 처리 중 (crash 시 복구용, optional)
|
|
11
|
+
*
|
|
12
|
+
* 워커 흐름:
|
|
13
|
+
* 1. BRPOP pending (blocking, timeout 5s)
|
|
14
|
+
* 2. Task.handle(data) 실행 (TaskClass.timeout 안)
|
|
15
|
+
* 3. 성공 → 완료
|
|
16
|
+
* 4. 실패 → retries 미만이면 ZADD scheduled (executeAt = now + retryDelay)
|
|
17
|
+
* → 도달 시 별도 promoter loop 가 ZRANGEBYSCORE 로 추출 후 LPUSH pending
|
|
18
|
+
* 5. 최종 실패 → LPUSH dead + Task.failed() 호출
|
|
19
|
+
*
|
|
20
|
+
* 클라이언트 분리:
|
|
21
|
+
* - this._client: 일반 명령 (LPUSH/ZADD/...)
|
|
22
|
+
* - this._blockingClient: BRPOP 전용 (blocking 명령이 일반 명령 차단 회피)
|
|
23
|
+
*
|
|
24
|
+
* @see lib/schedule/Queue.js
|
|
25
|
+
* @see lib/schedule/drivers/MemoryQueueDriver.js
|
|
26
|
+
*/
|
|
27
|
+
export default class RedisQueueDriver {
|
|
28
|
+
/**
|
|
29
|
+
* @param {import('../../core/Application.js').default} app
|
|
30
|
+
* @param {object} opts
|
|
31
|
+
* @param {string} opts.url - Redis 접속 URL
|
|
32
|
+
* @param {string} [opts.prefix='fuzionx:queue'] - 키 접두사
|
|
33
|
+
* @param {number} [opts.brpopTimeoutSec=5] - BRPOP timeout (초) — 짧을수록 graceful shutdown 빠름
|
|
34
|
+
* @param {number} [opts.promoterIntervalMs=1000] - scheduled → pending 승격 검사 주기
|
|
35
|
+
*/
|
|
36
|
+
constructor(app, opts = {}) {
|
|
37
|
+
this.app = app;
|
|
38
|
+
this._url = opts.url || 'redis://localhost:6379';
|
|
39
|
+
this._prefix = opts.prefix || 'fuzionx:queue';
|
|
40
|
+
this._brpopTimeoutSec = opts.brpopTimeoutSec ?? 5;
|
|
41
|
+
this._promoterIntervalMs = opts.promoterIntervalMs ?? 1000;
|
|
42
|
+
|
|
43
|
+
/** @type {Map<string, typeof import('../Task.js').default>} */
|
|
44
|
+
this._tasks = new Map();
|
|
45
|
+
|
|
46
|
+
/** @type {import('ioredis').default | null} 일반 명령용 */
|
|
47
|
+
this._client = null;
|
|
48
|
+
|
|
49
|
+
/** @type {import('ioredis').default | null} BRPOP blocking 용 (별도 connection) */
|
|
50
|
+
this._blockingClient = null;
|
|
51
|
+
|
|
52
|
+
/** @type {Set<string>} 활성 워커가 폴링 중인 큐 이름들 */
|
|
53
|
+
this._activeQueues = new Set();
|
|
54
|
+
|
|
55
|
+
/** @type {boolean} stop() 호출 후 워커 종료 신호 */
|
|
56
|
+
this._stopping = false;
|
|
57
|
+
|
|
58
|
+
/** @type {NodeJS.Timeout|null} scheduled → pending 승격 timer */
|
|
59
|
+
this._promoterTimer = null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 큐 키 helper */
|
|
63
|
+
_k(queue, suffix) { return `${this._prefix}:${queue}:${suffix}`; }
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 드라이버 초기화 — ioredis 연결 + 승격 worker 시작.
|
|
67
|
+
*
|
|
68
|
+
* @returns {Promise<void>}
|
|
69
|
+
*/
|
|
70
|
+
async start() {
|
|
71
|
+
const { default: Redis } = await import('ioredis');
|
|
72
|
+
const redisOpts = {
|
|
73
|
+
maxRetriesPerRequest: null, // blocking 명령 BRPOP 위해 null 필수
|
|
74
|
+
retryStrategy: (times) => {
|
|
75
|
+
if (times > 10) return null;
|
|
76
|
+
return Math.min(times * 200, 2000);
|
|
77
|
+
},
|
|
78
|
+
lazyConnect: false,
|
|
79
|
+
};
|
|
80
|
+
this._client = new Redis(this._url, redisOpts);
|
|
81
|
+
this._blockingClient = new Redis(this._url, redisOpts);
|
|
82
|
+
|
|
83
|
+
const onErr = (label) => (err) =>
|
|
84
|
+
this.app?.logger?.error?.(`[Queue:Redis:${label}] ${err.message}`);
|
|
85
|
+
this._client.on('error', onErr('cmd'));
|
|
86
|
+
this._blockingClient.on('error', onErr('blocking'));
|
|
87
|
+
|
|
88
|
+
// scheduled → pending 승격 loop
|
|
89
|
+
this._startPromoter();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Task 클래스 등록 + 해당 큐의 BRPOP worker 시작.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} name - Task 이름
|
|
96
|
+
* @param {typeof import('../Task.js').default} TaskClass
|
|
97
|
+
*/
|
|
98
|
+
register(name, TaskClass) {
|
|
99
|
+
this._tasks.set(name, TaskClass);
|
|
100
|
+
const queue = TaskClass.queue || 'default';
|
|
101
|
+
if (!this._activeQueues.has(queue)) {
|
|
102
|
+
this._activeQueues.add(queue);
|
|
103
|
+
this._startWorker(queue);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Task 디스패치 — pending 큐에 LPUSH.
|
|
109
|
+
*
|
|
110
|
+
* @param {string|Function} taskOrName
|
|
111
|
+
* @param {object} data
|
|
112
|
+
* @param {object} [opts]
|
|
113
|
+
*/
|
|
114
|
+
dispatch(taskOrName, data, opts = {}) {
|
|
115
|
+
const name = typeof taskOrName === 'string' ? taskOrName : taskOrName.name;
|
|
116
|
+
const TaskClass = this._tasks.get(name);
|
|
117
|
+
if (!TaskClass) {
|
|
118
|
+
this.app?.logger?.error?.(`[Queue:Redis] Task '${name}' not registered`);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const queue = TaskClass.queue || 'default';
|
|
122
|
+
const job = { name, data, opts, retries: 0, enqueued_at: Date.now() };
|
|
123
|
+
// LPUSH (FIFO 위해 worker 는 BRPOP — 오른쪽에서 pop)
|
|
124
|
+
this._client.lpush(this._k(queue, 'pending'), JSON.stringify(job))
|
|
125
|
+
.catch(err => this.app?.logger?.error?.(`[Queue:Redis] dispatch failed: ${err.message}`));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 큐별 BRPOP worker loop.
|
|
130
|
+
*
|
|
131
|
+
* @param {string} queue
|
|
132
|
+
* @private
|
|
133
|
+
*/
|
|
134
|
+
async _startWorker(queue) {
|
|
135
|
+
const pendingKey = this._k(queue, 'pending');
|
|
136
|
+
const deadKey = this._k(queue, 'dead');
|
|
137
|
+
|
|
138
|
+
while (!this._stopping) {
|
|
139
|
+
let result;
|
|
140
|
+
try {
|
|
141
|
+
// BRPOP returns [key, value] or null on timeout
|
|
142
|
+
result = await this._blockingClient.brpop(pendingKey, this._brpopTimeoutSec);
|
|
143
|
+
} catch (err) {
|
|
144
|
+
if (this._stopping) break;
|
|
145
|
+
this.app?.logger?.error?.(`[Queue:Redis] BRPOP error on '${queue}': ${err.message}`);
|
|
146
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (!result) continue; // timeout — 다시 BRPOP
|
|
150
|
+
|
|
151
|
+
let job;
|
|
152
|
+
try { job = JSON.parse(result[1]); }
|
|
153
|
+
catch (err) {
|
|
154
|
+
this.app?.logger?.error?.(`[Queue:Redis] bad job JSON: ${err.message}`);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const TaskClass = this._tasks.get(job.name);
|
|
159
|
+
if (!TaskClass) {
|
|
160
|
+
this.app?.logger?.error?.(`[Queue:Redis] Task '${job.name}' not registered (queue=${queue})`);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const timeout = TaskClass.timeout || 30000;
|
|
165
|
+
const task = new TaskClass(this.app);
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
let timer;
|
|
169
|
+
await Promise.race([
|
|
170
|
+
task.handle(job.data).finally(() => clearTimeout(timer)),
|
|
171
|
+
new Promise((_, reject) => {
|
|
172
|
+
timer = setTimeout(() => reject(new Error(`Task timeout (${timeout}ms)`)), timeout);
|
|
173
|
+
}),
|
|
174
|
+
]);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
job.retries = (job.retries || 0) + 1;
|
|
177
|
+
if (job.retries < (TaskClass.retries || 3)) {
|
|
178
|
+
// 지수 백오프 또는 fixed — Task 가 지정한 retryDelay 사용 + backoff factor 2x
|
|
179
|
+
const baseDelay = TaskClass.retryDelay || 1000;
|
|
180
|
+
const delay = baseDelay * Math.pow(2, job.retries - 1);
|
|
181
|
+
const executeAt = Date.now() + delay;
|
|
182
|
+
job.last_error = String(err?.message || err);
|
|
183
|
+
await this._client.zadd(
|
|
184
|
+
this._k(queue, 'scheduled'),
|
|
185
|
+
executeAt,
|
|
186
|
+
JSON.stringify(job),
|
|
187
|
+
).catch(e => this.app?.logger?.error?.(`[Queue:Redis] zadd failed: ${e.message}`));
|
|
188
|
+
} else {
|
|
189
|
+
// dead-letter
|
|
190
|
+
job.last_error = String(err?.message || err);
|
|
191
|
+
job.dead_at = Date.now();
|
|
192
|
+
await this._client.lpush(deadKey, JSON.stringify(job))
|
|
193
|
+
.catch(e => this.app?.logger?.error?.(`[Queue:Redis] dead push failed: ${e.message}`));
|
|
194
|
+
try { await task.failed(job.data, err); } catch {}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* scheduled → pending 승격 loop.
|
|
202
|
+
* ZRANGEBYSCORE 로 due-time 도달한 job 추출 후 LPUSH pending.
|
|
203
|
+
*
|
|
204
|
+
* @private
|
|
205
|
+
*/
|
|
206
|
+
_startPromoter() {
|
|
207
|
+
const tick = async () => {
|
|
208
|
+
if (this._stopping) return;
|
|
209
|
+
try {
|
|
210
|
+
for (const queue of this._activeQueues) {
|
|
211
|
+
const schedKey = this._k(queue, 'scheduled');
|
|
212
|
+
const pendingKey = this._k(queue, 'pending');
|
|
213
|
+
const now = Date.now();
|
|
214
|
+
|
|
215
|
+
// ZRANGEBYSCORE -inf now LIMIT 0 100
|
|
216
|
+
const dueJobs = await this._client.zrangebyscore(schedKey, '-inf', now, 'LIMIT', 0, 100);
|
|
217
|
+
if (dueJobs.length === 0) continue;
|
|
218
|
+
|
|
219
|
+
// pipeline: ZREM + LPUSH atomic 묶음
|
|
220
|
+
const pipe = this._client.pipeline();
|
|
221
|
+
for (const raw of dueJobs) {
|
|
222
|
+
pipe.zrem(schedKey, raw);
|
|
223
|
+
pipe.lpush(pendingKey, raw);
|
|
224
|
+
}
|
|
225
|
+
await pipe.exec();
|
|
226
|
+
}
|
|
227
|
+
} catch (err) {
|
|
228
|
+
this.app?.logger?.error?.(`[Queue:Redis] promoter error: ${err.message}`);
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
this._promoterTimer = setInterval(tick, this._promoterIntervalMs);
|
|
232
|
+
// unref — 프로세스 종료를 막지 않음
|
|
233
|
+
if (this._promoterTimer.unref) this._promoterTimer.unref();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* 대기 중 작업 수 (모든 활성 큐 합계).
|
|
238
|
+
* 비동기지만 동기 getter 형태 유지 위해 캐시 X — 정확한 수는 pendingAsync() 사용.
|
|
239
|
+
*
|
|
240
|
+
* @returns {number} 0 (sync 정확값 미지원 — Application 의 graceful drain 용)
|
|
241
|
+
*/
|
|
242
|
+
get pending() {
|
|
243
|
+
return 0;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* 정확한 pending 수 (모든 큐 합계).
|
|
248
|
+
*
|
|
249
|
+
* @returns {Promise<number>}
|
|
250
|
+
*/
|
|
251
|
+
async pendingAsync() {
|
|
252
|
+
let total = 0;
|
|
253
|
+
for (const queue of this._activeQueues) {
|
|
254
|
+
const len = await this._client.llen(this._k(queue, 'pending'));
|
|
255
|
+
total += len;
|
|
256
|
+
}
|
|
257
|
+
return total;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* graceful shutdown.
|
|
262
|
+
* 1. _stopping 신호 → worker loop 종료
|
|
263
|
+
* 2. blocking client 강제 종료 (BRPOP 대기 중인 client 깨움)
|
|
264
|
+
* 3. promoter timer clear
|
|
265
|
+
* 4. 연결 종료
|
|
266
|
+
*
|
|
267
|
+
* @param {number} [timeoutMs=10000]
|
|
268
|
+
*/
|
|
269
|
+
async stop(timeoutMs = 10000) {
|
|
270
|
+
this._stopping = true;
|
|
271
|
+
if (this._promoterTimer) {
|
|
272
|
+
clearInterval(this._promoterTimer);
|
|
273
|
+
this._promoterTimer = null;
|
|
274
|
+
}
|
|
275
|
+
try {
|
|
276
|
+
// disconnect 로 BRPOP 깨워 worker loop 종료 유도
|
|
277
|
+
if (this._blockingClient) this._blockingClient.disconnect();
|
|
278
|
+
} catch {}
|
|
279
|
+
try {
|
|
280
|
+
if (this._client) await this._client.quit();
|
|
281
|
+
} catch {}
|
|
282
|
+
this._client = null;
|
|
283
|
+
this._blockingClient = null;
|
|
284
|
+
}
|
|
285
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fuzionx/framework",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.77",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Full-stack MVC framework built on @fuzionx/core — Controller, Service, Model, Middleware, DI, EventBus",
|
|
6
6
|
"main": "index.js",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@aws-sdk/client-s3": "^3.1028.0",
|
|
38
|
-
"@fuzionx/core": "^0.1.
|
|
38
|
+
"@fuzionx/core": "^0.1.77",
|
|
39
39
|
"better-sqlite3": "^12.8.0",
|
|
40
40
|
"knex": "^3.2.5",
|
|
41
41
|
"mongoose": "^9.3.2",
|