mega-framework 0.1.3 → 0.1.4

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mega-framework",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Node.js 마이크로프레임웍 + CLI — Slim의 가벼움 + Rails의 관례",
5
5
  "type": "module",
6
6
  "engines": {
@@ -1234,7 +1234,7 @@ marked@^18.0.5:
1234
1234
  integrity sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==
1235
1235
 
1236
1236
  "mega-framework@file:../..":
1237
- version "0.1.2"
1237
+ version "0.1.3"
1238
1238
  dependencies:
1239
1239
  "@fastify/cookie" "^11.0.2"
1240
1240
  "@fastify/cors" "^11.2.0"
@@ -6,6 +6,11 @@
6
6
  * 같은 `get/set/del/has` API 를 로컬 파일로 만족시킨다 — driver 키 한 줄만 바꿔 환경 전환(ADR-082).
7
7
  * **신규 의존성 0** (`node:fs/promises`/`node:path`/`node:crypto` 표준만).
8
8
  *
9
+ * ⚠️ **만료 파일 능동 정리 없음(디스크 증가 주의)**: 만료 envelope 은 **재접근(get/has) 시에만 lazy 삭제**된다.
10
+ * set 후 다시 조회되지 않는 만료 키는 파일로 영구 잔존하므로, **짧은 TTL + 높은 키 카디널리티**(요청별 캐시 키
11
+ * 등) 워크로드에선 디스크/inode 가 누적될 수 있다. 그래서 file 캐시는 **dev / 단일·단명 인스턴스 권장**이며,
12
+ * 프로덕션 장기 구동에는 redis 캐시 또는 외부 정리(cron unlink)를 전제로 한다(능동 sweep 은 후속 과제).
13
+ *
9
14
  * # 표준 표면 (MegaCacheAdapter 상속)
10
15
  * - `_connect()` — `fs.mkdir(basePath, { recursive: true })` 로 디렉토리 보장.
11
16
  * - `_disconnect()`— no-op (파일시스템은 close 개념 없음).
@@ -3,8 +3,12 @@
3
3
  * MegaCacheAdapter — key-value 캐시 표준 인터페이스 (추상, 08-class-specs §3.4).
4
4
  *
5
5
  * `ctx.cache(key)` 가 본 베이스 인스턴스를 반환. 구체: `MegaRedisAdapter` /
6
- * `MegaFileAdapter` (ADR-082). 사용자 `key` 에 자동 prefix
7
- * `mega:cache:<appName>:<key>` 가 코어에서 부착됨 (ADR-064).
6
+ * `MegaFileAdapter` (ADR-082).
7
+ *
8
+ * ⚠️ **키 네임스페이스(ADR-064)**: ADR-064 가 `mega:cache:<appName>:<key>` 자동 prefix 를 *결정*했으나
9
+ * **현재 코어에 미구현**이다(get/set/del 이 raw `key` 를 그대로 사용). 멀티앱이 같은 redis 를 공유하면
10
+ * 키 충돌 위험이 있으니, **현재는 사용자가 키에 앱별 네임스페이스를 직접 붙여야 한다**. 자동 prefix 구현은
11
+ * 후속 과제(ADR-064 open). 단일앱(현 sample)에선 무관.
8
12
  *
9
13
  * @module adapters/mega-cache-adapter
10
14
  */
@@ -295,11 +295,16 @@ export class MegaNatsAdapter extends MegaBusAdapter {
295
295
  * 잡 처리 등록 — **queue group** 구독(같은 queue 구독자끼리 load-balance). 기본 queue 이름은 jobName
296
296
  * (같은 잡의 모든 워커가 한 그룹).
297
297
  *
298
+ * ⚠️ **전달 보장 = at-most-once(비영속, H5)**: 이건 **core NATS** queue group 이라 메시지가 디스크에
299
+ * 남지 않는다 — 구독자가 없거나 처리 중 죽으면 그 메시지는 **유실**된다(재시도·DLQ 없음). 유실이
300
+ * 치명적이거나 영속 큐/재시도/DLQ 가 필요하면 **`MegaJobQueue`(JetStream, at-least-once, ADR-028/112)**
301
+ * 를 쓴다. `ctx.bus(x).process` 와 `mega worker`(JetStream) 는 **전달 보장이 다르므로 혼용 금지**.
302
+ *
298
303
  * **subscription 핸들을 반환하지 않는 건 의도적**(L-2): worker 는 앱 수명 동안 상주하는 것이
299
304
  * 정상이고(개별 잡 처리 등록을 런타임에 떼었다 붙였다 하는 패턴은 비표준), 정리는 disconnect 시
300
305
  * `_disconnect()` 의 `nc.drain()` 이 **모든 구독을 일괄 비우며** 처리한다. 개별 unsubscribe 가
301
306
  * 필요한 일시 구독은 `subscribe()`(핸들 반환)를 쓴다 — `process` 는 fire-and-forget `enqueue` 와
302
- * 대칭인 상주 worker 용이다. 영속 큐/재시도/DLQ 가 필요하면 `MegaJob`(jetstream, ADR-028).
307
+ * 대칭인 상주 worker 용이다.
303
308
  *
304
309
  * @param {string} jobName
305
310
  * @param {(payload: any) => any} handler
@@ -30,7 +30,7 @@ function defaultReplFactory() {
30
30
  * @param {(msg: string) => void} [deps.out]
31
31
  * @param {() => (Promise<void> | void)} [deps.shutdown] - 주입용(테스트). REPL 종료 시 호출하는 graceful
32
32
  * shutdown 트리거. 기본 {@link MegaShutdown.now}(등록 hook 실행 후 process.exit).
33
- * @param {(opts: { signals?: string[] }) => void} [deps.setupSignals] - 주입용(테스트). 시그널 핸들러 등록.
33
+ * @param {(opts: { signals?: string[], globalErrorHandlers?: boolean }) => void} [deps.setupSignals] - 주입용(테스트). 시그널 핸들러 등록.
34
34
  * 기본 {@link MegaShutdown.setupSignals}.
35
35
  * @returns {Promise<{ ctx: Record<string, any>, config: object, server: { context: Record<string, any> } }>}
36
36
  */
@@ -44,7 +44,9 @@ export async function startConsole(
44
44
  // 두면 REPL 종료 후에도 열린 핸들(DB 풀·redis·wsHub listen)로 프로세스가 행하고 어댑터가 미정리된다.
45
45
  // 따라서 SIGTERM 은 graceful shutdown 으로 받는다. SIGINT 은 REPL 이 소유(빈 줄 클리어 / 이중 입력 시
46
46
  // 'exit')하므로 가로채지 않는다 — 가로채면 한 번의 Ctrl-C 가 콘솔을 죽인다(ADR-167).
47
- setupSignals({ signals: ['SIGTERM'] })
47
+ // REPL 은 전역 에러 핸들러를 끈다(globalErrorHandlers:false) 대화형에서 사용자 코드 예외가
48
+ // 프로세스를 graceful shutdown 시키면 안 되고, REPL 자체 에러 복구를 방해하지 않기 위함(ADR-178).
49
+ setupSignals({ signals: ['SIGTERM'], globalErrorHandlers: false })
48
50
  const server = replFactory()
49
51
  Object.assign(server.context, { ctx, config: global, mega: { config: global, host } })
50
52
  // REPL 종료(.exit / Ctrl-D / 이중 Ctrl-C)는 'exit' 이벤트로 온다 — 이때 graceful shutdown 으로 어댑터 등을
@@ -968,9 +968,9 @@ export class MegaApp {
968
968
  }
969
969
  // redis(cluster-wide) — 다른 워커/허브의 세션까지.
970
970
  if (this._wsRoster) {
971
- for (const ch of want) {
972
- for (const m of await this._wsRoster.list(ch)) if (!out.has(m.sessionId)) out.set(m.sessionId, m)
973
- }
971
+ // 여러 채널 redis 조회를 **병렬화**(Promise.all) onConnect 핫패스의 직렬 라운드트립 누적 제거(Med).
972
+ const lists = await Promise.all([...want].map((ch) => this._wsRoster.list(ch)))
973
+ for (const list of lists) for (const m of list) if (!out.has(m.sessionId)) out.set(m.sessionId, m)
974
974
  }
975
975
  return [...out.values()]
976
976
  }
@@ -1259,6 +1259,14 @@ export class MegaApp {
1259
1259
  this._hubBridgeId = null
1260
1260
  MegaShutdown.unregister(`mega-hublink:${this.name}`)
1261
1261
  }
1262
+ // NATS 클러스터 정리(ADR-176) — 구독·heartbeat/sweep 타이머·roster 멤버 해제. boot 의 shutdown hook 과
1263
+ // **양쪽**에서 정리해야 시그널을 안 거치는 종료(server.close()/프로그램적 재시작/멀티앱 부분 종료)에서도
1264
+ // 누수가 없다(_hubLink/_wsRoster 와 동일 대칭, H1). stop() 은 _isStarted 가드로 멱등이라 중복 안전.
1265
+ if (this._wsCluster) {
1266
+ await this._wsCluster.stop().catch((err) => this.fastify.log?.warn?.({ err, app: this.name }, 'ws-cluster stop failed'))
1267
+ this._wsCluster = null
1268
+ MegaShutdown.unregister(`mega-ws-cluster:${this.name}`)
1269
+ }
1262
1270
  // redis roster 정리(ADR-177) — heartbeat 중지 + 로컬 멤버 즉시 제거. _sessionConns 를 비우기 **전에**
1263
1271
  // 호출해야 stop() 이 localRosterMembers 로 자기 멤버를 redis 에서 뺄 수 있다.
1264
1272
  if (this._wsRoster) {
@@ -50,10 +50,16 @@ export class MegaCluster {
50
50
  /** @type {(() => Promise<void>) | null} */
51
51
  this._workerFn = null
52
52
 
53
- // M1 — respawn backoff (crash-loop 보호)
54
- this._respawnTooFast = 0 // 빠른 연속 crash 카운터
55
- this._maxRapidRespawn = 5 // 5번 연속 너무 빨리 crash 하면 중단
56
- this._minRespawnIntervalMs = 1000 // 1초 이내 crash 는 "너무 빠름"
53
+ // M1 — respawn backoff (crash-loop 보호). 멀티워커 정확성: 전역 카운터 + 정상종료-리셋은 한 워커의
54
+ // 정상 종료가 다른 워커들의 누적 빠른-crash 카운트를 통째로 지워 crash-loop 를 마스킹한다. 그래서
55
+ // **빠른 crash 타임스탬프의 슬라이딩 윈도우**로 판정한다(윈도우 N회 → 포기). 정상 종료는 카운트에
56
+ // 기여하지 않고, 오래된 빠른-crash 는 윈도우에서 자연 소거된다(안정화되면 자동 리셋).
57
+ this._maxRapidRespawn = 5 // 윈도우 내 5번 빠른 crash 면 중단
58
+ this._minRespawnIntervalMs = 1000 // 이 lifetime 미만 crash 는 "너무 빠름"
59
+ /** @type {number[]} 윈도우 내 빠른-crash 타임스탬프(ms). */
60
+ this._rapidCrashTimes = []
61
+ /** @type {ReturnType<typeof setTimeout>|null} SIGTERM grace 강제-kill 타이머(전원 정상종료 시 취소). */
62
+ this._graceTimer = null
57
63
  }
58
64
 
59
65
  /**
@@ -103,13 +109,15 @@ export class MegaCluster {
103
109
  this._shuttingDown = true
104
110
  console.log(`[mega-cluster] received ${sig}, broadcasting shutdown to ${this._workers.size} workers (grace ${this._gracePeriodMs}ms)`)
105
111
  this._broadcastShutdown()
106
- // grace 초과 시 강제 kill
107
- setTimeout(() => {
112
+ // grace 초과 시 강제 kill. 핸들을 보관해 전원 정상종료(exit 핸들러) 시 취소 — 정상 종료가 이 타이머의
113
+ // exit(1) 경합해 exit code 가 흔들리지 않게 한다(k8s/systemd 종료 판정 노이즈 제거).
114
+ this._graceTimer = setTimeout(() => {
108
115
  for (const w of this._workers) {
109
116
  try { w.kill('SIGKILL') } catch (err) { console.warn('[mega-cluster] SIGKILL failed:', err.message) }
110
117
  }
111
118
  this._proc.exit(1)
112
- }, this._gracePeriodMs).unref()
119
+ }, this._gracePeriodMs)
120
+ this._graceTimer.unref()
113
121
  }
114
122
  this._proc.on('SIGTERM', () => onSignal('SIGTERM'))
115
123
  this._proc.on('SIGINT', () => onSignal('SIGINT'))
@@ -124,33 +132,35 @@ export class MegaCluster {
124
132
  this._workers.delete(worker)
125
133
  if (this._shuttingDown) {
126
134
  if (this._workers.size === 0) {
135
+ if (this._graceTimer) clearTimeout(this._graceTimer) // 전원 정상종료 — 강제-kill 타이머 취소(exit code 경합 제거).
136
+ this._graceTimer = null
127
137
  console.log('[mega-cluster] all workers exited, primary exiting 0')
128
138
  this._proc.exit(0)
129
139
  }
130
140
  return
131
141
  }
132
142
  if (this._respawn) {
133
- // M1 — respawn backoff: 너무 빨리 연속 crash 하면 crash-loop 으로 보고 respawn 중단.
143
+ // M1 — respawn backoff: 빠른 crash 슬라이딩 윈도우로 crash-loop 판정(멀티워커 정확).
134
144
  const now = Date.now()
135
145
  const lastBirth = worker._megaBirthAt ?? now
136
146
  const lifetimeMs = now - lastBirth
147
+ // 윈도우 = maxRapidRespawn 회의 빠른 재시작이 걸릴 시간 + 여유. 이보다 오래된 빠른-crash 는 소거.
148
+ const windowMs = this._minRespawnIntervalMs * this._maxRapidRespawn * 2
149
+ this._rapidCrashTimes = this._rapidCrashTimes.filter((t) => now - t < windowMs)
137
150
  if (lifetimeMs < this._minRespawnIntervalMs) {
138
- this._respawnTooFast += 1
139
- if (this._respawnTooFast >= this._maxRapidRespawn) {
140
- // H-1 — crash-loop 포기 시 그냥 return 하면 이벤트루프가 비어 exit 0(성공)으로
141
- // 보인다 → systemd/k8s 가 "정상 종료" 로 판단해 재시작 함.
142
- // 명시적 exit 1 로 "비정상 종료" 를 외부 supervisor 에 알려 재시작을 유도한다.
151
+ this._rapidCrashTimes.push(now) // 빠른 crash 만 윈도우에 기록(정상 lifetime 은 기여 X).
152
+ if (this._rapidCrashTimes.length >= this._maxRapidRespawn) {
153
+ // H-1 — crash-loop 포기 시 그냥 return 하면 이벤트루프가 비어 exit 0(성공)으로 보인다 →
154
+ // systemd/k8s 가 "정상 종료" 로 오판. 명시적 exit 1 로 비정상 종료를 알려 재시작을 유도한다.
143
155
  console.error(
144
- `[mega-cluster] rapid crash-loop detected (${this._respawnTooFast} restarts within ${this._minRespawnIntervalMs}ms), giving up — exiting 1. Last exit: code=${code}, signal=${signal}.`,
156
+ `[mega-cluster] rapid crash-loop detected (${this._rapidCrashTimes.length} rapid crashes within ${windowMs}ms), giving up — exiting 1. Last exit: code=${code}, signal=${signal}.`,
145
157
  )
146
158
  this._proc.exit(1)
147
159
  return // 실 process.exit 은 즉시 종료하나, 주입된 fake 는 계속 실행되므로 명시적 중단
148
160
  }
149
- } else {
150
- this._respawnTooFast = 0 // 정상 lifetime 이면 카운터 reset
151
161
  }
152
162
  console.warn(
153
- `[mega-cluster] worker ${worker.process.pid} died (code=${code}, signal=${signal}, lifetime=${lifetimeMs}ms), respawning (rapid-crash-count=${this._respawnTooFast}/${this._maxRapidRespawn})`,
163
+ `[mega-cluster] worker ${worker.process.pid} died (code=${code}, signal=${signal}, lifetime=${lifetimeMs}ms), respawning (rapid-crashes=${this._rapidCrashTimes.length}/${this._maxRapidRespawn} in ${windowMs}ms)`,
154
164
  )
155
165
  this._forkWorker()
156
166
  } else {
@@ -46,6 +46,12 @@ export const CLOSE_CODE_DECRYPT_FAILED = 4500
46
46
  */
47
47
  export const CLOSE_CODE_INTERNAL_ERROR = 1011
48
48
 
49
+ /** 느린 소비자 백프레셔 close code (RFC 6455 §7.4.1 표준 1013 "Try Again Later", Med). */
50
+ export const CLOSE_CODE_SLOW_CONSUMER = 1013
51
+
52
+ /** send 버퍼(bufferedAmount) 기본 상한(바이트). 초과 = 소비자가 ack 를 못 따라옴 → 연결 종료(서버 OOM 방지). */
53
+ export const DEFAULT_MAX_BUFFERED_BYTES = 16 * 1024 * 1024
54
+
49
55
  /**
50
56
  * WS 프레임 코덱 — 평문/암호 와이어 변환을 추상화한다.
51
57
  * @typedef {Object} WsFrameCodec
@@ -93,11 +99,13 @@ export class MegaWsConnection {
93
99
  /**
94
100
  * @param {import('ws').WebSocket} rawSocket
95
101
  * @param {WsFrameCodec} codec
96
- * @param {{ id?: string, ns?: string, path: string }} meta
102
+ * @param {{ id?: string, ns?: string, path: string, maxBufferedBytes?: number }} meta
97
103
  */
98
104
  constructor(rawSocket, codec, meta) {
99
105
  /** @type {import('ws').WebSocket} */
100
106
  this._raw = rawSocket
107
+ /** @type {number} send 버퍼 상한(바이트) — 초과 시 느린 소비자로 보고 연결 종료(백프레셔). */
108
+ this._maxBufferedBytes = Number.isFinite(meta.maxBufferedBytes) ? /** @type {number} */ (meta.maxBufferedBytes) : DEFAULT_MAX_BUFFERED_BYTES
101
109
  /** @type {WsFrameCodec} */
102
110
  this._codec = codec
103
111
  /** @type {string} 연결 식별자 (ULID). */
@@ -136,6 +144,16 @@ export class MegaWsConnection {
136
144
  * @returns {void}
137
145
  */
138
146
  send(fields) {
147
+ // 백프레셔 가드(Med): 송신 버퍼가 상한을 넘으면 소비자가 ack 를 못 따라오는 것 → 더 쌓지 않고 연결을
148
+ // 종료한다(느린 소비자 몇 개가 fan-out 으로 서버 힙을 무한 적재→OOM 시키는 것 방지). close code 1013.
149
+ if (this._raw.bufferedAmount > this._maxBufferedBytes) {
150
+ try {
151
+ this._raw.close(CLOSE_CODE_SLOW_CONSUMER, 'backpressure: send buffer exceeded')
152
+ } catch {
153
+ // 이미 닫히는 중이면 close 는 무의미 — 무시(비치명적, 다음 send 는 isOpen 가드로 걸러짐).
154
+ }
155
+ return
156
+ }
139
157
  // ns 자동 주입 (L1): 명시 안 됐고 연결 ns 가 있으면 채운다. 명시값은 덮어쓰지 않음.
140
158
  const withNs = fields.ns === undefined && this.ns !== undefined ? { ...fields, ns: this.ns } : fields
141
159
  const env = createWsMessage(withNs)
@@ -22,6 +22,9 @@ const NONCE_KEY_PREFIX = 'asp:nonce:'
22
22
  /** 기본 TTL — drift 윈도우(±60s) 의 2배 (ADR-058: EX 120). */
23
23
  const DEFAULT_TTL_SEC = 120
24
24
 
25
+ /** in-memory nonce store 의 lazy sweep 주기 — insert 이 이 횟수만큼 누적되면 만료 일괄 제거(Med). */
26
+ const SWEEP_EVERY_OPS = 500
27
+
25
28
  /**
26
29
  * MegaAspNonceCache — nonce SETNX 래퍼.
27
30
  */
@@ -62,6 +65,8 @@ export class MegaMemoryNonceStore {
62
65
  constructor() {
63
66
  /** @type {Map<string, number>} key → 만료 epoch ms */
64
67
  this._store = new Map()
68
+ /** @type {number} 마지막 sweep 이후 insert 횟수 — SWEEP_EVERY_OPS 마다 만료 일괄 제거. */
69
+ this._opsSinceSweep = 0
65
70
  }
66
71
 
67
72
  /**
@@ -71,6 +76,13 @@ export class MegaMemoryNonceStore {
71
76
  */
72
77
  async setIfNotExists(key, ttlSec) {
73
78
  const now = Date.now()
79
+ // lazy 주기 sweep(Med) — setIfNotExists 가 키 단건만 만료 갱신하므로, 다시 안 들어오는 nonce 는 영구
80
+ // 잔존(메모리 릭)한다. 타이머 없이 insert 누적이 임계를 넘을 때마다 만료를 일괄 제거해 Map 을 bound 한다
81
+ // (타이머 lifecycle/dispose 불필요 → 안전). 활성(미만료) nonce 는 TTL 로 자연 bound.
82
+ if (++this._opsSinceSweep >= SWEEP_EVERY_OPS) {
83
+ this.evictExpired(now)
84
+ this._opsSinceSweep = 0
85
+ }
74
86
  const exp = this._store.get(key)
75
87
  if (exp !== undefined && exp > now) return false
76
88
  this._store.set(key, now + ttlSec * 1000)
@@ -76,16 +76,31 @@ const LEVEL_NAMES = /** @type {Record<number, string>} */ ({
76
76
  * rename·rm 된 inode 로 가서 항목이 **유실**된다(rename-claim 의 "유실 0" 이 깨지는 좁은 창). 그래서 큐
77
77
  * 연산을 promise 체인 mutex 로 **직렬화**해 인터리브를 원천 차단한다. 큐 연산은 드물어(전송 실패 시
78
78
  * append + 주기 drain) 직렬화 비용은 무시 가능. rename-claim 은 cross-process 안전용으로 그대로 둔다.
79
+ *
80
+ * # 상한(cap) — 무한 증가 방지 (H4, 최적화 리포트)
81
+ * 텔레그램이 장기 장애(토큰 만료·차단·네트워크 단절)면 실패분이 무한 적재되고, drain 이 파일 전체를
82
+ * 메모리로 읽어 재시도→또 실패→전량 재append 한다. 그래서 `drain` 이 **maxAge 만료 + maxEntries 상한**을
83
+ * 적용해(오래된 것부터 드롭, 드롭 수는 `onDrop` 으로 관측) 디스크·드레인 메모리를 함께 bound 한다.
84
+ * write 경로 append rate 는 transport throttle 로 이미 제한되므로 drain 시점 cap 으로 충분하다.
79
85
  */
80
86
  export class RetryQueue {
81
87
  /**
82
88
  * @param {string} filePath - JSONL 큐 파일 경로(없으면 비어 있음).
89
+ * @param {{ maxEntries?: number, maxAgeMs?: number, onDrop?: (count: number) => void }} [opts]
90
+ * maxEntries: 보관 최대 항목 수(기본 5000, 0=무제한). maxAgeMs: 항목 최대 보존 ms(기본 24h, 0=무제한).
91
+ * onDrop: cap 으로 드롭된 수 통지(관측용).
83
92
  */
84
- constructor(filePath) {
93
+ constructor(filePath, { maxEntries = 5000, maxAgeMs = 86_400_000, onDrop } = {}) {
85
94
  /** @type {string} */
86
95
  this._file = filePath
87
96
  /** @type {Promise<unknown>} 직렬화 락의 꼬리 — append/drain/clear 를 이 체인에 줄세운다. */
88
97
  this._tail = Promise.resolve()
98
+ /** @type {number} 보관 최대 항목 수(0=무제한). */
99
+ this._maxEntries = Number.isInteger(maxEntries) && maxEntries > 0 ? maxEntries : 0
100
+ /** @type {number} 항목 최대 보존 ms(0=무제한). */
101
+ this._maxAgeMs = Number.isInteger(maxAgeMs) && maxAgeMs > 0 ? maxAgeMs : 0
102
+ /** @type {((count: number) => void) | null} cap 드롭 수 통지. */
103
+ this._onDrop = typeof onDrop === 'function' ? onDrop : null
89
104
  }
90
105
 
91
106
  /**
@@ -134,19 +149,32 @@ export class RetryQueue {
134
149
  }
135
150
  const raw = await readFile(claim, 'utf8')
136
151
  await rm(claim, { force: true })
137
- /** @type {string[]} */
138
- const texts = []
152
+ const now = Date.now()
153
+ /** @type {Array<{ text: string, ts: number }>} */
154
+ let entries = []
139
155
  for (const line of raw.split('\n')) {
140
156
  if (!line.trim()) continue
141
157
  try {
142
158
  const obj = JSON.parse(line)
143
- if (typeof obj.text === 'string') texts.push(obj.text)
159
+ if (typeof obj.text === 'string') entries.push({ text: obj.text, ts: typeof obj.ts === 'number' ? obj.ts : now })
144
160
  } catch {
145
161
  // 손상된 라인은 건너뛴다 — 큐 전체를 막지 않기 위함(비치명적, 다음 항목 계속).
146
162
  continue
147
163
  }
148
164
  }
149
- return texts
165
+ // cap(H4) — maxAge 만료 + maxEntries 상한. 오래된 것부터 드롭(최신 보존). 드롭 수는 onDrop 으로 관측.
166
+ let dropped = 0
167
+ if (this._maxAgeMs > 0) {
168
+ const before = entries.length
169
+ entries = entries.filter((e) => now - e.ts <= this._maxAgeMs)
170
+ dropped += before - entries.length
171
+ }
172
+ if (this._maxEntries > 0 && entries.length > this._maxEntries) {
173
+ dropped += entries.length - this._maxEntries
174
+ entries = entries.slice(entries.length - this._maxEntries) // 최신 maxEntries 만 유지.
175
+ }
176
+ if (dropped > 0 && this._onDrop) this._onDrop(dropped)
177
+ return entries.map((e) => e.text)
150
178
  })
151
179
  }
152
180
 
@@ -61,6 +61,8 @@ export function httpsPost(url, body, { request = httpsRequestRaw, timeoutMs = 10
61
61
  * @param {string} [opts.retryDir] - retry queue 디렉터리(디폴트 './logs/telegram-retry').
62
62
  * @param {string} [opts.serviceName]
63
63
  * @param {number} [opts.retryDrainMs] - retry 드레인 주기(디폴트 30_000).
64
+ * @param {number} [opts.retryMaxEntries] - retry 큐 보관 최대 항목 수(디폴트 5000, 0=무제한, H4 cap).
65
+ * @param {number} [opts.retryMaxAgeMs] - retry 항목 최대 보존 ms(디폴트 24h, 0=무제한, H4 cap).
64
66
  * @param {(url: string, body: string) => Promise<{ statusCode: number }>} [opts.httpsRequest] - HTTP 주입(기본=httpsPost). 단위 테스트용 seam(ADR-165 동일 패턴) — pino worker 는 미전달.
65
67
  * @returns {import('node:stream').Writable}
66
68
  */
@@ -73,10 +75,20 @@ export default function telegramTransport(opts) {
73
75
  retryDir = './logs/telegram-retry',
74
76
  serviceName = 'mega',
75
77
  retryDrainMs = 30_000,
78
+ retryMaxEntries = 5000,
79
+ retryMaxAgeMs = 86_400_000,
76
80
  httpsRequest = httpsPost,
77
81
  } = opts
78
82
  const throttle = createThrottle(throttleMax, throttleWindowMs)
79
- const queue = new RetryQueue(join(retryDir, 'telegram-retry.jsonl'))
83
+ /** throttle 드롭된 메시지 수(폭주 억제) — 주기적으로 관측 노출(silent 드롭 사각 제거). */
84
+ let droppedByThrottle = 0
85
+ // retry queue cap(H4) — 무한 디스크/메모리 증가 방지. cap 드롭은 stderr 로 관측(텔레그램 sink 라
86
+ // logger 재귀를 피해 process.stderr 직접 사용).
87
+ const queue = new RetryQueue(join(retryDir, 'telegram-retry.jsonl'), {
88
+ maxEntries: retryMaxEntries,
89
+ maxAgeMs: retryMaxAgeMs,
90
+ onDrop: (n) => process.stderr.write(`[telegram-transport] dropped ${n} stale/over-cap retry entries (H4 cap)\n`),
91
+ })
80
92
 
81
93
  /** 한 건 전송 시도 — 실패/throw 시 retry queue 로. */
82
94
  async function trySend(/** @type {string} */ text) {
@@ -92,6 +104,11 @@ export default function telegramTransport(opts) {
92
104
  // 주기 드레인 — 쌓인 실패분을 재전송(여전히 실패하면 다시 큐로). unref 로 프로세스 종료 막지 않음.
93
105
  /** @returns {Promise<void>} 쌓인 실패분 재전송(여전히 실패하면 trySend 가 다시 큐로). */
94
106
  const drainTick = async () => {
107
+ // throttle 로 조용히 드롭된 수를 주기적으로 노출(관측 사각 제거, Med). stderr 직접(logger 재귀 회피).
108
+ if (droppedByThrottle > 0) {
109
+ process.stderr.write(`[telegram-transport] throttled-dropped ${droppedByThrottle} messages in last window\n`)
110
+ droppedByThrottle = 0
111
+ }
95
112
  const pending = await queue.drain().catch(() => /** @type {string[]} */ ([]))
96
113
  for (const text of pending) await trySend(text)
97
114
  }
@@ -113,7 +130,10 @@ export default function telegramTransport(opts) {
113
130
  } catch {
114
131
  continue // 손상 라인 skip — 다음 라인 계속.
115
132
  }
116
- if (!throttle.tryAcquire(Date.now())) continue // 폭주 억제(초과분 드롭).
133
+ if (!throttle.tryAcquire(Date.now())) {
134
+ droppedByThrottle++ // 폭주 억제(초과분 드롭) — 수를 세어 drainTick 에서 관측 노출.
135
+ continue
136
+ }
117
137
  void trySend(formatMessage(record, serviceName))
118
138
  }
119
139
  cb()
@@ -62,6 +62,19 @@ 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
+ /** DLQ 봉투에 싣는 error.stack 최대 길이(자) — poison 잡 폭주 시 DLQ 비대 방지(Med). */
66
+ const DLQ_MAX_STACK_LEN = 4000
67
+
68
+ /**
69
+ * DLQ 봉투용 stack 문자열을 상한으로 자른다. 초과 시 말미에 잘림 표식을 붙인다.
70
+ * @param {string | undefined} stack
71
+ * @returns {string | undefined}
72
+ */
73
+ function truncateStack(stack) {
74
+ if (typeof stack !== 'string' || stack.length <= DLQ_MAX_STACK_LEN) return stack
75
+ return stack.slice(0, DLQ_MAX_STACK_LEN) + `\n… [truncated ${stack.length - DLQ_MAX_STACK_LEN} chars]`
76
+ }
77
+
65
78
  /**
66
79
  * @typedef {Object} MegaJobQueueOptions
67
80
  * @property {import('nats').NatsConnection} nc - **연결된** NatsConnection(`ctx.bus(alias).native`).
@@ -387,6 +400,11 @@ export class MegaJobQueue extends EventEmitter {
387
400
  * (소비 워커 라이프사이클·bus 별명 배선은 `MegaJobWorker` 영역 — 본 메서드는 "처리 베이스".
388
401
  * 정본 `MegaWorker` = CPU `worker_threads` 풀로 별개 추상(ADR-120/ADR-121).)
389
402
  *
403
+ * ⚠️ **head-of-line blocking 운영 주의(Med)**: in-flight 상한은 `concurrency`(= `max_ack_pending`)다.
404
+ * 일시 장애로 **모든 in-flight 가 동시에 긴 백오프**(`static backoff.max` 큼 + `retries` 많음)에 들어가면
405
+ * 그 subject 처리량이 0 에 수렴하고 정상 메시지도 뒤에서 대기한다(메모리는 안전 — 무한 증가 X). 큰
406
+ * backoff 를 쓰면 `concurrency` 를 충분히 키우고, 재시도 적체를 메트릭(`job:start`/`fail` 이벤트)으로 관측하라.
407
+ *
390
408
  * @param {typeof MegaJob} JobClass @param {MegaJob} instance - `run` 호출 대상(서브클래스 인스턴스).
391
409
  * @param {Record<string, any>} ctx - run 에 넘길 컨텍스트.
392
410
  * @returns {Promise<{ stop: () => Promise<void> }>}
@@ -489,9 +507,11 @@ export class MegaJobQueue extends EventEmitter {
489
507
  this.#safeEmit('start', { subject, seq })
490
508
  let settled = false
491
509
  // 재시도 동안 메시지를 점유하므로 ack_wait 만료(→중복 재전달)를 막기 위해 working() 으로 lease 갱신.
510
+ // unref — in-flight 잡이 길게 돌아도 이 타이머가 프로세스 자연 종료를 막지 않게(형제 모듈 정합, Med).
492
511
  const heartbeat = setInterval(() => {
493
512
  if (!settled) msg.working()
494
513
  }, this.#heartbeatMs)
514
+ if (typeof heartbeat.unref === 'function') heartbeat.unref()
495
515
 
496
516
  // run(재시도) 결과를 변수에 담는다 — ack/DLQ(부작용) + emit 은 try/catch **밖**에서 처리한다(M-3·L-3).
497
517
  // 이유: emit('done')/emit('fail') 리스너가 throw 했을 때 run 의 catch 가 잡으면 성공 잡이 spurious
@@ -546,7 +566,8 @@ export class MegaJobQueue extends EventEmitter {
546
566
  originalSubject: subject,
547
567
  failedAt: new Date().toISOString(),
548
568
  deliveryCount: msg.info.deliveryCount,
549
- error: { name: error.name, message: error.message, stack: error.stack },
569
+ // stack 전체를 봉투에 담으면 poison 폭주 시 DLQ 직렬화 비용·디스크가 급증한다 → 상한으로 truncate(Med).
570
+ error: { name: error.name, message: error.message, stack: truncateStack(error.stack) },
550
571
  payload,
551
572
  }),
552
573
  )
@@ -92,11 +92,49 @@ export function buildTargets(sinks, level) {
92
92
  return targets
93
93
  }
94
94
 
95
+ /**
96
+ * 기본 시크릿 redact 경로(ADR-023, H2 보안). 사용자가 `logger.redact` 를 지정하지 않아도 **항상** 적용해
97
+ * 토큰·비밀번호·인증 헤더가 stdout/파일/텔레그램(외부 sink)으로 평문 유출되는 것을 막는다(CLAUDE.md P5).
98
+ * pino(fast-redact) 경로 문법으로 부팅 시 1회 검증됨(leading wildcard `*.x` 포함). 사용자 redact 는 병합된다.
99
+ * @type {ReadonlyArray<string>}
100
+ */
101
+ export const DEFAULT_REDACT_PATHS = Object.freeze([
102
+ 'req.headers.authorization',
103
+ 'req.headers.cookie',
104
+ '*.password',
105
+ '*.token',
106
+ '*.secret',
107
+ '*.accessToken',
108
+ '*.refreshToken',
109
+ '*.apiKey',
110
+ ])
111
+
112
+ /**
113
+ * 사용자 redact 설정(배열 또는 `{ paths, censor?, remove? }`)을 기본 경로와 **병합**한다(중복 제거).
114
+ * 사용자가 미지정이어도 기본 경로는 항상 적용된다.
115
+ * @param {unknown} userRedact
116
+ * @returns {{ paths: string[], censor?: any, remove?: boolean }}
117
+ */
118
+ function mergeRedact(userRedact) {
119
+ /** @type {string[]} */
120
+ let userPaths = []
121
+ /** @type {Record<string, any>} */
122
+ let extra = {}
123
+ if (Array.isArray(userRedact)) {
124
+ userPaths = userRedact.filter((p) => typeof p === 'string')
125
+ } else if (userRedact && typeof userRedact === 'object' && Array.isArray(/** @type {any} */ (userRedact).paths)) {
126
+ const { paths, ...rest } = /** @type {any} */ (userRedact)
127
+ userPaths = paths.filter((/** @type {any} */ p) => typeof p === 'string')
128
+ extra = rest // censor/remove 보존.
129
+ }
130
+ return { paths: [...new Set([...DEFAULT_REDACT_PATHS, ...userPaths])], ...extra }
131
+ }
132
+
95
133
  /**
96
134
  * `logger` config → pino 옵션(또는 비활성 시 `null`). Fastify `logger` 또는 `pino()` 에 그대로 전달 가능.
97
135
  *
98
136
  * @param {unknown} config - `MegaLoggerConfig`(`{ level, sinks, redact?, includeRequestId? }`).
99
- * @returns {{ level: string, mixin: Function, redact?: string[], transport: { targets: any[] } } | null}
137
+ * @returns {{ level: string, mixin: Function, redact: { paths: string[] }, transport: { targets: any[] } } | null}
100
138
  */
101
139
  export function buildLoggerOptions(config) {
102
140
  if (!config || typeof config !== 'object') return null
@@ -109,7 +147,8 @@ export function buildLoggerOptions(config) {
109
147
  level,
110
148
  // trace_id/span_id 자동 주입(ADR-116) — 활성 span 없으면 빈 객체(0 비용).
111
149
  mixin: MegaTracing.logMixin,
112
- ...(Array.isArray(c.redact) && c.redact.length > 0 ? { redact: c.redact } : {}),
150
+ // 기본 시크릿 redact **항상** 적용 + 사용자 설정 병합(H2 보안). 미지정이어도 마스킹된다.
151
+ redact: mergeRedact(c.redact),
113
152
  transport: { targets },
114
153
  }
115
154
  }
@@ -31,6 +31,12 @@ let isShuttingDownFlag = false
31
31
  let gracePeriodMs = 30_000
32
32
  let hardKillMs = 60_000
33
33
  let exitedHandlers = new Set()
34
+ /** 전역 에러 핸들러 등록 여부 + 참조(reset 시 removeListener 용). @type {boolean} */
35
+ let globalErrorsRegistered = false
36
+ /** @type {((reason: unknown) => void) | null} */
37
+ let unhandledRejectionHandler = null
38
+ /** @type {((err: Error, origin?: string) => void) | null} */
39
+ let uncaughtExceptionHandler = null
34
40
 
35
41
  /**
36
42
  * cleanup hook 등록. 순서는 등록 역순(LIFO)으로 실행.
@@ -74,8 +80,9 @@ function isShuttingDown() {
74
80
  }
75
81
 
76
82
  /**
77
- * SIGTERM/SIGINT 시그널 등록 (한 번만 등록됨).
78
- * @param {{ gracePeriodMs?: number, hardKillMs?: number, signals?: string[] }} [opts]
83
+ * SIGTERM/SIGINT 시그널 등록 (한 번만 등록됨). 전역 에러 핸들러(unhandledRejection/uncaughtException)도
84
+ * 기본 등록한다 `globalErrorHandlers: false` 있다(REPL 등).
85
+ * @param {{ gracePeriodMs?: number, hardKillMs?: number, signals?: string[], globalErrorHandlers?: boolean }} [opts]
79
86
  */
80
87
  function setupSignals(opts = {}) {
81
88
  if (signalsRegistered) return // 멱등성
@@ -88,6 +95,36 @@ function setupSignals(opts = {}) {
88
95
  void now({ signal: sig, exitCode: 0 })
89
96
  })
90
97
  }
98
+ // 전역 에러 핸들러도 함께 등록(opt-out: globalErrorHandlers === false). REPL(console-cmd) 등은 끌 수 있다.
99
+ if (opts.globalErrorHandlers !== false) setupGlobalErrorHandlers()
100
+ }
101
+
102
+ /**
103
+ * 전역 `unhandledRejection`/`uncaughtException` 핸들러 등록 (ADR-178, 멱등). floating promise reject 나
104
+ * 미처리 예외로 프로세스가 **graceful 없이 즉사**(어댑터 disconnect·드레인 미실행 → 커넥션/락 누수, 진행 중
105
+ * 잡 유실)하는 것을 막는다. 둘 다 fatal 로깅 후 graceful shutdown(exit 1)을 트리거한다 — uncaughtException
106
+ * 이후의 프로세스 상태는 신뢰 불가라 복구하지 않고 정리 후 종료가 표준.
107
+ * @param {{ exitCode?: number }} [opts]
108
+ * @returns {void}
109
+ */
110
+ function setupGlobalErrorHandlers({ exitCode = 1 } = {}) {
111
+ if (globalErrorsRegistered) return
112
+ globalErrorsRegistered = true
113
+ unhandledRejectionHandler = (reason) => {
114
+ const log = /** @type {any} */ (globalThis).logger
115
+ const err = reason instanceof Error ? reason : new Error(`unhandledRejection: ${String(reason)}`)
116
+ if (log?.fatal) log.fatal({ err }, 'unhandledRejection — initiating graceful shutdown')
117
+ else console.error('[mega-shutdown] unhandledRejection — initiating graceful shutdown:', err)
118
+ void now({ signal: 'unhandledRejection', exitCode })
119
+ }
120
+ uncaughtExceptionHandler = (err, origin) => {
121
+ const log = /** @type {any} */ (globalThis).logger
122
+ if (log?.fatal) log.fatal({ err, origin }, 'uncaughtException — initiating graceful shutdown')
123
+ else console.error('[mega-shutdown] uncaughtException — initiating graceful shutdown:', err, origin)
124
+ void now({ signal: 'uncaughtException', exitCode })
125
+ }
126
+ process.on('unhandledRejection', unhandledRejectionHandler)
127
+ process.on('uncaughtException', uncaughtExceptionHandler)
91
128
  }
92
129
 
93
130
  /**
@@ -159,6 +196,12 @@ function _reset() {
159
196
  gracePeriodMs = 30_000
160
197
  hardKillMs = 60_000
161
198
  exitedHandlers = new Set()
199
+ // 전역 에러 핸들러도 떼어 테스트 간 누적 방지.
200
+ if (unhandledRejectionHandler) process.removeListener('unhandledRejection', unhandledRejectionHandler)
201
+ if (uncaughtExceptionHandler) process.removeListener('uncaughtException', uncaughtExceptionHandler)
202
+ unhandledRejectionHandler = null
203
+ uncaughtExceptionHandler = null
204
+ globalErrorsRegistered = false
162
205
  }
163
206
 
164
207
  /**
@@ -170,6 +213,7 @@ export const MegaShutdown = {
170
213
  unregister,
171
214
  isShuttingDown,
172
215
  setupSignals,
216
+ setupGlobalErrorHandlers,
173
217
  now,
174
218
  registeredCount,
175
219
  _reset,