@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.
@@ -9,7 +9,7 @@
9
9
  "preview": "vite preview"
10
10
  },
11
11
  "dependencies": {
12
- "@fuzionx/player": "^0.1.76",
12
+ "@fuzionx/player": "^0.1.78",
13
13
  "pinia": "^3.0.4",
14
14
  "vue": "^3.5.0",
15
15
  "vue-router": "^4.5.0"
@@ -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
- this._scheduler = new Scheduler(this);
304
- this._queue = new Queue(this, { driver: this.config.get('queue.driver', 'memory') });
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
- if (this._scheduler && this._scheduler._jobs.length > 0 && !cluster.isWorker) {
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
- try {
118
- const MongooseModel = this._getMongooseModel();
119
- const doc = await MongooseModel.create(data);
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
- try {
134
- const MongooseModel = this._getMongooseModel();
135
- const doc = await MongooseModel.findById(id);
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
- try {
148
- const MongooseModel = this._getMongooseModel();
149
- const docs = await MongooseModel.find({});
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
- try {
165
- const MongooseModel = this._getMongooseModel();
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
- try {
199
- const MongooseModel = this.constructor._getMongooseModel();
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
- try {
228
- const MongooseModel = this.constructor._getMongooseModel();
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
- results = [];
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
- return 0;
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
- return 0;
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
- return 0;
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 { await fs.access(filePath); return true; } catch { return false; }
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) {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Queue — Task 큐 관리 (Memory/Redis)
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
- this._tasks = new Map(); // name → TaskClass
23
- this._queue = [];
24
- this._processing = false;
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._tasks.set(name, TaskClass);
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
- const name = typeof taskOrName === 'string' ? taskOrName : taskOrName.name;
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
- * @private
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
- async _process() {
57
- this._processing = true;
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
- * @returns {number}
94
+ * graceful shutdown.
95
+ *
96
+ * @param {number} [timeoutMs=10000]
99
97
  */
100
- get pending() {
101
- return this._queue.length;
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
+ }
@@ -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.76",
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.76",
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",