@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.
@@ -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
+ }
@@ -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.75",
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.75",
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",