@fuzionx/framework 0.1.75 → 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 +177 -29
- package/lib/core/Base.js +13 -0
- package/lib/core/Context.js +6 -1
- package/lib/http/ErrorHandler.js +47 -12
- package/lib/http/Validation.js +67 -24
- package/lib/middleware/apiAuth.js +18 -16
- package/lib/middleware/auth.js +10 -4
- package/lib/middleware/csrf.js +5 -2
- package/lib/middleware/roleGuard.js +10 -9
- 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/lib/services/Service.js +22 -14
- package/package.json +2 -2
|
@@ -3,13 +3,17 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Authorization: Bearer <token> → 검증 → ctx.user
|
|
5
5
|
*
|
|
6
|
+
* 인증 실패 시 ServiceError(401) throw → ErrorHandler가 Accept 협상으로 응답.
|
|
7
|
+
*
|
|
6
8
|
* @see docs/framework/14-authentication.md
|
|
9
|
+
* @see docs/framework/08-error-handling.md
|
|
7
10
|
*
|
|
8
11
|
* @param {object} [opts]
|
|
9
12
|
* @param {string} [opts.secret] - JWT 시크릿
|
|
10
13
|
* @param {string} [opts.model='User']
|
|
11
14
|
*/
|
|
12
15
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
16
|
+
import { ServiceError } from '../core/AppError.js';
|
|
13
17
|
|
|
14
18
|
export function apiAuth(opts = {}) {
|
|
15
19
|
const modelName = opts.model || 'User';
|
|
@@ -18,29 +22,27 @@ export function apiAuth(opts = {}) {
|
|
|
18
22
|
const authHeader = ctx.get('authorization') || '';
|
|
19
23
|
|
|
20
24
|
if (!authHeader.startsWith('Bearer ')) {
|
|
21
|
-
|
|
22
|
-
return;
|
|
25
|
+
throw new ServiceError('Token required', 401);
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
const token = authHeader.slice(7);
|
|
29
|
+
const secret = opts.secret || ctx.app?.config?.get('app.auth.secret', 'fuzionx');
|
|
26
30
|
|
|
31
|
+
let payload;
|
|
27
32
|
try {
|
|
28
|
-
|
|
29
|
-
|
|
33
|
+
payload = decodeJwtPayload(token, secret);
|
|
34
|
+
} catch {
|
|
35
|
+
throw new ServiceError('Invalid token', 401);
|
|
36
|
+
}
|
|
30
37
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
38
|
+
if (!payload || !payload.sub) {
|
|
39
|
+
throw new ServiceError('Invalid token', 401);
|
|
40
|
+
}
|
|
35
41
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
} catch {
|
|
42
|
-
ctx.status(401).json({ error: { message: 'Invalid token', status: 401 } });
|
|
43
|
-
return;
|
|
42
|
+
if (ctx.app?.db?.[modelName]) {
|
|
43
|
+
ctx.user = await ctx.app.db[modelName].find(payload.sub);
|
|
44
|
+
} else {
|
|
45
|
+
ctx.user = { id: payload.sub, ...payload };
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
await next();
|
package/lib/middleware/auth.js
CHANGED
|
@@ -3,13 +3,20 @@
|
|
|
3
3
|
*
|
|
4
4
|
* ctx.session.userId → db.User.find() → ctx.user
|
|
5
5
|
*
|
|
6
|
+
* 미인증 처리:
|
|
7
|
+
* - redirectTo 지정 시: 해당 URL로 302 리다이렉트 (HTML 라우트용)
|
|
8
|
+
* - 미지정 시: ServiceError(401) throw → ErrorHandler가 Accept 협상으로 응답 형식 결정
|
|
9
|
+
*
|
|
6
10
|
* @see docs/framework/14-authentication.md
|
|
11
|
+
* @see docs/framework/08-error-handling.md
|
|
7
12
|
*
|
|
8
13
|
* @param {object} [opts]
|
|
9
14
|
* @param {string} [opts.sessionKey='userId']
|
|
10
15
|
* @param {string} [opts.model='User']
|
|
11
|
-
* @param {string} [opts.redirectTo
|
|
16
|
+
* @param {string} [opts.redirectTo] - 미지정 시 401 throw, 지정 시 해당 URL로 redirect
|
|
12
17
|
*/
|
|
18
|
+
import { ServiceError } from '../core/AppError.js';
|
|
19
|
+
|
|
13
20
|
export function auth(opts = {}) {
|
|
14
21
|
const sessionKey = opts.sessionKey || 'userId';
|
|
15
22
|
const modelName = opts.model || 'User';
|
|
@@ -21,10 +28,9 @@ export function auth(opts = {}) {
|
|
|
21
28
|
if (!userId) {
|
|
22
29
|
if (redirectTo) {
|
|
23
30
|
ctx.redirect(redirectTo);
|
|
24
|
-
|
|
25
|
-
ctx.status(401).json({ error: { message: 'Unauthorized', status: 401 } });
|
|
31
|
+
return;
|
|
26
32
|
}
|
|
27
|
-
|
|
33
|
+
throw new ServiceError('Unauthorized', 401);
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
if (ctx.app?.db?.[modelName]) {
|
package/lib/middleware/csrf.js
CHANGED
|
@@ -2,13 +2,17 @@
|
|
|
2
2
|
* csrf — CSRF 보호 미들웨어
|
|
3
3
|
*
|
|
4
4
|
* GET/HEAD/OPTIONS 는 통과, 나머지는 토큰 검증.
|
|
5
|
+
* 토큰 불일치 시 ServiceError(403) throw → ErrorHandler가 Accept 협상으로 응답.
|
|
5
6
|
*
|
|
6
7
|
* @see docs/framework/12-middleware.md
|
|
8
|
+
* @see docs/framework/08-error-handling.md
|
|
7
9
|
*
|
|
8
10
|
* @param {object} [opts]
|
|
9
11
|
* @param {string} [opts.headerName='x-csrf-token']
|
|
10
12
|
* @param {string} [opts.sessionKey='_csrfToken']
|
|
11
13
|
*/
|
|
14
|
+
import { ServiceError } from '../core/AppError.js';
|
|
15
|
+
|
|
12
16
|
export function csrf(opts = {}) {
|
|
13
17
|
const headerName = opts.headerName || 'x-csrf-token';
|
|
14
18
|
const sessionKey = opts.sessionKey || '_csrfToken';
|
|
@@ -23,8 +27,7 @@ export function csrf(opts = {}) {
|
|
|
23
27
|
const expected = ctx._rawReq?.session?.[sessionKey];
|
|
24
28
|
|
|
25
29
|
if (!token || !expected || token !== expected) {
|
|
26
|
-
|
|
27
|
-
return;
|
|
30
|
+
throw new ServiceError('CSRF token mismatch', 403);
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
await next();
|
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
* `auth()` 미들웨어 이후에 사용.
|
|
5
5
|
* ctx.user.role_code가 허용된 역할 목록에 포함되는지 검사.
|
|
6
6
|
*
|
|
7
|
+
* 거부 시 ServiceError throw → ErrorHandler가 Accept 협상으로 응답.
|
|
8
|
+
* - 미인증: 401
|
|
9
|
+
* - 권한 부족: 403
|
|
10
|
+
*
|
|
7
11
|
* @example
|
|
8
12
|
* // 관리자 백오피스: developer, admin만 허용
|
|
9
13
|
* r.group('/api', { middleware: [auth(), roleGuard(['developer', 'admin'])] }, (r) => { ... });
|
|
@@ -12,6 +16,7 @@
|
|
|
12
16
|
* r.group('/api', { middleware: [auth(), roleGuard(['developer', 'admin', 'reseller'])] }, (r) => { ... });
|
|
13
17
|
*
|
|
14
18
|
* @see docs/framework/14-authentication.md
|
|
19
|
+
* @see docs/framework/08-error-handling.md
|
|
15
20
|
*
|
|
16
21
|
* @param {string[]} allowedRoles - 접근 허용 역할 코드 배열
|
|
17
22
|
* @param {object} [opts]
|
|
@@ -19,6 +24,8 @@
|
|
|
19
24
|
* @param {string} [opts.message='auth.role_denied'] - 거부 시 에러 메시지 키
|
|
20
25
|
* @returns {Function} 미들웨어 함수
|
|
21
26
|
*/
|
|
27
|
+
import { ServiceError } from '../core/AppError.js';
|
|
28
|
+
|
|
22
29
|
export function roleGuard(allowedRoles, opts = {}) {
|
|
23
30
|
const roleField = opts.roleField || 'role_code';
|
|
24
31
|
const message = opts.message || 'auth.role_denied';
|
|
@@ -26,8 +33,7 @@ export function roleGuard(allowedRoles, opts = {}) {
|
|
|
26
33
|
return async (ctx, next) => {
|
|
27
34
|
/** 인증되지 않은 사용자 */
|
|
28
35
|
if (!ctx.user) {
|
|
29
|
-
|
|
30
|
-
return;
|
|
36
|
+
throw new ServiceError('Unauthorized', 401);
|
|
31
37
|
}
|
|
32
38
|
|
|
33
39
|
/** 역할 코드 추출 */
|
|
@@ -35,13 +41,8 @@ export function roleGuard(allowedRoles, opts = {}) {
|
|
|
35
41
|
|
|
36
42
|
/** 허용된 역할인지 확인 */
|
|
37
43
|
if (!userRole || !allowedRoles.includes(userRole)) {
|
|
38
|
-
ctx.
|
|
39
|
-
|
|
40
|
-
message: ctx.t ? ctx.t(message) : message,
|
|
41
|
-
status: 403,
|
|
42
|
-
},
|
|
43
|
-
});
|
|
44
|
-
return;
|
|
44
|
+
const msg = (typeof ctx.t === 'function') ? ctx.t(message) : message;
|
|
45
|
+
throw new ServiceError(msg, 403);
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
await next();
|
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
|
+
}
|