mega-framework 0.1.6 → 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/bin/mega-ws-hub.js +2 -2
- package/package.json +33 -9
- package/sample/crud/.env +10 -1
- package/sample/crud/.env.example +10 -1
- 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/locales/server/en.json +12 -1
- package/sample/crud/apps/main/locales/server/ko.json +12 -1
- 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 +10 -2
- package/sample/crud/package.json +3 -3
- package/sample/crud/scripts/start-ws-hub.sh +20 -6
- package/sample/simple/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/sample/simple/package.json +2 -2
- package/src/adapters/adapter-manager.js +2 -1
- package/src/adapters/adapter-options.js +44 -3
- package/src/adapters/file-adapter.js +9 -5
- package/src/adapters/file-session-adapter.js +4 -3
- package/src/adapters/maria-adapter.js +33 -7
- package/src/adapters/mega-cache-adapter.js +83 -6
- package/src/adapters/mega-db-adapter.js +10 -1
- package/src/adapters/mongo-adapter.js +40 -8
- package/src/adapters/postgres-adapter.js +33 -6
- package/src/adapters/redis-adapter.js +7 -3
- package/src/adapters/sqlite-adapter.js +26 -3
- package/src/cli/commands/console-cmd.js +3 -1
- package/src/cli/commands/new.js +13 -3
- package/src/cli/commands/scaffold.js +173 -33
- package/src/cli/generators/index.js +140 -3
- package/src/cli/index.js +437 -155
- package/src/cli/watch.js +188 -0
- package/src/core/ajv-mapper.js +30 -3
- package/src/core/boot.js +464 -245
- package/src/core/cluster-metrics.js +13 -4
- package/src/core/ctx-builder.js +65 -3
- package/src/core/envelope.js +119 -12
- package/src/core/hub-link.js +89 -18
- package/src/core/i18n.js +11 -1
- package/src/core/index.js +7 -3
- package/src/core/mega-app.js +253 -505
- package/src/core/mega-cluster.js +4 -1
- 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 +131 -0
- package/src/core/router.js +70 -65
- package/src/core/scope-registry.js +1 -0
- package/src/core/security.js +70 -12
- package/src/core/session-store.js +14 -1
- 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 +636 -0
- package/src/core/ws-roster.js +50 -8
- package/src/core/ws-upgrade.js +223 -12
- package/src/index.js +1 -1
- package/src/lib/hub-protocol.js +29 -0
- package/src/lib/mega-circuit-breaker.js +5 -3
- package/src/lib/mega-health.js +35 -4
- package/src/lib/mega-job-queue.js +151 -34
- package/src/lib/mega-job.js +37 -1
- package/src/lib/mega-metrics.js +31 -13
- package/src/lib/mega-plugin.js +34 -3
- package/src/lib/mega-schedule.js +40 -22
- package/src/lib/mega-shutdown.js +114 -39
- package/src/lib/mega-tracing.js +66 -19
- package/src/lib/mega-worker.js +33 -6
- package/src/lib/otel-resource.js +36 -0
- package/src/{cli → lib}/ws-hub.js +139 -15
- 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/adr/code.tpl +23 -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 +93 -0
- package/types/adapters/file-adapter.d.ts +105 -0
- package/types/adapters/file-session-adapter.d.ts +103 -0
- package/types/adapters/index.d.ts +20 -0
- package/types/adapters/maria-adapter.d.ts +117 -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 +73 -0
- package/types/adapters/mega-db-adapter.d.ts +50 -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 +150 -0
- package/types/adapters/nats-adapter.d.ts +108 -0
- package/types/adapters/postgres-adapter.d.ts +141 -0
- package/types/adapters/redis-adapter.d.ts +78 -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 +112 -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 +122 -0
- package/types/cli/index.d.ts +234 -0
- package/types/cli/template-engine.d.ts +40 -0
- package/types/cli/watch.d.ts +59 -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 +103 -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 +266 -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 +93 -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 +25 -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 +108 -0
- package/types/core/ws-upgrade.d.ts +260 -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 +243 -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 +48 -0
- package/types/lib/mega-job-queue.d.ts +188 -0
- package/types/lib/mega-job-worker.d.ts +130 -0
- package/types/lib/mega-job.d.ts +145 -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 +129 -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 +259 -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
|
@@ -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
|
|
|
@@ -82,12 +91,18 @@ function truncateStack(stack) {
|
|
|
82
91
|
* 하트비트가 이 값의 절반마다 lease 를 갱신한다.
|
|
83
92
|
* @property {number} [maxDeliver=5] - JetStream 최대 전달 횟수(워커 크래시 재전달 backstop).
|
|
84
93
|
* @property {number} [heartbeatMs] - `working()` 전송 주기(ms). 기본 `max(1000, ackWaitMs/2)`.
|
|
94
|
+
* 양의 정수 + `< ackWaitMs` 필수(생성자 fail-fast) — 이상이면 lease 갱신이 늦어 정상 처리 중
|
|
95
|
+
* 중복 재전달, 0 이하면 working() 플러딩.
|
|
85
96
|
* @property {string} [streamPrefix='MEGA_JOBS'] - 스트림 이름 접두사.
|
|
86
97
|
* @property {number} [dlqMaxAgeMs=604800000] - DLQ 스트림 메시지 보존 기한(ms, 디폴트 7일). 초과한 실패
|
|
87
98
|
* 잡은 NATS 가 자동 만료시킨다(무한 적재 방지, ADR-134). `0` 이면 무제한(끔 — 영구 보존). **신규 DLQ
|
|
88
99
|
* 스트림 생성 시에만** 적용(멱등 — 기존 스트림은 운영자가 NATS CLI 로 갱신).
|
|
89
100
|
* @property {number} [dlqMaxBytes] - DLQ 스트림 최대 크기(bytes). 미지정이면 byte 상한 없음(`max_age` 가
|
|
90
101
|
* 주 가드). 디스크 상한이 필요한 운영 환경에서만 지정.
|
|
102
|
+
* @property {number} [runTimeoutMs=1800000] - run 전체(재시도 포함) 실행 상한 디폴트(ms, 기본 30분).
|
|
103
|
+
* 초과 시 잡을 실패로 판정해 DLQ 라우팅 — 행 잡이 `working()` lease 를 영구 갱신하며 메시지를 점유하는
|
|
104
|
+
* 것을 막는다. 잡별 `static timeoutMs` 가 우선, `0` = 무제한. ⚠️ 타임아웃돼도 진행 중 run 은 중단되지
|
|
105
|
+
* 않는다(백그라운드 계속 — run 멱등 설계 필요). 나중 실패는 fail(abandoned-run) 으로 표면화.
|
|
91
106
|
*/
|
|
92
107
|
|
|
93
108
|
/**
|
|
@@ -116,17 +131,25 @@ export class MegaJobQueue extends EventEmitter {
|
|
|
116
131
|
/** @type {string} */ #streamPrefix
|
|
117
132
|
/** @type {number} DLQ max_age(ms). 0 = 무제한. */ #dlqMaxAgeMs
|
|
118
133
|
/** @type {number|undefined} DLQ max_bytes. undefined = 무제한. */ #dlqMaxBytes
|
|
134
|
+
/** @type {number} run 전체 실행 상한 디폴트(ms). 0 = 무제한. 잡별 static timeoutMs 가 우선. */ #runTimeoutMs
|
|
119
135
|
/** @type {typeof import('nats')|null} 지연 로드된 nats 모듈(enum/codec/nanos). */ #nats = null
|
|
120
136
|
/** @type {import('nats').Codec<any>|null} */ #codec = null
|
|
121
137
|
/** @type {import('nats').JetStreamClient|null} */ #js = null
|
|
122
138
|
/** @type {import('nats').JetStreamManager|null} */ #jsm = null
|
|
123
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()
|
|
124
147
|
|
|
125
148
|
/**
|
|
126
149
|
* @param {MegaJobQueueOptions} options
|
|
127
150
|
* @throws {TypeError} nc 가 JetStream 가능한 NatsConnection 이 아니면(fail-fast).
|
|
128
151
|
*/
|
|
129
|
-
constructor({ nc, ackWaitMs = 30_000, maxDeliver = 5, heartbeatMs, streamPrefix = 'MEGA_JOBS', dlqMaxAgeMs = DEFAULT_DLQ_MAX_AGE_MS, dlqMaxBytes } = /** @type {any} */ ({})) {
|
|
152
|
+
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
153
|
super()
|
|
131
154
|
if (!nc || typeof nc.jetstream !== 'function' || typeof nc.jetstreamManager !== 'function') {
|
|
132
155
|
throw new TypeError(
|
|
@@ -146,6 +169,15 @@ export class MegaJobQueue extends EventEmitter {
|
|
|
146
169
|
if (dlqMaxBytes !== undefined && (typeof dlqMaxBytes !== 'number' || !Number.isInteger(dlqMaxBytes) || dlqMaxBytes < 1)) {
|
|
147
170
|
throw new TypeError(`MegaJobQueue: dlqMaxBytes must be an integer >= 1 when set. Got: ${dlqMaxBytes}.`)
|
|
148
171
|
}
|
|
172
|
+
// runTimeoutMs: 0 = 무제한(끔). 음수/비정수는 운영 실수라 fail-fast(silent 보정 X).
|
|
173
|
+
if (typeof runTimeoutMs !== 'number' || !Number.isInteger(runTimeoutMs) || runTimeoutMs < 0) {
|
|
174
|
+
throw new TypeError(`MegaJobQueue: runTimeoutMs must be an integer >= 0 (0 = unlimited). Got: ${runTimeoutMs}.`)
|
|
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
|
+
}
|
|
149
181
|
this.#nc = nc
|
|
150
182
|
this.#ackWaitMs = ackWaitMs
|
|
151
183
|
this.#maxDeliver = maxDeliver
|
|
@@ -153,6 +185,7 @@ export class MegaJobQueue extends EventEmitter {
|
|
|
153
185
|
this.#streamPrefix = streamPrefix
|
|
154
186
|
this.#dlqMaxAgeMs = dlqMaxAgeMs
|
|
155
187
|
this.#dlqMaxBytes = dlqMaxBytes
|
|
188
|
+
this.#runTimeoutMs = runTimeoutMs
|
|
156
189
|
}
|
|
157
190
|
|
|
158
191
|
// ── 이벤트 화이트리스트(L-1 정책 — 형제 클래스와 동일) ────────────────────
|
|
@@ -317,16 +350,30 @@ export class MegaJobQueue extends EventEmitter {
|
|
|
317
350
|
* @param {typeof MegaJob} JobClass @returns {Promise<void>}
|
|
318
351
|
*/
|
|
319
352
|
async ensureStream(JobClass) {
|
|
320
|
-
await this.ensureReady()
|
|
321
353
|
const subject = this.#assertJobSubject(JobClass)
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
|
330
377
|
}
|
|
331
378
|
|
|
332
379
|
/**
|
|
@@ -380,14 +427,26 @@ export class MegaJobQueue extends EventEmitter {
|
|
|
380
427
|
}
|
|
381
428
|
|
|
382
429
|
/**
|
|
383
|
-
* 잡을 큐에 넣는다(JetStream publish — 서버에 영속 저장). 스트림이 없으면
|
|
384
|
-
*
|
|
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 }>}
|
|
385
438
|
*/
|
|
386
|
-
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
|
+
}
|
|
387
443
|
await this.ensureStream(JobClass)
|
|
388
444
|
const subject = /** @type {string} */ (JobClass.subject)
|
|
389
445
|
const js = /** @type {import('nats').JetStreamClient} */ (this.#js)
|
|
390
|
-
|
|
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)
|
|
391
450
|
// dispatch 는 publish(부작용) 완료 후 발화 — 리스너 throw 가 반환값/흐름을 오염시키지 않게 safeEmit(L-3).
|
|
392
451
|
this.#safeEmit('dispatch', { subject, seq: ack.seq, stream: ack.stream })
|
|
393
452
|
return { seq: ack.seq, stream: ack.stream, duplicate: ack.duplicate }
|
|
@@ -416,6 +475,7 @@ export class MegaJobQueue extends EventEmitter {
|
|
|
416
475
|
const durable = this.#durableName(subject)
|
|
417
476
|
const concurrency = this.#resolveConcurrency(JobClass) // L-1: 양의 정수 fail-fast(max_ack_pending=0 풋건 차단).
|
|
418
477
|
const retryConfig = resolveJobRetryConfig(JobClass)
|
|
478
|
+
const runTimeoutMs = resolveJobRunTimeoutMs(JobClass, this.#runTimeoutMs) // 행 잡 영구 점유 backstop.
|
|
419
479
|
await this.#ensureConsumer(stream, durable, subject, concurrency)
|
|
420
480
|
|
|
421
481
|
const js = /** @type {import('nats').JetStreamClient} */ (this.#js)
|
|
@@ -426,7 +486,7 @@ export class MegaJobQueue extends EventEmitter {
|
|
|
426
486
|
const inFlight = new Set()
|
|
427
487
|
const loop = (async () => {
|
|
428
488
|
for await (const msg of messages) {
|
|
429
|
-
const p = this._handleMessage(instance, ctx, msg, retryConfig, subject).finally(() =>
|
|
489
|
+
const p = this._handleMessage(instance, ctx, msg, retryConfig, subject, runTimeoutMs).finally(() =>
|
|
430
490
|
inFlight.delete(p),
|
|
431
491
|
)
|
|
432
492
|
inFlight.add(p)
|
|
@@ -464,9 +524,10 @@ export class MegaJobQueue extends EventEmitter {
|
|
|
464
524
|
*
|
|
465
525
|
* @param {MegaJob} instance @param {Record<string, any>} ctx @param {import('nats').JsMsg} msg
|
|
466
526
|
* @param {ReturnType<typeof resolveJobRetryConfig>} retryConfig @param {string} subject
|
|
527
|
+
* @param {number} [runTimeoutMs] - run 전체(재시도 포함) 상한(ms). 미지정 시 큐 디폴트, 0 = 무제한.
|
|
467
528
|
* @returns {Promise<MegaJobHandleResult>}
|
|
468
529
|
*/
|
|
469
|
-
async _handleMessage(instance, ctx, msg, retryConfig, subject) {
|
|
530
|
+
async _handleMessage(instance, ctx, msg, retryConfig, subject, runTimeoutMs = this.#runTimeoutMs) {
|
|
470
531
|
const seq = msg.seq
|
|
471
532
|
|
|
472
533
|
// M-1(ADR-119 hardening): 이번 전달이 max_deliver 에 도달했고(deliveryCount >= maxDeliver)
|
|
@@ -520,8 +581,14 @@ export class MegaJobQueue extends EventEmitter {
|
|
|
520
581
|
let runResult
|
|
521
582
|
/** @type {Error|null} run 최종 실패 사유(성공이면 null). */
|
|
522
583
|
let runError = null
|
|
584
|
+
/** @type {NodeJS.Timeout|undefined} run 타임아웃 타이머(있으면 finally 에서 정리). */
|
|
585
|
+
let timeoutHandle
|
|
586
|
+
/** @type {boolean} 타임아웃이 race 를 이겼는지(버려진 run 의 잔여 실패 표면화 분기용). */
|
|
587
|
+
let timedOut = false
|
|
588
|
+
/** @type {Promise<any>|undefined} run(재시도) Promise — 타임아웃 시 잔여 실패 관찰에 필요. */
|
|
589
|
+
let runPromise
|
|
523
590
|
try {
|
|
524
|
-
|
|
591
|
+
runPromise = withRetry(() => instance.run(payload, ctx), {
|
|
525
592
|
...retryConfig,
|
|
526
593
|
onFailedAttempt: (info) => {
|
|
527
594
|
this.#safeEmit('retry', {
|
|
@@ -533,11 +600,44 @@ export class MegaJobQueue extends EventEmitter {
|
|
|
533
600
|
})
|
|
534
601
|
},
|
|
535
602
|
})
|
|
603
|
+
if (runTimeoutMs > 0) {
|
|
604
|
+
// 행(hang) 잡 backstop: run 전체(재시도 포함)에 상한을 건다. 상한 초과 = 실패 판정 → DLQ.
|
|
605
|
+
// 없으면 working() 하트비트가 lease 를 영원히 갱신해 메시지가 재전달도 DLQ 도 못 가는
|
|
606
|
+
// 영구 점유가 된다(프로세스 사망 시에만 해소).
|
|
607
|
+
runResult = await Promise.race([
|
|
608
|
+
runPromise,
|
|
609
|
+
new Promise((_resolve, reject) => {
|
|
610
|
+
timeoutHandle = setTimeout(() => {
|
|
611
|
+
timedOut = true
|
|
612
|
+
reject(new Error(
|
|
613
|
+
`MegaJobQueue: job on '${subject}' exceeded run timeout (${runTimeoutMs}ms) — treating as ` +
|
|
614
|
+
`failed (run continues in background; design run to be idempotent).`,
|
|
615
|
+
))
|
|
616
|
+
}, runTimeoutMs)
|
|
617
|
+
if (typeof timeoutHandle.unref === 'function') timeoutHandle.unref()
|
|
618
|
+
}),
|
|
619
|
+
])
|
|
620
|
+
} else {
|
|
621
|
+
runResult = await runPromise
|
|
622
|
+
}
|
|
536
623
|
} catch (err) {
|
|
537
624
|
runError = err instanceof Error ? err : new Error(String(err))
|
|
625
|
+
if (timedOut && runPromise) {
|
|
626
|
+
// 버려진(타임아웃 패배) run 의 나중 실패를 묵히지 않고 표면화한다 — 잡은 이미 실패 판정·DLQ
|
|
627
|
+
// 라우팅됐으므로 처리 흐름엔 영향 없다(reject 자체는 race 가 구독해 unhandledRejection 아님).
|
|
628
|
+
runPromise.catch((lateErr) => {
|
|
629
|
+
this.#safeEmit('fail', {
|
|
630
|
+
subject,
|
|
631
|
+
seq,
|
|
632
|
+
error: lateErr instanceof Error ? lateErr : new Error(String(lateErr)),
|
|
633
|
+
phase: 'abandoned-run',
|
|
634
|
+
})
|
|
635
|
+
})
|
|
636
|
+
}
|
|
538
637
|
} finally {
|
|
539
638
|
settled = true
|
|
540
639
|
clearInterval(heartbeat)
|
|
640
|
+
if (timeoutHandle !== undefined) clearTimeout(timeoutHandle)
|
|
541
641
|
}
|
|
542
642
|
|
|
543
643
|
if (runError === null) {
|
|
@@ -552,30 +652,47 @@ export class MegaJobQueue extends EventEmitter {
|
|
|
552
652
|
}
|
|
553
653
|
|
|
554
654
|
/**
|
|
555
|
-
* DLQ 라우팅 — 실패 페이로드+에러 메타를 `<subject>.dlq` 로 발행하고 원본을 ack.
|
|
556
|
-
*
|
|
655
|
+
* DLQ 라우팅 — 실패 페이로드+에러 메타를 `<subject>.dlq` 로 발행하고 원본을 ack. 발행은 인프로세스로
|
|
656
|
+
* 짧게 재시도(일시 장애 흡수)하고, 그래도 실패하면 ack 하지 않고 **점증 지연 nak** 해 잡을 보존한다
|
|
657
|
+
* (at-least-once — 즉시 재전달 핫 루프 방지). 단 이번 전달이 `max_deliver` 의 **마지막 전달**이면 nak 해도
|
|
658
|
+
* 재전달이 없어 메시지가 워크 스트림에 orphan 으로 남는다 — 이때는 un-ack 보존 + `fail(dlq-orphan)` 으로
|
|
659
|
+
* 운영자 개입을 명시 표면화한다(ack/term 으로 잡을 지우면 유실이라 하지 않는다). 본 메서드도 throw 하지 않는다.
|
|
557
660
|
* @param {string} subject @param {any} payload @param {Error} error @param {import('nats').JsMsg} msg @param {number} seq @returns {Promise<void>}
|
|
558
661
|
*/
|
|
559
662
|
async #routeToDlq(subject, payload, error, msg, seq) {
|
|
560
663
|
const dlqSubject = `${subject}.dlq`
|
|
561
664
|
const js = /** @type {import('nats').JetStreamClient} */ (this.#js)
|
|
562
665
|
try {
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
666
|
+
// DLQ publish 자체를 짧게 재시도(추가 2회, 250ms→1s) — 일시적 NATS 응답 지연/재연결 틈을 흡수해
|
|
667
|
+
// nak 재전달(전체 인프로세스 재시도 사이클 반복)보다 훨씬 싸게 orphan 확률을 줄인다.
|
|
668
|
+
await withRetry(
|
|
669
|
+
() =>
|
|
670
|
+
js.publish(
|
|
671
|
+
dlqSubject,
|
|
672
|
+
this.#encode({
|
|
673
|
+
originalSubject: subject,
|
|
674
|
+
failedAt: new Date().toISOString(),
|
|
675
|
+
deliveryCount: msg.info.deliveryCount,
|
|
676
|
+
// stack 전체를 봉투에 담으면 poison 잡 폭주 시 DLQ 직렬화 비용·디스크가 급증한다 → 상한으로 truncate(Med).
|
|
677
|
+
error: { name: error.name, message: error.message, stack: truncateStack(error.stack) },
|
|
678
|
+
payload,
|
|
679
|
+
}),
|
|
680
|
+
),
|
|
681
|
+
{ retries: 2, minTimeout: 250, maxTimeout: 1000, factor: 2, jitter: true },
|
|
573
682
|
)
|
|
574
683
|
} catch (pubErr) {
|
|
575
|
-
// DLQ 발행 실패 → ack 안 함(보존). nak 으로 재전달 요청 → DLQ 백엔드 회복 후 재시도(안 묻음).
|
|
576
684
|
const e = pubErr instanceof Error ? pubErr : new Error(String(pubErr))
|
|
577
|
-
msg.
|
|
578
|
-
|
|
685
|
+
if (msg.info.deliveryCount >= this.#maxDeliver) {
|
|
686
|
+
// 마지막 전달 — nak 해도 JetStream 이 더는 재전달하지 않는다. ack/term 하면 잡이 사라지므로
|
|
687
|
+
// un-ack 로 워크 스트림에 보존하고(운영자가 NATS CLI 로 수습 가능), orphan 을 크게 표면화한다.
|
|
688
|
+
this.#safeEmit('fail', { subject, seq, error: e, phase: 'dlq-orphan' })
|
|
689
|
+
return
|
|
690
|
+
}
|
|
691
|
+
// DLQ 발행 실패 → ack 안 함(보존). 점증 지연 nak — 즉시 재전달이면 DLQ 백엔드 장애 동안
|
|
692
|
+
// 재전달→재시도 사이클 전체가 타이트하게 도는 핫 루프가 된다(deliveryCount 기반 지수 지연).
|
|
693
|
+
const nakDelayMs = Math.min(NAK_DELAY_MAX_MS, 1000 * 2 ** (msg.info.deliveryCount - 1))
|
|
694
|
+
msg.nak(nakDelayMs) // 부작용(nak) 먼저 확정 — emit 보다 앞선다(M-3: 리스너 throw 가 보존 결정을 막지 않게).
|
|
695
|
+
this.#safeEmit('fail', { subject, seq, error: e, phase: 'dlq-publish', nakDelayMs })
|
|
579
696
|
return
|
|
580
697
|
}
|
|
581
698
|
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 (라이브러리 래핑)
|
|
@@ -72,7 +73,14 @@ export class MegaJob {
|
|
|
72
73
|
/** @type {string|undefined} bus 별명(`ctx.bus(alias)`). 워커 배선이 nc 해석에 사용. */
|
|
73
74
|
static bus = undefined
|
|
74
75
|
|
|
75
|
-
/**
|
|
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
|
+
*/
|
|
76
84
|
static concurrency = 1
|
|
77
85
|
|
|
78
86
|
/** @type {number} run 실패 시 **추가** 재시도 횟수(첫 시도 제외). 기본 3(OQ-012). */
|
|
@@ -81,6 +89,15 @@ export class MegaJob {
|
|
|
81
89
|
/** @type {MegaJobBackoff} 지수 백오프 설정. 기본 { exponential, 1s, 30s }(OQ-012). */
|
|
82
90
|
static backoff = JOB_RETRY_DEFAULTS.backoff
|
|
83
91
|
|
|
92
|
+
/**
|
|
93
|
+
* @type {number|undefined} run 전체(재시도 포함) 실행 상한(ms). 초과 시 큐가 잡을 실패로 판정해 DLQ 로
|
|
94
|
+
* 보낸다 — 행(hang)된 run 이 `working()` lease 를 영원히 갱신하며 메시지를 영구 점유하는 것을 막는다.
|
|
95
|
+
* 미지정 시 {@link import('./mega-job-queue.js').MegaJobQueue} 의 `runTimeoutMs`(기본 30분), `0` = 무제한.
|
|
96
|
+
* ⚠️ 타임아웃돼도 진행 중이던 run 은 JS 특성상 중단되지 않는다(백그라운드 계속) — run 은 멱등하게
|
|
97
|
+
* 설계해야 한다(at-least-once, 모듈 docstring).
|
|
98
|
+
*/
|
|
99
|
+
static timeoutMs = undefined
|
|
100
|
+
|
|
84
101
|
/**
|
|
85
102
|
* 메시지 1건마다 실행되는 본문. 서브클래스가 **반드시** 구현한다. throw 하면 {@link MegaJobQueue}
|
|
86
103
|
* 가 재시도하고, 재시도 소진 시 DLQ 로 보낸다 — 그러므로 비치명/일시 오류는 그냥 throw 하면 된다.
|
|
@@ -138,3 +155,22 @@ export function resolveJobRetryConfig(JobClass) {
|
|
|
138
155
|
// factor=2(exponential), jitter=on — OQ-012 정합. MegaRetry 가 첫 시도 즉시 + 이후 지수 백오프.
|
|
139
156
|
return { retries, minTimeout: initial, maxTimeout: max, factor: 2, jitter: true }
|
|
140
157
|
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 잡 클래스의 `static timeoutMs` 를 검증해 반환한다. 미지정(undefined/null)이면 `defaultMs`(큐 디폴트),
|
|
161
|
+
* `0` = 무제한. 음수/비정수는 운영 실수라 fail-fast(silent 보정 금지).
|
|
162
|
+
*
|
|
163
|
+
* @param {typeof MegaJob} JobClass - 잡 클래스.
|
|
164
|
+
* @param {number} defaultMs - 큐 레벨 디폴트(ms, 0 = 무제한).
|
|
165
|
+
* @returns {number} 적용할 타임아웃(ms). 0 = 무제한.
|
|
166
|
+
* @throws {TypeError} timeoutMs 가 0 이상 정수가 아닐 때.
|
|
167
|
+
*/
|
|
168
|
+
export function resolveJobRunTimeoutMs(JobClass, defaultMs) {
|
|
169
|
+
const timeoutMs = JobClass.timeoutMs ?? defaultMs
|
|
170
|
+
if (typeof timeoutMs !== 'number' || !Number.isInteger(timeoutMs) || timeoutMs < 0) {
|
|
171
|
+
throw new TypeError(
|
|
172
|
+
`MegaJob '${JobClass.name}': static timeoutMs must be an integer >= 0 (0 = unlimited). Got: ${timeoutMs}.`,
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
return timeoutMs
|
|
176
|
+
}
|
package/src/lib/mega-metrics.js
CHANGED
|
@@ -36,14 +36,10 @@
|
|
|
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
|
-
import {
|
|
42
|
-
import {
|
|
43
|
-
ATTR_SERVICE_NAME,
|
|
44
|
-
ATTR_SERVICE_VERSION,
|
|
45
|
-
ATTR_DEPLOYMENT_ENVIRONMENT_NAME,
|
|
46
|
-
} from '@opentelemetry/semantic-conventions'
|
|
42
|
+
import { buildOtelResource } from './otel-resource.js'
|
|
47
43
|
import { MegaConfigError } from '../errors/config-error.js'
|
|
48
44
|
|
|
49
45
|
/** meter 이름 (instrumentation scope) — OTel 컨벤션상 패키지명. */
|
|
@@ -147,12 +143,8 @@ export function init(opts = /** @type {any} */ ({})) {
|
|
|
147
143
|
)
|
|
148
144
|
}
|
|
149
145
|
|
|
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
|
-
})
|
|
146
|
+
// resource 조립은 트레이싱과 공유하는 단일 출처(otel-resource.js, ADR-193) — 두 SDK 간 드리프트 방지.
|
|
147
|
+
const resource = buildOtelResource({ serviceName, version: opts.version, environment: opts.environment, attributes: opts.attributes })
|
|
156
148
|
|
|
157
149
|
// preventServerStart — 자체 :9464 서버를 띄우지 않고 우리가 collect() 로 직접 긁는다(메인 포트 서빙).
|
|
158
150
|
const reader = new PrometheusExporter({ preventServerStart: true })
|
|
@@ -556,7 +548,7 @@ function buildInstruments(meter) {
|
|
|
556
548
|
*/
|
|
557
549
|
function registerSystemGauges(meter) {
|
|
558
550
|
const memory = meter.createObservableGauge('mega_process_memory_bytes', {
|
|
559
|
-
description: '프로세스 메모리 사용량(바이트) — kind=rss|heap_used|heap_total|external 라벨.',
|
|
551
|
+
description: '프로세스 메모리 사용량(바이트) — kind=rss|heap_used|heap_total|external|array_buffers 라벨.',
|
|
560
552
|
unit: 'By',
|
|
561
553
|
})
|
|
562
554
|
memory.addCallback((result) => {
|
|
@@ -565,6 +557,32 @@ function registerSystemGauges(meter) {
|
|
|
565
557
|
result.observe(mu.heapUsed, { kind: 'heap_used' })
|
|
566
558
|
result.observe(mu.heapTotal, { kind: 'heap_total' })
|
|
567
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
|
+
}
|
|
568
586
|
})
|
|
569
587
|
|
|
570
588
|
const uptime = meter.createObservableGauge('mega_process_uptime_seconds', {
|
package/src/lib/mega-plugin.js
CHANGED
|
@@ -53,11 +53,23 @@ const LIFECYCLE_EVENTS = /** @type {const} */ (['beforeBoot', 'afterBoot', 'befo
|
|
|
53
53
|
*/
|
|
54
54
|
|
|
55
55
|
/**
|
|
56
|
+
* 플러그인 scaffold generator manifest(03-api-spec §11) — `mega g <name>` 이 소비한다(ADR-187/199).
|
|
57
|
+
* `dir` 는 프로젝트 루트 상대 출력 기준, `files[].path`/`files[].template` 의 `{{token}}` 은
|
|
58
|
+
* cli/generators 의 `SCAFFOLD_TOKENS` 계약(Name/name/camelName/snake/app)으로 치환된다.
|
|
56
59
|
* @typedef {Object} MegaScaffoldDef
|
|
57
60
|
* @property {string} dir
|
|
58
61
|
* @property {Array<{ path: string, template: string }>} files
|
|
62
|
+
* @property {string} [description] - `mega help` 가 병기하는 한 줄 설명(선택).
|
|
59
63
|
*/
|
|
60
64
|
|
|
65
|
+
/** 빌트인 generator 이름 — 플러그인 scaffold 가 점유 금지(빌트인이 우선이라 silent shadow 가 됨).
|
|
66
|
+
* cli/generators 의 `GENERATOR_KINDS` 와 동일해야 한다(레이어 역전 회피를 위해 미러 — 동기화는
|
|
67
|
+
* mega-plugin 단위 테스트가 GENERATOR_KINDS 와 집합 비교로 강제한다). */
|
|
68
|
+
export const RESERVED_GENERATOR_NAMES = new Set([
|
|
69
|
+
'app', 'controller', 'channel', 'service', 'model', 'middleware', 'route',
|
|
70
|
+
'schedule', 'job', 'worker', 'locale', 'adapter', 'migration', 'adr',
|
|
71
|
+
])
|
|
72
|
+
|
|
61
73
|
/**
|
|
62
74
|
* @typedef {Object} LoadedPluginMeta
|
|
63
75
|
* @property {string} name
|
|
@@ -230,7 +242,8 @@ export class MegaPluginHost {
|
|
|
230
242
|
}
|
|
231
243
|
|
|
232
244
|
/**
|
|
233
|
-
* 스캐폴드 generator
|
|
245
|
+
* 스캐폴드 generator manifest 등록(ADR-199). 같은 이름 재등록·빌트인 13종 점유·파일 항목 모양 위반은
|
|
246
|
+
* **install 시점 fail-fast** — 첫 `mega g` 사용 때까지 잘못된 manifest 가 잠복하지 않게 한다.
|
|
234
247
|
* @param {string} name
|
|
235
248
|
* @param {MegaScaffoldDef} def
|
|
236
249
|
* @returns {void}
|
|
@@ -239,15 +252,33 @@ export class MegaPluginHost {
|
|
|
239
252
|
if (typeof name !== 'string' || name.length === 0) {
|
|
240
253
|
throw new TypeError('mega.scaffold.register: name must be a non-empty string.')
|
|
241
254
|
}
|
|
242
|
-
if (
|
|
255
|
+
if (RESERVED_GENERATOR_NAMES.has(name)) {
|
|
256
|
+
// 빌트인 kind 는 `mega g` 디스패치에서 항상 우선이라, 등록을 허용하면 도달 불가(silent shadow)다.
|
|
257
|
+
throw new MegaConfigError('plugin.builtin_generator_conflict', `Plugin cannot register builtin generator "${name}" (reserved).`, {
|
|
258
|
+
details: { name },
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
if (!def || typeof def.dir !== 'string' || def.dir.length === 0 || !Array.isArray(def.files)) {
|
|
243
262
|
throw new TypeError(`mega.scaffold.register('${name}'): def must be { dir: string, files: array }.`)
|
|
244
263
|
}
|
|
264
|
+
for (const f of def.files) {
|
|
265
|
+
if (!f || typeof f.path !== 'string' || f.path.length === 0 || typeof f.template !== 'string') {
|
|
266
|
+
throw new TypeError(`mega.scaffold.register('${name}'): files entries must be { path: string, template: string }. Got ${JSON.stringify(f)}.`)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (def.description !== undefined && typeof def.description !== 'string') {
|
|
270
|
+
throw new TypeError(`mega.scaffold.register('${name}'): description must be a string when given.`)
|
|
271
|
+
}
|
|
245
272
|
if (this.#generators.has(name)) {
|
|
246
273
|
throw new MegaConfigError('plugin.duplicate_generator', `Scaffold generator '${name}' is already registered.`, {
|
|
247
274
|
details: { name },
|
|
248
275
|
})
|
|
249
276
|
}
|
|
250
|
-
this.#generators.set(name, {
|
|
277
|
+
this.#generators.set(name, {
|
|
278
|
+
dir: def.dir,
|
|
279
|
+
files: def.files.map((f) => ({ path: f.path, template: f.template })),
|
|
280
|
+
...(typeof def.description === 'string' ? { description: def.description } : {}),
|
|
281
|
+
})
|
|
251
282
|
}
|
|
252
283
|
|
|
253
284
|
/**
|
package/src/lib/mega-schedule.js
CHANGED
|
@@ -15,9 +15,10 @@
|
|
|
15
15
|
* 먼저 잡은 1대만 실행하고, 못 잡은 나머지는 **조용히 건너뛴다**(skip). 락은 `ttl`(ms) 뒤 자동 만료돼
|
|
16
16
|
* 다음 주기엔 다시 경쟁한다.
|
|
17
17
|
*
|
|
18
|
-
* 구현: `lock.
|
|
19
|
-
* 즉시 skip 한다(retry 안 함 — retry+throw 는 "중복방지"가 아니라 "대기"라서 의미가 다름).
|
|
20
|
-
* `
|
|
18
|
+
* 구현: `lock.withLock(key, { ttl, retryCount: 0 }, run)` — **단 한 번** 시도하고 실패하면(이미 누가
|
|
19
|
+
* 잡음) 즉시 skip 한다(retry 안 함 — retry+throw 는 "중복방지"가 아니라 "대기"라서 의미가 다름).
|
|
20
|
+
* 성공하면 임계구역 동안 락이 **자동 연장**되고(redlock `using`), 종료 시 자동 release 된다 —
|
|
21
|
+
* run 이 ttl 을 넘겨도 락이 만료돼 다른 인스턴스가 끼어드는 중복 실행이 없다.
|
|
21
22
|
*
|
|
22
23
|
* # ⚠️ acquire 실패 = skip 의 한계 (ADR-118 기록)
|
|
23
24
|
* `acquire` 는 (a) 경합(다른 인스턴스가 보유)과 (b) 락 backend 장애(Redis 다운)를 **둘 다** throw 로
|
|
@@ -46,8 +47,10 @@ import { MegaCron } from './mega-cron.js'
|
|
|
46
47
|
* @typedef {Object} MegaScheduleLock
|
|
47
48
|
* @property {string} lock - lock 어댑터 별명(`ctx.lock(alias)` 로 해석). 03-api-spec 의 옛 `cache` 필드를
|
|
48
49
|
* 대체한다(lock 이 독립 도메인이 된 뒤 정합 — ADR-118).
|
|
49
|
-
* @property {number} ttl - 락 보유 시간(**밀리초**, 양의 정수).
|
|
50
|
-
*
|
|
50
|
+
* @property {number} ttl - 락 보유 시간(**밀리초**, 양의 정수). 임계구역 동안 **자동 연장**되므로
|
|
51
|
+
* (redlock `using`) 작업이 ttl 을 넘겨도 중복 실행되지 않는다 — ttl 은 "연장 1회분 윈도우"다.
|
|
52
|
+
* ⚠️ redlock 은 `ttl ≥ automaticExtensionThreshold(기본 500ms) + 100ms` 를 요구한다(기본 설정 기준
|
|
53
|
+
* **600ms 이상**) — 미달이면 fire 시 skip(error 동봉)으로 표면화된다.
|
|
51
54
|
* @property {string} [key] - 락 자원 키. 미지정 시 `mega:schedule:<클래스명>`.
|
|
52
55
|
*/
|
|
53
56
|
|
|
@@ -463,28 +466,43 @@ export class MegaScheduler extends EventEmitter {
|
|
|
463
466
|
}
|
|
464
467
|
const key = lock.key ?? `mega:schedule:${name}`
|
|
465
468
|
|
|
466
|
-
|
|
469
|
+
// withLock(= redlock `using`, ADR-113) — 단 한 번 시도(retryCount: 0) + **자동 연장** + 자동 release.
|
|
470
|
+
// run 이 ttl 을 넘겨도 redlock 이 임계구역 동안 락을 연장해 다른 인스턴스의 중복 실행을 막는다
|
|
471
|
+
// (기존 acquire/release 는 "ttl 을 넉넉히" 라는 운영자 추측에 의존 — ADR-118 재평가 조건 충족으로 전환).
|
|
472
|
+
// 경합/backend 장애는 routine 진입 **전에** throw 되므로 acquired 플래그로 skip(미획득)과
|
|
473
|
+
// release 실패(획득 후)를 구분한다. run(ctx) 계약은 무변경 — 연장 실패(signal.aborted)는
|
|
474
|
+
// 스케줄러가 관찰해 fail(lock-extend) 로 표면화한다.
|
|
475
|
+
let acquired = false
|
|
476
|
+
/** @type {MegaScheduleFireResult|undefined} */
|
|
477
|
+
let outcome
|
|
467
478
|
try {
|
|
468
|
-
|
|
469
|
-
|
|
479
|
+
await adapter.withLock(key, { ttl: lock.ttl, retryCount: 0 }, async (/** @type {any} */ signal) => {
|
|
480
|
+
acquired = true
|
|
481
|
+
outcome = await this.#runBody(name, instance, ctx, key)
|
|
482
|
+
// 자동 연장 실패 = 임계구역 도중 배타성 상실(드묾 — lock backend 장애). run 결과와 별개로 표면화.
|
|
483
|
+
if (signal?.aborted) {
|
|
484
|
+
const cause = signal.error instanceof Error ? signal.error : undefined
|
|
485
|
+
const error = new Error(
|
|
486
|
+
`lock auto-extension failed for '${key}' — exclusivity may have been lost during run`,
|
|
487
|
+
cause ? { cause } : undefined,
|
|
488
|
+
)
|
|
489
|
+
this.emit('fail', { name, key, error, phase: 'lock-extend' })
|
|
490
|
+
}
|
|
491
|
+
})
|
|
470
492
|
} catch (e) {
|
|
471
493
|
const error = e instanceof Error ? e : new Error(String(e))
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
try {
|
|
478
|
-
return await this.#runBody(name, instance, ctx, key)
|
|
479
|
-
} finally {
|
|
480
|
-
try {
|
|
481
|
-
await adapter.release(held)
|
|
482
|
-
} catch (e) {
|
|
483
|
-
// 락 해제 실패는 비치명적(ttl 로 자동 만료) — 다음 주기 재경쟁. 묵히지 않고 fail 로 표면화.
|
|
484
|
-
const error = e instanceof Error ? e : new Error(String(e))
|
|
485
|
-
this.emit('fail', { name, key, error, phase: 'release' })
|
|
494
|
+
if (!acquired) {
|
|
495
|
+
// 획득 실패 = 경합(다른 인스턴스 보유) 또는 backend 장애 — 구분 불가(상단 docstring).
|
|
496
|
+
// 에러를 실어 관측 가능하게 → 소비자가 로그/경보로 인프라 장애를 알아챈다.
|
|
497
|
+
this.emit('skip', { name, key, reason: 'lock-not-acquired', error })
|
|
498
|
+
return { name, ran: false, skipped: true, ok: false, error }
|
|
486
499
|
}
|
|
500
|
+
// routine 진입 후 throw = release 경로 실패(#runBody 는 throw 안 함) — 비치명적(ttl 만료로
|
|
501
|
+
// 자동 해제, 다음 주기 재경쟁). run 결과는 보존해 반환한다.
|
|
502
|
+
this.emit('fail', { name, key, error, phase: 'release' })
|
|
503
|
+
return outcome ?? { name, ran: true, skipped: false, ok: false, error }
|
|
487
504
|
}
|
|
505
|
+
return /** @type {MegaScheduleFireResult} */ (outcome)
|
|
488
506
|
}
|
|
489
507
|
|
|
490
508
|
/**
|