mega-framework 0.1.10 → 0.1.13
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 +14 -4
- package/package.json +23 -21
- package/sample/crud/.env +10 -2
- package/sample/crud/.env.example +8 -0
- package/sample/crud/apps/main/controllers/bus-controller.js +122 -0
- package/sample/crud/apps/main/controllers/lock-controller.js +117 -0
- package/sample/crud/apps/main/locales/server/en.json +31 -1
- package/sample/crud/apps/main/locales/server/ko.json +31 -1
- package/sample/crud/apps/main/public/js/bus-demo.js +131 -0
- package/sample/crud/apps/main/public/js/lock-demo.js +122 -0
- package/sample/crud/apps/main/routes/bus.js +43 -0
- package/sample/crud/apps/main/routes/lock.js +35 -0
- package/sample/crud/apps/main/services/jobs-demo-service.js +21 -14
- package/sample/crud/apps/main/views/bus/index.ejs +80 -0
- package/sample/crud/apps/main/views/layouts/main.ejs +2 -0
- package/sample/crud/apps/main/views/lock/index.ejs +99 -0
- package/sample/crud/docs/guide/03-service-model-db.md +110 -6
- package/sample/crud/docs/guide/05-scheduler-job-worker.md +3 -1
- package/sample/crud/docs/guide/09-distributed-lock-and-bus.md +224 -0
- package/sample/crud/docs/guide/10-multi-app.md +74 -0
- package/sample/crud/mega.config.js +32 -0
- package/sample/crud/package.json +3 -2
- package/sample/multi/.env +16 -0
- package/sample/multi/.env.example +17 -0
- package/sample/multi/README.md +54 -0
- package/sample/multi/apps/admin/app.config.js +24 -0
- package/sample/multi/apps/admin/controllers/admin-controller.js +42 -0
- package/sample/multi/apps/admin/public/js/admin.js +31 -0
- package/sample/multi/apps/admin/routes/pages.js +11 -0
- package/sample/multi/apps/admin/views/index.ejs +33 -0
- package/sample/multi/apps/web/app.config.js +30 -0
- package/sample/multi/apps/web/controllers/web-controller.js +45 -0
- package/sample/multi/apps/web/public/js/web.js +24 -0
- package/sample/multi/apps/web/routes/pages.js +13 -0
- package/sample/multi/apps/web/views/index.ejs +51 -0
- package/sample/multi/mega.config.js +42 -0
- package/sample/multi/package.json +20 -0
- package/sample/simple/package.json +2 -2
- package/src/adapters/nats-adapter.js +39 -44
- package/src/adapters/nats-codec.js +38 -0
- package/src/cli/commands/scaffold.js +1 -0
- package/src/cli/index.js +9 -1
- package/src/core/app-registry.js +69 -0
- package/src/core/boot.js +99 -0
- package/src/core/bus/cluster-bus.js +190 -0
- package/src/core/bus/contract.js +123 -0
- package/src/core/bus/index.js +285 -0
- package/src/core/bus/memory-bus.js +103 -0
- package/src/core/bus/nats-bus.js +203 -0
- package/src/core/config-validator.js +118 -1
- package/src/core/ctx-builder.js +14 -1
- package/src/core/index.js +2 -0
- package/src/core/lock/cluster-lock.js +174 -0
- package/src/core/lock/contract.js +123 -0
- package/src/core/lock/fifo-waitlist.js +93 -0
- package/src/core/lock/index.js +292 -0
- package/src/core/lock/memory-lock.js +162 -0
- package/src/core/lock/redis-lock.js +276 -0
- package/src/core/mega-app.js +29 -0
- package/src/core/migration/generate.js +1 -1
- package/src/core/migration/journal.js +1 -1
- package/src/core/scope-registry.js +9 -0
- package/src/eslint-plugin/no-direct-model-import.js +2 -2
- package/src/index.js +2 -0
- package/src/lib/mega-job-queue.js +71 -47
- package/templates/model/code-mongo.tpl +1 -9
- package/templates/model/code.tpl +1 -10
- package/templates/model/test-mongo.tpl +1 -23
- package/templates/model/test.tpl +0 -17
- package/types/adapters/mega-adapter.d.ts +1 -1
- package/types/adapters/nats-adapter.d.ts +4 -4
- package/types/adapters/nats-codec.d.ts +13 -0
- package/types/adapters/redlock-adapter.d.ts +1 -1
- package/types/core/app-registry.d.ts +22 -0
- package/types/core/bus/cluster-bus.d.ts +45 -0
- package/types/core/bus/contract.d.ts +164 -0
- package/types/core/bus/index.d.ts +100 -0
- package/types/core/bus/memory-bus.d.ts +45 -0
- package/types/core/bus/nats-bus.d.ts +41 -0
- package/types/core/index.d.ts +1 -0
- package/types/core/lock/cluster-lock.d.ts +44 -0
- package/types/core/lock/contract.d.ts +181 -0
- package/types/core/lock/fifo-waitlist.d.ts +38 -0
- package/types/core/lock/index.d.ts +96 -0
- package/types/core/lock/memory-lock.d.ts +58 -0
- package/types/core/lock/redis-lock.d.ts +43 -0
- package/types/core/mega-app.d.ts +10 -0
- package/types/core/scope-registry.d.ts +6 -0
- package/types/index.d.ts +1 -1
- package/types/lib/mega-job-queue.d.ts +27 -4
- package/sample/simple/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
|
@@ -38,13 +38,22 @@
|
|
|
38
38
|
* `fail`(최종 실패 — phase 로 단계 구분) · `dlq`(DLQ 라우팅). 소비자가 구독해 로그를 박는다.
|
|
39
39
|
* 타이머·콜백 경로라 호출자가 없으므로 `fail`/`dlq` 구독이 사실상 필수다.
|
|
40
40
|
*
|
|
41
|
+
* # nats v3 (`@nats-io/jetstream`) — JetStream API 위치 변경 (ADR-225)
|
|
42
|
+
* v2 `nats` 단일 패키지에서 `@nats-io/*` 로 분리됐다. JetStream client/manager 는 `nc.jetstream()`/
|
|
43
|
+
* `nc.jetstreamManager()` **메서드**에서 `jetstream(nc)`/`jetstreamManager(nc)` **함수**로 바뀌었고,
|
|
44
|
+
* enum(RetentionPolicy/StorageType/AckPolicy)은 `@nats-io/jetstream`, `nanos`/codec 은
|
|
45
|
+
* `@nats-io/nats-core` 로 이동, stream/consumer "not found" 가 문자열 code('404')에서 전용 에러
|
|
46
|
+
* 클래스(`StreamNotFoundError`/`ConsumerNotFoundError`)로 바뀌었다. 잡 미사용 앱에 nats 로드를
|
|
47
|
+
* 강제하지 않으려 ensureReady 가 `@nats-io/*` 를 lazy import 한다(어댑터 정합).
|
|
48
|
+
*
|
|
41
49
|
* @module lib/mega-job-queue
|
|
42
|
-
* @see ADR-119, ADR-028 (3종 분리), ADR-112 (NATS 어댑터), ADR-029 (래핑)
|
|
50
|
+
* @see ADR-119, ADR-028 (3종 분리), ADR-112 (NATS 어댑터), ADR-225 (nats v3 전환), ADR-029 (래핑)
|
|
43
51
|
*/
|
|
44
52
|
import { EventEmitter } from 'node:events'
|
|
45
53
|
import { MegaConfigError } from '../errors/config-error.js'
|
|
46
54
|
import { MegaJob, resolveJobRetryConfig, resolveJobRunTimeoutMs } from './mega-job.js'
|
|
47
55
|
import { withRetry } from './mega-retry.js'
|
|
56
|
+
import { encodeJson, decodeJson } from '../adapters/nats-codec.js'
|
|
48
57
|
|
|
49
58
|
/** 잡 큐가 노출하는 이벤트 화이트리스트(오타 차단 + 문서화 — 형제 클래스와 동일 정책). */
|
|
50
59
|
const KNOWN_EVENTS = Object.freeze([
|
|
@@ -56,9 +65,6 @@ const KNOWN_EVENTS = Object.freeze([
|
|
|
56
65
|
'dlq', // DLQ 라우팅 완료 — event.dlqSubject
|
|
57
66
|
])
|
|
58
67
|
|
|
59
|
-
/** JetStream "stream/consumer not found" 에러 코드(실측 — nats 2.29, code '404'). */
|
|
60
|
-
const NOT_FOUND_CODE = '404'
|
|
61
|
-
|
|
62
68
|
/** DLQ 스트림 `max_age` 디폴트(ms) — 7일. 무한 적재 방지(ADR-134). `dlqMaxAgeMs: 0` 으로 끌 수 있다. */
|
|
63
69
|
export const DEFAULT_DLQ_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
|
|
64
70
|
|
|
@@ -86,7 +92,7 @@ function truncateStack(stack) {
|
|
|
86
92
|
|
|
87
93
|
/**
|
|
88
94
|
* @typedef {Object} MegaJobQueueOptions
|
|
89
|
-
* @property {import('nats').NatsConnection} nc - **연결된** NatsConnection(`ctx.bus(alias).native`).
|
|
95
|
+
* @property {import('@nats-io/nats-core').NatsConnection} nc - **연결된** NatsConnection(`ctx.bus(alias).native`).
|
|
90
96
|
* @property {number} [ackWaitMs=30000] - consumer ack 대기(ms). 초과 시 JetStream 재전달. `working()`
|
|
91
97
|
* 하트비트가 이 값의 절반마다 lease 를 갱신한다.
|
|
92
98
|
* @property {number} [maxDeliver=5] - JetStream 최대 전달 횟수(워커 크래시 재전달 backstop).
|
|
@@ -112,6 +118,18 @@ function truncateStack(stack) {
|
|
|
112
118
|
* @property {Error} [error] - 실패 사유(실패 시).
|
|
113
119
|
*/
|
|
114
120
|
|
|
121
|
+
/**
|
|
122
|
+
* ensureReady 가 lazy import 로 채우는 v3 nats 심볼 묶음(ADR-225). enum·nanos·not-found 에러 클래스만
|
|
123
|
+
* 추려 보관해 잡 미사용 앱에 nats 전체 로드를 강제하지 않는다.
|
|
124
|
+
* @typedef {Object} MegaJobQueueNatsBundle
|
|
125
|
+
* @property {typeof import('@nats-io/jetstream').RetentionPolicy} RetentionPolicy
|
|
126
|
+
* @property {typeof import('@nats-io/jetstream').AckPolicy} AckPolicy
|
|
127
|
+
* @property {typeof import('@nats-io/jetstream').StorageType} StorageType
|
|
128
|
+
* @property {typeof import('@nats-io/nats-core').nanos} nanos
|
|
129
|
+
* @property {typeof import('@nats-io/jetstream').JetStreamApiError} JetStreamApiError
|
|
130
|
+
* @property {typeof import('@nats-io/jetstream').JetStreamApiCodes} JetStreamApiCodes
|
|
131
|
+
*/
|
|
132
|
+
|
|
115
133
|
/**
|
|
116
134
|
* JetStream 잡 큐 런타임. {@link MegaJob} 클래스를 받아 enqueue/consume·재시도·DLQ 를 처리한다.
|
|
117
135
|
*
|
|
@@ -124,7 +142,7 @@ function truncateStack(stack) {
|
|
|
124
142
|
* await sub.stop()
|
|
125
143
|
*/
|
|
126
144
|
export class MegaJobQueue extends EventEmitter {
|
|
127
|
-
/** @type {import('nats').NatsConnection} */ #nc
|
|
145
|
+
/** @type {import('@nats-io/nats-core').NatsConnection} */ #nc
|
|
128
146
|
/** @type {number} */ #ackWaitMs
|
|
129
147
|
/** @type {number} */ #maxDeliver
|
|
130
148
|
/** @type {number} */ #heartbeatMs
|
|
@@ -132,10 +150,9 @@ export class MegaJobQueue extends EventEmitter {
|
|
|
132
150
|
/** @type {number} DLQ max_age(ms). 0 = 무제한. */ #dlqMaxAgeMs
|
|
133
151
|
/** @type {number|undefined} DLQ max_bytes. undefined = 무제한. */ #dlqMaxBytes
|
|
134
152
|
/** @type {number} run 전체 실행 상한 디폴트(ms). 0 = 무제한. 잡별 static timeoutMs 가 우선. */ #runTimeoutMs
|
|
135
|
-
/** @type {
|
|
136
|
-
/** @type {import('nats').
|
|
137
|
-
/** @type {import('nats').
|
|
138
|
-
/** @type {import('nats').JetStreamManager|null} */ #jsm = null
|
|
153
|
+
/** @type {MegaJobQueueNatsBundle|null} 지연 로드된 v3 nats 심볼 묶음. ensureReady 가 채운다. */ #nats = null
|
|
154
|
+
/** @type {import('@nats-io/jetstream').JetStreamClient|null} */ #js = null
|
|
155
|
+
/** @type {import('@nats-io/jetstream').JetStreamManager|null} */ #jsm = null
|
|
139
156
|
/** @type {Promise<void>|null} ensureReady 멱등 가드. */ #readyPromise = null
|
|
140
157
|
/**
|
|
141
158
|
* subject 별 ensureStream 멱등 캐시(#readyPromise 와 동일 패턴). 없으면 enqueue 가 매 호출
|
|
@@ -151,9 +168,11 @@ export class MegaJobQueue extends EventEmitter {
|
|
|
151
168
|
*/
|
|
152
169
|
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} */ ({})) {
|
|
153
170
|
super()
|
|
154
|
-
|
|
171
|
+
// v3(ADR-225): jetstream()/jetstreamManager() 는 더 이상 nc 메서드가 아니라 standalone 함수다.
|
|
172
|
+
// nc 는 core NatsConnection 이면 충분(publish/subscribe 계약) — jetstream(nc)/jetstreamManager(nc) 에 넘긴다.
|
|
173
|
+
if (!nc || typeof nc.publish !== 'function' || typeof nc.subscribe !== 'function') {
|
|
155
174
|
throw new TypeError(
|
|
156
|
-
'MegaJobQueue({ nc }) — nc must be a connected NatsConnection (
|
|
175
|
+
'MegaJobQueue({ nc }) — nc must be a connected NatsConnection (publish()/subscribe()).',
|
|
157
176
|
)
|
|
158
177
|
}
|
|
159
178
|
if (typeof ackWaitMs !== 'number' || ackWaitMs <= 0) {
|
|
@@ -255,11 +274,21 @@ export class MegaJobQueue extends EventEmitter {
|
|
|
255
274
|
async ensureReady() {
|
|
256
275
|
if (this.#readyPromise) return this.#readyPromise
|
|
257
276
|
this.#readyPromise = (async () => {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
277
|
+
// v3(ADR-225): JetStream client/manager 는 standalone 함수(`jetstream(nc)`/`jetstreamManager(nc)`),
|
|
278
|
+
// enum 은 @nats-io/jetstream, nanos 는 @nats-io/nats-core. 둘을 병렬 lazy import(잡 미사용 앱 보호).
|
|
279
|
+
const [js, core] = await Promise.all([import('@nats-io/jetstream'), import('@nats-io/nats-core')])
|
|
280
|
+
// not-found 는 전용 서브클래스가 top-level export 가 아니라(jserrors 내부), base JetStreamApiError +
|
|
281
|
+
// JetStreamApiCodes(StreamNotFound=10059/ConsumerNotFound=10014)로 판별한다(실측 — v3 런타임 export 표면).
|
|
282
|
+
this.#nats = {
|
|
283
|
+
RetentionPolicy: js.RetentionPolicy,
|
|
284
|
+
AckPolicy: js.AckPolicy,
|
|
285
|
+
StorageType: js.StorageType,
|
|
286
|
+
nanos: core.nanos,
|
|
287
|
+
JetStreamApiError: js.JetStreamApiError,
|
|
288
|
+
JetStreamApiCodes: js.JetStreamApiCodes,
|
|
289
|
+
}
|
|
290
|
+
this.#js = js.jetstream(this.#nc)
|
|
291
|
+
this.#jsm = await js.jetstreamManager(this.#nc)
|
|
263
292
|
})()
|
|
264
293
|
return this.#readyPromise
|
|
265
294
|
}
|
|
@@ -356,7 +385,7 @@ export class MegaJobQueue extends EventEmitter {
|
|
|
356
385
|
if (cached) return cached
|
|
357
386
|
const ensured = (async () => {
|
|
358
387
|
await this.ensureReady()
|
|
359
|
-
const nats = /** @type {
|
|
388
|
+
const nats = /** @type {MegaJobQueueNatsBundle} */ (this.#nats)
|
|
360
389
|
await this.#ensureStreamExists(this.#workStreamName(subject), [subject], nats.RetentionPolicy.Workqueue)
|
|
361
390
|
// DLQ 스트림에 한도를 건다(무한 적재 방지, ADR-134). dlqMaxAgeMs=0 이면 max_age 미지정(무제한),
|
|
362
391
|
// dlqMaxBytes 미지정이면 max_bytes 미지정.
|
|
@@ -380,20 +409,22 @@ export class MegaJobQueue extends EventEmitter {
|
|
|
380
409
|
* 단일 스트림 멱등 생성 — info 로 존재 확인 후 없으면 add. not-found 외 에러는 전파. `limits` 가
|
|
381
410
|
* 주어지면 생성 시 `max_age`/`max_bytes` 를 함께 설정한다(DLQ 한도, ADR-134). 이미 존재하는 스트림은
|
|
382
411
|
* 갱신하지 않는다(멱등 — 설정 변경은 운영 책임).
|
|
383
|
-
* @param {string} name @param {string[]} subjects @param {import('nats').RetentionPolicy} retention
|
|
412
|
+
* @param {string} name @param {string[]} subjects @param {import('@nats-io/jetstream').RetentionPolicy} retention
|
|
384
413
|
* @param {{ maxAgeMs?: number, maxBytes?: number }} [limits]
|
|
385
414
|
* @returns {Promise<void>}
|
|
386
415
|
*/
|
|
387
416
|
async #ensureStreamExists(name, subjects, retention, limits) {
|
|
388
|
-
const jsm = /** @type {import('nats').JetStreamManager} */ (this.#jsm)
|
|
389
|
-
const nats = /** @type {
|
|
417
|
+
const jsm = /** @type {import('@nats-io/jetstream').JetStreamManager} */ (this.#jsm)
|
|
418
|
+
const nats = /** @type {MegaJobQueueNatsBundle} */ (this.#nats)
|
|
390
419
|
try {
|
|
391
420
|
await jsm.streams.info(name)
|
|
392
421
|
return // 이미 존재.
|
|
393
422
|
} catch (e) {
|
|
394
|
-
|
|
423
|
+
// v3(ADR-225): not-found 가 문자열 code('404')에서 JetStreamApiError(code=StreamNotFound)로 바뀜. 그 외 전파.
|
|
424
|
+
if (!(e instanceof nats.JetStreamApiError && e.code === nats.JetStreamApiCodes.StreamNotFound)) throw e
|
|
395
425
|
}
|
|
396
|
-
|
|
426
|
+
// v3 StreamConfig 는 `name` 필수(WithRequired) — name 을 가진 partial StreamConfig 로 타입 고정.
|
|
427
|
+
/** @type {import('@nats-io/nats-core').WithRequired<Partial<import('@nats-io/jetstream').StreamConfig>, 'name'>} */
|
|
397
428
|
const config = { name, subjects, retention, storage: nats.StorageType.File }
|
|
398
429
|
if (limits) {
|
|
399
430
|
// max_age 는 nanos(ms→ns). 0 은 NATS 에서 "무제한" 이므로 양수일 때만 설정(미지정=무제한과 동일).
|
|
@@ -408,13 +439,14 @@ export class MegaJobQueue extends EventEmitter {
|
|
|
408
439
|
* @param {string} stream @param {string} durable @param {string} subject @param {number} concurrency @returns {Promise<void>}
|
|
409
440
|
*/
|
|
410
441
|
async #ensureConsumer(stream, durable, subject, concurrency) {
|
|
411
|
-
const jsm = /** @type {import('nats').JetStreamManager} */ (this.#jsm)
|
|
412
|
-
const nats = /** @type {
|
|
442
|
+
const jsm = /** @type {import('@nats-io/jetstream').JetStreamManager} */ (this.#jsm)
|
|
443
|
+
const nats = /** @type {MegaJobQueueNatsBundle} */ (this.#nats)
|
|
413
444
|
try {
|
|
414
445
|
await jsm.consumers.info(stream, durable)
|
|
415
446
|
return
|
|
416
447
|
} catch (e) {
|
|
417
|
-
|
|
448
|
+
// v3(ADR-225): consumer not-found = JetStreamApiError(code=ConsumerNotFound). 그 외 에러는 전파.
|
|
449
|
+
if (!(e instanceof nats.JetStreamApiError && e.code === nats.JetStreamApiCodes.ConsumerNotFound)) throw e
|
|
418
450
|
}
|
|
419
451
|
await jsm.consumers.add(stream, {
|
|
420
452
|
durable_name: durable,
|
|
@@ -442,11 +474,11 @@ export class MegaJobQueue extends EventEmitter {
|
|
|
442
474
|
}
|
|
443
475
|
await this.ensureStream(JobClass)
|
|
444
476
|
const subject = /** @type {string} */ (JobClass.subject)
|
|
445
|
-
const js = /** @type {import('nats').JetStreamClient} */ (this.#js)
|
|
477
|
+
const js = /** @type {import('@nats-io/jetstream').JetStreamClient} */ (this.#js)
|
|
446
478
|
// msgID(옵트인) = JetStream `Nats-Msg-Id` dedup — 스트림 duplicate window(NATS 기본 2분) 안의
|
|
447
479
|
// 같은 msgID 재발행은 적재되지 않고 ack.duplicate=true 로 돌아온다(producer 중복: 재시도 enqueue·
|
|
448
480
|
// 이중 클릭 방어). 미지정 시 dedup 없음(기존 동작, 비용 0) — duplicate 는 그때 항상 false.
|
|
449
|
-
const ack = await js.publish(subject,
|
|
481
|
+
const ack = await js.publish(subject, encodeJson(payload), msgID !== undefined ? { msgID } : undefined)
|
|
450
482
|
// dispatch 는 publish(부작용) 완료 후 발화 — 리스너 throw 가 반환값/흐름을 오염시키지 않게 safeEmit(L-3).
|
|
451
483
|
this.#safeEmit('dispatch', { subject, seq: ack.seq, stream: ack.stream })
|
|
452
484
|
return { seq: ack.seq, stream: ack.stream, duplicate: ack.duplicate }
|
|
@@ -478,7 +510,7 @@ export class MegaJobQueue extends EventEmitter {
|
|
|
478
510
|
const runTimeoutMs = resolveJobRunTimeoutMs(JobClass, this.#runTimeoutMs) // 행 잡 영구 점유 backstop.
|
|
479
511
|
await this.#ensureConsumer(stream, durable, subject, concurrency)
|
|
480
512
|
|
|
481
|
-
const js = /** @type {import('nats').JetStreamClient} */ (this.#js)
|
|
513
|
+
const js = /** @type {import('@nats-io/jetstream').JetStreamClient} */ (this.#js)
|
|
482
514
|
const consumer = await js.consumers.get(stream, durable)
|
|
483
515
|
const messages = await consumer.consume({ max_messages: concurrency })
|
|
484
516
|
|
|
@@ -506,8 +538,8 @@ export class MegaJobQueue extends EventEmitter {
|
|
|
506
538
|
|
|
507
539
|
return {
|
|
508
540
|
stop: async () => {
|
|
509
|
-
// ConsumerMessages.close() 는 Promise<void|Error> 를 반환(nats
|
|
510
|
-
// 오류면 Error 인스턴스를 resolve 한다(reject 아님). 묵히지 않고 stderr 로 표면화한다(L-3).
|
|
541
|
+
// ConsumerMessages.close() 는 Promise<void|Error> 를 반환(@nats-io/jetstream v3 동일 계약) — drain
|
|
542
|
+
// 중 오류면 Error 인스턴스를 resolve 한다(reject 아님). 묵히지 않고 stderr 로 표면화한다(L-3).
|
|
511
543
|
const closeResult = await messages.close()
|
|
512
544
|
if (closeResult instanceof Error) {
|
|
513
545
|
console.error(`[mega-job-queue] consumer close reported an error on '${subject}':`, closeResult)
|
|
@@ -522,7 +554,7 @@ export class MegaJobQueue extends EventEmitter {
|
|
|
522
554
|
* in-flight Promise 가 reject 되지 않게 — MegaScheduler #fire 와 동일 불변식). 내부/테스트용 seam 이라
|
|
523
555
|
* `_` 접두사(어댑터 `_connect` 컨벤션).
|
|
524
556
|
*
|
|
525
|
-
* @param {MegaJob} instance @param {Record<string, any>} ctx @param {import('nats').JsMsg} msg
|
|
557
|
+
* @param {MegaJob} instance @param {Record<string, any>} ctx @param {import('@nats-io/jetstream').JsMsg} msg
|
|
526
558
|
* @param {ReturnType<typeof resolveJobRetryConfig>} retryConfig @param {string} subject
|
|
527
559
|
* @param {number} [runTimeoutMs] - run 전체(재시도 포함) 상한(ms). 미지정 시 큐 디폴트, 0 = 무제한.
|
|
528
560
|
* @returns {Promise<MegaJobHandleResult>}
|
|
@@ -545,7 +577,7 @@ export class MegaJobQueue extends EventEmitter {
|
|
|
545
577
|
this.#safeEmit('fail', { subject, seq, error, phase: 'max-deliver' })
|
|
546
578
|
let exhaustedPayload
|
|
547
579
|
try {
|
|
548
|
-
exhaustedPayload =
|
|
580
|
+
exhaustedPayload = decodeJson(msg.data)
|
|
549
581
|
} catch {
|
|
550
582
|
// 디코드까지 실패한 poison 이면 raw 바이트를 봉투에 보관(무시 아님, DLQ 로 보존).
|
|
551
583
|
exhaustedPayload = { decodeError: true, rawBase64: this.#toBase64(msg.data) }
|
|
@@ -556,7 +588,7 @@ export class MegaJobQueue extends EventEmitter {
|
|
|
556
588
|
|
|
557
589
|
let payload
|
|
558
590
|
try {
|
|
559
|
-
payload =
|
|
591
|
+
payload = decodeJson(msg.data)
|
|
560
592
|
} catch (decodeErr) {
|
|
561
593
|
// 디코드 불가 = poison 메시지. 재시도 무의미 → 곧장 DLQ(원본 바이트 base64 보관).
|
|
562
594
|
const error = decodeErr instanceof Error ? decodeErr : new Error(String(decodeErr))
|
|
@@ -657,11 +689,11 @@ export class MegaJobQueue extends EventEmitter {
|
|
|
657
689
|
* (at-least-once — 즉시 재전달 핫 루프 방지). 단 이번 전달이 `max_deliver` 의 **마지막 전달**이면 nak 해도
|
|
658
690
|
* 재전달이 없어 메시지가 워크 스트림에 orphan 으로 남는다 — 이때는 un-ack 보존 + `fail(dlq-orphan)` 으로
|
|
659
691
|
* 운영자 개입을 명시 표면화한다(ack/term 으로 잡을 지우면 유실이라 하지 않는다). 본 메서드도 throw 하지 않는다.
|
|
660
|
-
* @param {string} subject @param {any} payload @param {Error} error @param {import('nats').JsMsg} msg @param {number} seq @returns {Promise<void>}
|
|
692
|
+
* @param {string} subject @param {any} payload @param {Error} error @param {import('@nats-io/jetstream').JsMsg} msg @param {number} seq @returns {Promise<void>}
|
|
661
693
|
*/
|
|
662
694
|
async #routeToDlq(subject, payload, error, msg, seq) {
|
|
663
695
|
const dlqSubject = `${subject}.dlq`
|
|
664
|
-
const js = /** @type {import('nats').JetStreamClient} */ (this.#js)
|
|
696
|
+
const js = /** @type {import('@nats-io/jetstream').JetStreamClient} */ (this.#js)
|
|
665
697
|
try {
|
|
666
698
|
// DLQ publish 자체를 짧게 재시도(추가 2회, 250ms→1s) — 일시적 NATS 응답 지연/재연결 틈을 흡수해
|
|
667
699
|
// nak 재전달(전체 인프로세스 재시도 사이클 반복)보다 훨씬 싸게 orphan 확률을 줄인다.
|
|
@@ -669,7 +701,7 @@ export class MegaJobQueue extends EventEmitter {
|
|
|
669
701
|
() =>
|
|
670
702
|
js.publish(
|
|
671
703
|
dlqSubject,
|
|
672
|
-
|
|
704
|
+
encodeJson({
|
|
673
705
|
originalSubject: subject,
|
|
674
706
|
failedAt: new Date().toISOString(),
|
|
675
707
|
deliveryCount: msg.info.deliveryCount,
|
|
@@ -700,16 +732,8 @@ export class MegaJobQueue extends EventEmitter {
|
|
|
700
732
|
}
|
|
701
733
|
|
|
702
734
|
// ── 헬퍼 ────────────────────────────────────────────────────────────────
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
#encode(value) {
|
|
706
|
-
return /** @type {import('nats').Codec<any>} */ (this.#codec).encode(value === undefined ? null : value)
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
/** @param {Uint8Array} data @returns {any} JSONCodec 디코드. */
|
|
710
|
-
#decode(data) {
|
|
711
|
-
return /** @type {import('nats').Codec<any>} */ (this.#codec).decode(data)
|
|
712
|
-
}
|
|
735
|
+
// JSON 인코드/디코드는 공유 코덱(adapters/nats-codec.js)의 encodeJson/decodeJson 을 직접 쓴다
|
|
736
|
+
// (v3 에서 JSONCodec 팩토리 제거 — ADR-225). undefined→null 정규화·빈 payload→null 은 코덱이 담당.
|
|
713
737
|
|
|
714
738
|
/** @param {Uint8Array} data @returns {string} base64(원본 바이트 보존용). */
|
|
715
739
|
#toBase64(data) {
|
|
@@ -17,19 +17,11 @@ export class {{Name}} extends MegaModel {
|
|
|
17
17
|
static table = '{{table}}'
|
|
18
18
|
|
|
19
19
|
// 자동 마이그레이션 스키마(ADR-209) — 빌더 API 는 docs/guide/03-service-model-db.md §5 참조.
|
|
20
|
-
static schema = (t) => ({
|
|
20
|
+
static schema = /** @param {any} t */ (t) => ({
|
|
21
21
|
name: t.varchar(100).notNull(),
|
|
22
22
|
createdAt: t.timestamptz().notNull(), // 생성 시 앱에서 new Date() 로 채운다(mongo 는 default 미지원)
|
|
23
23
|
})
|
|
24
24
|
|
|
25
25
|
// 인덱스(선택): static indexes = (t) => [t.index(['name'], { unique: true })]
|
|
26
26
|
|
|
27
|
-
/**
|
|
28
|
-
* _id 로 1건 조회 — `this.db` 는 native mongodb `Db`(ADR-009), 도큐먼트 API 직접 사용.
|
|
29
|
-
* @param {import('mongodb').ObjectId} id
|
|
30
|
-
* @returns {Promise<object|null>}
|
|
31
|
-
*/
|
|
32
|
-
static async findById(id) {
|
|
33
|
-
return this.db.collection(this.table).findOne({ _id: id })
|
|
34
|
-
}
|
|
35
27
|
}
|
package/templates/model/code.tpl
CHANGED
|
@@ -15,7 +15,7 @@ export class {{Name}} extends MegaModel {
|
|
|
15
15
|
static table = '{{table}}'
|
|
16
16
|
|
|
17
17
|
// 자동 마이그레이션 스키마(ADR-204) — 빌더 API 는 docs/guide/03-service-model-db.md §5 참조.
|
|
18
|
-
static schema = (t) => ({
|
|
18
|
+
static schema = /** @param {any} t */ (t) => ({
|
|
19
19
|
id: t.serial().primary(),
|
|
20
20
|
name: t.varchar(100).notNull(),
|
|
21
21
|
createdAt: t.timestamptz().defaultNow(),
|
|
@@ -23,13 +23,4 @@ export class {{Name}} extends MegaModel {
|
|
|
23
23
|
|
|
24
24
|
// 인덱스(선택): static indexes = (t) => [t.index(['name'], { unique: true })]
|
|
25
25
|
|
|
26
|
-
/**
|
|
27
|
-
* id 로 1건 조회. `this.query` 는 계측된 어댑터 query 위임(ADR-138).
|
|
28
|
-
* @param {string|number} id
|
|
29
|
-
* @returns {Promise<object|null>}
|
|
30
|
-
*/
|
|
31
|
-
static async findById(id) {
|
|
32
|
-
const { rows } = await this.query('select * from {{table}} where id = $1', [id])
|
|
33
|
-
return rows[0] ?? null
|
|
34
|
-
}
|
|
35
26
|
}
|
|
@@ -12,27 +12,5 @@ describe('{{Name}} model (mongodb)', () => {
|
|
|
12
12
|
|
|
13
13
|
test('schema 빌더 선언 — 자동 마이그레이션 트랙 옵트인(ADR-209)', () => {
|
|
14
14
|
expect(typeof {{Name}}.schema).toBe('function')
|
|
15
|
-
})
|
|
16
|
-
|
|
17
|
-
test('findById — native Db 의 도큐먼트 API 에 위임', async () => {
|
|
18
|
-
/** @type {any[]} */
|
|
19
|
-
const calls = []
|
|
20
|
-
const fakeDb = {
|
|
21
|
-
collection: (/** @type {string} */ name) => ({
|
|
22
|
-
findOne: async (/** @type {any} */ filter) => {
|
|
23
|
-
calls.push({ name, filter })
|
|
24
|
-
return { _id: filter._id }
|
|
25
|
-
},
|
|
26
|
-
}),
|
|
27
|
-
}
|
|
28
|
-
// MegaModel.db 는 getter 라 서브클래스 own property 로 가린다(테스트 한정).
|
|
29
|
-
Object.defineProperty({{Name}}, 'db', { value: fakeDb, configurable: true })
|
|
30
|
-
try {
|
|
31
|
-
const doc = await {{Name}}.findById(/** @type {any} */ ('id-1'))
|
|
32
|
-
expect(doc).toEqual({ _id: 'id-1' })
|
|
33
|
-
expect(calls[0].name).toBe('{{table}}')
|
|
34
|
-
} finally {
|
|
35
|
-
delete /** @type {any} */ ({{Name}}).db
|
|
36
|
-
}
|
|
37
|
-
})
|
|
15
|
+
})
|
|
38
16
|
})
|
package/templates/model/test.tpl
CHANGED
|
@@ -13,21 +13,4 @@ describe('{{Name}} model', () => {
|
|
|
13
13
|
test('schema 빌더 선언 — 자동 마이그레이션 트랙 옵트인(ADR-204)', () => {
|
|
14
14
|
expect(typeof {{Name}}.schema).toBe('function')
|
|
15
15
|
})
|
|
16
|
-
|
|
17
|
-
test('findById — 어댑터 query 에 위임', async () => {
|
|
18
|
-
const orig = {{Name}}.query
|
|
19
|
-
/** @type {any[]} */
|
|
20
|
-
const calls = []
|
|
21
|
-
{{Name}}.query = async (/** @type {string} */ sql, /** @type {any[]} */ params) => {
|
|
22
|
-
calls.push({ sql, params })
|
|
23
|
-
return { rows: [{ id: params[0] }] }
|
|
24
|
-
}
|
|
25
|
-
try {
|
|
26
|
-
const row = await {{Name}}.findById(7)
|
|
27
|
-
expect(row).toEqual({ id: 7 })
|
|
28
|
-
expect(calls[0].params).toEqual([7])
|
|
29
|
-
} finally {
|
|
30
|
-
{{Name}}.query = orig
|
|
31
|
-
}
|
|
32
|
-
})
|
|
33
16
|
})
|
|
@@ -21,7 +21,7 @@ export class MegaAdapter {
|
|
|
21
21
|
* 토큰이 없으면(옵트인 OFF) 아예 쓰이지 않아 0 비용.
|
|
22
22
|
* @type {AsyncLocalStorage<any>}
|
|
23
23
|
*/
|
|
24
|
-
static
|
|
24
|
+
static #callScope: AsyncLocalStorage<any>;
|
|
25
25
|
/**
|
|
26
26
|
* @param {object} [config] - driver 별 설정 (url / pool / file 등).
|
|
27
27
|
*/
|
|
@@ -19,9 +19,9 @@ export class MegaNatsAdapter extends MegaBusAdapter {
|
|
|
19
19
|
/**
|
|
20
20
|
* raw NatsConnection handle (ADR-009). `connect()` 후에만 베이스 `native` getter 가 호출한다.
|
|
21
21
|
* @protected
|
|
22
|
-
* @returns {import('nats').NatsConnection}
|
|
22
|
+
* @returns {import('@nats-io/nats-core').NatsConnection}
|
|
23
23
|
*/
|
|
24
|
-
protected _native(): import("nats").NatsConnection;
|
|
24
|
+
protected _native(): import("@nats-io/nats-core").NatsConnection;
|
|
25
25
|
/**
|
|
26
26
|
* 헬스 체크 — 실제 `rtt`(서버 왕복 ping)로 응답성 확인. 실패는 throw 없이 `ok:false` + 사유.
|
|
27
27
|
* @returns {Promise<{ ok: boolean, driver: 'nats', state: string, server?: string, rttMs?: number, error?: string }>}
|
|
@@ -36,12 +36,12 @@ export class MegaNatsAdapter extends MegaBusAdapter {
|
|
|
36
36
|
}>;
|
|
37
37
|
/**
|
|
38
38
|
* 누적 통계 + nats 특화(server + 연결 stats). 연결 전이면 server/stats 는 undefined.
|
|
39
|
-
* @returns {ReturnType<import('./mega-adapter.js').MegaAdapter['getStats']> & { driver: string, server: string | undefined, nats: import('nats').Stats | undefined }}
|
|
39
|
+
* @returns {ReturnType<import('./mega-adapter.js').MegaAdapter['getStats']> & { driver: string, server: string | undefined, nats: import('@nats-io/nats-core').Stats | undefined }}
|
|
40
40
|
*/
|
|
41
41
|
getStats(): ReturnType<import("./mega-adapter.js").MegaAdapter["getStats"]> & {
|
|
42
42
|
driver: string;
|
|
43
43
|
server: string | undefined;
|
|
44
|
-
nats: import("nats").Stats | undefined;
|
|
44
|
+
nats: import("@nats-io/nats-core").Stats | undefined;
|
|
45
45
|
};
|
|
46
46
|
/**
|
|
47
47
|
* 잡 enqueue — 단순 publish (queue group 분배는 `process` 측 책임, ADR-112).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JS 값 → NATS wire 바이트(JSON). `undefined` 는 `null` 로 정규화한다.
|
|
3
|
+
* @param {any} value
|
|
4
|
+
* @returns {Uint8Array}
|
|
5
|
+
*/
|
|
6
|
+
export function encodeJson(value: any): Uint8Array;
|
|
7
|
+
/**
|
|
8
|
+
* NATS wire 바이트(JSON) → JS 값. 빈 payload(길이 0)는 `null`. 파싱 실패는 throw(호출부가 처리).
|
|
9
|
+
* @param {Uint8Array} data
|
|
10
|
+
* @returns {any}
|
|
11
|
+
* @throws {SyntaxError} JSON 파싱 실패 시(silent 금지 — poison 메시지 감지에 사용).
|
|
12
|
+
*/
|
|
13
|
+
export function decodeJson(data: Uint8Array): any;
|
|
@@ -10,7 +10,7 @@ export class MegaRedlockAdapter extends MegaLockAdapter {
|
|
|
10
10
|
* 보고 거부, 숫자 4종은 음 아닌 정수, driftFactor 는 [0,1) 유한수.
|
|
11
11
|
* @param {unknown} options @returns {Partial<RedlockSettings>}
|
|
12
12
|
*/
|
|
13
|
-
static
|
|
13
|
+
static #normalizeSettings(options: unknown): Partial<RedlockSettings>;
|
|
14
14
|
/**
|
|
15
15
|
* @param {RedlockConfig} [config] - services.locks.<key> 설정.
|
|
16
16
|
* @throws {MegaValidationError} `lock.redis_required` - `redis` 키 누락/비문자열.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* booted MegaApp 을 레지스트리에 등록한다(boot 의 apps 스테이지가 앱마다 1회 호출).
|
|
3
|
+
* @param {import('./mega-app.js').MegaApp} app
|
|
4
|
+
* @returns {void}
|
|
5
|
+
*/
|
|
6
|
+
export function setApp(app: import("./mega-app.js").MegaApp): void;
|
|
7
|
+
/**
|
|
8
|
+
* booted MegaApp 을 가져온다 — ctx 없는 영역의 표준 접근점.
|
|
9
|
+
* - `name` 지정: 그 이름의 앱(없으면 `app.not_found`).
|
|
10
|
+
* - `name` 생략 + 앱 1개: 그 앱.
|
|
11
|
+
* - `name` 생략 + 앱 0개: `app.not_initialized`(부팅 전 호출 — fail-fast).
|
|
12
|
+
* - `name` 생략 + 앱 2개 이상: `app.ambiguous`(이름 필수).
|
|
13
|
+
*
|
|
14
|
+
* @param {string} [name] - 앱 이름(멀티앱 프로세스에서 지정).
|
|
15
|
+
* @returns {import('./mega-app.js').MegaApp}
|
|
16
|
+
* @throws {MegaError} `app.not_initialized` | `app.ambiguous` | `app.not_found`
|
|
17
|
+
*/
|
|
18
|
+
export function getApp(name?: string): import("./mega-app.js").MegaApp;
|
|
19
|
+
/** 등록된 앱이 있나(부팅 여부 가드 — `getApp` throw 없이 확인). @returns {boolean} */
|
|
20
|
+
export function hasApp(): boolean;
|
|
21
|
+
/** 테스트 격리/재부팅용 — 레지스트리 비움(shutdown 에서도 호출). @returns {void} */
|
|
22
|
+
export function _resetApps(): void;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* master 프로세스에 버스 라우터를 설치한다(멱등). 워커들의 구독을 모아 발행 시 fan-out 한다.
|
|
3
|
+
* `mega start` 클러스터 분기에서 master 가 1회 호출한다.
|
|
4
|
+
* @param {import('node:cluster').Cluster} [cluster] - 테스트 주입용(기본 node:cluster).
|
|
5
|
+
* @returns {{ subscriptions: () => number } | null} 관측용 핸들(테스트). 이미 설치됐으면 null.
|
|
6
|
+
*/
|
|
7
|
+
export function installClusterBusMaster(cluster?: import("node:cluster").Cluster): {
|
|
8
|
+
subscriptions: () => number;
|
|
9
|
+
} | null;
|
|
10
|
+
/** 테스트 격리용 — master 설치 플래그 초기화. @returns {void} */
|
|
11
|
+
export function _resetClusterBusMaster(): void;
|
|
12
|
+
/**
|
|
13
|
+
* 워커 측 cluster 버스 driver — master 에 IPC 위임 + master 의 deliver 를 로컬 핸들러로 디스패치.
|
|
14
|
+
*/
|
|
15
|
+
export class ClusterBusDriver {
|
|
16
|
+
/**
|
|
17
|
+
* @param {{ proc?: NodeJS.Process }} [opts] - `proc` 테스트 주입용(기본 process). cluster 워커여야 send 존재.
|
|
18
|
+
* @throws {Error} proc.send 가 없으면(cluster 워커 아님).
|
|
19
|
+
*/
|
|
20
|
+
constructor({ proc }?: {
|
|
21
|
+
proc?: NodeJS.Process;
|
|
22
|
+
});
|
|
23
|
+
/** @type {'cluster'} */
|
|
24
|
+
get name(): "cluster";
|
|
25
|
+
/** cluster 는 persist 무시(opts 는 계약 정합용). @param {string} subject @param {import('./contract.js').BusEnvelope} envelope @param {{ persist?: boolean }} [_opts] @returns {Promise<void>} */
|
|
26
|
+
publish(subject: string, envelope: import("./contract.js").BusEnvelope, _opts?: {
|
|
27
|
+
persist?: boolean;
|
|
28
|
+
}): Promise<void>;
|
|
29
|
+
/** @param {string} pattern @param {Function} handler @param {{ ordered?: boolean }} opts @returns {Promise<import('./contract.js').Subscription>} */
|
|
30
|
+
subscribe(pattern: string, handler: Function, { ordered }?: {
|
|
31
|
+
ordered?: boolean;
|
|
32
|
+
}): Promise<import("./contract.js").Subscription>;
|
|
33
|
+
/** @param {string} subject @param {import('./contract.js').BusEnvelope} envelope @param {{ timeout: number }} opts @returns {Promise<import('./contract.js').BusEnvelope>} */
|
|
34
|
+
request(subject: string, envelope: import("./contract.js").BusEnvelope, { timeout }: {
|
|
35
|
+
timeout: number;
|
|
36
|
+
}): Promise<import("./contract.js").BusEnvelope>;
|
|
37
|
+
/** @returns {Promise<{ driver: string, subscriptions: number }>} */
|
|
38
|
+
stats(): Promise<{
|
|
39
|
+
driver: string;
|
|
40
|
+
subscriptions: number;
|
|
41
|
+
}>;
|
|
42
|
+
/** 정리 — IPC 리스너 제거 + 대기 요청 reject + 핸들러 비움. @returns {Promise<void>} */
|
|
43
|
+
close(): Promise<void>;
|
|
44
|
+
#private;
|
|
45
|
+
}
|