mega-framework 0.1.0
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/.env +127 -0
- package/.env.example +186 -0
- package/.prettierrc.json +8 -0
- package/CHANGELOG.md +259 -0
- package/LICENSE +21 -0
- package/README.md +153 -0
- package/bin/mega-ws-hub.js +15 -0
- package/bin/mega.js +38 -0
- package/docker-compose.yml +201 -0
- package/eslint.config.js +57 -0
- package/infra/otel-collector-config.yaml +43 -0
- package/jsconfig.json +18 -0
- package/package.json +121 -0
- package/sample/crud/.env +18 -0
- package/sample/crud/.env.example +50 -0
- package/sample/crud/README.md +85 -0
- package/sample/crud/apps/main/app.config.js +114 -0
- package/sample/crud/apps/main/channels/chat-bus.js +115 -0
- package/sample/crud/apps/main/channels/chat-channel.js +145 -0
- package/sample/crud/apps/main/controllers/auth-controller.js +144 -0
- package/sample/crud/apps/main/controllers/cron-controller.js +34 -0
- package/sample/crud/apps/main/controllers/guide-controller.js +37 -0
- package/sample/crud/apps/main/controllers/jobs-controller.js +43 -0
- package/sample/crud/apps/main/controllers/logs-controller.js +35 -0
- package/sample/crud/apps/main/controllers/metrics-controller.js +22 -0
- package/sample/crud/apps/main/controllers/note-controller.js +116 -0
- package/sample/crud/apps/main/controllers/perf-controller.js +38 -0
- package/sample/crud/apps/main/controllers/redis-controller.js +36 -0
- package/sample/crud/apps/main/controllers/tracing-controller.js +43 -0
- package/sample/crud/apps/main/controllers/upload-controller.js +98 -0
- package/sample/crud/apps/main/controllers/user-controller.js +34 -0
- package/sample/crud/apps/main/controllers/web-controller.js +137 -0
- package/sample/crud/apps/main/controllers/worker-controller.js +57 -0
- package/sample/crud/apps/main/controllers/ws-controller.js +29 -0
- package/sample/crud/apps/main/jobs/email-job.js +72 -0
- package/sample/crud/apps/main/locales/client/en.json +3 -0
- package/sample/crud/apps/main/locales/client/ko.json +3 -0
- package/sample/crud/apps/main/locales/server/en.json +316 -0
- package/sample/crud/apps/main/locales/server/ko.json +316 -0
- package/sample/crud/apps/main/middleware/web-auth.js +40 -0
- package/sample/crud/apps/main/middleware/ws-auth.js +48 -0
- package/sample/crud/apps/main/migrations/20260606000001-create-users.js +27 -0
- package/sample/crud/apps/main/migrations/20260606000002-add-auth-to-users.js +30 -0
- package/sample/crud/apps/main/models/note.js +71 -0
- package/sample/crud/apps/main/models/user.js +86 -0
- package/sample/crud/apps/main/public/css/app.css +101 -0
- package/sample/crud/apps/main/public/css/guide.css +137 -0
- package/sample/crud/apps/main/public/js/app.js +54 -0
- package/sample/crud/apps/main/public/js/perf.js +129 -0
- package/sample/crud/apps/main/public/js/theme-init.js +12 -0
- package/sample/crud/apps/main/public/js/upload-demo.js +63 -0
- package/sample/crud/apps/main/public/js/worker-demo.js +92 -0
- package/sample/crud/apps/main/public/js/ws-chat.js +161 -0
- package/sample/crud/apps/main/public/vendor/bootstrap/bootstrap.bundle.min.js +7 -0
- package/sample/crud/apps/main/public/vendor/bootstrap/bootstrap.min.css +6 -0
- package/sample/crud/apps/main/public/vendor/highlight/github-dark.css +109 -0
- package/sample/crud/apps/main/public/vendor/highlight/github.css +118 -0
- package/sample/crud/apps/main/public/vendor/mega-client-wasm/README.md +19 -0
- package/sample/crud/apps/main/public/vendor/mega-client-wasm/mega_client_wasm.d.ts +196 -0
- package/sample/crud/apps/main/public/vendor/mega-client-wasm/mega_client_wasm.js +1187 -0
- package/sample/crud/apps/main/public/vendor/mega-client-wasm/mega_client_wasm_bg.wasm +0 -0
- package/sample/crud/apps/main/routes/auth.js +15 -0
- package/sample/crud/apps/main/routes/cron.js +14 -0
- package/sample/crud/apps/main/routes/guide.js +25 -0
- package/sample/crud/apps/main/routes/jobs.js +14 -0
- package/sample/crud/apps/main/routes/logs.js +28 -0
- package/sample/crud/apps/main/routes/metrics.js +13 -0
- package/sample/crud/apps/main/routes/notes.js +19 -0
- package/sample/crud/apps/main/routes/perf.js +47 -0
- package/sample/crud/apps/main/routes/redis.js +14 -0
- package/sample/crud/apps/main/routes/tracing.js +14 -0
- package/sample/crud/apps/main/routes/upload.js +16 -0
- package/sample/crud/apps/main/routes/users.js +54 -0
- package/sample/crud/apps/main/routes/web.js +23 -0
- package/sample/crud/apps/main/routes/worker.js +15 -0
- package/sample/crud/apps/main/routes/ws.js +30 -0
- package/sample/crud/apps/main/schedules/cron-counter-schedule.js +30 -0
- package/sample/crud/apps/main/services/auth-service.js +74 -0
- package/sample/crud/apps/main/services/cron-demo-service.js +66 -0
- package/sample/crud/apps/main/services/guide-service.js +145 -0
- package/sample/crud/apps/main/services/jobs-demo-service.js +83 -0
- package/sample/crud/apps/main/services/logs-demo-service.js +59 -0
- package/sample/crud/apps/main/services/metrics-demo-service.js +144 -0
- package/sample/crud/apps/main/services/note-service.js +75 -0
- package/sample/crud/apps/main/services/perf-service.js +302 -0
- package/sample/crud/apps/main/services/redis-demo-service.js +75 -0
- package/sample/crud/apps/main/services/tracing-demo-service.js +69 -0
- package/sample/crud/apps/main/services/upload-demo-service.js +48 -0
- package/sample/crud/apps/main/services/user-service.js +65 -0
- package/sample/crud/apps/main/views/auth/login.ejs +57 -0
- package/sample/crud/apps/main/views/auth/register.ejs +71 -0
- package/sample/crud/apps/main/views/cron/index.ejs +92 -0
- package/sample/crud/apps/main/views/guide/index.ejs +24 -0
- package/sample/crud/apps/main/views/guide/page.ejs +64 -0
- package/sample/crud/apps/main/views/home.ejs +82 -0
- package/sample/crud/apps/main/views/jobs/index.ejs +113 -0
- package/sample/crud/apps/main/views/layouts/main.ejs +112 -0
- package/sample/crud/apps/main/views/logs/index.ejs +80 -0
- package/sample/crud/apps/main/views/metrics/index.ejs +123 -0
- package/sample/crud/apps/main/views/notes/edit.ejs +45 -0
- package/sample/crud/apps/main/views/notes/list.ejs +74 -0
- package/sample/crud/apps/main/views/notes/new.ejs +45 -0
- package/sample/crud/apps/main/views/perf/index.ejs +90 -0
- package/sample/crud/apps/main/views/redis/index.ejs +65 -0
- package/sample/crud/apps/main/views/tracing/index.ejs +106 -0
- package/sample/crud/apps/main/views/upload/index.ejs +79 -0
- package/sample/crud/apps/main/views/users/edit.ejs +48 -0
- package/sample/crud/apps/main/views/users/list.ejs +81 -0
- package/sample/crud/apps/main/views/users/new.ejs +48 -0
- package/sample/crud/apps/main/views/worker/index.ejs +70 -0
- package/sample/crud/apps/main/views/ws/index.ejs +62 -0
- package/sample/crud/apps/main/workers/hash-worker.js +17 -0
- package/sample/crud/apps/main/workers/hash.task.js +22 -0
- package/sample/crud/ecosystem.config.cjs +9 -0
- package/sample/crud/mega.config.js +105 -0
- package/sample/crud/package-lock.json +5665 -0
- package/sample/crud/package.json +28 -0
- package/sample/crud/test/apps/main/auth-flow.integration.test.js +177 -0
- package/sample/crud/test/apps/main/auth-service.test.js +93 -0
- package/sample/crud/test/apps/main/chat-bus.test.js +101 -0
- package/sample/crud/test/apps/main/chat-channel.test.js +144 -0
- package/sample/crud/test/apps/main/cron-demo-service.test.js +93 -0
- package/sample/crud/test/apps/main/demo-flow.integration.test.js +386 -0
- package/sample/crud/test/apps/main/email-job.test.js +76 -0
- package/sample/crud/test/apps/main/guide-service.test.js +68 -0
- package/sample/crud/test/apps/main/hash-task.test.js +30 -0
- package/sample/crud/test/apps/main/jobs-demo-service.test.js +88 -0
- package/sample/crud/test/apps/main/logs-demo-service.test.js +85 -0
- package/sample/crud/test/apps/main/metrics-demo-service.test.js +90 -0
- package/sample/crud/test/apps/main/note-service.test.js +68 -0
- package/sample/crud/test/apps/main/perf-service.test.js +121 -0
- package/sample/crud/test/apps/main/perf.integration.test.js +202 -0
- package/sample/crud/test/apps/main/redis-demo-service.test.js +98 -0
- package/sample/crud/test/apps/main/tracing-demo-service.test.js +90 -0
- package/sample/crud/test/apps/main/upload-demo-service.test.js +61 -0
- package/sample/crud/test/apps/main/user-service.test.js +65 -0
- package/sample/crud/test/apps/main/ws-chat.integration.test.js +232 -0
- package/sample/crud/vitest.config.js +8 -0
- package/sample/crud/yarn.lock +2142 -0
- package/sample/simple/.env.example +15 -0
- package/sample/simple/README.md +52 -0
- package/sample/simple/apps/main/app.config.js +35 -0
- package/sample/simple/apps/main/controllers/pages-controller.js +22 -0
- package/sample/simple/apps/main/locales/client/en.json +3 -0
- package/sample/simple/apps/main/locales/client/ko.json +3 -0
- package/sample/simple/apps/main/locales/server/en.json +23 -0
- package/sample/simple/apps/main/locales/server/ko.json +23 -0
- package/sample/simple/apps/main/public/css/app.css +101 -0
- package/sample/simple/apps/main/public/hello.txt +1 -0
- package/sample/simple/apps/main/public/js/app.js +54 -0
- package/sample/simple/apps/main/public/js/theme-init.js +12 -0
- package/sample/simple/apps/main/public/vendor/bootstrap/bootstrap.bundle.min.js +7 -0
- package/sample/simple/apps/main/public/vendor/bootstrap/bootstrap.min.css +6 -0
- package/sample/simple/apps/main/routes/index.js +9 -0
- package/sample/simple/apps/main/routes/pages.js +12 -0
- package/sample/simple/apps/main/views/index.ejs +56 -0
- package/sample/simple/apps/main/views/layouts/main.ejs +74 -0
- package/sample/simple/ecosystem.config.cjs +10 -0
- package/sample/simple/mega.config.js +27 -0
- package/sample/simple/package-lock.json +1851 -0
- package/sample/simple/package.json +25 -0
- package/sample/simple/test/apps/main/index.test.js +13 -0
- package/sample/simple/vitest.config.js +8 -0
- package/src/adapters/adapter-manager.js +305 -0
- package/src/adapters/adapter-options.js +208 -0
- package/src/adapters/file-adapter.js +350 -0
- package/src/adapters/file-session-adapter.js +363 -0
- package/src/adapters/index.js +38 -0
- package/src/adapters/maria-adapter.js +425 -0
- package/src/adapters/mega-adapter.js +511 -0
- package/src/adapters/mega-bus-adapter.js +81 -0
- package/src/adapters/mega-cache-adapter.js +94 -0
- package/src/adapters/mega-db-adapter.js +72 -0
- package/src/adapters/mega-lock-adapter.js +118 -0
- package/src/adapters/mega-log-sink-adapter.js +46 -0
- package/src/adapters/mega-session-adapter.js +72 -0
- package/src/adapters/mongo-adapter.js +396 -0
- package/src/adapters/nats-adapter.js +370 -0
- package/src/adapters/postgres-adapter.js +341 -0
- package/src/adapters/redis-adapter.js +331 -0
- package/src/adapters/redis-session-adapter.js +261 -0
- package/src/adapters/redlock-adapter.js +385 -0
- package/src/adapters/registry.js +157 -0
- package/src/adapters/sqlite-adapter.js +309 -0
- package/src/auth/index.js +103 -0
- package/src/cli/commands/console-cmd.js +56 -0
- package/src/cli/commands/new.js +101 -0
- package/src/cli/commands/routes.js +107 -0
- package/src/cli/commands/scaffold.js +120 -0
- package/src/cli/commands/test-cmd.js +45 -0
- package/src/cli/generators/index.js +368 -0
- package/src/cli/index.js +472 -0
- package/src/cli/template-engine.js +72 -0
- package/src/cli/ws-hub.js +582 -0
- package/src/core/ajv-mapper.js +80 -0
- package/src/core/boot.js +323 -0
- package/src/core/cluster-metrics.js +278 -0
- package/src/core/config-loader.js +115 -0
- package/src/core/config-validator.js +322 -0
- package/src/core/ctx-builder.js +253 -0
- package/src/core/envelope.js +88 -0
- package/src/core/error-mapper.js +116 -0
- package/src/core/formbody.js +69 -0
- package/src/core/hub-link.js +552 -0
- package/src/core/i18n.js +525 -0
- package/src/core/index.js +63 -0
- package/src/core/mega-app.js +1138 -0
- package/src/core/mega-cluster.js +232 -0
- package/src/core/mega-server.js +176 -0
- package/src/core/mega-service.js +41 -0
- package/src/core/migration-runner.js +196 -0
- package/src/core/multipart.js +282 -0
- package/src/core/openapi.js +114 -0
- package/src/core/router.js +388 -0
- package/src/core/routes-loader.js +57 -0
- package/src/core/scope-registry.js +53 -0
- package/src/core/security.js +275 -0
- package/src/core/services-loader.js +98 -0
- package/src/core/session-cleanup-schedule.js +57 -0
- package/src/core/session-store.js +55 -0
- package/src/core/session.js +414 -0
- package/src/core/static-assets.js +126 -0
- package/src/core/template.js +294 -0
- package/src/core/workers-manager.js +193 -0
- package/src/core/ws-compression.js +112 -0
- package/src/core/ws-controller.js +109 -0
- package/src/core/ws-message.js +176 -0
- package/src/core/ws-upgrade.js +445 -0
- package/src/errors/config-error.js +16 -0
- package/src/errors/http-errors.js +130 -0
- package/src/errors/index.js +19 -0
- package/src/errors/mega-error.js +34 -0
- package/src/eslint-plugin/index.js +15 -0
- package/src/eslint-plugin/no-direct-model-import.js +113 -0
- package/src/index.js +131 -0
- package/src/lib/asp/config.js +83 -0
- package/src/lib/asp/crypto.js +145 -0
- package/src/lib/asp/errors.js +49 -0
- package/src/lib/asp/nonce-cache.js +94 -0
- package/src/lib/asp/plugin.js +263 -0
- package/src/lib/asp/ws-terminator.js +101 -0
- package/src/lib/env-mapper.js +222 -0
- package/src/lib/hub-protocol.js +322 -0
- package/src/lib/index.js +42 -0
- package/src/lib/logger/telegram-core.js +150 -0
- package/src/lib/logger/telegram-transport.js +126 -0
- package/src/lib/mega-brute-force.js +225 -0
- package/src/lib/mega-circuit-breaker.js +412 -0
- package/src/lib/mega-cron.js +169 -0
- package/src/lib/mega-hash.js +179 -0
- package/src/lib/mega-health.js +91 -0
- package/src/lib/mega-job-queue.js +600 -0
- package/src/lib/mega-job-worker.js +295 -0
- package/src/lib/mega-job.js +140 -0
- package/src/lib/mega-logger.js +128 -0
- package/src/lib/mega-metrics.js +661 -0
- package/src/lib/mega-plugin.js +650 -0
- package/src/lib/mega-retry.js +95 -0
- package/src/lib/mega-schedule.js +507 -0
- package/src/lib/mega-shutdown.js +176 -0
- package/src/lib/mega-tracing.js +715 -0
- package/src/lib/mega-worker.js +653 -0
- package/src/lib/worker-runner/process-entry.js +30 -0
- package/src/lib/worker-runner/task-dispatch.js +72 -0
- package/src/lib/worker-runner/thread-entry.js +26 -0
- package/src/models/index.js +7 -0
- package/src/models/mega-model.js +151 -0
- package/src/test/index.js +288 -0
- package/templates/adapter/code.tpl +40 -0
- package/templates/adapter/test.tpl +13 -0
- package/templates/app/app.config.tpl +10 -0
- package/templates/app/route.tpl +10 -0
- package/templates/app/test.tpl +13 -0
- package/templates/channel/code.tpl +38 -0
- package/templates/channel/test.tpl +19 -0
- package/templates/controller/code.tpl +16 -0
- package/templates/controller/route.tpl +9 -0
- package/templates/controller/test.tpl +14 -0
- package/templates/job/code.tpl +23 -0
- package/templates/job/test.tpl +17 -0
- package/templates/locale/code.tpl +3 -0
- package/templates/locale/test.tpl +13 -0
- package/templates/middleware/code.tpl +13 -0
- package/templates/middleware/test.tpl +11 -0
- package/templates/migration/code.tpl +20 -0
- package/templates/migration/test.tpl +14 -0
- package/templates/model/code.tpl +21 -0
- package/templates/model/test.tpl +29 -0
- package/templates/project/app.config.tpl +8 -0
- package/templates/project/app.config.views.tpl +37 -0
- package/templates/project/ecosystem.config.tpl +10 -0
- package/templates/project/env.tpl +12 -0
- package/templates/project/gitignore.tpl +8 -0
- package/templates/project/locales/client/en.json.tpl +3 -0
- package/templates/project/locales/client/ko.json.tpl +3 -0
- package/templates/project/locales/server/en.json.tpl +17 -0
- package/templates/project/locales/server/ko.json.tpl +17 -0
- package/templates/project/mega.config.tpl +11 -0
- package/templates/project/package.tpl +25 -0
- package/templates/project/public/css/app.css +101 -0
- package/templates/project/public/js/app.js +54 -0
- package/templates/project/public/js/theme-init.js +12 -0
- package/templates/project/public/vendor/bootstrap/bootstrap.bundle.min.js +7 -0
- package/templates/project/public/vendor/bootstrap/bootstrap.min.css +6 -0
- package/templates/project/readme.tpl +48 -0
- package/templates/project/route.test.tpl +13 -0
- package/templates/project/route.test.views.tpl +15 -0
- package/templates/project/route.tpl +10 -0
- package/templates/project/route.views.tpl +10 -0
- package/templates/project/views/index.ejs.tpl +58 -0
- package/templates/project/views/layout.ejs.tpl +73 -0
- package/templates/project/vitest.config.tpl +8 -0
- package/templates/route/code.tpl +11 -0
- package/templates/route/test.tpl +26 -0
- package/templates/schedule/code.tpl +19 -0
- package/templates/schedule/test.tpl +17 -0
- package/templates/service/code.tpl +18 -0
- package/templates/service/test.tpl +17 -0
- package/templates/worker/code.tpl +14 -0
- package/templates/worker/task.tpl +13 -0
- package/templates/worker/test.tpl +18 -0
- package/vitest.config.js +33 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { MegaService } from 'mega-framework'
|
|
3
|
+
import { MegaHash, MegaAspCrypto } from 'mega-framework/lib'
|
|
4
|
+
import { MegaValidationError } from 'mega-framework/errors'
|
|
5
|
+
import { performance } from 'node:perf_hooks'
|
|
6
|
+
import { randomUUID } from 'node:crypto'
|
|
7
|
+
import { Buffer } from 'node:buffer'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* PerfService — /perf 벤치마크 로직(ADR-174). 파일명 `perf-service.js` → 자동 DI 이름 `perf`
|
|
11
|
+
* (ctx.services.perf, ADR-148). Node 내장 `perf_hooks.performance.now()` 로 시나리오별 per-iteration
|
|
12
|
+
* 지연을 직접 측정한다(외부 부하도구 autocannon/k6 미사용 — 신규 의존성 0).
|
|
13
|
+
*
|
|
14
|
+
* 측정 표면은 in-process(http/json/crypto)와 실제 어댑터 왕복(db/cache/session) 두 갈래다. in-process
|
|
15
|
+
* 시나리오는 프레임워크 공개 표면(MegaHash·MegaAspCrypto)을 그대로 호출해 암호화 비용을 잰다. 어댑터
|
|
16
|
+
* 시나리오는 `ctx.db`/`ctx.cache`/`ctx.app.sessionStore` 의 set/insert/select 왕복을 잰다.
|
|
17
|
+
*
|
|
18
|
+
* 안전장치(ADR-174): 시나리오별 유효 상한({@link SCENARIO_LIMITS})으로 반복·동시성을 clamp 한다. scrypt
|
|
19
|
+
* 해시(`crypto.hash`)는 호출당 ≈32 MiB(ADR-130)라 무제한 반복은 자기-DoS·OOM 위험이라 낮게 묶는다.
|
|
20
|
+
* clamp 가 일어나면 응답 `clamped` 필드 + debug 로그로 드러낸다(조용한 절삭 금지).
|
|
21
|
+
*/
|
|
22
|
+
export class PerfService extends MegaService {
|
|
23
|
+
/**
|
|
24
|
+
* 벤치마크 1회 실행. setup → 측정 → teardown(try/finally 로 보장).
|
|
25
|
+
* @param {{ scenario: string, iterations: number, concurrency?: number, payloadSize?: number }} input
|
|
26
|
+
* @returns {Promise<object>} 시나리오·실행 파라미터 + 통계(durationMs/rps/p50…/min/max/avg/ok/fail).
|
|
27
|
+
* @throws {MegaValidationError} `perf.unknown_scenario` - 미지원 시나리오. `perf.session_unavailable` - 세션 스토어 미배선.
|
|
28
|
+
*/
|
|
29
|
+
async run(input) {
|
|
30
|
+
const scenario = String(input.scenario)
|
|
31
|
+
const limit = SCENARIO_LIMITS[scenario]
|
|
32
|
+
if (!limit) {
|
|
33
|
+
throw new MegaValidationError('perf.unknown_scenario', `PerfService.run: unknown scenario "${scenario}".`, {
|
|
34
|
+
details: { scenario, available: Object.keys(SCENARIO_LIMITS) },
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 시나리오별 유효 상한으로 clamp — 동시성 디폴트도 시나리오마다 다르다(scrypt 는 1).
|
|
39
|
+
const reqIter = input.iterations
|
|
40
|
+
const reqConc = input.concurrency ?? limit.defConc
|
|
41
|
+
const effIter = Math.min(reqIter, limit.maxIter)
|
|
42
|
+
const effConc = Math.min(reqConc, limit.maxConc)
|
|
43
|
+
const clamped = {}
|
|
44
|
+
if (effIter !== reqIter) clamped.iterations = { requested: reqIter, applied: effIter }
|
|
45
|
+
if (effConc !== reqConc) clamped.concurrency = { requested: reqConc, applied: effConc }
|
|
46
|
+
if (Object.keys(clamped).length > 0) {
|
|
47
|
+
this.log.debug?.({ scenario, clamped }, 'perf.run clamped to scenario limit')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const runId = randomUUID()
|
|
51
|
+
this.log.debug?.({ scenario, iterations: effIter, concurrency: effConc, runId }, 'perf.run enter')
|
|
52
|
+
const { iterate, teardown } = await this.#prepare(scenario, {
|
|
53
|
+
payloadSize: input.payloadSize,
|
|
54
|
+
runId,
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const stats = await this.#measure(effIter, effConc, iterate)
|
|
59
|
+
this.log.debug?.({ scenario, durationMs: stats.durationMs, rps: stats.rps }, 'perf.run done')
|
|
60
|
+
return {
|
|
61
|
+
scenario,
|
|
62
|
+
iterations: effIter,
|
|
63
|
+
concurrency: effConc,
|
|
64
|
+
payloadSize: input.payloadSize ?? 0,
|
|
65
|
+
...stats,
|
|
66
|
+
...(Object.keys(clamped).length > 0 ? { clamped } : {}),
|
|
67
|
+
}
|
|
68
|
+
} finally {
|
|
69
|
+
// teardown 은 비치명적이라도 실패를 묵지 않는다 — 측정 결과는 살리되 정리 실패는 warn 으로 남긴다.
|
|
70
|
+
try {
|
|
71
|
+
await teardown()
|
|
72
|
+
} catch (e) {
|
|
73
|
+
this.log.warn?.({ err: e, scenario, runId }, 'perf.run teardown failed')
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 반복을 동시성만큼 청크(batch)로 나눠 실행하며 per-iteration 지연을 수집한다. 성공 표본만 통계에
|
|
80
|
+
* 넣고 실패는 fail 로 센다. 전체 wall-clock 으로 rps 를 낸다.
|
|
81
|
+
* @param {number} iterations @param {number} concurrency @param {(i: number) => Promise<void>} iterate
|
|
82
|
+
* @returns {Promise<object>} {@link summarize} 결과.
|
|
83
|
+
*/
|
|
84
|
+
async #measure(iterations, concurrency, iterate) {
|
|
85
|
+
/** @type {number[]} 성공 iteration 의 per-iteration 지연(ms). */
|
|
86
|
+
const samples = []
|
|
87
|
+
const wallStart = performance.now()
|
|
88
|
+
for (let start = 0; start < iterations; start += concurrency) {
|
|
89
|
+
const end = Math.min(start + concurrency, iterations)
|
|
90
|
+
const batch = []
|
|
91
|
+
for (let i = start; i < end; i++) {
|
|
92
|
+
batch.push(this.#timed(iterate, i, samples))
|
|
93
|
+
}
|
|
94
|
+
await Promise.all(batch)
|
|
95
|
+
}
|
|
96
|
+
const wallMs = performance.now() - wallStart
|
|
97
|
+
return summarize(samples, wallMs, iterations)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 한 iteration 을 측정한다. 성공이면 지연을 samples 에 push, 실패면 묵지 않고 debug 로 남기고 통과
|
|
102
|
+
* (전체 실행은 계속, 실패 수는 summarize 가 total−성공 으로 계산).
|
|
103
|
+
* @param {(i: number) => Promise<void>} iterate @param {number} i @param {number[]} samples
|
|
104
|
+
* @returns {Promise<void>}
|
|
105
|
+
*/
|
|
106
|
+
async #timed(iterate, i, samples) {
|
|
107
|
+
const t0 = performance.now()
|
|
108
|
+
try {
|
|
109
|
+
await iterate(i)
|
|
110
|
+
samples.push(performance.now() - t0)
|
|
111
|
+
} catch (e) {
|
|
112
|
+
this.log.debug?.({ err: /** @type {any} */ (e)?.message, i }, 'perf.iteration failed')
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 시나리오별 setup → per-iteration 함수 + teardown 을 만든다. setup 비용(테이블/키 준비, 키 유도)은
|
|
118
|
+
* 측정 밖에서 1회만 치른다.
|
|
119
|
+
* @param {string} scenario @param {{ payloadSize?: number, runId: string }} opts
|
|
120
|
+
* @returns {Promise<{ iterate: (i: number) => Promise<void>, teardown: () => Promise<void> }>}
|
|
121
|
+
*/
|
|
122
|
+
async #prepare(scenario, { payloadSize, runId }) {
|
|
123
|
+
const noTeardown = async () => {}
|
|
124
|
+
|
|
125
|
+
switch (scenario) {
|
|
126
|
+
case 'http.echo': {
|
|
127
|
+
// 순수 in-process 응답 — 핸들러 디스패치 비용의 하한(Promise 마이크로태스크 왕복)만 잰다.
|
|
128
|
+
const payload = { ok: true, value: 42 }
|
|
129
|
+
return { iterate: async () => { await Promise.resolve(payload) }, teardown: noTeardown }
|
|
130
|
+
}
|
|
131
|
+
case 'http.jsonSmall': {
|
|
132
|
+
// 작은(≈100B) 응답 직렬화/역직렬화 비용.
|
|
133
|
+
const obj = { id: 1, name: 'mega', tags: ['a', 'b', 'c'], nested: { x: 1, y: 2 }, active: true }
|
|
134
|
+
return { iterate: async () => { JSON.parse(JSON.stringify(obj)) }, teardown: noTeardown }
|
|
135
|
+
}
|
|
136
|
+
case 'http.jsonLarge': {
|
|
137
|
+
// payloadSize 바이트만큼의 페이로드 직렬화/역직렬화 — 큰 응답의 serialize 비용 곡선.
|
|
138
|
+
const size = clampInt(payloadSize, 0, 1_048_576, 65_536)
|
|
139
|
+
const obj = { data: 'x'.repeat(size) }
|
|
140
|
+
return { iterate: async () => { JSON.parse(JSON.stringify(obj)) }, teardown: noTeardown }
|
|
141
|
+
}
|
|
142
|
+
case 'crypto.hash': {
|
|
143
|
+
// scrypt 비밀번호 해시 비용(ADR-130 디폴트 파라미터, 호출당 ≈32 MiB) — 동시성을 낮게 묶는다.
|
|
144
|
+
return { iterate: async () => { await MegaHash.password.hash('benchmark-password') }, teardown: noTeardown }
|
|
145
|
+
}
|
|
146
|
+
case 'crypto.aspRoundtrip': {
|
|
147
|
+
// ASP 수송 암호(AES-256-GCM, ADR-053~060)의 encrypt→decrypt 왕복. 키 유도는 setup 1회만.
|
|
148
|
+
const key = MegaAspCrypto.deriveKey('perf-bench-secret', 'localhost', '/perf', '0000', 1_700_000_000_000)
|
|
149
|
+
const size = clampInt(payloadSize, 1, 1_048_576, 256)
|
|
150
|
+
const plain = Buffer.alloc(size, 0x61)
|
|
151
|
+
return {
|
|
152
|
+
iterate: async () => {
|
|
153
|
+
const blob = MegaAspCrypto.encryptTransport(key, plain)
|
|
154
|
+
MegaAspCrypto.decryptTransport(key, blob)
|
|
155
|
+
},
|
|
156
|
+
teardown: noTeardown,
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
case 'db.pg.insertSelect': {
|
|
160
|
+
// postgres 1행 INSERT + 1행 SELECT 왕복. run_id 로 동시 실행/잔여를 격리·정리한다.
|
|
161
|
+
const db = this.ctx.db('db')
|
|
162
|
+
const payload = 'x'.repeat(clampInt(payloadSize, 0, 4096, 64))
|
|
163
|
+
await db.query(
|
|
164
|
+
'CREATE TABLE IF NOT EXISTS perf_bench (id BIGSERIAL PRIMARY KEY, run_id TEXT, payload TEXT, created_at TIMESTAMPTZ DEFAULT now())',
|
|
165
|
+
)
|
|
166
|
+
return {
|
|
167
|
+
iterate: async () => {
|
|
168
|
+
const ins = await db.query('INSERT INTO perf_bench (run_id, payload) VALUES ($1, $2) RETURNING id', [runId, payload])
|
|
169
|
+
const id = ins.rows[0].id
|
|
170
|
+
await db.query('SELECT payload FROM perf_bench WHERE id = $1', [id])
|
|
171
|
+
},
|
|
172
|
+
teardown: async () => { await db.query('DELETE FROM perf_bench WHERE run_id = $1', [runId]) },
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
case 'db.mongo.insertFind': {
|
|
176
|
+
// mongo insertOne + findOne + deleteOne 왕복(SQL query 미구현 어댑터 — native 도큐먼트 API).
|
|
177
|
+
const coll = this.ctx.db('mongo').native.collection('perf_bench')
|
|
178
|
+
const payload = 'x'.repeat(clampInt(payloadSize, 0, 4096, 64))
|
|
179
|
+
return {
|
|
180
|
+
iterate: async () => {
|
|
181
|
+
const { insertedId } = await coll.insertOne({ runId, payload })
|
|
182
|
+
await coll.findOne({ _id: insertedId })
|
|
183
|
+
await coll.deleteOne({ _id: insertedId })
|
|
184
|
+
},
|
|
185
|
+
teardown: async () => { await coll.deleteMany({ runId }) },
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
case 'cache.redis.setGet': {
|
|
189
|
+
// redis SET(+TTL) → GET → DEL 왕복. 키는 run 네임스페이스(perf:bench:<runId>:*)로 격리.
|
|
190
|
+
const cache = this.ctx.cache('demo')
|
|
191
|
+
const payload = 'x'.repeat(clampInt(payloadSize, 0, 4096, 64))
|
|
192
|
+
return {
|
|
193
|
+
iterate: async (i) => {
|
|
194
|
+
const key = `perf:bench:${runId}:${i}`
|
|
195
|
+
await cache.set(key, payload, { ttl: 60 })
|
|
196
|
+
await cache.get(key)
|
|
197
|
+
await cache.del(key)
|
|
198
|
+
},
|
|
199
|
+
// del 이 매 iteration 정리하므로 별도 teardown 불필요(실패분은 TTL 60s 로 자동 만료).
|
|
200
|
+
teardown: noTeardown,
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
case 'session.createRead': {
|
|
204
|
+
// 세션 스토어(redis) save → load → destroy 왕복. ctx.app.sessionStore 는 세션 옵트인 시에만 존재.
|
|
205
|
+
const store = this.ctx.app?.sessionStore
|
|
206
|
+
if (!store) {
|
|
207
|
+
throw new MegaValidationError('perf.session_unavailable', 'PerfService.run: session store is not configured for this app.', {
|
|
208
|
+
details: { scenario },
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
iterate: async (i) => {
|
|
213
|
+
const sid = `perf-bench-${runId}-${i}`
|
|
214
|
+
await store.save(sid, { userId: i, runId })
|
|
215
|
+
await store.load(sid)
|
|
216
|
+
await store.destroy(sid)
|
|
217
|
+
},
|
|
218
|
+
teardown: noTeardown,
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
default:
|
|
222
|
+
// SCENARIO_LIMITS 게이트를 통과했으면 도달 불가 — 방어적 fail-fast.
|
|
223
|
+
throw new MegaValidationError('perf.unknown_scenario', `PerfService.#prepare: no handler for "${scenario}".`, { details: { scenario } })
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* 시나리오별 유효 상한·동시성 디폴트(ADR-174). maxIter 는 자기-DoS/OOM 방어 상한(스키마 max 와 별개로
|
|
230
|
+
* 시나리오 비용에 맞춰 더 낮게), defConc 는 concurrency 미지정 시 기본값, maxConc 는 동시성 상한.
|
|
231
|
+
* @type {Record<string, { maxIter: number, defConc: number, maxConc: number }>}
|
|
232
|
+
*/
|
|
233
|
+
export const SCENARIO_LIMITS = Object.freeze({
|
|
234
|
+
'http.echo': { maxIter: 100_000, defConc: 10, maxConc: 100 },
|
|
235
|
+
'http.jsonSmall': { maxIter: 100_000, defConc: 10, maxConc: 100 },
|
|
236
|
+
'http.jsonLarge': { maxIter: 20_000, defConc: 10, maxConc: 50 },
|
|
237
|
+
'crypto.hash': { maxIter: 500, defConc: 1, maxConc: 4 },
|
|
238
|
+
'crypto.aspRoundtrip': { maxIter: 100_000, defConc: 10, maxConc: 50 },
|
|
239
|
+
'db.pg.insertSelect': { maxIter: 5_000, defConc: 10, maxConc: 50 },
|
|
240
|
+
'db.mongo.insertFind': { maxIter: 5_000, defConc: 10, maxConc: 50 },
|
|
241
|
+
'cache.redis.setGet': { maxIter: 10_000, defConc: 10, maxConc: 50 },
|
|
242
|
+
'session.createRead': { maxIter: 10_000, defConc: 10, maxConc: 50 },
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* 정수 입력을 [min,max] 로 clamp(미지정/비정수는 fallback). payloadSize 같은 선택 입력을 시나리오별
|
|
247
|
+
* 안전 범위로 정규화한다.
|
|
248
|
+
* @param {unknown} v @param {number} min @param {number} max @param {number} fallback @returns {number}
|
|
249
|
+
*/
|
|
250
|
+
function clampInt(v, min, max, fallback) {
|
|
251
|
+
if (typeof v !== 'number' || !Number.isFinite(v)) return fallback
|
|
252
|
+
return Math.max(min, Math.min(max, Math.trunc(v)))
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* 정렬된 표본에서 nearest-rank 백분위수를 뽑는다. 빈 표본은 0.
|
|
257
|
+
* @param {number[]} sorted - 오름차순 정렬된 표본. @param {number} p - 1..100.
|
|
258
|
+
* @returns {number} p 백분위 값(ms).
|
|
259
|
+
*/
|
|
260
|
+
export function percentile(sorted, p) {
|
|
261
|
+
const n = sorted.length
|
|
262
|
+
if (n === 0) return 0
|
|
263
|
+
const rank = Math.ceil((p / 100) * n)
|
|
264
|
+
const idx = Math.min(n - 1, Math.max(0, rank - 1))
|
|
265
|
+
return sorted[idx]
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* per-iteration 지연 표본 + wall-clock 으로 통계를 만든다. rps 는 전체 반복 수 / wall 초.
|
|
270
|
+
* @param {number[]} samples - 성공 iteration 의 지연(ms, 미정렬 허용). @param {number} wallMs - 전체 wall-clock(ms).
|
|
271
|
+
* @param {number} total - 시도한 전체 반복 수(성공+실패).
|
|
272
|
+
* @returns {{ durationMs: number, rps: number, avg: number, p50: number, p90: number, p95: number, p99: number, min: number, max: number, ok: number, fail: number }}
|
|
273
|
+
*/
|
|
274
|
+
export function summarize(samples, wallMs, total) {
|
|
275
|
+
const sorted = [...samples].sort((a, b) => a - b)
|
|
276
|
+
const n = sorted.length
|
|
277
|
+
const sum = sorted.reduce((acc, v) => acc + v, 0)
|
|
278
|
+
const wallSec = wallMs / 1000
|
|
279
|
+
return {
|
|
280
|
+
durationMs: round3(wallMs),
|
|
281
|
+
rps: wallSec > 0 ? round1(total / wallSec) : 0,
|
|
282
|
+
avg: n > 0 ? round3(sum / n) : 0,
|
|
283
|
+
p50: round3(percentile(sorted, 50)),
|
|
284
|
+
p90: round3(percentile(sorted, 90)),
|
|
285
|
+
p95: round3(percentile(sorted, 95)),
|
|
286
|
+
p99: round3(percentile(sorted, 99)),
|
|
287
|
+
min: n > 0 ? round3(sorted[0]) : 0,
|
|
288
|
+
max: n > 0 ? round3(sorted[n - 1]) : 0,
|
|
289
|
+
ok: n,
|
|
290
|
+
fail: total - n,
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** 소수 3자리 반올림(ms 정밀도). @param {number} v @returns {number} */
|
|
295
|
+
function round3(v) {
|
|
296
|
+
return Math.round(v * 1000) / 1000
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** 소수 1자리 반올림(rps). @param {number} v @returns {number} */
|
|
300
|
+
function round1(v) {
|
|
301
|
+
return Math.round(v * 10) / 10
|
|
302
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { MegaService } from 'mega-framework'
|
|
3
|
+
import { User } from '../models/user.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* RedisDemoService — /demo/redis 데모 로직(ADR-157). 파일명 `redis-demo-service.js` → 자동 DI 이름
|
|
7
|
+
* `redisDemo`(ctx.services.redisDemo, ADR-148). 'demo' 캐시(redis db1)를 두 가지 방식으로 시연한다:
|
|
8
|
+
*
|
|
9
|
+
* 1) **방문 카운터** — 표준 캐시 표면(get/set)에 없는 원자적 INCR 가 필요하므로 `.native`(raw ioredis)로
|
|
10
|
+
* `INCR`/`EXPIRE` 를 직접 호출한다(ADR-009 escape hatch). 누적 카운터 + 일자별 카운터(TTL 2일).
|
|
11
|
+
* 2) **쿼리 결과 캐시** — 표준 표면 `get/set`(JSON 직렬화 + TTL)으로 SQL 카운트(User.count)를 30초 캐싱한다.
|
|
12
|
+
* hit/miss 와 남은 TTL(`.native.ttl`)을 함께 돌려줘 캐시 동작을 눈으로 보게 한다.
|
|
13
|
+
*
|
|
14
|
+
* 캐시 키는 'demo' 캐시 안에서 `demo:redis:*` 네임스페이스로 두어 다른 용도와 분리한다(같은 redis 인스턴스라도
|
|
15
|
+
* db1 단독 사용이라 충돌 위험은 없지만 가독성 목적).
|
|
16
|
+
*/
|
|
17
|
+
export class RedisDemoService extends MegaService {
|
|
18
|
+
/** 누적 방문 카운터 키. */
|
|
19
|
+
static VISITS_TOTAL_KEY = 'demo:redis:visits:total'
|
|
20
|
+
/** 일자별 방문 카운터 키 prefix(뒤에 YYYY-MM-DD). */
|
|
21
|
+
static VISITS_DAY_PREFIX = 'demo:redis:visits:'
|
|
22
|
+
/** 일자별 카운터 TTL(초) — 2일. 자정 경계 직후에도 전날 값이 잠시 남게 여유를 둔다. */
|
|
23
|
+
static VISITS_DAY_TTL = 172_800
|
|
24
|
+
/** 캐시 데모 키(SQL 카운트). */
|
|
25
|
+
static USER_COUNT_KEY = 'demo:redis:user-count'
|
|
26
|
+
/** 캐시 데모 TTL(초). */
|
|
27
|
+
static USER_COUNT_TTL = 30
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 방문 1회 기록. 누적·당일 카운터를 원자적 INCR 로 올리고, 당일 키에는 TTL 을 건다.
|
|
31
|
+
* `.native`(ioredis)의 `INCR` 는 키가 없으면 0→1 로 시작한다(별도 초기화 불필요).
|
|
32
|
+
* @returns {Promise<{ total: number, today: number, date: string }>}
|
|
33
|
+
*/
|
|
34
|
+
async recordVisit() {
|
|
35
|
+
const redis = this.ctx.cache('demo').native
|
|
36
|
+
const date = RedisDemoService.#today()
|
|
37
|
+
const dayKey = RedisDemoService.VISITS_DAY_PREFIX + date
|
|
38
|
+
const total = await redis.incr(RedisDemoService.VISITS_TOTAL_KEY)
|
|
39
|
+
const today = await redis.incr(dayKey)
|
|
40
|
+
// 매 방문마다 TTL 을 재설정(rolling) — 활동이 있는 한 당일 키는 유지되고, 멈추면 2일 뒤 자동 정리된다.
|
|
41
|
+
await redis.expire(dayKey, RedisDemoService.VISITS_DAY_TTL)
|
|
42
|
+
this.ctx.log?.debug?.({ total, today, date }, 'redis-demo.visit')
|
|
43
|
+
return { total, today, date }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 캐시된 사용자 수를 돌려준다. 캐시에 있으면 hit(+남은 TTL), 없으면 SQL 로 세어 30초 TTL 로 채운다(miss).
|
|
48
|
+
* @returns {Promise<{ value: number, isHit: boolean, ttlSeconds: number }>}
|
|
49
|
+
*/
|
|
50
|
+
async getUserCountCached() {
|
|
51
|
+
const cache = this.ctx.cache('demo')
|
|
52
|
+
const cached = await cache.get(RedisDemoService.USER_COUNT_KEY)
|
|
53
|
+
if (cached !== null) {
|
|
54
|
+
// ioredis TTL: 남은 초(-1=무기한, -2=키 없음). 방금 hit 이라 양수가 정상.
|
|
55
|
+
const ttlSeconds = await cache.native.ttl(RedisDemoService.USER_COUNT_KEY)
|
|
56
|
+
this.ctx.log?.debug?.({ value: cached, ttlSeconds }, 'redis-demo.cache hit')
|
|
57
|
+
return { value: cached, isHit: true, ttlSeconds }
|
|
58
|
+
}
|
|
59
|
+
const value = await User.count()
|
|
60
|
+
await cache.set(RedisDemoService.USER_COUNT_KEY, value, { ttl: RedisDemoService.USER_COUNT_TTL })
|
|
61
|
+
this.ctx.log?.debug?.({ value }, 'redis-demo.cache miss — recomputed from SQL')
|
|
62
|
+
return { value, isHit: false, ttlSeconds: RedisDemoService.USER_COUNT_TTL }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 캐시 데모 키를 비운다(DEL — 다음 조회는 miss 로 재계산). @returns {Promise<void>} */
|
|
66
|
+
async clearUserCountCache() {
|
|
67
|
+
await this.ctx.cache('demo').del(RedisDemoService.USER_COUNT_KEY)
|
|
68
|
+
this.ctx.log?.debug?.('redis-demo.cache cleared')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 오늘 날짜(YYYY-MM-DD, 로컬). @returns {string} */
|
|
72
|
+
static #today() {
|
|
73
|
+
return new Date().toISOString().slice(0, 10)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { MegaService, MegaTracing } from 'mega-framework'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* TracingDemoService — /demo/tracing 분산 추적 데모 로직(ADR-104/126/163). 파일명 `tracing-demo-service.js` →
|
|
6
|
+
* 자동 DI 이름 `tracingDemo`(ctx.services.tracingDemo, ADR-148).
|
|
7
|
+
*
|
|
8
|
+
* 코어가 모든 HTTP 요청에 루트 span 을 깔고(mega-app onRequest), 활성 span 의 trace_id 를 로그 mixin 으로도
|
|
9
|
+
* 주입한다. 본 서비스는 현재 요청의 trace_id 를 읽고(MegaTracing.currentTraceIds), 최근 trace 목록을 redis
|
|
10
|
+
* LIST 에 남겨(LPUSH+LTRIM) Zipkin 딥링크로 잇는다. 키는 'demo' 캐시 안 `demo:trace:*` 네임스페이스다.
|
|
11
|
+
*
|
|
12
|
+
* 트레이싱은 옵트인(.env MEGA_OTEL_ENABLED)이고, exporter(otlp→collector→Zipkin) 경로로 span 이 흘러간다.
|
|
13
|
+
* OFF 면 currentTraceIds() 가 null 이라 화면이 비활성 안내를 보여준다.
|
|
14
|
+
*/
|
|
15
|
+
export class TracingDemoService extends MegaService {
|
|
16
|
+
/** 최근 trace 이력 LIST 키. */
|
|
17
|
+
static RECENT_KEY = 'demo:trace:recent'
|
|
18
|
+
/** 이력 보관 최대 건수(LTRIM 유지). */
|
|
19
|
+
static RECENT_MAX = 15
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Zipkin UI base URL — `MEGA_OTEL_ZIPKIN_API`(.../api/v2)에서 API 경로를 떼 UI 루트를 만든다(신규 env 키 없이
|
|
23
|
+
* 기존 값 재사용). 미설정이면 docker 기본값. trace 딥링크는 `${base}/zipkin/traces/<traceId>`.
|
|
24
|
+
* @returns {string}
|
|
25
|
+
*/
|
|
26
|
+
static zipkinBase() {
|
|
27
|
+
const api = process.env.MEGA_OTEL_ZIPKIN_API || 'http://localhost:9411/api/v2'
|
|
28
|
+
return api.replace(/\/api\/v2\/?$/, '')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 현재 요청의 trace_id 를 최근 이력에 1건 기록한다(있을 때만). 트레이싱 OFF(traceId 없음)면 no-op.
|
|
33
|
+
* @param {string} route - 기록할 라우트 라벨(표시용).
|
|
34
|
+
* @returns {Promise<string|null>} 기록한 trace_id(없으면 null).
|
|
35
|
+
*/
|
|
36
|
+
async record(route) {
|
|
37
|
+
const ids = MegaTracing.currentTraceIds()
|
|
38
|
+
if (!ids) return null
|
|
39
|
+
const redis = this.ctx.cache('demo').native
|
|
40
|
+
const at = new Date().toISOString()
|
|
41
|
+
await redis.lpush(TracingDemoService.RECENT_KEY, JSON.stringify({ traceId: ids.traceId, spanId: ids.spanId, route, at }))
|
|
42
|
+
await redis.ltrim(TracingDemoService.RECENT_KEY, 0, TracingDemoService.RECENT_MAX - 1)
|
|
43
|
+
this.ctx.log?.debug?.({ traceId: ids.traceId, route }, 'tracing-demo.record')
|
|
44
|
+
return ids.traceId
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 화면 렌더용 스냅샷 — 활성 여부 + 현재 trace_id + 최근 trace 목록 + Zipkin base + service name.
|
|
49
|
+
* @returns {Promise<{
|
|
50
|
+
* enabled: boolean,
|
|
51
|
+
* current: { traceId: string, spanId: string } | null,
|
|
52
|
+
* recent: Array<{ traceId: string, spanId: string, route: string, at: string }>,
|
|
53
|
+
* zipkinBase: string,
|
|
54
|
+
* serviceName: string,
|
|
55
|
+
* }>}
|
|
56
|
+
*/
|
|
57
|
+
async snapshot() {
|
|
58
|
+
const redis = this.ctx.cache('demo').native
|
|
59
|
+
const rawList = await redis.lrange(TracingDemoService.RECENT_KEY, 0, TracingDemoService.RECENT_MAX - 1)
|
|
60
|
+
const recent = rawList.map((/** @type {string} */ s) => JSON.parse(s))
|
|
61
|
+
return {
|
|
62
|
+
enabled: MegaTracing.isEnabled(),
|
|
63
|
+
current: MegaTracing.currentTraceIds(),
|
|
64
|
+
recent,
|
|
65
|
+
zipkinBase: TracingDemoService.zipkinBase(),
|
|
66
|
+
serviceName: process.env.MEGA_OTEL_SERVICE_NAME || 'mega-app',
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { MegaService } from 'mega-framework'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* UploadDemoService — /demo/upload 파일 업로드 데모 로직(ADR-133/163). 파일명 `upload-demo-service.js` →
|
|
6
|
+
* 자동 DI 이름 `uploadDemo`(ctx.services.uploadDemo, ADR-148).
|
|
7
|
+
*
|
|
8
|
+
* `req.saveUploads()`(코어 multipart)가 임시 디렉터리에 저장한 결과 메타(파일명/크기/MIME)를 redis LIST 에
|
|
9
|
+
* 남겨(LPUSH+LTRIM) 화면에 최근 업로드 목록으로 보여준다. 저장된 실제 파일의 절대경로(savedAs)는 서버 내부
|
|
10
|
+
* 정보라 화면·이력엔 담지 않는다. 키는 'demo' 캐시 안 `demo:upload:*` 네임스페이스다.
|
|
11
|
+
*/
|
|
12
|
+
export class UploadDemoService extends MegaService {
|
|
13
|
+
/** 최근 업로드 이력 LIST 키. */
|
|
14
|
+
static RECENT_KEY = 'demo:upload:recent'
|
|
15
|
+
/** 이력 보관 최대 건수(LTRIM 유지). */
|
|
16
|
+
static RECENT_MAX = 15
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 저장 결과 메타를 최근 이력에 기록한다(파일별 1건). 저장 경로(path)는 프로젝트 루트 기준 상대경로다
|
|
20
|
+
* (서버 절대경로 비노출 — 데모에선 위치 확인이 목적).
|
|
21
|
+
* @param {Array<{ filename: string, bytes: number, mimetype: string, path: string }>} saved - 저장 결과 메타.
|
|
22
|
+
* @returns {Promise<void>}
|
|
23
|
+
*/
|
|
24
|
+
async record(saved) {
|
|
25
|
+
if (!Array.isArray(saved) || saved.length === 0) return
|
|
26
|
+
const redis = this.ctx.cache('demo').native
|
|
27
|
+
const at = new Date().toISOString()
|
|
28
|
+
for (const f of saved) {
|
|
29
|
+
await redis.lpush(
|
|
30
|
+
UploadDemoService.RECENT_KEY,
|
|
31
|
+
JSON.stringify({ filename: f.filename, bytes: f.bytes, mimetype: f.mimetype, path: f.path ?? null, at }),
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
await redis.ltrim(UploadDemoService.RECENT_KEY, 0, UploadDemoService.RECENT_MAX - 1)
|
|
35
|
+
this.ctx.log?.debug?.({ files: saved.length }, 'upload-demo.record')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 화면 렌더용 스냅샷 — 최근 업로드 메타 목록.
|
|
40
|
+
* @returns {Promise<{ recent: Array<{ filename: string, bytes: number, mimetype: string, path: string|null, at: string }> }>}
|
|
41
|
+
*/
|
|
42
|
+
async snapshot() {
|
|
43
|
+
const redis = this.ctx.cache('demo').native
|
|
44
|
+
const rawList = await redis.lrange(UploadDemoService.RECENT_KEY, 0, UploadDemoService.RECENT_MAX - 1)
|
|
45
|
+
const recent = rawList.map((/** @type {string} */ s) => JSON.parse(s))
|
|
46
|
+
return { recent }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { MegaService } from 'mega-framework'
|
|
3
|
+
import { MegaNotFoundError, MegaValidationError, MegaConflictError } from 'mega-framework/errors'
|
|
4
|
+
import { User } from '../models/user.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* UserService — user 도메인 비즈니스 로직(ADR-022). 컨트롤러는 모델을 직접 만지지 않고 이 서비스를
|
|
8
|
+
* 거친다. 파일명 `user-service.js` → 자동 DI 이름 `user`(ctx.services.user, ADR-148). 모델 import 는
|
|
9
|
+
* 서비스에서만 허용(유일한 모델 접근 경로).
|
|
10
|
+
*/
|
|
11
|
+
export class UserService extends MegaService {
|
|
12
|
+
/** @returns {Promise<object[]>} */
|
|
13
|
+
async list() {
|
|
14
|
+
this.log.debug?.('user.list')
|
|
15
|
+
return User.list()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** @param {string|number} id @returns {Promise<object>} @throws {MegaNotFoundError} */
|
|
19
|
+
async get(id) {
|
|
20
|
+
const user = await User.findById(Number(id))
|
|
21
|
+
if (!user) throw new MegaNotFoundError('user.not_found', `User ${id} not found`, { details: { id } })
|
|
22
|
+
return user
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** @param {{ name?: unknown, email?: unknown }} input @returns {Promise<object>} */
|
|
26
|
+
async create(input) {
|
|
27
|
+
const name = typeof input?.name === 'string' ? input.name.trim() : ''
|
|
28
|
+
const email = typeof input?.email === 'string' ? input.email.trim() : ''
|
|
29
|
+
if (!name || !email) {
|
|
30
|
+
throw new MegaValidationError('user.invalid', 'name and email are required', { details: { name: !!name, email: !!email } })
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
return await User.create({ name, email })
|
|
34
|
+
} catch (err) {
|
|
35
|
+
// postgres unique_violation(23505) → 이메일 중복은 도메인 충돌(409)로 매핑(P4 — 명시 처리 후 throw).
|
|
36
|
+
if (/** @type {any} */ (err)?.code === '23505') {
|
|
37
|
+
throw new MegaConflictError('user.email_taken', `email '${email}' already exists`, { details: { email }, cause: err })
|
|
38
|
+
}
|
|
39
|
+
throw err
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** @param {string|number} id @param {{ name?: string, email?: string }} patch @returns {Promise<object>} @throws {MegaNotFoundError} @throws {MegaConflictError} */
|
|
44
|
+
async update(id, patch) {
|
|
45
|
+
let updated
|
|
46
|
+
try {
|
|
47
|
+
updated = await User.update(Number(id), { name: patch?.name, email: patch?.email })
|
|
48
|
+
} catch (err) {
|
|
49
|
+
// create() 와 동일 — 이메일 중복(unique_violation 23505)은 도메인 충돌(409)로 매핑(P4 — 명시 처리 후 throw).
|
|
50
|
+
if (/** @type {any} */ (err)?.code === '23505') {
|
|
51
|
+
throw new MegaConflictError('user.email_taken', `email '${patch?.email}' already exists`, { details: { email: patch?.email }, cause: err })
|
|
52
|
+
}
|
|
53
|
+
throw err
|
|
54
|
+
}
|
|
55
|
+
if (!updated) throw new MegaNotFoundError('user.not_found', `User ${id} not found`, { details: { id } })
|
|
56
|
+
return updated
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** @param {string|number} id @returns {Promise<{ deleted: true }>} @throws {MegaNotFoundError} */
|
|
60
|
+
async remove(id) {
|
|
61
|
+
const ok = await User.remove(Number(id))
|
|
62
|
+
if (!ok) throw new MegaNotFoundError('user.not_found', `User ${id} not found`, { details: { id } })
|
|
63
|
+
return { deleted: true }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<% layout('layouts/main') %>
|
|
2
|
+
|
|
3
|
+
<div class="row justify-content-center">
|
|
4
|
+
<div class="col-md-6 col-lg-5">
|
|
5
|
+
<div class="card shadow-sm">
|
|
6
|
+
<div class="card-body p-4">
|
|
7
|
+
<h1 class="h4 mb-4 text-center"><%= t('login_title', '로그인') %></h1>
|
|
8
|
+
|
|
9
|
+
<% if (typeof notice !== 'undefined' && notice) { %>
|
|
10
|
+
<div class="alert alert-success" role="alert"><%= notice %></div>
|
|
11
|
+
<% } %>
|
|
12
|
+
<% if (typeof error !== 'undefined' && error) { %>
|
|
13
|
+
<div class="alert alert-danger" role="alert"><%= error %></div>
|
|
14
|
+
<% } %>
|
|
15
|
+
|
|
16
|
+
<form method="post" action="/auth/login" novalidate>
|
|
17
|
+
<input type="hidden" name="_csrf" value="<%= csrfToken %>" />
|
|
18
|
+
|
|
19
|
+
<div class="mb-3">
|
|
20
|
+
<label for="email" class="form-label"><%= t('field_email', '이메일') %></label>
|
|
21
|
+
<input
|
|
22
|
+
type="email"
|
|
23
|
+
class="form-control"
|
|
24
|
+
id="email"
|
|
25
|
+
name="email"
|
|
26
|
+
value="<%= values && values.email ? values.email : '' %>"
|
|
27
|
+
placeholder="<%= t('field_email_ph', '예: hong@example.com') %>"
|
|
28
|
+
autocomplete="username"
|
|
29
|
+
required
|
|
30
|
+
/>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<div class="mb-4">
|
|
34
|
+
<label for="password" class="form-label"><%= t('field_password', '비밀번호') %></label>
|
|
35
|
+
<input
|
|
36
|
+
type="password"
|
|
37
|
+
class="form-control"
|
|
38
|
+
id="password"
|
|
39
|
+
name="password"
|
|
40
|
+
autocomplete="current-password"
|
|
41
|
+
required
|
|
42
|
+
/>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<div class="d-grid">
|
|
46
|
+
<button type="submit" class="btn btn-primary"><%= t('btn_login', '로그인') %></button>
|
|
47
|
+
</div>
|
|
48
|
+
</form>
|
|
49
|
+
|
|
50
|
+
<p class="text-center text-body-secondary small mt-4 mb-0">
|
|
51
|
+
<%= t('login_no_account', '계정이 없으신가요?') %>
|
|
52
|
+
<a href="/register"><%= t('register_title', '회원가입') %></a>
|
|
53
|
+
</p>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|