mega-framework 0.1.7 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/package.json +3 -3
- package/sample/crud/.env +9 -0
- package/sample/crud/.env.example +9 -0
- package/sample/crud/apps/main/locales/server/en.json +12 -1
- package/sample/crud/apps/main/locales/server/ko.json +12 -1
- package/sample/crud/mega.config.js +7 -0
- package/sample/crud/package.json +2 -2
- package/sample/crud/scripts/start-ws-hub.sh +18 -4
- package/sample/simple/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/src/adapters/adapter-options.js +14 -3
- package/src/adapters/file-adapter.js +9 -5
- package/src/adapters/file-session-adapter.js +4 -3
- package/src/adapters/maria-adapter.js +7 -4
- package/src/adapters/mega-cache-adapter.js +83 -6
- package/src/adapters/mega-db-adapter.js +4 -1
- package/src/adapters/mongo-adapter.js +21 -7
- package/src/adapters/postgres-adapter.js +8 -4
- package/src/adapters/redis-adapter.js +7 -3
- package/src/adapters/sqlite-adapter.js +6 -2
- package/src/cli/commands/console-cmd.js +3 -1
- package/src/cli/commands/scaffold.js +38 -2
- package/src/cli/generators/index.js +58 -1
- package/src/cli/index.js +88 -59
- package/src/cli/watch.js +188 -0
- package/src/core/ajv-mapper.js +3 -1
- package/src/core/ctx-builder.js +59 -1
- package/src/core/envelope.js +9 -2
- package/src/core/hub-link.js +24 -14
- package/src/core/index.js +1 -1
- package/src/core/mega-app.js +55 -45
- package/src/core/pipeline.js +8 -6
- package/src/core/scope-registry.js +1 -0
- package/src/core/security.js +3 -3
- package/src/core/session-store.js +14 -1
- package/src/core/ws-presence.js +17 -5
- package/src/core/ws-roster.js +49 -10
- package/src/core/ws-upgrade.js +105 -0
- package/src/lib/mega-circuit-breaker.js +5 -3
- package/src/lib/mega-health.js +10 -0
- package/src/lib/mega-job-queue.js +53 -13
- package/src/lib/mega-job.js +8 -1
- package/src/lib/mega-metrics.js +28 -1
- package/src/lib/mega-plugin.js +2 -2
- package/src/lib/mega-worker.js +28 -5
- package/src/lib/ws-hub.js +90 -9
- package/templates/adr/code.tpl +23 -0
- package/types/adapters/adapter-options.d.ts +2 -0
- package/types/adapters/file-adapter.d.ts +12 -1
- package/types/adapters/file-session-adapter.d.ts +4 -2
- package/types/adapters/maria-adapter.d.ts +5 -3
- package/types/adapters/mega-cache-adapter.d.ts +27 -1
- package/types/adapters/mega-db-adapter.d.ts +4 -1
- package/types/adapters/mongo-adapter.d.ts +13 -2
- package/types/adapters/postgres-adapter.d.ts +4 -2
- package/types/adapters/redis-adapter.d.ts +8 -0
- package/types/adapters/sqlite-adapter.d.ts +8 -2
- package/types/cli/generators/index.d.ts +11 -1
- package/types/cli/index.d.ts +12 -27
- package/types/cli/watch.d.ts +59 -0
- package/types/core/ctx-builder.d.ts +23 -0
- package/types/core/hub-link.d.ts +3 -1
- package/types/core/index.d.ts +1 -1
- package/types/core/mega-app.d.ts +1 -1
- package/types/core/pipeline.d.ts +2 -1
- package/types/core/security.d.ts +3 -3
- package/types/core/session-store.d.ts +7 -0
- package/types/core/ws-roster.d.ts +13 -1
- package/types/core/ws-upgrade.d.ts +29 -0
- package/types/lib/mega-circuit-breaker.d.ts +4 -2
- package/types/lib/mega-health.d.ts +7 -0
- package/types/lib/mega-job-queue.d.ts +16 -4
- package/types/lib/mega-job.d.ts +8 -1
- package/types/lib/mega-plugin.d.ts +1 -1
- package/types/lib/mega-worker.d.ts +3 -1
- package/types/lib/ws-hub.d.ts +27 -2
package/src/core/ws-upgrade.js
CHANGED
|
@@ -61,6 +61,104 @@ export const CLOSE_CODE_REQUEUE = 4503
|
|
|
61
61
|
/** send 버퍼(bufferedAmount) 기본 상한(바이트). 초과 = 소비자가 ack 를 못 따라옴 → 연결 종료(서버 OOM 방지). */
|
|
62
62
|
export const DEFAULT_MAX_BUFFERED_BYTES = 16 * 1024 * 1024
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* 프로세스 **합산** send 버퍼 기본 budget(바이트, ADR-215 — G5 audit M-6).
|
|
66
|
+
*
|
|
67
|
+
* per-conn 상한(위 16MiB)은 연결 1개의 OOM 방어일 뿐이라, 느린 소비자 N 개가 각자 cap 직전까지
|
|
68
|
+
* 쌓으면 합산은 무제한이었다(이론상 1,000개 × 16MiB = 16GB). 합산이 본 budget 을 넘으면 **가장 큰
|
|
69
|
+
* 송신 큐를 보유한 연결부터** 1013(slow consumer)으로 종료해 budget 아래로 회수한다.
|
|
70
|
+
* `configureWsSendBudget({ maxTotalBufferedBytes: 0 })` 으로 무제한 옵트아웃.
|
|
71
|
+
*/
|
|
72
|
+
export const DEFAULT_MAX_TOTAL_BUFFERED_BYTES = 256 * 1024 * 1024
|
|
73
|
+
|
|
74
|
+
/** 합산 budget 스윕 최소 간격(ms) — 매 send 마다 전 연결 O(N) 합산을 돌지 않게 하는 비용 상한. */
|
|
75
|
+
const BUDGET_SWEEP_INTERVAL_MS = 250
|
|
76
|
+
|
|
77
|
+
/** budget 추적 대상 연결 — driveWsConnection 경유 실연결만(직접 생성한 테스트 더블은 미추적). @type {Set<MegaWsConnection>} */
|
|
78
|
+
const budgetConns = new Set()
|
|
79
|
+
|
|
80
|
+
/** @type {number} 현재 합산 budget(바이트). Infinity = 옵트아웃. */
|
|
81
|
+
let maxTotalBufferedBytes = DEFAULT_MAX_TOTAL_BUFFERED_BYTES
|
|
82
|
+
|
|
83
|
+
/** @type {number} 마지막 budget 스윕 시각(epoch ms). */
|
|
84
|
+
let lastBudgetSweepAt = 0
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 프로세스 합산 send budget 을 조정한다(ADR-215). 부팅/운영 튜닝 진입점.
|
|
88
|
+
* @param {{ maxTotalBufferedBytes?: number }} [opts] - 바이트 budget. `0` = 무제한(옵트아웃).
|
|
89
|
+
* @returns {{ maxTotalBufferedBytes: number }} 적용된 현재 값.
|
|
90
|
+
* @throws {TypeError} 음수/비숫자 — 설정 실수 fail-fast.
|
|
91
|
+
*/
|
|
92
|
+
export function configureWsSendBudget({ maxTotalBufferedBytes: max } = {}) {
|
|
93
|
+
if (max !== undefined) {
|
|
94
|
+
if (typeof max !== 'number' || Number.isNaN(max) || max < 0) {
|
|
95
|
+
throw new TypeError(`configureWsSendBudget: maxTotalBufferedBytes must be a non-negative number (0 = unlimited). Got ${max}`)
|
|
96
|
+
}
|
|
97
|
+
maxTotalBufferedBytes = max === 0 ? Infinity : max
|
|
98
|
+
}
|
|
99
|
+
return { maxTotalBufferedBytes }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 합산 budget 스윕 — 추적 중 전 연결의 `bufferedAmount` 를 합산해 budget 초과면 가장 큰 큐 보유
|
|
104
|
+
* 연결부터 종료한다. 스로틀({@link BUDGET_SWEEP_INTERVAL_MS}) 안쪽 재호출은 no-op(send 핫패스 보호).
|
|
105
|
+
* @param {number} [now] - epoch ms(테스트 주입용).
|
|
106
|
+
* @returns {void}
|
|
107
|
+
*/
|
|
108
|
+
function sweepWsSendBudget(now = Date.now()) {
|
|
109
|
+
if (now - lastBudgetSweepAt < BUDGET_SWEEP_INTERVAL_MS) return
|
|
110
|
+
lastBudgetSweepAt = now
|
|
111
|
+
if (budgetConns.size === 0 || maxTotalBufferedBytes === Infinity) return
|
|
112
|
+
let total = 0
|
|
113
|
+
for (const c of budgetConns) total += c._raw.bufferedAmount ?? 0
|
|
114
|
+
while (total > maxTotalBufferedBytes) {
|
|
115
|
+
/** @type {MegaWsConnection | null} */
|
|
116
|
+
let worst = null
|
|
117
|
+
for (const c of budgetConns) {
|
|
118
|
+
if (worst === null || (c._raw.bufferedAmount ?? 0) > (worst._raw.bufferedAmount ?? 0)) worst = c
|
|
119
|
+
}
|
|
120
|
+
const worstBytes = worst === null ? 0 : (worst._raw.bufferedAmount ?? 0)
|
|
121
|
+
if (worst === null || worstBytes === 0) break // 잔여가 전부 0 이면 더 회수할 게 없음(무한루프 차단).
|
|
122
|
+
budgetConns.delete(worst) // close 이벤트 전에 즉시 제외 — 같은 스윕 내 재선정 방지.
|
|
123
|
+
total -= worstBytes
|
|
124
|
+
try {
|
|
125
|
+
worst._raw.close(CLOSE_CODE_SLOW_CONSUMER, 'backpressure: process-wide send budget exceeded')
|
|
126
|
+
} catch {
|
|
127
|
+
// 이미 닫히는 중이면 close 는 무의미 — per-conn 가드와 동일 의미(비치명적). Set 에선 이미 제거됨.
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** budget 추적 등록(driveWsConnection 전용). @param {MegaWsConnection} conn @returns {void} */
|
|
133
|
+
function trackWsSendBudget(conn) {
|
|
134
|
+
budgetConns.add(conn)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** budget 추적 해제(연결 close 시). @param {MegaWsConnection} conn @returns {void} */
|
|
138
|
+
function untrackWsSendBudget(conn) {
|
|
139
|
+
budgetConns.delete(conn)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 테스트 격리용 — budget 상태 초기화(추적 Set·스로틀·budget 디폴트 복원).
|
|
144
|
+
* @returns {void}
|
|
145
|
+
*/
|
|
146
|
+
export function _resetWsSendBudget() {
|
|
147
|
+
budgetConns.clear()
|
|
148
|
+
lastBudgetSweepAt = 0
|
|
149
|
+
maxTotalBufferedBytes = DEFAULT_MAX_TOTAL_BUFFERED_BYTES
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** 테스트용 — 연결을 budget 추적에 등록. @param {MegaWsConnection} conn @returns {void} */
|
|
153
|
+
export function _trackWsSendBudget(conn) {
|
|
154
|
+
trackWsSendBudget(conn)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 테스트용 — 스로틀 우회 가능한 스윕 직접 호출. @param {number} [now] @returns {void} */
|
|
158
|
+
export function _sweepWsSendBudget(now) {
|
|
159
|
+
sweepWsSendBudget(now)
|
|
160
|
+
}
|
|
161
|
+
|
|
64
162
|
/**
|
|
65
163
|
* 클라↔bridge ping/pong liveness 기본 주기(ms) — 30초. 주기마다 ping 을 보내고 직전 주기의 pong 이
|
|
66
164
|
* 없으면 half-open(상대 사망·네트워크 단절) 으로 보고 terminate 한다 — 좀비 연결이 OS TCP 타임아웃까지
|
|
@@ -197,6 +295,10 @@ export class MegaWsConnection {
|
|
|
197
295
|
}
|
|
198
296
|
return
|
|
199
297
|
}
|
|
298
|
+
// 프로세스 합산 budget 스윕(ADR-215) — per-conn cap 아래의 "다수의 느린 소비자" 합산 OOM 방어.
|
|
299
|
+
// 스로틀이 있어 핫패스 비용은 주기당 O(N) 1회. 스윕이 이 연결을 닫았으면(가장 큰 큐) 송신 생략.
|
|
300
|
+
sweepWsSendBudget()
|
|
301
|
+
if (this._raw.readyState !== undefined && this._raw.readyState !== 1) return
|
|
200
302
|
// ns 자동 주입 (L1): 명시 안 됐고 연결 ns 가 있으면 채운다. 명시값은 덮어쓰지 않음.
|
|
201
303
|
const withNs = fields.ns === undefined && this.ns !== undefined ? { ...fields, ns: this.ns } : fields
|
|
202
304
|
const env = createWsMessage(withNs)
|
|
@@ -284,6 +386,8 @@ function buildWsPresence(app, conn, ns) {
|
|
|
284
386
|
*/
|
|
285
387
|
export function driveWsConnection({ raw, req, route, app, codec, log, auth = null, protocolVersion = WS_PROTOCOL_VERSION }) {
|
|
286
388
|
const conn = new MegaWsConnection(raw, codec, { ns: route.ns, path: route.path })
|
|
389
|
+
// 프로세스 합산 send budget 추적(ADR-215) — 실연결만 등록, close 에서 해제(아래 'close' 핸들러).
|
|
390
|
+
trackWsSendBudget(conn)
|
|
287
391
|
// 협상된 envelope 버전 — 이 연결의 검증 기준이자 v2 도입 시 코덱/검증 분기의 기준점.
|
|
288
392
|
conn.protocolVersion = protocolVersion
|
|
289
393
|
// ChannelClass 는 부팅 시 MegaWebSocketController 상속 검증됨 (router.ws). 여기선 인스턴스화만.
|
|
@@ -400,6 +504,7 @@ export function driveWsConnection({ raw, req, route, app, codec, log, auth = nul
|
|
|
400
504
|
}
|
|
401
505
|
|
|
402
506
|
raw.on('close', (code, reasonBuf) => {
|
|
507
|
+
untrackWsSendBudget(conn)
|
|
403
508
|
app._untrackWsConn?.(conn)
|
|
404
509
|
const reason = Buffer.isBuffer(reasonBuf) ? reasonBuf.toString('utf8') : String(reasonBuf ?? '')
|
|
405
510
|
log.debug?.({ connId: conn.id, code, reason }, 'ws.disconnect')
|
|
@@ -72,8 +72,10 @@ export const CAPACITY_ERROR_CODE = 'ESEMLOCKED'
|
|
|
72
72
|
* @property {number} [resetTimeout=30000] - open 상태 유지 시간(ms). 경과 후 halfOpen 으로 1회 프로빙.
|
|
73
73
|
* @property {number} [rollingCountTimeout=10000] - 실패율 집계 롤링 윈도우 길이(ms).
|
|
74
74
|
* @property {number} [rollingCountBuckets=10] - 롤링 윈도우를 나누는 버킷 수.
|
|
75
|
-
* @property {number} [volumeThreshold=
|
|
76
|
-
* (표본
|
|
75
|
+
* @property {number} [volumeThreshold=5] - 롤링 윈도우에 이 횟수만큼 호출이 쌓이기 전엔 실패율이
|
|
76
|
+
* 높아도 open 안 함(표본 부족 조기 trip 방지). 0=비활성. ⚠️ opossum 정본값(0)과 다른 프레임워크
|
|
77
|
+
* 디폴트 — 0 이면 부팅 직후/저빈도 호출에서 **첫 실패 1건**이 실패율 100% 가 돼 즉시 30s 차단되는
|
|
78
|
+
* 풋건이라 5 로 올렸다. opossum 원 동작이 필요하면 명시적으로 0 을 지정.
|
|
77
79
|
* @property {number} [capacity] - 동시 진행(in-flight) 호출 상한. 초과분은 `ESEMLOCKED` 로 즉시 거부.
|
|
78
80
|
* 미지정=무제한.
|
|
79
81
|
* @property {(err: any, ...args: any[]) => boolean} [errorFilter] - `true` 반환 시 그 에러는 **실패로 집계하지 않음**
|
|
@@ -149,7 +151,7 @@ export class MegaCircuitBreaker {
|
|
|
149
151
|
resetTimeout = 30_000,
|
|
150
152
|
rollingCountTimeout = 10_000,
|
|
151
153
|
rollingCountBuckets = 10,
|
|
152
|
-
volumeThreshold = 0
|
|
154
|
+
volumeThreshold = 5, // opossum 정본(0)과 의도적으로 다름 — 단일 실패 즉시 open 풋건 방지.
|
|
153
155
|
capacity,
|
|
154
156
|
errorFilter,
|
|
155
157
|
name,
|
package/src/lib/mega-health.js
CHANGED
|
@@ -45,6 +45,16 @@ export function register(name, fn, opts = {}) {
|
|
|
45
45
|
checks.set(name, { fn, timeoutMs })
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* 등록된 체크 제거(이름 기준). 닫힌 자원(예: hub link)의 체크가 stale 클로저로 남지 않게
|
|
50
|
+
* 소유자가 register 와 짝맞춰 부른다.
|
|
51
|
+
* @param {string} name
|
|
52
|
+
* @returns {boolean} 제거됐으면 true(미등록 이름이면 false).
|
|
53
|
+
*/
|
|
54
|
+
export function unregister(name) {
|
|
55
|
+
return checks.delete(name)
|
|
56
|
+
}
|
|
57
|
+
|
|
48
58
|
/**
|
|
49
59
|
* 모든 체크 실행 (병렬). 하나라도 false 면 전체 ok=false.
|
|
50
60
|
* @returns {Promise<{ ok: boolean, checks: Record<string, { ok: boolean, error?: string, [key: string]: any }> }>}
|
|
@@ -91,6 +91,8 @@ function truncateStack(stack) {
|
|
|
91
91
|
* 하트비트가 이 값의 절반마다 lease 를 갱신한다.
|
|
92
92
|
* @property {number} [maxDeliver=5] - JetStream 최대 전달 횟수(워커 크래시 재전달 backstop).
|
|
93
93
|
* @property {number} [heartbeatMs] - `working()` 전송 주기(ms). 기본 `max(1000, ackWaitMs/2)`.
|
|
94
|
+
* 양의 정수 + `< ackWaitMs` 필수(생성자 fail-fast) — 이상이면 lease 갱신이 늦어 정상 처리 중
|
|
95
|
+
* 중복 재전달, 0 이하면 working() 플러딩.
|
|
94
96
|
* @property {string} [streamPrefix='MEGA_JOBS'] - 스트림 이름 접두사.
|
|
95
97
|
* @property {number} [dlqMaxAgeMs=604800000] - DLQ 스트림 메시지 보존 기한(ms, 디폴트 7일). 초과한 실패
|
|
96
98
|
* 잡은 NATS 가 자동 만료시킨다(무한 적재 방지, ADR-134). `0` 이면 무제한(끔 — 영구 보존). **신규 DLQ
|
|
@@ -135,6 +137,13 @@ export class MegaJobQueue extends EventEmitter {
|
|
|
135
137
|
/** @type {import('nats').JetStreamClient|null} */ #js = null
|
|
136
138
|
/** @type {import('nats').JetStreamManager|null} */ #jsm = null
|
|
137
139
|
/** @type {Promise<void>|null} ensureReady 멱등 가드. */ #readyPromise = null
|
|
140
|
+
/**
|
|
141
|
+
* subject 별 ensureStream 멱등 캐시(#readyPromise 와 동일 패턴). 없으면 enqueue 가 매 호출
|
|
142
|
+
* `jsm.streams.info` RPC ×2(워크+DLQ)를 반복해 enqueue 비용의 2/3 가 존재 재확인에 낭비된다.
|
|
143
|
+
* 실패한 Promise 는 캐시에서 비워 다음 호출이 재시도하게 한다.
|
|
144
|
+
* @type {Map<string, Promise<void>>}
|
|
145
|
+
*/
|
|
146
|
+
#ensuredStreams = new Map()
|
|
138
147
|
|
|
139
148
|
/**
|
|
140
149
|
* @param {MegaJobQueueOptions} options
|
|
@@ -164,6 +173,11 @@ export class MegaJobQueue extends EventEmitter {
|
|
|
164
173
|
if (typeof runTimeoutMs !== 'number' || !Number.isInteger(runTimeoutMs) || runTimeoutMs < 0) {
|
|
165
174
|
throw new TypeError(`MegaJobQueue: runTimeoutMs must be an integer >= 0 (0 = unlimited). Got: ${runTimeoutMs}.`)
|
|
166
175
|
}
|
|
176
|
+
// heartbeatMs: working() lease 갱신 주기. ackWaitMs 이상이면 갱신이 늦어 정상 처리 중 lease 가
|
|
177
|
+
// 만료돼 중복 재전달(at-least-once 폭증)되고, 0 이하면 setInterval 1ms 클램프로 working() 플러딩.
|
|
178
|
+
if (heartbeatMs !== undefined && (typeof heartbeatMs !== 'number' || !Number.isInteger(heartbeatMs) || heartbeatMs < 1 || heartbeatMs >= ackWaitMs)) {
|
|
179
|
+
throw new TypeError(`MegaJobQueue: heartbeatMs must be a positive integer < ackWaitMs (${ackWaitMs}). Got: ${heartbeatMs}.`)
|
|
180
|
+
}
|
|
167
181
|
this.#nc = nc
|
|
168
182
|
this.#ackWaitMs = ackWaitMs
|
|
169
183
|
this.#maxDeliver = maxDeliver
|
|
@@ -336,16 +350,30 @@ export class MegaJobQueue extends EventEmitter {
|
|
|
336
350
|
* @param {typeof MegaJob} JobClass @returns {Promise<void>}
|
|
337
351
|
*/
|
|
338
352
|
async ensureStream(JobClass) {
|
|
339
|
-
await this.ensureReady()
|
|
340
353
|
const subject = this.#assertJobSubject(JobClass)
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
354
|
+
// subject 별 1회만 실제 확인(RPC ×2) — 이후 호출은 캐시된 Promise 를 기다린다(동시 호출도 1회).
|
|
355
|
+
const cached = this.#ensuredStreams.get(subject)
|
|
356
|
+
if (cached) return cached
|
|
357
|
+
const ensured = (async () => {
|
|
358
|
+
await this.ensureReady()
|
|
359
|
+
const nats = /** @type {typeof import('nats')} */ (this.#nats)
|
|
360
|
+
await this.#ensureStreamExists(this.#workStreamName(subject), [subject], nats.RetentionPolicy.Workqueue)
|
|
361
|
+
// DLQ 스트림에 한도를 건다(무한 적재 방지, ADR-134). dlqMaxAgeMs=0 이면 max_age 미지정(무제한),
|
|
362
|
+
// dlqMaxBytes 미지정이면 max_bytes 미지정.
|
|
363
|
+
await this.#ensureStreamExists(this.#dlqStreamName(subject), [`${subject}.dlq`], nats.RetentionPolicy.Limits, {
|
|
364
|
+
maxAgeMs: this.#dlqMaxAgeMs,
|
|
365
|
+
maxBytes: this.#dlqMaxBytes,
|
|
366
|
+
})
|
|
367
|
+
})()
|
|
368
|
+
this.#ensuredStreams.set(subject, ensured)
|
|
369
|
+
try {
|
|
370
|
+
await ensured
|
|
371
|
+
} catch (err) {
|
|
372
|
+
// 실패는 캐시하지 않는다 — NATS 일시 장애 후 다음 enqueue/consume 이 재시도할 수 있게.
|
|
373
|
+
this.#ensuredStreams.delete(subject)
|
|
374
|
+
throw err
|
|
375
|
+
}
|
|
376
|
+
return ensured
|
|
349
377
|
}
|
|
350
378
|
|
|
351
379
|
/**
|
|
@@ -399,14 +427,26 @@ export class MegaJobQueue extends EventEmitter {
|
|
|
399
427
|
}
|
|
400
428
|
|
|
401
429
|
/**
|
|
402
|
-
* 잡을 큐에 넣는다(JetStream publish — 서버에 영속 저장). 스트림이 없으면
|
|
403
|
-
*
|
|
430
|
+
* 잡을 큐에 넣는다(JetStream publish — 서버에 영속 저장). 스트림이 없으면 만든다(subject 별 1회 확인 후 캐시).
|
|
431
|
+
*
|
|
432
|
+
* @param {typeof MegaJob} JobClass @param {any} payload
|
|
433
|
+
* @param {{ msgID?: string }} [opts] - `msgID` = JetStream `Nats-Msg-Id` dedup 키(옵트인). 스트림
|
|
434
|
+
* duplicate window(NATS 기본 2분 — 운영자가 NATS CLI 로 스트림별 조정) 안의 같은 msgID 재발행은
|
|
435
|
+
* 적재되지 않고 `duplicate: true` 로 반환된다. 비즈니스 멱등키(주문 ID 등)를 권장. 미지정 시
|
|
436
|
+
* dedup 없음 — `duplicate` 는 항상 false.
|
|
437
|
+
* @returns {Promise<{ seq: number, stream: string, duplicate: boolean }>}
|
|
404
438
|
*/
|
|
405
|
-
async enqueue(JobClass, payload) {
|
|
439
|
+
async enqueue(JobClass, payload, { msgID } = /** @type {{ msgID?: string }} */ ({})) {
|
|
440
|
+
if (msgID !== undefined && (typeof msgID !== 'string' || msgID.length === 0)) {
|
|
441
|
+
throw new TypeError(`MegaJobQueue.enqueue: msgID, if set, must be a non-empty string. Got: ${msgID}.`)
|
|
442
|
+
}
|
|
406
443
|
await this.ensureStream(JobClass)
|
|
407
444
|
const subject = /** @type {string} */ (JobClass.subject)
|
|
408
445
|
const js = /** @type {import('nats').JetStreamClient} */ (this.#js)
|
|
409
|
-
|
|
446
|
+
// msgID(옵트인) = JetStream `Nats-Msg-Id` dedup — 스트림 duplicate window(NATS 기본 2분) 안의
|
|
447
|
+
// 같은 msgID 재발행은 적재되지 않고 ack.duplicate=true 로 돌아온다(producer 중복: 재시도 enqueue·
|
|
448
|
+
// 이중 클릭 방어). 미지정 시 dedup 없음(기존 동작, 비용 0) — duplicate 는 그때 항상 false.
|
|
449
|
+
const ack = await js.publish(subject, this.#encode(payload), msgID !== undefined ? { msgID } : undefined)
|
|
410
450
|
// dispatch 는 publish(부작용) 완료 후 발화 — 리스너 throw 가 반환값/흐름을 오염시키지 않게 safeEmit(L-3).
|
|
411
451
|
this.#safeEmit('dispatch', { subject, seq: ack.seq, stream: ack.stream })
|
|
412
452
|
return { seq: ack.seq, stream: ack.stream, duplicate: ack.duplicate }
|
package/src/lib/mega-job.js
CHANGED
|
@@ -73,7 +73,14 @@ export class MegaJob {
|
|
|
73
73
|
/** @type {string|undefined} bus 별명(`ctx.bus(alias)`). 워커 배선이 nc 해석에 사용. */
|
|
74
74
|
static bus = undefined
|
|
75
75
|
|
|
76
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* @type {number} 동시 처리 메시지 수. 기본 1(순차·안전).
|
|
78
|
+
*
|
|
79
|
+
* ⚠️ 이 값은 durable consumer 의 `max_ack_pending` 으로 들어가므로 **워커 그룹(같은 subject 를
|
|
80
|
+
* 소비하는 모든 인스턴스) 전체의 합산 in-flight 상한**이다 — `mega worker` 인스턴스를 늘려도
|
|
81
|
+
* 합산 동시 처리는 이 값을 넘지 못한다. 처리량을 늘리려면 인스턴스 증설과 **함께** concurrency 를
|
|
82
|
+
* 키워야 한다(실측: c=1→c=32 에서 처리량 ~10배).
|
|
83
|
+
*/
|
|
77
84
|
static concurrency = 1
|
|
78
85
|
|
|
79
86
|
/** @type {number} run 실패 시 **추가** 재시도 횟수(첫 시도 제외). 기본 3(OQ-012). */
|
package/src/lib/mega-metrics.js
CHANGED
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
* @see https://opentelemetry.io/docs/specs/otel/metrics/ (OTel metrics)
|
|
37
37
|
* @see https://prometheus.io/docs/instrumenting/exposition_formats/ (Prometheus 텍스트 포맷)
|
|
38
38
|
*/
|
|
39
|
+
import { getHeapStatistics } from 'node:v8'
|
|
39
40
|
import { MeterProvider } from '@opentelemetry/sdk-metrics'
|
|
40
41
|
import { PrometheusExporter, PrometheusSerializer } from '@opentelemetry/exporter-prometheus'
|
|
41
42
|
import { buildOtelResource } from './otel-resource.js'
|
|
@@ -547,7 +548,7 @@ function buildInstruments(meter) {
|
|
|
547
548
|
*/
|
|
548
549
|
function registerSystemGauges(meter) {
|
|
549
550
|
const memory = meter.createObservableGauge('mega_process_memory_bytes', {
|
|
550
|
-
description: '프로세스 메모리 사용량(바이트) — kind=rss|heap_used|heap_total|external 라벨.',
|
|
551
|
+
description: '프로세스 메모리 사용량(바이트) — kind=rss|heap_used|heap_total|external|array_buffers 라벨.',
|
|
551
552
|
unit: 'By',
|
|
552
553
|
})
|
|
553
554
|
memory.addCallback((result) => {
|
|
@@ -556,6 +557,32 @@ function registerSystemGauges(meter) {
|
|
|
556
557
|
result.observe(mu.heapUsed, { kind: 'heap_used' })
|
|
557
558
|
result.observe(mu.heapTotal, { kind: 'heap_total' })
|
|
558
559
|
result.observe(mu.external, { kind: 'external' })
|
|
560
|
+
// arrayBuffers 분리 노출(ADR-215, G5 M-5) — mongo driver 등 ArrayBuffer 상주분을 heap 과 구분해
|
|
561
|
+
// "RSS 만 보는" 관측 함정(burst 후 V8 페이지 미반환 = 정상 평형)을 운영자가 분해해 읽을 수 있게.
|
|
562
|
+
result.observe(mu.arrayBuffers ?? 0, { kind: 'array_buffers' })
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
// V8 힙 통계(ADR-215, G5 M-5) — heap_size_limit(OOM 한계)·total_available_size(여유)·physical 등
|
|
566
|
+
// process.memoryUsage 가 못 보여주는 V8 내부 수위. kind 는 고정 enum 이라 카디널리티 안전.
|
|
567
|
+
const V8_HEAP_KINDS = Object.freeze([
|
|
568
|
+
'total_heap_size',
|
|
569
|
+
'total_physical_size',
|
|
570
|
+
'total_available_size',
|
|
571
|
+
'used_heap_size',
|
|
572
|
+
'heap_size_limit',
|
|
573
|
+
'malloced_memory',
|
|
574
|
+
'peak_malloced_memory',
|
|
575
|
+
'external_memory',
|
|
576
|
+
])
|
|
577
|
+
const v8Heap = meter.createObservableGauge('mega_v8_heap_bytes', {
|
|
578
|
+
description: `V8 힙 통계(바이트, v8.getHeapStatistics) — kind=${V8_HEAP_KINDS.join('|')} 라벨.`,
|
|
579
|
+
unit: 'By',
|
|
580
|
+
})
|
|
581
|
+
v8Heap.addCallback((result) => {
|
|
582
|
+
const hs = /** @type {Record<string, number>} */ (/** @type {unknown} */ (getHeapStatistics()))
|
|
583
|
+
for (const kind of V8_HEAP_KINDS) {
|
|
584
|
+
if (typeof hs[kind] === 'number') result.observe(hs[kind], { kind })
|
|
585
|
+
}
|
|
559
586
|
})
|
|
560
587
|
|
|
561
588
|
const uptime = meter.createObservableGauge('mega_process_uptime_seconds', {
|
package/src/lib/mega-plugin.js
CHANGED
|
@@ -62,12 +62,12 @@ const LIFECYCLE_EVENTS = /** @type {const} */ (['beforeBoot', 'afterBoot', 'befo
|
|
|
62
62
|
* @property {string} [description] - `mega help` 가 병기하는 한 줄 설명(선택).
|
|
63
63
|
*/
|
|
64
64
|
|
|
65
|
-
/** 빌트인 generator
|
|
65
|
+
/** 빌트인 generator 이름 — 플러그인 scaffold 가 점유 금지(빌트인이 우선이라 silent shadow 가 됨).
|
|
66
66
|
* cli/generators 의 `GENERATOR_KINDS` 와 동일해야 한다(레이어 역전 회피를 위해 미러 — 동기화는
|
|
67
67
|
* mega-plugin 단위 테스트가 GENERATOR_KINDS 와 집합 비교로 강제한다). */
|
|
68
68
|
export const RESERVED_GENERATOR_NAMES = new Set([
|
|
69
69
|
'app', 'controller', 'channel', 'service', 'model', 'middleware', 'route',
|
|
70
|
-
'schedule', 'job', 'worker', 'locale', 'adapter', 'migration',
|
|
70
|
+
'schedule', 'job', 'worker', 'locale', 'adapter', 'migration', 'adr',
|
|
71
71
|
])
|
|
72
72
|
|
|
73
73
|
/**
|
package/src/lib/mega-worker.js
CHANGED
|
@@ -16,7 +16,9 @@
|
|
|
16
16
|
* 프로세스 메모리 격리), process=`child_process.fork`(완전 격리, 더 무거움). 둘 다 node 빌트인(의존성 0).
|
|
17
17
|
* - **풀 정책** — `static poolSize`(디폴트 `os.cpus().length - 1`, 최소 1). 작업 큐 + 가용 워커에 디스패치.
|
|
18
18
|
* - **crash 자동 재시작** — 워커가 예기치 않게 죽으면 in-flight task 를 `worker.crashed` 로 reject 하고
|
|
19
|
-
* `static maxRestarts`(디폴트 5)
|
|
19
|
+
* `static maxRestarts`(디폴트 5)/`static restartWindowMs`(디폴트 60s) **슬라이딩 윈도우** 안에서만
|
|
20
|
+
* 교체 워커를 띄운다 — 윈도우 내 한도 초과 = crash-loop 으로 보고 포기(MegaCluster 정책 통일).
|
|
21
|
+
* 산발 crash(윈도우 밖)는 한도를 소모하지 않아 장수 프로세스의 풀이 영구 축소되지 않는다.
|
|
20
22
|
* - **graceful shutdown** — `stop()`: 새 `run()` 거부 + 큐 대기분 `worker.stopped` reject + in-flight 완료
|
|
21
23
|
* 대기(allSettled) → 워커 terminate. `MegaShutdown` 통합은 workers-manager 가 배선.
|
|
22
24
|
*
|
|
@@ -57,6 +59,14 @@ const DEFAULT_POOL_SIZE = Math.max(1, os.cpus().length - 1)
|
|
|
57
59
|
/** crash 시 풀 전체에서 허용하는 교체 워커 재시작 총량 디폴트. */
|
|
58
60
|
const DEFAULT_MAX_RESTARTS = 5
|
|
59
61
|
|
|
62
|
+
/**
|
|
63
|
+
* crash 교체 판정 윈도우 기본값(ms) — 60초. `maxRestarts` 는 이 윈도우 **안의** 교체 횟수 상한이다
|
|
64
|
+
* (수명 누적이 아님). 누적 카운터면 몇 주 간격의 산발 crash 도 한도를 소모해 장수 프로세스의 풀이
|
|
65
|
+
* 영구 축소되다 `pool_exhausted` 로 죽는다 — crash-loop(즉시 연속 사망)만 막으면 되는 안전망이므로
|
|
66
|
+
* 시간 윈도우 판정이 맞다(MegaCluster 의 rapid-crash 슬라이딩 윈도우와 정책 통일).
|
|
67
|
+
*/
|
|
68
|
+
const DEFAULT_RESTART_WINDOW_MS = 60_000
|
|
69
|
+
|
|
60
70
|
/**
|
|
61
71
|
* @typedef {object} WorkerHandle - 풀 안의 워커 1개 핸들.
|
|
62
72
|
* @property {number} id - 핸들 식별자(로그/디버그).
|
|
@@ -94,7 +104,8 @@ export class MegaWorker extends EventEmitter {
|
|
|
94
104
|
/** @type {boolean} */ #stopping = false
|
|
95
105
|
/** @type {number} */ #nextTaskId = 1
|
|
96
106
|
/** @type {number} */ #nextHandleId = 1
|
|
97
|
-
/** @type {number} crash 교체
|
|
107
|
+
/** @type {number[]} crash 교체 시각(epoch ms) 슬라이딩 윈도우 — restartWindowMs 밖은 판정 시 제거. */
|
|
108
|
+
#restartTimes = []
|
|
98
109
|
/** @type {number} 진행 중인 교체 spawn 수(일시적 풀 0 상태에서 run 을 큐잉할지 판단). */ #pendingRespawns = 0
|
|
99
110
|
|
|
100
111
|
/**
|
|
@@ -130,12 +141,18 @@ export class MegaWorker extends EventEmitter {
|
|
|
130
141
|
return typeof p === 'number' ? p : DEFAULT_POOL_SIZE
|
|
131
142
|
}
|
|
132
143
|
|
|
133
|
-
/** @returns {number} crash 교체 허용
|
|
144
|
+
/** @returns {number} 윈도우({@link MegaWorker#restartWindowMs}) 내 crash 교체 허용 횟수. */
|
|
134
145
|
get maxRestarts() {
|
|
135
146
|
const r = /** @type {any} */ (this.constructor).maxRestarts
|
|
136
147
|
return Number.isInteger(r) && r >= 0 ? r : DEFAULT_MAX_RESTARTS
|
|
137
148
|
}
|
|
138
149
|
|
|
150
|
+
/** @returns {number} crash 교체 판정 슬라이딩 윈도우(ms). `static restartWindowMs` 로 조정. */
|
|
151
|
+
get restartWindowMs() {
|
|
152
|
+
const w = /** @type {any} */ (this.constructor).restartWindowMs
|
|
153
|
+
return Number.isInteger(w) && w > 0 ? w : DEFAULT_RESTART_WINDOW_MS
|
|
154
|
+
}
|
|
155
|
+
|
|
139
156
|
/** @returns {boolean} start() 후 stop() 전이면 true. */
|
|
140
157
|
get isStarted() {
|
|
141
158
|
return this.#started
|
|
@@ -511,8 +528,14 @@ export class MegaWorker extends EventEmitter {
|
|
|
511
528
|
* @returns {void}
|
|
512
529
|
*/
|
|
513
530
|
#scheduleRespawn() {
|
|
514
|
-
if (this.#stopping
|
|
515
|
-
|
|
531
|
+
if (this.#stopping) return
|
|
532
|
+
// 슬라이딩 윈도우 판정 — 윈도우 밖 기록을 비우고 남은 횟수가 한도면 crash-loop 으로 보고 포기.
|
|
533
|
+
// (수명 누적이 아니라서 산발 crash 는 풀을 영구 축소시키지 않는다 — MegaCluster 정책 통일.)
|
|
534
|
+
const now = Date.now()
|
|
535
|
+
const windowStart = now - this.restartWindowMs
|
|
536
|
+
this.#restartTimes = this.#restartTimes.filter((t) => t >= windowStart)
|
|
537
|
+
if (this.#restartTimes.length >= this.maxRestarts) return
|
|
538
|
+
this.#restartTimes.push(now)
|
|
516
539
|
this.#pendingRespawns++
|
|
517
540
|
this.#spawn()
|
|
518
541
|
.then((h) => {
|
package/src/lib/ws-hub.js
CHANGED
|
@@ -103,7 +103,7 @@ export class MegaWsHub {
|
|
|
103
103
|
this._wss = null
|
|
104
104
|
/** heartbeat liveness 체크 interval (M3). @type {ReturnType<typeof setInterval> | null} */
|
|
105
105
|
this._livenessTimer = null
|
|
106
|
-
/** 등록된 bridge 연결. connId → { socket, lastSeen, protocolVersion }. @type {Map<string, { socket: import('ws').WebSocket, lastSeen: number, protocolVersion?: number }>} */
|
|
106
|
+
/** 등록된 bridge 연결. connId → { socket, lastSeen, protocolVersion, presenceFanout }. @type {Map<string, { socket: import('ws').WebSocket, lastSeen: number, protocolVersion?: number, presenceFanout?: boolean }>} */
|
|
107
107
|
this._bridges = new Map()
|
|
108
108
|
/** presence. sessionId → { bridgeConnId, userId, channels:Set, metadata }. @type {Map<string, { bridgeConnId: string, userId: string, channels: Set<string>, metadata: Object }>} */
|
|
109
109
|
this._sessions = new Map()
|
|
@@ -111,6 +111,13 @@ export class MegaWsHub {
|
|
|
111
111
|
this._channelSessions = new Map()
|
|
112
112
|
/** userId → sessionId 집합 (DIRECT fan-out, ADR-035). @type {Map<string, Set<string>>} */
|
|
113
113
|
this._userSessions = new Map()
|
|
114
|
+
/**
|
|
115
|
+
* 역인덱스: channel → (bridgeConnId → 그 bridge 의 채널 내 세션 수). BROADCAST 의 bridge 선정을
|
|
116
|
+
* 세션 수 무관 O(bridge 수)로 만든다 — 멤버 10k 채널에서 메시지당 수백 µs 가 µs 대로 떨어진다.
|
|
117
|
+
* `_addSession`/`_removeSession` 이 증분 유지(카운트 0 → 키 제거), 메모리는 채널×bridge 수준.
|
|
118
|
+
* @type {Map<string, Map<string, number>>}
|
|
119
|
+
*/
|
|
120
|
+
this._channelBridges = new Map()
|
|
114
121
|
}
|
|
115
122
|
|
|
116
123
|
/** hub 식별자. */
|
|
@@ -170,6 +177,20 @@ export class MegaWsHub {
|
|
|
170
177
|
let isRegistered = false
|
|
171
178
|
this._log?.debug?.({ connId }, 'ws-hub connection (awaiting register)')
|
|
172
179
|
|
|
180
|
+
// 미등록 연결 register 타임아웃 — liveness(_checkLiveness)는 등록된 bridge 만 스캔하므로,
|
|
181
|
+
// REGISTER 를 안 보내는 연결(오설정 bridge·포트 스캐너·half-open)은 이 타이머가 없으면
|
|
182
|
+
// fd/메모리로 영구 잔존한다. heartbeatMs 안에 등록 못 하면 1008 로 닫는다(fail-closed).
|
|
183
|
+
const registerTimer = setTimeout(() => {
|
|
184
|
+
if (isRegistered) return
|
|
185
|
+
this._log?.warn?.({ connId, timeoutMs: this._heartbeatMs }, 'ws-hub register timeout — closing unregistered connection (1008)')
|
|
186
|
+
try {
|
|
187
|
+
socket.close(1008, 'register timeout')
|
|
188
|
+
} catch (err) {
|
|
189
|
+
this._log?.debug?.({ err, connId }, 'ws-hub register-timeout close failed (already closing)')
|
|
190
|
+
}
|
|
191
|
+
}, this._heartbeatMs)
|
|
192
|
+
registerTimer.unref?.()
|
|
193
|
+
|
|
173
194
|
socket.on('message', (raw) => {
|
|
174
195
|
const frame = Array.isArray(raw) ? Buffer.concat(raw).toString('utf8') : raw.toString('utf8')
|
|
175
196
|
let msg
|
|
@@ -196,12 +217,14 @@ export class MegaWsHub {
|
|
|
196
217
|
return
|
|
197
218
|
}
|
|
198
219
|
isRegistered = this._handleRegister(connId, socket, msg)
|
|
220
|
+
if (isRegistered) clearTimeout(registerTimer)
|
|
199
221
|
return
|
|
200
222
|
}
|
|
201
223
|
this._route(connId, socket, msg)
|
|
202
224
|
})
|
|
203
225
|
|
|
204
226
|
socket.on('close', () => {
|
|
227
|
+
clearTimeout(registerTimer) // 미등록 타임아웃 정리(등록 전 절단·정상 종료 공통).
|
|
205
228
|
if (isRegistered) this._handleBridgeGone(connId)
|
|
206
229
|
this._log?.debug?.({ connId }, 'ws-hub connection closed')
|
|
207
230
|
})
|
|
@@ -234,7 +257,10 @@ export class MegaWsHub {
|
|
|
234
257
|
const protocolVersion = hasVersionRequest
|
|
235
258
|
? negotiateHubProtocolVersion(payload.protocolVersion)
|
|
236
259
|
: HUB_PROTOCOL_VERSION
|
|
237
|
-
|
|
260
|
+
// presence fan-out 은 옵트인 — roster 가 redis 로 분리(ADR-177)된 뒤 bridge 는 JOIN/LEAVE/METADATA
|
|
261
|
+
// 를 no-op 으로 버리므로, `presence-fanout` capability 를 선언한 bridge 에만 보낸다(죽은 트래픽 제거).
|
|
262
|
+
const presenceFanout = Array.isArray(payload.capabilities) && payload.capabilities.includes('presence-fanout')
|
|
263
|
+
this._bridges.set(connId, { socket, lastSeen: Date.now(), protocolVersion, presenceFanout })
|
|
238
264
|
this._log?.info?.({ connId, instanceId: payload.instanceId, hubId: this._hubId, protocolVersion }, 'ws-hub bridge registered')
|
|
239
265
|
this._safeSend(socket, createHubMessage({
|
|
240
266
|
type: T.REGISTER_OK,
|
|
@@ -262,12 +288,12 @@ export class MegaWsHub {
|
|
|
262
288
|
switch (type) {
|
|
263
289
|
case T.JOIN:
|
|
264
290
|
this._addSession(connId, payload)
|
|
265
|
-
// 클러스터 presence 공유 —
|
|
266
|
-
this.
|
|
291
|
+
// 클러스터 presence 공유 — `presence-fanout` 선언 bridge 에만 (07 §2 + ADR-177 후 죽은 트래픽 제거).
|
|
292
|
+
this._fanOutPresence(connId, msg)
|
|
267
293
|
break
|
|
268
294
|
case T.LEAVE: {
|
|
269
295
|
const removed = this._removeSession(payload.sessionId)
|
|
270
|
-
if (removed) this.
|
|
296
|
+
if (removed) this._fanOutPresence(connId, msg)
|
|
271
297
|
break
|
|
272
298
|
}
|
|
273
299
|
case T.BULK_LEAVE: {
|
|
@@ -289,7 +315,7 @@ export class MegaWsHub {
|
|
|
289
315
|
const session = this._sessions.get(payload.sessionId)
|
|
290
316
|
if (session) {
|
|
291
317
|
session.metadata = payload.metadata
|
|
292
|
-
this.
|
|
318
|
+
this._fanOutPresence(connId, msg) // presence 메타 동기화 — 옵트인 bridge 만.
|
|
293
319
|
}
|
|
294
320
|
break
|
|
295
321
|
}
|
|
@@ -360,6 +386,7 @@ export class MegaWsHub {
|
|
|
360
386
|
this._channelSessions.set(ch, set)
|
|
361
387
|
}
|
|
362
388
|
set.add(entry.sessionId)
|
|
389
|
+
this._bumpChannelBridge(ch, connId, +1)
|
|
363
390
|
}
|
|
364
391
|
let uset = this._userSessions.get(entry.userId)
|
|
365
392
|
if (!uset) {
|
|
@@ -369,6 +396,27 @@ export class MegaWsHub {
|
|
|
369
396
|
uset.add(entry.sessionId)
|
|
370
397
|
}
|
|
371
398
|
|
|
399
|
+
/**
|
|
400
|
+
* channel→bridge 역인덱스 증분 갱신. 카운트가 0 이 되면 키를 지워 인덱스가 stale 하지 않게 한다.
|
|
401
|
+
* @param {string} channel @param {string} connId @param {1|-1} delta
|
|
402
|
+
* @private
|
|
403
|
+
*/
|
|
404
|
+
_bumpChannelBridge(channel, connId, delta) {
|
|
405
|
+
let counts = this._channelBridges.get(channel)
|
|
406
|
+
if (!counts) {
|
|
407
|
+
if (delta < 0) return // 제거인데 인덱스 없음 — 정합상 없을 일이지만 방어.
|
|
408
|
+
counts = new Map()
|
|
409
|
+
this._channelBridges.set(channel, counts)
|
|
410
|
+
}
|
|
411
|
+
const next = (counts.get(connId) ?? 0) + delta
|
|
412
|
+
if (next > 0) {
|
|
413
|
+
counts.set(connId, next)
|
|
414
|
+
} else {
|
|
415
|
+
counts.delete(connId)
|
|
416
|
+
if (counts.size === 0) this._channelBridges.delete(channel)
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
372
420
|
/**
|
|
373
421
|
* presence 에서 세션 제거. 빈 인덱스는 정리.
|
|
374
422
|
* @param {string} sessionId
|
|
@@ -385,6 +433,7 @@ export class MegaWsHub {
|
|
|
385
433
|
set.delete(sessionId)
|
|
386
434
|
if (set.size === 0) this._channelSessions.delete(ch)
|
|
387
435
|
}
|
|
436
|
+
this._bumpChannelBridge(ch, session.bridgeConnId, -1)
|
|
388
437
|
}
|
|
389
438
|
const uset = this._userSessions.get(session.userId)
|
|
390
439
|
if (uset) {
|
|
@@ -415,7 +464,8 @@ export class MegaWsHub {
|
|
|
415
464
|
}
|
|
416
465
|
|
|
417
466
|
/**
|
|
418
|
-
* origin 을 제외한 모든 등록 bridge 로
|
|
467
|
+
* origin 을 제외한 모든 등록 bridge 로 송신. envelope 는 1회만 직렬화(L5).
|
|
468
|
+
* BULK_LEAVE(bridge-gone 정리 통지)처럼 전 bridge 가 받아야 하는 통지에 쓴다.
|
|
419
469
|
* @param {string} exceptConnId
|
|
420
470
|
* @param {Object} envelope
|
|
421
471
|
* @private
|
|
@@ -427,6 +477,25 @@ export class MegaWsHub {
|
|
|
427
477
|
}
|
|
428
478
|
}
|
|
429
479
|
|
|
480
|
+
/**
|
|
481
|
+
* presence(JOIN/LEAVE/METADATA) fan-out — `presence-fanout` capability 를 선언한 bridge 에만 송신.
|
|
482
|
+
* roster 가 redis 로 분리(ADR-177)된 뒤 표준 bridge 는 이 타입들을 no-op 으로 버리므로, 옵트인하지
|
|
483
|
+
* 않은 bridge 에는 보내지 않는다 — 세션 churn × bridge 수 만큼의 죽은 프레임을 제거한다.
|
|
484
|
+
* 구버전 bridge 호환: hub presence 를 실제로 쓰려는 bridge 는 capability 로 명시 선언한다.
|
|
485
|
+
* @param {string} exceptConnId
|
|
486
|
+
* @param {Object} envelope
|
|
487
|
+
* @private
|
|
488
|
+
*/
|
|
489
|
+
_fanOutPresence(exceptConnId, envelope) {
|
|
490
|
+
/** @type {string | null} 첫 대상에서 1회 직렬화(L5) — 대상 0 이면 직렬화도 안 함. */
|
|
491
|
+
let data = null
|
|
492
|
+
for (const [connId, bridge] of this._bridges) {
|
|
493
|
+
if (connId === exceptConnId || !(/** @type {any} */ (bridge).presenceFanout)) continue
|
|
494
|
+
if (data === null) data = JSON.stringify(envelope)
|
|
495
|
+
this._sendSerialized(bridge.socket, data)
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
430
499
|
/**
|
|
431
500
|
* 한 채널의 세션을 가진 bridge 들로 fan-out (origin 제외, 중복 bridge 1회). 직렬화 1회(L5).
|
|
432
501
|
*
|
|
@@ -441,12 +510,24 @@ export class MegaWsHub {
|
|
|
441
510
|
* @private
|
|
442
511
|
*/
|
|
443
512
|
_fanOutChannel(channel, exceptConnId, envelope, exceptSessionIds) {
|
|
513
|
+
const except = Array.isArray(exceptSessionIds) && exceptSessionIds.length > 0 ? new Set(exceptSessionIds) : null
|
|
514
|
+
if (!except) {
|
|
515
|
+
// 일반 경로(대부분의 메시지): channel→bridge 역인덱스로 선정이 O(bridge 수) — 세션 수 무관.
|
|
516
|
+
const counts = this._channelBridges.get(channel)
|
|
517
|
+
if (!counts || counts.size === 0) return
|
|
518
|
+
const data = JSON.stringify(envelope)
|
|
519
|
+
for (const connId of counts.keys()) {
|
|
520
|
+
if (connId !== exceptConnId) this._sendTo(connId, data)
|
|
521
|
+
}
|
|
522
|
+
return
|
|
523
|
+
}
|
|
524
|
+
// exceptSessionIds 경로(드묾): "제외 세션만 가진 bridge 통째 스킵" 판정에 세션 단위 정보가
|
|
525
|
+
// 필요하므로 기존 멤버 순회를 유지한다.
|
|
444
526
|
const sids = this._channelSessions.get(channel)
|
|
445
527
|
if (!sids) return
|
|
446
|
-
const except = Array.isArray(exceptSessionIds) && exceptSessionIds.length > 0 ? new Set(exceptSessionIds) : null
|
|
447
528
|
const targets = new Set()
|
|
448
529
|
for (const sid of sids) {
|
|
449
|
-
if (except
|
|
530
|
+
if (except.has(sid)) continue // 제외 세션은 bridge 선정에 기여하지 않음
|
|
450
531
|
const session = this._sessions.get(sid)
|
|
451
532
|
if (session && session.bridgeConnId !== exceptConnId) targets.add(session.bridgeConnId)
|
|
452
533
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
---
|
|
2
|
+
number: {{number}}
|
|
3
|
+
title: {{title}}
|
|
4
|
+
date: {{date}}
|
|
5
|
+
status: accepted
|
|
6
|
+
supersedes: []
|
|
7
|
+
superseded_by: null
|
|
8
|
+
tags: []
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# ADR-{{number}}: {{title}}
|
|
12
|
+
|
|
13
|
+
## Context
|
|
14
|
+
|
|
15
|
+
(결정이 필요했던 배경 — 문제·제약·관련 ADR 링크)
|
|
16
|
+
|
|
17
|
+
## Decision
|
|
18
|
+
|
|
19
|
+
(무엇을 결정했는가 — 한 문장 요지 + 세부 사항)
|
|
20
|
+
|
|
21
|
+
## Consequences
|
|
22
|
+
|
|
23
|
+
(트레이드오프·후속 작업·영향 파일·검증 결과)
|