mega-framework 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (248) hide show
  1. package/README.md +9 -0
  2. package/bin/mega-ws-hub.js +2 -2
  3. package/package.json +33 -9
  4. package/sample/crud/.env +10 -1
  5. package/sample/crud/.env.example +10 -1
  6. package/sample/crud/.mega/journal/history/20260612092543-create-users.json +261 -0
  7. package/sample/crud/.mega/journal/snapshot.json +261 -0
  8. package/sample/crud/apps/main/controllers/auth-controller.js +22 -14
  9. package/sample/crud/apps/main/controllers/web-controller.js +7 -5
  10. package/sample/crud/apps/main/locales/server/en.json +12 -1
  11. package/sample/crud/apps/main/locales/server/ko.json +12 -1
  12. package/sample/crud/apps/main/migrations/20260606000001-create-users.js +91 -13
  13. package/sample/crud/apps/main/migrations/20260606000002-create-boards.js +165 -0
  14. package/sample/crud/apps/main/migrations/20260606000003-create-logs.js +107 -0
  15. package/sample/crud/apps/main/models/log-partition-model.js +105 -0
  16. package/sample/crud/apps/main/models/note-model.js +79 -0
  17. package/sample/crud/apps/main/models/user-level-model.js +24 -0
  18. package/sample/crud/apps/main/models/user-model.js +146 -0
  19. package/sample/crud/apps/main/models/user-type-model.js +21 -0
  20. package/sample/crud/apps/main/models/wallet-model.js +24 -0
  21. package/sample/crud/apps/main/routes/users.js +55 -10
  22. package/sample/crud/apps/main/schedules/log-partition-schedule.js +33 -0
  23. package/sample/crud/apps/main/services/auth-service.js +39 -24
  24. package/sample/crud/apps/main/services/log-partition-service.js +101 -0
  25. package/sample/crud/apps/main/services/note-service.js +6 -6
  26. package/sample/crud/apps/main/services/redis-demo-service.js +3 -3
  27. package/sample/crud/apps/main/services/user-service.js +62 -21
  28. package/sample/crud/apps/main/views/auth/login.ejs +6 -6
  29. package/sample/crud/apps/main/views/auth/register.ejs +46 -5
  30. package/sample/crud/apps/main/views/users/edit.ejs +42 -5
  31. package/sample/crud/apps/main/views/users/list.ejs +6 -2
  32. package/sample/crud/apps/main/views/users/new.ejs +56 -4
  33. package/sample/crud/docs/log_partition_design.mm.md +23 -0
  34. package/sample/crud/mega.config.js +10 -2
  35. package/sample/crud/package.json +3 -3
  36. package/sample/crud/scripts/start-ws-hub.sh +20 -6
  37. package/sample/simple/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  38. package/sample/simple/package.json +2 -2
  39. package/src/adapters/adapter-manager.js +2 -1
  40. package/src/adapters/adapter-options.js +44 -3
  41. package/src/adapters/file-adapter.js +9 -5
  42. package/src/adapters/file-session-adapter.js +4 -3
  43. package/src/adapters/maria-adapter.js +33 -7
  44. package/src/adapters/mega-cache-adapter.js +83 -6
  45. package/src/adapters/mega-db-adapter.js +10 -1
  46. package/src/adapters/mongo-adapter.js +40 -8
  47. package/src/adapters/postgres-adapter.js +33 -6
  48. package/src/adapters/redis-adapter.js +7 -3
  49. package/src/adapters/sqlite-adapter.js +26 -3
  50. package/src/cli/commands/console-cmd.js +3 -1
  51. package/src/cli/commands/new.js +13 -3
  52. package/src/cli/commands/scaffold.js +173 -33
  53. package/src/cli/generators/index.js +140 -3
  54. package/src/cli/index.js +437 -155
  55. package/src/cli/watch.js +188 -0
  56. package/src/core/ajv-mapper.js +30 -3
  57. package/src/core/boot.js +464 -245
  58. package/src/core/cluster-metrics.js +13 -4
  59. package/src/core/ctx-builder.js +65 -3
  60. package/src/core/envelope.js +119 -12
  61. package/src/core/hub-link.js +89 -18
  62. package/src/core/i18n.js +11 -1
  63. package/src/core/index.js +7 -3
  64. package/src/core/mega-app.js +253 -505
  65. package/src/core/mega-cluster.js +4 -1
  66. package/src/core/mega-server.js +40 -9
  67. package/src/core/migration/dialect-registry.js +107 -0
  68. package/src/core/migration/dialects/README.md +62 -0
  69. package/src/core/migration/dialects/maria.js +496 -0
  70. package/src/core/migration/dialects/mongo.js +824 -0
  71. package/src/core/migration/dialects/postgres.js +563 -0
  72. package/src/core/migration/dialects/sqlite.js +476 -0
  73. package/src/core/migration/differ.js +456 -0
  74. package/src/core/migration/generate.js +508 -0
  75. package/src/core/migration/journal.js +167 -0
  76. package/src/core/migration/model-scan.js +84 -0
  77. package/src/core/migration/mongo-migration-db.js +97 -0
  78. package/src/core/migration/schema-builder.js +400 -0
  79. package/src/core/migration/schema-validator.js +315 -0
  80. package/src/core/migration-lock.js +205 -0
  81. package/src/core/migration-runner.js +166 -38
  82. package/src/core/multipart.js +28 -5
  83. package/src/core/pipeline.js +131 -0
  84. package/src/core/router.js +70 -65
  85. package/src/core/scope-registry.js +1 -0
  86. package/src/core/security.js +70 -12
  87. package/src/core/session-store.js +14 -1
  88. package/src/core/workers-manager.js +12 -1
  89. package/src/core/ws-cluster.js +10 -3
  90. package/src/core/ws-message.js +48 -4
  91. package/src/core/ws-presence.js +636 -0
  92. package/src/core/ws-roster.js +50 -8
  93. package/src/core/ws-upgrade.js +223 -12
  94. package/src/index.js +1 -1
  95. package/src/lib/hub-protocol.js +29 -0
  96. package/src/lib/mega-circuit-breaker.js +5 -3
  97. package/src/lib/mega-health.js +35 -4
  98. package/src/lib/mega-job-queue.js +151 -34
  99. package/src/lib/mega-job.js +37 -1
  100. package/src/lib/mega-metrics.js +31 -13
  101. package/src/lib/mega-plugin.js +34 -3
  102. package/src/lib/mega-schedule.js +40 -22
  103. package/src/lib/mega-shutdown.js +114 -39
  104. package/src/lib/mega-tracing.js +66 -19
  105. package/src/lib/mega-worker.js +33 -6
  106. package/src/lib/otel-resource.js +36 -0
  107. package/src/{cli → lib}/ws-hub.js +139 -15
  108. package/src/models/crud-sql-builder.js +133 -0
  109. package/src/models/mega-model.js +82 -2
  110. package/src/models/model-crud.js +483 -0
  111. package/src/models/mongo-crud.js +285 -0
  112. package/templates/adr/code.tpl +23 -0
  113. package/templates/model/code-mongo.tpl +35 -0
  114. package/templates/model/code.tpl +15 -1
  115. package/templates/model/test-mongo.tpl +38 -0
  116. package/templates/model/test.tpl +4 -0
  117. package/types/adapters/adapter-manager.d.ts +95 -0
  118. package/types/adapters/adapter-options.d.ts +93 -0
  119. package/types/adapters/file-adapter.d.ts +105 -0
  120. package/types/adapters/file-session-adapter.d.ts +103 -0
  121. package/types/adapters/index.d.ts +20 -0
  122. package/types/adapters/maria-adapter.d.ts +117 -0
  123. package/types/adapters/mega-adapter.d.ts +215 -0
  124. package/types/adapters/mega-bus-adapter.d.ts +45 -0
  125. package/types/adapters/mega-cache-adapter.d.ts +73 -0
  126. package/types/adapters/mega-db-adapter.d.ts +50 -0
  127. package/types/adapters/mega-lock-adapter.d.ts +62 -0
  128. package/types/adapters/mega-log-sink-adapter.d.ts +15 -0
  129. package/types/adapters/mega-session-adapter.d.ts +32 -0
  130. package/types/adapters/mongo-adapter.d.ts +150 -0
  131. package/types/adapters/nats-adapter.d.ts +108 -0
  132. package/types/adapters/postgres-adapter.d.ts +141 -0
  133. package/types/adapters/redis-adapter.d.ts +78 -0
  134. package/types/adapters/redis-session-adapter.d.ts +82 -0
  135. package/types/adapters/redlock-adapter.d.ts +149 -0
  136. package/types/adapters/registry.d.ts +46 -0
  137. package/types/adapters/sqlite-adapter.d.ts +112 -0
  138. package/types/auth/index.d.ts +24 -0
  139. package/types/cli/commands/console-cmd.d.ts +37 -0
  140. package/types/cli/commands/new.d.ts +16 -0
  141. package/types/cli/commands/routes.d.ts +36 -0
  142. package/types/cli/commands/scaffold.d.ts +78 -0
  143. package/types/cli/commands/test-cmd.d.ts +14 -0
  144. package/types/cli/generators/index.d.ts +122 -0
  145. package/types/cli/index.d.ts +234 -0
  146. package/types/cli/template-engine.d.ts +40 -0
  147. package/types/cli/watch.d.ts +59 -0
  148. package/types/core/ajv-mapper.d.ts +27 -0
  149. package/types/core/boot.d.ts +233 -0
  150. package/types/core/cluster-metrics.d.ts +52 -0
  151. package/types/core/config-loader.d.ts +13 -0
  152. package/types/core/config-validator.d.ts +30 -0
  153. package/types/core/ctx-builder.d.ts +103 -0
  154. package/types/core/envelope.d.ts +79 -0
  155. package/types/core/error-mapper.d.ts +17 -0
  156. package/types/core/formbody.d.ts +41 -0
  157. package/types/core/hub-link.d.ts +266 -0
  158. package/types/core/i18n.d.ts +178 -0
  159. package/types/core/index.d.ts +28 -0
  160. package/types/core/mega-app.d.ts +529 -0
  161. package/types/core/mega-cluster.d.ts +104 -0
  162. package/types/core/mega-server.d.ts +91 -0
  163. package/types/core/mega-service.d.ts +31 -0
  164. package/types/core/migration/dialect-registry.d.ts +22 -0
  165. package/types/core/migration/dialects/maria.d.ts +99 -0
  166. package/types/core/migration/dialects/mongo.d.ts +89 -0
  167. package/types/core/migration/dialects/postgres.d.ts +117 -0
  168. package/types/core/migration/dialects/sqlite.d.ts +111 -0
  169. package/types/core/migration/differ.d.ts +47 -0
  170. package/types/core/migration/generate.d.ts +56 -0
  171. package/types/core/migration/journal.d.ts +52 -0
  172. package/types/core/migration/model-scan.d.ts +19 -0
  173. package/types/core/migration/mongo-migration-db.d.ts +7 -0
  174. package/types/core/migration/schema-builder.d.ts +197 -0
  175. package/types/core/migration/schema-validator.d.ts +20 -0
  176. package/types/core/migration-lock.d.ts +33 -0
  177. package/types/core/migration-runner.d.ts +101 -0
  178. package/types/core/multipart.d.ts +86 -0
  179. package/types/core/openapi.d.ts +62 -0
  180. package/types/core/pipeline.d.ts +93 -0
  181. package/types/core/router.d.ts +159 -0
  182. package/types/core/routes-loader.d.ts +21 -0
  183. package/types/core/scope-registry.d.ts +14 -0
  184. package/types/core/security.d.ts +77 -0
  185. package/types/core/services-loader.d.ts +27 -0
  186. package/types/core/session-cleanup-schedule.d.ts +19 -0
  187. package/types/core/session-store.d.ts +25 -0
  188. package/types/core/session.d.ts +77 -0
  189. package/types/core/static-assets.d.ts +73 -0
  190. package/types/core/template.d.ts +106 -0
  191. package/types/core/workers-manager.d.ts +79 -0
  192. package/types/core/ws-cluster.d.ts +208 -0
  193. package/types/core/ws-compression.d.ts +112 -0
  194. package/types/core/ws-controller.d.ts +65 -0
  195. package/types/core/ws-message.d.ts +106 -0
  196. package/types/core/ws-presence.d.ts +273 -0
  197. package/types/core/ws-roster.d.ts +108 -0
  198. package/types/core/ws-upgrade.d.ts +260 -0
  199. package/types/errors/config-error.d.ts +10 -0
  200. package/types/errors/http-errors.d.ts +120 -0
  201. package/types/errors/index.d.ts +3 -0
  202. package/types/errors/mega-error.d.ts +32 -0
  203. package/types/index.d.ts +39 -0
  204. package/types/lib/asp/config.d.ts +49 -0
  205. package/types/lib/asp/crypto.d.ts +43 -0
  206. package/types/lib/asp/errors.d.ts +30 -0
  207. package/types/lib/asp/nonce-cache.d.ts +52 -0
  208. package/types/lib/asp/plugin.d.ts +30 -0
  209. package/types/lib/asp/ws-terminator.d.ts +45 -0
  210. package/types/lib/env-mapper.d.ts +14 -0
  211. package/types/lib/hub-protocol.d.ts +106 -0
  212. package/types/lib/index.d.ts +22 -0
  213. package/types/lib/logger/telegram-core.d.ts +104 -0
  214. package/types/lib/logger/telegram-transport.d.ts +45 -0
  215. package/types/lib/mega-brute-force.d.ts +66 -0
  216. package/types/lib/mega-circuit-breaker.d.ts +243 -0
  217. package/types/lib/mega-cron.d.ts +66 -0
  218. package/types/lib/mega-hash.d.ts +32 -0
  219. package/types/lib/mega-health.d.ts +48 -0
  220. package/types/lib/mega-job-queue.d.ts +188 -0
  221. package/types/lib/mega-job-worker.d.ts +130 -0
  222. package/types/lib/mega-job.d.ts +145 -0
  223. package/types/lib/mega-logger.d.ts +45 -0
  224. package/types/lib/mega-metrics.d.ts +285 -0
  225. package/types/lib/mega-plugin.d.ts +245 -0
  226. package/types/lib/mega-retry.d.ts +85 -0
  227. package/types/lib/mega-schedule.d.ts +260 -0
  228. package/types/lib/mega-shutdown.d.ts +135 -0
  229. package/types/lib/mega-tracing.d.ts +224 -0
  230. package/types/lib/mega-worker.d.ts +129 -0
  231. package/types/lib/otel-resource.d.ts +16 -0
  232. package/types/lib/worker-runner/process-entry.d.ts +1 -0
  233. package/types/lib/worker-runner/task-dispatch.d.ts +28 -0
  234. package/types/lib/worker-runner/thread-entry.d.ts +1 -0
  235. package/types/lib/ws-hub.d.ts +259 -0
  236. package/types/models/crud-sql-builder.d.ts +48 -0
  237. package/types/models/index.d.ts +1 -0
  238. package/types/models/mega-model.d.ts +138 -0
  239. package/types/models/model-crud.d.ts +82 -0
  240. package/types/models/mongo-crud.d.ts +59 -0
  241. package/types/test/index.d.ts +84 -0
  242. package/.env +0 -127
  243. package/sample/crud/apps/main/migrations/20260606000002-add-auth-to-users.js +0 -30
  244. package/sample/crud/apps/main/models/note.js +0 -71
  245. package/sample/crud/apps/main/models/user.js +0 -86
  246. package/sample/crud/package-lock.json +0 -5665
  247. package/sample/crud/yarn.lock +0 -2142
  248. package/sample/simple/package-lock.json +0 -1851
@@ -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
 
@@ -82,12 +91,18 @@ function truncateStack(stack) {
82
91
  * 하트비트가 이 값의 절반마다 lease 를 갱신한다.
83
92
  * @property {number} [maxDeliver=5] - JetStream 최대 전달 횟수(워커 크래시 재전달 backstop).
84
93
  * @property {number} [heartbeatMs] - `working()` 전송 주기(ms). 기본 `max(1000, ackWaitMs/2)`.
94
+ * 양의 정수 + `< ackWaitMs` 필수(생성자 fail-fast) — 이상이면 lease 갱신이 늦어 정상 처리 중
95
+ * 중복 재전달, 0 이하면 working() 플러딩.
85
96
  * @property {string} [streamPrefix='MEGA_JOBS'] - 스트림 이름 접두사.
86
97
  * @property {number} [dlqMaxAgeMs=604800000] - DLQ 스트림 메시지 보존 기한(ms, 디폴트 7일). 초과한 실패
87
98
  * 잡은 NATS 가 자동 만료시킨다(무한 적재 방지, ADR-134). `0` 이면 무제한(끔 — 영구 보존). **신규 DLQ
88
99
  * 스트림 생성 시에만** 적용(멱등 — 기존 스트림은 운영자가 NATS CLI 로 갱신).
89
100
  * @property {number} [dlqMaxBytes] - DLQ 스트림 최대 크기(bytes). 미지정이면 byte 상한 없음(`max_age` 가
90
101
  * 주 가드). 디스크 상한이 필요한 운영 환경에서만 지정.
102
+ * @property {number} [runTimeoutMs=1800000] - run 전체(재시도 포함) 실행 상한 디폴트(ms, 기본 30분).
103
+ * 초과 시 잡을 실패로 판정해 DLQ 라우팅 — 행 잡이 `working()` lease 를 영구 갱신하며 메시지를 점유하는
104
+ * 것을 막는다. 잡별 `static timeoutMs` 가 우선, `0` = 무제한. ⚠️ 타임아웃돼도 진행 중 run 은 중단되지
105
+ * 않는다(백그라운드 계속 — run 멱등 설계 필요). 나중 실패는 fail(abandoned-run) 으로 표면화.
91
106
  */
92
107
 
93
108
  /**
@@ -116,17 +131,25 @@ export class MegaJobQueue extends EventEmitter {
116
131
  /** @type {string} */ #streamPrefix
117
132
  /** @type {number} DLQ max_age(ms). 0 = 무제한. */ #dlqMaxAgeMs
118
133
  /** @type {number|undefined} DLQ max_bytes. undefined = 무제한. */ #dlqMaxBytes
134
+ /** @type {number} run 전체 실행 상한 디폴트(ms). 0 = 무제한. 잡별 static timeoutMs 가 우선. */ #runTimeoutMs
119
135
  /** @type {typeof import('nats')|null} 지연 로드된 nats 모듈(enum/codec/nanos). */ #nats = null
120
136
  /** @type {import('nats').Codec<any>|null} */ #codec = null
121
137
  /** @type {import('nats').JetStreamClient|null} */ #js = null
122
138
  /** @type {import('nats').JetStreamManager|null} */ #jsm = null
123
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()
124
147
 
125
148
  /**
126
149
  * @param {MegaJobQueueOptions} options
127
150
  * @throws {TypeError} nc 가 JetStream 가능한 NatsConnection 이 아니면(fail-fast).
128
151
  */
129
- constructor({ nc, ackWaitMs = 30_000, maxDeliver = 5, heartbeatMs, streamPrefix = 'MEGA_JOBS', dlqMaxAgeMs = DEFAULT_DLQ_MAX_AGE_MS, dlqMaxBytes } = /** @type {any} */ ({})) {
152
+ 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
153
  super()
131
154
  if (!nc || typeof nc.jetstream !== 'function' || typeof nc.jetstreamManager !== 'function') {
132
155
  throw new TypeError(
@@ -146,6 +169,15 @@ export class MegaJobQueue extends EventEmitter {
146
169
  if (dlqMaxBytes !== undefined && (typeof dlqMaxBytes !== 'number' || !Number.isInteger(dlqMaxBytes) || dlqMaxBytes < 1)) {
147
170
  throw new TypeError(`MegaJobQueue: dlqMaxBytes must be an integer >= 1 when set. Got: ${dlqMaxBytes}.`)
148
171
  }
172
+ // runTimeoutMs: 0 = 무제한(끔). 음수/비정수는 운영 실수라 fail-fast(silent 보정 X).
173
+ if (typeof runTimeoutMs !== 'number' || !Number.isInteger(runTimeoutMs) || runTimeoutMs < 0) {
174
+ throw new TypeError(`MegaJobQueue: runTimeoutMs must be an integer >= 0 (0 = unlimited). Got: ${runTimeoutMs}.`)
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
+ }
149
181
  this.#nc = nc
150
182
  this.#ackWaitMs = ackWaitMs
151
183
  this.#maxDeliver = maxDeliver
@@ -153,6 +185,7 @@ export class MegaJobQueue extends EventEmitter {
153
185
  this.#streamPrefix = streamPrefix
154
186
  this.#dlqMaxAgeMs = dlqMaxAgeMs
155
187
  this.#dlqMaxBytes = dlqMaxBytes
188
+ this.#runTimeoutMs = runTimeoutMs
156
189
  }
157
190
 
158
191
  // ── 이벤트 화이트리스트(L-1 정책 — 형제 클래스와 동일) ────────────────────
@@ -317,16 +350,30 @@ export class MegaJobQueue extends EventEmitter {
317
350
  * @param {typeof MegaJob} JobClass @returns {Promise<void>}
318
351
  */
319
352
  async ensureStream(JobClass) {
320
- await this.ensureReady()
321
353
  const subject = this.#assertJobSubject(JobClass)
322
- const nats = /** @type {typeof import('nats')} */ (this.#nats)
323
- await this.#ensureStreamExists(this.#workStreamName(subject), [subject], nats.RetentionPolicy.Workqueue)
324
- // DLQ 스트림에 한도를 건다(무한 적재 방지, ADR-134). dlqMaxAgeMs=0 이면 max_age 미지정(무제한),
325
- // dlqMaxBytes 미지정이면 max_bytes 미지정.
326
- await this.#ensureStreamExists(this.#dlqStreamName(subject), [`${subject}.dlq`], nats.RetentionPolicy.Limits, {
327
- maxAgeMs: this.#dlqMaxAgeMs,
328
- maxBytes: this.#dlqMaxBytes,
329
- })
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
330
377
  }
331
378
 
332
379
  /**
@@ -380,14 +427,26 @@ export class MegaJobQueue extends EventEmitter {
380
427
  }
381
428
 
382
429
  /**
383
- * 잡을 큐에 넣는다(JetStream publish — 서버에 영속 저장). 스트림이 없으면 만든다.
384
- * @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 }>}
385
438
  */
386
- 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
+ }
387
443
  await this.ensureStream(JobClass)
388
444
  const subject = /** @type {string} */ (JobClass.subject)
389
445
  const js = /** @type {import('nats').JetStreamClient} */ (this.#js)
390
- 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)
391
450
  // dispatch 는 publish(부작용) 완료 후 발화 — 리스너 throw 가 반환값/흐름을 오염시키지 않게 safeEmit(L-3).
392
451
  this.#safeEmit('dispatch', { subject, seq: ack.seq, stream: ack.stream })
393
452
  return { seq: ack.seq, stream: ack.stream, duplicate: ack.duplicate }
@@ -416,6 +475,7 @@ export class MegaJobQueue extends EventEmitter {
416
475
  const durable = this.#durableName(subject)
417
476
  const concurrency = this.#resolveConcurrency(JobClass) // L-1: 양의 정수 fail-fast(max_ack_pending=0 풋건 차단).
418
477
  const retryConfig = resolveJobRetryConfig(JobClass)
478
+ const runTimeoutMs = resolveJobRunTimeoutMs(JobClass, this.#runTimeoutMs) // 행 잡 영구 점유 backstop.
419
479
  await this.#ensureConsumer(stream, durable, subject, concurrency)
420
480
 
421
481
  const js = /** @type {import('nats').JetStreamClient} */ (this.#js)
@@ -426,7 +486,7 @@ export class MegaJobQueue extends EventEmitter {
426
486
  const inFlight = new Set()
427
487
  const loop = (async () => {
428
488
  for await (const msg of messages) {
429
- const p = this._handleMessage(instance, ctx, msg, retryConfig, subject).finally(() =>
489
+ const p = this._handleMessage(instance, ctx, msg, retryConfig, subject, runTimeoutMs).finally(() =>
430
490
  inFlight.delete(p),
431
491
  )
432
492
  inFlight.add(p)
@@ -464,9 +524,10 @@ export class MegaJobQueue extends EventEmitter {
464
524
  *
465
525
  * @param {MegaJob} instance @param {Record<string, any>} ctx @param {import('nats').JsMsg} msg
466
526
  * @param {ReturnType<typeof resolveJobRetryConfig>} retryConfig @param {string} subject
527
+ * @param {number} [runTimeoutMs] - run 전체(재시도 포함) 상한(ms). 미지정 시 큐 디폴트, 0 = 무제한.
467
528
  * @returns {Promise<MegaJobHandleResult>}
468
529
  */
469
- async _handleMessage(instance, ctx, msg, retryConfig, subject) {
530
+ async _handleMessage(instance, ctx, msg, retryConfig, subject, runTimeoutMs = this.#runTimeoutMs) {
470
531
  const seq = msg.seq
471
532
 
472
533
  // M-1(ADR-119 hardening): 이번 전달이 max_deliver 에 도달했고(deliveryCount >= maxDeliver)
@@ -520,8 +581,14 @@ export class MegaJobQueue extends EventEmitter {
520
581
  let runResult
521
582
  /** @type {Error|null} run 최종 실패 사유(성공이면 null). */
522
583
  let runError = null
584
+ /** @type {NodeJS.Timeout|undefined} run 타임아웃 타이머(있으면 finally 에서 정리). */
585
+ let timeoutHandle
586
+ /** @type {boolean} 타임아웃이 race 를 이겼는지(버려진 run 의 잔여 실패 표면화 분기용). */
587
+ let timedOut = false
588
+ /** @type {Promise<any>|undefined} run(재시도) Promise — 타임아웃 시 잔여 실패 관찰에 필요. */
589
+ let runPromise
523
590
  try {
524
- runResult = await withRetry(() => instance.run(payload, ctx), {
591
+ runPromise = withRetry(() => instance.run(payload, ctx), {
525
592
  ...retryConfig,
526
593
  onFailedAttempt: (info) => {
527
594
  this.#safeEmit('retry', {
@@ -533,11 +600,44 @@ export class MegaJobQueue extends EventEmitter {
533
600
  })
534
601
  },
535
602
  })
603
+ if (runTimeoutMs > 0) {
604
+ // 행(hang) 잡 backstop: run 전체(재시도 포함)에 상한을 건다. 상한 초과 = 실패 판정 → DLQ.
605
+ // 없으면 working() 하트비트가 lease 를 영원히 갱신해 메시지가 재전달도 DLQ 도 못 가는
606
+ // 영구 점유가 된다(프로세스 사망 시에만 해소).
607
+ runResult = await Promise.race([
608
+ runPromise,
609
+ new Promise((_resolve, reject) => {
610
+ timeoutHandle = setTimeout(() => {
611
+ timedOut = true
612
+ reject(new Error(
613
+ `MegaJobQueue: job on '${subject}' exceeded run timeout (${runTimeoutMs}ms) — treating as ` +
614
+ `failed (run continues in background; design run to be idempotent).`,
615
+ ))
616
+ }, runTimeoutMs)
617
+ if (typeof timeoutHandle.unref === 'function') timeoutHandle.unref()
618
+ }),
619
+ ])
620
+ } else {
621
+ runResult = await runPromise
622
+ }
536
623
  } catch (err) {
537
624
  runError = err instanceof Error ? err : new Error(String(err))
625
+ if (timedOut && runPromise) {
626
+ // 버려진(타임아웃 패배) run 의 나중 실패를 묵히지 않고 표면화한다 — 잡은 이미 실패 판정·DLQ
627
+ // 라우팅됐으므로 처리 흐름엔 영향 없다(reject 자체는 race 가 구독해 unhandledRejection 아님).
628
+ runPromise.catch((lateErr) => {
629
+ this.#safeEmit('fail', {
630
+ subject,
631
+ seq,
632
+ error: lateErr instanceof Error ? lateErr : new Error(String(lateErr)),
633
+ phase: 'abandoned-run',
634
+ })
635
+ })
636
+ }
538
637
  } finally {
539
638
  settled = true
540
639
  clearInterval(heartbeat)
640
+ if (timeoutHandle !== undefined) clearTimeout(timeoutHandle)
541
641
  }
542
642
 
543
643
  if (runError === null) {
@@ -552,30 +652,47 @@ export class MegaJobQueue extends EventEmitter {
552
652
  }
553
653
 
554
654
  /**
555
- * DLQ 라우팅 — 실패 페이로드+에러 메타를 `<subject>.dlq` 로 발행하고 원본을 ack. 발행 실패 시 ack 하지
556
- * 않고 nak 잡을 보존한다(at-least-once). 메서드도 throw 하지 않는다.
655
+ * DLQ 라우팅 — 실패 페이로드+에러 메타를 `<subject>.dlq` 로 발행하고 원본을 ack. 발행은 인프로세스로
656
+ * 짧게 재시도(일시 장애 흡수)하고, 그래도 실패하면 ack 하지 않고 **점증 지연 nak** 해 잡을 보존한다
657
+ * (at-least-once — 즉시 재전달 핫 루프 방지). 단 이번 전달이 `max_deliver` 의 **마지막 전달**이면 nak 해도
658
+ * 재전달이 없어 메시지가 워크 스트림에 orphan 으로 남는다 — 이때는 un-ack 보존 + `fail(dlq-orphan)` 으로
659
+ * 운영자 개입을 명시 표면화한다(ack/term 으로 잡을 지우면 유실이라 하지 않는다). 본 메서드도 throw 하지 않는다.
557
660
  * @param {string} subject @param {any} payload @param {Error} error @param {import('nats').JsMsg} msg @param {number} seq @returns {Promise<void>}
558
661
  */
559
662
  async #routeToDlq(subject, payload, error, msg, seq) {
560
663
  const dlqSubject = `${subject}.dlq`
561
664
  const js = /** @type {import('nats').JetStreamClient} */ (this.#js)
562
665
  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
- }),
666
+ // DLQ publish 자체를 짧게 재시도(추가 2회, 250ms→1s) — 일시적 NATS 응답 지연/재연결 틈을 흡수해
667
+ // nak 재전달(전체 인프로세스 재시도 사이클 반복)보다 훨씬 싸게 orphan 확률을 줄인다.
668
+ await withRetry(
669
+ () =>
670
+ js.publish(
671
+ dlqSubject,
672
+ this.#encode({
673
+ originalSubject: subject,
674
+ failedAt: new Date().toISOString(),
675
+ deliveryCount: msg.info.deliveryCount,
676
+ // stack 전체를 봉투에 담으면 poison 잡 폭주 시 DLQ 직렬화 비용·디스크가 급증한다 → 상한으로 truncate(Med).
677
+ error: { name: error.name, message: error.message, stack: truncateStack(error.stack) },
678
+ payload,
679
+ }),
680
+ ),
681
+ { retries: 2, minTimeout: 250, maxTimeout: 1000, factor: 2, jitter: true },
573
682
  )
574
683
  } catch (pubErr) {
575
- // DLQ 발행 실패 → ack 안 함(보존). nak 으로 재전달 요청 → DLQ 백엔드 회복 후 재시도(안 묻음).
576
684
  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' })
685
+ if (msg.info.deliveryCount >= this.#maxDeliver) {
686
+ // 마지막 전달 nak 해도 JetStream 더는 재전달하지 않는다. ack/term 하면 잡이 사라지므로
687
+ // un-ack 로 워크 스트림에 보존하고(운영자가 NATS CLI 로 수습 가능), orphan 을 크게 표면화한다.
688
+ this.#safeEmit('fail', { subject, seq, error: e, phase: 'dlq-orphan' })
689
+ return
690
+ }
691
+ // DLQ 발행 실패 → ack 안 함(보존). 점증 지연 nak — 즉시 재전달이면 DLQ 백엔드 장애 동안
692
+ // 재전달→재시도 사이클 전체가 타이트하게 도는 핫 루프가 된다(deliveryCount 기반 지수 지연).
693
+ const nakDelayMs = Math.min(NAK_DELAY_MAX_MS, 1000 * 2 ** (msg.info.deliveryCount - 1))
694
+ msg.nak(nakDelayMs) // 부작용(nak) 먼저 확정 — emit 보다 앞선다(M-3: 리스너 throw 가 보존 결정을 막지 않게).
695
+ this.#safeEmit('fail', { subject, seq, error: e, phase: 'dlq-publish', nakDelayMs })
579
696
  return
580
697
  }
581
698
  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 (라이브러리 래핑)
@@ -72,7 +73,14 @@ export class MegaJob {
72
73
  /** @type {string|undefined} bus 별명(`ctx.bus(alias)`). 워커 배선이 nc 해석에 사용. */
73
74
  static bus = undefined
74
75
 
75
- /** @type {number} 동시 처리 메시지 수(consumer max_ack_pending). 기본 1(순차·안전). */
76
+ /**
77
+ * @type {number} 동시 처리 메시지 수. 기본 1(순차·안전).
78
+ *
79
+ * ⚠️ 이 값은 durable consumer 의 `max_ack_pending` 으로 들어가므로 **워커 그룹(같은 subject 를
80
+ * 소비하는 모든 인스턴스) 전체의 합산 in-flight 상한**이다 — `mega worker` 인스턴스를 늘려도
81
+ * 합산 동시 처리는 이 값을 넘지 못한다. 처리량을 늘리려면 인스턴스 증설과 **함께** concurrency 를
82
+ * 키워야 한다(실측: c=1→c=32 에서 처리량 ~10배).
83
+ */
76
84
  static concurrency = 1
77
85
 
78
86
  /** @type {number} run 실패 시 **추가** 재시도 횟수(첫 시도 제외). 기본 3(OQ-012). */
@@ -81,6 +89,15 @@ export class MegaJob {
81
89
  /** @type {MegaJobBackoff} 지수 백오프 설정. 기본 { exponential, 1s, 30s }(OQ-012). */
82
90
  static backoff = JOB_RETRY_DEFAULTS.backoff
83
91
 
92
+ /**
93
+ * @type {number|undefined} run 전체(재시도 포함) 실행 상한(ms). 초과 시 큐가 잡을 실패로 판정해 DLQ 로
94
+ * 보낸다 — 행(hang)된 run 이 `working()` lease 를 영원히 갱신하며 메시지를 영구 점유하는 것을 막는다.
95
+ * 미지정 시 {@link import('./mega-job-queue.js').MegaJobQueue} 의 `runTimeoutMs`(기본 30분), `0` = 무제한.
96
+ * ⚠️ 타임아웃돼도 진행 중이던 run 은 JS 특성상 중단되지 않는다(백그라운드 계속) — run 은 멱등하게
97
+ * 설계해야 한다(at-least-once, 모듈 docstring).
98
+ */
99
+ static timeoutMs = undefined
100
+
84
101
  /**
85
102
  * 메시지 1건마다 실행되는 본문. 서브클래스가 **반드시** 구현한다. throw 하면 {@link MegaJobQueue}
86
103
  * 가 재시도하고, 재시도 소진 시 DLQ 로 보낸다 — 그러므로 비치명/일시 오류는 그냥 throw 하면 된다.
@@ -138,3 +155,22 @@ export function resolveJobRetryConfig(JobClass) {
138
155
  // factor=2(exponential), jitter=on — OQ-012 정합. MegaRetry 가 첫 시도 즉시 + 이후 지수 백오프.
139
156
  return { retries, minTimeout: initial, maxTimeout: max, factor: 2, jitter: true }
140
157
  }
158
+
159
+ /**
160
+ * 잡 클래스의 `static timeoutMs` 를 검증해 반환한다. 미지정(undefined/null)이면 `defaultMs`(큐 디폴트),
161
+ * `0` = 무제한. 음수/비정수는 운영 실수라 fail-fast(silent 보정 금지).
162
+ *
163
+ * @param {typeof MegaJob} JobClass - 잡 클래스.
164
+ * @param {number} defaultMs - 큐 레벨 디폴트(ms, 0 = 무제한).
165
+ * @returns {number} 적용할 타임아웃(ms). 0 = 무제한.
166
+ * @throws {TypeError} timeoutMs 가 0 이상 정수가 아닐 때.
167
+ */
168
+ export function resolveJobRunTimeoutMs(JobClass, defaultMs) {
169
+ const timeoutMs = JobClass.timeoutMs ?? defaultMs
170
+ if (typeof timeoutMs !== 'number' || !Number.isInteger(timeoutMs) || timeoutMs < 0) {
171
+ throw new TypeError(
172
+ `MegaJob '${JobClass.name}': static timeoutMs must be an integer >= 0 (0 = unlimited). Got: ${timeoutMs}.`,
173
+ )
174
+ }
175
+ return timeoutMs
176
+ }
@@ -36,14 +36,10 @@
36
36
  * @see https://opentelemetry.io/docs/specs/otel/metrics/ (OTel metrics)
37
37
  * @see https://prometheus.io/docs/instrumenting/exposition_formats/ (Prometheus 텍스트 포맷)
38
38
  */
39
+ import { getHeapStatistics } from 'node:v8'
39
40
  import { MeterProvider } from '@opentelemetry/sdk-metrics'
40
41
  import { PrometheusExporter, PrometheusSerializer } from '@opentelemetry/exporter-prometheus'
41
- 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'
42
+ import { buildOtelResource } from './otel-resource.js'
47
43
  import { MegaConfigError } from '../errors/config-error.js'
48
44
 
49
45
  /** meter 이름 (instrumentation scope) — OTel 컨벤션상 패키지명. */
@@ -147,12 +143,8 @@ export function init(opts = /** @type {any} */ ({})) {
147
143
  )
148
144
  }
149
145
 
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
- })
146
+ // resource 조립은 트레이싱과 공유하는 단일 출처(otel-resource.js, ADR-193) — 두 SDK 간 드리프트 방지.
147
+ const resource = buildOtelResource({ serviceName, version: opts.version, environment: opts.environment, attributes: opts.attributes })
156
148
 
157
149
  // preventServerStart — 자체 :9464 서버를 띄우지 않고 우리가 collect() 로 직접 긁는다(메인 포트 서빙).
158
150
  const reader = new PrometheusExporter({ preventServerStart: true })
@@ -556,7 +548,7 @@ function buildInstruments(meter) {
556
548
  */
557
549
  function registerSystemGauges(meter) {
558
550
  const memory = meter.createObservableGauge('mega_process_memory_bytes', {
559
- description: '프로세스 메모리 사용량(바이트) — kind=rss|heap_used|heap_total|external 라벨.',
551
+ description: '프로세스 메모리 사용량(바이트) — kind=rss|heap_used|heap_total|external|array_buffers 라벨.',
560
552
  unit: 'By',
561
553
  })
562
554
  memory.addCallback((result) => {
@@ -565,6 +557,32 @@ function registerSystemGauges(meter) {
565
557
  result.observe(mu.heapUsed, { kind: 'heap_used' })
566
558
  result.observe(mu.heapTotal, { kind: 'heap_total' })
567
559
  result.observe(mu.external, { kind: 'external' })
560
+ // arrayBuffers 분리 노출(ADR-215, G5 M-5) — mongo driver 등 ArrayBuffer 상주분을 heap 과 구분해
561
+ // "RSS 만 보는" 관측 함정(burst 후 V8 페이지 미반환 = 정상 평형)을 운영자가 분해해 읽을 수 있게.
562
+ result.observe(mu.arrayBuffers ?? 0, { kind: 'array_buffers' })
563
+ })
564
+
565
+ // V8 힙 통계(ADR-215, G5 M-5) — heap_size_limit(OOM 한계)·total_available_size(여유)·physical 등
566
+ // process.memoryUsage 가 못 보여주는 V8 내부 수위. kind 는 고정 enum 이라 카디널리티 안전.
567
+ const V8_HEAP_KINDS = Object.freeze([
568
+ 'total_heap_size',
569
+ 'total_physical_size',
570
+ 'total_available_size',
571
+ 'used_heap_size',
572
+ 'heap_size_limit',
573
+ 'malloced_memory',
574
+ 'peak_malloced_memory',
575
+ 'external_memory',
576
+ ])
577
+ const v8Heap = meter.createObservableGauge('mega_v8_heap_bytes', {
578
+ description: `V8 힙 통계(바이트, v8.getHeapStatistics) — kind=${V8_HEAP_KINDS.join('|')} 라벨.`,
579
+ unit: 'By',
580
+ })
581
+ v8Heap.addCallback((result) => {
582
+ const hs = /** @type {Record<string, number>} */ (/** @type {unknown} */ (getHeapStatistics()))
583
+ for (const kind of V8_HEAP_KINDS) {
584
+ if (typeof hs[kind] === 'number') result.observe(hs[kind], { kind })
585
+ }
568
586
  })
569
587
 
570
588
  const uptime = meter.createObservableGauge('mega_process_uptime_seconds', {
@@ -53,11 +53,23 @@ const LIFECYCLE_EVENTS = /** @type {const} */ (['beforeBoot', 'afterBoot', 'befo
53
53
  */
54
54
 
55
55
  /**
56
+ * 플러그인 scaffold generator manifest(03-api-spec §11) — `mega g <name>` 이 소비한다(ADR-187/199).
57
+ * `dir` 는 프로젝트 루트 상대 출력 기준, `files[].path`/`files[].template` 의 `{{token}}` 은
58
+ * cli/generators 의 `SCAFFOLD_TOKENS` 계약(Name/name/camelName/snake/app)으로 치환된다.
56
59
  * @typedef {Object} MegaScaffoldDef
57
60
  * @property {string} dir
58
61
  * @property {Array<{ path: string, template: string }>} files
62
+ * @property {string} [description] - `mega help` 가 병기하는 한 줄 설명(선택).
59
63
  */
60
64
 
65
+ /** 빌트인 generator 이름 — 플러그인 scaffold 가 점유 금지(빌트인이 우선이라 silent shadow 가 됨).
66
+ * cli/generators 의 `GENERATOR_KINDS` 와 동일해야 한다(레이어 역전 회피를 위해 미러 — 동기화는
67
+ * mega-plugin 단위 테스트가 GENERATOR_KINDS 와 집합 비교로 강제한다). */
68
+ export const RESERVED_GENERATOR_NAMES = new Set([
69
+ 'app', 'controller', 'channel', 'service', 'model', 'middleware', 'route',
70
+ 'schedule', 'job', 'worker', 'locale', 'adapter', 'migration', 'adr',
71
+ ])
72
+
61
73
  /**
62
74
  * @typedef {Object} LoadedPluginMeta
63
75
  * @property {string} name
@@ -230,7 +242,8 @@ export class MegaPluginHost {
230
242
  }
231
243
 
232
244
  /**
233
- * 스캐폴드 generator 등록. 같은 이름 재등록은 fail-fast.
245
+ * 스캐폴드 generator manifest 등록(ADR-199). 같은 이름 재등록·빌트인 13종 점유·파일 항목 모양 위반은
246
+ * **install 시점 fail-fast** — 첫 `mega g` 사용 때까지 잘못된 manifest 가 잠복하지 않게 한다.
234
247
  * @param {string} name
235
248
  * @param {MegaScaffoldDef} def
236
249
  * @returns {void}
@@ -239,15 +252,33 @@ export class MegaPluginHost {
239
252
  if (typeof name !== 'string' || name.length === 0) {
240
253
  throw new TypeError('mega.scaffold.register: name must be a non-empty string.')
241
254
  }
242
- if (!def || typeof def.dir !== 'string' || !Array.isArray(def.files)) {
255
+ if (RESERVED_GENERATOR_NAMES.has(name)) {
256
+ // 빌트인 kind 는 `mega g` 디스패치에서 항상 우선이라, 등록을 허용하면 도달 불가(silent shadow)다.
257
+ throw new MegaConfigError('plugin.builtin_generator_conflict', `Plugin cannot register builtin generator "${name}" (reserved).`, {
258
+ details: { name },
259
+ })
260
+ }
261
+ if (!def || typeof def.dir !== 'string' || def.dir.length === 0 || !Array.isArray(def.files)) {
243
262
  throw new TypeError(`mega.scaffold.register('${name}'): def must be { dir: string, files: array }.`)
244
263
  }
264
+ for (const f of def.files) {
265
+ if (!f || typeof f.path !== 'string' || f.path.length === 0 || typeof f.template !== 'string') {
266
+ throw new TypeError(`mega.scaffold.register('${name}'): files entries must be { path: string, template: string }. Got ${JSON.stringify(f)}.`)
267
+ }
268
+ }
269
+ if (def.description !== undefined && typeof def.description !== 'string') {
270
+ throw new TypeError(`mega.scaffold.register('${name}'): description must be a string when given.`)
271
+ }
245
272
  if (this.#generators.has(name)) {
246
273
  throw new MegaConfigError('plugin.duplicate_generator', `Scaffold generator '${name}' is already registered.`, {
247
274
  details: { name },
248
275
  })
249
276
  }
250
- this.#generators.set(name, { dir: def.dir, files: [...def.files] })
277
+ this.#generators.set(name, {
278
+ dir: def.dir,
279
+ files: def.files.map((f) => ({ path: f.path, template: f.template })),
280
+ ...(typeof def.description === 'string' ? { description: def.description } : {}),
281
+ })
251
282
  }
252
283
 
253
284
  /**
@@ -15,9 +15,10 @@
15
15
  * 먼저 잡은 1대만 실행하고, 못 잡은 나머지는 **조용히 건너뛴다**(skip). 락은 `ttl`(ms) 뒤 자동 만료돼
16
16
  * 다음 주기엔 다시 경쟁한다.
17
17
  *
18
- * 구현: `lock.acquire(key, { ttl, retryCount: 0 })` — **단 한 번** 시도하고 실패하면(이미 누가 잡음)
19
- * 즉시 skip 한다(retry 안 함 — retry+throw 는 "중복방지"가 아니라 "대기"라서 의미가 다름). 성공하면
20
- * `run(ctx)` 실행 **반드시 release**(finally).
18
+ * 구현: `lock.withLock(key, { ttl, retryCount: 0 }, run)` — **단 한 번** 시도하고 실패하면(이미 누가
19
+ * 잡음) 즉시 skip 한다(retry 안 함 — retry+throw 는 "중복방지"가 아니라 "대기"라서 의미가 다름).
20
+ * 성공하면 임계구역 동안 락이 **자동 연장**되고(redlock `using`), 종료 자동 release 된다 —
21
+ * run 이 ttl 을 넘겨도 락이 만료돼 다른 인스턴스가 끼어드는 중복 실행이 없다.
21
22
  *
22
23
  * # ⚠️ acquire 실패 = skip 의 한계 (ADR-118 기록)
23
24
  * `acquire` 는 (a) 경합(다른 인스턴스가 보유)과 (b) 락 backend 장애(Redis 다운)를 **둘 다** throw 로
@@ -46,8 +47,10 @@ import { MegaCron } from './mega-cron.js'
46
47
  * @typedef {Object} MegaScheduleLock
47
48
  * @property {string} lock - lock 어댑터 별명(`ctx.lock(alias)` 로 해석). 03-api-spec 의 옛 `cache` 필드를
48
49
  * 대체한다(lock 이 독립 도메인이 된 뒤 정합 — ADR-118).
49
- * @property {number} ttl - 락 보유 시간(**밀리초**, 양의 정수). **작업 예상 소요보다 넉넉히** 잡아야
50
- * 한다 작업이 ttl 을 넘기면 락이 자동 만료돼 다른 인스턴스가 중복 실행할 있다(자동 연장 미사용).
50
+ * @property {number} ttl - 락 보유 시간(**밀리초**, 양의 정수). 임계구역 동안 **자동 연장**되므로
51
+ * (redlock `using`) 작업이 ttl 을 넘겨도 중복 실행되지 않는다 ttl "연장 1회분 윈도우"다.
52
+ * ⚠️ redlock 은 `ttl ≥ automaticExtensionThreshold(기본 500ms) + 100ms` 를 요구한다(기본 설정 기준
53
+ * **600ms 이상**) — 미달이면 fire 시 skip(error 동봉)으로 표면화된다.
51
54
  * @property {string} [key] - 락 자원 키. 미지정 시 `mega:schedule:<클래스명>`.
52
55
  */
53
56
 
@@ -463,28 +466,43 @@ export class MegaScheduler extends EventEmitter {
463
466
  }
464
467
  const key = lock.key ?? `mega:schedule:${name}`
465
468
 
466
- let held
469
+ // withLock(= redlock `using`, ADR-113) — 단 한 번 시도(retryCount: 0) + **자동 연장** + 자동 release.
470
+ // run 이 ttl 을 넘겨도 redlock 이 임계구역 동안 락을 연장해 다른 인스턴스의 중복 실행을 막는다
471
+ // (기존 acquire/release 는 "ttl 을 넉넉히" 라는 운영자 추측에 의존 — ADR-118 재평가 조건 충족으로 전환).
472
+ // 경합/backend 장애는 routine 진입 **전에** throw 되므로 acquired 플래그로 skip(미획득)과
473
+ // release 실패(획득 후)를 구분한다. run(ctx) 계약은 무변경 — 연장 실패(signal.aborted)는
474
+ // 스케줄러가 관찰해 fail(lock-extend) 로 표면화한다.
475
+ let acquired = false
476
+ /** @type {MegaScheduleFireResult|undefined} */
477
+ let outcome
467
478
  try {
468
- // 시도(retryCount: 0). 실패=이미 누가 보유(또는 backend 장애) skip.
469
- held = await adapter.acquire(key, { ttl: lock.ttl, retryCount: 0 })
479
+ await adapter.withLock(key, { ttl: lock.ttl, retryCount: 0 }, async (/** @type {any} */ signal) => {
480
+ acquired = true
481
+ outcome = await this.#runBody(name, instance, ctx, key)
482
+ // 자동 연장 실패 = 임계구역 도중 배타성 상실(드묾 — lock backend 장애). run 결과와 별개로 표면화.
483
+ if (signal?.aborted) {
484
+ const cause = signal.error instanceof Error ? signal.error : undefined
485
+ const error = new Error(
486
+ `lock auto-extension failed for '${key}' — exclusivity may have been lost during run`,
487
+ cause ? { cause } : undefined,
488
+ )
489
+ this.emit('fail', { name, key, error, phase: 'lock-extend' })
490
+ }
491
+ })
470
492
  } catch (e) {
471
493
  const error = e instanceof Error ? e : new Error(String(e))
472
- // 에러를 실어 관측 가능하게 — 인프라 장애도 여기로 오므로 소비자가 구분/경보(상단 docstring).
473
- this.emit('skip', { name, key, reason: 'lock-not-acquired', error })
474
- return { name, ran: false, skipped: true, ok: false, error }
475
- }
476
-
477
- try {
478
- return await this.#runBody(name, instance, ctx, key)
479
- } finally {
480
- try {
481
- await adapter.release(held)
482
- } catch (e) {
483
- // 락 해제 실패는 비치명적(ttl 로 자동 만료) — 다음 주기 재경쟁. 묵히지 않고 fail 로 표면화.
484
- const error = e instanceof Error ? e : new Error(String(e))
485
- this.emit('fail', { name, key, error, phase: 'release' })
494
+ if (!acquired) {
495
+ // 획득 실패 = 경합(다른 인스턴스 보유) 또는 backend 장애 구분 불가(상단 docstring).
496
+ // 에러를 실어 관측 가능하게 소비자가 로그/경보로 인프라 장애를 알아챈다.
497
+ this.emit('skip', { name, key, reason: 'lock-not-acquired', error })
498
+ return { name, ran: false, skipped: true, ok: false, error }
486
499
  }
500
+ // routine 진입 후 throw = release 경로 실패(#runBody 는 throw 안 함) — 비치명적(ttl 만료로
501
+ // 자동 해제, 다음 주기 재경쟁). run 결과는 보존해 반환한다.
502
+ this.emit('fail', { name, key, error, phase: 'release' })
503
+ return outcome ?? { name, ran: true, skipped: false, ok: false, error }
487
504
  }
505
+ return /** @type {MegaScheduleFireResult} */ (outcome)
488
506
  }
489
507
 
490
508
  /**