mega-framework 0.1.6 → 0.1.7

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 (233) hide show
  1. package/bin/mega-ws-hub.js +2 -2
  2. package/package.json +32 -8
  3. package/sample/crud/.env +1 -1
  4. package/sample/crud/.env.example +1 -1
  5. package/sample/crud/.mega/journal/history/20260612092543-create-users.json +261 -0
  6. package/sample/crud/.mega/journal/snapshot.json +261 -0
  7. package/sample/crud/apps/main/controllers/auth-controller.js +22 -14
  8. package/sample/crud/apps/main/controllers/web-controller.js +7 -5
  9. package/sample/crud/apps/main/migrations/20260606000001-create-users.js +91 -13
  10. package/sample/crud/apps/main/migrations/20260606000002-create-boards.js +165 -0
  11. package/sample/crud/apps/main/migrations/20260606000003-create-logs.js +107 -0
  12. package/sample/crud/apps/main/models/log-partition-model.js +105 -0
  13. package/sample/crud/apps/main/models/note-model.js +79 -0
  14. package/sample/crud/apps/main/models/user-level-model.js +24 -0
  15. package/sample/crud/apps/main/models/user-model.js +146 -0
  16. package/sample/crud/apps/main/models/user-type-model.js +21 -0
  17. package/sample/crud/apps/main/models/wallet-model.js +24 -0
  18. package/sample/crud/apps/main/routes/users.js +55 -10
  19. package/sample/crud/apps/main/schedules/log-partition-schedule.js +33 -0
  20. package/sample/crud/apps/main/services/auth-service.js +39 -24
  21. package/sample/crud/apps/main/services/log-partition-service.js +101 -0
  22. package/sample/crud/apps/main/services/note-service.js +6 -6
  23. package/sample/crud/apps/main/services/redis-demo-service.js +3 -3
  24. package/sample/crud/apps/main/services/user-service.js +62 -21
  25. package/sample/crud/apps/main/views/auth/login.ejs +6 -6
  26. package/sample/crud/apps/main/views/auth/register.ejs +46 -5
  27. package/sample/crud/apps/main/views/users/edit.ejs +42 -5
  28. package/sample/crud/apps/main/views/users/list.ejs +6 -2
  29. package/sample/crud/apps/main/views/users/new.ejs +56 -4
  30. package/sample/crud/docs/log_partition_design.mm.md +23 -0
  31. package/sample/crud/mega.config.js +3 -2
  32. package/sample/crud/package.json +1 -1
  33. package/sample/crud/scripts/start-ws-hub.sh +2 -2
  34. package/sample/simple/package.json +2 -2
  35. package/src/adapters/adapter-manager.js +2 -1
  36. package/src/adapters/adapter-options.js +30 -0
  37. package/src/adapters/maria-adapter.js +26 -3
  38. package/src/adapters/mega-db-adapter.js +7 -1
  39. package/src/adapters/mongo-adapter.js +19 -1
  40. package/src/adapters/postgres-adapter.js +25 -2
  41. package/src/adapters/sqlite-adapter.js +20 -1
  42. package/src/cli/commands/new.js +13 -3
  43. package/src/cli/commands/scaffold.js +137 -33
  44. package/src/cli/generators/index.js +82 -2
  45. package/src/cli/index.js +353 -100
  46. package/src/core/ajv-mapper.js +27 -2
  47. package/src/core/boot.js +464 -245
  48. package/src/core/cluster-metrics.js +13 -4
  49. package/src/core/ctx-builder.js +6 -2
  50. package/src/core/envelope.js +112 -12
  51. package/src/core/hub-link.js +65 -4
  52. package/src/core/i18n.js +11 -1
  53. package/src/core/index.js +6 -2
  54. package/src/core/mega-app.js +201 -463
  55. package/src/core/mega-cluster.js +4 -1
  56. package/src/core/mega-server.js +40 -9
  57. package/src/core/migration/dialect-registry.js +107 -0
  58. package/src/core/migration/dialects/README.md +62 -0
  59. package/src/core/migration/dialects/maria.js +496 -0
  60. package/src/core/migration/dialects/mongo.js +824 -0
  61. package/src/core/migration/dialects/postgres.js +563 -0
  62. package/src/core/migration/dialects/sqlite.js +476 -0
  63. package/src/core/migration/differ.js +456 -0
  64. package/src/core/migration/generate.js +508 -0
  65. package/src/core/migration/journal.js +167 -0
  66. package/src/core/migration/model-scan.js +84 -0
  67. package/src/core/migration/mongo-migration-db.js +97 -0
  68. package/src/core/migration/schema-builder.js +400 -0
  69. package/src/core/migration/schema-validator.js +315 -0
  70. package/src/core/migration-lock.js +205 -0
  71. package/src/core/migration-runner.js +166 -38
  72. package/src/core/multipart.js +28 -5
  73. package/src/core/pipeline.js +129 -0
  74. package/src/core/router.js +70 -65
  75. package/src/core/security.js +67 -9
  76. package/src/core/workers-manager.js +12 -1
  77. package/src/core/ws-cluster.js +10 -3
  78. package/src/core/ws-message.js +48 -4
  79. package/src/core/ws-presence.js +624 -0
  80. package/src/core/ws-roster.js +4 -1
  81. package/src/core/ws-upgrade.js +118 -12
  82. package/src/index.js +1 -1
  83. package/src/lib/hub-protocol.js +29 -0
  84. package/src/lib/mega-health.js +25 -4
  85. package/src/lib/mega-job-queue.js +98 -21
  86. package/src/lib/mega-job.js +29 -0
  87. package/src/lib/mega-metrics.js +3 -12
  88. package/src/lib/mega-plugin.js +34 -3
  89. package/src/lib/mega-schedule.js +40 -22
  90. package/src/lib/mega-shutdown.js +114 -39
  91. package/src/lib/mega-tracing.js +66 -19
  92. package/src/lib/mega-worker.js +5 -1
  93. package/src/lib/otel-resource.js +36 -0
  94. package/src/{cli → lib}/ws-hub.js +51 -8
  95. package/src/models/crud-sql-builder.js +133 -0
  96. package/src/models/mega-model.js +82 -2
  97. package/src/models/model-crud.js +483 -0
  98. package/src/models/mongo-crud.js +285 -0
  99. package/templates/model/code-mongo.tpl +35 -0
  100. package/templates/model/code.tpl +15 -1
  101. package/templates/model/test-mongo.tpl +38 -0
  102. package/templates/model/test.tpl +4 -0
  103. package/types/adapters/adapter-manager.d.ts +95 -0
  104. package/types/adapters/adapter-options.d.ts +91 -0
  105. package/types/adapters/file-adapter.d.ts +94 -0
  106. package/types/adapters/file-session-adapter.d.ts +101 -0
  107. package/types/adapters/index.d.ts +20 -0
  108. package/types/adapters/maria-adapter.d.ts +115 -0
  109. package/types/adapters/mega-adapter.d.ts +215 -0
  110. package/types/adapters/mega-bus-adapter.d.ts +45 -0
  111. package/types/adapters/mega-cache-adapter.d.ts +47 -0
  112. package/types/adapters/mega-db-adapter.d.ts +47 -0
  113. package/types/adapters/mega-lock-adapter.d.ts +62 -0
  114. package/types/adapters/mega-log-sink-adapter.d.ts +15 -0
  115. package/types/adapters/mega-session-adapter.d.ts +32 -0
  116. package/types/adapters/mongo-adapter.d.ts +139 -0
  117. package/types/adapters/nats-adapter.d.ts +108 -0
  118. package/types/adapters/postgres-adapter.d.ts +139 -0
  119. package/types/adapters/redis-adapter.d.ts +70 -0
  120. package/types/adapters/redis-session-adapter.d.ts +82 -0
  121. package/types/adapters/redlock-adapter.d.ts +149 -0
  122. package/types/adapters/registry.d.ts +46 -0
  123. package/types/adapters/sqlite-adapter.d.ts +106 -0
  124. package/types/auth/index.d.ts +24 -0
  125. package/types/cli/commands/console-cmd.d.ts +37 -0
  126. package/types/cli/commands/new.d.ts +16 -0
  127. package/types/cli/commands/routes.d.ts +36 -0
  128. package/types/cli/commands/scaffold.d.ts +78 -0
  129. package/types/cli/commands/test-cmd.d.ts +14 -0
  130. package/types/cli/generators/index.d.ts +112 -0
  131. package/types/cli/index.d.ts +249 -0
  132. package/types/cli/template-engine.d.ts +40 -0
  133. package/types/core/ajv-mapper.d.ts +27 -0
  134. package/types/core/boot.d.ts +233 -0
  135. package/types/core/cluster-metrics.d.ts +52 -0
  136. package/types/core/config-loader.d.ts +13 -0
  137. package/types/core/config-validator.d.ts +30 -0
  138. package/types/core/ctx-builder.d.ts +80 -0
  139. package/types/core/envelope.d.ts +79 -0
  140. package/types/core/error-mapper.d.ts +17 -0
  141. package/types/core/formbody.d.ts +41 -0
  142. package/types/core/hub-link.d.ts +264 -0
  143. package/types/core/i18n.d.ts +178 -0
  144. package/types/core/index.d.ts +28 -0
  145. package/types/core/mega-app.d.ts +529 -0
  146. package/types/core/mega-cluster.d.ts +104 -0
  147. package/types/core/mega-server.d.ts +91 -0
  148. package/types/core/mega-service.d.ts +31 -0
  149. package/types/core/migration/dialect-registry.d.ts +22 -0
  150. package/types/core/migration/dialects/maria.d.ts +99 -0
  151. package/types/core/migration/dialects/mongo.d.ts +89 -0
  152. package/types/core/migration/dialects/postgres.d.ts +117 -0
  153. package/types/core/migration/dialects/sqlite.d.ts +111 -0
  154. package/types/core/migration/differ.d.ts +47 -0
  155. package/types/core/migration/generate.d.ts +56 -0
  156. package/types/core/migration/journal.d.ts +52 -0
  157. package/types/core/migration/model-scan.d.ts +19 -0
  158. package/types/core/migration/mongo-migration-db.d.ts +7 -0
  159. package/types/core/migration/schema-builder.d.ts +197 -0
  160. package/types/core/migration/schema-validator.d.ts +20 -0
  161. package/types/core/migration-lock.d.ts +33 -0
  162. package/types/core/migration-runner.d.ts +101 -0
  163. package/types/core/multipart.d.ts +86 -0
  164. package/types/core/openapi.d.ts +62 -0
  165. package/types/core/pipeline.d.ts +92 -0
  166. package/types/core/router.d.ts +159 -0
  167. package/types/core/routes-loader.d.ts +21 -0
  168. package/types/core/scope-registry.d.ts +14 -0
  169. package/types/core/security.d.ts +77 -0
  170. package/types/core/services-loader.d.ts +27 -0
  171. package/types/core/session-cleanup-schedule.d.ts +19 -0
  172. package/types/core/session-store.d.ts +18 -0
  173. package/types/core/session.d.ts +77 -0
  174. package/types/core/static-assets.d.ts +73 -0
  175. package/types/core/template.d.ts +106 -0
  176. package/types/core/workers-manager.d.ts +79 -0
  177. package/types/core/ws-cluster.d.ts +208 -0
  178. package/types/core/ws-compression.d.ts +112 -0
  179. package/types/core/ws-controller.d.ts +65 -0
  180. package/types/core/ws-message.d.ts +106 -0
  181. package/types/core/ws-presence.d.ts +273 -0
  182. package/types/core/ws-roster.d.ts +96 -0
  183. package/types/core/ws-upgrade.d.ts +231 -0
  184. package/types/errors/config-error.d.ts +10 -0
  185. package/types/errors/http-errors.d.ts +120 -0
  186. package/types/errors/index.d.ts +3 -0
  187. package/types/errors/mega-error.d.ts +32 -0
  188. package/types/index.d.ts +39 -0
  189. package/types/lib/asp/config.d.ts +49 -0
  190. package/types/lib/asp/crypto.d.ts +43 -0
  191. package/types/lib/asp/errors.d.ts +30 -0
  192. package/types/lib/asp/nonce-cache.d.ts +52 -0
  193. package/types/lib/asp/plugin.d.ts +30 -0
  194. package/types/lib/asp/ws-terminator.d.ts +45 -0
  195. package/types/lib/env-mapper.d.ts +14 -0
  196. package/types/lib/hub-protocol.d.ts +106 -0
  197. package/types/lib/index.d.ts +22 -0
  198. package/types/lib/logger/telegram-core.d.ts +104 -0
  199. package/types/lib/logger/telegram-transport.d.ts +45 -0
  200. package/types/lib/mega-brute-force.d.ts +66 -0
  201. package/types/lib/mega-circuit-breaker.d.ts +241 -0
  202. package/types/lib/mega-cron.d.ts +66 -0
  203. package/types/lib/mega-hash.d.ts +32 -0
  204. package/types/lib/mega-health.d.ts +41 -0
  205. package/types/lib/mega-job-queue.d.ts +176 -0
  206. package/types/lib/mega-job-worker.d.ts +130 -0
  207. package/types/lib/mega-job.d.ts +138 -0
  208. package/types/lib/mega-logger.d.ts +45 -0
  209. package/types/lib/mega-metrics.d.ts +285 -0
  210. package/types/lib/mega-plugin.d.ts +245 -0
  211. package/types/lib/mega-retry.d.ts +85 -0
  212. package/types/lib/mega-schedule.d.ts +260 -0
  213. package/types/lib/mega-shutdown.d.ts +135 -0
  214. package/types/lib/mega-tracing.d.ts +224 -0
  215. package/types/lib/mega-worker.d.ts +127 -0
  216. package/types/lib/otel-resource.d.ts +16 -0
  217. package/types/lib/worker-runner/process-entry.d.ts +1 -0
  218. package/types/lib/worker-runner/task-dispatch.d.ts +28 -0
  219. package/types/lib/worker-runner/thread-entry.d.ts +1 -0
  220. package/types/lib/ws-hub.d.ts +234 -0
  221. package/types/models/crud-sql-builder.d.ts +48 -0
  222. package/types/models/index.d.ts +1 -0
  223. package/types/models/mega-model.d.ts +138 -0
  224. package/types/models/model-crud.d.ts +82 -0
  225. package/types/models/mongo-crud.d.ts +59 -0
  226. package/types/test/index.d.ts +84 -0
  227. package/.env +0 -127
  228. package/sample/crud/apps/main/migrations/20260606000002-add-auth-to-users.js +0 -30
  229. package/sample/crud/apps/main/models/note.js +0 -71
  230. package/sample/crud/apps/main/models/user.js +0 -86
  231. package/sample/crud/package-lock.json +0 -5665
  232. package/sample/crud/yarn.lock +0 -2142
  233. package/sample/simple/package-lock.json +0 -1851
@@ -25,7 +25,7 @@
25
25
  *
26
26
  * @module core/ws-upgrade
27
27
  */
28
- import { createWsMessage, parseWsMessage, generateMessageId, WS_TYPE_PATTERN } from './ws-message.js'
28
+ import { createWsMessage, parseWsMessage, generateMessageId, WS_TYPE_PATTERN, WS_PROTOCOL_VERSION } from './ws-message.js'
29
29
  import { MegaAspDecryptError } from '../lib/asp/errors.js'
30
30
  import * as MegaTracing from '../lib/mega-tracing.js'
31
31
  import * as MegaMetrics from '../lib/mega-metrics.js'
@@ -49,9 +49,50 @@ export const CLOSE_CODE_INTERNAL_ERROR = 1011
49
49
  /** 느린 소비자 백프레셔 close code (RFC 6455 §7.4.1 표준 1013 "Try Again Later", Med). */
50
50
  export const CLOSE_CODE_SLOW_CONSUMER = 1013
51
51
 
52
+ /**
53
+ * 재배치(requeue) close code — 4503. "이 워커 말고 다른 곳으로 즉시 재연결하라"는 신호로,
54
+ * bridge↔hub 의 drain(4503, `hub-protocol.js` CLOSE_CODE_DRAIN)과 같은 코드를 써서 클라이언트가
55
+ * 한 가지 규약("4503 = 세션 유지한 채 재연결")으로 두 링크를 모두 처리한다. admin-kick 의
56
+ * `requeue: true`(hub.disconnect) 가 사용 — kick(1008, 돌아오지 마라)과 의미가 반대다.
57
+ * HTTP 세션은 보존되므로 재연결 시 `before` 인증이 세션 쿠키로 자연 통과한다(transparent re-route).
58
+ */
59
+ export const CLOSE_CODE_REQUEUE = 4503
60
+
52
61
  /** send 버퍼(bufferedAmount) 기본 상한(바이트). 초과 = 소비자가 ack 를 못 따라옴 → 연결 종료(서버 OOM 방지). */
53
62
  export const DEFAULT_MAX_BUFFERED_BYTES = 16 * 1024 * 1024
54
63
 
64
+ /**
65
+ * 클라↔bridge ping/pong liveness 기본 주기(ms) — 30초. 주기마다 ping 을 보내고 직전 주기의 pong 이
66
+ * 없으면 half-open(상대 사망·네트워크 단절) 으로 보고 terminate 한다 — 좀비 연결이 OS TCP 타임아웃까지
67
+ * `_wsConns`/roster 에 잔존하는 것을 막는다. 라우트 `opts.heartbeatMs` 로 조정, `0` = 끔.
68
+ */
69
+ export const DEFAULT_WS_HEARTBEAT_MS = 30_000
70
+
71
+ /**
72
+ * onConnect 완료 전 도착한 프레임의 대기 큐 상한 — 초과 시 연결 종료(1013). onConnect 가 느릴 때
73
+ * 악의적/과속 클라이언트가 큐로 메모리를 채우는 것을 막는다.
74
+ */
75
+ export const MAX_PENDING_FRAMES_BEFORE_CONNECT = 256
76
+
77
+ /**
78
+ * 라우트 opts 의 `heartbeatMs` 를 검증해 반환한다. 미지정 → 기본 30초, `0` = liveness 끔.
79
+ * 무효값(음수/비정수/비숫자)은 운영 실수 — 조용히 보정하지 않고 warn 로그 후 기본값을 쓴다
80
+ * (연결 구동 시점이라 throw 하면 핸드셰이크 콜백 밖으로 새므로 로그+기본값이 안전한 fail-safe).
81
+ *
82
+ * @param {{ heartbeatMs?: number } | undefined} opts - WS 라우트 opts.
83
+ * @param {any} [log] - 로거(warn).
84
+ * @returns {number} 적용할 주기(ms). 0 = 끔.
85
+ */
86
+ export function resolveWsHeartbeatMs(opts, log) {
87
+ const v = opts?.heartbeatMs
88
+ if (v === undefined || v === null) return DEFAULT_WS_HEARTBEAT_MS
89
+ if (typeof v !== 'number' || !Number.isInteger(v) || v < 0) {
90
+ log?.warn?.({ heartbeatMs: v }, `ws route opts.heartbeatMs is invalid (integer >= 0 expected) — using default ${DEFAULT_WS_HEARTBEAT_MS}ms`)
91
+ return DEFAULT_WS_HEARTBEAT_MS
92
+ }
93
+ return v
94
+ }
95
+
55
96
  /**
56
97
  * WS 프레임 코덱 — 평문/암호 와이어 변환을 추상화한다.
57
98
  * @typedef {Object} WsFrameCodec
@@ -122,6 +163,8 @@ export class MegaWsConnection {
122
163
  this.channels = null
123
164
  /** @type {Object|undefined} joinSession/updateMetadata 로 저장한 presence 메타(재연결 재동기화에 보존, M-1). */
124
165
  this.metadata = undefined
166
+ /** @type {number} 협상된 envelope 프로토콜 버전(기본 v1) — driveWsConnection 이 핸드셰이크 결과로 설정. */
167
+ this.protocolVersion = 1
125
168
  }
126
169
 
127
170
  /** 하위 raw `ws` WebSocket (escape hatch — 바이너리/직접 제어용). */
@@ -234,10 +277,15 @@ function buildWsPresence(app, conn, ns) {
234
277
  * @param {any} args.log - request 로거 (debug/warn/error).
235
278
  * @param {any} [args.auth] - `before` 미들웨어가 인증 후 돌려준 신원(`{ userId, sessionId, ... }`).
236
279
  * `ctx.auth` 로 노출 — onConnect 에서 `app.joinSession(sock, { userId: ctx.auth.userId, ... })` 에 쓴다.
280
+ * @param {number} [args.protocolVersion] - 핸드셰이크에서 협상된 envelope 버전(`mega.v<N>` subprotocol,
281
+ * {@link import('./ws-message.js').negotiateWsProtocol}). 미지정 = v1(레거시). 이 연결의 envelope
282
+ * 검증 기준이 되고 `conn.protocolVersion`/`ctx.protocolVersion` 으로 노출된다.
237
283
  * @returns {MegaWsConnection}
238
284
  */
239
- export function driveWsConnection({ raw, req, route, app, codec, log, auth = null }) {
285
+ export function driveWsConnection({ raw, req, route, app, codec, log, auth = null, protocolVersion = WS_PROTOCOL_VERSION }) {
240
286
  const conn = new MegaWsConnection(raw, codec, { ns: route.ns, path: route.path })
287
+ // 협상된 envelope 버전 — 이 연결의 검증 기준이자 v2 도입 시 코덱/검증 분기의 기준점.
288
+ conn.protocolVersion = protocolVersion
241
289
  // ChannelClass 는 부팅 시 MegaWebSocketController 상속 검증됨 (router.ws). 여기선 인스턴스화만.
242
290
  const channel = /** @type {import('./ws-controller.js').MegaWebSocketController} */ (
243
291
  new (/** @type {any} */ (route.ChannelClass))()
@@ -256,6 +304,7 @@ export function driveWsConnection({ raw, req, route, app, codec, log, auth = nul
256
304
  path: route.path,
257
305
  req,
258
306
  connId: conn.id,
307
+ protocolVersion, // 협상된 envelope 버전(v1 기본) — 채널이 버전별 동작을 분기할 때 사용.
259
308
  tracer: MegaTracing.tracer, // ctx.tracer.span(name, fn) — WS 핸들러에서도 사용자 직접 span(ADR-126).
260
309
  // presence 단축 API (ADR-176) — list(클러스터 roster)/join/directToUser/broadcast 를 ns·conn 바인딩으로
261
310
  // 노출. 클러스터 동기화는 wsCluster 가 처리하므로 채널은 비즈니스 로직만 쓴다. mock app 이면 null.
@@ -270,29 +319,86 @@ export function driveWsConnection({ raw, req, route, app, codec, log, auth = nul
270
319
 
271
320
  log.debug?.({ connId: conn.id, path: route.path, ns: route.ns }, 'ws.connect enter')
272
321
 
322
+ // type 별 payload 스키마 검증 함수 맵 (M2, router.ws 에서 사전 컴파일). 없으면 검증 없음.
323
+ const schemaValidators = route.schemaValidators ?? null
324
+
325
+ /** 프레임 1건 처리 시작 — fire-and-forget(각 메시지 독립 async 흐름). @param {string} frame */
326
+ const processFrame = (frame) => {
327
+ // 최외곽 가드 (L2): handleIncoming 은 내부에서 단계별 try/catch 하지만, 예기치 못한
328
+ // 동기 throw / reject 가 unhandledRejection 으로 새지 않도록 .catch 로 마무리한다.
329
+ handleIncoming({ conn, channel, codec, ctx, raw, frame, schemaValidators, log }).catch((err) => {
330
+ log.warn?.({ err, connId: conn.id }, 'ws handleIncoming unexpected error')
331
+ })
332
+ }
333
+
334
+ // onConnect 완료 전 도착한 프레임은 큐에 보관했다가 완료 후 도착 순서대로 처리한다 — 'message' 리스너는
335
+ // 동기 부착되고 onConnect 는 비동기라, 빠른 클라이언트의 첫 메시지가 채널 초기화(joinSession 등) 전에
336
+ // 디스패치되는 race 를 막는다. onConnect 실패 시 연결이 닫히므로 큐는 버린다.
337
+ let isConnectSettled = false
338
+ /** @type {string[]} */
339
+ let pendingFrames = []
340
+
273
341
  // onConnect — 실패 시 fail-closed. 복호화와 무관한 서버 내부 오류이므로 1011 (M3). silent 금지.
274
342
  Promise.resolve()
275
343
  .then(() => channel.onConnect(conn, ctx))
344
+ .then(() => {
345
+ isConnectSettled = true
346
+ const queued = pendingFrames
347
+ pendingFrames = []
348
+ for (const frame of queued) processFrame(frame)
349
+ })
276
350
  .catch((err) => {
277
351
  log.error?.({ err, connId: conn.id }, 'ws.onConnect threw — closing (1011)')
352
+ pendingFrames = [] // 연결이 닫히므로 대기 프레임은 처리하지 않는다.
278
353
  if (conn.isOpen) conn.close(CLOSE_CODE_INTERNAL_ERROR, 'onConnect failed')
279
354
  })
280
355
 
281
- // type 별 payload 스키마 검증 함수 맵 (M2, router.ws 에서 사전 컴파일). 없으면 검증 없음.
282
- const schemaValidators = route.schemaValidators ?? null
283
-
284
356
  raw.on('message', (data) => {
285
357
  // ws 는 Buffer | ArrayBuffer | Buffer[] 를 줄 수 있음. 텍스트 프레임만 처리 (바이너리는 후속 BINARY 타입).
286
358
  const frame = Array.isArray(data)
287
359
  ? Buffer.concat(data).toString('utf8')
288
360
  : data.toString('utf8')
289
- // 최외곽 가드 (L2): handleIncoming 은 내부에서 단계별 try/catch 하지만, 예기치 못한
290
- // 동기 throw / reject 가 unhandledRejection 으로 새지 않도록 .catch 로 마무리한다.
291
- handleIncoming({ conn, channel, codec, ctx, raw, frame, schemaValidators, log }).catch((err) => {
292
- log.warn?.({ err, connId: conn.id }, 'ws handleIncoming unexpected error')
293
- })
361
+ if (!isConnectSettled) {
362
+ if (pendingFrames.length >= MAX_PENDING_FRAMES_BEFORE_CONNECT) {
363
+ // onConnect 끝나기 전에 상한 도달 더 쌓으면 메모리 abuse 라 연결을 닫는다(1013).
364
+ log.warn?.({ connId: conn.id, queued: pendingFrames.length }, 'ws pre-connect frame queue overflow — closing (1013)')
365
+ pendingFrames = []
366
+ if (conn.isOpen) conn.close(CLOSE_CODE_SLOW_CONSUMER, 'pre-connect queue overflow')
367
+ return
368
+ }
369
+ pendingFrames.push(frame)
370
+ return
371
+ }
372
+ processFrame(frame)
294
373
  })
295
374
 
375
+ // liveness(ping/pong): 주기마다 ping 을 보내고 직전 주기의 pong 이 없으면 half-open 으로 보고
376
+ // terminate 한다 — 'close' 이벤트가 onDisconnect/untrack 정리를 그대로 트리거한다. opts.heartbeatMs=0 으로 끔.
377
+ const heartbeatMs = resolveWsHeartbeatMs(/** @type {any} */ (route.opts), log)
378
+ if (heartbeatMs > 0) {
379
+ let isAlive = true
380
+ raw.on('pong', () => {
381
+ isAlive = true
382
+ })
383
+ const pingTimer = setInterval(() => {
384
+ if (raw.readyState !== 1) return // 닫히는 중 — 'close' 가 곧 타이머를 정리한다.
385
+ if (!isAlive) {
386
+ log.warn?.({ connId: conn.id, heartbeatMs }, 'ws heartbeat timeout — terminating half-open connection')
387
+ raw.terminate()
388
+ return
389
+ }
390
+ isAlive = false
391
+ try {
392
+ raw.ping()
393
+ } catch (err) {
394
+ // 소켓이 닫히는 중이면 ping 실패 — 비치명적, close 가 뒤따른다 (이유+로그).
395
+ log.debug?.({ err, connId: conn.id }, 'ws ping send failed (socket closing)')
396
+ }
397
+ }, heartbeatMs)
398
+ if (typeof pingTimer.unref === 'function') pingTimer.unref()
399
+ raw.on('close', () => clearInterval(pingTimer))
400
+ }
401
+
296
402
  raw.on('close', (code, reasonBuf) => {
297
403
  app._untrackWsConn?.(conn)
298
404
  const reason = Buffer.isBuffer(reasonBuf) ? reasonBuf.toString('utf8') : String(reasonBuf ?? '')
@@ -396,11 +502,11 @@ async function handleIncoming({ conn, channel, codec, ctx, raw, frame, schemaVal
396
502
  return
397
503
  }
398
504
 
399
- // 2) envelope 파싱 + 검증. 실패 → error envelope 응답(연결 유지 — 비치명적).
505
+ // 2) envelope 파싱 + 검증 — 이 연결에서 협상된 버전 기준. 실패 → error envelope 응답(연결 유지 — 비치명적).
400
506
  /** @type {{ type: string, id: string }} */
401
507
  let msg
402
508
  try {
403
- msg = /** @type {{ type: string, id: string }} */ (parseWsMessage(plain))
509
+ msg = /** @type {{ type: string, id: string }} */ (parseWsMessage(plain, { version: conn.protocolVersion }))
404
510
  } catch (err) {
405
511
  log.warn?.({ err, connId: conn.id }, 'ws invalid envelope')
406
512
  if (conn.isOpen) {
package/src/index.js CHANGED
@@ -115,7 +115,7 @@ export { MegaAspDecryptError, ASP_RULES } from './lib/asp/errors.js'
115
115
  export { normalizeAspConfig } from './lib/asp/config.js'
116
116
 
117
117
  // Bridge ↔ Hub 12-타입 프로토콜 (ADR-033/059/097)
118
- export { MegaWsHub, DEFAULT_HEARTBEAT_MS, runWsHubCli } from './cli/ws-hub.js'
118
+ export { MegaWsHub, DEFAULT_HEARTBEAT_MS, runWsHubCli } from './lib/ws-hub.js'
119
119
  export { MegaHubLink } from './core/hub-link.js'
120
120
  // WS per-message deflate 압축 (ADR-078)
121
121
  export { buildPerMessageDeflate, checkCompressionConfig, COMPRESSION_DEFAULTS } from './core/ws-compression.js'
@@ -61,6 +61,30 @@ export const HUB_MESSAGE_TYPES = Object.freeze({
61
61
  /** 12 개 wire `type` 문자열 집합 (빠른 소속 판별용). @type {ReadonlySet<string>} */
62
62
  export const HUB_TYPE_SET = new Set(Object.values(HUB_MESSAGE_TYPES))
63
63
 
64
+ /** bridge↔hub 프로토콜 현 버전. REGISTER/REGISTER_OK 의 `protocolVersion` 협상 기준(부재 = v1). */
65
+ export const HUB_PROTOCOL_VERSION = 1
66
+
67
+ /**
68
+ * 이 측이 지원하는 bridge↔hub 프로토콜 버전 목록. 버전은 선형 누적 계약 — vN 지원 = v1..vN 전부 지원.
69
+ * @type {ReadonlyArray<number>}
70
+ */
71
+ export const SUPPORTED_HUB_PROTOCOL_VERSIONS = Object.freeze([HUB_PROTOCOL_VERSION])
72
+
73
+ /**
74
+ * bridge 가 REGISTER 로 보낸 최대 지원 버전과 hub 지원 버전의 상호 최고 버전을 고른다.
75
+ * 선형 누적 계약(vN 지원 = v1..vN 지원)이라 `min(bridgeMax, hubMax)` 가 항상 유효한 상호 버전이다
76
+ * (v1 이 바닥이라 협상이 실패할 수 없다 — 부재/무효 입력은 v1).
77
+ *
78
+ * @param {unknown} requestedMax - bridge 의 `protocolVersion`(최대 지원 버전).
79
+ * @param {ReadonlyArray<number>} [supported] - 이 측 지원 버전 목록.
80
+ * @returns {number} 협상된 버전(>= 1).
81
+ */
82
+ export function negotiateHubProtocolVersion(requestedMax, supported = SUPPORTED_HUB_PROTOCOL_VERSIONS) {
83
+ const ourMax = Math.max(...supported)
84
+ if (!Number.isInteger(requestedMax) || /** @type {number} */ (requestedMax) < 1) return HUB_PROTOCOL_VERSION
85
+ return Math.min(/** @type {number} */ (requestedMax), ourMax)
86
+ }
87
+
64
88
  /**
65
89
  * bridge↔hub WebSocket close code 카탈로그 (ADR-098).
66
90
  *
@@ -107,6 +131,8 @@ export const HUB_PAYLOAD_SCHEMAS = Object.freeze({
107
131
  instanceId: { type: 'string', minLength: 1 },
108
132
  token: { type: 'string', minLength: 1 },
109
133
  capabilities: { type: 'array', items: { type: 'string' } },
134
+ // 버전 협상(옵션): bridge 의 최대 지원 버전. 부재 = v1(레거시 bridge — 협상 없이 v1 고정).
135
+ protocolVersion: { type: 'integer', minimum: 1 },
110
136
  },
111
137
  additionalProperties: false,
112
138
  },
@@ -117,6 +143,9 @@ export const HUB_PAYLOAD_SCHEMAS = Object.freeze({
117
143
  hubId: { type: 'string', minLength: 1 },
118
144
  acceptedAt: { type: 'integer' },
119
145
  heartbeatMs: { type: 'integer', minimum: 1 },
146
+ // 버전 협상 결과(옵션): hub 가 채택한 버전. bridge 가 REGISTER 에 protocolVersion 을 실었을
147
+ // 때만 회신(echo-on-request) — 레거시 bridge(strict 스키마)가 모르는 필드를 받지 않게 한다.
148
+ protocolVersion: { type: 'integer', minimum: 1 },
120
149
  },
121
150
  additionalProperties: false,
122
151
  },
@@ -19,21 +19,30 @@
19
19
 
20
20
  import { MegaShutdown } from './mega-shutdown.js'
21
21
 
22
+ /** 체크 1개의 기본 타임아웃(ms) — hung 체크가 readiness 응답을 무기한 막지 않게 한다. */
23
+ export const DEFAULT_CHECK_TIMEOUT_MS = 5_000
24
+
25
+ /** @type {Map<string, { fn: Function, timeoutMs: number }>} */
22
26
  const checks = new Map()
23
27
 
24
28
  /**
25
29
  * 헬스 체크 등록.
26
30
  * @param {string} name
27
31
  * @param {() => Promise<{ ok: boolean, [key: string]: any }> | { ok: boolean }} fn
32
+ * @param {{ timeoutMs?: number }} [opts] - 체크별 타임아웃(양의 정수 ms, 기본 {@link DEFAULT_CHECK_TIMEOUT_MS}).
28
33
  */
29
- export function register(name, fn) {
34
+ export function register(name, fn, opts = {}) {
30
35
  if (typeof name !== 'string' || name.length === 0) {
31
36
  throw new Error('MegaHealth.register: name is required (string)')
32
37
  }
33
38
  if (typeof fn !== 'function') {
34
39
  throw new Error('MegaHealth.register: fn must be a function')
35
40
  }
36
- checks.set(name, fn)
41
+ const timeoutMs =
42
+ Number.isInteger(opts.timeoutMs) && /** @type {number} */ (opts.timeoutMs) > 0
43
+ ? /** @type {number} */ (opts.timeoutMs)
44
+ : DEFAULT_CHECK_TIMEOUT_MS
45
+ checks.set(name, { fn, timeoutMs })
37
46
  }
38
47
 
39
48
  /**
@@ -48,12 +57,24 @@ export async function checkAll() {
48
57
 
49
58
  const entries = [...checks.entries()]
50
59
  const results = await Promise.all(
51
- entries.map(async ([name, fn]) => {
60
+ entries.map(async ([name, { fn, timeoutMs }]) => {
61
+ /** @type {NodeJS.Timeout | undefined} */
62
+ let timer
52
63
  try {
53
- const result = await fn()
64
+ // 체크별 race hung 체크 1개가 readiness 전체를 무기한 막지 않게 timeoutMs 에서 끊는다
65
+ // (probe timeout 으로 인한 연쇄 재시작 방지). 타임아웃은 해당 체크만 ok:false 처리.
66
+ const result = await Promise.race([
67
+ Promise.resolve(fn()),
68
+ new Promise((_, reject) => {
69
+ timer = setTimeout(() => reject(new Error(`health check timed out after ${timeoutMs}ms`)), timeoutMs)
70
+ timer.unref?.()
71
+ }),
72
+ ])
54
73
  return [name, result?.ok === true ? result : { ok: false, ...result }]
55
74
  } catch (err) {
56
75
  return [name, { ok: false, error: err?.message ?? String(err) }]
76
+ } finally {
77
+ clearTimeout(timer)
57
78
  }
58
79
  }),
59
80
  )
@@ -43,7 +43,7 @@
43
43
  */
44
44
  import { EventEmitter } from 'node:events'
45
45
  import { MegaConfigError } from '../errors/config-error.js'
46
- import { MegaJob, resolveJobRetryConfig } from './mega-job.js'
46
+ import { MegaJob, resolveJobRetryConfig, resolveJobRunTimeoutMs } from './mega-job.js'
47
47
  import { withRetry } from './mega-retry.js'
48
48
 
49
49
  /** 잡 큐가 노출하는 이벤트 화이트리스트(오타 차단 + 문서화 — 형제 클래스와 동일 정책). */
@@ -52,7 +52,7 @@ const KNOWN_EVENTS = Object.freeze([
52
52
  'start', // 메시지 처리 시작
53
53
  'done', // 처리 성공 + ack — event.result
54
54
  'retry', // 재시도 1회 실패(다음 시도 전) — event.attempt/retriesLeft/error
55
- 'fail', // 최종 실패 — event.phase('run'|'decode'|'dlq-publish'|'max-deliver'|'consume-loop')/error
55
+ 'fail', // 최종 실패 — event.phase('run'|'decode'|'dlq-publish'|'dlq-orphan'|'max-deliver'|'consume-loop'|'abandoned-run')/error
56
56
  'dlq', // DLQ 라우팅 완료 — event.dlqSubject
57
57
  ])
58
58
 
@@ -62,6 +62,15 @@ const NOT_FOUND_CODE = '404'
62
62
  /** DLQ 스트림 `max_age` 디폴트(ms) — 7일. 무한 적재 방지(ADR-134). `dlqMaxAgeMs: 0` 으로 끌 수 있다. */
63
63
  export const DEFAULT_DLQ_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
64
64
 
65
+ /**
66
+ * run 전체(재시도 포함) 실행 상한 디폴트(ms) — 30분. 행(hang)된 run 이 `working()` lease 를 영원히
67
+ * 갱신하며 메시지를 영구 점유하는 것을 막는 backstop. 잡별 `static timeoutMs` 로 override, `0` = 무제한.
68
+ */
69
+ export const DEFAULT_RUN_TIMEOUT_MS = 30 * 60 * 1000
70
+
71
+ /** DLQ publish 실패 nak 재전달 지연 상한(ms) — 즉시 재전달 핫 루프 방지(점증 지연의 cap). */
72
+ const NAK_DELAY_MAX_MS = 30_000
73
+
65
74
  /** DLQ 봉투에 싣는 error.stack 최대 길이(자) — poison 잡 폭주 시 DLQ 비대 방지(Med). */
66
75
  const DLQ_MAX_STACK_LEN = 4000
67
76
 
@@ -88,6 +97,10 @@ function truncateStack(stack) {
88
97
  * 스트림 생성 시에만** 적용(멱등 — 기존 스트림은 운영자가 NATS CLI 로 갱신).
89
98
  * @property {number} [dlqMaxBytes] - DLQ 스트림 최대 크기(bytes). 미지정이면 byte 상한 없음(`max_age` 가
90
99
  * 주 가드). 디스크 상한이 필요한 운영 환경에서만 지정.
100
+ * @property {number} [runTimeoutMs=1800000] - run 전체(재시도 포함) 실행 상한 디폴트(ms, 기본 30분).
101
+ * 초과 시 잡을 실패로 판정해 DLQ 라우팅 — 행 잡이 `working()` lease 를 영구 갱신하며 메시지를 점유하는
102
+ * 것을 막는다. 잡별 `static timeoutMs` 가 우선, `0` = 무제한. ⚠️ 타임아웃돼도 진행 중 run 은 중단되지
103
+ * 않는다(백그라운드 계속 — run 멱등 설계 필요). 나중 실패는 fail(abandoned-run) 으로 표면화.
91
104
  */
92
105
 
93
106
  /**
@@ -116,6 +129,7 @@ export class MegaJobQueue extends EventEmitter {
116
129
  /** @type {string} */ #streamPrefix
117
130
  /** @type {number} DLQ max_age(ms). 0 = 무제한. */ #dlqMaxAgeMs
118
131
  /** @type {number|undefined} DLQ max_bytes. undefined = 무제한. */ #dlqMaxBytes
132
+ /** @type {number} run 전체 실행 상한 디폴트(ms). 0 = 무제한. 잡별 static timeoutMs 가 우선. */ #runTimeoutMs
119
133
  /** @type {typeof import('nats')|null} 지연 로드된 nats 모듈(enum/codec/nanos). */ #nats = null
120
134
  /** @type {import('nats').Codec<any>|null} */ #codec = null
121
135
  /** @type {import('nats').JetStreamClient|null} */ #js = null
@@ -126,7 +140,7 @@ export class MegaJobQueue extends EventEmitter {
126
140
  * @param {MegaJobQueueOptions} options
127
141
  * @throws {TypeError} nc 가 JetStream 가능한 NatsConnection 이 아니면(fail-fast).
128
142
  */
129
- constructor({ nc, ackWaitMs = 30_000, maxDeliver = 5, heartbeatMs, streamPrefix = 'MEGA_JOBS', dlqMaxAgeMs = DEFAULT_DLQ_MAX_AGE_MS, dlqMaxBytes } = /** @type {any} */ ({})) {
143
+ constructor({ nc, ackWaitMs = 30_000, maxDeliver = 5, heartbeatMs, streamPrefix = 'MEGA_JOBS', dlqMaxAgeMs = DEFAULT_DLQ_MAX_AGE_MS, dlqMaxBytes, runTimeoutMs = DEFAULT_RUN_TIMEOUT_MS } = /** @type {any} */ ({})) {
130
144
  super()
131
145
  if (!nc || typeof nc.jetstream !== 'function' || typeof nc.jetstreamManager !== 'function') {
132
146
  throw new TypeError(
@@ -146,6 +160,10 @@ export class MegaJobQueue extends EventEmitter {
146
160
  if (dlqMaxBytes !== undefined && (typeof dlqMaxBytes !== 'number' || !Number.isInteger(dlqMaxBytes) || dlqMaxBytes < 1)) {
147
161
  throw new TypeError(`MegaJobQueue: dlqMaxBytes must be an integer >= 1 when set. Got: ${dlqMaxBytes}.`)
148
162
  }
163
+ // runTimeoutMs: 0 = 무제한(끔). 음수/비정수는 운영 실수라 fail-fast(silent 보정 X).
164
+ if (typeof runTimeoutMs !== 'number' || !Number.isInteger(runTimeoutMs) || runTimeoutMs < 0) {
165
+ throw new TypeError(`MegaJobQueue: runTimeoutMs must be an integer >= 0 (0 = unlimited). Got: ${runTimeoutMs}.`)
166
+ }
149
167
  this.#nc = nc
150
168
  this.#ackWaitMs = ackWaitMs
151
169
  this.#maxDeliver = maxDeliver
@@ -153,6 +171,7 @@ export class MegaJobQueue extends EventEmitter {
153
171
  this.#streamPrefix = streamPrefix
154
172
  this.#dlqMaxAgeMs = dlqMaxAgeMs
155
173
  this.#dlqMaxBytes = dlqMaxBytes
174
+ this.#runTimeoutMs = runTimeoutMs
156
175
  }
157
176
 
158
177
  // ── 이벤트 화이트리스트(L-1 정책 — 형제 클래스와 동일) ────────────────────
@@ -416,6 +435,7 @@ export class MegaJobQueue extends EventEmitter {
416
435
  const durable = this.#durableName(subject)
417
436
  const concurrency = this.#resolveConcurrency(JobClass) // L-1: 양의 정수 fail-fast(max_ack_pending=0 풋건 차단).
418
437
  const retryConfig = resolveJobRetryConfig(JobClass)
438
+ const runTimeoutMs = resolveJobRunTimeoutMs(JobClass, this.#runTimeoutMs) // 행 잡 영구 점유 backstop.
419
439
  await this.#ensureConsumer(stream, durable, subject, concurrency)
420
440
 
421
441
  const js = /** @type {import('nats').JetStreamClient} */ (this.#js)
@@ -426,7 +446,7 @@ export class MegaJobQueue extends EventEmitter {
426
446
  const inFlight = new Set()
427
447
  const loop = (async () => {
428
448
  for await (const msg of messages) {
429
- const p = this._handleMessage(instance, ctx, msg, retryConfig, subject).finally(() =>
449
+ const p = this._handleMessage(instance, ctx, msg, retryConfig, subject, runTimeoutMs).finally(() =>
430
450
  inFlight.delete(p),
431
451
  )
432
452
  inFlight.add(p)
@@ -464,9 +484,10 @@ export class MegaJobQueue extends EventEmitter {
464
484
  *
465
485
  * @param {MegaJob} instance @param {Record<string, any>} ctx @param {import('nats').JsMsg} msg
466
486
  * @param {ReturnType<typeof resolveJobRetryConfig>} retryConfig @param {string} subject
487
+ * @param {number} [runTimeoutMs] - run 전체(재시도 포함) 상한(ms). 미지정 시 큐 디폴트, 0 = 무제한.
467
488
  * @returns {Promise<MegaJobHandleResult>}
468
489
  */
469
- async _handleMessage(instance, ctx, msg, retryConfig, subject) {
490
+ async _handleMessage(instance, ctx, msg, retryConfig, subject, runTimeoutMs = this.#runTimeoutMs) {
470
491
  const seq = msg.seq
471
492
 
472
493
  // M-1(ADR-119 hardening): 이번 전달이 max_deliver 에 도달했고(deliveryCount >= maxDeliver)
@@ -520,8 +541,14 @@ export class MegaJobQueue extends EventEmitter {
520
541
  let runResult
521
542
  /** @type {Error|null} run 최종 실패 사유(성공이면 null). */
522
543
  let runError = null
544
+ /** @type {NodeJS.Timeout|undefined} run 타임아웃 타이머(있으면 finally 에서 정리). */
545
+ let timeoutHandle
546
+ /** @type {boolean} 타임아웃이 race 를 이겼는지(버려진 run 의 잔여 실패 표면화 분기용). */
547
+ let timedOut = false
548
+ /** @type {Promise<any>|undefined} run(재시도) Promise — 타임아웃 시 잔여 실패 관찰에 필요. */
549
+ let runPromise
523
550
  try {
524
- runResult = await withRetry(() => instance.run(payload, ctx), {
551
+ runPromise = withRetry(() => instance.run(payload, ctx), {
525
552
  ...retryConfig,
526
553
  onFailedAttempt: (info) => {
527
554
  this.#safeEmit('retry', {
@@ -533,11 +560,44 @@ export class MegaJobQueue extends EventEmitter {
533
560
  })
534
561
  },
535
562
  })
563
+ if (runTimeoutMs > 0) {
564
+ // 행(hang) 잡 backstop: run 전체(재시도 포함)에 상한을 건다. 상한 초과 = 실패 판정 → DLQ.
565
+ // 없으면 working() 하트비트가 lease 를 영원히 갱신해 메시지가 재전달도 DLQ 도 못 가는
566
+ // 영구 점유가 된다(프로세스 사망 시에만 해소).
567
+ runResult = await Promise.race([
568
+ runPromise,
569
+ new Promise((_resolve, reject) => {
570
+ timeoutHandle = setTimeout(() => {
571
+ timedOut = true
572
+ reject(new Error(
573
+ `MegaJobQueue: job on '${subject}' exceeded run timeout (${runTimeoutMs}ms) — treating as ` +
574
+ `failed (run continues in background; design run to be idempotent).`,
575
+ ))
576
+ }, runTimeoutMs)
577
+ if (typeof timeoutHandle.unref === 'function') timeoutHandle.unref()
578
+ }),
579
+ ])
580
+ } else {
581
+ runResult = await runPromise
582
+ }
536
583
  } catch (err) {
537
584
  runError = err instanceof Error ? err : new Error(String(err))
585
+ if (timedOut && runPromise) {
586
+ // 버려진(타임아웃 패배) run 의 나중 실패를 묵히지 않고 표면화한다 — 잡은 이미 실패 판정·DLQ
587
+ // 라우팅됐으므로 처리 흐름엔 영향 없다(reject 자체는 race 가 구독해 unhandledRejection 아님).
588
+ runPromise.catch((lateErr) => {
589
+ this.#safeEmit('fail', {
590
+ subject,
591
+ seq,
592
+ error: lateErr instanceof Error ? lateErr : new Error(String(lateErr)),
593
+ phase: 'abandoned-run',
594
+ })
595
+ })
596
+ }
538
597
  } finally {
539
598
  settled = true
540
599
  clearInterval(heartbeat)
600
+ if (timeoutHandle !== undefined) clearTimeout(timeoutHandle)
541
601
  }
542
602
 
543
603
  if (runError === null) {
@@ -552,30 +612,47 @@ export class MegaJobQueue extends EventEmitter {
552
612
  }
553
613
 
554
614
  /**
555
- * DLQ 라우팅 — 실패 페이로드+에러 메타를 `<subject>.dlq` 로 발행하고 원본을 ack. 발행 실패 시 ack 하지
556
- * 않고 nak 잡을 보존한다(at-least-once). 메서드도 throw 하지 않는다.
615
+ * DLQ 라우팅 — 실패 페이로드+에러 메타를 `<subject>.dlq` 로 발행하고 원본을 ack. 발행은 인프로세스로
616
+ * 짧게 재시도(일시 장애 흡수)하고, 그래도 실패하면 ack 하지 않고 **점증 지연 nak** 해 잡을 보존한다
617
+ * (at-least-once — 즉시 재전달 핫 루프 방지). 단 이번 전달이 `max_deliver` 의 **마지막 전달**이면 nak 해도
618
+ * 재전달이 없어 메시지가 워크 스트림에 orphan 으로 남는다 — 이때는 un-ack 보존 + `fail(dlq-orphan)` 으로
619
+ * 운영자 개입을 명시 표면화한다(ack/term 으로 잡을 지우면 유실이라 하지 않는다). 본 메서드도 throw 하지 않는다.
557
620
  * @param {string} subject @param {any} payload @param {Error} error @param {import('nats').JsMsg} msg @param {number} seq @returns {Promise<void>}
558
621
  */
559
622
  async #routeToDlq(subject, payload, error, msg, seq) {
560
623
  const dlqSubject = `${subject}.dlq`
561
624
  const js = /** @type {import('nats').JetStreamClient} */ (this.#js)
562
625
  try {
563
- await js.publish(
564
- dlqSubject,
565
- this.#encode({
566
- originalSubject: subject,
567
- failedAt: new Date().toISOString(),
568
- deliveryCount: msg.info.deliveryCount,
569
- // stack 전체를 봉투에 담으면 poison 잡 폭주 시 DLQ 직렬화 비용·디스크가 급증한다 → 상한으로 truncate(Med).
570
- error: { name: error.name, message: error.message, stack: truncateStack(error.stack) },
571
- payload,
572
- }),
626
+ // DLQ publish 자체를 짧게 재시도(추가 2회, 250ms→1s) — 일시적 NATS 응답 지연/재연결 틈을 흡수해
627
+ // nak 재전달(전체 인프로세스 재시도 사이클 반복)보다 훨씬 싸게 orphan 확률을 줄인다.
628
+ await withRetry(
629
+ () =>
630
+ js.publish(
631
+ dlqSubject,
632
+ this.#encode({
633
+ originalSubject: subject,
634
+ failedAt: new Date().toISOString(),
635
+ deliveryCount: msg.info.deliveryCount,
636
+ // stack 전체를 봉투에 담으면 poison 잡 폭주 시 DLQ 직렬화 비용·디스크가 급증한다 → 상한으로 truncate(Med).
637
+ error: { name: error.name, message: error.message, stack: truncateStack(error.stack) },
638
+ payload,
639
+ }),
640
+ ),
641
+ { retries: 2, minTimeout: 250, maxTimeout: 1000, factor: 2, jitter: true },
573
642
  )
574
643
  } catch (pubErr) {
575
- // DLQ 발행 실패 → ack 안 함(보존). nak 으로 재전달 요청 → DLQ 백엔드 회복 후 재시도(안 묻음).
576
644
  const e = pubErr instanceof Error ? pubErr : new Error(String(pubErr))
577
- msg.nak() // 부작용(nak) 먼저 확정 — emit 보다 앞선다(M-3: 리스너 throw 가 보존 결정을 막지 않게).
578
- this.#safeEmit('fail', { subject, seq, error: e, phase: 'dlq-publish' })
645
+ if (msg.info.deliveryCount >= this.#maxDeliver) {
646
+ // 마지막 전달 nak 해도 JetStream 더는 재전달하지 않는다. ack/term 하면 잡이 사라지므로
647
+ // un-ack 로 워크 스트림에 보존하고(운영자가 NATS CLI 로 수습 가능), orphan 을 크게 표면화한다.
648
+ this.#safeEmit('fail', { subject, seq, error: e, phase: 'dlq-orphan' })
649
+ return
650
+ }
651
+ // DLQ 발행 실패 → ack 안 함(보존). 점증 지연 nak — 즉시 재전달이면 DLQ 백엔드 장애 동안
652
+ // 재전달→재시도 사이클 전체가 타이트하게 도는 핫 루프가 된다(deliveryCount 기반 지수 지연).
653
+ const nakDelayMs = Math.min(NAK_DELAY_MAX_MS, 1000 * 2 ** (msg.info.deliveryCount - 1))
654
+ msg.nak(nakDelayMs) // 부작용(nak) 먼저 확정 — emit 보다 앞선다(M-3: 리스너 throw 가 보존 결정을 막지 않게).
655
+ this.#safeEmit('fail', { subject, seq, error: e, phase: 'dlq-publish', nakDelayMs })
579
656
  return
580
657
  }
581
658
  msg.ack() // DLQ 에 안전히 보관됨 → 워크 메시지 제거(emit 보다 먼저 확정).
@@ -23,6 +23,7 @@
23
23
  * - `static concurrency` : 동시에 처리할 메시지 수(consumer `max_ack_pending`). 기본 1(순차·안전).
24
24
  * - `static retries` : run 실패 시 **추가** 재시도 횟수(첫 시도 제외). 기본 3.
25
25
  * - `static backoff` : `{ type:'exponential', initial, max }`. p-retry 로 매핑(factor=2, jitter=on).
26
+ * - `static timeoutMs` : run 전체(재시도 포함) 상한(ms). 미지정 시 큐 디폴트, `0` = 무제한.
26
27
  *
27
28
  * @module lib/mega-job
28
29
  * @see ADR-119, ADR-028 (잡·스케줄러·워커 3종 분리), ADR-029 (라이브러리 래핑)
@@ -81,6 +82,15 @@ export class MegaJob {
81
82
  /** @type {MegaJobBackoff} 지수 백오프 설정. 기본 { exponential, 1s, 30s }(OQ-012). */
82
83
  static backoff = JOB_RETRY_DEFAULTS.backoff
83
84
 
85
+ /**
86
+ * @type {number|undefined} run 전체(재시도 포함) 실행 상한(ms). 초과 시 큐가 잡을 실패로 판정해 DLQ 로
87
+ * 보낸다 — 행(hang)된 run 이 `working()` lease 를 영원히 갱신하며 메시지를 영구 점유하는 것을 막는다.
88
+ * 미지정 시 {@link import('./mega-job-queue.js').MegaJobQueue} 의 `runTimeoutMs`(기본 30분), `0` = 무제한.
89
+ * ⚠️ 타임아웃돼도 진행 중이던 run 은 JS 특성상 중단되지 않는다(백그라운드 계속) — run 은 멱등하게
90
+ * 설계해야 한다(at-least-once, 모듈 docstring).
91
+ */
92
+ static timeoutMs = undefined
93
+
84
94
  /**
85
95
  * 메시지 1건마다 실행되는 본문. 서브클래스가 **반드시** 구현한다. throw 하면 {@link MegaJobQueue}
86
96
  * 가 재시도하고, 재시도 소진 시 DLQ 로 보낸다 — 그러므로 비치명/일시 오류는 그냥 throw 하면 된다.
@@ -138,3 +148,22 @@ export function resolveJobRetryConfig(JobClass) {
138
148
  // factor=2(exponential), jitter=on — OQ-012 정합. MegaRetry 가 첫 시도 즉시 + 이후 지수 백오프.
139
149
  return { retries, minTimeout: initial, maxTimeout: max, factor: 2, jitter: true }
140
150
  }
151
+
152
+ /**
153
+ * 잡 클래스의 `static timeoutMs` 를 검증해 반환한다. 미지정(undefined/null)이면 `defaultMs`(큐 디폴트),
154
+ * `0` = 무제한. 음수/비정수는 운영 실수라 fail-fast(silent 보정 금지).
155
+ *
156
+ * @param {typeof MegaJob} JobClass - 잡 클래스.
157
+ * @param {number} defaultMs - 큐 레벨 디폴트(ms, 0 = 무제한).
158
+ * @returns {number} 적용할 타임아웃(ms). 0 = 무제한.
159
+ * @throws {TypeError} timeoutMs 가 0 이상 정수가 아닐 때.
160
+ */
161
+ export function resolveJobRunTimeoutMs(JobClass, defaultMs) {
162
+ const timeoutMs = JobClass.timeoutMs ?? defaultMs
163
+ if (typeof timeoutMs !== 'number' || !Number.isInteger(timeoutMs) || timeoutMs < 0) {
164
+ throw new TypeError(
165
+ `MegaJob '${JobClass.name}': static timeoutMs must be an integer >= 0 (0 = unlimited). Got: ${timeoutMs}.`,
166
+ )
167
+ }
168
+ return timeoutMs
169
+ }
@@ -38,12 +38,7 @@
38
38
  */
39
39
  import { MeterProvider } from '@opentelemetry/sdk-metrics'
40
40
  import { PrometheusExporter, PrometheusSerializer } from '@opentelemetry/exporter-prometheus'
41
- import { resourceFromAttributes } from '@opentelemetry/resources'
42
- import {
43
- ATTR_SERVICE_NAME,
44
- ATTR_SERVICE_VERSION,
45
- ATTR_DEPLOYMENT_ENVIRONMENT_NAME,
46
- } from '@opentelemetry/semantic-conventions'
41
+ import { buildOtelResource } from './otel-resource.js'
47
42
  import { MegaConfigError } from '../errors/config-error.js'
48
43
 
49
44
  /** meter 이름 (instrumentation scope) — OTel 컨벤션상 패키지명. */
@@ -147,12 +142,8 @@ export function init(opts = /** @type {any} */ ({})) {
147
142
  )
148
143
  }
149
144
 
150
- const resource = resourceFromAttributes({
151
- [ATTR_SERVICE_NAME]: serviceName,
152
- ...(typeof opts.version === 'string' ? { [ATTR_SERVICE_VERSION]: opts.version } : {}),
153
- ...(typeof opts.environment === 'string' ? { [ATTR_DEPLOYMENT_ENVIRONMENT_NAME]: opts.environment } : {}),
154
- ...(opts.attributes && typeof opts.attributes === 'object' ? opts.attributes : {}),
155
- })
145
+ // resource 조립은 트레이싱과 공유하는 단일 출처(otel-resource.js, ADR-193) — 두 SDK 간 드리프트 방지.
146
+ const resource = buildOtelResource({ serviceName, version: opts.version, environment: opts.environment, attributes: opts.attributes })
156
147
 
157
148
  // preventServerStart — 자체 :9464 서버를 띄우지 않고 우리가 collect() 로 직접 긁는다(메인 포트 서빙).
158
149
  const reader = new PrometheusExporter({ preventServerStart: true })