mega-framework 0.1.5 → 0.1.7
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/bin/mega-ws-hub.js +2 -2
- package/package.json +32 -8
- package/sample/crud/.env +156 -8
- package/sample/crud/.env.example +153 -28
- package/sample/crud/.mega/journal/history/20260612092543-create-users.json +261 -0
- package/sample/crud/.mega/journal/snapshot.json +261 -0
- package/sample/crud/apps/main/controllers/auth-controller.js +22 -14
- package/sample/crud/apps/main/controllers/web-controller.js +7 -5
- package/sample/crud/apps/main/migrations/20260606000001-create-users.js +91 -13
- package/sample/crud/apps/main/migrations/20260606000002-create-boards.js +165 -0
- package/sample/crud/apps/main/migrations/20260606000003-create-logs.js +107 -0
- package/sample/crud/apps/main/models/log-partition-model.js +105 -0
- package/sample/crud/apps/main/models/note-model.js +79 -0
- package/sample/crud/apps/main/models/user-level-model.js +24 -0
- package/sample/crud/apps/main/models/user-model.js +146 -0
- package/sample/crud/apps/main/models/user-type-model.js +21 -0
- package/sample/crud/apps/main/models/wallet-model.js +24 -0
- package/sample/crud/apps/main/routes/users.js +55 -10
- package/sample/crud/apps/main/schedules/log-partition-schedule.js +33 -0
- package/sample/crud/apps/main/services/auth-service.js +39 -24
- package/sample/crud/apps/main/services/log-partition-service.js +101 -0
- package/sample/crud/apps/main/services/note-service.js +6 -6
- package/sample/crud/apps/main/services/redis-demo-service.js +3 -3
- package/sample/crud/apps/main/services/user-service.js +62 -21
- package/sample/crud/apps/main/views/auth/login.ejs +6 -6
- package/sample/crud/apps/main/views/auth/register.ejs +46 -5
- package/sample/crud/apps/main/views/users/edit.ejs +42 -5
- package/sample/crud/apps/main/views/users/list.ejs +6 -2
- package/sample/crud/apps/main/views/users/new.ejs +56 -4
- package/sample/crud/docs/log_partition_design.mm.md +23 -0
- package/sample/crud/mega.config.js +63 -3
- package/sample/crud/package.json +3 -3
- package/sample/crud/scripts/start-ws-hub.sh +2 -2
- package/sample/simple/package.json +2 -2
- package/src/adapters/adapter-manager.js +2 -1
- package/src/adapters/adapter-options.js +30 -0
- package/src/adapters/maria-adapter.js +26 -3
- package/src/adapters/mega-db-adapter.js +7 -1
- package/src/adapters/mongo-adapter.js +19 -1
- package/src/adapters/postgres-adapter.js +25 -2
- package/src/adapters/sqlite-adapter.js +20 -1
- package/src/cli/commands/new.js +13 -3
- package/src/cli/commands/scaffold.js +137 -33
- package/src/cli/generators/index.js +82 -2
- package/src/cli/index.js +478 -104
- package/src/core/ajv-mapper.js +27 -2
- package/src/core/boot.js +485 -237
- package/src/core/cluster-metrics.js +13 -4
- package/src/core/config-validator.js +25 -0
- package/src/core/ctx-builder.js +6 -2
- package/src/core/envelope.js +112 -12
- package/src/core/hub-link.js +65 -4
- package/src/core/i18n.js +11 -1
- package/src/core/index.js +6 -2
- package/src/core/mega-app.js +223 -481
- package/src/core/mega-cluster.js +54 -13
- package/src/core/mega-server.js +40 -9
- package/src/core/migration/dialect-registry.js +107 -0
- package/src/core/migration/dialects/README.md +62 -0
- package/src/core/migration/dialects/maria.js +496 -0
- package/src/core/migration/dialects/mongo.js +824 -0
- package/src/core/migration/dialects/postgres.js +563 -0
- package/src/core/migration/dialects/sqlite.js +476 -0
- package/src/core/migration/differ.js +456 -0
- package/src/core/migration/generate.js +508 -0
- package/src/core/migration/journal.js +167 -0
- package/src/core/migration/model-scan.js +84 -0
- package/src/core/migration/mongo-migration-db.js +97 -0
- package/src/core/migration/schema-builder.js +400 -0
- package/src/core/migration/schema-validator.js +315 -0
- package/src/core/migration-lock.js +205 -0
- package/src/core/migration-runner.js +166 -38
- package/src/core/multipart.js +28 -5
- package/src/core/pipeline.js +129 -0
- package/src/core/router.js +70 -65
- package/src/core/scope-registry.js +0 -1
- package/src/core/security.js +67 -9
- package/src/core/workers-manager.js +12 -1
- package/src/core/ws-cluster.js +10 -3
- package/src/core/ws-message.js +48 -4
- package/src/core/ws-presence.js +624 -0
- package/src/core/ws-roster.js +4 -1
- package/src/core/ws-upgrade.js +118 -12
- package/src/index.js +1 -1
- package/src/lib/hub-protocol.js +29 -0
- package/src/lib/mega-health.js +25 -4
- package/src/lib/mega-job-queue.js +98 -21
- package/src/lib/mega-job.js +29 -0
- package/src/lib/mega-logger.js +1 -1
- package/src/lib/mega-metrics.js +3 -12
- package/src/lib/mega-plugin.js +34 -3
- package/src/lib/mega-schedule.js +40 -22
- package/src/lib/mega-shutdown.js +162 -49
- package/src/lib/mega-tracing.js +66 -19
- package/src/lib/mega-worker.js +5 -1
- package/src/lib/otel-resource.js +36 -0
- package/src/{cli → lib}/ws-hub.js +51 -8
- package/src/models/crud-sql-builder.js +133 -0
- package/src/models/mega-model.js +82 -2
- package/src/models/model-crud.js +483 -0
- package/src/models/mongo-crud.js +285 -0
- package/templates/model/code-mongo.tpl +35 -0
- package/templates/model/code.tpl +15 -1
- package/templates/model/test-mongo.tpl +38 -0
- package/templates/model/test.tpl +4 -0
- package/types/adapters/adapter-manager.d.ts +95 -0
- package/types/adapters/adapter-options.d.ts +91 -0
- package/types/adapters/file-adapter.d.ts +94 -0
- package/types/adapters/file-session-adapter.d.ts +101 -0
- package/types/adapters/index.d.ts +20 -0
- package/types/adapters/maria-adapter.d.ts +115 -0
- package/types/adapters/mega-adapter.d.ts +215 -0
- package/types/adapters/mega-bus-adapter.d.ts +45 -0
- package/types/adapters/mega-cache-adapter.d.ts +47 -0
- package/types/adapters/mega-db-adapter.d.ts +47 -0
- package/types/adapters/mega-lock-adapter.d.ts +62 -0
- package/types/adapters/mega-log-sink-adapter.d.ts +15 -0
- package/types/adapters/mega-session-adapter.d.ts +32 -0
- package/types/adapters/mongo-adapter.d.ts +139 -0
- package/types/adapters/nats-adapter.d.ts +108 -0
- package/types/adapters/postgres-adapter.d.ts +139 -0
- package/types/adapters/redis-adapter.d.ts +70 -0
- package/types/adapters/redis-session-adapter.d.ts +82 -0
- package/types/adapters/redlock-adapter.d.ts +149 -0
- package/types/adapters/registry.d.ts +46 -0
- package/types/adapters/sqlite-adapter.d.ts +106 -0
- package/types/auth/index.d.ts +24 -0
- package/types/cli/commands/console-cmd.d.ts +37 -0
- package/types/cli/commands/new.d.ts +16 -0
- package/types/cli/commands/routes.d.ts +36 -0
- package/types/cli/commands/scaffold.d.ts +78 -0
- package/types/cli/commands/test-cmd.d.ts +14 -0
- package/types/cli/generators/index.d.ts +112 -0
- package/types/cli/index.d.ts +249 -0
- package/types/cli/template-engine.d.ts +40 -0
- package/types/core/ajv-mapper.d.ts +27 -0
- package/types/core/boot.d.ts +233 -0
- package/types/core/cluster-metrics.d.ts +52 -0
- package/types/core/config-loader.d.ts +13 -0
- package/types/core/config-validator.d.ts +30 -0
- package/types/core/ctx-builder.d.ts +80 -0
- package/types/core/envelope.d.ts +79 -0
- package/types/core/error-mapper.d.ts +17 -0
- package/types/core/formbody.d.ts +41 -0
- package/types/core/hub-link.d.ts +264 -0
- package/types/core/i18n.d.ts +178 -0
- package/types/core/index.d.ts +28 -0
- package/types/core/mega-app.d.ts +529 -0
- package/types/core/mega-cluster.d.ts +104 -0
- package/types/core/mega-server.d.ts +91 -0
- package/types/core/mega-service.d.ts +31 -0
- package/types/core/migration/dialect-registry.d.ts +22 -0
- package/types/core/migration/dialects/maria.d.ts +99 -0
- package/types/core/migration/dialects/mongo.d.ts +89 -0
- package/types/core/migration/dialects/postgres.d.ts +117 -0
- package/types/core/migration/dialects/sqlite.d.ts +111 -0
- package/types/core/migration/differ.d.ts +47 -0
- package/types/core/migration/generate.d.ts +56 -0
- package/types/core/migration/journal.d.ts +52 -0
- package/types/core/migration/model-scan.d.ts +19 -0
- package/types/core/migration/mongo-migration-db.d.ts +7 -0
- package/types/core/migration/schema-builder.d.ts +197 -0
- package/types/core/migration/schema-validator.d.ts +20 -0
- package/types/core/migration-lock.d.ts +33 -0
- package/types/core/migration-runner.d.ts +101 -0
- package/types/core/multipart.d.ts +86 -0
- package/types/core/openapi.d.ts +62 -0
- package/types/core/pipeline.d.ts +92 -0
- package/types/core/router.d.ts +159 -0
- package/types/core/routes-loader.d.ts +21 -0
- package/types/core/scope-registry.d.ts +14 -0
- package/types/core/security.d.ts +77 -0
- package/types/core/services-loader.d.ts +27 -0
- package/types/core/session-cleanup-schedule.d.ts +19 -0
- package/types/core/session-store.d.ts +18 -0
- package/types/core/session.d.ts +77 -0
- package/types/core/static-assets.d.ts +73 -0
- package/types/core/template.d.ts +106 -0
- package/types/core/workers-manager.d.ts +79 -0
- package/types/core/ws-cluster.d.ts +208 -0
- package/types/core/ws-compression.d.ts +112 -0
- package/types/core/ws-controller.d.ts +65 -0
- package/types/core/ws-message.d.ts +106 -0
- package/types/core/ws-presence.d.ts +273 -0
- package/types/core/ws-roster.d.ts +96 -0
- package/types/core/ws-upgrade.d.ts +231 -0
- package/types/errors/config-error.d.ts +10 -0
- package/types/errors/http-errors.d.ts +120 -0
- package/types/errors/index.d.ts +3 -0
- package/types/errors/mega-error.d.ts +32 -0
- package/types/index.d.ts +39 -0
- package/types/lib/asp/config.d.ts +49 -0
- package/types/lib/asp/crypto.d.ts +43 -0
- package/types/lib/asp/errors.d.ts +30 -0
- package/types/lib/asp/nonce-cache.d.ts +52 -0
- package/types/lib/asp/plugin.d.ts +30 -0
- package/types/lib/asp/ws-terminator.d.ts +45 -0
- package/types/lib/env-mapper.d.ts +14 -0
- package/types/lib/hub-protocol.d.ts +106 -0
- package/types/lib/index.d.ts +22 -0
- package/types/lib/logger/telegram-core.d.ts +104 -0
- package/types/lib/logger/telegram-transport.d.ts +45 -0
- package/types/lib/mega-brute-force.d.ts +66 -0
- package/types/lib/mega-circuit-breaker.d.ts +241 -0
- package/types/lib/mega-cron.d.ts +66 -0
- package/types/lib/mega-hash.d.ts +32 -0
- package/types/lib/mega-health.d.ts +41 -0
- package/types/lib/mega-job-queue.d.ts +176 -0
- package/types/lib/mega-job-worker.d.ts +130 -0
- package/types/lib/mega-job.d.ts +138 -0
- package/types/lib/mega-logger.d.ts +45 -0
- package/types/lib/mega-metrics.d.ts +285 -0
- package/types/lib/mega-plugin.d.ts +245 -0
- package/types/lib/mega-retry.d.ts +85 -0
- package/types/lib/mega-schedule.d.ts +260 -0
- package/types/lib/mega-shutdown.d.ts +135 -0
- package/types/lib/mega-tracing.d.ts +224 -0
- package/types/lib/mega-worker.d.ts +127 -0
- package/types/lib/otel-resource.d.ts +16 -0
- package/types/lib/worker-runner/process-entry.d.ts +1 -0
- package/types/lib/worker-runner/task-dispatch.d.ts +28 -0
- package/types/lib/worker-runner/thread-entry.d.ts +1 -0
- package/types/lib/ws-hub.d.ts +234 -0
- package/types/models/crud-sql-builder.d.ts +48 -0
- package/types/models/index.d.ts +1 -0
- package/types/models/mega-model.d.ts +138 -0
- package/types/models/model-crud.d.ts +82 -0
- package/types/models/mongo-crud.d.ts +59 -0
- package/types/test/index.d.ts +84 -0
- package/.env +0 -127
- package/sample/crud/apps/main/migrations/20260606000002-add-auth-to-users.js +0 -30
- package/sample/crud/apps/main/models/note.js +0 -71
- package/sample/crud/apps/main/models/user.js +0 -86
- package/sample/crud/package-lock.json +0 -5665
- package/sample/crud/yarn.lock +0 -2142
- package/sample/simple/package-lock.json +0 -1851
package/src/core/ws-upgrade.js
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
*
|
|
26
26
|
* @module core/ws-upgrade
|
|
27
27
|
*/
|
|
28
|
-
import { createWsMessage, parseWsMessage, generateMessageId, WS_TYPE_PATTERN } from './ws-message.js'
|
|
28
|
+
import { createWsMessage, parseWsMessage, generateMessageId, WS_TYPE_PATTERN, WS_PROTOCOL_VERSION } from './ws-message.js'
|
|
29
29
|
import { MegaAspDecryptError } from '../lib/asp/errors.js'
|
|
30
30
|
import * as MegaTracing from '../lib/mega-tracing.js'
|
|
31
31
|
import * as MegaMetrics from '../lib/mega-metrics.js'
|
|
@@ -49,9 +49,50 @@ export const CLOSE_CODE_INTERNAL_ERROR = 1011
|
|
|
49
49
|
/** 느린 소비자 백프레셔 close code (RFC 6455 §7.4.1 표준 1013 "Try Again Later", Med). */
|
|
50
50
|
export const CLOSE_CODE_SLOW_CONSUMER = 1013
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* 재배치(requeue) close code — 4503. "이 워커 말고 다른 곳으로 즉시 재연결하라"는 신호로,
|
|
54
|
+
* bridge↔hub 의 drain(4503, `hub-protocol.js` CLOSE_CODE_DRAIN)과 같은 코드를 써서 클라이언트가
|
|
55
|
+
* 한 가지 규약("4503 = 세션 유지한 채 재연결")으로 두 링크를 모두 처리한다. admin-kick 의
|
|
56
|
+
* `requeue: true`(hub.disconnect) 가 사용 — kick(1008, 돌아오지 마라)과 의미가 반대다.
|
|
57
|
+
* HTTP 세션은 보존되므로 재연결 시 `before` 인증이 세션 쿠키로 자연 통과한다(transparent re-route).
|
|
58
|
+
*/
|
|
59
|
+
export const CLOSE_CODE_REQUEUE = 4503
|
|
60
|
+
|
|
52
61
|
/** send 버퍼(bufferedAmount) 기본 상한(바이트). 초과 = 소비자가 ack 를 못 따라옴 → 연결 종료(서버 OOM 방지). */
|
|
53
62
|
export const DEFAULT_MAX_BUFFERED_BYTES = 16 * 1024 * 1024
|
|
54
63
|
|
|
64
|
+
/**
|
|
65
|
+
* 클라↔bridge ping/pong liveness 기본 주기(ms) — 30초. 주기마다 ping 을 보내고 직전 주기의 pong 이
|
|
66
|
+
* 없으면 half-open(상대 사망·네트워크 단절) 으로 보고 terminate 한다 — 좀비 연결이 OS TCP 타임아웃까지
|
|
67
|
+
* `_wsConns`/roster 에 잔존하는 것을 막는다. 라우트 `opts.heartbeatMs` 로 조정, `0` = 끔.
|
|
68
|
+
*/
|
|
69
|
+
export const DEFAULT_WS_HEARTBEAT_MS = 30_000
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* onConnect 완료 전 도착한 프레임의 대기 큐 상한 — 초과 시 연결 종료(1013). onConnect 가 느릴 때
|
|
73
|
+
* 악의적/과속 클라이언트가 큐로 메모리를 채우는 것을 막는다.
|
|
74
|
+
*/
|
|
75
|
+
export const MAX_PENDING_FRAMES_BEFORE_CONNECT = 256
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 라우트 opts 의 `heartbeatMs` 를 검증해 반환한다. 미지정 → 기본 30초, `0` = liveness 끔.
|
|
79
|
+
* 무효값(음수/비정수/비숫자)은 운영 실수 — 조용히 보정하지 않고 warn 로그 후 기본값을 쓴다
|
|
80
|
+
* (연결 구동 시점이라 throw 하면 핸드셰이크 콜백 밖으로 새므로 로그+기본값이 안전한 fail-safe).
|
|
81
|
+
*
|
|
82
|
+
* @param {{ heartbeatMs?: number } | undefined} opts - WS 라우트 opts.
|
|
83
|
+
* @param {any} [log] - 로거(warn).
|
|
84
|
+
* @returns {number} 적용할 주기(ms). 0 = 끔.
|
|
85
|
+
*/
|
|
86
|
+
export function resolveWsHeartbeatMs(opts, log) {
|
|
87
|
+
const v = opts?.heartbeatMs
|
|
88
|
+
if (v === undefined || v === null) return DEFAULT_WS_HEARTBEAT_MS
|
|
89
|
+
if (typeof v !== 'number' || !Number.isInteger(v) || v < 0) {
|
|
90
|
+
log?.warn?.({ heartbeatMs: v }, `ws route opts.heartbeatMs is invalid (integer >= 0 expected) — using default ${DEFAULT_WS_HEARTBEAT_MS}ms`)
|
|
91
|
+
return DEFAULT_WS_HEARTBEAT_MS
|
|
92
|
+
}
|
|
93
|
+
return v
|
|
94
|
+
}
|
|
95
|
+
|
|
55
96
|
/**
|
|
56
97
|
* WS 프레임 코덱 — 평문/암호 와이어 변환을 추상화한다.
|
|
57
98
|
* @typedef {Object} WsFrameCodec
|
|
@@ -122,6 +163,8 @@ export class MegaWsConnection {
|
|
|
122
163
|
this.channels = null
|
|
123
164
|
/** @type {Object|undefined} joinSession/updateMetadata 로 저장한 presence 메타(재연결 재동기화에 보존, M-1). */
|
|
124
165
|
this.metadata = undefined
|
|
166
|
+
/** @type {number} 협상된 envelope 프로토콜 버전(기본 v1) — driveWsConnection 이 핸드셰이크 결과로 설정. */
|
|
167
|
+
this.protocolVersion = 1
|
|
125
168
|
}
|
|
126
169
|
|
|
127
170
|
/** 하위 raw `ws` WebSocket (escape hatch — 바이너리/직접 제어용). */
|
|
@@ -234,10 +277,15 @@ function buildWsPresence(app, conn, ns) {
|
|
|
234
277
|
* @param {any} args.log - request 로거 (debug/warn/error).
|
|
235
278
|
* @param {any} [args.auth] - `before` 미들웨어가 인증 후 돌려준 신원(`{ userId, sessionId, ... }`).
|
|
236
279
|
* `ctx.auth` 로 노출 — onConnect 에서 `app.joinSession(sock, { userId: ctx.auth.userId, ... })` 에 쓴다.
|
|
280
|
+
* @param {number} [args.protocolVersion] - 핸드셰이크에서 협상된 envelope 버전(`mega.v<N>` subprotocol,
|
|
281
|
+
* {@link import('./ws-message.js').negotiateWsProtocol}). 미지정 = v1(레거시). 이 연결의 envelope
|
|
282
|
+
* 검증 기준이 되고 `conn.protocolVersion`/`ctx.protocolVersion` 으로 노출된다.
|
|
237
283
|
* @returns {MegaWsConnection}
|
|
238
284
|
*/
|
|
239
|
-
export function driveWsConnection({ raw, req, route, app, codec, log, auth = null }) {
|
|
285
|
+
export function driveWsConnection({ raw, req, route, app, codec, log, auth = null, protocolVersion = WS_PROTOCOL_VERSION }) {
|
|
240
286
|
const conn = new MegaWsConnection(raw, codec, { ns: route.ns, path: route.path })
|
|
287
|
+
// 협상된 envelope 버전 — 이 연결의 검증 기준이자 v2 도입 시 코덱/검증 분기의 기준점.
|
|
288
|
+
conn.protocolVersion = protocolVersion
|
|
241
289
|
// ChannelClass 는 부팅 시 MegaWebSocketController 상속 검증됨 (router.ws). 여기선 인스턴스화만.
|
|
242
290
|
const channel = /** @type {import('./ws-controller.js').MegaWebSocketController} */ (
|
|
243
291
|
new (/** @type {any} */ (route.ChannelClass))()
|
|
@@ -256,6 +304,7 @@ export function driveWsConnection({ raw, req, route, app, codec, log, auth = nul
|
|
|
256
304
|
path: route.path,
|
|
257
305
|
req,
|
|
258
306
|
connId: conn.id,
|
|
307
|
+
protocolVersion, // 협상된 envelope 버전(v1 기본) — 채널이 버전별 동작을 분기할 때 사용.
|
|
259
308
|
tracer: MegaTracing.tracer, // ctx.tracer.span(name, fn) — WS 핸들러에서도 사용자 직접 span(ADR-126).
|
|
260
309
|
// presence 단축 API (ADR-176) — list(클러스터 roster)/join/directToUser/broadcast 를 ns·conn 바인딩으로
|
|
261
310
|
// 노출. 클러스터 동기화는 wsCluster 가 처리하므로 채널은 비즈니스 로직만 쓴다. mock app 이면 null.
|
|
@@ -270,29 +319,86 @@ export function driveWsConnection({ raw, req, route, app, codec, log, auth = nul
|
|
|
270
319
|
|
|
271
320
|
log.debug?.({ connId: conn.id, path: route.path, ns: route.ns }, 'ws.connect enter')
|
|
272
321
|
|
|
322
|
+
// type 별 payload 스키마 검증 함수 맵 (M2, router.ws 에서 사전 컴파일). 없으면 검증 없음.
|
|
323
|
+
const schemaValidators = route.schemaValidators ?? null
|
|
324
|
+
|
|
325
|
+
/** 프레임 1건 처리 시작 — fire-and-forget(각 메시지 독립 async 흐름). @param {string} frame */
|
|
326
|
+
const processFrame = (frame) => {
|
|
327
|
+
// 최외곽 가드 (L2): handleIncoming 은 내부에서 단계별 try/catch 하지만, 예기치 못한
|
|
328
|
+
// 동기 throw / reject 가 unhandledRejection 으로 새지 않도록 .catch 로 마무리한다.
|
|
329
|
+
handleIncoming({ conn, channel, codec, ctx, raw, frame, schemaValidators, log }).catch((err) => {
|
|
330
|
+
log.warn?.({ err, connId: conn.id }, 'ws handleIncoming unexpected error')
|
|
331
|
+
})
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// onConnect 완료 전 도착한 프레임은 큐에 보관했다가 완료 후 도착 순서대로 처리한다 — 'message' 리스너는
|
|
335
|
+
// 동기 부착되고 onConnect 는 비동기라, 빠른 클라이언트의 첫 메시지가 채널 초기화(joinSession 등) 전에
|
|
336
|
+
// 디스패치되는 race 를 막는다. onConnect 실패 시 연결이 닫히므로 큐는 버린다.
|
|
337
|
+
let isConnectSettled = false
|
|
338
|
+
/** @type {string[]} */
|
|
339
|
+
let pendingFrames = []
|
|
340
|
+
|
|
273
341
|
// onConnect — 실패 시 fail-closed. 복호화와 무관한 서버 내부 오류이므로 1011 (M3). silent 금지.
|
|
274
342
|
Promise.resolve()
|
|
275
343
|
.then(() => channel.onConnect(conn, ctx))
|
|
344
|
+
.then(() => {
|
|
345
|
+
isConnectSettled = true
|
|
346
|
+
const queued = pendingFrames
|
|
347
|
+
pendingFrames = []
|
|
348
|
+
for (const frame of queued) processFrame(frame)
|
|
349
|
+
})
|
|
276
350
|
.catch((err) => {
|
|
277
351
|
log.error?.({ err, connId: conn.id }, 'ws.onConnect threw — closing (1011)')
|
|
352
|
+
pendingFrames = [] // 연결이 닫히므로 대기 프레임은 처리하지 않는다.
|
|
278
353
|
if (conn.isOpen) conn.close(CLOSE_CODE_INTERNAL_ERROR, 'onConnect failed')
|
|
279
354
|
})
|
|
280
355
|
|
|
281
|
-
// type 별 payload 스키마 검증 함수 맵 (M2, router.ws 에서 사전 컴파일). 없으면 검증 없음.
|
|
282
|
-
const schemaValidators = route.schemaValidators ?? null
|
|
283
|
-
|
|
284
356
|
raw.on('message', (data) => {
|
|
285
357
|
// ws 는 Buffer | ArrayBuffer | Buffer[] 를 줄 수 있음. 텍스트 프레임만 처리 (바이너리는 후속 BINARY 타입).
|
|
286
358
|
const frame = Array.isArray(data)
|
|
287
359
|
? Buffer.concat(data).toString('utf8')
|
|
288
360
|
: data.toString('utf8')
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
361
|
+
if (!isConnectSettled) {
|
|
362
|
+
if (pendingFrames.length >= MAX_PENDING_FRAMES_BEFORE_CONNECT) {
|
|
363
|
+
// onConnect 가 끝나기 전에 큐 상한 도달 — 더 쌓으면 메모리 abuse 라 연결을 닫는다(1013).
|
|
364
|
+
log.warn?.({ connId: conn.id, queued: pendingFrames.length }, 'ws pre-connect frame queue overflow — closing (1013)')
|
|
365
|
+
pendingFrames = []
|
|
366
|
+
if (conn.isOpen) conn.close(CLOSE_CODE_SLOW_CONSUMER, 'pre-connect queue overflow')
|
|
367
|
+
return
|
|
368
|
+
}
|
|
369
|
+
pendingFrames.push(frame)
|
|
370
|
+
return
|
|
371
|
+
}
|
|
372
|
+
processFrame(frame)
|
|
294
373
|
})
|
|
295
374
|
|
|
375
|
+
// liveness(ping/pong): 주기마다 ping 을 보내고 직전 주기의 pong 이 없으면 half-open 으로 보고
|
|
376
|
+
// terminate 한다 — 'close' 이벤트가 onDisconnect/untrack 정리를 그대로 트리거한다. opts.heartbeatMs=0 으로 끔.
|
|
377
|
+
const heartbeatMs = resolveWsHeartbeatMs(/** @type {any} */ (route.opts), log)
|
|
378
|
+
if (heartbeatMs > 0) {
|
|
379
|
+
let isAlive = true
|
|
380
|
+
raw.on('pong', () => {
|
|
381
|
+
isAlive = true
|
|
382
|
+
})
|
|
383
|
+
const pingTimer = setInterval(() => {
|
|
384
|
+
if (raw.readyState !== 1) return // 닫히는 중 — 'close' 가 곧 타이머를 정리한다.
|
|
385
|
+
if (!isAlive) {
|
|
386
|
+
log.warn?.({ connId: conn.id, heartbeatMs }, 'ws heartbeat timeout — terminating half-open connection')
|
|
387
|
+
raw.terminate()
|
|
388
|
+
return
|
|
389
|
+
}
|
|
390
|
+
isAlive = false
|
|
391
|
+
try {
|
|
392
|
+
raw.ping()
|
|
393
|
+
} catch (err) {
|
|
394
|
+
// 소켓이 닫히는 중이면 ping 실패 — 비치명적, close 가 뒤따른다 (이유+로그).
|
|
395
|
+
log.debug?.({ err, connId: conn.id }, 'ws ping send failed (socket closing)')
|
|
396
|
+
}
|
|
397
|
+
}, heartbeatMs)
|
|
398
|
+
if (typeof pingTimer.unref === 'function') pingTimer.unref()
|
|
399
|
+
raw.on('close', () => clearInterval(pingTimer))
|
|
400
|
+
}
|
|
401
|
+
|
|
296
402
|
raw.on('close', (code, reasonBuf) => {
|
|
297
403
|
app._untrackWsConn?.(conn)
|
|
298
404
|
const reason = Buffer.isBuffer(reasonBuf) ? reasonBuf.toString('utf8') : String(reasonBuf ?? '')
|
|
@@ -396,11 +502,11 @@ async function handleIncoming({ conn, channel, codec, ctx, raw, frame, schemaVal
|
|
|
396
502
|
return
|
|
397
503
|
}
|
|
398
504
|
|
|
399
|
-
// 2) envelope 파싱 +
|
|
505
|
+
// 2) envelope 파싱 + 검증 — 이 연결에서 협상된 버전 기준. 실패 → error envelope 응답(연결 유지 — 비치명적).
|
|
400
506
|
/** @type {{ type: string, id: string }} */
|
|
401
507
|
let msg
|
|
402
508
|
try {
|
|
403
|
-
msg = /** @type {{ type: string, id: string }} */ (parseWsMessage(plain))
|
|
509
|
+
msg = /** @type {{ type: string, id: string }} */ (parseWsMessage(plain, { version: conn.protocolVersion }))
|
|
404
510
|
} catch (err) {
|
|
405
511
|
log.warn?.({ err, connId: conn.id }, 'ws invalid envelope')
|
|
406
512
|
if (conn.isOpen) {
|
package/src/index.js
CHANGED
|
@@ -115,7 +115,7 @@ export { MegaAspDecryptError, ASP_RULES } from './lib/asp/errors.js'
|
|
|
115
115
|
export { normalizeAspConfig } from './lib/asp/config.js'
|
|
116
116
|
|
|
117
117
|
// Bridge ↔ Hub 12-타입 프로토콜 (ADR-033/059/097)
|
|
118
|
-
export { MegaWsHub, DEFAULT_HEARTBEAT_MS, runWsHubCli } from './
|
|
118
|
+
export { MegaWsHub, DEFAULT_HEARTBEAT_MS, runWsHubCli } from './lib/ws-hub.js'
|
|
119
119
|
export { MegaHubLink } from './core/hub-link.js'
|
|
120
120
|
// WS per-message deflate 압축 (ADR-078)
|
|
121
121
|
export { buildPerMessageDeflate, checkCompressionConfig, COMPRESSION_DEFAULTS } from './core/ws-compression.js'
|
package/src/lib/hub-protocol.js
CHANGED
|
@@ -61,6 +61,30 @@ export const HUB_MESSAGE_TYPES = Object.freeze({
|
|
|
61
61
|
/** 12 개 wire `type` 문자열 집합 (빠른 소속 판별용). @type {ReadonlySet<string>} */
|
|
62
62
|
export const HUB_TYPE_SET = new Set(Object.values(HUB_MESSAGE_TYPES))
|
|
63
63
|
|
|
64
|
+
/** bridge↔hub 프로토콜 현 버전. REGISTER/REGISTER_OK 의 `protocolVersion` 협상 기준(부재 = v1). */
|
|
65
|
+
export const HUB_PROTOCOL_VERSION = 1
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 이 측이 지원하는 bridge↔hub 프로토콜 버전 목록. 버전은 선형 누적 계약 — vN 지원 = v1..vN 전부 지원.
|
|
69
|
+
* @type {ReadonlyArray<number>}
|
|
70
|
+
*/
|
|
71
|
+
export const SUPPORTED_HUB_PROTOCOL_VERSIONS = Object.freeze([HUB_PROTOCOL_VERSION])
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* bridge 가 REGISTER 로 보낸 최대 지원 버전과 hub 지원 버전의 상호 최고 버전을 고른다.
|
|
75
|
+
* 선형 누적 계약(vN 지원 = v1..vN 지원)이라 `min(bridgeMax, hubMax)` 가 항상 유효한 상호 버전이다
|
|
76
|
+
* (v1 이 바닥이라 협상이 실패할 수 없다 — 부재/무효 입력은 v1).
|
|
77
|
+
*
|
|
78
|
+
* @param {unknown} requestedMax - bridge 의 `protocolVersion`(최대 지원 버전).
|
|
79
|
+
* @param {ReadonlyArray<number>} [supported] - 이 측 지원 버전 목록.
|
|
80
|
+
* @returns {number} 협상된 버전(>= 1).
|
|
81
|
+
*/
|
|
82
|
+
export function negotiateHubProtocolVersion(requestedMax, supported = SUPPORTED_HUB_PROTOCOL_VERSIONS) {
|
|
83
|
+
const ourMax = Math.max(...supported)
|
|
84
|
+
if (!Number.isInteger(requestedMax) || /** @type {number} */ (requestedMax) < 1) return HUB_PROTOCOL_VERSION
|
|
85
|
+
return Math.min(/** @type {number} */ (requestedMax), ourMax)
|
|
86
|
+
}
|
|
87
|
+
|
|
64
88
|
/**
|
|
65
89
|
* bridge↔hub WebSocket close code 카탈로그 (ADR-098).
|
|
66
90
|
*
|
|
@@ -107,6 +131,8 @@ export const HUB_PAYLOAD_SCHEMAS = Object.freeze({
|
|
|
107
131
|
instanceId: { type: 'string', minLength: 1 },
|
|
108
132
|
token: { type: 'string', minLength: 1 },
|
|
109
133
|
capabilities: { type: 'array', items: { type: 'string' } },
|
|
134
|
+
// 버전 협상(옵션): bridge 의 최대 지원 버전. 부재 = v1(레거시 bridge — 협상 없이 v1 고정).
|
|
135
|
+
protocolVersion: { type: 'integer', minimum: 1 },
|
|
110
136
|
},
|
|
111
137
|
additionalProperties: false,
|
|
112
138
|
},
|
|
@@ -117,6 +143,9 @@ export const HUB_PAYLOAD_SCHEMAS = Object.freeze({
|
|
|
117
143
|
hubId: { type: 'string', minLength: 1 },
|
|
118
144
|
acceptedAt: { type: 'integer' },
|
|
119
145
|
heartbeatMs: { type: 'integer', minimum: 1 },
|
|
146
|
+
// 버전 협상 결과(옵션): hub 가 채택한 버전. bridge 가 REGISTER 에 protocolVersion 을 실었을
|
|
147
|
+
// 때만 회신(echo-on-request) — 레거시 bridge(strict 스키마)가 모르는 필드를 받지 않게 한다.
|
|
148
|
+
protocolVersion: { type: 'integer', minimum: 1 },
|
|
120
149
|
},
|
|
121
150
|
additionalProperties: false,
|
|
122
151
|
},
|
package/src/lib/mega-health.js
CHANGED
|
@@ -19,21 +19,30 @@
|
|
|
19
19
|
|
|
20
20
|
import { MegaShutdown } from './mega-shutdown.js'
|
|
21
21
|
|
|
22
|
+
/** 체크 1개의 기본 타임아웃(ms) — hung 체크가 readiness 응답을 무기한 막지 않게 한다. */
|
|
23
|
+
export const DEFAULT_CHECK_TIMEOUT_MS = 5_000
|
|
24
|
+
|
|
25
|
+
/** @type {Map<string, { fn: Function, timeoutMs: number }>} */
|
|
22
26
|
const checks = new Map()
|
|
23
27
|
|
|
24
28
|
/**
|
|
25
29
|
* 헬스 체크 등록.
|
|
26
30
|
* @param {string} name
|
|
27
31
|
* @param {() => Promise<{ ok: boolean, [key: string]: any }> | { ok: boolean }} fn
|
|
32
|
+
* @param {{ timeoutMs?: number }} [opts] - 체크별 타임아웃(양의 정수 ms, 기본 {@link DEFAULT_CHECK_TIMEOUT_MS}).
|
|
28
33
|
*/
|
|
29
|
-
export function register(name, fn) {
|
|
34
|
+
export function register(name, fn, opts = {}) {
|
|
30
35
|
if (typeof name !== 'string' || name.length === 0) {
|
|
31
36
|
throw new Error('MegaHealth.register: name is required (string)')
|
|
32
37
|
}
|
|
33
38
|
if (typeof fn !== 'function') {
|
|
34
39
|
throw new Error('MegaHealth.register: fn must be a function')
|
|
35
40
|
}
|
|
36
|
-
|
|
41
|
+
const timeoutMs =
|
|
42
|
+
Number.isInteger(opts.timeoutMs) && /** @type {number} */ (opts.timeoutMs) > 0
|
|
43
|
+
? /** @type {number} */ (opts.timeoutMs)
|
|
44
|
+
: DEFAULT_CHECK_TIMEOUT_MS
|
|
45
|
+
checks.set(name, { fn, timeoutMs })
|
|
37
46
|
}
|
|
38
47
|
|
|
39
48
|
/**
|
|
@@ -48,12 +57,24 @@ export async function checkAll() {
|
|
|
48
57
|
|
|
49
58
|
const entries = [...checks.entries()]
|
|
50
59
|
const results = await Promise.all(
|
|
51
|
-
entries.map(async ([name, fn]) => {
|
|
60
|
+
entries.map(async ([name, { fn, timeoutMs }]) => {
|
|
61
|
+
/** @type {NodeJS.Timeout | undefined} */
|
|
62
|
+
let timer
|
|
52
63
|
try {
|
|
53
|
-
|
|
64
|
+
// 체크별 race — hung 체크 1개가 readiness 전체를 무기한 막지 않게 timeoutMs 에서 끊는다
|
|
65
|
+
// (probe timeout 으로 인한 연쇄 재시작 방지). 타임아웃은 해당 체크만 ok:false 처리.
|
|
66
|
+
const result = await Promise.race([
|
|
67
|
+
Promise.resolve(fn()),
|
|
68
|
+
new Promise((_, reject) => {
|
|
69
|
+
timer = setTimeout(() => reject(new Error(`health check timed out after ${timeoutMs}ms`)), timeoutMs)
|
|
70
|
+
timer.unref?.()
|
|
71
|
+
}),
|
|
72
|
+
])
|
|
54
73
|
return [name, result?.ok === true ? result : { ok: false, ...result }]
|
|
55
74
|
} catch (err) {
|
|
56
75
|
return [name, { ok: false, error: err?.message ?? String(err) }]
|
|
76
|
+
} finally {
|
|
77
|
+
clearTimeout(timer)
|
|
57
78
|
}
|
|
58
79
|
}),
|
|
59
80
|
)
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
*/
|
|
44
44
|
import { EventEmitter } from 'node:events'
|
|
45
45
|
import { MegaConfigError } from '../errors/config-error.js'
|
|
46
|
-
import { MegaJob, resolveJobRetryConfig } from './mega-job.js'
|
|
46
|
+
import { MegaJob, resolveJobRetryConfig, resolveJobRunTimeoutMs } from './mega-job.js'
|
|
47
47
|
import { withRetry } from './mega-retry.js'
|
|
48
48
|
|
|
49
49
|
/** 잡 큐가 노출하는 이벤트 화이트리스트(오타 차단 + 문서화 — 형제 클래스와 동일 정책). */
|
|
@@ -52,7 +52,7 @@ const KNOWN_EVENTS = Object.freeze([
|
|
|
52
52
|
'start', // 메시지 처리 시작
|
|
53
53
|
'done', // 처리 성공 + ack — event.result
|
|
54
54
|
'retry', // 재시도 1회 실패(다음 시도 전) — event.attempt/retriesLeft/error
|
|
55
|
-
'fail', // 최종 실패 — event.phase('run'|'decode'|'dlq-publish'|'max-deliver'|'consume-loop')/error
|
|
55
|
+
'fail', // 최종 실패 — event.phase('run'|'decode'|'dlq-publish'|'dlq-orphan'|'max-deliver'|'consume-loop'|'abandoned-run')/error
|
|
56
56
|
'dlq', // DLQ 라우팅 완료 — event.dlqSubject
|
|
57
57
|
])
|
|
58
58
|
|
|
@@ -62,6 +62,15 @@ const NOT_FOUND_CODE = '404'
|
|
|
62
62
|
/** DLQ 스트림 `max_age` 디폴트(ms) — 7일. 무한 적재 방지(ADR-134). `dlqMaxAgeMs: 0` 으로 끌 수 있다. */
|
|
63
63
|
export const DEFAULT_DLQ_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* run 전체(재시도 포함) 실행 상한 디폴트(ms) — 30분. 행(hang)된 run 이 `working()` lease 를 영원히
|
|
67
|
+
* 갱신하며 메시지를 영구 점유하는 것을 막는 backstop. 잡별 `static timeoutMs` 로 override, `0` = 무제한.
|
|
68
|
+
*/
|
|
69
|
+
export const DEFAULT_RUN_TIMEOUT_MS = 30 * 60 * 1000
|
|
70
|
+
|
|
71
|
+
/** DLQ publish 실패 nak 재전달 지연 상한(ms) — 즉시 재전달 핫 루프 방지(점증 지연의 cap). */
|
|
72
|
+
const NAK_DELAY_MAX_MS = 30_000
|
|
73
|
+
|
|
65
74
|
/** DLQ 봉투에 싣는 error.stack 최대 길이(자) — poison 잡 폭주 시 DLQ 비대 방지(Med). */
|
|
66
75
|
const DLQ_MAX_STACK_LEN = 4000
|
|
67
76
|
|
|
@@ -88,6 +97,10 @@ function truncateStack(stack) {
|
|
|
88
97
|
* 스트림 생성 시에만** 적용(멱등 — 기존 스트림은 운영자가 NATS CLI 로 갱신).
|
|
89
98
|
* @property {number} [dlqMaxBytes] - DLQ 스트림 최대 크기(bytes). 미지정이면 byte 상한 없음(`max_age` 가
|
|
90
99
|
* 주 가드). 디스크 상한이 필요한 운영 환경에서만 지정.
|
|
100
|
+
* @property {number} [runTimeoutMs=1800000] - run 전체(재시도 포함) 실행 상한 디폴트(ms, 기본 30분).
|
|
101
|
+
* 초과 시 잡을 실패로 판정해 DLQ 라우팅 — 행 잡이 `working()` lease 를 영구 갱신하며 메시지를 점유하는
|
|
102
|
+
* 것을 막는다. 잡별 `static timeoutMs` 가 우선, `0` = 무제한. ⚠️ 타임아웃돼도 진행 중 run 은 중단되지
|
|
103
|
+
* 않는다(백그라운드 계속 — run 멱등 설계 필요). 나중 실패는 fail(abandoned-run) 으로 표면화.
|
|
91
104
|
*/
|
|
92
105
|
|
|
93
106
|
/**
|
|
@@ -116,6 +129,7 @@ export class MegaJobQueue extends EventEmitter {
|
|
|
116
129
|
/** @type {string} */ #streamPrefix
|
|
117
130
|
/** @type {number} DLQ max_age(ms). 0 = 무제한. */ #dlqMaxAgeMs
|
|
118
131
|
/** @type {number|undefined} DLQ max_bytes. undefined = 무제한. */ #dlqMaxBytes
|
|
132
|
+
/** @type {number} run 전체 실행 상한 디폴트(ms). 0 = 무제한. 잡별 static timeoutMs 가 우선. */ #runTimeoutMs
|
|
119
133
|
/** @type {typeof import('nats')|null} 지연 로드된 nats 모듈(enum/codec/nanos). */ #nats = null
|
|
120
134
|
/** @type {import('nats').Codec<any>|null} */ #codec = null
|
|
121
135
|
/** @type {import('nats').JetStreamClient|null} */ #js = null
|
|
@@ -126,7 +140,7 @@ export class MegaJobQueue extends EventEmitter {
|
|
|
126
140
|
* @param {MegaJobQueueOptions} options
|
|
127
141
|
* @throws {TypeError} nc 가 JetStream 가능한 NatsConnection 이 아니면(fail-fast).
|
|
128
142
|
*/
|
|
129
|
-
constructor({ nc, ackWaitMs = 30_000, maxDeliver = 5, heartbeatMs, streamPrefix = 'MEGA_JOBS', dlqMaxAgeMs = DEFAULT_DLQ_MAX_AGE_MS, dlqMaxBytes } = /** @type {any} */ ({})) {
|
|
143
|
+
constructor({ nc, ackWaitMs = 30_000, maxDeliver = 5, heartbeatMs, streamPrefix = 'MEGA_JOBS', dlqMaxAgeMs = DEFAULT_DLQ_MAX_AGE_MS, dlqMaxBytes, runTimeoutMs = DEFAULT_RUN_TIMEOUT_MS } = /** @type {any} */ ({})) {
|
|
130
144
|
super()
|
|
131
145
|
if (!nc || typeof nc.jetstream !== 'function' || typeof nc.jetstreamManager !== 'function') {
|
|
132
146
|
throw new TypeError(
|
|
@@ -146,6 +160,10 @@ export class MegaJobQueue extends EventEmitter {
|
|
|
146
160
|
if (dlqMaxBytes !== undefined && (typeof dlqMaxBytes !== 'number' || !Number.isInteger(dlqMaxBytes) || dlqMaxBytes < 1)) {
|
|
147
161
|
throw new TypeError(`MegaJobQueue: dlqMaxBytes must be an integer >= 1 when set. Got: ${dlqMaxBytes}.`)
|
|
148
162
|
}
|
|
163
|
+
// runTimeoutMs: 0 = 무제한(끔). 음수/비정수는 운영 실수라 fail-fast(silent 보정 X).
|
|
164
|
+
if (typeof runTimeoutMs !== 'number' || !Number.isInteger(runTimeoutMs) || runTimeoutMs < 0) {
|
|
165
|
+
throw new TypeError(`MegaJobQueue: runTimeoutMs must be an integer >= 0 (0 = unlimited). Got: ${runTimeoutMs}.`)
|
|
166
|
+
}
|
|
149
167
|
this.#nc = nc
|
|
150
168
|
this.#ackWaitMs = ackWaitMs
|
|
151
169
|
this.#maxDeliver = maxDeliver
|
|
@@ -153,6 +171,7 @@ export class MegaJobQueue extends EventEmitter {
|
|
|
153
171
|
this.#streamPrefix = streamPrefix
|
|
154
172
|
this.#dlqMaxAgeMs = dlqMaxAgeMs
|
|
155
173
|
this.#dlqMaxBytes = dlqMaxBytes
|
|
174
|
+
this.#runTimeoutMs = runTimeoutMs
|
|
156
175
|
}
|
|
157
176
|
|
|
158
177
|
// ── 이벤트 화이트리스트(L-1 정책 — 형제 클래스와 동일) ────────────────────
|
|
@@ -416,6 +435,7 @@ export class MegaJobQueue extends EventEmitter {
|
|
|
416
435
|
const durable = this.#durableName(subject)
|
|
417
436
|
const concurrency = this.#resolveConcurrency(JobClass) // L-1: 양의 정수 fail-fast(max_ack_pending=0 풋건 차단).
|
|
418
437
|
const retryConfig = resolveJobRetryConfig(JobClass)
|
|
438
|
+
const runTimeoutMs = resolveJobRunTimeoutMs(JobClass, this.#runTimeoutMs) // 행 잡 영구 점유 backstop.
|
|
419
439
|
await this.#ensureConsumer(stream, durable, subject, concurrency)
|
|
420
440
|
|
|
421
441
|
const js = /** @type {import('nats').JetStreamClient} */ (this.#js)
|
|
@@ -426,7 +446,7 @@ export class MegaJobQueue extends EventEmitter {
|
|
|
426
446
|
const inFlight = new Set()
|
|
427
447
|
const loop = (async () => {
|
|
428
448
|
for await (const msg of messages) {
|
|
429
|
-
const p = this._handleMessage(instance, ctx, msg, retryConfig, subject).finally(() =>
|
|
449
|
+
const p = this._handleMessage(instance, ctx, msg, retryConfig, subject, runTimeoutMs).finally(() =>
|
|
430
450
|
inFlight.delete(p),
|
|
431
451
|
)
|
|
432
452
|
inFlight.add(p)
|
|
@@ -464,9 +484,10 @@ export class MegaJobQueue extends EventEmitter {
|
|
|
464
484
|
*
|
|
465
485
|
* @param {MegaJob} instance @param {Record<string, any>} ctx @param {import('nats').JsMsg} msg
|
|
466
486
|
* @param {ReturnType<typeof resolveJobRetryConfig>} retryConfig @param {string} subject
|
|
487
|
+
* @param {number} [runTimeoutMs] - run 전체(재시도 포함) 상한(ms). 미지정 시 큐 디폴트, 0 = 무제한.
|
|
467
488
|
* @returns {Promise<MegaJobHandleResult>}
|
|
468
489
|
*/
|
|
469
|
-
async _handleMessage(instance, ctx, msg, retryConfig, subject) {
|
|
490
|
+
async _handleMessage(instance, ctx, msg, retryConfig, subject, runTimeoutMs = this.#runTimeoutMs) {
|
|
470
491
|
const seq = msg.seq
|
|
471
492
|
|
|
472
493
|
// M-1(ADR-119 hardening): 이번 전달이 max_deliver 에 도달했고(deliveryCount >= maxDeliver)
|
|
@@ -520,8 +541,14 @@ export class MegaJobQueue extends EventEmitter {
|
|
|
520
541
|
let runResult
|
|
521
542
|
/** @type {Error|null} run 최종 실패 사유(성공이면 null). */
|
|
522
543
|
let runError = null
|
|
544
|
+
/** @type {NodeJS.Timeout|undefined} run 타임아웃 타이머(있으면 finally 에서 정리). */
|
|
545
|
+
let timeoutHandle
|
|
546
|
+
/** @type {boolean} 타임아웃이 race 를 이겼는지(버려진 run 의 잔여 실패 표면화 분기용). */
|
|
547
|
+
let timedOut = false
|
|
548
|
+
/** @type {Promise<any>|undefined} run(재시도) Promise — 타임아웃 시 잔여 실패 관찰에 필요. */
|
|
549
|
+
let runPromise
|
|
523
550
|
try {
|
|
524
|
-
|
|
551
|
+
runPromise = withRetry(() => instance.run(payload, ctx), {
|
|
525
552
|
...retryConfig,
|
|
526
553
|
onFailedAttempt: (info) => {
|
|
527
554
|
this.#safeEmit('retry', {
|
|
@@ -533,11 +560,44 @@ export class MegaJobQueue extends EventEmitter {
|
|
|
533
560
|
})
|
|
534
561
|
},
|
|
535
562
|
})
|
|
563
|
+
if (runTimeoutMs > 0) {
|
|
564
|
+
// 행(hang) 잡 backstop: run 전체(재시도 포함)에 상한을 건다. 상한 초과 = 실패 판정 → DLQ.
|
|
565
|
+
// 없으면 working() 하트비트가 lease 를 영원히 갱신해 메시지가 재전달도 DLQ 도 못 가는
|
|
566
|
+
// 영구 점유가 된다(프로세스 사망 시에만 해소).
|
|
567
|
+
runResult = await Promise.race([
|
|
568
|
+
runPromise,
|
|
569
|
+
new Promise((_resolve, reject) => {
|
|
570
|
+
timeoutHandle = setTimeout(() => {
|
|
571
|
+
timedOut = true
|
|
572
|
+
reject(new Error(
|
|
573
|
+
`MegaJobQueue: job on '${subject}' exceeded run timeout (${runTimeoutMs}ms) — treating as ` +
|
|
574
|
+
`failed (run continues in background; design run to be idempotent).`,
|
|
575
|
+
))
|
|
576
|
+
}, runTimeoutMs)
|
|
577
|
+
if (typeof timeoutHandle.unref === 'function') timeoutHandle.unref()
|
|
578
|
+
}),
|
|
579
|
+
])
|
|
580
|
+
} else {
|
|
581
|
+
runResult = await runPromise
|
|
582
|
+
}
|
|
536
583
|
} catch (err) {
|
|
537
584
|
runError = err instanceof Error ? err : new Error(String(err))
|
|
585
|
+
if (timedOut && runPromise) {
|
|
586
|
+
// 버려진(타임아웃 패배) run 의 나중 실패를 묵히지 않고 표면화한다 — 잡은 이미 실패 판정·DLQ
|
|
587
|
+
// 라우팅됐으므로 처리 흐름엔 영향 없다(reject 자체는 race 가 구독해 unhandledRejection 아님).
|
|
588
|
+
runPromise.catch((lateErr) => {
|
|
589
|
+
this.#safeEmit('fail', {
|
|
590
|
+
subject,
|
|
591
|
+
seq,
|
|
592
|
+
error: lateErr instanceof Error ? lateErr : new Error(String(lateErr)),
|
|
593
|
+
phase: 'abandoned-run',
|
|
594
|
+
})
|
|
595
|
+
})
|
|
596
|
+
}
|
|
538
597
|
} finally {
|
|
539
598
|
settled = true
|
|
540
599
|
clearInterval(heartbeat)
|
|
600
|
+
if (timeoutHandle !== undefined) clearTimeout(timeoutHandle)
|
|
541
601
|
}
|
|
542
602
|
|
|
543
603
|
if (runError === null) {
|
|
@@ -552,30 +612,47 @@ export class MegaJobQueue extends EventEmitter {
|
|
|
552
612
|
}
|
|
553
613
|
|
|
554
614
|
/**
|
|
555
|
-
* DLQ 라우팅 — 실패 페이로드+에러 메타를 `<subject>.dlq` 로 발행하고 원본을 ack.
|
|
556
|
-
*
|
|
615
|
+
* DLQ 라우팅 — 실패 페이로드+에러 메타를 `<subject>.dlq` 로 발행하고 원본을 ack. 발행은 인프로세스로
|
|
616
|
+
* 짧게 재시도(일시 장애 흡수)하고, 그래도 실패하면 ack 하지 않고 **점증 지연 nak** 해 잡을 보존한다
|
|
617
|
+
* (at-least-once — 즉시 재전달 핫 루프 방지). 단 이번 전달이 `max_deliver` 의 **마지막 전달**이면 nak 해도
|
|
618
|
+
* 재전달이 없어 메시지가 워크 스트림에 orphan 으로 남는다 — 이때는 un-ack 보존 + `fail(dlq-orphan)` 으로
|
|
619
|
+
* 운영자 개입을 명시 표면화한다(ack/term 으로 잡을 지우면 유실이라 하지 않는다). 본 메서드도 throw 하지 않는다.
|
|
557
620
|
* @param {string} subject @param {any} payload @param {Error} error @param {import('nats').JsMsg} msg @param {number} seq @returns {Promise<void>}
|
|
558
621
|
*/
|
|
559
622
|
async #routeToDlq(subject, payload, error, msg, seq) {
|
|
560
623
|
const dlqSubject = `${subject}.dlq`
|
|
561
624
|
const js = /** @type {import('nats').JetStreamClient} */ (this.#js)
|
|
562
625
|
try {
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
626
|
+
// DLQ publish 자체를 짧게 재시도(추가 2회, 250ms→1s) — 일시적 NATS 응답 지연/재연결 틈을 흡수해
|
|
627
|
+
// nak 재전달(전체 인프로세스 재시도 사이클 반복)보다 훨씬 싸게 orphan 확률을 줄인다.
|
|
628
|
+
await withRetry(
|
|
629
|
+
() =>
|
|
630
|
+
js.publish(
|
|
631
|
+
dlqSubject,
|
|
632
|
+
this.#encode({
|
|
633
|
+
originalSubject: subject,
|
|
634
|
+
failedAt: new Date().toISOString(),
|
|
635
|
+
deliveryCount: msg.info.deliveryCount,
|
|
636
|
+
// stack 전체를 봉투에 담으면 poison 잡 폭주 시 DLQ 직렬화 비용·디스크가 급증한다 → 상한으로 truncate(Med).
|
|
637
|
+
error: { name: error.name, message: error.message, stack: truncateStack(error.stack) },
|
|
638
|
+
payload,
|
|
639
|
+
}),
|
|
640
|
+
),
|
|
641
|
+
{ retries: 2, minTimeout: 250, maxTimeout: 1000, factor: 2, jitter: true },
|
|
573
642
|
)
|
|
574
643
|
} catch (pubErr) {
|
|
575
|
-
// DLQ 발행 실패 → ack 안 함(보존). nak 으로 재전달 요청 → DLQ 백엔드 회복 후 재시도(안 묻음).
|
|
576
644
|
const e = pubErr instanceof Error ? pubErr : new Error(String(pubErr))
|
|
577
|
-
msg.
|
|
578
|
-
|
|
645
|
+
if (msg.info.deliveryCount >= this.#maxDeliver) {
|
|
646
|
+
// 마지막 전달 — nak 해도 JetStream 이 더는 재전달하지 않는다. ack/term 하면 잡이 사라지므로
|
|
647
|
+
// un-ack 로 워크 스트림에 보존하고(운영자가 NATS CLI 로 수습 가능), orphan 을 크게 표면화한다.
|
|
648
|
+
this.#safeEmit('fail', { subject, seq, error: e, phase: 'dlq-orphan' })
|
|
649
|
+
return
|
|
650
|
+
}
|
|
651
|
+
// DLQ 발행 실패 → ack 안 함(보존). 점증 지연 nak — 즉시 재전달이면 DLQ 백엔드 장애 동안
|
|
652
|
+
// 재전달→재시도 사이클 전체가 타이트하게 도는 핫 루프가 된다(deliveryCount 기반 지수 지연).
|
|
653
|
+
const nakDelayMs = Math.min(NAK_DELAY_MAX_MS, 1000 * 2 ** (msg.info.deliveryCount - 1))
|
|
654
|
+
msg.nak(nakDelayMs) // 부작용(nak) 먼저 확정 — emit 보다 앞선다(M-3: 리스너 throw 가 보존 결정을 막지 않게).
|
|
655
|
+
this.#safeEmit('fail', { subject, seq, error: e, phase: 'dlq-publish', nakDelayMs })
|
|
579
656
|
return
|
|
580
657
|
}
|
|
581
658
|
msg.ack() // DLQ 에 안전히 보관됨 → 워크 메시지 제거(emit 보다 먼저 확정).
|
package/src/lib/mega-job.js
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
* - `static concurrency` : 동시에 처리할 메시지 수(consumer `max_ack_pending`). 기본 1(순차·안전).
|
|
24
24
|
* - `static retries` : run 실패 시 **추가** 재시도 횟수(첫 시도 제외). 기본 3.
|
|
25
25
|
* - `static backoff` : `{ type:'exponential', initial, max }`. p-retry 로 매핑(factor=2, jitter=on).
|
|
26
|
+
* - `static timeoutMs` : run 전체(재시도 포함) 상한(ms). 미지정 시 큐 디폴트, `0` = 무제한.
|
|
26
27
|
*
|
|
27
28
|
* @module lib/mega-job
|
|
28
29
|
* @see ADR-119, ADR-028 (잡·스케줄러·워커 3종 분리), ADR-029 (라이브러리 래핑)
|
|
@@ -81,6 +82,15 @@ export class MegaJob {
|
|
|
81
82
|
/** @type {MegaJobBackoff} 지수 백오프 설정. 기본 { exponential, 1s, 30s }(OQ-012). */
|
|
82
83
|
static backoff = JOB_RETRY_DEFAULTS.backoff
|
|
83
84
|
|
|
85
|
+
/**
|
|
86
|
+
* @type {number|undefined} run 전체(재시도 포함) 실행 상한(ms). 초과 시 큐가 잡을 실패로 판정해 DLQ 로
|
|
87
|
+
* 보낸다 — 행(hang)된 run 이 `working()` lease 를 영원히 갱신하며 메시지를 영구 점유하는 것을 막는다.
|
|
88
|
+
* 미지정 시 {@link import('./mega-job-queue.js').MegaJobQueue} 의 `runTimeoutMs`(기본 30분), `0` = 무제한.
|
|
89
|
+
* ⚠️ 타임아웃돼도 진행 중이던 run 은 JS 특성상 중단되지 않는다(백그라운드 계속) — run 은 멱등하게
|
|
90
|
+
* 설계해야 한다(at-least-once, 모듈 docstring).
|
|
91
|
+
*/
|
|
92
|
+
static timeoutMs = undefined
|
|
93
|
+
|
|
84
94
|
/**
|
|
85
95
|
* 메시지 1건마다 실행되는 본문. 서브클래스가 **반드시** 구현한다. throw 하면 {@link MegaJobQueue}
|
|
86
96
|
* 가 재시도하고, 재시도 소진 시 DLQ 로 보낸다 — 그러므로 비치명/일시 오류는 그냥 throw 하면 된다.
|
|
@@ -138,3 +148,22 @@ export function resolveJobRetryConfig(JobClass) {
|
|
|
138
148
|
// factor=2(exponential), jitter=on — OQ-012 정합. MegaRetry 가 첫 시도 즉시 + 이후 지수 백오프.
|
|
139
149
|
return { retries, minTimeout: initial, maxTimeout: max, factor: 2, jitter: true }
|
|
140
150
|
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 잡 클래스의 `static timeoutMs` 를 검증해 반환한다. 미지정(undefined/null)이면 `defaultMs`(큐 디폴트),
|
|
154
|
+
* `0` = 무제한. 음수/비정수는 운영 실수라 fail-fast(silent 보정 금지).
|
|
155
|
+
*
|
|
156
|
+
* @param {typeof MegaJob} JobClass - 잡 클래스.
|
|
157
|
+
* @param {number} defaultMs - 큐 레벨 디폴트(ms, 0 = 무제한).
|
|
158
|
+
* @returns {number} 적용할 타임아웃(ms). 0 = 무제한.
|
|
159
|
+
* @throws {TypeError} timeoutMs 가 0 이상 정수가 아닐 때.
|
|
160
|
+
*/
|
|
161
|
+
export function resolveJobRunTimeoutMs(JobClass, defaultMs) {
|
|
162
|
+
const timeoutMs = JobClass.timeoutMs ?? defaultMs
|
|
163
|
+
if (typeof timeoutMs !== 'number' || !Number.isInteger(timeoutMs) || timeoutMs < 0) {
|
|
164
|
+
throw new TypeError(
|
|
165
|
+
`MegaJob '${JobClass.name}': static timeoutMs must be an integer >= 0 (0 = unlimited). Got: ${timeoutMs}.`,
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
return timeoutMs
|
|
169
|
+
}
|
package/src/lib/mega-logger.js
CHANGED
|
@@ -133,7 +133,7 @@ function mergeRedact(userRedact) {
|
|
|
133
133
|
/**
|
|
134
134
|
* `logger` config → pino 옵션(또는 비활성 시 `null`). Fastify `logger` 또는 `pino()` 에 그대로 전달 가능.
|
|
135
135
|
*
|
|
136
|
-
* @param {unknown} config - `MegaLoggerConfig`(`{ level, sinks, redact
|
|
136
|
+
* @param {unknown} config - `MegaLoggerConfig`(`{ level, sinks, redact? }`).
|
|
137
137
|
* @returns {{ level: string, mixin: Function, redact: { paths: string[] }, transport: { targets: any[] } } | null}
|
|
138
138
|
*/
|
|
139
139
|
export function buildLoggerOptions(config) {
|
package/src/lib/mega-metrics.js
CHANGED
|
@@ -38,12 +38,7 @@
|
|
|
38
38
|
*/
|
|
39
39
|
import { MeterProvider } from '@opentelemetry/sdk-metrics'
|
|
40
40
|
import { PrometheusExporter, PrometheusSerializer } from '@opentelemetry/exporter-prometheus'
|
|
41
|
-
import {
|
|
42
|
-
import {
|
|
43
|
-
ATTR_SERVICE_NAME,
|
|
44
|
-
ATTR_SERVICE_VERSION,
|
|
45
|
-
ATTR_DEPLOYMENT_ENVIRONMENT_NAME,
|
|
46
|
-
} from '@opentelemetry/semantic-conventions'
|
|
41
|
+
import { buildOtelResource } from './otel-resource.js'
|
|
47
42
|
import { MegaConfigError } from '../errors/config-error.js'
|
|
48
43
|
|
|
49
44
|
/** meter 이름 (instrumentation scope) — OTel 컨벤션상 패키지명. */
|
|
@@ -147,12 +142,8 @@ export function init(opts = /** @type {any} */ ({})) {
|
|
|
147
142
|
)
|
|
148
143
|
}
|
|
149
144
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
...(typeof opts.version === 'string' ? { [ATTR_SERVICE_VERSION]: opts.version } : {}),
|
|
153
|
-
...(typeof opts.environment === 'string' ? { [ATTR_DEPLOYMENT_ENVIRONMENT_NAME]: opts.environment } : {}),
|
|
154
|
-
...(opts.attributes && typeof opts.attributes === 'object' ? opts.attributes : {}),
|
|
155
|
-
})
|
|
145
|
+
// resource 조립은 트레이싱과 공유하는 단일 출처(otel-resource.js, ADR-193) — 두 SDK 간 드리프트 방지.
|
|
146
|
+
const resource = buildOtelResource({ serviceName, version: opts.version, environment: opts.environment, attributes: opts.attributes })
|
|
156
147
|
|
|
157
148
|
// preventServerStart — 자체 :9464 서버를 띄우지 않고 우리가 collect() 로 직접 긁는다(메인 포트 서빙).
|
|
158
149
|
const reader = new PrometheusExporter({ preventServerStart: true })
|