mega-framework 0.1.9 → 0.1.11
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/jobs-controller.js +22 -2
- package/sample/crud/apps/main/controllers/lock-controller.js +117 -0
- package/sample/crud/apps/main/jobs/email-job.js +37 -2
- package/sample/crud/apps/main/locales/server/en.json +36 -1
- package/sample/crud/apps/main/locales/server/ko.json +36 -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 +22 -15
- package/sample/crud/apps/main/views/bus/index.ejs +80 -0
- package/sample/crud/apps/main/views/jobs/index.ejs +9 -3
- 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 +48 -0
- package/sample/crud/docs/guide/05-scheduler-job-worker.md +29 -2
- 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 +50 -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/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
package/README.md
CHANGED
|
@@ -22,6 +22,8 @@ HTTP·WebSocket 을 동급 일급으로 다루고, 데이터 어댑터·잡/스
|
|
|
22
22
|
| **HTTP** | `MegaApp`(Fastify v5 래퍼) · `MegaServer`(멀티앱 vhost 라우팅) · 단일 시그니처 `router.http.<method>(path, handler, opts?)` · 라우트 자동 로드 · 자동 envelope(`{ok, data\|error, meta}`) · AJV 검증 에러 → `details` 자동 매핑 |
|
|
23
23
|
| **WebSocket** | `MegaWebSocketController`(타입 자동 디스패치) · Bridge↔Hub 12-타입 프로토콜 · presence · `broadcast`/`directToUser` fan-out · per-message deflate 압축 · embedded/별도 프로세스 hub |
|
|
24
24
|
| **데이터** | `MegaModel`(ORM 없음, native API + 트랜잭션) · 어댑터 베이스 트리(Db/Cache/Bus/Lock/Session) · 드라이버: Postgres·MariaDB·SQLite·MongoDB·Redis·NATS·File · `MegaRedlockAdapter`(분산 락) |
|
|
25
|
+
| **동시성(분산 락)** | `ctx.lock.with/.acquire/.tryAcquire`(ADR-226) · driver 자동폴백 redis>cluster>memory · FIFO·fence·watchdog 옵트인 |
|
|
26
|
+
| **메시지 버스** | `ctx.bus.emit/.on/.request/.with`(ADR-227) · driver 자동폴백 nats>cluster>memory · wildcards(`*`/`>`)·request/reply·persist(JetStream)·ordered 옵트인 |
|
|
25
27
|
| **운영(잡/스케줄)** | `MegaJobQueue`(NATS JetStream + 재시도 + DLQ) · `MegaJobWorker` · `MegaScheduler`(croner + 분산 중복방지) · `MegaWorker`(worker_threads CPU 풀) |
|
|
26
28
|
| **복원력** | `MegaRetry`(p-retry 지수 백오프) · `MegaCircuitBreaker`(opossum) · `MegaHealth`(`/health`·`/health/ready`) · `MegaShutdown`(LIFO graceful) |
|
|
27
29
|
| **관측성** | `MegaTracing`(OpenTelemetry 옵트인, HTTP/WS/어댑터/native query 자동 span, Zipkin) · `MegaMetrics`(Prometheus `/metrics`) |
|
|
@@ -128,14 +130,22 @@ docs/ 설계·ADR·운영 문서
|
|
|
128
130
|
|
|
129
131
|
## 의존성
|
|
130
132
|
|
|
131
|
-
- **런타임**: Node.js `>=20` (engines)
|
|
133
|
+
- **런타임**: Node.js `>=20.19.0` (engines — `mongodb` v7 요구)
|
|
132
134
|
- **HTTP/WS**: `fastify` v5 + `@fastify/*`(cookie/cors/csrf/helmet/multipart/rate-limit) · `ws`
|
|
133
|
-
- **데이터**: `pg` · `mariadb` · `mongodb` · `better-sqlite3` · `ioredis` ·
|
|
135
|
+
- **데이터**: `pg` · `mariadb` · `mongodb` · `better-sqlite3` · `ioredis` · `@nats-io/*`(transport-node/jetstream/nats-core, ADR-225) · `redlock`
|
|
134
136
|
- **복원력·운영**: `p-retry` · `opossum` · `croner`
|
|
135
|
-
- **관측성**: `@opentelemetry/*`(api/sdk-trace/sdk-metrics/exporter-zipkin/exporter-prometheus/exporter-otlp)
|
|
137
|
+
- **관측성**: `@opentelemetry/*`(api/sdk-trace/sdk-metrics/exporter-zipkin/exporter-prometheus/exporter-otlp) · `pino`
|
|
136
138
|
- **SSR·i18n·검증**: `ejs` + `ejs-mate` · `i18next` · `ajv`
|
|
137
139
|
|
|
138
|
-
> 모든 신규 의존성은
|
|
140
|
+
> 모든 신규 의존성은 ADR 로 1줄 기록(P8 — ADR-218+ 는 [docs/adr/](./docs/adr/)). 커스텀 구현은 Node 빌트인만 사용(scrypt/HMAC/cluster/worker_threads).
|
|
141
|
+
|
|
142
|
+
### `npm update` 호환
|
|
143
|
+
|
|
144
|
+
받은 직후 `npm update` 로 한 번에 최신화해도 동작하도록 검증한다(검증 매트릭스·정책 = [ADR-224](./docs/adr/0224-dependency-bulk-update-and-npm-update-compat.md)). semver range 는 `^`(caret) 유지로 patch/minor 를 자동 수용한다.
|
|
145
|
+
|
|
146
|
+
> **OTel 만 예외 — 부분 업데이트 금지**: experimental exporter(`exporter-prometheus`/`exporter-trace-otlp-http`, 0.21x)가 stable `@opentelemetry/core`·`sdk-*`(2.x)를 exact 핀한다. stable 만 올리면 `@opentelemetry/core` 가 중복 로드되어 trace context 전파가 깨진다. `@opentelemetry/*` 는 stable+experimental 을 **항상 함께** 올릴 것.
|
|
147
|
+
|
|
148
|
+
> **`@nats-io/*` 도 lockstep**: NATS v3 클라이언트는 `transport-node`·`jetstream`·`nats-core` 로 분리됐고 세 패키지는 같은 버전을 함께 써야 한다(`nats-core` 가 다른 둘의 공유 기반 — 버전이 어긋나면 타입/런타임 불일치). `@nats-io/*` 는 **항상 같은 버전으로 동시에** 올릴 것(ADR-225).
|
|
139
149
|
|
|
140
150
|
## 문서
|
|
141
151
|
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mega-framework",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
4
4
|
"description": "Node.js 마이크로프레임웍 + CLI — Slim의 가벼움 + Rails의 관례",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
7
|
-
"node": ">=20.
|
|
7
|
+
"node": ">=20.19.0"
|
|
8
8
|
},
|
|
9
9
|
"main": "src/index.js",
|
|
10
10
|
"types": "types/index.d.ts",
|
|
@@ -84,17 +84,17 @@
|
|
|
84
84
|
"release": "bash scripts/publish.sh"
|
|
85
85
|
},
|
|
86
86
|
"devDependencies": {
|
|
87
|
-
"@eslint/js": "^
|
|
87
|
+
"@eslint/js": "^10.0.1",
|
|
88
88
|
"@types/better-sqlite3": "^7.6.13",
|
|
89
89
|
"@types/node": "^25.9.1",
|
|
90
90
|
"@types/opossum": "^8.1.9",
|
|
91
91
|
"@types/pg": "^8.20.0",
|
|
92
92
|
"@vitest/coverage-v8": "^4.1.8",
|
|
93
|
-
"eslint": "^
|
|
93
|
+
"eslint": "^10.5.0",
|
|
94
94
|
"eslint-plugin-promise": "^7.3.0",
|
|
95
|
-
"globals": "^
|
|
95
|
+
"globals": "^17.6.0",
|
|
96
96
|
"prettier": "^3.0.0",
|
|
97
|
-
"typescript": "^
|
|
97
|
+
"typescript": "^6.0.3",
|
|
98
98
|
"vitest": "^4.1.8"
|
|
99
99
|
},
|
|
100
100
|
"license": "MIT",
|
|
@@ -102,22 +102,25 @@
|
|
|
102
102
|
"dependencies": {
|
|
103
103
|
"@fastify/cookie": "^11.0.2",
|
|
104
104
|
"@fastify/cors": "^11.2.0",
|
|
105
|
+
"@fastify/csrf-protection": "^8.0.0",
|
|
105
106
|
"@fastify/formbody": "^8.0.2",
|
|
106
|
-
"@fastify/csrf-protection": "^7.1.0",
|
|
107
107
|
"@fastify/helmet": "^13.0.2",
|
|
108
108
|
"@fastify/multipart": "^10.0.0",
|
|
109
|
-
"@fastify/rate-limit": "^
|
|
109
|
+
"@fastify/rate-limit": "^11.0.0",
|
|
110
110
|
"@fastify/static": "^9.1.3",
|
|
111
111
|
"@fastify/swagger": "^9.7.0",
|
|
112
|
-
"@fastify/swagger-ui": "^
|
|
112
|
+
"@fastify/swagger-ui": "^6.0.0",
|
|
113
|
+
"@nats-io/jetstream": "^3.4.0",
|
|
114
|
+
"@nats-io/nats-core": "^3.4.0",
|
|
115
|
+
"@nats-io/transport-node": "^3.4.0",
|
|
113
116
|
"@opentelemetry/api": "^1.9.1",
|
|
114
|
-
"@opentelemetry/core": "^2.
|
|
115
|
-
"@opentelemetry/exporter-prometheus": "^0.
|
|
116
|
-
"@opentelemetry/exporter-trace-otlp-http": "^0.
|
|
117
|
-
"@opentelemetry/exporter-zipkin": "^2.
|
|
118
|
-
"@opentelemetry/resources": "^2.
|
|
119
|
-
"@opentelemetry/sdk-metrics": "^2.
|
|
120
|
-
"@opentelemetry/sdk-trace-base": "^2.
|
|
117
|
+
"@opentelemetry/core": "^2.8.0",
|
|
118
|
+
"@opentelemetry/exporter-prometheus": "^0.219.0",
|
|
119
|
+
"@opentelemetry/exporter-trace-otlp-http": "^0.219.0",
|
|
120
|
+
"@opentelemetry/exporter-zipkin": "^2.8.0",
|
|
121
|
+
"@opentelemetry/resources": "^2.8.0",
|
|
122
|
+
"@opentelemetry/sdk-metrics": "^2.8.0",
|
|
123
|
+
"@opentelemetry/sdk-trace-base": "^2.8.0",
|
|
121
124
|
"@opentelemetry/semantic-conventions": "^1.41.1",
|
|
122
125
|
"ajv": "^8.20.0",
|
|
123
126
|
"ajv-formats": "^3.0.1",
|
|
@@ -130,16 +133,15 @@
|
|
|
130
133
|
"i18next": "^26.3.1",
|
|
131
134
|
"ioredis": "^5.11.0",
|
|
132
135
|
"mariadb": "^3.5.2",
|
|
133
|
-
"mongodb": "^
|
|
134
|
-
"nats": "^2.29.3",
|
|
136
|
+
"mongodb": "^7.3.0",
|
|
135
137
|
"opossum": "^9.0.0",
|
|
136
138
|
"p-retry": "^8.0.0",
|
|
137
139
|
"pg": "^8.21.0",
|
|
138
|
-
"pino": "^
|
|
140
|
+
"pino": "^10.3.1",
|
|
139
141
|
"pino-pretty": "^13.1.3",
|
|
140
|
-
"pino-roll": "^
|
|
142
|
+
"pino-roll": "^4.0.0",
|
|
141
143
|
"prompts": "^2.4.2",
|
|
142
144
|
"redlock": "5.0.0-beta.2",
|
|
143
145
|
"ws": "^8.21.0"
|
|
144
146
|
}
|
|
145
|
-
}
|
|
147
|
+
}
|
package/sample/crud/.env
CHANGED
|
@@ -67,7 +67,11 @@ REDIS_DEMO_URL=redis://:dkTkqkfl12@localhost:6379/1
|
|
|
67
67
|
# 분산 락(redlock, ADR-113) — caches.lock(locks.main 이 빌려 씀). /demo/cron leader election.
|
|
68
68
|
REDIS_LOCK_URL=redis://:dkTkqkfl12@localhost:6379/3
|
|
69
69
|
# 범용 캐시(미사용) — 별도 캐시 어댑터가 필요하면 services.caches 에 추가해 참조.
|
|
70
|
-
|
|
70
|
+
REDIS_CACHE_URL=redis://:dkTkqkfl12@localhost:6379/4
|
|
71
|
+
|
|
72
|
+
# 분산 락 사용자 API(ADR-226, ctx.lock.with/.acquire) — driver 자동 폴백이라 **추가 .env 변수는 필요 없다**.
|
|
73
|
+
# redis 분산 락을 켜려면 mega.config.js 의 `lock` 블록 주석을 풀고 `cache: 'lock'`(위 REDIS_LOCK_URL 재사용)을
|
|
74
|
+
# 지정한다. 미설정 시 redis 가용→redis, 클러스터 워커→cluster, 그 외→memory(단일 프로세스, 부팅 경고).
|
|
71
75
|
|
|
72
76
|
|
|
73
77
|
# ── NATS 버스 (mega.config.js > services.buses) ──────────────────────────────
|
|
@@ -77,6 +81,10 @@ NATS_JOBS_URL=nats://localhost:4222
|
|
|
77
81
|
# 이벤트 버스(미사용) — pub/sub 등 두 번째 버스가 필요하면 services.buses 에 추가.
|
|
78
82
|
# NATS_EVENTS_URL=nats://localhost:4222
|
|
79
83
|
|
|
84
|
+
# 메시지 버스 사용자 API(ADR-227, ctx.bus.emit/.on/.request) — driver 자동 폴백이라 **추가 .env 변수는 필요 없다**.
|
|
85
|
+
# NATS 분산 버스를 켜려면 mega.config.js 의 `bus` 블록 주석을 풀고 `nats: 'jobs'`(위 NATS_JOBS_URL 재사용)을
|
|
86
|
+
# 지정한다. 미설정 시 NATS 가용→nats, 클러스터 워커→cluster, 그 외→memory(단일 프로세스, 부팅 경고).
|
|
87
|
+
|
|
80
88
|
|
|
81
89
|
# ── WS Hub: 클러스터 채팅 브릿지 (ADR-059/065/137/176) ────────────────────────
|
|
82
90
|
# crud 는 app.config.js 의 bridgeHub 로 /ws/chat 을 클러스터 전파한다. 허브는 `mega ws-hub`(scripts/
|
|
@@ -172,7 +180,7 @@ DEMO_UPLOAD_DIR=var/uploads
|
|
|
172
180
|
# DATABASE | DB → database / DBNAME → dbName(Mongo)
|
|
173
181
|
# POOL_<X> → pool.{camelCase(X)} (드라이버 공통, 값 자동 타입변환)
|
|
174
182
|
# OPTIONS_<X> → options.{드라이버 표기} (postgres/sqlite=snake, 그 외=camel; MS 대문자 보존)
|
|
175
|
-
# 공통 풀 인터페이스(min/max/idleTimeoutMs/acquireTimeoutMs/maxLifetimeMs) 예:
|
|
183
|
+
# 공통 풀 인터페이스(min/max/idleTimeoutMs/acquireTimeoutMs/maxLifetimeMs) 예:
|
|
176
184
|
# MEGA_PG_POOL_MIN=0
|
|
177
185
|
# MEGA_PG_POOL_MAX=10
|
|
178
186
|
# MEGA_PG_POOL_IDLE_TIMEOUT_MS=10000
|
package/sample/crud/.env.example
CHANGED
|
@@ -69,6 +69,10 @@ REDIS_LOCK_URL=redis://:change-me@localhost:6379/3
|
|
|
69
69
|
# 범용 캐시(미사용) — 별도 캐시 어댑터가 필요하면 services.caches 에 추가해 참조.
|
|
70
70
|
# REDIS_CACHE_URL=redis://:change-me@localhost:6379/4
|
|
71
71
|
|
|
72
|
+
# 분산 락 사용자 API(ADR-226, ctx.lock.with/.acquire) — driver 자동 폴백이라 **추가 .env 변수는 필요 없다**.
|
|
73
|
+
# redis 분산 락을 켜려면 mega.config.js 의 `lock` 블록 주석을 풀고 `cache: 'lock'`(위 REDIS_LOCK_URL 재사용)을
|
|
74
|
+
# 지정한다. 미설정 시 redis 가용→redis, 클러스터 워커→cluster, 그 외→memory(단일 프로세스, 부팅 경고).
|
|
75
|
+
|
|
72
76
|
|
|
73
77
|
# ── NATS 버스 (mega.config.js > services.buses) ──────────────────────────────
|
|
74
78
|
# 잡 큐(EmailJob JetStream, ADR-119) — buses.jobs.url. `mega worker` 가 소비, 웹이 enqueue.
|
|
@@ -77,6 +81,10 @@ NATS_JOBS_URL=nats://localhost:4222
|
|
|
77
81
|
# 이벤트 버스(미사용) — pub/sub 등 두 번째 버스가 필요하면 services.buses 에 추가.
|
|
78
82
|
# NATS_EVENTS_URL=nats://localhost:4222
|
|
79
83
|
|
|
84
|
+
# 메시지 버스 사용자 API(ADR-227, ctx.bus.emit/.on/.request) — driver 자동 폴백이라 **추가 .env 변수는 필요 없다**.
|
|
85
|
+
# NATS 분산 버스를 켜려면 mega.config.js 의 `bus` 블록 주석을 풀고 `nats: 'jobs'`(위 NATS_JOBS_URL 재사용)을
|
|
86
|
+
# 지정한다. 미설정 시 NATS 가용→nats, 클러스터 워커→cluster, 그 외→memory(단일 프로세스, 부팅 경고).
|
|
87
|
+
|
|
80
88
|
|
|
81
89
|
# ── WS Hub: 클러스터 채팅 브릿지 (ADR-059/065/137/176) ────────────────────────
|
|
82
90
|
# crud 는 app.config.js 의 bridgeHub 로 /ws/chat 을 클러스터 전파한다. 허브는 `mega ws-hub`(scripts/
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* BusController — /demo/bus 메시지 버스 데모 컨트롤러(ADR-074: 베이스 없음, 정적 메서드만, ADR-227 시연).
|
|
4
|
+
*
|
|
5
|
+
* `ctx.bus.emit/.on/.request` 를 그대로 쓴다(프레임워크 확장 X). 워커마다 첫 요청에서 한 번 `demo.>` 를 구독해
|
|
6
|
+
* (비영속 + 영속 둘 다) 수신 이벤트를 인메모리 링버퍼에 쌓고, `demo.echo` 응답자를 등록한다. 페이지는 폴링으로
|
|
7
|
+
* 버퍼를 가져와 fan-out·wildcards·persist·request/reply 를 워커 PID 와 함께 시각화한다.
|
|
8
|
+
*
|
|
9
|
+
* 핸들러 3번째 인자 `subject`(ADR-227)로 어떤 구체 subject 가 매칭됐는지 안다(`meta.subject` 도 동일). 발행자가
|
|
10
|
+
* 라벨링할 필요 없이 버스가 직접 알려준다 — wildcard 구독에서 특히 유용.
|
|
11
|
+
*/
|
|
12
|
+
import { currentUser } from '../middleware/web-auth.js'
|
|
13
|
+
|
|
14
|
+
/** 수신 이벤트 링버퍼(워커별 인메모리). @type {any[]} */
|
|
15
|
+
const eventBuf = []
|
|
16
|
+
const MAX_EVENTS = 60
|
|
17
|
+
let seq = 0
|
|
18
|
+
/** 워커별 1회 구독 가드. @type {boolean} */
|
|
19
|
+
let subscribed = false
|
|
20
|
+
|
|
21
|
+
/** 버퍼에 수신 이벤트 1건 기록. @param {any} payload @param {any} meta @param {boolean} persisted @param {string} subject @returns {void} */
|
|
22
|
+
function record(payload, meta, persisted, subject) {
|
|
23
|
+
eventBuf.push({
|
|
24
|
+
id: ++seq,
|
|
25
|
+
pid: process.pid,
|
|
26
|
+
subject: subject || '?',
|
|
27
|
+
emittedByPid: (meta && meta.emittedByPid) || null,
|
|
28
|
+
persisted,
|
|
29
|
+
payload,
|
|
30
|
+
at: new Date().toISOString(),
|
|
31
|
+
})
|
|
32
|
+
if (eventBuf.length > MAX_EVENTS) eventBuf.shift()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 이 워커에서 한 번만 demo 구독을 건다(멱등). 비영속/영속 `demo.>` 와 `demo.echo` 응답자.
|
|
37
|
+
* @param {any} ctx @returns {Promise<void>}
|
|
38
|
+
*/
|
|
39
|
+
async function ensureSubscribed(ctx) {
|
|
40
|
+
if (subscribed) return
|
|
41
|
+
subscribed = true
|
|
42
|
+
// 비영속 fan-out — 일반 emit 을 받는다. 3번째 인자 subject 로 매칭된 구체 subject 를 안다(ADR-227).
|
|
43
|
+
await ctx.bus.on('demo.>', (/** @type {any} */ payload, /** @type {any} */ meta, /** @type {any} */ subject) => record(payload, meta, false, subject))
|
|
44
|
+
// 영속 fan-out — persist:true emit 을 JetStream 에서 받는다(nats driver 만 영속, 그 외엔 비영속으로 폴백).
|
|
45
|
+
try {
|
|
46
|
+
await ctx.bus.on('demo.>', (/** @type {any} */ payload, /** @type {any} */ meta, /** @type {any} */ subject) => record(payload, meta, true, subject), { persist: true })
|
|
47
|
+
} catch (e) {
|
|
48
|
+
// 영속 구독 실패(드라이버 미지원/JetStream 비활성)는 데모 비치명 — 비영속만으로 계속.
|
|
49
|
+
ctx.log?.warn?.({ err: e }, 'bus demo: persistent subscribe unavailable')
|
|
50
|
+
}
|
|
51
|
+
// request/reply 응답자 — 반환값이 reply.
|
|
52
|
+
await ctx.bus.on('demo.echo', (/** @type {any} */ payload) => ({ echo: payload, pid: process.pid, at: new Date().toISOString() }))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class BusController {
|
|
56
|
+
/** GET /demo/bus — 데모 셸 렌더(구독 보장). @param {any} req @param {any} reply @param {any} ctx */
|
|
57
|
+
static async index(req, reply, ctx) {
|
|
58
|
+
await ensureSubscribed(ctx)
|
|
59
|
+
const stats = await BusController.#safeStats(ctx)
|
|
60
|
+
return ctx.render('bus/index', {
|
|
61
|
+
title: ctx.t('bus_title', { defaultValue: '메시지 버스 데모' }),
|
|
62
|
+
stats,
|
|
63
|
+
pid: process.pid,
|
|
64
|
+
currentUser: currentUser(req),
|
|
65
|
+
csrfToken: reply.generateCsrf(),
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* POST /demo/bus/emit — fan-out 발행. subject·payload(JSON)·persist·ordered. meta 에 subject·워커 PID 를 실어 보낸다.
|
|
71
|
+
* @param {any} req @param {any} reply @param {any} ctx
|
|
72
|
+
*/
|
|
73
|
+
static async emit(req, reply, ctx) {
|
|
74
|
+
const body = req.body ?? {}
|
|
75
|
+
const subject = String(body.subject ?? 'demo.event')
|
|
76
|
+
const payload = body.payload ?? {}
|
|
77
|
+
await ctx.bus.emit(subject, payload, {
|
|
78
|
+
persist: body.persist === true,
|
|
79
|
+
ordered: body.ordered === true,
|
|
80
|
+
meta: { emittedByPid: process.pid }, // subject 는 버스가 수신측에 자동 제공(ADR-227) — 라벨링 불필요.
|
|
81
|
+
})
|
|
82
|
+
return { ok: true, pid: process.pid, subject, persist: body.persist === true }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* POST /demo/bus/request — req/reply. demo.echo 응답자가 첫 응답을 준다(어느 워커가 답했는지 reply.pid 로 보임).
|
|
87
|
+
* @param {any} req @param {any} reply @param {any} ctx
|
|
88
|
+
*/
|
|
89
|
+
static async request(req, reply, ctx) {
|
|
90
|
+
const body = req.body ?? {}
|
|
91
|
+
const subject = String(body.subject ?? 'demo.echo')
|
|
92
|
+
const timeout = body.timeout ?? 2000
|
|
93
|
+
try {
|
|
94
|
+
const answer = await ctx.bus.request(subject, body.payload ?? {}, { timeout })
|
|
95
|
+
return { ok: true, requestedByPid: process.pid, subject, reply: answer }
|
|
96
|
+
} catch (e) {
|
|
97
|
+
// no_responders / request_timeout 은 정상 데모 결과 — 코드와 함께 응답(throw 아님).
|
|
98
|
+
const code = /** @type {any} */ (e)?.code
|
|
99
|
+
if (code === 'bus.no_responders' || code === 'bus.request_timeout') {
|
|
100
|
+
return { ok: false, requestedByPid: process.pid, subject, error: code }
|
|
101
|
+
}
|
|
102
|
+
throw e
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** GET /demo/bus/events — 이 워커의 수신 버퍼 + driver(폴링용 JSON). @param {any} _req @param {any} _reply @param {any} ctx */
|
|
107
|
+
static async events(_req, _reply, ctx) {
|
|
108
|
+
await ensureSubscribed(ctx)
|
|
109
|
+
const stats = await BusController.#safeStats(ctx)
|
|
110
|
+
return { pid: process.pid, driver: stats ? stats.driver : '?', events: eventBuf.slice().reverse() }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** stats 안전 조회. @param {any} ctx @returns {Promise<any>} */
|
|
114
|
+
static async #safeStats(ctx) {
|
|
115
|
+
try {
|
|
116
|
+
return await ctx.bus.stats()
|
|
117
|
+
} catch (e) {
|
|
118
|
+
ctx.log?.warn?.({ err: e }, 'bus demo: stats failed')
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
import { currentUser } from '../middleware/web-auth.js'
|
|
9
9
|
|
|
10
10
|
/** 발송 시뮬레이션 모드 화이트리스트(폼 입력 검증). */
|
|
11
|
-
const MODES = ['ok', 'flaky', 'fail']
|
|
11
|
+
const MODES = ['ok', 'flaky', 'fail', 'hang']
|
|
12
|
+
/** 'hang' 모드 지연 상한(ms) — 폼 입력 검증(10분). 잡 클래스 timeoutMs(5s)와 별개의 입력 가드. */
|
|
13
|
+
const MAX_DELAY_MS = 600_000
|
|
12
14
|
/** 알림 쿼리(?notice=) → 로케일 키 화이트리스트. */
|
|
13
15
|
const NOTICE_KEYS = new Set(['enqueued'])
|
|
14
16
|
|
|
@@ -37,7 +39,25 @@ export class JobsController {
|
|
|
37
39
|
const to = rawTo.length > 0 ? rawTo : 'demo@example.com'
|
|
38
40
|
// 잡 식별자 — 시도 카운터/이벤트 키에 쓰인다. 같은 페이로드 중복 enqueue 도 서로 구분되게 유니크하게 만든다.
|
|
39
41
|
const id = `email-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
|
40
|
-
|
|
42
|
+
/** @type {{ id: string, to: string, mode: string, delayMs?: number }} */
|
|
43
|
+
const payload = { id, to, mode }
|
|
44
|
+
// hang 모드만 delayMs 를 싣는다. 미입력·무효(<=0)면 생략 → 잡 클래스가 기본 지연(8s)으로 timeout 을 시연.
|
|
45
|
+
if (mode === 'hang') {
|
|
46
|
+
const delayMs = JobsController.#parseDelayMs(body.delayMs)
|
|
47
|
+
if (delayMs > 0) payload.delayMs = delayMs
|
|
48
|
+
}
|
|
49
|
+
await ctx.services.jobsDemo.enqueue(payload)
|
|
41
50
|
return reply.redirect('/demo/jobs?notice=enqueued')
|
|
42
51
|
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 폼 delayMs(문자열) → [0, MAX_DELAY_MS] 정수. 비정수·음수는 0 으로, 초과는 상한으로 보정한다. 폼 POST 라
|
|
55
|
+
* AJV body 스키마 대신 컨트롤러에서 검증한다(기존 mode/to 처리와 동일 스타일 — _csrf/AJV 순서 이슈 회피).
|
|
56
|
+
* @param {unknown} raw @returns {number} 0 = 미지정(잡 클래스 기본 지연 사용).
|
|
57
|
+
*/
|
|
58
|
+
static #parseDelayMs(raw) {
|
|
59
|
+
const n = Number.parseInt(typeof raw === 'string' ? raw : String(raw ?? ''), 10)
|
|
60
|
+
if (!Number.isFinite(n) || n <= 0) return 0
|
|
61
|
+
return Math.min(n, MAX_DELAY_MS)
|
|
62
|
+
}
|
|
43
63
|
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* LockController — /demo/lock 분산 락 데모 컨트롤러(ADR-074: 베이스 없음, 정적 메서드만, ADR-226 시연).
|
|
4
|
+
*
|
|
5
|
+
* `ctx.lock.with`(권장 — 자동 해제)와 `ctx.lock.tryAcquire`(즉시 1회)를 그대로 쓴다(프레임워크 확장 X). 두 탭에서
|
|
6
|
+
* 같은 key 로 동시에 "실행"하면 한 번에 하나만 임계구역에 들어가고(상호배제), `fifo` 면 도착 순서대로 깬다 —
|
|
7
|
+
* 응답의 워커 PID·대기 시간으로 분산 동작이 보인다. 최근 실행 로그는 워커별 모듈 링버퍼에 남긴다(상태 패널).
|
|
8
|
+
*
|
|
9
|
+
* 락 핸들을 요청 사이에 들고 있지 않는다(클러스터에서 acquire·release 가 다른 워커에 갈 수 있음) — `with` 가
|
|
10
|
+
* 한 요청 안에서 획득→대기(hold)→해제까지 자족적으로 끝내므로 워커 경계 문제가 없다.
|
|
11
|
+
*/
|
|
12
|
+
import { currentUser } from '../middleware/web-auth.js'
|
|
13
|
+
|
|
14
|
+
/** @param {number} ms @returns {Promise<void>} */
|
|
15
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
16
|
+
|
|
17
|
+
/** 최근 실행 로그(워커별 인메모리 링버퍼). 상태 패널에 역순으로 보여준다. @type {any[]} */
|
|
18
|
+
const runLog = []
|
|
19
|
+
const MAX_LOG = 25
|
|
20
|
+
|
|
21
|
+
/** 로그 1건 추가(상한 유지). @param {any} entry @returns {void} */
|
|
22
|
+
function pushLog(entry) {
|
|
23
|
+
runLog.push(entry)
|
|
24
|
+
if (runLog.length > MAX_LOG) runLog.shift()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 락 옵션 정규화(스키마가 타입은 검증 — 여기선 데모 기본값만). @param {any} body */
|
|
28
|
+
function readOpts(body) {
|
|
29
|
+
return {
|
|
30
|
+
key: String(body.key ?? 'demo:resource'),
|
|
31
|
+
ttl: body.ttl ?? 5000,
|
|
32
|
+
waitMs: body.waitMs ?? 3000,
|
|
33
|
+
holdMs: body.holdMs ?? 2000,
|
|
34
|
+
fifo: body.fifo === true,
|
|
35
|
+
fence: body.fence === true,
|
|
36
|
+
extendable: body.extendable === true,
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class LockController {
|
|
41
|
+
/** GET /demo/lock — 데모 셸 + 현재 상태(stats) 렌더. @param {any} req @param {any} reply @param {any} ctx */
|
|
42
|
+
static async index(req, reply, ctx) {
|
|
43
|
+
const stats = await LockController.#safeStats(ctx)
|
|
44
|
+
return ctx.render('lock/index', {
|
|
45
|
+
title: ctx.t('lock_title', { defaultValue: '분산 락 데모' }),
|
|
46
|
+
stats,
|
|
47
|
+
pid: process.pid,
|
|
48
|
+
runLog: runLog.slice().reverse(),
|
|
49
|
+
currentUser: currentUser(req),
|
|
50
|
+
csrfToken: reply.generateCsrf(),
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* POST /demo/lock/run — `ctx.lock.with` 로 임계구역을 잡고 holdMs 동안 보유 후 해제. 경합 시 waitMs 까지 대기.
|
|
56
|
+
* @param {any} req @param {any} reply @param {any} ctx
|
|
57
|
+
*/
|
|
58
|
+
static async run(req, reply, ctx) {
|
|
59
|
+
const o = readOpts(req.body ?? {})
|
|
60
|
+
const startedAt = Date.now()
|
|
61
|
+
try {
|
|
62
|
+
const inner = await ctx.lock.with(o.key, { ttl: o.ttl, waitMs: o.waitMs, fifo: o.fifo, fence: o.fence, extendable: o.extendable }, async (/** @type {any} */ lock) => {
|
|
63
|
+
const acquiredAt = Date.now()
|
|
64
|
+
await sleep(o.holdMs) // 임계구역 점유(다른 시도는 이만큼 대기).
|
|
65
|
+
return { acquiredAt, fence: lock.fence }
|
|
66
|
+
})
|
|
67
|
+
const entry = { ok: true, acquired: true, key: o.key, pid: process.pid, waitedMs: inner.acquiredAt - startedAt, heldMs: o.holdMs, fence: inner.fence ?? null, fifo: o.fifo, at: new Date().toISOString() }
|
|
68
|
+
pushLog(entry)
|
|
69
|
+
return entry
|
|
70
|
+
} catch (e) {
|
|
71
|
+
// 경합으로 못 잡음(lock.not_acquired, 409)은 **정상 데모 결과** — 그대로 응답한다(throw 아님).
|
|
72
|
+
if (/** @type {any} */ (e)?.code === 'lock.not_acquired') {
|
|
73
|
+
const entry = { ok: true, acquired: false, key: o.key, pid: process.pid, waitedMs: Date.now() - startedAt, reason: 'contended', fifo: o.fifo, at: new Date().toISOString() }
|
|
74
|
+
pushLog(entry)
|
|
75
|
+
return entry
|
|
76
|
+
}
|
|
77
|
+
throw e // 그 외(검증·드라이버 오류)는 표면화(500).
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* POST /demo/lock/try — `ctx.lock.tryAcquire`(즉시 1회). 잡으면 짧게 보유 후 해제, 못 잡으면 즉시 null.
|
|
83
|
+
* @param {any} req @param {any} reply @param {any} ctx
|
|
84
|
+
*/
|
|
85
|
+
static async tryRun(req, reply, ctx) {
|
|
86
|
+
const o = readOpts(req.body ?? {})
|
|
87
|
+
const lock = await ctx.lock.tryAcquire(o.key, { ttl: o.ttl, fence: o.fence })
|
|
88
|
+
if (!lock) {
|
|
89
|
+
const entry = { ok: true, acquired: false, key: o.key, pid: process.pid, reason: 'held', mode: 'try', at: new Date().toISOString() }
|
|
90
|
+
pushLog(entry)
|
|
91
|
+
return entry
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
await sleep(Math.min(o.holdMs, 2000))
|
|
95
|
+
const entry = { ok: true, acquired: true, key: o.key, pid: process.pid, token: lock.token, fence: lock.fence ?? null, mode: 'try', at: new Date().toISOString() }
|
|
96
|
+
pushLog(entry)
|
|
97
|
+
return entry
|
|
98
|
+
} finally {
|
|
99
|
+
await lock.release()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** GET /demo/lock/status — 현재 보유/대기 수(stats) + 워커별 최근 실행 로그(JSON, 페이지 폴링용). @param {any} _req @param {any} _reply @param {any} ctx */
|
|
104
|
+
static async status(_req, _reply, ctx) {
|
|
105
|
+
return { stats: await LockController.#safeStats(ctx), pid: process.pid, runLog: runLog.slice().reverse() }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** stats 안전 조회 — 드라이버/연결 문제 시 null(페이지가 깨지지 않게). @param {any} ctx @returns {Promise<any>} */
|
|
109
|
+
static async #safeStats(ctx) {
|
|
110
|
+
try {
|
|
111
|
+
return await ctx.lock.stats()
|
|
112
|
+
} catch (e) {
|
|
113
|
+
ctx.log?.warn?.({ err: e }, 'lock demo: stats failed')
|
|
114
|
+
return null
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -6,6 +6,24 @@ const FLAKY_SUCCEED_ON = 2
|
|
|
6
6
|
/** 시도 카운터 키 TTL(초) — 데모 잡은 짧게 살고 사라진다. */
|
|
7
7
|
const ATTEMPT_TTL = 600
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* EmailJob run 전체(재시도 포함) 실행 상한(ms) — 'hang' 모드 timeout 시연용 backstop. run 이 이 값을 넘게
|
|
11
|
+
* 돌면 MegaJobQueue 가 timeout 으로 판정해 잡을 실패시키고 DLQ 로 보낸다. ok/flaky/fail 의 실 실행 시간
|
|
12
|
+
* (< 3s — fail 의 백오프 최악치 ~3s 포함)보다 충분히 크게 둬(5s) 기존 모드엔 영향이 없게 한다.
|
|
13
|
+
*/
|
|
14
|
+
const RUN_TIMEOUT_MS = 5000
|
|
15
|
+
/** 'hang' 모드 기본 지연(ms) — RUN_TIMEOUT_MS 보다 커서 그냥 enqueue 해도 timeout 을 시연한다. */
|
|
16
|
+
const HANG_DEFAULT_DELAY_MS = 8000
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 지정 ms 만큼 멈춘다('hang' 모드 시뮬레이션). 프레임워크는 timeout 시 진행 중 run 을 **중단하지 않으므로**
|
|
20
|
+
* (abort 없음 — run 은 멱등 설계), 이 sleep 은 timeout 후에도 백그라운드에서 끝까지 흐른다.
|
|
21
|
+
* @param {number} ms @returns {Promise<void>}
|
|
22
|
+
*/
|
|
23
|
+
function sleep(ms) {
|
|
24
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
25
|
+
}
|
|
26
|
+
|
|
9
27
|
/**
|
|
10
28
|
* EmailJob — /demo/jobs 데모 잡(ADR-028/119). `mega worker` 프로세스(ecosystem instances:2)가 소비한다
|
|
11
29
|
* (config.jobs). 실제 메일은 보내지 않고 발송을 **시뮬레이션**하며, payload.mode 로 세 가지 흐름을 시연한다:
|
|
@@ -14,6 +32,10 @@ const ATTEMPT_TTL = 600
|
|
|
14
32
|
* - `flaky` : 1번째 시도는 throw(일시 실패) → MegaJobQueue 가 재시도(p-retry, static retries/backoff) →
|
|
15
33
|
* 2번째 시도에 성공. "일시 오류는 그냥 throw 하면 재시도된다"(MegaJob 정본)를 보여준다.
|
|
16
34
|
* - `fail` : 매 시도 throw(영구 실패) → 재시도 소진 후 DLQ(`<subject>.dlq`)로 격리된다.
|
|
35
|
+
* - `hang` : payload.delayMs 만큼 잠든다(끝나지 않는 잡 시뮬레이션). delayMs 가 `static timeoutMs`(5s)를
|
|
36
|
+
* 넘으면 MegaJobQueue 가 run 전체에 건 상한을 초과로 판정해 잡을 실패시키고 DLQ 로 보낸다.
|
|
37
|
+
* timeout 후에도 run 은 백그라운드에서 계속 흐르므로(abort 없음) delayMs 뒤 'sent' 가 뒤늦게
|
|
38
|
+
* 남는다 — "run 은 멱등하게 설계하라"는 정본 경고를 눈으로 보여준다.
|
|
17
39
|
*
|
|
18
40
|
* 각 시도를 'demo' 캐시(redis)에 기록해(시도 카운터 + 이벤트 LIST) 웹 페이지가 처리 타임라인을 보여준다.
|
|
19
41
|
* NATS durable consumer group(같은 durable 이름)이라 instances:2 워커가 메시지를 자연 분산 처리한다(중복 X).
|
|
@@ -25,6 +47,9 @@ export class EmailJob extends MegaJob {
|
|
|
25
47
|
// 추가 재시도 2회(첫 시도 포함 최대 3회). 데모 체감용으로 백오프를 짧게 둔다(기본 1s~30s 대신 0.5s~2s).
|
|
26
48
|
static retries = 2
|
|
27
49
|
static backoff = { type: 'exponential', initial: 500, max: 2000 }
|
|
50
|
+
// run 전체(재시도 포함) 상한 — 'hang' 모드 timeout 시연(미지정이면 큐 디폴트 30분이라 체감 불가). ok/flaky/
|
|
51
|
+
// fail 은 < 3s 라 영향 없다(fail 백오프 최악치 ~3s + 마진). 초과 run 은 timeout 실패 → DLQ.
|
|
52
|
+
static timeoutMs = RUN_TIMEOUT_MS
|
|
28
53
|
|
|
29
54
|
/** 이벤트 LIST 키(LPUSH → 최신이 앞). 웹이 LRANGE 로 읽는다. */
|
|
30
55
|
static EVENTS_KEY = 'demo:jobs:events'
|
|
@@ -33,7 +58,7 @@ export class EmailJob extends MegaJob {
|
|
|
33
58
|
|
|
34
59
|
/**
|
|
35
60
|
* 메시지 1건 처리. throw 하면 MegaJobQueue 가 재시도/DLQ 를 담당하므로 일시·영구 실패는 그냥 throw 한다.
|
|
36
|
-
* @param {{ id: string, to: string, subject?: string, mode: 'ok'|'flaky'|'fail' }} payload
|
|
61
|
+
* @param {{ id: string, to: string, subject?: string, mode: 'ok'|'flaky'|'fail'|'hang', delayMs?: number }} payload
|
|
37
62
|
* @param {Record<string, any>} ctx - worker 프로세스 컨텍스트(ctx.cache('demo') 글로벌 키).
|
|
38
63
|
* @returns {Promise<{ id: string, status: 'sent', attempt: number }>}
|
|
39
64
|
* @throws {Error} flaky 의 1번째 시도, fail 의 모든 시도 — 재시도/DLQ 트리거.
|
|
@@ -46,6 +71,16 @@ export class EmailJob extends MegaJob {
|
|
|
46
71
|
await redis.expire(`demo:jobs:attempt:${id}`, ATTEMPT_TTL)
|
|
47
72
|
ctx.log?.debug?.({ id, mode, attempt }, 'email-job.run')
|
|
48
73
|
|
|
74
|
+
if (mode === 'hang') {
|
|
75
|
+
// 오래 끄는 잡 — delayMs 가 static timeoutMs(5s)를 넘으면 큐가 run 상한 초과로 판정해 잡을 실패시키고
|
|
76
|
+
// DLQ 로 보낸다(fail(phase:'run') + dlq, ADR-223 로깅으로 표면화). timeout 후에도 이 run 은 백그라운드
|
|
77
|
+
// 에서 계속 흘러(abort 없음) delayMs 뒤 'sent' 를 뒤늦게 남긴다 — 멱등 설계 경고를 눈으로 보여준다.
|
|
78
|
+
const delayMs = Number.isInteger(payload.delayMs) ? /** @type {number} */ (payload.delayMs) : HANG_DEFAULT_DELAY_MS
|
|
79
|
+
await EmailJob.#logEvent(redis, { id, to, mode, attempt, status: 'running' })
|
|
80
|
+
await sleep(delayMs)
|
|
81
|
+
await EmailJob.#logEvent(redis, { id, to, mode, attempt, status: 'sent' })
|
|
82
|
+
return { id, status: 'sent', attempt }
|
|
83
|
+
}
|
|
49
84
|
if (mode === 'flaky' && attempt < FLAKY_SUCCEED_ON) {
|
|
50
85
|
await EmailJob.#logEvent(redis, { id, to, mode, attempt, status: 'retry' })
|
|
51
86
|
throw new Error(`EmailJob ${id}: simulated transient failure on attempt ${attempt} (will retry)`)
|
|
@@ -62,7 +97,7 @@ export class EmailJob extends MegaJob {
|
|
|
62
97
|
/**
|
|
63
98
|
* 처리 이벤트 1건을 redis LIST 머리에 넣고 최근 N건만 남긴다.
|
|
64
99
|
* @param {any} redis - ioredis 핸들(ctx.cache('demo').native).
|
|
65
|
-
* @param {{ id: string, to: string, mode: string, attempt: number, status: 'retry'|'failed'|'sent' }} event
|
|
100
|
+
* @param {{ id: string, to: string, mode: string, attempt: number, status: 'retry'|'failed'|'sent'|'running' }} event
|
|
66
101
|
* @returns {Promise<void>}
|
|
67
102
|
*/
|
|
68
103
|
static async #logEvent(redis, event) {
|
|
@@ -221,6 +221,8 @@
|
|
|
221
221
|
"ws_encrypted_note": "Messages are encrypted with ASP (AES-256-GCM) E: frames in transit.",
|
|
222
222
|
"nav_cron": "Scheduler (Cron)",
|
|
223
223
|
"nav_jobs": "Job Queue (NATS)",
|
|
224
|
+
"nav_lock": "Distributed Lock",
|
|
225
|
+
"nav_bus": "Message Bus",
|
|
224
226
|
"nav_worker": "Workers (Threads)",
|
|
225
227
|
"home_demo_cron": "Scheduler demo",
|
|
226
228
|
"home_demo_jobs": "Job queue demo",
|
|
@@ -251,12 +253,16 @@
|
|
|
251
253
|
"jobs_reload": "Reload",
|
|
252
254
|
"jobs_field_to": "Recipient",
|
|
253
255
|
"jobs_field_mode": "Mode",
|
|
256
|
+
"jobs_field_delay": "Delay (ms) — hang mode only",
|
|
257
|
+
"jobs_field_delay_hint": "Leave blank for the default 8000ms. Exceeding 5000ms (timeoutMs) isolates the job to the DLQ on timeout.",
|
|
254
258
|
"jobs_mode_ok": "Success",
|
|
255
259
|
"jobs_mode_flaky": "Retry",
|
|
256
260
|
"jobs_mode_fail": "Permanent failure",
|
|
261
|
+
"jobs_mode_hang": "Timeout",
|
|
257
262
|
"jobs_mode_ok_hint": "succeeds on the first attempt",
|
|
258
263
|
"jobs_mode_flaky_hint": "1st attempt fails → retry → 2nd succeeds",
|
|
259
264
|
"jobs_mode_fail_hint": "every attempt fails → DLQ after retries are exhausted",
|
|
265
|
+
"jobs_mode_hang_hint": "hangs for delayMs → exceeds 5s (timeoutMs) → timeout → DLQ",
|
|
260
266
|
"jobs_dlq_title": "DLQ (isolated jobs)",
|
|
261
267
|
"jobs_dlq_desc": "Where permanently failed jobs land after retries are exhausted (a NATS stream). For analysis and reprocessing.",
|
|
262
268
|
"jobs_dlq_empty": "No jobs have reached the DLQ yet.",
|
|
@@ -274,6 +280,7 @@
|
|
|
274
280
|
"jobs_status_sent": "sent",
|
|
275
281
|
"jobs_status_retry": "retry",
|
|
276
282
|
"jobs_status_failed": "failed",
|
|
283
|
+
"jobs_status_running": "running",
|
|
277
284
|
"jobs_notice_enqueued": "Job enqueued. Once a worker processes it, it appears in the events below.",
|
|
278
285
|
"worker_title": "CPU worker demo (MegaWorker)",
|
|
279
286
|
"worker_subtitle": "Runs CPU-bound work like N rounds of SHA-256 in a worker_threads pool. A heartbeat confirms the server still answers other requests instantly while computing (main thread non-blocking).",
|
|
@@ -324,5 +331,33 @@
|
|
|
324
331
|
"field_phone": "전화번호",
|
|
325
332
|
"field_phone_ph": "예: 010-1234-5678",
|
|
326
333
|
"col_username": "아이디",
|
|
327
|
-
"col_nickname": "별명"
|
|
334
|
+
"col_nickname": "별명",
|
|
335
|
+
"lock_title": "분산 락 데모",
|
|
336
|
+
"lock_subtitle": "ctx.lock.with / tryAcquire 로 임계구역을 보호합니다. 두 탭에서 같은 키로 동시에 실행하면 한 번에 하나만 들어가고(상호배제), FIFO 면 도착 순서대로 깨어납니다.",
|
|
337
|
+
"lock_run_title": "임계구역 실행",
|
|
338
|
+
"lock_run_desc": "키를 잡고 holdMs 동안 점유한 뒤 자동 해제합니다(ctx.lock.with). 다른 시도는 waitMs 까지 대기합니다.",
|
|
339
|
+
"lock_f_key": "자원 키",
|
|
340
|
+
"lock_f_fifo": "(도착 순서 보장)",
|
|
341
|
+
"lock_f_fence": "(단조 토큰)",
|
|
342
|
+
"lock_f_ext": "(watchdog 자동연장)",
|
|
343
|
+
"lock_btn_run": "실행 (with)",
|
|
344
|
+
"lock_btn_try": "tryAcquire",
|
|
345
|
+
"lock_status_title": "현재 상태",
|
|
346
|
+
"lock_active": "보유 중",
|
|
347
|
+
"lock_waiting": "대기 중",
|
|
348
|
+
"lock_worker_pid": "이 페이지를 처리한 워커 PID",
|
|
349
|
+
"lock_log_title": "최근 실행 (이 워커)",
|
|
350
|
+
"lock_log_result": "결과",
|
|
351
|
+
"bus_title": "메시지 버스 데모",
|
|
352
|
+
"bus_subtitle": "ctx.bus.emit / on / request 로 이벤트를 주고받습니다. 이 페이지는 demo.> 를 구독해 수신 이벤트를 실시간(폴링) 표시합니다 — 다른 워커가 발행해도 fan-out 으로 도착합니다.",
|
|
353
|
+
"bus_emit_title": "발행 (emit)",
|
|
354
|
+
"bus_emit_desc": "subject 와 payload(JSON)를 fan-out 발행합니다. persist:true 면 JetStream 에 저장됩니다.",
|
|
355
|
+
"bus_btn_emit": "Emit",
|
|
356
|
+
"bus_req_title": "요청/응답 (request)",
|
|
357
|
+
"bus_req_desc": "demo.echo 응답자가 첫 응답을 돌려줍니다 — 어느 워커가 답했는지 PID 로 보입니다.",
|
|
358
|
+
"bus_btn_request": "Request demo.echo",
|
|
359
|
+
"bus_recv_title": "수신 이벤트",
|
|
360
|
+
"bus_recv_pid": "구독 워커",
|
|
361
|
+
"bus_recv_desc": "서버가 demo.> 를 구독합니다 — * 는 한 토큰, > 는 꼬리 전체. order.created / order.created.eu / user.login 모두 demo.> 에 매칭됩니다. persist 이벤트는 배지로 구분됩니다.",
|
|
362
|
+
"bus_recv_pid_col": "수신 워커"
|
|
328
363
|
}
|