@fuzionx/framework 0.1.76 → 0.1.78
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/database/MongoModel.js +39 -38
- package/lib/database/SqlQueryBuilder.js +25 -4
- package/lib/helpers/FileHelper.js +17 -1
- package/lib/schedule/Queue.js +58 -58
- package/lib/schedule/Scheduler.js +73 -3
- package/lib/schedule/drivers/MemoryQueueDriver.js +146 -0
- package/lib/schedule/drivers/RedisQueueDriver.js +294 -0
- package/lib/services/Storage.js +12 -6
- 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) {
|
|
@@ -110,63 +110,60 @@ export default class MongoModel extends Model {
|
|
|
110
110
|
|
|
111
111
|
/**
|
|
112
112
|
* 레코드 생성
|
|
113
|
+
*
|
|
114
|
+
* 실패 시 예외 throw — silent stub 반환 X.
|
|
115
|
+
* 응용이 "INSERT 성공한 줄 알고" 진행하는 디버깅 지옥 차단.
|
|
113
116
|
* @param {object} data
|
|
114
117
|
* @returns {Promise<MongoModel>}
|
|
118
|
+
* @throws {Error} mongoose 미설치 / 연결 실패 / 검증 실패 / 키 충돌 등
|
|
115
119
|
*/
|
|
116
120
|
static async create(data) {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
return new this(doc.toObject());
|
|
121
|
-
} catch {
|
|
122
|
-
// Mongoose 미설치 → stub
|
|
123
|
-
return new this({ ...data, _id: Date.now().toString(36) });
|
|
124
|
-
}
|
|
121
|
+
const MongooseModel = this._getMongooseModel();
|
|
122
|
+
const doc = await MongooseModel.create(data);
|
|
123
|
+
return new this(doc.toObject());
|
|
125
124
|
}
|
|
126
125
|
|
|
127
126
|
/**
|
|
128
127
|
* ID로 조회
|
|
128
|
+
*
|
|
129
|
+
* 레코드가 실제로 없으면 null 반환. 연결/쿼리 오류는 throw — silent null 반환 X.
|
|
130
|
+
* "USER_NOT_FOUND" 가 실제로 미존재인지 연결 장애인지 구분 가능.
|
|
129
131
|
* @param {*} id
|
|
130
132
|
* @returns {Promise<MongoModel|null>}
|
|
133
|
+
* @throws {Error} mongoose 미설치 / 연결 실패 / 쿼리 오류 등
|
|
131
134
|
*/
|
|
132
135
|
static async find(id) {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
return doc ? new this(doc.toObject()) : null;
|
|
137
|
-
} catch {
|
|
138
|
-
return null;
|
|
139
|
-
}
|
|
136
|
+
const MongooseModel = this._getMongooseModel();
|
|
137
|
+
const doc = await MongooseModel.findById(id);
|
|
138
|
+
return doc ? new this(doc.toObject()) : null;
|
|
140
139
|
}
|
|
141
140
|
|
|
142
141
|
/**
|
|
143
142
|
* 전체 조회
|
|
143
|
+
*
|
|
144
|
+
* 결과 없으면 빈 배열 반환. 연결/쿼리 오류는 throw — silent [] 반환 X.
|
|
144
145
|
* @returns {Promise<Array>}
|
|
146
|
+
* @throws {Error} mongoose 미설치 / 연결 실패 / 쿼리 오류 등
|
|
145
147
|
*/
|
|
146
148
|
static async all() {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
return docs.map(d => new this(d.toObject()));
|
|
151
|
-
} catch {
|
|
152
|
-
return [];
|
|
153
|
-
}
|
|
149
|
+
const MongooseModel = this._getMongooseModel();
|
|
150
|
+
const docs = await MongooseModel.find({});
|
|
151
|
+
return docs.map(d => new this(d.toObject()));
|
|
154
152
|
}
|
|
155
153
|
|
|
156
154
|
static async findAll() { return this.all(); }
|
|
157
155
|
|
|
158
156
|
/**
|
|
159
157
|
* Raw aggregation pipeline
|
|
158
|
+
*
|
|
159
|
+
* 파이프라인 실행 결과 반환. 오류 시 throw — silent [] 반환 X.
|
|
160
160
|
* @param {Array} pipeline
|
|
161
161
|
* @returns {Promise<Array>}
|
|
162
|
+
* @throws {Error} mongoose 미설치 / 연결 실패 / 파이프라인 오류 등
|
|
162
163
|
*/
|
|
163
164
|
static async raw(pipeline) {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
return MongooseModel.aggregate(pipeline);
|
|
167
|
-
} catch {
|
|
168
|
-
return [];
|
|
169
|
-
}
|
|
165
|
+
const MongooseModel = this._getMongooseModel();
|
|
166
|
+
return MongooseModel.aggregate(pipeline);
|
|
170
167
|
}
|
|
171
168
|
|
|
172
169
|
/**
|
|
@@ -185,8 +182,13 @@ export default class MongoModel extends Model {
|
|
|
185
182
|
|
|
186
183
|
/**
|
|
187
184
|
* 인스턴스 수정
|
|
185
|
+
*
|
|
186
|
+
* `_id` 가 없으면 no-op (메모리만 갱신). DB 수정 실패 시 throw —
|
|
187
|
+
* silent swallow 안 함. 응용이 "DB 저장됐다고 착각하는" 케이스 차단.
|
|
188
|
+
*
|
|
188
189
|
* @param {object} data
|
|
189
190
|
* @returns {Promise<this>}
|
|
191
|
+
* @throws {Error} mongoose 미설치 / 연결 실패 / 검증 실패 등
|
|
190
192
|
*/
|
|
191
193
|
async update(data) {
|
|
192
194
|
Object.assign(this._attributes, data);
|
|
@@ -195,16 +197,13 @@ export default class MongoModel extends Model {
|
|
|
195
197
|
const id = this._attributes._id || this._attributes[this.constructor.primaryKey];
|
|
196
198
|
if (!id) return this;
|
|
197
199
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
await MongooseModel.updateOne({ _id: id }, { $set: data });
|
|
201
|
-
} catch {}
|
|
202
|
-
|
|
200
|
+
const MongooseModel = this.constructor._getMongooseModel();
|
|
201
|
+
await MongooseModel.updateOne({ _id: id }, { $set: data });
|
|
203
202
|
return this;
|
|
204
203
|
}
|
|
205
204
|
|
|
206
205
|
/**
|
|
207
|
-
* 삭제
|
|
206
|
+
* 삭제 (soft delete 지원)
|
|
208
207
|
*/
|
|
209
208
|
async delete() {
|
|
210
209
|
if (this.constructor.softDelete) {
|
|
@@ -219,14 +218,16 @@ export default class MongoModel extends Model {
|
|
|
219
218
|
|
|
220
219
|
/**
|
|
221
220
|
* 실제 삭제
|
|
221
|
+
*
|
|
222
|
+
* `_id` 없으면 no-op. DB 삭제 오류 시 throw — silent swallow X.
|
|
223
|
+
*
|
|
224
|
+
* @throws {Error} mongoose 미설치 / 연결 실패 / 권한 오류 등
|
|
222
225
|
*/
|
|
223
226
|
async forceDelete() {
|
|
224
227
|
const id = this._attributes._id || this._attributes[this.constructor.primaryKey];
|
|
225
228
|
if (!id) return;
|
|
226
229
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
await MongooseModel.deleteOne({ _id: id });
|
|
230
|
-
} catch {}
|
|
230
|
+
const MongooseModel = this.constructor._getMongooseModel();
|
|
231
|
+
await MongooseModel.deleteOne({ _id: id });
|
|
231
232
|
}
|
|
232
233
|
}
|
|
@@ -29,7 +29,12 @@ export default class SqlQueryBuilder extends QueryBuilder {
|
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
31
|
* 결과 조회 — SQL SELECT 실행
|
|
32
|
+
*
|
|
33
|
+
* 지원 driver (sqlite/knex) 매칭 안 되거나 conn.db 비어 있으면 throw —
|
|
34
|
+
* silent [] 반환 X. driver 미지원/연결 미수립을 빈 결과로 착각하지 않게.
|
|
35
|
+
*
|
|
32
36
|
* @returns {Promise<Array<import('./Model.js').default>>}
|
|
37
|
+
* @throws {Error} 지원 driver 없음 / 연결 미수립
|
|
33
38
|
*/
|
|
34
39
|
async get() {
|
|
35
40
|
const conn = this._model.getConnection();
|
|
@@ -40,7 +45,9 @@ export default class SqlQueryBuilder extends QueryBuilder {
|
|
|
40
45
|
} else if (conn.type === 'knex' && conn.db) {
|
|
41
46
|
results = await this._executeKnex(conn.db, 'select');
|
|
42
47
|
} else {
|
|
43
|
-
|
|
48
|
+
throw new Error(
|
|
49
|
+
`[SqlQueryBuilder.get] '${this._model.table}' — unsupported driver or no connection (conn.type=${conn?.type}, conn.db=${!!conn?.db})`,
|
|
50
|
+
);
|
|
44
51
|
}
|
|
45
52
|
|
|
46
53
|
// eager-loading — with()로 지정된 관계 로드
|
|
@@ -63,7 +70,11 @@ export default class SqlQueryBuilder extends QueryBuilder {
|
|
|
63
70
|
|
|
64
71
|
/**
|
|
65
72
|
* 카운트
|
|
73
|
+
*
|
|
74
|
+
* 지원 driver 매칭 안 되면 throw — silent 0 반환 X.
|
|
75
|
+
*
|
|
66
76
|
* @returns {Promise<number>}
|
|
77
|
+
* @throws {Error} 지원 driver 없음 / 연결 미수립
|
|
67
78
|
*/
|
|
68
79
|
async count() {
|
|
69
80
|
const conn = this._model.getConnection();
|
|
@@ -83,7 +94,9 @@ export default class SqlQueryBuilder extends QueryBuilder {
|
|
|
83
94
|
return Number(cnt);
|
|
84
95
|
}
|
|
85
96
|
|
|
86
|
-
|
|
97
|
+
throw new Error(
|
|
98
|
+
`[SqlQueryBuilder.count] '${this._model.table}' — unsupported driver or no connection (conn.type=${conn?.type}, conn.db=${!!conn?.db})`,
|
|
99
|
+
);
|
|
87
100
|
}
|
|
88
101
|
|
|
89
102
|
/**
|
|
@@ -115,12 +128,18 @@ export default class SqlQueryBuilder extends QueryBuilder {
|
|
|
115
128
|
return query.update(data);
|
|
116
129
|
}
|
|
117
130
|
|
|
118
|
-
|
|
131
|
+
throw new Error(
|
|
132
|
+
`[SqlQueryBuilder.update] '${this._model.table}' — unsupported driver or no connection (conn.type=${conn?.type}, conn.db=${!!conn?.db})`,
|
|
133
|
+
);
|
|
119
134
|
}
|
|
120
135
|
|
|
121
136
|
/**
|
|
122
137
|
* 조건부 삭제
|
|
138
|
+
*
|
|
139
|
+
* 지원 driver 매칭 안 되면 throw — silent 0 반환 X.
|
|
140
|
+
*
|
|
123
141
|
* @returns {Promise<number>}
|
|
142
|
+
* @throws {Error} 지원 driver 없음 / 연결 미수립
|
|
124
143
|
*/
|
|
125
144
|
async delete() {
|
|
126
145
|
const conn = this._model.getConnection();
|
|
@@ -148,7 +167,9 @@ export default class SqlQueryBuilder extends QueryBuilder {
|
|
|
148
167
|
return query.delete();
|
|
149
168
|
}
|
|
150
169
|
|
|
151
|
-
|
|
170
|
+
throw new Error(
|
|
171
|
+
`[SqlQueryBuilder.delete] '${this._model.table}' — unsupported driver or no connection (conn.type=${conn?.type}, conn.db=${!!conn?.db})`,
|
|
172
|
+
);
|
|
152
173
|
}
|
|
153
174
|
|
|
154
175
|
/**
|
|
@@ -39,9 +39,25 @@ export default class FileHelper {
|
|
|
39
39
|
return stat.size;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* 파일 존재 여부.
|
|
44
|
+
*
|
|
45
|
+
* ENOENT (파일 없음) 만 false 반환. 권한 오류(EACCES) / 심볼 무한 루프(ELOOP) /
|
|
46
|
+
* IO 오류 등은 throw — silent false 로 가리지 않음.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} filePath
|
|
49
|
+
* @returns {Promise<boolean>}
|
|
50
|
+
* @throws {Error} ENOENT 외의 fs 오류
|
|
51
|
+
*/
|
|
42
52
|
async exists(filePath) {
|
|
43
53
|
if (this._bridge?.fileExists) return this._bridge.fileExists(filePath);
|
|
44
|
-
try {
|
|
54
|
+
try {
|
|
55
|
+
await fs.access(filePath);
|
|
56
|
+
return true;
|
|
57
|
+
} catch (err) {
|
|
58
|
+
if (err && err.code === 'ENOENT') return false;
|
|
59
|
+
throw err;
|
|
60
|
+
}
|
|
45
61
|
}
|
|
46
62
|
|
|
47
63
|
async remove(filePath) {
|
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,146 @@
|
|
|
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
|
+
// Task.failed 콜백 자체가 실패해도 워커 루프는 멈추면 안 됨 (fire-and-forget).
|
|
108
|
+
// 단 silent 하지 않게 logger.warn 으로 노출 — 디버깅 가능.
|
|
109
|
+
try { await task.failed(job.data, err); }
|
|
110
|
+
catch (failedErr) {
|
|
111
|
+
const log = this.app?.logger || console;
|
|
112
|
+
log.warn?.(
|
|
113
|
+
`[MemoryQueueDriver] ${TaskClass.name || 'Task'}.failed() callback threw — ` +
|
|
114
|
+
`original error: ${err?.message || err}; failed-callback error: ${failedErr?.message || failedErr}`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
this._processing = false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 남은 큐 길이 (대기 중인 Task 수).
|
|
126
|
+
*
|
|
127
|
+
* @returns {number}
|
|
128
|
+
*/
|
|
129
|
+
get pending() {
|
|
130
|
+
return this._queue.length;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* graceful shutdown — 처리 중 작업 완료 대기.
|
|
135
|
+
* memory driver 는 _processing 가 false 될 때까지 polling.
|
|
136
|
+
*
|
|
137
|
+
* @param {number} [timeoutMs=10000]
|
|
138
|
+
* @returns {Promise<void>}
|
|
139
|
+
*/
|
|
140
|
+
async stop(timeoutMs = 10000) {
|
|
141
|
+
const start = Date.now();
|
|
142
|
+
while (this._processing && Date.now() - start < timeoutMs) {
|
|
143
|
+
await new Promise(r => setTimeout(r, 100));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
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
|
+
// Task.failed 콜백 자체가 실패해도 워커 루프 유지 (fire-and-forget),
|
|
195
|
+
// 단 silent 하지 않게 logger.warn 으로 노출.
|
|
196
|
+
try { await task.failed(job.data, err); }
|
|
197
|
+
catch (failedErr) {
|
|
198
|
+
const log = this.app?.logger || console;
|
|
199
|
+
log.warn?.(
|
|
200
|
+
`[RedisQueueDriver] ${TaskClass.name || 'Task'}.failed() callback threw — ` +
|
|
201
|
+
`original error: ${err?.message || err}; failed-callback error: ${failedErr?.message || failedErr}`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* scheduled → pending 승격 loop.
|
|
211
|
+
* ZRANGEBYSCORE 로 due-time 도달한 job 추출 후 LPUSH pending.
|
|
212
|
+
*
|
|
213
|
+
* @private
|
|
214
|
+
*/
|
|
215
|
+
_startPromoter() {
|
|
216
|
+
const tick = async () => {
|
|
217
|
+
if (this._stopping) return;
|
|
218
|
+
try {
|
|
219
|
+
for (const queue of this._activeQueues) {
|
|
220
|
+
const schedKey = this._k(queue, 'scheduled');
|
|
221
|
+
const pendingKey = this._k(queue, 'pending');
|
|
222
|
+
const now = Date.now();
|
|
223
|
+
|
|
224
|
+
// ZRANGEBYSCORE -inf now LIMIT 0 100
|
|
225
|
+
const dueJobs = await this._client.zrangebyscore(schedKey, '-inf', now, 'LIMIT', 0, 100);
|
|
226
|
+
if (dueJobs.length === 0) continue;
|
|
227
|
+
|
|
228
|
+
// pipeline: ZREM + LPUSH atomic 묶음
|
|
229
|
+
const pipe = this._client.pipeline();
|
|
230
|
+
for (const raw of dueJobs) {
|
|
231
|
+
pipe.zrem(schedKey, raw);
|
|
232
|
+
pipe.lpush(pendingKey, raw);
|
|
233
|
+
}
|
|
234
|
+
await pipe.exec();
|
|
235
|
+
}
|
|
236
|
+
} catch (err) {
|
|
237
|
+
this.app?.logger?.error?.(`[Queue:Redis] promoter error: ${err.message}`);
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
this._promoterTimer = setInterval(tick, this._promoterIntervalMs);
|
|
241
|
+
// unref — 프로세스 종료를 막지 않음
|
|
242
|
+
if (this._promoterTimer.unref) this._promoterTimer.unref();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* 대기 중 작업 수 (모든 활성 큐 합계).
|
|
247
|
+
* 비동기지만 동기 getter 형태 유지 위해 캐시 X — 정확한 수는 pendingAsync() 사용.
|
|
248
|
+
*
|
|
249
|
+
* @returns {number} 0 (sync 정확값 미지원 — Application 의 graceful drain 용)
|
|
250
|
+
*/
|
|
251
|
+
get pending() {
|
|
252
|
+
return 0;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* 정확한 pending 수 (모든 큐 합계).
|
|
257
|
+
*
|
|
258
|
+
* @returns {Promise<number>}
|
|
259
|
+
*/
|
|
260
|
+
async pendingAsync() {
|
|
261
|
+
let total = 0;
|
|
262
|
+
for (const queue of this._activeQueues) {
|
|
263
|
+
const len = await this._client.llen(this._k(queue, 'pending'));
|
|
264
|
+
total += len;
|
|
265
|
+
}
|
|
266
|
+
return total;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* graceful shutdown.
|
|
271
|
+
* 1. _stopping 신호 → worker loop 종료
|
|
272
|
+
* 2. blocking client 강제 종료 (BRPOP 대기 중인 client 깨움)
|
|
273
|
+
* 3. promoter timer clear
|
|
274
|
+
* 4. 연결 종료
|
|
275
|
+
*
|
|
276
|
+
* @param {number} [timeoutMs=10000]
|
|
277
|
+
*/
|
|
278
|
+
async stop(timeoutMs = 10000) {
|
|
279
|
+
this._stopping = true;
|
|
280
|
+
if (this._promoterTimer) {
|
|
281
|
+
clearInterval(this._promoterTimer);
|
|
282
|
+
this._promoterTimer = null;
|
|
283
|
+
}
|
|
284
|
+
try {
|
|
285
|
+
// disconnect 로 BRPOP 깨워 worker loop 종료 유도
|
|
286
|
+
if (this._blockingClient) this._blockingClient.disconnect();
|
|
287
|
+
} catch {}
|
|
288
|
+
try {
|
|
289
|
+
if (this._client) await this._client.quit();
|
|
290
|
+
} catch {}
|
|
291
|
+
this._client = null;
|
|
292
|
+
this._blockingClient = null;
|
|
293
|
+
}
|
|
294
|
+
}
|
package/lib/services/Storage.js
CHANGED
|
@@ -117,19 +117,25 @@ export default class Storage {
|
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
/**
|
|
120
|
-
* 파일 존재
|
|
120
|
+
* 파일 존재 여부.
|
|
121
|
+
*
|
|
122
|
+
* ENOENT (파일 없음) 만 false 반환. 권한 오류 / IO 오류 / S3 인증 오류 등은
|
|
123
|
+
* throw — silent false 로 가리지 않음.
|
|
124
|
+
*
|
|
121
125
|
* @param {string} filePath
|
|
122
126
|
* @returns {Promise<boolean>}
|
|
127
|
+
* @throws {Error} ENOENT 외의 fs/S3 오류
|
|
123
128
|
*/
|
|
124
129
|
async exists(filePath) {
|
|
130
|
+
if (this.driver === 's3') {
|
|
131
|
+
return this._s3Exists(filePath);
|
|
132
|
+
}
|
|
125
133
|
try {
|
|
126
|
-
if (this.driver === 's3') {
|
|
127
|
-
return this._s3Exists(filePath);
|
|
128
|
-
}
|
|
129
134
|
await fs.access(path.join(this.basePath, filePath));
|
|
130
135
|
return true;
|
|
131
|
-
} catch {
|
|
132
|
-
return false;
|
|
136
|
+
} catch (err) {
|
|
137
|
+
if (err && err.code === 'ENOENT') return false;
|
|
138
|
+
throw err;
|
|
133
139
|
}
|
|
134
140
|
}
|
|
135
141
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fuzionx/framework",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.78",
|
|
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.78",
|
|
39
39
|
"better-sqlite3": "^12.8.0",
|
|
40
40
|
"knex": "^3.2.5",
|
|
41
41
|
"mongoose": "^9.3.2",
|