@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
|
@@ -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/lib/services/Service.js
CHANGED
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
* @see docs/framework/class-design.mm.md (Service)
|
|
9
9
|
*/
|
|
10
10
|
import Base from '../core/Base.js';
|
|
11
|
-
import { ServiceError } from '../core/AppError.js';
|
|
12
11
|
|
|
13
12
|
/** @type {Map<string, { value: *, expires: number }>} 전역 캐시 스토어 */
|
|
14
13
|
const _cache = new Map();
|
|
@@ -141,6 +140,28 @@ export default class Service extends Base {
|
|
|
141
140
|
return cached ? cached.expires > Date.now() : false;
|
|
142
141
|
}
|
|
143
142
|
|
|
143
|
+
/**
|
|
144
|
+
* 접두어 기반 캐시 일괄 무효화
|
|
145
|
+
*
|
|
146
|
+
* prefix로 시작하는 모든 캐시 키를 삭제한다.
|
|
147
|
+
* 예: 'reseller:tree:' → 'reseller:tree:roots', 'reseller:tree:full', 'reseller:tree:children:5' 등 모두 삭제
|
|
148
|
+
*
|
|
149
|
+
* @param {string} prefix - 캐시 키 접두어
|
|
150
|
+
*/
|
|
151
|
+
async invalidateCacheByPrefix(prefix) {
|
|
152
|
+
// 인메모리 Map — prefix로 시작하는 키 일괄 삭제
|
|
153
|
+
for (const key of _cache.keys()) {
|
|
154
|
+
if (key.startsWith(prefix)) {
|
|
155
|
+
_cache.delete(key);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// CacheManager — deleteByPrefix 지원 시 위임
|
|
160
|
+
if (this.cache && typeof this.cache.deleteByPrefix === 'function') {
|
|
161
|
+
await this.cache.deleteByPrefix(prefix);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
144
165
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
145
166
|
// 병렬 실행
|
|
146
167
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
@@ -257,19 +278,6 @@ export default class Service extends Base {
|
|
|
257
278
|
}
|
|
258
279
|
}
|
|
259
280
|
|
|
260
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
261
|
-
// 에러
|
|
262
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
263
|
-
|
|
264
|
-
/**
|
|
265
|
-
* 에러 생성 헬퍼
|
|
266
|
-
* @param {string} message
|
|
267
|
-
* @param {number} [status=400]
|
|
268
|
-
* @returns {ServiceError}
|
|
269
|
-
*/
|
|
270
|
-
error(message, status = 400) {
|
|
271
|
-
return new ServiceError(message, status);
|
|
272
|
-
}
|
|
273
281
|
}
|
|
274
282
|
|
|
275
283
|
/**
|
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",
|