mega-framework 0.1.2 → 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 +1 -1
- package/sample/crud/apps/main/app.config.js +4 -0
- package/sample/crud/apps/main/channels/chat-channel.js +15 -13
- package/sample/crud/apps/main/locales/server/en.json +1 -0
- package/sample/crud/apps/main/locales/server/ko.json +1 -0
- package/sample/crud/apps/main/public/js/ws-chat.js +30 -2
- package/sample/crud/apps/main/views/ws/index.ejs +7 -0
- package/sample/crud/yarn.lock +1 -1
- package/src/adapters/file-adapter.js +5 -0
- package/src/adapters/mega-cache-adapter.js +6 -2
- package/src/adapters/nats-adapter.js +6 -1
- package/src/cli/commands/console-cmd.js +4 -2
- package/src/core/boot.js +29 -2
- package/src/core/config-validator.js +39 -0
- package/src/core/mega-app.js +108 -3
- package/src/core/mega-cluster.js +27 -17
- package/src/core/ws-roster.js +163 -0
- package/src/core/ws-upgrade.js +29 -4
- package/src/lib/asp/nonce-cache.js +12 -0
- package/src/lib/logger/telegram-core.js +83 -28
- package/src/lib/logger/telegram-transport.js +22 -2
- package/src/lib/mega-job-queue.js +22 -1
- package/src/lib/mega-logger.js +41 -2
- package/src/lib/mega-shutdown.js +46 -2
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* MegaWsRedisRoster — **채널별** 클러스터 접속자 목록(roster)을 redis 로 관리 (ADR-177).
|
|
4
|
+
*
|
|
5
|
+
* roster 는 "전송(broadcast)"이 아니라 **공유 상태(누가 어느 채널에 접속 중인지)** 라, 허브 개수와 무관한
|
|
6
|
+
* **공유 스토어(redis)** 가 단일 source-of-truth 다. 이로써 (1) **멀티 허브**에서도 명단이 정합하고,
|
|
7
|
+
* (2) 신규/재연결 브릿지가 `HGETALL` 한 번으로 **전체 명단을 즉시** 획득하며(허브 presence 스냅샷 불요),
|
|
8
|
+
* (3) TTL+heartbeat 로 crash 워커의 stale 엔트리를 자동 정리한다. broadcast(메시지)는 그대로 허브(또는
|
|
9
|
+
* NATS)가 fan-out 하고, 본 모듈은 **roster(상태)만** 다룬다(관심사 분리).
|
|
10
|
+
*
|
|
11
|
+
* # 키 스키마 — 채널 기준
|
|
12
|
+
* `<prefix>:<channel>` = HASH( sessionId → JSON({ userId, metadata?, expiresAt }) )
|
|
13
|
+
* 예) `ws:roster:chat`. 룸(채널 내 더 잘게 나눔)은 **앱 비즈니스 로직** — 프레임워크는 채널 단위만.
|
|
14
|
+
*
|
|
15
|
+
* # crash 정리(TTL) — HEXPIRE 없이 모든 redis 버전 호환
|
|
16
|
+
* per-field TTL(HEXPIRE)은 redis 7.4+ 라, 대신 **값에 `expiresAt` 을 싣고 읽을 때 lazy 만료**한다.
|
|
17
|
+
* 각 워커가 heartbeat 로 자기 로컬 세션의 `expiresAt` 을 주기 갱신 → 살아있는 세션은 유지, crash 워커의
|
|
18
|
+
* 세션은 갱신이 끊겨 만료된다. `list` 는 만료 엔트리를 결과에서 빼고 best-effort 로 `HDEL` 한다.
|
|
19
|
+
*
|
|
20
|
+
* @module core/ws-roster
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** 기본 키 접두. */
|
|
24
|
+
const DEFAULT_KEY_PREFIX = 'ws:roster'
|
|
25
|
+
/** 기본 멤버 TTL(ms) — heartbeat 미갱신 시 stale 로 간주. */
|
|
26
|
+
const DEFAULT_TTL_MS = 30_000
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 채널 roster 멤버(redis 에 저장되는 형태 — sessionId 는 HASH field 라 값엔 안 넣음).
|
|
30
|
+
* @typedef {Object} RosterMember
|
|
31
|
+
* @property {string} userId
|
|
32
|
+
* @property {Object} [metadata]
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* redis 기반 채널별 roster.
|
|
37
|
+
*
|
|
38
|
+
* 앱(MegaApp) 1개당 1 인스턴스. boot 가 `bridgeHub.roster.driver==='redis'` 일 때 생성·start·shutdown 한다.
|
|
39
|
+
* redis 핸들은 캐시 어댑터의 `.native`(ioredis) 를 받는다 — pub/sub 가 아니라 HASH 명령(HSET/HGETALL/HDEL)만 쓴다.
|
|
40
|
+
*/
|
|
41
|
+
export class MegaWsRedisRoster {
|
|
42
|
+
/**
|
|
43
|
+
* @param {Object} opts
|
|
44
|
+
* @param {import('ioredis').Redis} opts.redis - 캐시 어댑터의 raw ioredis 핸들.
|
|
45
|
+
* @param {() => Array<{ channel: string, sessionId: string, member: RosterMember }>} opts.getLocalMembers -
|
|
46
|
+
* 이 워커의 현재 로컬 멤버 목록(heartbeat 갱신 대상). 보통 mega-app 의 세션 매핑에서 도출.
|
|
47
|
+
* @param {string} [opts.keyPrefix] - 키 접두(기본 'ws:roster').
|
|
48
|
+
* @param {number} [opts.ttlMs] - 멤버 TTL(ms, 기본 30000).
|
|
49
|
+
* @param {{ debug?: Function, warn?: Function, error?: Function }} [opts.logger]
|
|
50
|
+
*/
|
|
51
|
+
constructor({ redis, getLocalMembers, keyPrefix, ttlMs, logger } = /** @type {any} */ ({})) {
|
|
52
|
+
if (!redis || typeof redis.hset !== 'function' || typeof redis.hgetall !== 'function') {
|
|
53
|
+
throw new Error('MegaWsRedisRoster: a redis(ioredis) handle (cache adapter .native) is required.')
|
|
54
|
+
}
|
|
55
|
+
this._redis = redis
|
|
56
|
+
this._getLocalMembers = typeof getLocalMembers === 'function' ? getLocalMembers : () => /** @type {Array<{ channel: string, sessionId: string, member: RosterMember }>} */ ([])
|
|
57
|
+
this._prefix = typeof keyPrefix === 'string' && keyPrefix.length > 0 ? keyPrefix : DEFAULT_KEY_PREFIX
|
|
58
|
+
this._ttlMs = Number.isInteger(ttlMs) && /** @type {number} */ (ttlMs) > 0 ? /** @type {number} */ (ttlMs) : DEFAULT_TTL_MS
|
|
59
|
+
this._log = logger
|
|
60
|
+
/** @type {ReturnType<typeof setInterval>|null} */
|
|
61
|
+
this._hbTimer = null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 채널 → redis 키. @param {string} channel @returns {string} @private */
|
|
65
|
+
_key(channel) {
|
|
66
|
+
return `${this._prefix}:${channel}`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 채널 roster 에 멤버 추가/갱신(joinSession). 키 자체에도 EXPIRE 를 걸어 버려진 채널이 새지 않게 한다.
|
|
71
|
+
* @param {string} channel @param {string} sessionId @param {RosterMember} member @returns {Promise<void>}
|
|
72
|
+
*/
|
|
73
|
+
async add(channel, sessionId, member) {
|
|
74
|
+
if (typeof channel !== 'string' || typeof sessionId !== 'string') return
|
|
75
|
+
const key = this._key(channel)
|
|
76
|
+
const value = JSON.stringify({ userId: member?.userId, ...(member?.metadata ? { metadata: member.metadata } : {}), expiresAt: Date.now() + this._ttlMs })
|
|
77
|
+
await this._redis.hset(key, sessionId, value)
|
|
78
|
+
// 채널 HASH 키 전체 TTL — 모든 멤버가 stale 가 돼도 키가 영구히 남지 않게(멤버 TTL 의 2배 여유).
|
|
79
|
+
await this._redis.pexpire(key, this._ttlMs * 2)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 채널 roster 에서 멤버 제거(disconnect).
|
|
84
|
+
* @param {string} channel @param {string} sessionId @returns {Promise<void>}
|
|
85
|
+
*/
|
|
86
|
+
async remove(channel, sessionId) {
|
|
87
|
+
if (typeof channel !== 'string' || typeof sessionId !== 'string') return
|
|
88
|
+
await this._redis.hdel(this._key(channel), sessionId)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 채널의 **현재 접속자 목록**(만료 제외). 만료 엔트리는 best-effort 로 정리한다.
|
|
93
|
+
* @param {string} channel @returns {Promise<Array<{ sessionId: string, userId: string, metadata?: Object }>>}
|
|
94
|
+
*/
|
|
95
|
+
async list(channel) {
|
|
96
|
+
if (typeof channel !== 'string') return []
|
|
97
|
+
/** @type {Record<string, string>} */
|
|
98
|
+
const raw = await this._redis.hgetall(this._key(channel))
|
|
99
|
+
const now = Date.now()
|
|
100
|
+
/** @type {Array<{ sessionId: string, userId: string, metadata?: Object }>} */
|
|
101
|
+
const out = []
|
|
102
|
+
/** @type {string[]} 만료돼 정리할 field. */
|
|
103
|
+
const expired = []
|
|
104
|
+
for (const [sessionId, json] of Object.entries(raw ?? {})) {
|
|
105
|
+
let m
|
|
106
|
+
try {
|
|
107
|
+
m = JSON.parse(json)
|
|
108
|
+
} catch (err) {
|
|
109
|
+
// 손상 엔트리는 결과에서 제외 + 정리(silent 금지 — 사유 로그).
|
|
110
|
+
this._log?.warn?.({ err, channel, sessionId }, 'ws-roster corrupt entry (dropping)')
|
|
111
|
+
expired.push(sessionId)
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
if (typeof m.expiresAt === 'number' && m.expiresAt < now) {
|
|
115
|
+
expired.push(sessionId) // heartbeat 끊긴 crash 세션 — 만료.
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
out.push({ sessionId, userId: m.userId, ...(m.metadata ? { metadata: m.metadata } : {}) })
|
|
119
|
+
}
|
|
120
|
+
if (expired.length > 0) {
|
|
121
|
+
// best-effort 정리(실패해도 다음 list 에서 다시 시도) — 결과엔 이미 제외됨.
|
|
122
|
+
this._redis.hdel(this._key(channel), ...expired).catch((err) => this._log?.warn?.({ err, channel }, 'ws-roster expired cleanup failed'))
|
|
123
|
+
}
|
|
124
|
+
return out
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* heartbeat 시작 — 주기적으로 로컬 멤버의 `expiresAt` 을 갱신해 살아있음을 알린다(crash 워커는 갱신이
|
|
129
|
+
* 끊겨 만료). 프로세스 종료를 막지 않게 unref. 멱등(중복 호출 무시).
|
|
130
|
+
* @returns {void}
|
|
131
|
+
*/
|
|
132
|
+
startHeartbeat() {
|
|
133
|
+
if (this._hbTimer) return
|
|
134
|
+
const intervalMs = Math.max(1000, Math.floor(this._ttlMs / 2))
|
|
135
|
+
this._hbTimer = setInterval(() => {
|
|
136
|
+
this._refreshLocal().catch((err) => this._log?.warn?.({ err }, 'ws-roster heartbeat refresh failed'))
|
|
137
|
+
}, intervalMs)
|
|
138
|
+
this._hbTimer.unref?.()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 로컬 멤버 전체를 다시 add(=expiresAt 갱신). @returns {Promise<void>} @private
|
|
143
|
+
*/
|
|
144
|
+
async _refreshLocal() {
|
|
145
|
+
const members = this._getLocalMembers()
|
|
146
|
+
for (const { channel, sessionId, member } of members) {
|
|
147
|
+
await this.add(channel, sessionId, member)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 정리 — heartbeat 중지 + 로컬 멤버를 redis 에서 제거(graceful 종료 시 다른 워커가 즉시 정합).
|
|
153
|
+
* @returns {Promise<void>}
|
|
154
|
+
*/
|
|
155
|
+
async stop() {
|
|
156
|
+
if (this._hbTimer) clearInterval(this._hbTimer)
|
|
157
|
+
this._hbTimer = null
|
|
158
|
+
// graceful: 자기 로컬 멤버 즉시 제거(crash 가 아닌 정상 종료라 TTL 대기 없이 정리).
|
|
159
|
+
for (const { channel, sessionId } of this._getLocalMembers()) {
|
|
160
|
+
await this.remove(channel, sessionId).catch(() => {})
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
package/src/core/ws-upgrade.js
CHANGED
|
@@ -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)
|
|
@@ -175,14 +193,21 @@ export class MegaWsConnection {
|
|
|
175
193
|
* @param {import('./mega-app.js').MegaApp|null|undefined} app
|
|
176
194
|
* @param {MegaWsConnection} conn
|
|
177
195
|
* @param {string|undefined} ns
|
|
178
|
-
* @returns {{ list: () => Array<object
|
|
196
|
+
* @returns {{ list: () => Promise<Array<object>>, join: (entry: object) => void, directToUser: (userId: string, message: object) => void, broadcast: (args: object) => void } | null}
|
|
179
197
|
*/
|
|
180
198
|
function buildWsPresence(app, conn, ns) {
|
|
181
199
|
if (!app || typeof (/** @type {any} */ (app).joinSession) !== 'function' || typeof ns !== 'string') return null
|
|
182
200
|
const a = /** @type {any} */ (app)
|
|
183
201
|
return {
|
|
184
|
-
/**
|
|
185
|
-
|
|
202
|
+
/**
|
|
203
|
+
* 이 연결이 가입한 채널의 **클러스터 전역** 접속자 목록(async). redis roster(ADR-177) 배선 시 채널 기준
|
|
204
|
+
* (redis HASH, 멀티 허브 정합); 미배선이면 NATS/로컬 ns 기준. 룸별 필터는 앱이 직접(프레임워크는 채널 단위).
|
|
205
|
+
* @returns {Promise<Array<{ sessionId: string, userId: string, metadata?: Object }>>}
|
|
206
|
+
*/
|
|
207
|
+
list: async () => {
|
|
208
|
+
if (a._wsRoster && conn.channels && conn.channels.size > 0) return a.presenceList([...conn.channels])
|
|
209
|
+
return a.roster(ns)
|
|
210
|
+
},
|
|
186
211
|
/** 연결을 신원에 매핑 — `{ userId, sessionId, channels?, metadata? }`. roster 등록이 자동으로 따라온다. */
|
|
187
212
|
join: (entry) => {
|
|
188
213
|
a.joinSession(conn, entry)
|
|
@@ -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)
|
|
@@ -68,25 +68,63 @@ const LEVEL_NAMES = /** @type {Record<number, string>} */ ({
|
|
|
68
68
|
|
|
69
69
|
/**
|
|
70
70
|
* disk-backed retry queue — 전송 실패한 텍스트를 파일(JSONL)에 쌓고, 다음 기회에 재시도한다(전송 실패해도
|
|
71
|
-
* 메시지를 잃지 않음, ADR-023).
|
|
71
|
+
* 메시지를 잃지 않음, ADR-023).
|
|
72
|
+
*
|
|
73
|
+
* # in-process 직렬화 락 (ADR-023)
|
|
74
|
+
* 단일 스레드라도 `append`(open→write→close)와 `drain`(rename→read→rm)은 각 await 사이에서 **이벤트루프
|
|
75
|
+
* 인터리브**된다. 인터리브되면 append 의 `open` 이 drain 의 `rename` 보다 먼저 일어날 때, write 가 이미
|
|
76
|
+
* rename·rm 된 inode 로 가서 항목이 **유실**된다(rename-claim 의 "유실 0" 이 깨지는 좁은 창). 그래서 큐
|
|
77
|
+
* 연산을 promise 체인 mutex 로 **직렬화**해 인터리브를 원천 차단한다. 큐 연산은 드물어(전송 실패 시
|
|
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 으로 충분하다.
|
|
72
85
|
*/
|
|
73
86
|
export class RetryQueue {
|
|
74
87
|
/**
|
|
75
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 으로 드롭된 수 통지(관측용).
|
|
76
92
|
*/
|
|
77
|
-
constructor(filePath) {
|
|
93
|
+
constructor(filePath, { maxEntries = 5000, maxAgeMs = 86_400_000, onDrop } = {}) {
|
|
78
94
|
/** @type {string} */
|
|
79
95
|
this._file = filePath
|
|
96
|
+
/** @type {Promise<unknown>} 직렬화 락의 꼬리 — append/drain/clear 를 이 체인에 줄세운다. */
|
|
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
|
|
80
104
|
}
|
|
81
105
|
|
|
82
106
|
/**
|
|
83
|
-
*
|
|
107
|
+
* 큐 연산을 직렬화 — 앞 연산이 끝난 뒤(성패 무관) `fn` 을 실행한다. 한 연산의 실패가 다음 연산을 막지
|
|
108
|
+
* 않도록 락 체인은 에러를 삼키되, 호출자에겐 결과/에러를 그대로 전파한다.
|
|
109
|
+
* @template T @param {() => Promise<T>} fn @returns {Promise<T>} @private
|
|
110
|
+
*/
|
|
111
|
+
_serialize(fn) {
|
|
112
|
+
const result = this._tail.then(() => fn())
|
|
113
|
+
// 락 꼬리: 앞 연산의 성패와 무관하게 다음 연산을 진행시킨다(에러는 result 로 호출자에게만 전파).
|
|
114
|
+
this._tail = result.catch(() => {})
|
|
115
|
+
return result
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 실패 메시지를 큐에 append (직렬화됨).
|
|
84
120
|
* @param {string} text
|
|
85
121
|
* @returns {Promise<void>}
|
|
86
122
|
*/
|
|
87
123
|
async append(text) {
|
|
88
|
-
|
|
89
|
-
|
|
124
|
+
return this._serialize(async () => {
|
|
125
|
+
await mkdir(dirname(this._file), { recursive: true })
|
|
126
|
+
await appendFile(this._file, JSON.stringify({ text, ts: Date.now() }) + '\n', 'utf8')
|
|
127
|
+
})
|
|
90
128
|
}
|
|
91
129
|
|
|
92
130
|
/**
|
|
@@ -100,34 +138,51 @@ export class RetryQueue {
|
|
|
100
138
|
* @returns {Promise<string[]>}
|
|
101
139
|
*/
|
|
102
140
|
async drain() {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
await rename(this._file, claim)
|
|
106
|
-
} catch (e) {
|
|
107
|
-
// 큐 파일 없음(아직 실패 없음) 또는 다른 drain 이 이미 가로챔 → 처리할 것 없음.
|
|
108
|
-
if (/** @type {NodeJS.ErrnoException} */ (e)?.code === 'ENOENT') return []
|
|
109
|
-
throw e
|
|
110
|
-
}
|
|
111
|
-
const raw = await readFile(claim, 'utf8')
|
|
112
|
-
await rm(claim, { force: true })
|
|
113
|
-
/** @type {string[]} */
|
|
114
|
-
const texts = []
|
|
115
|
-
for (const line of raw.split('\n')) {
|
|
116
|
-
if (!line.trim()) continue
|
|
141
|
+
return this._serialize(async () => {
|
|
142
|
+
const claim = `${this._file}.draining`
|
|
117
143
|
try {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
144
|
+
await rename(this._file, claim)
|
|
145
|
+
} catch (e) {
|
|
146
|
+
// 큐 파일 없음(아직 실패 없음) 또는 다른 drain 이 이미 가로챔 → 처리할 것 없음.
|
|
147
|
+
if (/** @type {NodeJS.ErrnoException} */ (e)?.code === 'ENOENT') return []
|
|
148
|
+
throw e
|
|
149
|
+
}
|
|
150
|
+
const raw = await readFile(claim, 'utf8')
|
|
151
|
+
await rm(claim, { force: true })
|
|
152
|
+
const now = Date.now()
|
|
153
|
+
/** @type {Array<{ text: string, ts: number }>} */
|
|
154
|
+
let entries = []
|
|
155
|
+
for (const line of raw.split('\n')) {
|
|
156
|
+
if (!line.trim()) continue
|
|
157
|
+
try {
|
|
158
|
+
const obj = JSON.parse(line)
|
|
159
|
+
if (typeof obj.text === 'string') entries.push({ text: obj.text, ts: typeof obj.ts === 'number' ? obj.ts : now })
|
|
160
|
+
} catch {
|
|
161
|
+
// 손상된 라인은 건너뛴다 — 큐 전체를 막지 않기 위함(비치명적, 다음 항목 계속).
|
|
162
|
+
continue
|
|
163
|
+
}
|
|
164
|
+
}
|
|
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 만 유지.
|
|
123
175
|
}
|
|
124
|
-
|
|
125
|
-
|
|
176
|
+
if (dropped > 0 && this._onDrop) this._onDrop(dropped)
|
|
177
|
+
return entries.map((e) => e.text)
|
|
178
|
+
})
|
|
126
179
|
}
|
|
127
180
|
|
|
128
|
-
/** 큐 파일 제거(
|
|
181
|
+
/** 큐 파일 제거(테스트·정리용, 직렬화됨). @returns {Promise<void>} */
|
|
129
182
|
async clear() {
|
|
130
|
-
|
|
183
|
+
return this._serialize(async () => {
|
|
184
|
+
await rm(this._file, { force: true })
|
|
185
|
+
})
|
|
131
186
|
}
|
|
132
187
|
}
|
|
133
188
|
|
|
@@ -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
|
-
|
|
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()))
|
|
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
|
-
|
|
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
|
)
|
package/src/lib/mega-logger.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
150
|
+
// 기본 시크릿 redact 를 **항상** 적용 + 사용자 설정 병합(H2 보안). 미지정이어도 마스킹된다.
|
|
151
|
+
redact: mergeRedact(c.redact),
|
|
113
152
|
transport: { targets },
|
|
114
153
|
}
|
|
115
154
|
}
|
package/src/lib/mega-shutdown.js
CHANGED
|
@@ -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
|
-
*
|
|
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,
|