mega-framework 0.1.7 → 0.1.8

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.
Files changed (76) hide show
  1. package/README.md +9 -0
  2. package/package.json +3 -3
  3. package/sample/crud/.env +9 -0
  4. package/sample/crud/.env.example +9 -0
  5. package/sample/crud/apps/main/locales/server/en.json +12 -1
  6. package/sample/crud/apps/main/locales/server/ko.json +12 -1
  7. package/sample/crud/mega.config.js +7 -0
  8. package/sample/crud/package.json +2 -2
  9. package/sample/crud/scripts/start-ws-hub.sh +18 -4
  10. package/sample/simple/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  11. package/src/adapters/adapter-options.js +14 -3
  12. package/src/adapters/file-adapter.js +9 -5
  13. package/src/adapters/file-session-adapter.js +4 -3
  14. package/src/adapters/maria-adapter.js +7 -4
  15. package/src/adapters/mega-cache-adapter.js +83 -6
  16. package/src/adapters/mega-db-adapter.js +4 -1
  17. package/src/adapters/mongo-adapter.js +21 -7
  18. package/src/adapters/postgres-adapter.js +8 -4
  19. package/src/adapters/redis-adapter.js +7 -3
  20. package/src/adapters/sqlite-adapter.js +6 -2
  21. package/src/cli/commands/console-cmd.js +3 -1
  22. package/src/cli/commands/scaffold.js +38 -2
  23. package/src/cli/generators/index.js +58 -1
  24. package/src/cli/index.js +88 -59
  25. package/src/cli/watch.js +188 -0
  26. package/src/core/ajv-mapper.js +3 -1
  27. package/src/core/ctx-builder.js +59 -1
  28. package/src/core/envelope.js +9 -2
  29. package/src/core/hub-link.js +24 -14
  30. package/src/core/index.js +1 -1
  31. package/src/core/mega-app.js +55 -45
  32. package/src/core/pipeline.js +8 -6
  33. package/src/core/scope-registry.js +1 -0
  34. package/src/core/security.js +3 -3
  35. package/src/core/session-store.js +14 -1
  36. package/src/core/ws-presence.js +17 -5
  37. package/src/core/ws-roster.js +49 -10
  38. package/src/core/ws-upgrade.js +105 -0
  39. package/src/lib/mega-circuit-breaker.js +5 -3
  40. package/src/lib/mega-health.js +10 -0
  41. package/src/lib/mega-job-queue.js +53 -13
  42. package/src/lib/mega-job.js +8 -1
  43. package/src/lib/mega-metrics.js +28 -1
  44. package/src/lib/mega-plugin.js +2 -2
  45. package/src/lib/mega-worker.js +28 -5
  46. package/src/lib/ws-hub.js +90 -9
  47. package/templates/adr/code.tpl +23 -0
  48. package/types/adapters/adapter-options.d.ts +2 -0
  49. package/types/adapters/file-adapter.d.ts +12 -1
  50. package/types/adapters/file-session-adapter.d.ts +4 -2
  51. package/types/adapters/maria-adapter.d.ts +5 -3
  52. package/types/adapters/mega-cache-adapter.d.ts +27 -1
  53. package/types/adapters/mega-db-adapter.d.ts +4 -1
  54. package/types/adapters/mongo-adapter.d.ts +13 -2
  55. package/types/adapters/postgres-adapter.d.ts +4 -2
  56. package/types/adapters/redis-adapter.d.ts +8 -0
  57. package/types/adapters/sqlite-adapter.d.ts +8 -2
  58. package/types/cli/generators/index.d.ts +11 -1
  59. package/types/cli/index.d.ts +12 -27
  60. package/types/cli/watch.d.ts +59 -0
  61. package/types/core/ctx-builder.d.ts +23 -0
  62. package/types/core/hub-link.d.ts +3 -1
  63. package/types/core/index.d.ts +1 -1
  64. package/types/core/mega-app.d.ts +1 -1
  65. package/types/core/pipeline.d.ts +2 -1
  66. package/types/core/security.d.ts +3 -3
  67. package/types/core/session-store.d.ts +7 -0
  68. package/types/core/ws-roster.d.ts +13 -1
  69. package/types/core/ws-upgrade.d.ts +29 -0
  70. package/types/lib/mega-circuit-breaker.d.ts +4 -2
  71. package/types/lib/mega-health.d.ts +7 -0
  72. package/types/lib/mega-job-queue.d.ts +16 -4
  73. package/types/lib/mega-job.d.ts +8 -1
  74. package/types/lib/mega-plugin.d.ts +1 -1
  75. package/types/lib/mega-worker.d.ts +3 -1
  76. package/types/lib/ws-hub.d.ts +27 -2
@@ -61,6 +61,104 @@ export const CLOSE_CODE_REQUEUE = 4503
61
61
  /** send 버퍼(bufferedAmount) 기본 상한(바이트). 초과 = 소비자가 ack 를 못 따라옴 → 연결 종료(서버 OOM 방지). */
62
62
  export const DEFAULT_MAX_BUFFERED_BYTES = 16 * 1024 * 1024
63
63
 
64
+ /**
65
+ * 프로세스 **합산** send 버퍼 기본 budget(바이트, ADR-215 — G5 audit M-6).
66
+ *
67
+ * per-conn 상한(위 16MiB)은 연결 1개의 OOM 방어일 뿐이라, 느린 소비자 N 개가 각자 cap 직전까지
68
+ * 쌓으면 합산은 무제한이었다(이론상 1,000개 × 16MiB = 16GB). 합산이 본 budget 을 넘으면 **가장 큰
69
+ * 송신 큐를 보유한 연결부터** 1013(slow consumer)으로 종료해 budget 아래로 회수한다.
70
+ * `configureWsSendBudget({ maxTotalBufferedBytes: 0 })` 으로 무제한 옵트아웃.
71
+ */
72
+ export const DEFAULT_MAX_TOTAL_BUFFERED_BYTES = 256 * 1024 * 1024
73
+
74
+ /** 합산 budget 스윕 최소 간격(ms) — 매 send 마다 전 연결 O(N) 합산을 돌지 않게 하는 비용 상한. */
75
+ const BUDGET_SWEEP_INTERVAL_MS = 250
76
+
77
+ /** budget 추적 대상 연결 — driveWsConnection 경유 실연결만(직접 생성한 테스트 더블은 미추적). @type {Set<MegaWsConnection>} */
78
+ const budgetConns = new Set()
79
+
80
+ /** @type {number} 현재 합산 budget(바이트). Infinity = 옵트아웃. */
81
+ let maxTotalBufferedBytes = DEFAULT_MAX_TOTAL_BUFFERED_BYTES
82
+
83
+ /** @type {number} 마지막 budget 스윕 시각(epoch ms). */
84
+ let lastBudgetSweepAt = 0
85
+
86
+ /**
87
+ * 프로세스 합산 send budget 을 조정한다(ADR-215). 부팅/운영 튜닝 진입점.
88
+ * @param {{ maxTotalBufferedBytes?: number }} [opts] - 바이트 budget. `0` = 무제한(옵트아웃).
89
+ * @returns {{ maxTotalBufferedBytes: number }} 적용된 현재 값.
90
+ * @throws {TypeError} 음수/비숫자 — 설정 실수 fail-fast.
91
+ */
92
+ export function configureWsSendBudget({ maxTotalBufferedBytes: max } = {}) {
93
+ if (max !== undefined) {
94
+ if (typeof max !== 'number' || Number.isNaN(max) || max < 0) {
95
+ throw new TypeError(`configureWsSendBudget: maxTotalBufferedBytes must be a non-negative number (0 = unlimited). Got ${max}`)
96
+ }
97
+ maxTotalBufferedBytes = max === 0 ? Infinity : max
98
+ }
99
+ return { maxTotalBufferedBytes }
100
+ }
101
+
102
+ /**
103
+ * 합산 budget 스윕 — 추적 중 전 연결의 `bufferedAmount` 를 합산해 budget 초과면 가장 큰 큐 보유
104
+ * 연결부터 종료한다. 스로틀({@link BUDGET_SWEEP_INTERVAL_MS}) 안쪽 재호출은 no-op(send 핫패스 보호).
105
+ * @param {number} [now] - epoch ms(테스트 주입용).
106
+ * @returns {void}
107
+ */
108
+ function sweepWsSendBudget(now = Date.now()) {
109
+ if (now - lastBudgetSweepAt < BUDGET_SWEEP_INTERVAL_MS) return
110
+ lastBudgetSweepAt = now
111
+ if (budgetConns.size === 0 || maxTotalBufferedBytes === Infinity) return
112
+ let total = 0
113
+ for (const c of budgetConns) total += c._raw.bufferedAmount ?? 0
114
+ while (total > maxTotalBufferedBytes) {
115
+ /** @type {MegaWsConnection | null} */
116
+ let worst = null
117
+ for (const c of budgetConns) {
118
+ if (worst === null || (c._raw.bufferedAmount ?? 0) > (worst._raw.bufferedAmount ?? 0)) worst = c
119
+ }
120
+ const worstBytes = worst === null ? 0 : (worst._raw.bufferedAmount ?? 0)
121
+ if (worst === null || worstBytes === 0) break // 잔여가 전부 0 이면 더 회수할 게 없음(무한루프 차단).
122
+ budgetConns.delete(worst) // close 이벤트 전에 즉시 제외 — 같은 스윕 내 재선정 방지.
123
+ total -= worstBytes
124
+ try {
125
+ worst._raw.close(CLOSE_CODE_SLOW_CONSUMER, 'backpressure: process-wide send budget exceeded')
126
+ } catch {
127
+ // 이미 닫히는 중이면 close 는 무의미 — per-conn 가드와 동일 의미(비치명적). Set 에선 이미 제거됨.
128
+ }
129
+ }
130
+ }
131
+
132
+ /** budget 추적 등록(driveWsConnection 전용). @param {MegaWsConnection} conn @returns {void} */
133
+ function trackWsSendBudget(conn) {
134
+ budgetConns.add(conn)
135
+ }
136
+
137
+ /** budget 추적 해제(연결 close 시). @param {MegaWsConnection} conn @returns {void} */
138
+ function untrackWsSendBudget(conn) {
139
+ budgetConns.delete(conn)
140
+ }
141
+
142
+ /**
143
+ * 테스트 격리용 — budget 상태 초기화(추적 Set·스로틀·budget 디폴트 복원).
144
+ * @returns {void}
145
+ */
146
+ export function _resetWsSendBudget() {
147
+ budgetConns.clear()
148
+ lastBudgetSweepAt = 0
149
+ maxTotalBufferedBytes = DEFAULT_MAX_TOTAL_BUFFERED_BYTES
150
+ }
151
+
152
+ /** 테스트용 — 연결을 budget 추적에 등록. @param {MegaWsConnection} conn @returns {void} */
153
+ export function _trackWsSendBudget(conn) {
154
+ trackWsSendBudget(conn)
155
+ }
156
+
157
+ /** 테스트용 — 스로틀 우회 가능한 스윕 직접 호출. @param {number} [now] @returns {void} */
158
+ export function _sweepWsSendBudget(now) {
159
+ sweepWsSendBudget(now)
160
+ }
161
+
64
162
  /**
65
163
  * 클라↔bridge ping/pong liveness 기본 주기(ms) — 30초. 주기마다 ping 을 보내고 직전 주기의 pong 이
66
164
  * 없으면 half-open(상대 사망·네트워크 단절) 으로 보고 terminate 한다 — 좀비 연결이 OS TCP 타임아웃까지
@@ -197,6 +295,10 @@ export class MegaWsConnection {
197
295
  }
198
296
  return
199
297
  }
298
+ // 프로세스 합산 budget 스윕(ADR-215) — per-conn cap 아래의 "다수의 느린 소비자" 합산 OOM 방어.
299
+ // 스로틀이 있어 핫패스 비용은 주기당 O(N) 1회. 스윕이 이 연결을 닫았으면(가장 큰 큐) 송신 생략.
300
+ sweepWsSendBudget()
301
+ if (this._raw.readyState !== undefined && this._raw.readyState !== 1) return
200
302
  // ns 자동 주입 (L1): 명시 안 됐고 연결 ns 가 있으면 채운다. 명시값은 덮어쓰지 않음.
201
303
  const withNs = fields.ns === undefined && this.ns !== undefined ? { ...fields, ns: this.ns } : fields
202
304
  const env = createWsMessage(withNs)
@@ -284,6 +386,8 @@ function buildWsPresence(app, conn, ns) {
284
386
  */
285
387
  export function driveWsConnection({ raw, req, route, app, codec, log, auth = null, protocolVersion = WS_PROTOCOL_VERSION }) {
286
388
  const conn = new MegaWsConnection(raw, codec, { ns: route.ns, path: route.path })
389
+ // 프로세스 합산 send budget 추적(ADR-215) — 실연결만 등록, close 에서 해제(아래 'close' 핸들러).
390
+ trackWsSendBudget(conn)
287
391
  // 협상된 envelope 버전 — 이 연결의 검증 기준이자 v2 도입 시 코덱/검증 분기의 기준점.
288
392
  conn.protocolVersion = protocolVersion
289
393
  // ChannelClass 는 부팅 시 MegaWebSocketController 상속 검증됨 (router.ws). 여기선 인스턴스화만.
@@ -400,6 +504,7 @@ export function driveWsConnection({ raw, req, route, app, codec, log, auth = nul
400
504
  }
401
505
 
402
506
  raw.on('close', (code, reasonBuf) => {
507
+ untrackWsSendBudget(conn)
403
508
  app._untrackWsConn?.(conn)
404
509
  const reason = Buffer.isBuffer(reasonBuf) ? reasonBuf.toString('utf8') : String(reasonBuf ?? '')
405
510
  log.debug?.({ connId: conn.id, code, reason }, 'ws.disconnect')
@@ -72,8 +72,10 @@ export const CAPACITY_ERROR_CODE = 'ESEMLOCKED'
72
72
  * @property {number} [resetTimeout=30000] - open 상태 유지 시간(ms). 경과 후 halfOpen 으로 1회 프로빙.
73
73
  * @property {number} [rollingCountTimeout=10000] - 실패율 집계 롤링 윈도우 길이(ms).
74
74
  * @property {number} [rollingCountBuckets=10] - 롤링 윈도우를 나누는 버킷 수.
75
- * @property {number} [volumeThreshold=0] - 이 횟수만큼 호출이 쌓이기 전엔 실패율이 높아도 open 안 함
76
- * (표본 부족으로 인한 조기 trip 방지). 0=비활성.
75
+ * @property {number} [volumeThreshold=5] - 롤링 윈도우에 이 횟수만큼 호출이 쌓이기 전엔 실패율이
76
+ * 높아도 open 안 함(표본 부족 조기 trip 방지). 0=비활성. ⚠️ opossum 정본값(0)과 다른 프레임워크
77
+ * 디폴트 — 0 이면 부팅 직후/저빈도 호출에서 **첫 실패 1건**이 실패율 100% 가 돼 즉시 30s 차단되는
78
+ * 풋건이라 5 로 올렸다. opossum 원 동작이 필요하면 명시적으로 0 을 지정.
77
79
  * @property {number} [capacity] - 동시 진행(in-flight) 호출 상한. 초과분은 `ESEMLOCKED` 로 즉시 거부.
78
80
  * 미지정=무제한.
79
81
  * @property {(err: any, ...args: any[]) => boolean} [errorFilter] - `true` 반환 시 그 에러는 **실패로 집계하지 않음**
@@ -149,7 +151,7 @@ export class MegaCircuitBreaker {
149
151
  resetTimeout = 30_000,
150
152
  rollingCountTimeout = 10_000,
151
153
  rollingCountBuckets = 10,
152
- volumeThreshold = 0,
154
+ volumeThreshold = 5, // opossum 정본(0)과 의도적으로 다름 — 단일 실패 즉시 open 풋건 방지.
153
155
  capacity,
154
156
  errorFilter,
155
157
  name,
@@ -45,6 +45,16 @@ export function register(name, fn, opts = {}) {
45
45
  checks.set(name, { fn, timeoutMs })
46
46
  }
47
47
 
48
+ /**
49
+ * 등록된 체크 제거(이름 기준). 닫힌 자원(예: hub link)의 체크가 stale 클로저로 남지 않게
50
+ * 소유자가 register 와 짝맞춰 부른다.
51
+ * @param {string} name
52
+ * @returns {boolean} 제거됐으면 true(미등록 이름이면 false).
53
+ */
54
+ export function unregister(name) {
55
+ return checks.delete(name)
56
+ }
57
+
48
58
  /**
49
59
  * 모든 체크 실행 (병렬). 하나라도 false 면 전체 ok=false.
50
60
  * @returns {Promise<{ ok: boolean, checks: Record<string, { ok: boolean, error?: string, [key: string]: any }> }>}
@@ -91,6 +91,8 @@ function truncateStack(stack) {
91
91
  * 하트비트가 이 값의 절반마다 lease 를 갱신한다.
92
92
  * @property {number} [maxDeliver=5] - JetStream 최대 전달 횟수(워커 크래시 재전달 backstop).
93
93
  * @property {number} [heartbeatMs] - `working()` 전송 주기(ms). 기본 `max(1000, ackWaitMs/2)`.
94
+ * 양의 정수 + `< ackWaitMs` 필수(생성자 fail-fast) — 이상이면 lease 갱신이 늦어 정상 처리 중
95
+ * 중복 재전달, 0 이하면 working() 플러딩.
94
96
  * @property {string} [streamPrefix='MEGA_JOBS'] - 스트림 이름 접두사.
95
97
  * @property {number} [dlqMaxAgeMs=604800000] - DLQ 스트림 메시지 보존 기한(ms, 디폴트 7일). 초과한 실패
96
98
  * 잡은 NATS 가 자동 만료시킨다(무한 적재 방지, ADR-134). `0` 이면 무제한(끔 — 영구 보존). **신규 DLQ
@@ -135,6 +137,13 @@ export class MegaJobQueue extends EventEmitter {
135
137
  /** @type {import('nats').JetStreamClient|null} */ #js = null
136
138
  /** @type {import('nats').JetStreamManager|null} */ #jsm = null
137
139
  /** @type {Promise<void>|null} ensureReady 멱등 가드. */ #readyPromise = null
140
+ /**
141
+ * subject 별 ensureStream 멱등 캐시(#readyPromise 와 동일 패턴). 없으면 enqueue 가 매 호출
142
+ * `jsm.streams.info` RPC ×2(워크+DLQ)를 반복해 enqueue 비용의 2/3 가 존재 재확인에 낭비된다.
143
+ * 실패한 Promise 는 캐시에서 비워 다음 호출이 재시도하게 한다.
144
+ * @type {Map<string, Promise<void>>}
145
+ */
146
+ #ensuredStreams = new Map()
138
147
 
139
148
  /**
140
149
  * @param {MegaJobQueueOptions} options
@@ -164,6 +173,11 @@ export class MegaJobQueue extends EventEmitter {
164
173
  if (typeof runTimeoutMs !== 'number' || !Number.isInteger(runTimeoutMs) || runTimeoutMs < 0) {
165
174
  throw new TypeError(`MegaJobQueue: runTimeoutMs must be an integer >= 0 (0 = unlimited). Got: ${runTimeoutMs}.`)
166
175
  }
176
+ // heartbeatMs: working() lease 갱신 주기. ackWaitMs 이상이면 갱신이 늦어 정상 처리 중 lease 가
177
+ // 만료돼 중복 재전달(at-least-once 폭증)되고, 0 이하면 setInterval 1ms 클램프로 working() 플러딩.
178
+ if (heartbeatMs !== undefined && (typeof heartbeatMs !== 'number' || !Number.isInteger(heartbeatMs) || heartbeatMs < 1 || heartbeatMs >= ackWaitMs)) {
179
+ throw new TypeError(`MegaJobQueue: heartbeatMs must be a positive integer < ackWaitMs (${ackWaitMs}). Got: ${heartbeatMs}.`)
180
+ }
167
181
  this.#nc = nc
168
182
  this.#ackWaitMs = ackWaitMs
169
183
  this.#maxDeliver = maxDeliver
@@ -336,16 +350,30 @@ export class MegaJobQueue extends EventEmitter {
336
350
  * @param {typeof MegaJob} JobClass @returns {Promise<void>}
337
351
  */
338
352
  async ensureStream(JobClass) {
339
- await this.ensureReady()
340
353
  const subject = this.#assertJobSubject(JobClass)
341
- const nats = /** @type {typeof import('nats')} */ (this.#nats)
342
- await this.#ensureStreamExists(this.#workStreamName(subject), [subject], nats.RetentionPolicy.Workqueue)
343
- // DLQ 스트림에 한도를 건다(무한 적재 방지, ADR-134). dlqMaxAgeMs=0 이면 max_age 미지정(무제한),
344
- // dlqMaxBytes 미지정이면 max_bytes 미지정.
345
- await this.#ensureStreamExists(this.#dlqStreamName(subject), [`${subject}.dlq`], nats.RetentionPolicy.Limits, {
346
- maxAgeMs: this.#dlqMaxAgeMs,
347
- maxBytes: this.#dlqMaxBytes,
348
- })
354
+ // subject 1회만 실제 확인(RPC ×2) 이후 호출은 캐시된 Promise 를 기다린다(동시 호출도 1회).
355
+ const cached = this.#ensuredStreams.get(subject)
356
+ if (cached) return cached
357
+ const ensured = (async () => {
358
+ await this.ensureReady()
359
+ const nats = /** @type {typeof import('nats')} */ (this.#nats)
360
+ await this.#ensureStreamExists(this.#workStreamName(subject), [subject], nats.RetentionPolicy.Workqueue)
361
+ // DLQ 스트림에 한도를 건다(무한 적재 방지, ADR-134). dlqMaxAgeMs=0 이면 max_age 미지정(무제한),
362
+ // dlqMaxBytes 미지정이면 max_bytes 미지정.
363
+ await this.#ensureStreamExists(this.#dlqStreamName(subject), [`${subject}.dlq`], nats.RetentionPolicy.Limits, {
364
+ maxAgeMs: this.#dlqMaxAgeMs,
365
+ maxBytes: this.#dlqMaxBytes,
366
+ })
367
+ })()
368
+ this.#ensuredStreams.set(subject, ensured)
369
+ try {
370
+ await ensured
371
+ } catch (err) {
372
+ // 실패는 캐시하지 않는다 — NATS 일시 장애 후 다음 enqueue/consume 이 재시도할 수 있게.
373
+ this.#ensuredStreams.delete(subject)
374
+ throw err
375
+ }
376
+ return ensured
349
377
  }
350
378
 
351
379
  /**
@@ -399,14 +427,26 @@ export class MegaJobQueue extends EventEmitter {
399
427
  }
400
428
 
401
429
  /**
402
- * 잡을 큐에 넣는다(JetStream publish — 서버에 영속 저장). 스트림이 없으면 만든다.
403
- * @param {typeof MegaJob} JobClass @param {any} payload @returns {Promise<{ seq: number, stream: string, duplicate: boolean }>}
430
+ * 잡을 큐에 넣는다(JetStream publish — 서버에 영속 저장). 스트림이 없으면 만든다(subject 별 1회 확인 후 캐시).
431
+ *
432
+ * @param {typeof MegaJob} JobClass @param {any} payload
433
+ * @param {{ msgID?: string }} [opts] - `msgID` = JetStream `Nats-Msg-Id` dedup 키(옵트인). 스트림
434
+ * duplicate window(NATS 기본 2분 — 운영자가 NATS CLI 로 스트림별 조정) 안의 같은 msgID 재발행은
435
+ * 적재되지 않고 `duplicate: true` 로 반환된다. 비즈니스 멱등키(주문 ID 등)를 권장. 미지정 시
436
+ * dedup 없음 — `duplicate` 는 항상 false.
437
+ * @returns {Promise<{ seq: number, stream: string, duplicate: boolean }>}
404
438
  */
405
- async enqueue(JobClass, payload) {
439
+ async enqueue(JobClass, payload, { msgID } = /** @type {{ msgID?: string }} */ ({})) {
440
+ if (msgID !== undefined && (typeof msgID !== 'string' || msgID.length === 0)) {
441
+ throw new TypeError(`MegaJobQueue.enqueue: msgID, if set, must be a non-empty string. Got: ${msgID}.`)
442
+ }
406
443
  await this.ensureStream(JobClass)
407
444
  const subject = /** @type {string} */ (JobClass.subject)
408
445
  const js = /** @type {import('nats').JetStreamClient} */ (this.#js)
409
- const ack = await js.publish(subject, this.#encode(payload))
446
+ // msgID(옵트인) = JetStream `Nats-Msg-Id` dedup — 스트림 duplicate window(NATS 기본 2분) 안의
447
+ // 같은 msgID 재발행은 적재되지 않고 ack.duplicate=true 로 돌아온다(producer 중복: 재시도 enqueue·
448
+ // 이중 클릭 방어). 미지정 시 dedup 없음(기존 동작, 비용 0) — duplicate 는 그때 항상 false.
449
+ const ack = await js.publish(subject, this.#encode(payload), msgID !== undefined ? { msgID } : undefined)
410
450
  // dispatch 는 publish(부작용) 완료 후 발화 — 리스너 throw 가 반환값/흐름을 오염시키지 않게 safeEmit(L-3).
411
451
  this.#safeEmit('dispatch', { subject, seq: ack.seq, stream: ack.stream })
412
452
  return { seq: ack.seq, stream: ack.stream, duplicate: ack.duplicate }
@@ -73,7 +73,14 @@ export class MegaJob {
73
73
  /** @type {string|undefined} bus 별명(`ctx.bus(alias)`). 워커 배선이 nc 해석에 사용. */
74
74
  static bus = undefined
75
75
 
76
- /** @type {number} 동시 처리 메시지 수(consumer max_ack_pending). 기본 1(순차·안전). */
76
+ /**
77
+ * @type {number} 동시 처리 메시지 수. 기본 1(순차·안전).
78
+ *
79
+ * ⚠️ 이 값은 durable consumer 의 `max_ack_pending` 으로 들어가므로 **워커 그룹(같은 subject 를
80
+ * 소비하는 모든 인스턴스) 전체의 합산 in-flight 상한**이다 — `mega worker` 인스턴스를 늘려도
81
+ * 합산 동시 처리는 이 값을 넘지 못한다. 처리량을 늘리려면 인스턴스 증설과 **함께** concurrency 를
82
+ * 키워야 한다(실측: c=1→c=32 에서 처리량 ~10배).
83
+ */
77
84
  static concurrency = 1
78
85
 
79
86
  /** @type {number} run 실패 시 **추가** 재시도 횟수(첫 시도 제외). 기본 3(OQ-012). */
@@ -36,6 +36,7 @@
36
36
  * @see https://opentelemetry.io/docs/specs/otel/metrics/ (OTel metrics)
37
37
  * @see https://prometheus.io/docs/instrumenting/exposition_formats/ (Prometheus 텍스트 포맷)
38
38
  */
39
+ import { getHeapStatistics } from 'node:v8'
39
40
  import { MeterProvider } from '@opentelemetry/sdk-metrics'
40
41
  import { PrometheusExporter, PrometheusSerializer } from '@opentelemetry/exporter-prometheus'
41
42
  import { buildOtelResource } from './otel-resource.js'
@@ -547,7 +548,7 @@ function buildInstruments(meter) {
547
548
  */
548
549
  function registerSystemGauges(meter) {
549
550
  const memory = meter.createObservableGauge('mega_process_memory_bytes', {
550
- description: '프로세스 메모리 사용량(바이트) — kind=rss|heap_used|heap_total|external 라벨.',
551
+ description: '프로세스 메모리 사용량(바이트) — kind=rss|heap_used|heap_total|external|array_buffers 라벨.',
551
552
  unit: 'By',
552
553
  })
553
554
  memory.addCallback((result) => {
@@ -556,6 +557,32 @@ function registerSystemGauges(meter) {
556
557
  result.observe(mu.heapUsed, { kind: 'heap_used' })
557
558
  result.observe(mu.heapTotal, { kind: 'heap_total' })
558
559
  result.observe(mu.external, { kind: 'external' })
560
+ // arrayBuffers 분리 노출(ADR-215, G5 M-5) — mongo driver 등 ArrayBuffer 상주분을 heap 과 구분해
561
+ // "RSS 만 보는" 관측 함정(burst 후 V8 페이지 미반환 = 정상 평형)을 운영자가 분해해 읽을 수 있게.
562
+ result.observe(mu.arrayBuffers ?? 0, { kind: 'array_buffers' })
563
+ })
564
+
565
+ // V8 힙 통계(ADR-215, G5 M-5) — heap_size_limit(OOM 한계)·total_available_size(여유)·physical 등
566
+ // process.memoryUsage 가 못 보여주는 V8 내부 수위. kind 는 고정 enum 이라 카디널리티 안전.
567
+ const V8_HEAP_KINDS = Object.freeze([
568
+ 'total_heap_size',
569
+ 'total_physical_size',
570
+ 'total_available_size',
571
+ 'used_heap_size',
572
+ 'heap_size_limit',
573
+ 'malloced_memory',
574
+ 'peak_malloced_memory',
575
+ 'external_memory',
576
+ ])
577
+ const v8Heap = meter.createObservableGauge('mega_v8_heap_bytes', {
578
+ description: `V8 힙 통계(바이트, v8.getHeapStatistics) — kind=${V8_HEAP_KINDS.join('|')} 라벨.`,
579
+ unit: 'By',
580
+ })
581
+ v8Heap.addCallback((result) => {
582
+ const hs = /** @type {Record<string, number>} */ (/** @type {unknown} */ (getHeapStatistics()))
583
+ for (const kind of V8_HEAP_KINDS) {
584
+ if (typeof hs[kind] === 'number') result.observe(hs[kind], { kind })
585
+ }
559
586
  })
560
587
 
561
588
  const uptime = meter.createObservableGauge('mega_process_uptime_seconds', {
@@ -62,12 +62,12 @@ const LIFECYCLE_EVENTS = /** @type {const} */ (['beforeBoot', 'afterBoot', 'befo
62
62
  * @property {string} [description] - `mega help` 가 병기하는 한 줄 설명(선택).
63
63
  */
64
64
 
65
- /** 빌트인 generator 13종 이름 — 플러그인 scaffold 가 점유 금지(빌트인이 우선이라 silent shadow 가 됨).
65
+ /** 빌트인 generator 이름 — 플러그인 scaffold 가 점유 금지(빌트인이 우선이라 silent shadow 가 됨).
66
66
  * cli/generators 의 `GENERATOR_KINDS` 와 동일해야 한다(레이어 역전 회피를 위해 미러 — 동기화는
67
67
  * mega-plugin 단위 테스트가 GENERATOR_KINDS 와 집합 비교로 강제한다). */
68
68
  export const RESERVED_GENERATOR_NAMES = new Set([
69
69
  'app', 'controller', 'channel', 'service', 'model', 'middleware', 'route',
70
- 'schedule', 'job', 'worker', 'locale', 'adapter', 'migration',
70
+ 'schedule', 'job', 'worker', 'locale', 'adapter', 'migration', 'adr',
71
71
  ])
72
72
 
73
73
  /**
@@ -16,7 +16,9 @@
16
16
  * 프로세스 메모리 격리), process=`child_process.fork`(완전 격리, 더 무거움). 둘 다 node 빌트인(의존성 0).
17
17
  * - **풀 정책** — `static poolSize`(디폴트 `os.cpus().length - 1`, 최소 1). 작업 큐 + 가용 워커에 디스패치.
18
18
  * - **crash 자동 재시작** — 워커가 예기치 않게 죽으면 in-flight task 를 `worker.crashed` 로 reject 하고
19
- * `static maxRestarts`(디폴트 5)까지 교체 워커를 띄운다(MegaJobWorker M-1 패턴 정합).
19
+ * `static maxRestarts`(디폴트 5)/`static restartWindowMs`(디폴트 60s) **슬라이딩 윈도우** 안에서만
20
+ * 교체 워커를 띄운다 — 윈도우 내 한도 초과 = crash-loop 으로 보고 포기(MegaCluster 정책 통일).
21
+ * 산발 crash(윈도우 밖)는 한도를 소모하지 않아 장수 프로세스의 풀이 영구 축소되지 않는다.
20
22
  * - **graceful shutdown** — `stop()`: 새 `run()` 거부 + 큐 대기분 `worker.stopped` reject + in-flight 완료
21
23
  * 대기(allSettled) → 워커 terminate. `MegaShutdown` 통합은 workers-manager 가 배선.
22
24
  *
@@ -57,6 +59,14 @@ const DEFAULT_POOL_SIZE = Math.max(1, os.cpus().length - 1)
57
59
  /** crash 시 풀 전체에서 허용하는 교체 워커 재시작 총량 디폴트. */
58
60
  const DEFAULT_MAX_RESTARTS = 5
59
61
 
62
+ /**
63
+ * crash 교체 판정 윈도우 기본값(ms) — 60초. `maxRestarts` 는 이 윈도우 **안의** 교체 횟수 상한이다
64
+ * (수명 누적이 아님). 누적 카운터면 몇 주 간격의 산발 crash 도 한도를 소모해 장수 프로세스의 풀이
65
+ * 영구 축소되다 `pool_exhausted` 로 죽는다 — crash-loop(즉시 연속 사망)만 막으면 되는 안전망이므로
66
+ * 시간 윈도우 판정이 맞다(MegaCluster 의 rapid-crash 슬라이딩 윈도우와 정책 통일).
67
+ */
68
+ const DEFAULT_RESTART_WINDOW_MS = 60_000
69
+
60
70
  /**
61
71
  * @typedef {object} WorkerHandle - 풀 안의 워커 1개 핸들.
62
72
  * @property {number} id - 핸들 식별자(로그/디버그).
@@ -94,7 +104,8 @@ export class MegaWorker extends EventEmitter {
94
104
  /** @type {boolean} */ #stopping = false
95
105
  /** @type {number} */ #nextTaskId = 1
96
106
  /** @type {number} */ #nextHandleId = 1
97
- /** @type {number} crash 교체 누적. */ #restarts = 0
107
+ /** @type {number[]} crash 교체 시각(epoch ms) 슬라이딩 윈도우 — restartWindowMs 밖은 판정 시 제거. */
108
+ #restartTimes = []
98
109
  /** @type {number} 진행 중인 교체 spawn 수(일시적 풀 0 상태에서 run 을 큐잉할지 판단). */ #pendingRespawns = 0
99
110
 
100
111
  /**
@@ -130,12 +141,18 @@ export class MegaWorker extends EventEmitter {
130
141
  return typeof p === 'number' ? p : DEFAULT_POOL_SIZE
131
142
  }
132
143
 
133
- /** @returns {number} crash 교체 허용 총량. */
144
+ /** @returns {number} 윈도우({@link MegaWorker#restartWindowMs}) 내 crash 교체 허용 횟수. */
134
145
  get maxRestarts() {
135
146
  const r = /** @type {any} */ (this.constructor).maxRestarts
136
147
  return Number.isInteger(r) && r >= 0 ? r : DEFAULT_MAX_RESTARTS
137
148
  }
138
149
 
150
+ /** @returns {number} crash 교체 판정 슬라이딩 윈도우(ms). `static restartWindowMs` 로 조정. */
151
+ get restartWindowMs() {
152
+ const w = /** @type {any} */ (this.constructor).restartWindowMs
153
+ return Number.isInteger(w) && w > 0 ? w : DEFAULT_RESTART_WINDOW_MS
154
+ }
155
+
139
156
  /** @returns {boolean} start() 후 stop() 전이면 true. */
140
157
  get isStarted() {
141
158
  return this.#started
@@ -511,8 +528,14 @@ export class MegaWorker extends EventEmitter {
511
528
  * @returns {void}
512
529
  */
513
530
  #scheduleRespawn() {
514
- if (this.#stopping || this.#restarts >= this.maxRestarts) return
515
- this.#restarts++
531
+ if (this.#stopping) return
532
+ // 슬라이딩 윈도우 판정 — 윈도우 밖 기록을 비우고 남은 횟수가 한도면 crash-loop 으로 보고 포기.
533
+ // (수명 누적이 아니라서 산발 crash 는 풀을 영구 축소시키지 않는다 — MegaCluster 정책 통일.)
534
+ const now = Date.now()
535
+ const windowStart = now - this.restartWindowMs
536
+ this.#restartTimes = this.#restartTimes.filter((t) => t >= windowStart)
537
+ if (this.#restartTimes.length >= this.maxRestarts) return
538
+ this.#restartTimes.push(now)
516
539
  this.#pendingRespawns++
517
540
  this.#spawn()
518
541
  .then((h) => {
package/src/lib/ws-hub.js CHANGED
@@ -103,7 +103,7 @@ export class MegaWsHub {
103
103
  this._wss = null
104
104
  /** heartbeat liveness 체크 interval (M3). @type {ReturnType<typeof setInterval> | null} */
105
105
  this._livenessTimer = null
106
- /** 등록된 bridge 연결. connId → { socket, lastSeen, protocolVersion }. @type {Map<string, { socket: import('ws').WebSocket, lastSeen: number, protocolVersion?: number }>} */
106
+ /** 등록된 bridge 연결. connId → { socket, lastSeen, protocolVersion, presenceFanout }. @type {Map<string, { socket: import('ws').WebSocket, lastSeen: number, protocolVersion?: number, presenceFanout?: boolean }>} */
107
107
  this._bridges = new Map()
108
108
  /** presence. sessionId → { bridgeConnId, userId, channels:Set, metadata }. @type {Map<string, { bridgeConnId: string, userId: string, channels: Set<string>, metadata: Object }>} */
109
109
  this._sessions = new Map()
@@ -111,6 +111,13 @@ export class MegaWsHub {
111
111
  this._channelSessions = new Map()
112
112
  /** userId → sessionId 집합 (DIRECT fan-out, ADR-035). @type {Map<string, Set<string>>} */
113
113
  this._userSessions = new Map()
114
+ /**
115
+ * 역인덱스: channel → (bridgeConnId → 그 bridge 의 채널 내 세션 수). BROADCAST 의 bridge 선정을
116
+ * 세션 수 무관 O(bridge 수)로 만든다 — 멤버 10k 채널에서 메시지당 수백 µs 가 µs 대로 떨어진다.
117
+ * `_addSession`/`_removeSession` 이 증분 유지(카운트 0 → 키 제거), 메모리는 채널×bridge 수준.
118
+ * @type {Map<string, Map<string, number>>}
119
+ */
120
+ this._channelBridges = new Map()
114
121
  }
115
122
 
116
123
  /** hub 식별자. */
@@ -170,6 +177,20 @@ export class MegaWsHub {
170
177
  let isRegistered = false
171
178
  this._log?.debug?.({ connId }, 'ws-hub connection (awaiting register)')
172
179
 
180
+ // 미등록 연결 register 타임아웃 — liveness(_checkLiveness)는 등록된 bridge 만 스캔하므로,
181
+ // REGISTER 를 안 보내는 연결(오설정 bridge·포트 스캐너·half-open)은 이 타이머가 없으면
182
+ // fd/메모리로 영구 잔존한다. heartbeatMs 안에 등록 못 하면 1008 로 닫는다(fail-closed).
183
+ const registerTimer = setTimeout(() => {
184
+ if (isRegistered) return
185
+ this._log?.warn?.({ connId, timeoutMs: this._heartbeatMs }, 'ws-hub register timeout — closing unregistered connection (1008)')
186
+ try {
187
+ socket.close(1008, 'register timeout')
188
+ } catch (err) {
189
+ this._log?.debug?.({ err, connId }, 'ws-hub register-timeout close failed (already closing)')
190
+ }
191
+ }, this._heartbeatMs)
192
+ registerTimer.unref?.()
193
+
173
194
  socket.on('message', (raw) => {
174
195
  const frame = Array.isArray(raw) ? Buffer.concat(raw).toString('utf8') : raw.toString('utf8')
175
196
  let msg
@@ -196,12 +217,14 @@ export class MegaWsHub {
196
217
  return
197
218
  }
198
219
  isRegistered = this._handleRegister(connId, socket, msg)
220
+ if (isRegistered) clearTimeout(registerTimer)
199
221
  return
200
222
  }
201
223
  this._route(connId, socket, msg)
202
224
  })
203
225
 
204
226
  socket.on('close', () => {
227
+ clearTimeout(registerTimer) // 미등록 타임아웃 정리(등록 전 절단·정상 종료 공통).
205
228
  if (isRegistered) this._handleBridgeGone(connId)
206
229
  this._log?.debug?.({ connId }, 'ws-hub connection closed')
207
230
  })
@@ -234,7 +257,10 @@ export class MegaWsHub {
234
257
  const protocolVersion = hasVersionRequest
235
258
  ? negotiateHubProtocolVersion(payload.protocolVersion)
236
259
  : HUB_PROTOCOL_VERSION
237
- this._bridges.set(connId, { socket, lastSeen: Date.now(), protocolVersion })
260
+ // presence fan-out 옵트인 — roster 가 redis 로 분리(ADR-177) bridge 는 JOIN/LEAVE/METADATA
261
+ // 를 no-op 으로 버리므로, `presence-fanout` capability 를 선언한 bridge 에만 보낸다(죽은 트래픽 제거).
262
+ const presenceFanout = Array.isArray(payload.capabilities) && payload.capabilities.includes('presence-fanout')
263
+ this._bridges.set(connId, { socket, lastSeen: Date.now(), protocolVersion, presenceFanout })
238
264
  this._log?.info?.({ connId, instanceId: payload.instanceId, hubId: this._hubId, protocolVersion }, 'ws-hub bridge registered')
239
265
  this._safeSend(socket, createHubMessage({
240
266
  type: T.REGISTER_OK,
@@ -262,12 +288,12 @@ export class MegaWsHub {
262
288
  switch (type) {
263
289
  case T.JOIN:
264
290
  this._addSession(connId, payload)
265
- // 클러스터 presence 공유 — 다른 모든 bridge 같은 JOIN fan-out (07 §2).
266
- this._fanOutToOthers(connId, msg)
291
+ // 클러스터 presence 공유 — `presence-fanout` 선언 bridge 에만 (07 §2 + ADR-177 죽은 트래픽 제거).
292
+ this._fanOutPresence(connId, msg)
267
293
  break
268
294
  case T.LEAVE: {
269
295
  const removed = this._removeSession(payload.sessionId)
270
- if (removed) this._fanOutToOthers(connId, msg)
296
+ if (removed) this._fanOutPresence(connId, msg)
271
297
  break
272
298
  }
273
299
  case T.BULK_LEAVE: {
@@ -289,7 +315,7 @@ export class MegaWsHub {
289
315
  const session = this._sessions.get(payload.sessionId)
290
316
  if (session) {
291
317
  session.metadata = payload.metadata
292
- this._fanOutToOthers(connId, msg) // presence 메타 동기화
318
+ this._fanOutPresence(connId, msg) // presence 메타 동기화 — 옵트인 bridge 만.
293
319
  }
294
320
  break
295
321
  }
@@ -360,6 +386,7 @@ export class MegaWsHub {
360
386
  this._channelSessions.set(ch, set)
361
387
  }
362
388
  set.add(entry.sessionId)
389
+ this._bumpChannelBridge(ch, connId, +1)
363
390
  }
364
391
  let uset = this._userSessions.get(entry.userId)
365
392
  if (!uset) {
@@ -369,6 +396,27 @@ export class MegaWsHub {
369
396
  uset.add(entry.sessionId)
370
397
  }
371
398
 
399
+ /**
400
+ * channel→bridge 역인덱스 증분 갱신. 카운트가 0 이 되면 키를 지워 인덱스가 stale 하지 않게 한다.
401
+ * @param {string} channel @param {string} connId @param {1|-1} delta
402
+ * @private
403
+ */
404
+ _bumpChannelBridge(channel, connId, delta) {
405
+ let counts = this._channelBridges.get(channel)
406
+ if (!counts) {
407
+ if (delta < 0) return // 제거인데 인덱스 없음 — 정합상 없을 일이지만 방어.
408
+ counts = new Map()
409
+ this._channelBridges.set(channel, counts)
410
+ }
411
+ const next = (counts.get(connId) ?? 0) + delta
412
+ if (next > 0) {
413
+ counts.set(connId, next)
414
+ } else {
415
+ counts.delete(connId)
416
+ if (counts.size === 0) this._channelBridges.delete(channel)
417
+ }
418
+ }
419
+
372
420
  /**
373
421
  * presence 에서 세션 제거. 빈 인덱스는 정리.
374
422
  * @param {string} sessionId
@@ -385,6 +433,7 @@ export class MegaWsHub {
385
433
  set.delete(sessionId)
386
434
  if (set.size === 0) this._channelSessions.delete(ch)
387
435
  }
436
+ this._bumpChannelBridge(ch, session.bridgeConnId, -1)
388
437
  }
389
438
  const uset = this._userSessions.get(session.userId)
390
439
  if (uset) {
@@ -415,7 +464,8 @@ export class MegaWsHub {
415
464
  }
416
465
 
417
466
  /**
418
- * origin 을 제외한 모든 등록 bridge 로 송신 (presence 공유용). envelope 는 1회만 직렬화(L5).
467
+ * origin 을 제외한 모든 등록 bridge 로 송신. envelope 는 1회만 직렬화(L5).
468
+ * BULK_LEAVE(bridge-gone 정리 통지)처럼 전 bridge 가 받아야 하는 통지에 쓴다.
419
469
  * @param {string} exceptConnId
420
470
  * @param {Object} envelope
421
471
  * @private
@@ -427,6 +477,25 @@ export class MegaWsHub {
427
477
  }
428
478
  }
429
479
 
480
+ /**
481
+ * presence(JOIN/LEAVE/METADATA) fan-out — `presence-fanout` capability 를 선언한 bridge 에만 송신.
482
+ * roster 가 redis 로 분리(ADR-177)된 뒤 표준 bridge 는 이 타입들을 no-op 으로 버리므로, 옵트인하지
483
+ * 않은 bridge 에는 보내지 않는다 — 세션 churn × bridge 수 만큼의 죽은 프레임을 제거한다.
484
+ * 구버전 bridge 호환: hub presence 를 실제로 쓰려는 bridge 는 capability 로 명시 선언한다.
485
+ * @param {string} exceptConnId
486
+ * @param {Object} envelope
487
+ * @private
488
+ */
489
+ _fanOutPresence(exceptConnId, envelope) {
490
+ /** @type {string | null} 첫 대상에서 1회 직렬화(L5) — 대상 0 이면 직렬화도 안 함. */
491
+ let data = null
492
+ for (const [connId, bridge] of this._bridges) {
493
+ if (connId === exceptConnId || !(/** @type {any} */ (bridge).presenceFanout)) continue
494
+ if (data === null) data = JSON.stringify(envelope)
495
+ this._sendSerialized(bridge.socket, data)
496
+ }
497
+ }
498
+
430
499
  /**
431
500
  * 한 채널의 세션을 가진 bridge 들로 fan-out (origin 제외, 중복 bridge 1회). 직렬화 1회(L5).
432
501
  *
@@ -441,12 +510,24 @@ export class MegaWsHub {
441
510
  * @private
442
511
  */
443
512
  _fanOutChannel(channel, exceptConnId, envelope, exceptSessionIds) {
513
+ const except = Array.isArray(exceptSessionIds) && exceptSessionIds.length > 0 ? new Set(exceptSessionIds) : null
514
+ if (!except) {
515
+ // 일반 경로(대부분의 메시지): channel→bridge 역인덱스로 선정이 O(bridge 수) — 세션 수 무관.
516
+ const counts = this._channelBridges.get(channel)
517
+ if (!counts || counts.size === 0) return
518
+ const data = JSON.stringify(envelope)
519
+ for (const connId of counts.keys()) {
520
+ if (connId !== exceptConnId) this._sendTo(connId, data)
521
+ }
522
+ return
523
+ }
524
+ // exceptSessionIds 경로(드묾): "제외 세션만 가진 bridge 통째 스킵" 판정에 세션 단위 정보가
525
+ // 필요하므로 기존 멤버 순회를 유지한다.
444
526
  const sids = this._channelSessions.get(channel)
445
527
  if (!sids) return
446
- const except = Array.isArray(exceptSessionIds) && exceptSessionIds.length > 0 ? new Set(exceptSessionIds) : null
447
528
  const targets = new Set()
448
529
  for (const sid of sids) {
449
- if (except && except.has(sid)) continue // 제외 세션은 bridge 선정에 기여하지 않음
530
+ if (except.has(sid)) continue // 제외 세션은 bridge 선정에 기여하지 않음
450
531
  const session = this._sessions.get(sid)
451
532
  if (session && session.bridgeConnId !== exceptConnId) targets.add(session.bridgeConnId)
452
533
  }
@@ -0,0 +1,23 @@
1
+ ---
2
+ number: {{number}}
3
+ title: {{title}}
4
+ date: {{date}}
5
+ status: accepted
6
+ supersedes: []
7
+ superseded_by: null
8
+ tags: []
9
+ ---
10
+
11
+ # ADR-{{number}}: {{title}}
12
+
13
+ ## Context
14
+
15
+ (결정이 필요했던 배경 — 문제·제약·관련 ADR 링크)
16
+
17
+ ## Decision
18
+
19
+ (무엇을 결정했는가 — 한 문장 요지 + 세부 사항)
20
+
21
+ ## Consequences
22
+
23
+ (트레이드오프·후속 작업·영향 파일·검증 결과)