mega-framework 0.1.7 → 0.1.9

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 (95) 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/controllers/upload-controller.js +28 -5
  6. package/sample/crud/apps/main/locales/server/en.json +12 -1
  7. package/sample/crud/apps/main/locales/server/ko.json +12 -1
  8. package/sample/crud/apps/main/routes/upload.js +20 -1
  9. package/sample/crud/apps/main/services/guide-service.js +4 -3
  10. package/sample/crud/apps/main/services/upload-demo-service.js +6 -5
  11. package/sample/crud/apps/main/views/upload/index.ejs +4 -1
  12. package/sample/crud/docs/guide/01-cli.md +587 -0
  13. package/sample/crud/docs/guide/02-router-controller.md +497 -0
  14. package/sample/crud/docs/guide/03-service-model-db.md +929 -0
  15. package/sample/crud/docs/guide/04-websocket-asp.md +632 -0
  16. package/sample/crud/docs/guide/05-scheduler-job-worker.md +400 -0
  17. package/sample/crud/docs/guide/06-config-auth-session-security.md +495 -0
  18. package/sample/crud/docs/guide/07-view-i18n-static-multipart.md +462 -0
  19. package/sample/crud/docs/guide/08-observability.md +373 -0
  20. package/sample/crud/mega.config.js +7 -0
  21. package/sample/crud/package.json +2 -2
  22. package/sample/crud/scripts/start-ws-hub.sh +18 -4
  23. package/sample/crud/var/uploads/(/355/212/271/352/270/260/354/236/220) 2026 /353/251/264/354/240/221/354/225/210/353/202/264-mqbnwq5v-d2125aa8.txt" +1 -0
  24. package/sample/crud/var/uploads/(/355/212/271/352/270/260/354/236/220) 2026 /353/251/264/354/240/221/354/225/210/353/202/264-mqbo0nbf-842b6135.txt" +1 -0
  25. package/sample/crud/var/uploads/00_b-mqbnh7lc-c9790ab8.png +0 -0
  26. package/sample/crud/var/uploads/2025-07-22 13 35 56-mqbngvvk-e545e90e.png +0 -0
  27. package/sample/crud/var/uploads/big-file-mqbo1h9e-2957eaf5.png +0 -0
  28. package/sample/crud/var/uploads//341/204/200/341/205/247/341/206/274/341/204/200/341/205/265/341/204/211/341/205/265/341/206/257/341/204/214/341/205/245/341/206/250/341/204/214/341/205/263/341/206/274/341/204/206/341/205/247/341/206/274/341/204/211/341/205/245-mqbo5yxh-5288d8ef.pdf +0 -0
  29. package/sample/simple/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  30. package/src/adapters/adapter-options.js +14 -3
  31. package/src/adapters/file-adapter.js +9 -5
  32. package/src/adapters/file-session-adapter.js +4 -3
  33. package/src/adapters/maria-adapter.js +7 -4
  34. package/src/adapters/mega-cache-adapter.js +83 -6
  35. package/src/adapters/mega-db-adapter.js +4 -1
  36. package/src/adapters/mongo-adapter.js +21 -7
  37. package/src/adapters/postgres-adapter.js +8 -4
  38. package/src/adapters/redis-adapter.js +7 -3
  39. package/src/adapters/sqlite-adapter.js +6 -2
  40. package/src/cli/commands/console-cmd.js +3 -1
  41. package/src/cli/commands/scaffold.js +38 -2
  42. package/src/cli/generators/index.js +58 -1
  43. package/src/cli/index.js +88 -59
  44. package/src/cli/watch.js +188 -0
  45. package/src/core/ajv-mapper.js +3 -1
  46. package/src/core/ctx-builder.js +59 -1
  47. package/src/core/envelope.js +9 -2
  48. package/src/core/hub-link.js +24 -14
  49. package/src/core/index.js +1 -1
  50. package/src/core/mega-app.js +55 -45
  51. package/src/core/pipeline.js +8 -6
  52. package/src/core/scope-registry.js +1 -0
  53. package/src/core/security.js +3 -3
  54. package/src/core/session-store.js +14 -1
  55. package/src/core/ws-presence.js +17 -5
  56. package/src/core/ws-roster.js +49 -10
  57. package/src/core/ws-upgrade.js +105 -0
  58. package/src/lib/mega-circuit-breaker.js +5 -3
  59. package/src/lib/mega-health.js +10 -0
  60. package/src/lib/mega-job-queue.js +53 -13
  61. package/src/lib/mega-job.js +8 -1
  62. package/src/lib/mega-metrics.js +28 -1
  63. package/src/lib/mega-plugin.js +2 -2
  64. package/src/lib/mega-worker.js +28 -5
  65. package/src/lib/ws-hub.js +90 -9
  66. package/templates/adr/code.tpl +23 -0
  67. package/types/adapters/adapter-options.d.ts +2 -0
  68. package/types/adapters/file-adapter.d.ts +12 -1
  69. package/types/adapters/file-session-adapter.d.ts +4 -2
  70. package/types/adapters/maria-adapter.d.ts +5 -3
  71. package/types/adapters/mega-cache-adapter.d.ts +27 -1
  72. package/types/adapters/mega-db-adapter.d.ts +4 -1
  73. package/types/adapters/mongo-adapter.d.ts +13 -2
  74. package/types/adapters/postgres-adapter.d.ts +4 -2
  75. package/types/adapters/redis-adapter.d.ts +8 -0
  76. package/types/adapters/sqlite-adapter.d.ts +8 -2
  77. package/types/cli/generators/index.d.ts +11 -1
  78. package/types/cli/index.d.ts +12 -27
  79. package/types/cli/watch.d.ts +59 -0
  80. package/types/core/ctx-builder.d.ts +23 -0
  81. package/types/core/hub-link.d.ts +3 -1
  82. package/types/core/index.d.ts +1 -1
  83. package/types/core/mega-app.d.ts +1 -1
  84. package/types/core/pipeline.d.ts +2 -1
  85. package/types/core/security.d.ts +3 -3
  86. package/types/core/session-store.d.ts +7 -0
  87. package/types/core/ws-roster.d.ts +13 -1
  88. package/types/core/ws-upgrade.d.ts +29 -0
  89. package/types/lib/mega-circuit-breaker.d.ts +4 -2
  90. package/types/lib/mega-health.d.ts +7 -0
  91. package/types/lib/mega-job-queue.d.ts +16 -4
  92. package/types/lib/mega-job.d.ts +8 -1
  93. package/types/lib/mega-plugin.d.ts +1 -1
  94. package/types/lib/mega-worker.d.ts +3 -1
  95. package/types/lib/ws-hub.d.ts +27 -2
@@ -63,7 +63,7 @@ export class MegaApp {
63
63
  * @param {Object|false} [opts.helmet] - fastify-helmet 옵션 (보안 헤더). false=미등록, undefined=디폴트 ON
64
64
  * (ADR-047/127). 라우트 옵션 오버라이드는 완전 교체(ADR-073).
65
65
  * @param {Object|false} [opts.cors] - fastify-cors 옵션. false=미등록, undefined=origin:false(교차출처 거부, 안전 디폴트).
66
- * @param {Object|false} [opts.rateLimit] - fastify-rate-limit 옵션. false=미등록, undefined=디폴트(IP당 100/min, ADR-048).
66
+ * @param {Object|false} [opts.rateLimit] - fastify-rate-limit 옵션. false=미등록, undefined=디폴트(IP당 1000/min, ADR-048/197).
67
67
  * 멀티 인스턴스 분산 카운팅은 `caches.rate` 별명(Redis), 미선언 시 in-memory 폴백.
68
68
  * @param {Object|false} [opts.csrf] - fastify-csrf-protection 옵션. false=미등록, undefined=디폴트 ON.
69
69
  * ADR-051: JSON 요청은 토큰 면제+Origin 검증, 폼 요청만 토큰 검증.
@@ -233,53 +233,63 @@ export class MegaApp {
233
233
  Date.now()
234
234
  })
235
235
 
236
- // 1b) HTTP 루트 span (ADR-126) 옵트인 OFF 면 isEnabled() false 즉시 return(0 비용).
237
- // onRequest 에서 span 시작 + 활성 컨텍스트 진입(enterWith) 핸들러의 ctx.tracer.span·어댑터 호출이
238
- // span 자식으로 중첩됨. onResponse 에서 status_code 기록 종료. HTTP 요청은 독립 async
239
- // context enterWith 요청 누수 없이 격리된다(실측 확인 ADR-126).
240
- this.fastify.addHook('onRequest', async (req, reply) => {
241
- if (!MegaTracing.isEnabled()) return
242
- const route = /** @type {any} */ (req).routeOptions?.url ?? req.url
243
- const host = String(req.headers.host ?? '').split(':')[0]
244
- const handle = MegaTracing.enterHttpSpan({
245
- method: req.method,
246
- route,
247
- path: req.url,
248
- host,
249
- app: this.name,
250
- // inbound traceparent/tracestate 를 부모로 복원(W3C trace context, ADR-196) — 게이트웨이/업스트림
251
- // trace 루트 span 이 이어진다. 무효/부재 헤더는 종전대로 새 루트(fail-safe).
252
- headers: req.headers,
253
- })
254
- ;(/** @type {Record<symbol, any>} */ (/** @type {unknown} */ (reply)))[HTTP_SPAN_SYMBOL] = handle
255
- })
256
- // onError 는 핸들러/직렬화 throw 시 호출 — 예외를 span 에 기록(상태 ERROR). 종료는 onResponse 담당.
257
- this.fastify.addHook('onError', async (req, reply, err) => {
258
- const handle = (/** @type {Record<symbol, any>} */ (/** @type {unknown} */ (reply)))[HTTP_SPAN_SYMBOL]
259
- handle?.setError(err)
260
- // 보안 거부(ASP 복호/drift/replay/signal)도 활성 span 에 사유 기록(ADR-127).
261
- // CSRF/rate-limit 은 security.js 가 직접 박는다(throw 전·onExceeded). ASP 는 기존 코드 무변경 위해 여기서.
262
- if (err instanceof MegaAspDecryptError) {
263
- MegaTracing.tracer.activeSpan()?.setAttributes({ 'mega.security.reason': `asp.${err.rule}` })
264
- }
265
- })
266
- // onResponse 는 성공·에러 응답 모두에서 마지막에 호출 — 상태코드 기록 후 span 종료(단일 종료 지점).
267
- this.fastify.addHook('onResponse', async (req, reply) => {
268
- const handle = (/** @type {Record<symbol, any>} */ (/** @type {unknown} */ (reply)))[HTTP_SPAN_SYMBOL]
269
- handle?.finish(reply.statusCode)
270
- // HTTP 요청 메트릭 (ADR-131) — 옵트인 OFF 면 isEnabled() false 라 즉시 return(0 비용).
271
- // route 는 **매칭된 패턴**(routeOptions.url)만 — 매칭 안 되면(404) recordHttp 가 __unmatched__ 로 접어
272
- // 카디널리티 폭증 차단. reply.elapsedTime = onRequest~onResponse ms(Fastify 제공).
273
- if (MegaMetrics.isEnabled()) {
274
- MegaMetrics.recordHttp({
236
+ // 1b) HTTP 루트 span (ADR-126) + 메트릭 (ADR-131) **부팅 시점 분기** (G1 M-3, ADR-214).
237
+ // 이전엔 옵트인 OFF 여도 훅을 등록하고 본문에서 즉시 return 했는데, 본문 비용은 0 이어도
238
+ // Fastify async hook 디스패치(훅당 Promise 1개) 자체가 요청 남는다. 옵트인은 부팅(boot
239
+ // prepareRuntime metrics tracing MegaApp 생성 **전에** init)에 결정되므로, 생성 시점에
240
+ // OFF 훅을 아예 등록하지 않는다. 런타임 중 토글은 공개 표면에 없음(init 부팅 1회 계약 —
241
+ // 앱 생성 후 init 하는 직접 생성 경로는 미지원, ADR-214 명시).
242
+ const isTracingOn = MegaTracing.isEnabled()
243
+ const isMetricsOn = MegaMetrics.isEnabled()
244
+ if (isTracingOn) {
245
+ // onRequest 에서 span 시작 + 활성 컨텍스트 진입(enterWith) → 핸들러의 ctx.tracer.span·어댑터 호출이
246
+ // 이 span 의 자식으로 중첩됨. onResponse 에서 status_code 기록 후 종료. 각 HTTP 요청은 독립 async
247
+ // context 라 enterWith 가 요청 간 누수 없이 격리된다(실측 확인 — ADR-126).
248
+ this.fastify.addHook('onRequest', async (req, reply) => {
249
+ const route = /** @type {any} */ (req).routeOptions?.url ?? req.url
250
+ const host = String(req.headers.host ?? '').split(':')[0]
251
+ const handle = MegaTracing.enterHttpSpan({
275
252
  method: req.method,
276
- route: /** @type {any} */ (req).routeOptions?.url,
277
- statusCode: reply.statusCode,
278
- durationMs: reply.elapsedTime,
253
+ route,
254
+ path: req.url,
255
+ host,
279
256
  app: this.name,
257
+ // inbound traceparent/tracestate 를 부모로 복원(W3C trace context, ADR-196) — 게이트웨이/업스트림
258
+ // trace 에 루트 span 이 이어진다. 무효/부재 헤더는 종전대로 새 루트(fail-safe).
259
+ headers: req.headers,
280
260
  })
281
- }
282
- })
261
+ ;(/** @type {Record<symbol, any>} */ (/** @type {unknown} */ (reply)))[HTTP_SPAN_SYMBOL] = handle
262
+ })
263
+ // onError 는 핸들러/직렬화 throw 시 호출 — 예외를 span 에 기록(상태 ERROR). 종료는 onResponse 담당.
264
+ this.fastify.addHook('onError', async (req, reply, err) => {
265
+ const handle = (/** @type {Record<symbol, any>} */ (/** @type {unknown} */ (reply)))[HTTP_SPAN_SYMBOL]
266
+ handle?.setError(err)
267
+ // 보안 거부(ASP 복호/drift/replay/signal)도 활성 span 에 사유 기록(ADR-127).
268
+ // CSRF/rate-limit 은 security.js 가 직접 박는다(throw 전·onExceeded). ASP 는 기존 코드 무변경 위해 여기서.
269
+ if (err instanceof MegaAspDecryptError) {
270
+ MegaTracing.tracer.activeSpan()?.setAttributes({ 'mega.security.reason': `asp.${err.rule}` })
271
+ }
272
+ })
273
+ }
274
+ // onResponse 는 성공·에러 응답 모두에서 마지막에 호출 — 상태코드 기록 후 span 종료(단일 종료 지점).
275
+ // 트레이싱(span finish)·메트릭(recordHttp) 둘 중 하나라도 켜져 있을 때만 등록.
276
+ if (isTracingOn || isMetricsOn) {
277
+ this.fastify.addHook('onResponse', async (req, reply) => {
278
+ const handle = (/** @type {Record<symbol, any>} */ (/** @type {unknown} */ (reply)))[HTTP_SPAN_SYMBOL]
279
+ handle?.finish(reply.statusCode)
280
+ // HTTP 요청 메트릭 (ADR-131) — route 는 **매칭된 패턴**(routeOptions.url)만. 매칭 안 되면(404)
281
+ // recordHttp 가 __unmatched__ 로 접어 카디널리티 폭증 차단. reply.elapsedTime = Fastify 제공 ms.
282
+ if (isMetricsOn && MegaMetrics.isEnabled()) {
283
+ MegaMetrics.recordHttp({
284
+ method: req.method,
285
+ route: /** @type {any} */ (req).routeOptions?.url,
286
+ statusCode: reply.statusCode,
287
+ durationMs: reply.elapsedTime,
288
+ app: this.name,
289
+ })
290
+ }
291
+ })
292
+ }
283
293
 
284
294
  // 2) 자동 envelope (ADR-018, ADR-076).
285
295
  // onRoute 훅으로 각 라우트의 preSerialization 체인 "맨 끝" 에 wrapEnvelope 를 붙인다.
@@ -21,19 +21,20 @@
21
21
  *
22
22
  * @module core/pipeline
23
23
  */
24
- import { getHttpCtx } from './ctx-builder.js'
24
+ import { getLazyHttpCtx } from './ctx-builder.js'
25
25
 
26
26
  /**
27
27
  * 미들웨어를 Fastify 가 async preHandler 로 인식하는 arity-2 래퍼로 감싸고, canonical ctx 를
28
28
  * 3번째 인자로 주입한다 — 핸들러·글로벌 미들웨어와 동일한 `(req, reply, ctx)` 계약(ADR-134/184).
29
- * getHttpCtx요청당 캐싱이라 같은 요청의 모든 미들웨어·핸들러가 동일 ctx 객체를 공유한다.
29
+ * ctx**lazy 프록시**(ADR-214) 미들웨어가 실제로 속성을 만질 때만 빌드되고, 요청당 캐싱이라
30
+ * 같은 요청의 모든 미들웨어·핸들러가 동일 ctx 객체를 공유한다.
30
31
  *
31
32
  * @param {Function} fn - `(req, reply, ctx?)` 미들웨어 (arity-2 도 하위 호환 — 3번째 인자 무시).
32
33
  * @param {import('./mega-app.js').MegaApp | null} [app] - ctx 의 어댑터·서비스 접근자 출처(없으면 null).
33
34
  * @returns {(req: import('fastify').FastifyRequest, reply: import('fastify').FastifyReply) => Promise<any>}
34
35
  */
35
36
  export function wrapPreHandler(fn, app) {
36
- return async (req, reply) => fn(req, reply, getHttpCtx({ app: app ?? null, req, reply }))
37
+ return async (req, reply) => fn(req, reply, getLazyHttpCtx({ app: app ?? null, req, reply }))
37
38
  }
38
39
 
39
40
  /**
@@ -102,9 +103,10 @@ export function composeAfter(afters, { method, path }) {
102
103
  export function buildHttpPipeline({ app = null, method, path, handler, before = [], transform = [], after = [] }) {
103
104
  /** @type {HttpPipeline} */
104
105
  const pipeline = {
105
- // canonical 핸들러 시그니처 (req, res, ctx) (ADR-074, docs/03 §581). getHttpCtx요청당 1회
106
- // 캐싱이라 글로벌·before 미들웨어가 먼저 만든 ctx 그대로 이어받는다(ADR-134).
107
- handler: async (req, reply) => handler(req, reply, getHttpCtx({ app, req, reply })),
106
+ // canonical 핸들러 시그니처 (req, res, ctx) (ADR-074, docs/03 §581). ctxlazy 프록시
107
+ // (ADR-214) 핸들러가 쓰면 buildHttpCtx 비용(1.6 KB + 0.7 µs/req, G1 H-1)이 발생하지 않고,
108
+ // 접근부터는 요청당 1회 캐싱이라 글로벌·before 미들웨어가 먼저 만든 ctx 를 그대로 이어받는다(ADR-134).
109
+ handler: async (req, reply) => handler(req, reply, getLazyHttpCtx({ app, req, reply })),
108
110
  describe: () => ({
109
111
  method,
110
112
  path,
@@ -20,6 +20,7 @@ export const GLOBAL_ONLY_KEYS = Object.freeze([
20
20
  'jobs', // MegaJob 서브클래스 배열 — mega worker 가 소비 (ADR-123)
21
21
  'schedules', // MegaSchedule 서브클래스 배열 — mega scheduler 가 소비 (ADR-123)
22
22
  'workers', // MegaWorker 서브클래스 배열 — CPU 워커 풀, ctx.workers.<name> 배선 (ADR-124)
23
+ 'watch', // mega start --watch 의 ignore/paths 정책 (ADR-220)
23
24
  ])
24
25
 
25
26
  /** apps/<name>/app.config.js 에만 허용되는 키 */
@@ -43,8 +43,8 @@ import { registerAspPlugin } from '../lib/asp/plugin.js'
43
43
  import { MegaForbiddenError } from '../errors/http-errors.js'
44
44
  import * as MegaTracing from '../lib/mega-tracing.js'
45
45
 
46
- /** rate-limit 기본값 (ADR-048: IP 당 100 req/min). 사용자 config 로 완전 교체(ADR-073). */
47
- export const DEFAULT_RATE_LIMIT = Object.freeze({ max: 100, timeWindow: '1 minute' })
46
+ /** rate-limit 기본값 (IP 당 1000 req/min — ADR-048 의 100/min 은 평상 트래픽도 429 를 만드는 운영 함정이라 상향, ADR-214). 사용자 config 로 완전 교체(ADR-073). */
47
+ export const DEFAULT_RATE_LIMIT = Object.freeze({ max: 1000, timeWindow: '1 minute' })
48
48
 
49
49
  /** CSRF 토큰 검증을 건너뛰는 안전 메서드(상태 변경 없음). */
50
50
  const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
@@ -83,7 +83,7 @@ function annotateReject(reason, extra = {}) {
83
83
  * @param {string[]} [opts.hosts] - 앱 도메인 목록(CSRF Origin 검증 allowlist, ADR-051).
84
84
  * @param {Object|false} [opts.helmet] - fastify-helmet 옵션. false=미등록, undefined=디폴트 ON.
85
85
  * @param {Object|false} [opts.cors] - fastify-cors 옵션. false=미등록, undefined=origin:false(교차출처 거부).
86
- * @param {Object|false} [opts.rateLimit] - fastify-rate-limit 옵션. false=미등록, undefined=디폴트(100/min).
86
+ * @param {Object|false} [opts.rateLimit] - fastify-rate-limit 옵션. false=미등록, undefined=디폴트(1000/min, ADR-214).
87
87
  * @param {Object|false} [opts.csrf] - fastify-csrf-protection 옵션. false=미등록, undefined=디폴트 ON.
88
88
  * @param {{ masterSecret?: string, http?: { enabledPaths?: string[], driftMs?: number, headerSignal?: string, timestampHeader?: string, nonceCache?: any } }} [opts.asp] -
89
89
  * ASP 설정. `masterSecret` + `http.enabledPaths`(비어있지 않음)가 둘 다 있을 때만 HTTP ASP 등록.
@@ -23,6 +23,14 @@ const SESSION_DRIVERS = Object.freeze({
23
23
  redis: MegaRedisSessionAdapter,
24
24
  })
25
25
 
26
+ /**
27
+ * file driver 만료 스캔 cleanup 기본 주기(1시간, ms) — ADR-046 의 "file 모드 cleanup 자동" 약속 구현(ADR-215).
28
+ * 어댑터 자체 디폴트는 0(off)이라, 팩토리 경유 생성(=프레임워크 부팅 경로)에서만 기본 주기를 주입한다.
29
+ * 만료 파일의 lazy 삭제는 같은 sid 재접근 시에만 일어나므로, 주기 스캔이 없으면 미접근 만료 세션이
30
+ * 디스크에 무한 누적된다(G5 audit M-3). `cleanupIntervalMs: 0` 명시로 옵트아웃(외부 cron/scheduler 운용 시).
31
+ */
32
+ export const DEFAULT_FILE_CLEANUP_INTERVAL_MS = 3_600_000
33
+
26
34
  /**
27
35
  * 세션 스토어 어댑터를 만든다(connect 는 호출자 책임 — MegaApp 가 부팅 시 connect).
28
36
  *
@@ -47,7 +55,12 @@ export function createSessionStore(storeConfig, defaults = {}) {
47
55
  }
48
56
  const Cls = SESSION_DRIVERS[/** @type {'file'|'redis'} */ (driver)]
49
57
  // store 에 ttlMs 가 명시되지 않았으면 미들웨어 기본 ttlMs 를 주입(단일 출처 — session.ttlMs).
50
- const cfg = storeConfig.ttlMs === undefined && defaults.ttlMs !== undefined ? { ...storeConfig, ttlMs: defaults.ttlMs } : storeConfig
58
+ let cfg = storeConfig.ttlMs === undefined && defaults.ttlMs !== undefined ? { ...storeConfig, ttlMs: defaults.ttlMs } : storeConfig
59
+ // file driver 는 만료 스캔 cleanup 을 기본 1h 주기로 — 미지정 시에만 주입(명시 0 = 옵트아웃, ADR-215).
60
+ // 타이머는 어댑터 _connect 가 unref() 로 걸어 프로세스 종료를 막지 않는다.
61
+ if (driver === 'file' && cfg.cleanupIntervalMs === undefined) {
62
+ cfg = { ...cfg, cleanupIntervalMs: DEFAULT_FILE_CLEANUP_INTERVAL_MS }
63
+ }
51
64
  return new Cls(/** @type {any} */ (cfg))
52
65
  }
53
66
 
@@ -18,6 +18,7 @@ import { MegaHubLink } from './hub-link.js'
18
18
  import { CLOSE_CODE_REQUEUE } from './ws-upgrade.js'
19
19
  import { HUB_MESSAGE_TYPES } from '../lib/hub-protocol.js'
20
20
  import { MegaShutdown } from '../lib/mega-shutdown.js'
21
+ import * as MegaHealth from '../lib/mega-health.js'
21
22
 
22
23
  export class MegaWsPresence {
23
24
  /**
@@ -113,16 +114,26 @@ export class MegaWsPresence {
113
114
  link.on(HUB_MESSAGE_TYPES.HEARTBEAT, noopHub)
114
115
  // 재연결 성공 시 presence 재동기화(ADR-098) — hub 는 절단 시점 presence 를 잃으므로 broadcast 채널을 다시 JOIN.
115
116
  link.on(MegaHubLink.EVENTS.RECONNECTED, () => this._resyncPresence())
117
+
118
+ // shutdown hook 은 connect **전에** 등록한다(L1 dedup 유지) — 초기 연결이 실패해 백그라운드
119
+ // 재시도로 전환돼도(아래 throw) hook 이 link.close 로 백오프를 abort 해 프로세스가 행하지 않는다.
120
+ const hookName = `mega-hublink:${this._appName}`
121
+ MegaShutdown.unregister(hookName)
122
+ MegaShutdown.register(hookName, async () => link.close())
123
+
124
+ // readiness 에 hub link 상태 노출 — hub 는 best-effort(다운이어도 부팅·로컬 전달 지속)라 readiness 를
125
+ // **게이트하지 않는다**(ok 고정 true — 어댑터 자동 체크와 달리 503 원인이 되면 안 됨). 연결 상태는
126
+ // detail 필드(registered/open)로 노출된다(기본 응답은 ok 만 — 상세는 health.exposeCheckDetails 옵트인).
127
+ MegaHealth.register(`hub:${this._appName}`, () => ({ ok: true, registered: link.isRegistered, open: link.isOpen }))
128
+
129
+ // 초기 연결은 단 1회 시도(hub-link connect 계약) — 실패 시 reject 가 전파되고(호출부 boot 가 warn 후
130
+ // 부팅 계속) retry 설정이 있으면 link 가 백그라운드로 재시도한다. RECONNECTED 핸들러(위)가 성공 시
131
+ // presence 를 재동기화하므로 여기 실패해도 채널 구독은 복구된다.
116
132
  await link.connect()
117
133
 
118
134
  // 채널 구독 — bridge-subscriber 세션 JOIN(zero-config 브로드캐스트 수신용) + 이미 붙어 있는 실
119
135
  // 사용자 세션 재JOIN(예: 첫 connect 전에 클라가 연결됐을 수 있음).
120
136
  this._resyncPresence()
121
-
122
- // shutdown hook 누수 방지(L1) — 재연결 등으로 connectHub 가 다시 불려도 hook 은 항상 1개.
123
- const hookName = `mega-hublink:${this._appName}`
124
- MegaShutdown.unregister(hookName)
125
- MegaShutdown.register(hookName, async () => link.close())
126
137
  return link
127
138
  }
128
139
 
@@ -601,6 +612,7 @@ export class MegaWsPresence {
601
612
  this._hubLink = null
602
613
  this._hubBridgeId = null
603
614
  MegaShutdown.unregister(`mega-hublink:${this._appName}`)
615
+ MegaHealth.unregister(`hub:${this._appName}`) // 닫힌 link 의 stale 체크 제거(register 와 짝맞춤).
604
616
  }
605
617
  // NATS 클러스터 정리(ADR-176) — 구독·heartbeat/sweep 타이머·roster 멤버 해제. boot 의 shutdown hook 과
606
618
  // **양쪽**에서 정리해야 시그널을 안 거치는 종료(server.close()/프로그램적 재시작/멀티앱 부분 종료)에서도
@@ -74,9 +74,25 @@ export class MegaWsRedisRoster {
74
74
  if (typeof channel !== 'string' || typeof sessionId !== 'string') return
75
75
  const key = this._key(channel)
76
76
  const value = JSON.stringify({ userId: member?.userId, ...(member?.metadata ? { metadata: member.metadata } : {}), expiresAt: Date.now() + this._ttlMs })
77
- await this._redis.hset(key, sessionId, value)
78
- // 채널 HASH 전체 TTL 모든 멤버가 stale 돼도 키가 영구히 남지 않게(멤버 TTL 의 2배 여유).
79
- await this._redis.pexpire(key, this._ttlMs * 2)
77
+ // HSET + 채널 HASH 키 TTL(멤버 TTL 2배 — 전원 stale 돼도 키가 영구히 안 남게)을 pipeline 1 RT 로.
78
+ // 직렬 await 2회는 joinSession(채널 수만큼 add)·heartbeat 경로에서 왕복 수가 비용을 지배했다.
79
+ const results = await this._redis.pipeline().hset(key, sessionId, value).pexpire(key, this._ttlMs * 2).exec()
80
+ this._throwFirstPipelineError(results)
81
+ }
82
+
83
+ /**
84
+ * ioredis pipeline.exec() 결과(`[err, res][]`)에서 첫 명령 오류를 throw 한다 — pipeline 은 명령별
85
+ * 오류를 reject 가 아니라 결과 배열에 싣기 때문에, 묵히면 호출부의 기존 실패 처리(catch+warn)가
86
+ * 더는 동작하지 않는다(직렬 await 시절의 throw 계약 유지).
87
+ * @param {Array<[Error | null, any]> | null} results
88
+ * @returns {void}
89
+ * @private
90
+ */
91
+ _throwFirstPipelineError(results) {
92
+ if (!Array.isArray(results)) return
93
+ for (const [err] of results) {
94
+ if (err) throw err
95
+ }
80
96
  }
81
97
 
82
98
  /**
@@ -139,13 +155,29 @@ export class MegaWsRedisRoster {
139
155
  }
140
156
 
141
157
  /**
142
- * 로컬 멤버 전체를 다시 add(=expiresAt 갱신). @returns {Promise<void>} @private
158
+ * 로컬 멤버 전체의 `expiresAt` 을 한 pipeline 으로 일괄 갱신. 직렬 add 루프(멤버당 2 RT) 멤버
159
+ * 1,000 에 주기당 수백 ms — 갱신 지연이 TTL 윈도를 침식해 살아있는 세션이 lazy 만료될 수 있었다.
160
+ * pipeline 이면 단일 왕복 수준(실측 ~200×). 채널 키 PEXPIRE 는 채널당 1회만 싣는다.
161
+ * @returns {Promise<void>} @private
143
162
  */
144
163
  async _refreshLocal() {
145
164
  const members = this._getLocalMembers()
165
+ if (members.length === 0) return
166
+ const expiresAt = Date.now() + this._ttlMs
167
+ const pipeline = this._redis.pipeline()
168
+ /** @type {Set<string>} PEXPIRE 를 이미 실은 채널(중복 제거). */
169
+ const touchedChannels = new Set()
146
170
  for (const { channel, sessionId, member } of members) {
147
- await this.add(channel, sessionId, member)
171
+ if (typeof channel !== 'string' || typeof sessionId !== 'string') continue
172
+ const key = this._key(channel)
173
+ const value = JSON.stringify({ userId: member?.userId, ...(member?.metadata ? { metadata: member.metadata } : {}), expiresAt })
174
+ pipeline.hset(key, sessionId, value)
175
+ if (!touchedChannels.has(channel)) {
176
+ touchedChannels.add(channel)
177
+ pipeline.pexpire(key, this._ttlMs * 2)
178
+ }
148
179
  }
180
+ this._throwFirstPipelineError(await pipeline.exec())
149
181
  }
150
182
 
151
183
  /**
@@ -155,12 +187,19 @@ export class MegaWsRedisRoster {
155
187
  async stop() {
156
188
  if (this._hbTimer) clearInterval(this._hbTimer)
157
189
  this._hbTimer = null
158
- // graceful: 자기 로컬 멤버 즉시 제거(crash 가 아닌 정상 종료라 TTL 대기 없이 정리).
190
+ // graceful: 자기 로컬 멤버 즉시 제거(crash 가 아닌 정상 종료라 TTL 대기 없이 정리) — 한 pipeline 으로.
159
191
  // 실패해도 종료는 계속(lazy 만료가 정리) — 단 명단에 TTL 까지 남으므로 묵히지 않고 알린다.
160
- for (const { channel, sessionId } of this._getLocalMembers()) {
161
- await this.remove(channel, sessionId).catch((err) =>
162
- this._log?.warn?.({ err, channel, sessionId }, 'ws-roster graceful remove failed (stale until TTL)'),
163
- )
192
+ const members = this._getLocalMembers()
193
+ if (members.length === 0) return
194
+ const pipeline = this._redis.pipeline()
195
+ for (const { channel, sessionId } of members) {
196
+ if (typeof channel !== 'string' || typeof sessionId !== 'string') continue
197
+ pipeline.hdel(this._key(channel), sessionId)
198
+ }
199
+ try {
200
+ this._throwFirstPipelineError(await pipeline.exec())
201
+ } catch (err) {
202
+ this._log?.warn?.({ err }, 'ws-roster graceful remove failed (stale until TTL)')
164
203
  }
165
204
  }
166
205
  }
@@ -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 }