mega-framework 0.1.0

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 (322) hide show
  1. package/.env +127 -0
  2. package/.env.example +186 -0
  3. package/.prettierrc.json +8 -0
  4. package/CHANGELOG.md +259 -0
  5. package/LICENSE +21 -0
  6. package/README.md +153 -0
  7. package/bin/mega-ws-hub.js +15 -0
  8. package/bin/mega.js +38 -0
  9. package/docker-compose.yml +201 -0
  10. package/eslint.config.js +57 -0
  11. package/infra/otel-collector-config.yaml +43 -0
  12. package/jsconfig.json +18 -0
  13. package/package.json +121 -0
  14. package/sample/crud/.env +18 -0
  15. package/sample/crud/.env.example +50 -0
  16. package/sample/crud/README.md +85 -0
  17. package/sample/crud/apps/main/app.config.js +114 -0
  18. package/sample/crud/apps/main/channels/chat-bus.js +115 -0
  19. package/sample/crud/apps/main/channels/chat-channel.js +145 -0
  20. package/sample/crud/apps/main/controllers/auth-controller.js +144 -0
  21. package/sample/crud/apps/main/controllers/cron-controller.js +34 -0
  22. package/sample/crud/apps/main/controllers/guide-controller.js +37 -0
  23. package/sample/crud/apps/main/controllers/jobs-controller.js +43 -0
  24. package/sample/crud/apps/main/controllers/logs-controller.js +35 -0
  25. package/sample/crud/apps/main/controllers/metrics-controller.js +22 -0
  26. package/sample/crud/apps/main/controllers/note-controller.js +116 -0
  27. package/sample/crud/apps/main/controllers/perf-controller.js +38 -0
  28. package/sample/crud/apps/main/controllers/redis-controller.js +36 -0
  29. package/sample/crud/apps/main/controllers/tracing-controller.js +43 -0
  30. package/sample/crud/apps/main/controllers/upload-controller.js +98 -0
  31. package/sample/crud/apps/main/controllers/user-controller.js +34 -0
  32. package/sample/crud/apps/main/controllers/web-controller.js +137 -0
  33. package/sample/crud/apps/main/controllers/worker-controller.js +57 -0
  34. package/sample/crud/apps/main/controllers/ws-controller.js +29 -0
  35. package/sample/crud/apps/main/jobs/email-job.js +72 -0
  36. package/sample/crud/apps/main/locales/client/en.json +3 -0
  37. package/sample/crud/apps/main/locales/client/ko.json +3 -0
  38. package/sample/crud/apps/main/locales/server/en.json +316 -0
  39. package/sample/crud/apps/main/locales/server/ko.json +316 -0
  40. package/sample/crud/apps/main/middleware/web-auth.js +40 -0
  41. package/sample/crud/apps/main/middleware/ws-auth.js +48 -0
  42. package/sample/crud/apps/main/migrations/20260606000001-create-users.js +27 -0
  43. package/sample/crud/apps/main/migrations/20260606000002-add-auth-to-users.js +30 -0
  44. package/sample/crud/apps/main/models/note.js +71 -0
  45. package/sample/crud/apps/main/models/user.js +86 -0
  46. package/sample/crud/apps/main/public/css/app.css +101 -0
  47. package/sample/crud/apps/main/public/css/guide.css +137 -0
  48. package/sample/crud/apps/main/public/js/app.js +54 -0
  49. package/sample/crud/apps/main/public/js/perf.js +129 -0
  50. package/sample/crud/apps/main/public/js/theme-init.js +12 -0
  51. package/sample/crud/apps/main/public/js/upload-demo.js +63 -0
  52. package/sample/crud/apps/main/public/js/worker-demo.js +92 -0
  53. package/sample/crud/apps/main/public/js/ws-chat.js +161 -0
  54. package/sample/crud/apps/main/public/vendor/bootstrap/bootstrap.bundle.min.js +7 -0
  55. package/sample/crud/apps/main/public/vendor/bootstrap/bootstrap.min.css +6 -0
  56. package/sample/crud/apps/main/public/vendor/highlight/github-dark.css +109 -0
  57. package/sample/crud/apps/main/public/vendor/highlight/github.css +118 -0
  58. package/sample/crud/apps/main/public/vendor/mega-client-wasm/README.md +19 -0
  59. package/sample/crud/apps/main/public/vendor/mega-client-wasm/mega_client_wasm.d.ts +196 -0
  60. package/sample/crud/apps/main/public/vendor/mega-client-wasm/mega_client_wasm.js +1187 -0
  61. package/sample/crud/apps/main/public/vendor/mega-client-wasm/mega_client_wasm_bg.wasm +0 -0
  62. package/sample/crud/apps/main/routes/auth.js +15 -0
  63. package/sample/crud/apps/main/routes/cron.js +14 -0
  64. package/sample/crud/apps/main/routes/guide.js +25 -0
  65. package/sample/crud/apps/main/routes/jobs.js +14 -0
  66. package/sample/crud/apps/main/routes/logs.js +28 -0
  67. package/sample/crud/apps/main/routes/metrics.js +13 -0
  68. package/sample/crud/apps/main/routes/notes.js +19 -0
  69. package/sample/crud/apps/main/routes/perf.js +47 -0
  70. package/sample/crud/apps/main/routes/redis.js +14 -0
  71. package/sample/crud/apps/main/routes/tracing.js +14 -0
  72. package/sample/crud/apps/main/routes/upload.js +16 -0
  73. package/sample/crud/apps/main/routes/users.js +54 -0
  74. package/sample/crud/apps/main/routes/web.js +23 -0
  75. package/sample/crud/apps/main/routes/worker.js +15 -0
  76. package/sample/crud/apps/main/routes/ws.js +30 -0
  77. package/sample/crud/apps/main/schedules/cron-counter-schedule.js +30 -0
  78. package/sample/crud/apps/main/services/auth-service.js +74 -0
  79. package/sample/crud/apps/main/services/cron-demo-service.js +66 -0
  80. package/sample/crud/apps/main/services/guide-service.js +145 -0
  81. package/sample/crud/apps/main/services/jobs-demo-service.js +83 -0
  82. package/sample/crud/apps/main/services/logs-demo-service.js +59 -0
  83. package/sample/crud/apps/main/services/metrics-demo-service.js +144 -0
  84. package/sample/crud/apps/main/services/note-service.js +75 -0
  85. package/sample/crud/apps/main/services/perf-service.js +302 -0
  86. package/sample/crud/apps/main/services/redis-demo-service.js +75 -0
  87. package/sample/crud/apps/main/services/tracing-demo-service.js +69 -0
  88. package/sample/crud/apps/main/services/upload-demo-service.js +48 -0
  89. package/sample/crud/apps/main/services/user-service.js +65 -0
  90. package/sample/crud/apps/main/views/auth/login.ejs +57 -0
  91. package/sample/crud/apps/main/views/auth/register.ejs +71 -0
  92. package/sample/crud/apps/main/views/cron/index.ejs +92 -0
  93. package/sample/crud/apps/main/views/guide/index.ejs +24 -0
  94. package/sample/crud/apps/main/views/guide/page.ejs +64 -0
  95. package/sample/crud/apps/main/views/home.ejs +82 -0
  96. package/sample/crud/apps/main/views/jobs/index.ejs +113 -0
  97. package/sample/crud/apps/main/views/layouts/main.ejs +112 -0
  98. package/sample/crud/apps/main/views/logs/index.ejs +80 -0
  99. package/sample/crud/apps/main/views/metrics/index.ejs +123 -0
  100. package/sample/crud/apps/main/views/notes/edit.ejs +45 -0
  101. package/sample/crud/apps/main/views/notes/list.ejs +74 -0
  102. package/sample/crud/apps/main/views/notes/new.ejs +45 -0
  103. package/sample/crud/apps/main/views/perf/index.ejs +90 -0
  104. package/sample/crud/apps/main/views/redis/index.ejs +65 -0
  105. package/sample/crud/apps/main/views/tracing/index.ejs +106 -0
  106. package/sample/crud/apps/main/views/upload/index.ejs +79 -0
  107. package/sample/crud/apps/main/views/users/edit.ejs +48 -0
  108. package/sample/crud/apps/main/views/users/list.ejs +81 -0
  109. package/sample/crud/apps/main/views/users/new.ejs +48 -0
  110. package/sample/crud/apps/main/views/worker/index.ejs +70 -0
  111. package/sample/crud/apps/main/views/ws/index.ejs +62 -0
  112. package/sample/crud/apps/main/workers/hash-worker.js +17 -0
  113. package/sample/crud/apps/main/workers/hash.task.js +22 -0
  114. package/sample/crud/ecosystem.config.cjs +9 -0
  115. package/sample/crud/mega.config.js +105 -0
  116. package/sample/crud/package-lock.json +5665 -0
  117. package/sample/crud/package.json +28 -0
  118. package/sample/crud/test/apps/main/auth-flow.integration.test.js +177 -0
  119. package/sample/crud/test/apps/main/auth-service.test.js +93 -0
  120. package/sample/crud/test/apps/main/chat-bus.test.js +101 -0
  121. package/sample/crud/test/apps/main/chat-channel.test.js +144 -0
  122. package/sample/crud/test/apps/main/cron-demo-service.test.js +93 -0
  123. package/sample/crud/test/apps/main/demo-flow.integration.test.js +386 -0
  124. package/sample/crud/test/apps/main/email-job.test.js +76 -0
  125. package/sample/crud/test/apps/main/guide-service.test.js +68 -0
  126. package/sample/crud/test/apps/main/hash-task.test.js +30 -0
  127. package/sample/crud/test/apps/main/jobs-demo-service.test.js +88 -0
  128. package/sample/crud/test/apps/main/logs-demo-service.test.js +85 -0
  129. package/sample/crud/test/apps/main/metrics-demo-service.test.js +90 -0
  130. package/sample/crud/test/apps/main/note-service.test.js +68 -0
  131. package/sample/crud/test/apps/main/perf-service.test.js +121 -0
  132. package/sample/crud/test/apps/main/perf.integration.test.js +202 -0
  133. package/sample/crud/test/apps/main/redis-demo-service.test.js +98 -0
  134. package/sample/crud/test/apps/main/tracing-demo-service.test.js +90 -0
  135. package/sample/crud/test/apps/main/upload-demo-service.test.js +61 -0
  136. package/sample/crud/test/apps/main/user-service.test.js +65 -0
  137. package/sample/crud/test/apps/main/ws-chat.integration.test.js +232 -0
  138. package/sample/crud/vitest.config.js +8 -0
  139. package/sample/crud/yarn.lock +2142 -0
  140. package/sample/simple/.env.example +15 -0
  141. package/sample/simple/README.md +52 -0
  142. package/sample/simple/apps/main/app.config.js +35 -0
  143. package/sample/simple/apps/main/controllers/pages-controller.js +22 -0
  144. package/sample/simple/apps/main/locales/client/en.json +3 -0
  145. package/sample/simple/apps/main/locales/client/ko.json +3 -0
  146. package/sample/simple/apps/main/locales/server/en.json +23 -0
  147. package/sample/simple/apps/main/locales/server/ko.json +23 -0
  148. package/sample/simple/apps/main/public/css/app.css +101 -0
  149. package/sample/simple/apps/main/public/hello.txt +1 -0
  150. package/sample/simple/apps/main/public/js/app.js +54 -0
  151. package/sample/simple/apps/main/public/js/theme-init.js +12 -0
  152. package/sample/simple/apps/main/public/vendor/bootstrap/bootstrap.bundle.min.js +7 -0
  153. package/sample/simple/apps/main/public/vendor/bootstrap/bootstrap.min.css +6 -0
  154. package/sample/simple/apps/main/routes/index.js +9 -0
  155. package/sample/simple/apps/main/routes/pages.js +12 -0
  156. package/sample/simple/apps/main/views/index.ejs +56 -0
  157. package/sample/simple/apps/main/views/layouts/main.ejs +74 -0
  158. package/sample/simple/ecosystem.config.cjs +10 -0
  159. package/sample/simple/mega.config.js +27 -0
  160. package/sample/simple/package-lock.json +1851 -0
  161. package/sample/simple/package.json +25 -0
  162. package/sample/simple/test/apps/main/index.test.js +13 -0
  163. package/sample/simple/vitest.config.js +8 -0
  164. package/src/adapters/adapter-manager.js +305 -0
  165. package/src/adapters/adapter-options.js +208 -0
  166. package/src/adapters/file-adapter.js +350 -0
  167. package/src/adapters/file-session-adapter.js +363 -0
  168. package/src/adapters/index.js +38 -0
  169. package/src/adapters/maria-adapter.js +425 -0
  170. package/src/adapters/mega-adapter.js +511 -0
  171. package/src/adapters/mega-bus-adapter.js +81 -0
  172. package/src/adapters/mega-cache-adapter.js +94 -0
  173. package/src/adapters/mega-db-adapter.js +72 -0
  174. package/src/adapters/mega-lock-adapter.js +118 -0
  175. package/src/adapters/mega-log-sink-adapter.js +46 -0
  176. package/src/adapters/mega-session-adapter.js +72 -0
  177. package/src/adapters/mongo-adapter.js +396 -0
  178. package/src/adapters/nats-adapter.js +370 -0
  179. package/src/adapters/postgres-adapter.js +341 -0
  180. package/src/adapters/redis-adapter.js +331 -0
  181. package/src/adapters/redis-session-adapter.js +261 -0
  182. package/src/adapters/redlock-adapter.js +385 -0
  183. package/src/adapters/registry.js +157 -0
  184. package/src/adapters/sqlite-adapter.js +309 -0
  185. package/src/auth/index.js +103 -0
  186. package/src/cli/commands/console-cmd.js +56 -0
  187. package/src/cli/commands/new.js +101 -0
  188. package/src/cli/commands/routes.js +107 -0
  189. package/src/cli/commands/scaffold.js +120 -0
  190. package/src/cli/commands/test-cmd.js +45 -0
  191. package/src/cli/generators/index.js +368 -0
  192. package/src/cli/index.js +472 -0
  193. package/src/cli/template-engine.js +72 -0
  194. package/src/cli/ws-hub.js +582 -0
  195. package/src/core/ajv-mapper.js +80 -0
  196. package/src/core/boot.js +323 -0
  197. package/src/core/cluster-metrics.js +278 -0
  198. package/src/core/config-loader.js +115 -0
  199. package/src/core/config-validator.js +322 -0
  200. package/src/core/ctx-builder.js +253 -0
  201. package/src/core/envelope.js +88 -0
  202. package/src/core/error-mapper.js +116 -0
  203. package/src/core/formbody.js +69 -0
  204. package/src/core/hub-link.js +552 -0
  205. package/src/core/i18n.js +525 -0
  206. package/src/core/index.js +63 -0
  207. package/src/core/mega-app.js +1138 -0
  208. package/src/core/mega-cluster.js +232 -0
  209. package/src/core/mega-server.js +176 -0
  210. package/src/core/mega-service.js +41 -0
  211. package/src/core/migration-runner.js +196 -0
  212. package/src/core/multipart.js +282 -0
  213. package/src/core/openapi.js +114 -0
  214. package/src/core/router.js +388 -0
  215. package/src/core/routes-loader.js +57 -0
  216. package/src/core/scope-registry.js +53 -0
  217. package/src/core/security.js +275 -0
  218. package/src/core/services-loader.js +98 -0
  219. package/src/core/session-cleanup-schedule.js +57 -0
  220. package/src/core/session-store.js +55 -0
  221. package/src/core/session.js +414 -0
  222. package/src/core/static-assets.js +126 -0
  223. package/src/core/template.js +294 -0
  224. package/src/core/workers-manager.js +193 -0
  225. package/src/core/ws-compression.js +112 -0
  226. package/src/core/ws-controller.js +109 -0
  227. package/src/core/ws-message.js +176 -0
  228. package/src/core/ws-upgrade.js +445 -0
  229. package/src/errors/config-error.js +16 -0
  230. package/src/errors/http-errors.js +130 -0
  231. package/src/errors/index.js +19 -0
  232. package/src/errors/mega-error.js +34 -0
  233. package/src/eslint-plugin/index.js +15 -0
  234. package/src/eslint-plugin/no-direct-model-import.js +113 -0
  235. package/src/index.js +131 -0
  236. package/src/lib/asp/config.js +83 -0
  237. package/src/lib/asp/crypto.js +145 -0
  238. package/src/lib/asp/errors.js +49 -0
  239. package/src/lib/asp/nonce-cache.js +94 -0
  240. package/src/lib/asp/plugin.js +263 -0
  241. package/src/lib/asp/ws-terminator.js +101 -0
  242. package/src/lib/env-mapper.js +222 -0
  243. package/src/lib/hub-protocol.js +322 -0
  244. package/src/lib/index.js +42 -0
  245. package/src/lib/logger/telegram-core.js +150 -0
  246. package/src/lib/logger/telegram-transport.js +126 -0
  247. package/src/lib/mega-brute-force.js +225 -0
  248. package/src/lib/mega-circuit-breaker.js +412 -0
  249. package/src/lib/mega-cron.js +169 -0
  250. package/src/lib/mega-hash.js +179 -0
  251. package/src/lib/mega-health.js +91 -0
  252. package/src/lib/mega-job-queue.js +600 -0
  253. package/src/lib/mega-job-worker.js +295 -0
  254. package/src/lib/mega-job.js +140 -0
  255. package/src/lib/mega-logger.js +128 -0
  256. package/src/lib/mega-metrics.js +661 -0
  257. package/src/lib/mega-plugin.js +650 -0
  258. package/src/lib/mega-retry.js +95 -0
  259. package/src/lib/mega-schedule.js +507 -0
  260. package/src/lib/mega-shutdown.js +176 -0
  261. package/src/lib/mega-tracing.js +715 -0
  262. package/src/lib/mega-worker.js +653 -0
  263. package/src/lib/worker-runner/process-entry.js +30 -0
  264. package/src/lib/worker-runner/task-dispatch.js +72 -0
  265. package/src/lib/worker-runner/thread-entry.js +26 -0
  266. package/src/models/index.js +7 -0
  267. package/src/models/mega-model.js +151 -0
  268. package/src/test/index.js +288 -0
  269. package/templates/adapter/code.tpl +40 -0
  270. package/templates/adapter/test.tpl +13 -0
  271. package/templates/app/app.config.tpl +10 -0
  272. package/templates/app/route.tpl +10 -0
  273. package/templates/app/test.tpl +13 -0
  274. package/templates/channel/code.tpl +38 -0
  275. package/templates/channel/test.tpl +19 -0
  276. package/templates/controller/code.tpl +16 -0
  277. package/templates/controller/route.tpl +9 -0
  278. package/templates/controller/test.tpl +14 -0
  279. package/templates/job/code.tpl +23 -0
  280. package/templates/job/test.tpl +17 -0
  281. package/templates/locale/code.tpl +3 -0
  282. package/templates/locale/test.tpl +13 -0
  283. package/templates/middleware/code.tpl +13 -0
  284. package/templates/middleware/test.tpl +11 -0
  285. package/templates/migration/code.tpl +20 -0
  286. package/templates/migration/test.tpl +14 -0
  287. package/templates/model/code.tpl +21 -0
  288. package/templates/model/test.tpl +29 -0
  289. package/templates/project/app.config.tpl +8 -0
  290. package/templates/project/app.config.views.tpl +37 -0
  291. package/templates/project/ecosystem.config.tpl +10 -0
  292. package/templates/project/env.tpl +12 -0
  293. package/templates/project/gitignore.tpl +8 -0
  294. package/templates/project/locales/client/en.json.tpl +3 -0
  295. package/templates/project/locales/client/ko.json.tpl +3 -0
  296. package/templates/project/locales/server/en.json.tpl +17 -0
  297. package/templates/project/locales/server/ko.json.tpl +17 -0
  298. package/templates/project/mega.config.tpl +11 -0
  299. package/templates/project/package.tpl +25 -0
  300. package/templates/project/public/css/app.css +101 -0
  301. package/templates/project/public/js/app.js +54 -0
  302. package/templates/project/public/js/theme-init.js +12 -0
  303. package/templates/project/public/vendor/bootstrap/bootstrap.bundle.min.js +7 -0
  304. package/templates/project/public/vendor/bootstrap/bootstrap.min.css +6 -0
  305. package/templates/project/readme.tpl +48 -0
  306. package/templates/project/route.test.tpl +13 -0
  307. package/templates/project/route.test.views.tpl +15 -0
  308. package/templates/project/route.tpl +10 -0
  309. package/templates/project/route.views.tpl +10 -0
  310. package/templates/project/views/index.ejs.tpl +58 -0
  311. package/templates/project/views/layout.ejs.tpl +73 -0
  312. package/templates/project/vitest.config.tpl +8 -0
  313. package/templates/route/code.tpl +11 -0
  314. package/templates/route/test.tpl +26 -0
  315. package/templates/schedule/code.tpl +19 -0
  316. package/templates/schedule/test.tpl +17 -0
  317. package/templates/service/code.tpl +18 -0
  318. package/templates/service/test.tpl +17 -0
  319. package/templates/worker/code.tpl +14 -0
  320. package/templates/worker/task.tpl +13 -0
  321. package/templates/worker/test.tpl +18 -0
  322. package/vitest.config.js +33 -0
@@ -0,0 +1,600 @@
1
+ // @ts-check
2
+ /**
3
+ * MegaJobQueue — NATS JetStream 기반 잡 큐 런타임 (ADR-119).
4
+ *
5
+ * {@link import('./mega-job.js').MegaJob} 정의를 받아 **영속 큐(enqueue)** · **처리(consume)** · **재시도** ·
6
+ * **DLQ 라우팅**을 담당한다. 시각/타이머가 croner 였던 MegaScheduler 처럼, 본 런타임은 **영속성·전달
7
+ * 보장·분산 중복방지를 NATS JetStream 에 위임**하고(ADR-029: 검증된 라이브러리 래핑), 재시도 백오프는
8
+ * `MegaRetry`(p-retry)를 재사용한다.
9
+ *
10
+ * # 분산 중복방지 = workqueue 스트림 (OQ-012 leader election 대체)
11
+ * 잡 subject 당 **retention=workqueue** 스트림을 만든다. workqueue 는 메시지 1건이 **딱 하나의
12
+ * consumer** 에게만 전달되고 ack 시 삭제된다 — 여러 워커가 같은 durable consumer(같은 이름)를
13
+ * 공유하면 자동 load-balance 되고, 한 메시지를 두 워커가 동시에 처리하는 일이 없다. 즉 별도 leader
14
+ * election 없이 큐 자체가 분산 중복방지를 보장한다(ADR-119, OQ-012 결정).
15
+ *
16
+ * # 재시도 → DLQ (인프로세스 MegaRetry + working() lease 유지)
17
+ * 메시지를 받으면 `MegaRetry.withRetry` 로 `run(payload, ctx)` 를 `static retries`·`static backoff`
18
+ * 만큼 **인프로세스 지수 백오프 재시도**한다. 재시도 동안 메시지를 계속 점유하므로, `ack_wait` 가
19
+ * 만료돼 JetStream 이 **중복 재전달**하지 않도록 주기적으로 `msg.working()`(ack 타이머 리셋)을 보낸다.
20
+ * 모든 재시도 소진 시 페이로드를 **DLQ subject `<subject>.dlq`**(별도 limits 스트림)로 발행하고 원본
21
+ * 메시지를 ack 한다. DLQ 발행이 실패하면 ack 하지 않고 `nak` 해 **잡을 잃지 않는다**(at-least-once).
22
+ *
23
+ * # DLQ 스트림 한도 (ADR-134 — 무한 적재 방지)
24
+ * DLQ 는 `Limits` retention 스트림이라 NATS 디폴트로는 무한 적재된다(디스크 소진 위험). 그래서 DLQ
25
+ * 스트림에 **`max_age` 디폴트 7일**(`dlqMaxAgeMs`)을 걸어 오래된 실패 잡을 자동 만료시킨다. `dlqMaxBytes`
26
+ * 로 디스크 상한도 걸 수 있다. 스트림 생성은 멱등(이미 있으면 그대로 — 운영자가 NATS CLI 로 갱신)이라
27
+ * **신규 스트림에만** 한도가 적용된다. 영구 보존이 필요하면 `dlqMaxAgeMs: 0`(=무제한)으로 끈다. 운영
28
+ * 가이드는 docs/INFRA.md §6.
29
+ *
30
+ * # ⚠️ ack_wait vs 인프로세스 재시도 (기록)
31
+ * `static backoff.max` 가 크고 `retries` 가 많으면 한 메시지를 오래 점유한다. `working()` 하트비트가
32
+ * lease 를 갱신하지만, **워커가 재시도 도중 죽으면** ack_wait 만료 후 다른 워커로 재전달된다(at-least-once
33
+ * — run 은 멱등하게 설계해야 함). `max_deliver`(기본 5)는 워커 크래시 재전달의 backstop 이다.
34
+ *
35
+ * # 흐름 길목을 이벤트로 노출 (MegaScheduler/MegaCircuitBreaker 와 동일 정책)
36
+ * 순수 런타임이라 logger 를 모른다. 대신 길목을 이벤트로 노출한다:
37
+ * `dispatch`(enqueue) · `start`(처리 시작) · `done`(성공 ack) · `retry`(재시도 1회 실패) ·
38
+ * `fail`(최종 실패 — phase 로 단계 구분) · `dlq`(DLQ 라우팅). 소비자가 구독해 로그를 박는다.
39
+ * 타이머·콜백 경로라 호출자가 없으므로 `fail`/`dlq` 구독이 사실상 필수다.
40
+ *
41
+ * @module lib/mega-job-queue
42
+ * @see ADR-119, ADR-028 (3종 분리), ADR-112 (NATS 어댑터), ADR-029 (래핑)
43
+ */
44
+ import { EventEmitter } from 'node:events'
45
+ import { MegaConfigError } from '../errors/config-error.js'
46
+ import { MegaJob, resolveJobRetryConfig } from './mega-job.js'
47
+ import { withRetry } from './mega-retry.js'
48
+
49
+ /** 잡 큐가 노출하는 이벤트 화이트리스트(오타 차단 + 문서화 — 형제 클래스와 동일 정책). */
50
+ const KNOWN_EVENTS = Object.freeze([
51
+ 'dispatch', // enqueue(잡 발행) — event.seq/stream
52
+ 'start', // 메시지 처리 시작
53
+ 'done', // 처리 성공 + ack — event.result
54
+ 'retry', // 재시도 1회 실패(다음 시도 전) — event.attempt/retriesLeft/error
55
+ 'fail', // 최종 실패 — event.phase('run'|'decode'|'dlq-publish'|'max-deliver'|'consume-loop')/error
56
+ 'dlq', // DLQ 라우팅 완료 — event.dlqSubject
57
+ ])
58
+
59
+ /** JetStream "stream/consumer not found" 에러 코드(실측 — nats 2.29, code '404'). */
60
+ const NOT_FOUND_CODE = '404'
61
+
62
+ /** DLQ 스트림 `max_age` 디폴트(ms) — 7일. 무한 적재 방지(ADR-134). `dlqMaxAgeMs: 0` 으로 끌 수 있다. */
63
+ export const DEFAULT_DLQ_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
64
+
65
+ /**
66
+ * @typedef {Object} MegaJobQueueOptions
67
+ * @property {import('nats').NatsConnection} nc - **연결된** NatsConnection(`ctx.bus(alias).native`).
68
+ * @property {number} [ackWaitMs=30000] - consumer ack 대기(ms). 초과 시 JetStream 재전달. `working()`
69
+ * 하트비트가 이 값의 절반마다 lease 를 갱신한다.
70
+ * @property {number} [maxDeliver=5] - JetStream 최대 전달 횟수(워커 크래시 재전달 backstop).
71
+ * @property {number} [heartbeatMs] - `working()` 전송 주기(ms). 기본 `max(1000, ackWaitMs/2)`.
72
+ * @property {string} [streamPrefix='MEGA_JOBS'] - 스트림 이름 접두사.
73
+ * @property {number} [dlqMaxAgeMs=604800000] - DLQ 스트림 메시지 보존 기한(ms, 디폴트 7일). 초과한 실패
74
+ * 잡은 NATS 가 자동 만료시킨다(무한 적재 방지, ADR-134). `0` 이면 무제한(끔 — 영구 보존). **신규 DLQ
75
+ * 스트림 생성 시에만** 적용(멱등 — 기존 스트림은 운영자가 NATS CLI 로 갱신).
76
+ * @property {number} [dlqMaxBytes] - DLQ 스트림 최대 크기(bytes). 미지정이면 byte 상한 없음(`max_age` 가
77
+ * 주 가드). 디스크 상한이 필요한 운영 환경에서만 지정.
78
+ */
79
+
80
+ /**
81
+ * @typedef {Object} MegaJobHandleResult - {@link MegaJobQueue#_handleMessage} 결과(내부/테스트용).
82
+ * @property {boolean} ok - run 이 성공해 ack 됐는지.
83
+ * @property {any} [result] - run 반환값(성공 시).
84
+ * @property {Error} [error] - 실패 사유(실패 시).
85
+ */
86
+
87
+ /**
88
+ * JetStream 잡 큐 런타임. {@link MegaJob} 클래스를 받아 enqueue/consume·재시도·DLQ 를 처리한다.
89
+ *
90
+ * @example
91
+ * const queue = new MegaJobQueue({ nc: ctx.bus('jobs').native })
92
+ * queue.on('dlq', (e) => log.error(e, 'job moved to DLQ'))
93
+ * await queue.enqueue(SendEmailJob, { to: 'a@b.c' })
94
+ * const sub = await queue.consume(SendEmailJob, new SendEmailJob(), ctx)
95
+ * // graceful shutdown
96
+ * await sub.stop()
97
+ */
98
+ export class MegaJobQueue extends EventEmitter {
99
+ /** @type {import('nats').NatsConnection} */ #nc
100
+ /** @type {number} */ #ackWaitMs
101
+ /** @type {number} */ #maxDeliver
102
+ /** @type {number} */ #heartbeatMs
103
+ /** @type {string} */ #streamPrefix
104
+ /** @type {number} DLQ max_age(ms). 0 = 무제한. */ #dlqMaxAgeMs
105
+ /** @type {number|undefined} DLQ max_bytes. undefined = 무제한. */ #dlqMaxBytes
106
+ /** @type {typeof import('nats')|null} 지연 로드된 nats 모듈(enum/codec/nanos). */ #nats = null
107
+ /** @type {import('nats').Codec<any>|null} */ #codec = null
108
+ /** @type {import('nats').JetStreamClient|null} */ #js = null
109
+ /** @type {import('nats').JetStreamManager|null} */ #jsm = null
110
+ /** @type {Promise<void>|null} ensureReady 멱등 가드. */ #readyPromise = null
111
+
112
+ /**
113
+ * @param {MegaJobQueueOptions} options
114
+ * @throws {TypeError} nc 가 JetStream 가능한 NatsConnection 이 아니면(fail-fast).
115
+ */
116
+ constructor({ nc, ackWaitMs = 30_000, maxDeliver = 5, heartbeatMs, streamPrefix = 'MEGA_JOBS', dlqMaxAgeMs = DEFAULT_DLQ_MAX_AGE_MS, dlqMaxBytes } = /** @type {any} */ ({})) {
117
+ super()
118
+ if (!nc || typeof nc.jetstream !== 'function' || typeof nc.jetstreamManager !== 'function') {
119
+ throw new TypeError(
120
+ 'MegaJobQueue({ nc }) — nc must be a connected NatsConnection (jetstream()/jetstreamManager()).',
121
+ )
122
+ }
123
+ if (typeof ackWaitMs !== 'number' || ackWaitMs <= 0) {
124
+ throw new TypeError(`MegaJobQueue: ackWaitMs must be a positive number (ms). Got: ${ackWaitMs}.`)
125
+ }
126
+ if (typeof maxDeliver !== 'number' || !Number.isInteger(maxDeliver) || maxDeliver < 1) {
127
+ throw new TypeError(`MegaJobQueue: maxDeliver must be an integer >= 1. Got: ${maxDeliver}.`)
128
+ }
129
+ // dlqMaxAgeMs: 0 = 무제한(끔). 음수/비정수는 운영 실수라 fail-fast(silent 보정 X).
130
+ if (typeof dlqMaxAgeMs !== 'number' || !Number.isInteger(dlqMaxAgeMs) || dlqMaxAgeMs < 0) {
131
+ throw new TypeError(`MegaJobQueue: dlqMaxAgeMs must be an integer >= 0 (0 = unlimited). Got: ${dlqMaxAgeMs}.`)
132
+ }
133
+ if (dlqMaxBytes !== undefined && (typeof dlqMaxBytes !== 'number' || !Number.isInteger(dlqMaxBytes) || dlqMaxBytes < 1)) {
134
+ throw new TypeError(`MegaJobQueue: dlqMaxBytes must be an integer >= 1 when set. Got: ${dlqMaxBytes}.`)
135
+ }
136
+ this.#nc = nc
137
+ this.#ackWaitMs = ackWaitMs
138
+ this.#maxDeliver = maxDeliver
139
+ this.#heartbeatMs = heartbeatMs ?? Math.max(1000, Math.floor(ackWaitMs / 2))
140
+ this.#streamPrefix = streamPrefix
141
+ this.#dlqMaxAgeMs = dlqMaxAgeMs
142
+ this.#dlqMaxBytes = dlqMaxBytes
143
+ }
144
+
145
+ // ── 이벤트 화이트리스트(L-1 정책 — 형제 클래스와 동일) ────────────────────
146
+
147
+ /**
148
+ * 이벤트명 화이트리스트 검증. 모든 구독/해제 오버라이드가 공유.
149
+ * @param {string} method @param {string} event @returns {void}
150
+ * @throws {RangeError} 알 수 없는 이벤트명일 때.
151
+ */
152
+ #assertKnownEvent(method, event) {
153
+ if (!KNOWN_EVENTS.includes(event)) {
154
+ throw new RangeError(
155
+ `MegaJobQueue.${method}('${event}', ...) — unknown event. Known: ${KNOWN_EVENTS.join(', ')}.`,
156
+ )
157
+ }
158
+ }
159
+
160
+ /** @param {'dispatch'|'start'|'done'|'retry'|'fail'|'dlq'} event @param {(payload: any) => void} listener @returns {this} */
161
+ on(event, listener) {
162
+ this.#assertKnownEvent('on', event)
163
+ return super.on(event, listener)
164
+ }
165
+
166
+ /** @param {'dispatch'|'start'|'done'|'retry'|'fail'|'dlq'} event @param {(payload: any) => void} listener @returns {this} */
167
+ off(event, listener) {
168
+ this.#assertKnownEvent('off', event)
169
+ return super.off(event, listener)
170
+ }
171
+
172
+ /** @param {'dispatch'|'start'|'done'|'retry'|'fail'|'dlq'} event @param {(payload: any) => void} listener @returns {this} */
173
+ once(event, listener) {
174
+ this.#assertKnownEvent('once', event)
175
+ return super.once(event, listener)
176
+ }
177
+
178
+ /** @param {'dispatch'|'start'|'done'|'retry'|'fail'|'dlq'} event @param {(payload: any) => void} listener @returns {this} */
179
+ addListener(event, listener) {
180
+ this.#assertKnownEvent('addListener', event)
181
+ return super.addListener(event, listener)
182
+ }
183
+
184
+ /** @param {'dispatch'|'start'|'done'|'retry'|'fail'|'dlq'} event @param {(payload: any) => void} listener @returns {this} */
185
+ removeListener(event, listener) {
186
+ this.#assertKnownEvent('removeListener', event)
187
+ return super.removeListener(event, listener)
188
+ }
189
+
190
+ /** @param {'dispatch'|'start'|'done'|'retry'|'fail'|'dlq'} event @param {(payload: any) => void} listener @returns {this} */
191
+ prependListener(event, listener) {
192
+ this.#assertKnownEvent('prependListener', event)
193
+ return super.prependListener(event, listener)
194
+ }
195
+
196
+ /** @param {'dispatch'|'start'|'done'|'retry'|'fail'|'dlq'} event @param {(payload: any) => void} listener @returns {this} */
197
+ prependOnceListener(event, listener) {
198
+ this.#assertKnownEvent('prependOnceListener', event)
199
+ return super.prependOnceListener(event, listener)
200
+ }
201
+
202
+ // ── 초기화 ──────────────────────────────────────────────────────────────
203
+
204
+ /**
205
+ * JetStream client/manager·codec 을 지연 초기화한다(멱등 — 동시 호출도 1회만). nats 드라이버는
206
+ * 여기서 lazy import(어댑터 정합 — 잡 미사용 앱에 nats 로드 강제 안 함).
207
+ * @returns {Promise<void>}
208
+ */
209
+ async ensureReady() {
210
+ if (this.#readyPromise) return this.#readyPromise
211
+ this.#readyPromise = (async () => {
212
+ const nats = await import('nats')
213
+ this.#nats = nats
214
+ this.#codec = nats.JSONCodec()
215
+ this.#js = this.#nc.jetstream()
216
+ this.#jsm = await this.#nc.jetstreamManager()
217
+ })()
218
+ return this.#readyPromise
219
+ }
220
+
221
+ /**
222
+ * 잡 클래스 검증 — MegaJob 서브클래스 + 유효한 static subject(fail-fast).
223
+ *
224
+ * subject 는 단순 "비어있지 않은 문자열" 이 아니라 **NATS subject 규칙**을 따라야 한다(L-2): 와일드카드
225
+ * (`*`·`>`)·공백·빈 토큰을 허용하면 workqueue 필터가 다른 잡의 메시지까지 빨아들이거나 `<subject>.dlq`
226
+ * 와 overlap 해 **무한 재처리/유실** 풋건이 된다. 또 `<subject>.dlq` 를 DLQ 로 쓰므로 subject 가 이미
227
+ * `.dlq` 로 끝나면 다른 잡의 DLQ 와 충돌한다 — 모두 등록 시점에 막는다.
228
+ *
229
+ * @param {typeof MegaJob} JobClass @returns {string} subject.
230
+ * @throws {TypeError} 서브클래스가 아니거나 subject 가 무효(빈 값·와일드카드·공백·빈 토큰·`.dlq` 말미)일 때.
231
+ */
232
+ #assertJobSubject(JobClass) {
233
+ if (typeof JobClass !== 'function' || !(JobClass.prototype instanceof MegaJob)) {
234
+ throw new TypeError('MegaJobQueue: JobClass must be a subclass of MegaJob.')
235
+ }
236
+ const subject = JobClass.subject
237
+ if (typeof subject !== 'string' || subject.trim() === '') {
238
+ throw new TypeError(`MegaJobQueue: '${JobClass.name}' static subject must be a non-empty string.`)
239
+ }
240
+ // 토큰 문자셋만 허용 — 와일드카드(`*`·`>`)·공백·기타 특수문자 차단(L-2).
241
+ if (!/^[A-Za-z0-9._-]+$/.test(subject)) {
242
+ throw new TypeError(
243
+ `MegaJobQueue: '${JobClass.name}' static subject '${subject}' contains invalid characters ` +
244
+ `(only letters, digits, '.', '_', '-' allowed — no wildcards '*'/'>' or whitespace).`,
245
+ )
246
+ }
247
+ // 빈 토큰(선두/말미 점·연속 점)은 NATS 가 거부 — 우리도 동일하게 막는다.
248
+ if (subject.split('.').some((token) => token.length === 0)) {
249
+ throw new TypeError(
250
+ `MegaJobQueue: '${JobClass.name}' static subject '${subject}' has an empty token (no leading/trailing/double dots).`,
251
+ )
252
+ }
253
+ // `<subject>.dlq` 가 DLQ subject 라, subject 자체가 '.dlq' 로 끝나면 다른 잡의 DLQ 와 충돌한다.
254
+ if (subject.endsWith('.dlq')) {
255
+ throw new TypeError(
256
+ `MegaJobQueue: '${JobClass.name}' static subject '${subject}' must not end with '.dlq' (reserved for DLQ routing).`,
257
+ )
258
+ }
259
+ return subject
260
+ }
261
+
262
+ /**
263
+ * `static concurrency` 를 검증해 반환한다(fail-fast). **NATS 는 `max_ack_pending=0` 을 무제한으로
264
+ * 해석**하므로(consumer 설정 `max_ack_pending: concurrency`), concurrency=0/음수/비정수면 의도치 않은
265
+ * 무제한 in-flight(서버측 동시성 제한 무력화) 풋건이 된다 — 그 전에 막는다.
266
+ * @param {typeof MegaJob} JobClass @returns {number} 양의 정수 concurrency.
267
+ * @throws {MegaConfigError} concurrency 가 양의 정수가 아닐 때.
268
+ */
269
+ #resolveConcurrency(JobClass) {
270
+ const concurrency = JobClass.concurrency ?? 1
271
+ if (!Number.isInteger(concurrency) || concurrency < 1) {
272
+ throw new MegaConfigError(
273
+ 'job.invalid_concurrency',
274
+ `MegaJobQueue: '${JobClass.name}' static concurrency must be a positive integer (got: ${concurrency}). ` +
275
+ `NATS treats max_ack_pending=0 as unlimited, so 0/negative/non-integer values are rejected.`,
276
+ { details: { job: JobClass.name, concurrency } },
277
+ )
278
+ }
279
+ return concurrency
280
+ }
281
+
282
+ /**
283
+ * 이벤트를 안전하게 발화한다 — 리스너(소비자 코드)가 throw 해도 잡 처리 흐름(ack/nak/DLQ)을 오염시키지
284
+ * 않도록 격리한다. `_handleMessage`/`#routeToDlq` 의 **"절대 throw 안 함" 불변식**을 지키는 핵심(M-3·L-3:
285
+ * 형제 MegaScheduler `#fire` 와 동일 정책). 리스너 예외는 묵히지 않고 stderr 로 표면화한다( * logger 미주입이라 console). `fail` 리스너 예외를 다시 `fail` 로 emit 하면 무한 재귀라 console 로만 알린다.
286
+ * @param {'dispatch'|'start'|'done'|'retry'|'fail'|'dlq'} event @param {any} payload @returns {void}
287
+ */
288
+ #safeEmit(event, payload) {
289
+ try {
290
+ this.emit(event, payload)
291
+ } catch (e) {
292
+ // 리스너 throw 는 비치명 — ack/nak(부작용)은 emit 보다 먼저 확정되므로 잡 처리에 영향 없다.
293
+ // 묵히지 않고 stderr 로 알린다(logger 미주입이라 console).
294
+ console.error(
295
+ `[mega-job-queue] '${event}' event listener threw (ignored — does not affect job processing):`,
296
+ e,
297
+ )
298
+ }
299
+ }
300
+
301
+ /**
302
+ * 잡 스트림(workqueue)과 DLQ 스트림(limits)을 멱등 생성한다. 이미 있으면 그대로 둔다(설정 변경은
303
+ * 운영 책임 — 자동 update 안 함). enqueue/consume 가 자동 호출하므로 보통 직접 부를 필요 없다.
304
+ * @param {typeof MegaJob} JobClass @returns {Promise<void>}
305
+ */
306
+ async ensureStream(JobClass) {
307
+ await this.ensureReady()
308
+ const subject = this.#assertJobSubject(JobClass)
309
+ const nats = /** @type {typeof import('nats')} */ (this.#nats)
310
+ await this.#ensureStreamExists(this.#workStreamName(subject), [subject], nats.RetentionPolicy.Workqueue)
311
+ // DLQ 스트림에 한도를 건다(무한 적재 방지, ADR-134). dlqMaxAgeMs=0 이면 max_age 미지정(무제한),
312
+ // dlqMaxBytes 미지정이면 max_bytes 미지정.
313
+ await this.#ensureStreamExists(this.#dlqStreamName(subject), [`${subject}.dlq`], nats.RetentionPolicy.Limits, {
314
+ maxAgeMs: this.#dlqMaxAgeMs,
315
+ maxBytes: this.#dlqMaxBytes,
316
+ })
317
+ }
318
+
319
+ /**
320
+ * 단일 스트림 멱등 생성 — info 로 존재 확인 후 없으면 add. not-found 외 에러는 전파. `limits` 가
321
+ * 주어지면 생성 시 `max_age`/`max_bytes` 를 함께 설정한다(DLQ 한도, ADR-134). 이미 존재하는 스트림은
322
+ * 갱신하지 않는다(멱등 — 설정 변경은 운영 책임).
323
+ * @param {string} name @param {string[]} subjects @param {import('nats').RetentionPolicy} retention
324
+ * @param {{ maxAgeMs?: number, maxBytes?: number }} [limits]
325
+ * @returns {Promise<void>}
326
+ */
327
+ async #ensureStreamExists(name, subjects, retention, limits) {
328
+ const jsm = /** @type {import('nats').JetStreamManager} */ (this.#jsm)
329
+ const nats = /** @type {typeof import('nats')} */ (this.#nats)
330
+ try {
331
+ await jsm.streams.info(name)
332
+ return // 이미 존재.
333
+ } catch (e) {
334
+ if (/** @type {any} */ (e)?.code !== NOT_FOUND_CODE) throw e // 진짜 장애는 묻지 않는다.
335
+ }
336
+ /** @type {Record<string, any>} */
337
+ const config = { name, subjects, retention, storage: nats.StorageType.File }
338
+ if (limits) {
339
+ // max_age 는 nanos(ms→ns). 0 은 NATS 에서 "무제한" 이므로 양수일 때만 설정(미지정=무제한과 동일).
340
+ if (typeof limits.maxAgeMs === 'number' && limits.maxAgeMs > 0) config.max_age = nats.nanos(limits.maxAgeMs)
341
+ if (typeof limits.maxBytes === 'number' && limits.maxBytes > 0) config.max_bytes = limits.maxBytes
342
+ }
343
+ await jsm.streams.add(config)
344
+ }
345
+
346
+ /**
347
+ * durable consumer 를 멱등 생성한다(같은 durable_name = 워커 그룹 → load-balance + 중복방지).
348
+ * @param {string} stream @param {string} durable @param {string} subject @param {number} concurrency @returns {Promise<void>}
349
+ */
350
+ async #ensureConsumer(stream, durable, subject, concurrency) {
351
+ const jsm = /** @type {import('nats').JetStreamManager} */ (this.#jsm)
352
+ const nats = /** @type {typeof import('nats')} */ (this.#nats)
353
+ try {
354
+ await jsm.consumers.info(stream, durable)
355
+ return
356
+ } catch (e) {
357
+ if (/** @type {any} */ (e)?.code !== NOT_FOUND_CODE) throw e
358
+ }
359
+ await jsm.consumers.add(stream, {
360
+ durable_name: durable,
361
+ ack_policy: nats.AckPolicy.Explicit, // 각 메시지를 명시 ack — 재시도/DLQ 제어의 전제.
362
+ ack_wait: nats.nanos(this.#ackWaitMs), // ms → ns(nanos). working() 하트비트가 갱신.
363
+ max_deliver: this.#maxDeliver, // 워커 크래시 재전달 backstop.
364
+ max_ack_pending: concurrency, // 동시 미ack 상한 = concurrency(서버측 in-flight 제한).
365
+ filter_subject: subject,
366
+ })
367
+ }
368
+
369
+ /**
370
+ * 잡을 큐에 넣는다(JetStream publish — 서버에 영속 저장). 스트림이 없으면 만든다.
371
+ * @param {typeof MegaJob} JobClass @param {any} payload @returns {Promise<{ seq: number, stream: string, duplicate: boolean }>}
372
+ */
373
+ async enqueue(JobClass, payload) {
374
+ await this.ensureStream(JobClass)
375
+ const subject = /** @type {string} */ (JobClass.subject)
376
+ const js = /** @type {import('nats').JetStreamClient} */ (this.#js)
377
+ const ack = await js.publish(subject, this.#encode(payload))
378
+ // dispatch 는 publish(부작용) 완료 후 발화 — 리스너 throw 가 반환값/흐름을 오염시키지 않게 safeEmit(L-3).
379
+ this.#safeEmit('dispatch', { subject, seq: ack.seq, stream: ack.stream })
380
+ return { seq: ack.seq, stream: ack.stream, duplicate: ack.duplicate }
381
+ }
382
+
383
+ /**
384
+ * 잡 처리를 시작한다 — durable consumer 를 만들고 메시지를 받아 `run(payload, ctx)` 를 재시도와 함께
385
+ * 실행한다. `static concurrency` 만큼 동시 처리한다. 반환 핸들의 `stop()` 으로 graceful 중단.
386
+ *
387
+ * (소비 워커 라이프사이클·bus 별명 배선은 `MegaJobWorker` 영역 — 본 메서드는 "처리 베이스".
388
+ * 정본 `MegaWorker` = CPU `worker_threads` 풀로 별개 추상(ADR-120/ADR-121).)
389
+ *
390
+ * @param {typeof MegaJob} JobClass @param {MegaJob} instance - `run` 호출 대상(서브클래스 인스턴스).
391
+ * @param {Record<string, any>} ctx - run 에 넘길 컨텍스트.
392
+ * @returns {Promise<{ stop: () => Promise<void> }>}
393
+ */
394
+ async consume(JobClass, instance, ctx) {
395
+ await this.ensureStream(JobClass)
396
+ const subject = /** @type {string} */ (JobClass.subject)
397
+ const stream = this.#workStreamName(subject)
398
+ const durable = this.#durableName(subject)
399
+ const concurrency = this.#resolveConcurrency(JobClass) // L-1: 양의 정수 fail-fast(max_ack_pending=0 풋건 차단).
400
+ const retryConfig = resolveJobRetryConfig(JobClass)
401
+ await this.#ensureConsumer(stream, durable, subject, concurrency)
402
+
403
+ const js = /** @type {import('nats').JetStreamClient} */ (this.#js)
404
+ const consumer = await js.consumers.get(stream, durable)
405
+ const messages = await consumer.consume({ max_messages: concurrency })
406
+
407
+ /** @type {Set<Promise<any>>} 진행 중 처리(동시성 상한). */
408
+ const inFlight = new Set()
409
+ const loop = (async () => {
410
+ for await (const msg of messages) {
411
+ const p = this._handleMessage(instance, ctx, msg, retryConfig, subject).finally(() =>
412
+ inFlight.delete(p),
413
+ )
414
+ inFlight.add(p)
415
+ // 동시성 상한 도달 시 하나라도 끝날 때까지 대기(과도 in-flight 방지).
416
+ if (inFlight.size >= concurrency) await Promise.race(inFlight)
417
+ }
418
+ await Promise.allSettled(inFlight) // close 후 남은 처리 마무리.
419
+ })()
420
+ // 루프 자체 에러(iterator 깨짐 등)는 묵지 않고 fail 로 표면화. _handleMessage 는 throw 안 함.
421
+ loop.catch((e) =>
422
+ this.#safeEmit('fail', {
423
+ subject,
424
+ error: e instanceof Error ? e : new Error(String(e)),
425
+ phase: 'consume-loop',
426
+ }),
427
+ )
428
+
429
+ return {
430
+ stop: async () => {
431
+ // ConsumerMessages.close() 는 Promise<void|Error> 를 반환(nats 2.29 consumer.d.ts) — drain 중
432
+ // 오류면 Error 인스턴스를 resolve 한다(reject 아님). 묵히지 않고 stderr 로 표면화한다(L-3).
433
+ const closeResult = await messages.close()
434
+ if (closeResult instanceof Error) {
435
+ console.error(`[mega-job-queue] consumer close reported an error on '${subject}':`, closeResult)
436
+ }
437
+ await loop.catch(() => {}) // 남은 처리 완료까지 대기(에러는 위 loop.catch 에서 이미 표면화).
438
+ },
439
+ }
440
+ }
441
+
442
+ /**
443
+ * 메시지 1건 처리 — decode → run(재시도) → ack | DLQ. **절대 throw 하지 않는다**(consume 루프의
444
+ * in-flight Promise 가 reject 되지 않게 — MegaScheduler #fire 와 동일 불변식). 내부/테스트용 seam 이라
445
+ * `_` 접두사(어댑터 `_connect` 컨벤션).
446
+ *
447
+ * @param {MegaJob} instance @param {Record<string, any>} ctx @param {import('nats').JsMsg} msg
448
+ * @param {ReturnType<typeof resolveJobRetryConfig>} retryConfig @param {string} subject
449
+ * @returns {Promise<MegaJobHandleResult>}
450
+ */
451
+ async _handleMessage(instance, ctx, msg, retryConfig, subject) {
452
+ const seq = msg.seq
453
+
454
+ // M-1(ADR-119 hardening): 이번 전달이 max_deliver 에 도달했고(deliveryCount >= maxDeliver)
455
+ // **이전에 최소 1번 재전달이 있었다면**(deliveryCount > 1), 이전 전달들이 워커 크래시(ack 못 하고
456
+ // 죽음 → ack_wait 만료 → 재전달)로 소진된 것이다. 이번이 JetStream 의 **마지막 전달**이라, 여기서
457
+ // run 을 또 시도해 같은 크래시가 나면 메시지가 워크 스트림에 orphan 으로 남는다(더는 재전달 안 됨,
458
+ // DLQ 로도 못 감). 따라서 run 없이 곧장 DLQ 로 라우팅한다(phase: 'max-deliver').
459
+ // ⚠️ `deliveryCount > 1` 가드: maxDeliver=1(재전달 없음)이면 첫 전달도 deliveryCount=1 이라, 가드가
460
+ // 없으면 **정상 잡이 한 번도 실행되지 않고** DLQ 로 빠지는 풋건이 된다. 첫 전달은 항상 정상 처리한다.
461
+ if (msg.info.deliveryCount > 1 && msg.info.deliveryCount >= this.#maxDeliver) {
462
+ const error = new Error(
463
+ `MegaJobQueue: job on '${subject}' exhausted max_deliver (${this.#maxDeliver}) at delivery ` +
464
+ `${msg.info.deliveryCount} — routing to DLQ (likely worker crash loop).`,
465
+ )
466
+ this.#safeEmit('fail', { subject, seq, error, phase: 'max-deliver' })
467
+ let exhaustedPayload
468
+ try {
469
+ exhaustedPayload = this.#decode(msg.data)
470
+ } catch {
471
+ // 디코드까지 실패한 poison 이면 raw 바이트를 봉투에 보관(무시 아님, DLQ 로 보존).
472
+ exhaustedPayload = { decodeError: true, rawBase64: this.#toBase64(msg.data) }
473
+ }
474
+ await this.#routeToDlq(subject, exhaustedPayload, error, msg, seq)
475
+ return { ok: false, error }
476
+ }
477
+
478
+ let payload
479
+ try {
480
+ payload = this.#decode(msg.data)
481
+ } catch (decodeErr) {
482
+ // 디코드 불가 = poison 메시지. 재시도 무의미 → 곧장 DLQ(원본 바이트 base64 보관).
483
+ const error = decodeErr instanceof Error ? decodeErr : new Error(String(decodeErr))
484
+ this.#safeEmit('fail', { subject, seq, error, phase: 'decode' })
485
+ await this.#routeToDlq(subject, { decodeError: true, rawBase64: this.#toBase64(msg.data) }, error, msg, seq)
486
+ return { ok: false, error }
487
+ }
488
+
489
+ this.#safeEmit('start', { subject, seq })
490
+ let settled = false
491
+ // 재시도 동안 메시지를 점유하므로 ack_wait 만료(→중복 재전달)를 막기 위해 working() 으로 lease 갱신.
492
+ const heartbeat = setInterval(() => {
493
+ if (!settled) msg.working()
494
+ }, this.#heartbeatMs)
495
+
496
+ // run(재시도) 결과를 변수에 담는다 — ack/DLQ(부작용) + emit 은 try/catch **밖**에서 처리한다(M-3·L-3).
497
+ // 이유: emit('done')/emit('fail') 리스너가 throw 했을 때 run 의 catch 가 잡으면 성공 잡이 spurious
498
+ // DLQ·double-ack 로 오염된다. 결과 판정과 부작용을 분리해 "절대 throw 안 함" 불변식을 지킨다.
499
+ /** @type {any} run 성공 시 반환값(runError===null 일 때만 유효). */
500
+ let runResult
501
+ /** @type {Error|null} run 최종 실패 사유(성공이면 null). */
502
+ let runError = null
503
+ try {
504
+ runResult = await withRetry(() => instance.run(payload, ctx), {
505
+ ...retryConfig,
506
+ onFailedAttempt: (info) => {
507
+ this.#safeEmit('retry', {
508
+ subject,
509
+ seq,
510
+ attempt: info.attemptNumber,
511
+ retriesLeft: info.retriesLeft,
512
+ error: info.error,
513
+ })
514
+ },
515
+ })
516
+ } catch (err) {
517
+ runError = err instanceof Error ? err : new Error(String(err))
518
+ } finally {
519
+ settled = true
520
+ clearInterval(heartbeat)
521
+ }
522
+
523
+ if (runError === null) {
524
+ msg.ack() // 부작용(ack) 먼저 — emit 보다 앞서 확정한다.
525
+ this.#safeEmit('done', { subject, seq, result: runResult })
526
+ return { ok: true, result: runResult }
527
+ }
528
+ // run 소진 → DLQ 라우팅. emit('fail') 은 safeEmit 이라 리스너 throw 가 DLQ 흐름을 막지 않는다.
529
+ this.#safeEmit('fail', { subject, seq, error: runError, phase: 'run' })
530
+ await this.#routeToDlq(subject, payload, runError, msg, seq)
531
+ return { ok: false, error: runError }
532
+ }
533
+
534
+ /**
535
+ * DLQ 라우팅 — 실패 페이로드+에러 메타를 `<subject>.dlq` 로 발행하고 원본을 ack. 발행 실패 시 ack 하지
536
+ * 않고 nak 해 잡을 보존한다(at-least-once). 본 메서드도 throw 하지 않는다.
537
+ * @param {string} subject @param {any} payload @param {Error} error @param {import('nats').JsMsg} msg @param {number} seq @returns {Promise<void>}
538
+ */
539
+ async #routeToDlq(subject, payload, error, msg, seq) {
540
+ const dlqSubject = `${subject}.dlq`
541
+ const js = /** @type {import('nats').JetStreamClient} */ (this.#js)
542
+ try {
543
+ await js.publish(
544
+ dlqSubject,
545
+ this.#encode({
546
+ originalSubject: subject,
547
+ failedAt: new Date().toISOString(),
548
+ deliveryCount: msg.info.deliveryCount,
549
+ error: { name: error.name, message: error.message, stack: error.stack },
550
+ payload,
551
+ }),
552
+ )
553
+ } catch (pubErr) {
554
+ // DLQ 발행 실패 → ack 안 함(보존). nak 으로 재전달 요청 → DLQ 백엔드 회복 후 재시도(안 묻음).
555
+ const e = pubErr instanceof Error ? pubErr : new Error(String(pubErr))
556
+ msg.nak() // 부작용(nak) 먼저 확정 — emit 보다 앞선다(M-3: 리스너 throw 가 보존 결정을 막지 않게).
557
+ this.#safeEmit('fail', { subject, seq, error: e, phase: 'dlq-publish' })
558
+ return
559
+ }
560
+ msg.ack() // DLQ 에 안전히 보관됨 → 워크 메시지 제거(emit 보다 먼저 확정).
561
+ this.#safeEmit('dlq', { subject, dlqSubject, seq, error })
562
+ }
563
+
564
+ // ── 헬퍼 ────────────────────────────────────────────────────────────────
565
+
566
+ /** @param {any} value @returns {Uint8Array} JSONCodec 인코드(undefined→null). */
567
+ #encode(value) {
568
+ return /** @type {import('nats').Codec<any>} */ (this.#codec).encode(value === undefined ? null : value)
569
+ }
570
+
571
+ /** @param {Uint8Array} data @returns {any} JSONCodec 디코드. */
572
+ #decode(data) {
573
+ return /** @type {import('nats').Codec<any>} */ (this.#codec).decode(data)
574
+ }
575
+
576
+ /** @param {Uint8Array} data @returns {string} base64(원본 바이트 보존용). */
577
+ #toBase64(data) {
578
+ return Buffer.from(data).toString('base64')
579
+ }
580
+
581
+ /** @param {string} subject @returns {string} 스트림 이름 안전화(영숫자·_·- 외 → _). */
582
+ #sanitize(subject) {
583
+ return subject.replace(/[^A-Za-z0-9_-]/g, '_')
584
+ }
585
+
586
+ /** @param {string} subject @returns {string} 워크 스트림 이름. */
587
+ #workStreamName(subject) {
588
+ return `${this.#streamPrefix}_${this.#sanitize(subject)}`
589
+ }
590
+
591
+ /** @param {string} subject @returns {string} DLQ 스트림 이름. */
592
+ #dlqStreamName(subject) {
593
+ return `${this.#streamPrefix}_${this.#sanitize(subject)}_DLQ`
594
+ }
595
+
596
+ /** @param {string} subject @returns {string} durable consumer 이름(워커 그룹 키). */
597
+ #durableName(subject) {
598
+ return `${this.#sanitize(subject)}_workers`
599
+ }
600
+ }