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
package/package.json
CHANGED
|
@@ -82,6 +82,10 @@ export default {
|
|
|
82
82
|
bridgeId: process.env.BRIDGE_ID ?? 'main-1',
|
|
83
83
|
channels: ['chat'],
|
|
84
84
|
retry: { retries: 30, minTimeout: 1000, maxTimeout: 10_000 },
|
|
85
|
+
// 접속자 목록(roster)은 broadcast(허브)와 별개로 **redis** 로 관리한다(ADR-177). 멀티 허브에서도 명단이
|
|
86
|
+
// 정합하고, 신규/재연결 브릿지가 즉시 전체 명단을 받는다(허브 presence 스냅샷 불요). cache='demo'(글로벌
|
|
87
|
+
// redis 캐시, db1). 룸은 앱 비즈니스 로직 — 프레임워크는 **채널별 전체 접속자**만 관리한다(키 ws:roster:<channel>).
|
|
88
|
+
roster: { driver: 'redis', cache: 'demo', ttlMs: 30_000 },
|
|
85
89
|
},
|
|
86
90
|
|
|
87
91
|
// rate limit 상향(ADR-073) — /demo/worker 데모는 1초 간격 하트비트 ping(메인 스레드 non-block 시연)으로
|
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* ChatChannel — /ws/chat 실시간 채팅 WS 채널 (ADR-158/176).
|
|
4
4
|
*
|
|
5
|
-
* 클러스터 전파(broadcast)와 접속자 목록(roster)
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* (`demo` 캐시)에 KV 로 둔다 — 이건 전파/roster 가 아니라 단순 저장이다.
|
|
5
|
+
* 클러스터 전파(broadcast)와 접속자 목록(roster)은 **프레임워크가 자동 처리**한다 — 전파는 **WS Hub**(broadcast
|
|
6
|
+
* fan-out, ADR-176), 접속자 목록은 **redis**(채널별 roster, ADR-177)로 **분리**된다(멀티 허브 정합). 채널은
|
|
7
|
+
* `ctx.presence`/`ctx.app.broadcast` 로 **비즈니스 로직만** 작성하고 전파·roster 동기화 코드를 직접 두지 않는다.
|
|
8
|
+
* 최근 메시지 기록(history)만 redis(`demo` 캐시)에 KV 로 둔다 — 이건 전파/roster 가 아니라 단순 저장이다.
|
|
9
9
|
*
|
|
10
|
-
* - 전파: `ctx.presence.broadcast({ message })` → 같은
|
|
11
|
-
* - 접속자: `ctx.presence.join({...})`(자동 roster
|
|
10
|
+
* - 전파: `ctx.presence.broadcast({ message })` → 같은 채널의 전 클러스터 클라가 1회씩 수신(허브 fan-out).
|
|
11
|
+
* - 접속자: `ctx.presence.join({...})`(자동 roster 등록) + `await ctx.presence.list()`(채널 전역 명단, redis).
|
|
12
12
|
* - 기록: redis RPUSH/LTRIM(최근 N건 cap), 연결 시 replay.
|
|
13
|
+
* - 룸(채널 내 세분화)은 앱 몫 — 프레임워크는 **채널별 전체 접속자**만 관리한다.
|
|
13
14
|
*
|
|
14
15
|
* ASP: `/ws/chat` 는 asp.websocket.namespaces 라 E: 암호화. 코덱은 프레임워크가 종단(평문 envelope 만 다룸).
|
|
15
16
|
* 인증: before(makeWsRequireAuth) 가 로그인 세션만 통과 → ctx.auth(userId/sessionId/userName) 보장.
|
|
@@ -39,7 +40,7 @@ export class ChatChannel extends MegaWebSocketController {
|
|
|
39
40
|
|
|
40
41
|
// 최근 기록 replay (redis KV — 전파/roster 와 무관한 단순 저장).
|
|
41
42
|
const items = await this._loadHistory(ctx, sock)
|
|
42
|
-
const members = this._members(ctx) // 클러스터 전역 명단(
|
|
43
|
+
const members = await this._members(ctx) // 클러스터 전역 명단(redis roster, ADR-177).
|
|
43
44
|
|
|
44
45
|
// 입장 본인: 신원 + 최근기록 + 현재 명단/인원 + 워커 PID(클러스터 확인용).
|
|
45
46
|
sock.send({
|
|
@@ -86,7 +87,7 @@ export class ChatChannel extends MegaWebSocketController {
|
|
|
86
87
|
if (!ctx.auth) return
|
|
87
88
|
const { sessionId, userName } = ctx.auth
|
|
88
89
|
// 본인을 제외한 명단(자동 roster 제거 타이밍과 무관하게 sessionId 로 명시 제외).
|
|
89
|
-
const members = this._members(ctx, sessionId)
|
|
90
|
+
const members = await this._members(ctx, sessionId)
|
|
90
91
|
ctx.presence.broadcast({
|
|
91
92
|
channel: CHANNEL,
|
|
92
93
|
message: { type: 'chat.presence', payload: { event: 'leave', userName, online: members.length, members } },
|
|
@@ -95,13 +96,14 @@ export class ChatChannel extends MegaWebSocketController {
|
|
|
95
96
|
}
|
|
96
97
|
|
|
97
98
|
/**
|
|
98
|
-
* 클러스터 전역 접속자 이름 목록(
|
|
99
|
-
*
|
|
99
|
+
* 클러스터 전역 접속자 이름 목록(프레임워크가 redis 로 동기화한 채널 roster, ADR-177). `ctx.presence.list()`
|
|
100
|
+
* 는 async(redis 조회)다. exceptSessionId 가 주어지면 그 세션은 뺀다.
|
|
101
|
+
* @param {any} ctx @param {string} [exceptSessionId] @returns {Promise<string[]>}
|
|
100
102
|
* @private
|
|
101
103
|
*/
|
|
102
|
-
_members(ctx, exceptSessionId) {
|
|
103
|
-
|
|
104
|
-
|
|
104
|
+
async _members(ctx, exceptSessionId) {
|
|
105
|
+
const list = await ctx.presence.list()
|
|
106
|
+
return list
|
|
105
107
|
.filter((/** @type {any} */ m) => exceptSessionId == null || m.sessionId !== exceptSessionId)
|
|
106
108
|
.map((/** @type {any} */ m) => m.metadata?.userName)
|
|
107
109
|
.filter((/** @type {any} */ n) => typeof n === 'string' && n.length > 0)
|
|
@@ -212,6 +212,7 @@
|
|
|
212
212
|
"ws_status_closed": "Disconnected",
|
|
213
213
|
"ws_status_error": "Error",
|
|
214
214
|
"ws_online": "{n} online",
|
|
215
|
+
"ws_members_label": "Online",
|
|
215
216
|
"ws_presence_join": "{user} joined.",
|
|
216
217
|
"ws_presence_leave": "{user} left.",
|
|
217
218
|
"ws_empty": "No messages yet. Be the first to say hi.",
|
|
@@ -212,6 +212,7 @@
|
|
|
212
212
|
"ws_status_closed": "연결 끊김",
|
|
213
213
|
"ws_status_error": "오류",
|
|
214
214
|
"ws_online": "접속자 {n}명",
|
|
215
|
+
"ws_members_label": "접속자",
|
|
215
216
|
"ws_presence_join": "{user} 님이 입장했습니다.",
|
|
216
217
|
"ws_presence_leave": "{user} 님이 퇴장했습니다.",
|
|
217
218
|
"ws_empty": "아직 메시지가 없습니다. 첫 메시지를 보내보세요.",
|
|
@@ -15,6 +15,7 @@ const messagesEl = document.getElementById('chat-messages')
|
|
|
15
15
|
const statusEl = document.getElementById('chat-status')
|
|
16
16
|
const statusDot = document.getElementById('chat-status-dot')
|
|
17
17
|
const onlineEl = document.getElementById('chat-online')
|
|
18
|
+
const membersListEl = document.getElementById('chat-members-list')
|
|
18
19
|
const workerEl = document.getElementById('chat-worker')
|
|
19
20
|
const form = /** @type {HTMLFormElement} */ (document.getElementById('chat-form'))
|
|
20
21
|
const input = /** @type {HTMLInputElement} */ (document.getElementById('chat-input'))
|
|
@@ -49,12 +50,39 @@ function setStatus(state) {
|
|
|
49
50
|
if (canSend) input.focus()
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* 접속자 수 뱃지 + **가시적 명단(칩)** 갱신. roster 는 cluster-wide — 프레임워크가 wsCluster(NATS) 또는
|
|
55
|
+
* WS Hub(bridgeHub) 로 워커 전역 접속자를 동기화한다(ADR-176). 서버가 매 chat.history/chat.presence 에
|
|
56
|
+
* 갱신된 members 를 실어 보내므로, 받을 때마다 명단을 통째로 다시 그린다(서버 authoritative).
|
|
57
|
+
* @param {number} [online] @param {string[]} [members]
|
|
58
|
+
*/
|
|
53
59
|
function setRoster(online, members) {
|
|
54
60
|
if (onlineEl && typeof online === 'number') {
|
|
55
61
|
onlineEl.textContent = fill(d.tOnline, { n: online })
|
|
56
62
|
if (Array.isArray(members)) onlineEl.title = members.join(', ')
|
|
57
63
|
}
|
|
64
|
+
renderMembers(members)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 접속자 명단 칩을 다시 그린다(textContent 만 — XSS 방지). @param {string[]} [members] */
|
|
68
|
+
function renderMembers(members) {
|
|
69
|
+
if (!membersListEl) return
|
|
70
|
+
const list = Array.isArray(members) ? members : null
|
|
71
|
+
if (!list) return
|
|
72
|
+
membersListEl.replaceChildren() // 기존 칩 제거(innerHTML 미사용).
|
|
73
|
+
if (list.length === 0) {
|
|
74
|
+
const empty = document.createElement('span')
|
|
75
|
+
empty.className = 'text-body-secondary'
|
|
76
|
+
empty.textContent = '—'
|
|
77
|
+
membersListEl.append(empty)
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
for (const name of list) {
|
|
81
|
+
const chip = document.createElement('span')
|
|
82
|
+
chip.className = 'badge rounded-pill text-bg-light border'
|
|
83
|
+
chip.textContent = String(name ?? '') // ← XSS 방지: 닉네임은 textContent 로만.
|
|
84
|
+
membersListEl.append(chip)
|
|
85
|
+
}
|
|
58
86
|
}
|
|
59
87
|
|
|
60
88
|
/** 이 연결이 붙은 클러스터 워커 PID 표시(cluster broadcast 실증용). */
|
|
@@ -137,7 +165,7 @@ async function start() {
|
|
|
137
165
|
setRoster(payload?.online, payload?.members)
|
|
138
166
|
setWorker(payload?.workerPid)
|
|
139
167
|
})
|
|
140
|
-
// 채팅 메시지(본인 echo 포함) — cluster-wide
|
|
168
|
+
// 채팅 메시지(본인 echo 포함) — cluster-wide(프레임워크가 WS Hub 또는 NATS 로 모든 워커에 fan-out, ADR-176).
|
|
141
169
|
sock.on('chat.msg', (payload) => appendMessage(payload ?? {}))
|
|
142
170
|
// 입퇴장 알림 + 갱신된 명단/수(cluster-wide).
|
|
143
171
|
sock.on('chat.presence', (payload) => {
|
|
@@ -37,6 +37,13 @@
|
|
|
37
37
|
<span id="chat-online" class="badge text-bg-light" data-bs-toggle="tooltip" title=""></span>
|
|
38
38
|
</div>
|
|
39
39
|
|
|
40
|
+
<%# 접속자 명단(cluster-wide) — JS(ws-chat.js)가 chat.history/chat.presence 의 members 로 칩을 채운다.
|
|
41
|
+
XSS 방지: 닉네임은 textContent 로만 삽입. %>
|
|
42
|
+
<div id="chat-members" class="px-3 py-2 d-flex flex-wrap align-items-center gap-1 border-bottom bg-body-tertiary small">
|
|
43
|
+
<span class="text-body-secondary me-1"><%= t('ws_members_label', { defaultValue: '접속자' }) %>:</span>
|
|
44
|
+
<span id="chat-members-list" class="d-flex flex-wrap gap-1"></span>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
40
47
|
<%# 메시지 목록 — JS 가 textContent 로만 채운다(XSS 방지: 사용자 입력을 innerHTML 로 넣지 않음). %>
|
|
41
48
|
<div id="chat-messages" class="list-group list-group-flush overflow-auto" style="height: 22rem" aria-live="polite">
|
|
42
49
|
<div data-empty class="list-group-item text-body-secondary small text-center py-5"><%= t('ws_empty', { defaultValue: '아직 메시지가 없습니다. 첫 메시지를 보내보세요.' }) %></div>
|
package/sample/crud/yarn.lock
CHANGED
|
@@ -1234,7 +1234,7 @@ marked@^18.0.5:
|
|
|
1234
1234
|
integrity sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==
|
|
1235
1235
|
|
|
1236
1236
|
"mega-framework@file:../..":
|
|
1237
|
-
version "0.1.
|
|
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).
|
|
7
|
-
*
|
|
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 용이다.
|
|
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
|
-
|
|
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 으로 어댑터 등을
|
package/src/core/boot.js
CHANGED
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
* @module core/boot
|
|
35
35
|
*/
|
|
36
36
|
import { join } from 'node:path'
|
|
37
|
+
import nodeCluster from 'node:cluster' // 워커 식별자(cluster.worker.id)로 bridgeId 유일화(ADR-176, L-3).
|
|
37
38
|
// 빌트인 어댑터(postgres/mongodb/mariadb/sqlite/redis/file/nats/redlock)는 import 시 레지스트리에
|
|
38
39
|
// 자기등록한다(ADR-044). CLI 런타임 부팅(`mega start`/`worker`/`scheduler`/`migrate`)은 사용자 코드가
|
|
39
40
|
// 'mega-framework' 를 import 하기 전에 `buildFromGlobalConfig` 로 driver 를 resolve 하므로, 이 배럴을
|
|
@@ -50,6 +51,7 @@ import { buildLogger } from '../lib/mega-logger.js'
|
|
|
50
51
|
import { buildFromGlobalConfig, connectAll, get as getAdapter, entries as adapterEntries } from '../adapters/adapter-manager.js'
|
|
51
52
|
import { buildWorkers, startAll as startWorkers, contextProxy as workersContext } from './workers-manager.js'
|
|
52
53
|
import { MegaWsCluster } from './ws-cluster.js'
|
|
54
|
+
import { MegaWsRedisRoster } from './ws-roster.js'
|
|
53
55
|
import * as MegaMetrics from '../lib/mega-metrics.js'
|
|
54
56
|
import * as MegaTracing from '../lib/mega-tracing.js'
|
|
55
57
|
import { MegaWsHub } from '../cli/ws-hub.js'
|
|
@@ -306,15 +308,40 @@ export async function bootApp(projectRoot, { listen = true, port, host: listenHo
|
|
|
306
308
|
const a = /** @type {any} */ (app) // _deliverBroadcast/_deliverDirect 는 framework-internal(@private).
|
|
307
309
|
const bridgeHub = /** @type {any} */ (apps[i].config).bridgeHub
|
|
308
310
|
if (bridgeHub?.url) {
|
|
311
|
+
// ⚠️ 클러스터 워커마다 별개 브릿지라 bridgeId 가 **워커별로 유일**해야 한다(허브 sessionId global-unique
|
|
312
|
+
// 계약). 모든 워커가 같은 bridgeId 면 bridge-subscriber sessionId(`bridge:<id>#<ch>`, mega-app
|
|
313
|
+
// _resyncPresence)가 충돌해 허브가 계속 재할당(thrashing)한다(L-3). 설정 bridgeId 를 베이스로 워커
|
|
314
|
+
// 식별자(cluster.worker.id, 단일 프로세스면 pid)를 붙여 유일화한다. instanceId 도 동일하게 유일화
|
|
315
|
+
// (hub-link 는 instanceId 미지정 시 bridgeId 로 폴백하므로).
|
|
316
|
+
const baseId = bridgeHub.bridgeId ?? app.name
|
|
317
|
+
const workerTag = nodeCluster.worker?.id ?? process.pid
|
|
318
|
+
const uniqueBridgeId = `${baseId}-w${workerTag}`
|
|
309
319
|
// WS Hub 자동연결(ADR-065/176). connectHub 가 _hubLink 를 세워 app.broadcast/joinSession 의 hub 경로를
|
|
310
320
|
// 활성화한다(shutdown hook 도 connectHub 가 등록). 허브 다운이어도 boot 를 막지 않는다 — 초기 연결
|
|
311
321
|
// 실패는 warn 하고(retry 설정 시 백그라운드 재연결, ADR-098) 앱은 계속 뜬다(로컬 전달은 동작).
|
|
312
322
|
try {
|
|
313
|
-
await app.connectHub(bridgeHub)
|
|
314
|
-
logger?.debug?.({ app: app.name, url: bridgeHub.url }, 'boot.bridgeHub connected (ADR-176)')
|
|
323
|
+
await app.connectHub({ ...bridgeHub, bridgeId: uniqueBridgeId, instanceId: bridgeHub.instanceId ?? uniqueBridgeId })
|
|
324
|
+
logger?.debug?.({ app: app.name, url: bridgeHub.url, bridgeId: uniqueBridgeId }, 'boot.bridgeHub connected (ADR-176)')
|
|
315
325
|
} catch (err) {
|
|
316
326
|
logger?.warn?.({ err, app: app.name, url: bridgeHub.url }, 'boot.bridgeHub initial connect failed (retry if configured)')
|
|
317
327
|
}
|
|
328
|
+
// redis roster 자동배선(ADR-177) — `bridgeHub.roster.driver==='redis'` 면 **채널별 접속자 목록**을 redis HASH 로
|
|
329
|
+
// 관리한다(broadcast 와 별개 — 멀티 허브에서도 정합, 신규/재연결 브릿지가 즉시 전체 명단). 캐시 어댑터의 raw
|
|
330
|
+
// ioredis(`.native`)를 쓰고, heartbeat 로 crash 워커 stale 정리. hub 연결 성패와 무관(roster 는 redis 독립).
|
|
331
|
+
const rosterCfg = /** @type {any} */ (bridgeHub).roster
|
|
332
|
+
if (rosterCfg?.driver === 'redis') {
|
|
333
|
+
const cacheAdapter = /** @type {any} */ (getAdapter('cache', rosterCfg.cache))
|
|
334
|
+
const redis = cacheAdapter?.native
|
|
335
|
+
if (!redis || typeof redis.hset !== 'function') {
|
|
336
|
+
logger?.warn?.({ app: app.name, cache: rosterCfg.cache }, 'boot.wsRoster: cache adapter has no native redis — roster disabled')
|
|
337
|
+
} else {
|
|
338
|
+
const roster = new MegaWsRedisRoster({ redis, getLocalMembers: () => a.localRosterMembers(), ttlMs: rosterCfg.ttlMs, keyPrefix: rosterCfg.keyPrefix, logger })
|
|
339
|
+
roster.startHeartbeat()
|
|
340
|
+
a.setWsRoster(roster)
|
|
341
|
+
MegaShutdown.register(`mega-wsroster:${app.name}`, () => roster.stop())
|
|
342
|
+
logger?.debug?.({ app: app.name, cache: rosterCfg.cache }, 'boot.wsRoster connected (ADR-177, redis)')
|
|
343
|
+
}
|
|
344
|
+
}
|
|
318
345
|
} else if (wsClusterBus) {
|
|
319
346
|
const cluster = new MegaWsCluster({
|
|
320
347
|
bus: /** @type {any} */ (wsClusterBus),
|
|
@@ -287,6 +287,45 @@ export function validateAppConfig(appConfig, expectedFolderName, globalConfig) {
|
|
|
287
287
|
{ details: { app: expectedFolderName, bridgeHubUrl: bh.url, wsClusterBus: globalConfig.wsCluster.bus } },
|
|
288
288
|
)
|
|
289
289
|
}
|
|
290
|
+
// bridgeHub.roster(채널별 접속자 목록 백엔드, ADR-177) — broadcast 와 별개. driver='redis' 면 cache 는
|
|
291
|
+
// 글로벌 services.caches 의 **redis** 어댑터 키여야 한다(멀티 허브 정합용 공유 스토어).
|
|
292
|
+
if (bh.roster !== undefined) {
|
|
293
|
+
const r = bh.roster
|
|
294
|
+
if (typeof r !== 'object' || r === null || Array.isArray(r)) {
|
|
295
|
+
throw new MegaConfigError('config.bridgeHub_roster_invalid', `app '${expectedFolderName}': bridgeHub.roster must be an object ({ driver, cache?, ttlMs? }).`, {
|
|
296
|
+
details: { app: expectedFolderName },
|
|
297
|
+
})
|
|
298
|
+
}
|
|
299
|
+
const VALID_ROSTER_DRIVERS = ['redis', 'none']
|
|
300
|
+
if (r.driver !== undefined && !VALID_ROSTER_DRIVERS.includes(r.driver)) {
|
|
301
|
+
throw new MegaConfigError('config.bridgeHub_roster_driver_invalid', `app '${expectedFolderName}': bridgeHub.roster.driver must be one of ${VALID_ROSTER_DRIVERS.join('|')}.`, {
|
|
302
|
+
details: { app: expectedFolderName, driver: r.driver },
|
|
303
|
+
})
|
|
304
|
+
}
|
|
305
|
+
if (r.driver === 'redis') {
|
|
306
|
+
const cacheSvc = typeof r.cache === 'string' ? globalConfig?.services?.caches?.[r.cache] : undefined
|
|
307
|
+
if (typeof r.cache !== 'string' || r.cache.length === 0) {
|
|
308
|
+
throw new MegaConfigError('config.bridgeHub_roster_cache_required', `app '${expectedFolderName}': bridgeHub.roster.cache (global redis cache key) is required when driver='redis'.`, {
|
|
309
|
+
details: { app: expectedFolderName },
|
|
310
|
+
})
|
|
311
|
+
}
|
|
312
|
+
if (!cacheSvc) {
|
|
313
|
+
throw new MegaConfigError('config.bridgeHub_roster_cache_unknown', `app '${expectedFolderName}': bridgeHub.roster.cache '${r.cache}' is not a registered global cache (services.caches).`, {
|
|
314
|
+
details: { app: expectedFolderName, cache: r.cache },
|
|
315
|
+
})
|
|
316
|
+
}
|
|
317
|
+
if (cacheSvc.driver !== 'redis') {
|
|
318
|
+
throw new MegaConfigError('config.bridgeHub_roster_cache_not_redis', `app '${expectedFolderName}': bridgeHub.roster.cache '${r.cache}' must be a redis cache (driver='redis'), got '${cacheSvc.driver}'.`, {
|
|
319
|
+
details: { app: expectedFolderName, cache: r.cache, driver: cacheSvc.driver },
|
|
320
|
+
})
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (r.ttlMs !== undefined && (!Number.isInteger(r.ttlMs) || r.ttlMs <= 0)) {
|
|
324
|
+
throw new MegaConfigError('config.bridgeHub_roster_ttl_invalid', `app '${expectedFolderName}': bridgeHub.roster.ttlMs must be a positive integer (ms).`, {
|
|
325
|
+
details: { app: expectedFolderName, ttlMs: r.ttlMs },
|
|
326
|
+
})
|
|
327
|
+
}
|
|
328
|
+
}
|
|
290
329
|
}
|
|
291
330
|
|
|
292
331
|
// 3) Shared-Reference 키 검증 — 참조하는 키가 globalConfig.services 에 존재해야 함
|
package/src/core/mega-app.js
CHANGED
|
@@ -184,6 +184,9 @@ export class MegaApp {
|
|
|
184
184
|
this._hubLink = null
|
|
185
185
|
/** @type {import('./ws-cluster.js').MegaWsCluster|null} NATS 기반 클러스터 fan-out/roster (ADR-176, boot 자동배선). */
|
|
186
186
|
this._wsCluster = null
|
|
187
|
+
/** @type {import('./ws-roster.js').MegaWsRedisRoster|null} 채널별 redis roster(ADR-177, boot 자동배선).
|
|
188
|
+
* 접속자 목록(상태)을 redis HASH 로 cluster-wide 관리한다(broadcast 와 별개 — 멀티 허브 정합·즉시 스냅샷). */
|
|
189
|
+
this._wsRoster = null
|
|
187
190
|
/** ns(=ws path) → 활성 로컬 연결 집합 (hub broadcast 의 local fan-out 용). @type {Map<string, Set<import('./ws-upgrade.js').MegaWsConnection>>} */
|
|
188
191
|
this._wsConns = new Map()
|
|
189
192
|
/** userId → 활성 로컬 연결 집합 (DIRECT 타겟팅, H-latent guard). @type {Map<string, Set<import('./ws-upgrade.js').MegaWsConnection>>} */
|
|
@@ -636,7 +639,17 @@ export class MegaApp {
|
|
|
636
639
|
// hub → bridge 푸시를 로컬 소켓에 전달.
|
|
637
640
|
link.on(HUB_MESSAGE_TYPES.BROADCAST, (msg) => this._deliverBroadcast(/** @type {any} */ (msg).payload))
|
|
638
641
|
link.on(HUB_MESSAGE_TYPES.DIRECT, (msg) => this._deliverDirect(/** @type {any} */ (msg).payload))
|
|
639
|
-
//
|
|
642
|
+
// 허브가 fan-out 하는 presence(JOIN/LEAVE/METADATA)·heartbeat 는 broadcast 채널 멤버십·keepalive 용으로만
|
|
643
|
+
// 의미 있다. **roster(접속자 목록)는 redis(MegaWsRedisRoster, ADR-177)가 별도 관리**하므로 브릿지는 이들을
|
|
644
|
+
// 소비하지 않는다 — no-op 핸들러로 "no handler for type" 노이즈만 차단한다(JOIN 자체는 joinSession 이
|
|
645
|
+
// broadcast 채널 가입용으로 계속 송신). 멀티 허브에서도 redis 가 single source-of-truth 라 명단이 정합.
|
|
646
|
+
const noopHub = () => {}
|
|
647
|
+
link.on(HUB_MESSAGE_TYPES.JOIN, noopHub)
|
|
648
|
+
link.on(HUB_MESSAGE_TYPES.LEAVE, noopHub)
|
|
649
|
+
link.on(HUB_MESSAGE_TYPES.BULK_LEAVE, noopHub)
|
|
650
|
+
link.on(HUB_MESSAGE_TYPES.METADATA, noopHub)
|
|
651
|
+
link.on(HUB_MESSAGE_TYPES.HEARTBEAT, noopHub)
|
|
652
|
+
// 재연결 성공 시 presence 재동기화(ADR-098) — hub 는 절단 시점 presence 를 잃으므로 broadcast 채널을 다시 JOIN.
|
|
640
653
|
link.on(MegaHubLink.EVENTS.RECONNECTED, () => this._resyncPresence())
|
|
641
654
|
await link.connect()
|
|
642
655
|
|
|
@@ -765,6 +778,15 @@ export class MegaApp {
|
|
|
765
778
|
if (this._wsCluster && typeof conn.ns === 'string') {
|
|
766
779
|
this._wsCluster.rosterAdd({ ns: conn.ns, sessionId, userId, ...(metadata ? { metadata } : {}) })
|
|
767
780
|
}
|
|
781
|
+
// redis roster 동기화 (ADR-177) — **채널별** 접속자 목록을 redis HASH 에 add(멀티 허브 정합). best-effort.
|
|
782
|
+
if (this._wsRoster && chans.length > 0) {
|
|
783
|
+
const member = { userId, ...(metadata ? { metadata } : {}) }
|
|
784
|
+
for (const ch of chans) {
|
|
785
|
+
this._wsRoster.add(ch, sessionId, member).catch((err) =>
|
|
786
|
+
this.fastify.log?.warn?.({ err, channel: ch, app: this.name }, 'ws-roster add failed'),
|
|
787
|
+
)
|
|
788
|
+
}
|
|
789
|
+
}
|
|
768
790
|
return this
|
|
769
791
|
}
|
|
770
792
|
|
|
@@ -898,8 +920,9 @@ export class MegaApp {
|
|
|
898
920
|
* @returns {Array<{ sessionId: string, userId: string, metadata?: Object }>}
|
|
899
921
|
*/
|
|
900
922
|
roster(ns) {
|
|
901
|
-
if (this._wsCluster) return this._wsCluster.roster(ns)
|
|
902
|
-
//
|
|
923
|
+
if (this._wsCluster) return this._wsCluster.roster(ns) // NATS: 이미 cluster-wide(roster 동기화 포함).
|
|
924
|
+
// ns 기준 로컬 명단(동기 API). **채널 기준 redis roster(ADR-177)** 는 `ctx.presence.list()`(async)가
|
|
925
|
+
// 다룬다 — 이 메서드는 NATS/로컬용 ns 기준 동기 API 로 유지(하위호환).
|
|
903
926
|
/** @type {Array<{ sessionId: string, userId: string, metadata?: Object }>} */
|
|
904
927
|
const out = []
|
|
905
928
|
for (const [sessionId, conn] of this._sessionConns) {
|
|
@@ -909,6 +932,65 @@ export class MegaApp {
|
|
|
909
932
|
return out
|
|
910
933
|
}
|
|
911
934
|
|
|
935
|
+
/**
|
|
936
|
+
* 채널별 redis roster(ADR-177)를 이 앱에 배선한다. boot 가 `bridgeHub.roster.driver==='redis'` 일 때
|
|
937
|
+
* 자동 호출하므로 개발자가 직접 부를 일은 없다(테스트·고급 용도로만 공개).
|
|
938
|
+
* @param {import('./ws-roster.js').MegaWsRedisRoster|null} roster
|
|
939
|
+
* @returns {this}
|
|
940
|
+
*/
|
|
941
|
+
setWsRoster(roster) {
|
|
942
|
+
this._wsRoster = roster
|
|
943
|
+
return this
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* 주어진 **채널들**의 cluster-wide 접속자 목록 — redis roster(원격 포함) + 로컬 세션을 병합한다(ADR-177).
|
|
948
|
+
* 로컬을 항상 포함해 join 직후 redis HSET 반영 전에도 본인이 빠지지 않게 한다. `ctx.presence.list()` 의
|
|
949
|
+
* redis 경로가 호출(async). roster 미배선이면 로컬 채널 멤버만.
|
|
950
|
+
* @param {string[]} channels
|
|
951
|
+
* @returns {Promise<Array<{ sessionId: string, userId: string, metadata?: Object }>>}
|
|
952
|
+
*/
|
|
953
|
+
async presenceList(channels) {
|
|
954
|
+
const want = new Set(Array.isArray(channels) ? channels : [])
|
|
955
|
+
/** @type {Map<string, { sessionId: string, userId: string, metadata?: Object }>} */
|
|
956
|
+
const out = new Map()
|
|
957
|
+
// 로컬 세션(이 워커) — 요청 채널에 가입한 것. 항상 fresh(본인 포함).
|
|
958
|
+
for (const [sessionId, conn] of this._sessionConns) {
|
|
959
|
+
if (!conn.isOpen || !conn.channels) continue
|
|
960
|
+
let inCh = false
|
|
961
|
+
for (const ch of conn.channels) {
|
|
962
|
+
if (want.has(ch)) {
|
|
963
|
+
inCh = true
|
|
964
|
+
break
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
if (inCh) out.set(sessionId, { sessionId, userId: /** @type {string} */ (conn.userId), ...(conn.metadata ? { metadata: conn.metadata } : {}) })
|
|
968
|
+
}
|
|
969
|
+
// redis(cluster-wide) — 다른 워커/허브의 세션까지.
|
|
970
|
+
if (this._wsRoster) {
|
|
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
|
+
}
|
|
975
|
+
return [...out.values()]
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* 이 워커의 로컬 멤버 목록 — redis roster heartbeat 갱신 대상(ADR-177). joinSession 으로 매핑된
|
|
980
|
+
* (채널 × 세션)마다 1개. MegaWsRedisRoster 가 주기적으로 이 목록의 expiresAt 을 갱신한다.
|
|
981
|
+
* @returns {Array<{ channel: string, sessionId: string, member: { userId: string, metadata?: Object } }>}
|
|
982
|
+
*/
|
|
983
|
+
localRosterMembers() {
|
|
984
|
+
/** @type {Array<{ channel: string, sessionId: string, member: { userId: string, metadata?: Object } }>} */
|
|
985
|
+
const out = []
|
|
986
|
+
for (const [sessionId, conn] of this._sessionConns) {
|
|
987
|
+
if (!conn.isOpen || !conn.channels) continue
|
|
988
|
+
const member = { userId: /** @type {string} */ (conn.userId), ...(conn.metadata ? { metadata: conn.metadata } : {}) }
|
|
989
|
+
for (const ch of conn.channels) out.push({ channel: ch, sessionId, member })
|
|
990
|
+
}
|
|
991
|
+
return out
|
|
992
|
+
}
|
|
993
|
+
|
|
912
994
|
/**
|
|
913
995
|
* broadcast payload 를 로컬 ns 소켓에 전달한다. message 는 `{ type, payload }` 내부 envelope.
|
|
914
996
|
*
|
|
@@ -999,6 +1081,14 @@ export class MegaApp {
|
|
|
999
1081
|
}
|
|
1000
1082
|
// NATS roster 제거 (ADR-176) — disconnect 시 클러스터 접속자 목록에서 자동 제거(개발자 코드 불요).
|
|
1001
1083
|
this._wsCluster?.rosterRemove(conn.sessionId)
|
|
1084
|
+
// redis roster 제거 (ADR-177) — 이 세션이 가입한 모든 채널에서 제거. best-effort.
|
|
1085
|
+
if (this._wsRoster && conn.channels) {
|
|
1086
|
+
for (const ch of conn.channels) {
|
|
1087
|
+
this._wsRoster.remove(ch, /** @type {string} */ (conn.sessionId)).catch((err) =>
|
|
1088
|
+
this.fastify.log?.warn?.({ err, channel: ch, app: this.name }, 'ws-roster remove failed'),
|
|
1089
|
+
)
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1002
1092
|
}
|
|
1003
1093
|
}
|
|
1004
1094
|
|
|
@@ -1169,6 +1259,21 @@ export class MegaApp {
|
|
|
1169
1259
|
this._hubBridgeId = null
|
|
1170
1260
|
MegaShutdown.unregister(`mega-hublink:${this.name}`)
|
|
1171
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
|
+
}
|
|
1270
|
+
// redis roster 정리(ADR-177) — heartbeat 중지 + 로컬 멤버 즉시 제거. _sessionConns 를 비우기 **전에**
|
|
1271
|
+
// 호출해야 stop() 이 localRosterMembers 로 자기 멤버를 redis 에서 뺄 수 있다.
|
|
1272
|
+
if (this._wsRoster) {
|
|
1273
|
+
await this._wsRoster.stop().catch((err) => this.fastify.log?.warn?.({ err, app: this.name }, 'ws-roster stop failed'))
|
|
1274
|
+
this._wsRoster = null
|
|
1275
|
+
MegaShutdown.unregister(`mega-wsroster:${this.name}`)
|
|
1276
|
+
}
|
|
1172
1277
|
this._wsConns.clear()
|
|
1173
1278
|
this._userConns.clear()
|
|
1174
1279
|
this._sessionConns.clear()
|
package/src/core/mega-cluster.js
CHANGED
|
@@ -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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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)
|
|
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:
|
|
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.
|
|
139
|
-
if (this.
|
|
140
|
-
// H-1 — crash-loop 포기 시 그냥 return 하면 이벤트루프가 비어 exit 0(성공)으로
|
|
141
|
-
//
|
|
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.
|
|
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-
|
|
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 {
|