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,261 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* MegaRedisSessionAdapter — Redis 세션 스토어 (`ioredis` 래퍼, ADR-129).
|
|
4
|
+
*
|
|
5
|
+
* `MegaSessionAdapter`(ADR-046/027) 의 두 번째 구체(file 다음). **분산/클러스터 환경 권장 스토어**
|
|
6
|
+
* (ADR-046 — "cluster 모드는 Redis 권장"). 세션 레코드를 Redis 키로 보관하고 TTL 을 Redis 가
|
|
7
|
+
* 자동 만료시켜 cleanup 이 거의 불필요하다.
|
|
8
|
+
*
|
|
9
|
+
* # 연결 처리 — `MegaRedisAdapter`(cache)와 동일 전략 재사용
|
|
10
|
+
* `resolveConnection`(url XOR discrete) + lazy ioredis import(미사용 환경 비강제) + connect+ping
|
|
11
|
+
* 검증. cache 어댑터와 같은 패턴이라 운영 일관성이 있다(같은 url/host 옵션 표면).
|
|
12
|
+
*
|
|
13
|
+
* # 키 네임스페이스
|
|
14
|
+
* `<keyPrefix><sid>` (기본 prefix `mega:sess:`). cache 키(ADR-064 `mega:cache:…`)와 분리돼
|
|
15
|
+
* 같은 Redis 인스턴스를 캐시·세션이 공유해도 충돌하지 않는다.
|
|
16
|
+
*
|
|
17
|
+
* # TTL / cleanup
|
|
18
|
+
* `save()` 는 `SET key json PX ttlMs`(원자적 저장+만료). `touch()` 는 `PEXPIRE key ttlMs`(rolling).
|
|
19
|
+
* `cleanup()` 은 **no-op(0 반환)** — Redis 가 TTL 만료를 자동 처리하므로 스캔이 불필요하다
|
|
20
|
+
* (file 스토어와 분기. ADR-046 "Redis 는 TTL 자동").
|
|
21
|
+
*
|
|
22
|
+
* # 설정
|
|
23
|
+
* ```js
|
|
24
|
+
* session: {
|
|
25
|
+
* store: { driver: 'redis', url: 'redis://:pw@host:6379/0', keyPrefix: 'mega:sess:' },
|
|
26
|
+
* ttlMs: 86400000, // save() 가 적용할 기본 TTL (미들웨어가 store 생성 시 주입)
|
|
27
|
+
* }
|
|
28
|
+
* ```
|
|
29
|
+
* 비밀번호/url 은 healthCheck/getStats/에러 details 에 절대 노출하지 않는다.
|
|
30
|
+
*
|
|
31
|
+
* @module adapters/redis-session-adapter
|
|
32
|
+
*/
|
|
33
|
+
import { MegaValidationError } from '../errors/http-errors.js'
|
|
34
|
+
import { MegaSessionAdapter } from './mega-session-adapter.js'
|
|
35
|
+
import { resolveConnection, assertPlainObject } from './adapter-options.js'
|
|
36
|
+
|
|
37
|
+
/** save() 가 store 기본 TTL 미설정 시 적용할 폴백 (24시간, ms). */
|
|
38
|
+
const DEFAULT_TTL_MS = 86_400_000
|
|
39
|
+
|
|
40
|
+
/** 기본 키 prefix — cache 키(mega:cache:)와 분리. */
|
|
41
|
+
const DEFAULT_KEY_PREFIX = 'mega:sess:'
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @typedef {object} RedisSessionConfig
|
|
45
|
+
* @property {string} [driver] - 'redis' (팩토리가 사용 — 어댑터는 무시).
|
|
46
|
+
* @property {string} [url] - `redis://[:pw@]host:port[/db]` (discrete 와 배타).
|
|
47
|
+
* @property {string} [connectionString] - `url` 의 deprecated 별칭.
|
|
48
|
+
* @property {string} [host] @property {number} [port] @property {string} [user] @property {string} [password]
|
|
49
|
+
* @property {number} [db] - 논리 DB 번호 0~15.
|
|
50
|
+
* @property {string} [keyPrefix] - 세션 키 prefix(기본 'mega:sess:').
|
|
51
|
+
* @property {number} [ttlMs] - save() 가 적용할 기본 TTL(ms). 미지정 시 24시간.
|
|
52
|
+
* @property {Record<string, any>} [options] - ioredis passthrough.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
export class MegaRedisSessionAdapter extends MegaSessionAdapter {
|
|
56
|
+
/** @type {import('ioredis').Redis | null} */
|
|
57
|
+
#client = null
|
|
58
|
+
/** @type {string | undefined} 연결 URL(시크릿 포함 — 외부 노출 금지). */
|
|
59
|
+
#url
|
|
60
|
+
/** @type {import('ioredis').RedisOptions} */
|
|
61
|
+
#clientOptions
|
|
62
|
+
/** @type {string} 세션 키 prefix. */
|
|
63
|
+
#keyPrefix
|
|
64
|
+
/** @type {number} save() 기본 TTL(ms). */
|
|
65
|
+
#ttlMs
|
|
66
|
+
/** @type {number | undefined} 논리 DB(getStats 용). */
|
|
67
|
+
#db
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {RedisSessionConfig} [config]
|
|
71
|
+
* @throws {MegaValidationError} `adapter.connection_required` / `adapter.connection_conflict` / `session.invalid_option`
|
|
72
|
+
*/
|
|
73
|
+
constructor(config = /** @type {any} */ ({})) {
|
|
74
|
+
super(config)
|
|
75
|
+
|
|
76
|
+
// db 는 connection 과 별개 축이라 url 과 충돌하지 않음(MegaRedisAdapter 와 동일).
|
|
77
|
+
const conn = resolveConnection(config, { driver: 'redis', dbConflictsWithUrl: false })
|
|
78
|
+
|
|
79
|
+
if (config.db !== undefined) {
|
|
80
|
+
if (!Number.isInteger(config.db) || config.db < 0 || config.db > 15) {
|
|
81
|
+
throw new MegaValidationError('session.invalid_option', 'redis session "db" must be an integer between 0 and 15.', {
|
|
82
|
+
details: { driver: 'redis', option: 'db', value: config.db },
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
this.#db = config.db
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
this.#keyPrefix = config.keyPrefix ?? DEFAULT_KEY_PREFIX
|
|
89
|
+
if (typeof this.#keyPrefix !== 'string') {
|
|
90
|
+
throw new MegaValidationError('session.invalid_option', 'redis session "keyPrefix" must be a string.', {
|
|
91
|
+
details: { driver: 'redis', option: 'keyPrefix', type: typeof this.#keyPrefix },
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (config.ttlMs !== undefined && (!Number.isInteger(config.ttlMs) || config.ttlMs <= 0)) {
|
|
96
|
+
throw new MegaValidationError('session.invalid_option', `redis session "ttlMs" must be a positive integer (ms). Got: ${config.ttlMs}.`, {
|
|
97
|
+
details: { driver: 'redis', option: 'ttlMs', value: config.ttlMs },
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
this.#ttlMs = config.ttlMs ?? DEFAULT_TTL_MS
|
|
101
|
+
|
|
102
|
+
assertPlainObject('options', config.options, { driver: 'redis' })
|
|
103
|
+
|
|
104
|
+
/** @type {import('ioredis').RedisOptions} */
|
|
105
|
+
const clientOptions = {}
|
|
106
|
+
if (config.options !== undefined) Object.assign(clientOptions, config.options)
|
|
107
|
+
// 라이프사이클은 베이스 상태머신이 관리 — ioredis 자동 연결 끄고 _connect 에서 명시 연결.
|
|
108
|
+
clientOptions.lazyConnect = true
|
|
109
|
+
if (conn.url === undefined) {
|
|
110
|
+
if (conn.host !== undefined) clientOptions.host = conn.host
|
|
111
|
+
if (conn.port !== undefined) clientOptions.port = conn.port
|
|
112
|
+
if (conn.user !== undefined) clientOptions.username = conn.user
|
|
113
|
+
if (conn.password !== undefined) clientOptions.password = conn.password
|
|
114
|
+
}
|
|
115
|
+
if (this.#db !== undefined) clientOptions.db = this.#db
|
|
116
|
+
this.#url = conn.url
|
|
117
|
+
this.#clientOptions = clientOptions
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* ioredis 클라이언트 생성 + connect + ping (driver 는 connect 시점 lazy import — 미사용 환경 비강제).
|
|
122
|
+
* @protected
|
|
123
|
+
* @returns {Promise<void>}
|
|
124
|
+
*/
|
|
125
|
+
async _connect() {
|
|
126
|
+
const { Redis } = await import('ioredis')
|
|
127
|
+
const client = this.#url !== undefined ? new Redis(this.#url, this.#clientOptions) : new Redis(this.#clientOptions)
|
|
128
|
+
try {
|
|
129
|
+
await client.connect()
|
|
130
|
+
const pong = await client.ping()
|
|
131
|
+
if (pong !== 'PONG') {
|
|
132
|
+
throw new MegaValidationError('adapter.health_failed', `redis session ping returned unexpected reply: ${pong}`, {
|
|
133
|
+
details: { driver: 'redis', reply: pong },
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
this.#client = client
|
|
137
|
+
} catch (err) {
|
|
138
|
+
try {
|
|
139
|
+
client.disconnect()
|
|
140
|
+
} catch (closeErr) {
|
|
141
|
+
// 검증 실패 후 정리 실패는 비치명적 — 원본 연결 에러가 진짜 원인.
|
|
142
|
+
console.warn('[MegaRedisSessionAdapter] client.disconnect() failed after connect validation error (original error wins):', closeErr)
|
|
143
|
+
}
|
|
144
|
+
throw err
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* ioredis graceful 종료(`quit`).
|
|
150
|
+
* @protected
|
|
151
|
+
* @returns {Promise<void>}
|
|
152
|
+
*/
|
|
153
|
+
async _disconnect() {
|
|
154
|
+
if (this.#client !== null) {
|
|
155
|
+
const client = this.#client
|
|
156
|
+
this.#client = null
|
|
157
|
+
await client.quit()
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* raw ioredis handle (ADR-009).
|
|
163
|
+
* @protected
|
|
164
|
+
* @returns {import('ioredis').Redis}
|
|
165
|
+
*/
|
|
166
|
+
_native() {
|
|
167
|
+
if (this.#client === null) return this._notImplemented('native')
|
|
168
|
+
return this.#client
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 헬스 체크 — 실제 ping. 비밀번호/url 은 노출하지 않는다.
|
|
173
|
+
* @returns {Promise<{ ok: boolean, driver: 'redis', state: string, db?: number, error?: string }>}
|
|
174
|
+
*/
|
|
175
|
+
async healthCheck() {
|
|
176
|
+
if (this.state !== 'connected' || this.#client === null) {
|
|
177
|
+
return { ok: false, driver: 'redis', state: this.state }
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
const pong = await this.#client.ping()
|
|
181
|
+
return { ok: pong === 'PONG', driver: 'redis', state: this.state, db: this.#db }
|
|
182
|
+
} catch (err) {
|
|
183
|
+
return { ok: false, driver: 'redis', state: this.state, db: this.#db, error: err instanceof Error ? err.message : String(err) }
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* 누적 통계 + redis 세션 특화.
|
|
189
|
+
* @returns {ReturnType<import('./mega-adapter.js').MegaAdapter['getStats']> & { driver: string, db: number | undefined, keyPrefix: string, ttlMs: number, status: string | undefined }}
|
|
190
|
+
*/
|
|
191
|
+
getStats() {
|
|
192
|
+
return { ...super.getStats(), driver: 'redis', db: this.#db, keyPrefix: this.#keyPrefix, ttlMs: this.#ttlMs, status: this.#client?.status }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* sid → Redis 키.
|
|
197
|
+
* @param {string} sid @returns {string}
|
|
198
|
+
*/
|
|
199
|
+
#keyFor(sid) {
|
|
200
|
+
return this.#keyPrefix + sid
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* 세션 조회. 없으면 `null`(Redis 가 만료 키를 자동 제거하므로 별도 만료 검사 불필요).
|
|
205
|
+
* @param {string} sid
|
|
206
|
+
* @returns {Promise<object | null>}
|
|
207
|
+
*/
|
|
208
|
+
async load(sid) {
|
|
209
|
+
return this._instrument('load', { sid }, async () => {
|
|
210
|
+
const raw = await /** @type {import('ioredis').Redis} */ (this.#client).get(this.#keyFor(sid))
|
|
211
|
+
if (raw === null) return null
|
|
212
|
+
return JSON.parse(raw)
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* 세션 저장 — `SET key json PX ttlMs`(원자적 저장+만료).
|
|
218
|
+
* @param {string} sid
|
|
219
|
+
* @param {object} record
|
|
220
|
+
* @returns {Promise<void>}
|
|
221
|
+
*/
|
|
222
|
+
async save(sid, record) {
|
|
223
|
+
return this._instrument('save', { sid }, async () => {
|
|
224
|
+
const raw = JSON.stringify(record ?? {})
|
|
225
|
+
await /** @type {import('ioredis').Redis} */ (this.#client).set(this.#keyFor(sid), raw, 'PX', this.#ttlMs)
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* 세션 삭제(idempotent — DEL 은 없는 키도 0 반환).
|
|
231
|
+
* @param {string} sid
|
|
232
|
+
* @returns {Promise<void>}
|
|
233
|
+
*/
|
|
234
|
+
async destroy(sid) {
|
|
235
|
+
return this._instrument('destroy', { sid }, async () => {
|
|
236
|
+
await /** @type {import('ioredis').Redis} */ (this.#client).del(this.#keyFor(sid))
|
|
237
|
+
})
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* rolling TTL 갱신(ADR-046) — `PEXPIRE key ttlMs`. 키가 없으면 PEXPIRE 가 0 을 반환하고 no-op
|
|
242
|
+
* (없는/만료된 세션을 되살리지 않음 — file 스토어와 동일 의미).
|
|
243
|
+
* @param {string} sid
|
|
244
|
+
* @param {number} ttlMs
|
|
245
|
+
* @returns {Promise<void>}
|
|
246
|
+
*/
|
|
247
|
+
async touch(sid, ttlMs) {
|
|
248
|
+
return this._instrument('touch', { sid, ttlMs }, async () => {
|
|
249
|
+
await /** @type {import('ioredis').Redis} */ (this.#client).pexpire(this.#keyFor(sid), ttlMs)
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* no-op(0 반환) — Redis 가 TTL 만료를 자동 처리하므로 스캔 cleanup 이 불필요하다(ADR-046).
|
|
255
|
+
* 인터페이스 정합을 위해 존재하며, `mega scheduler` 가 호출해도 안전하다.
|
|
256
|
+
* @returns {Promise<number>} 항상 0.
|
|
257
|
+
*/
|
|
258
|
+
async cleanup() {
|
|
259
|
+
return this._instrument('cleanup', {}, async () => 0)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* MegaRedlockAdapter — 분산 락 어댑터 (`redlock` 래퍼, ADR-113).
|
|
4
|
+
*
|
|
5
|
+
* **첫 lock 도메인 어댑터**(`MegaLockAdapter` 첫 구체). Redis 기반 Redlock 알고리즘으로
|
|
6
|
+
* 여러 프로세스·인스턴스 간 상호배제 락을 제공한다. cluster 미지원(단일 노드, ADR-110 정합) — 단일
|
|
7
|
+
* Redis 인스턴스 위 단일 클라이언트로 동작한다.
|
|
8
|
+
*
|
|
9
|
+
* # 표준 표면 (MegaLockAdapter 상속)
|
|
10
|
+
* - `_connect()` — 참조한 Redis 캐시 어댑터의 native client 를 빌려 `new Redlock([client], settings)`.
|
|
11
|
+
* - `_disconnect()`— redlock 의 'error' 리스너 제거 + 참조 해제. **`redlock.quit()` 은 호출하지 않는다**
|
|
12
|
+
* (아래 "공유 client lifecycle" 참조).
|
|
13
|
+
* - `_native()` — redlock 인스턴스(raw handle, ADR-009).
|
|
14
|
+
* - `healthCheck()`— 참조 Redis 어댑터의 healthCheck 에 위임(락 가용성 == Redis 가용성).
|
|
15
|
+
* - `getStats()` — 베이스 stats + redlock 특화(driver/redis 키/settings).
|
|
16
|
+
* - `acquire/release/extend/withLock` — redlock acquire/release/extend/using 위임.
|
|
17
|
+
*
|
|
18
|
+
* # 공유 Redis client 참조 (중요 — ADR-102 글로벌 공유 확장)
|
|
19
|
+
* redlock 은 자체 연결을 열지 않고, `config.redis` 가 가리키는 **이미 등록된 Redis 캐시 어댑터**
|
|
20
|
+
* (`services.caches.<key>`)의 native client 를 빌려 쓴다. `_connect` 에서 매니저로 그 어댑터를 찾아
|
|
21
|
+
* `await adapter.connect()`(멱등·race-guard 라 부팅 병렬 connect 와 순서 무관) 후 `.native` 를 잡는다.
|
|
22
|
+
*
|
|
23
|
+
* **공유 client lifecycle**: 빌려온 ioredis client 의 소유권은 Redis 어댑터에 있다. 따라서
|
|
24
|
+
* `_disconnect` 에서 `redlock.quit()`(=모든 client 에 `.quit()`)을 호출하면 **Redis 어댑터가 소유한
|
|
25
|
+
* 공유 연결을 끊어버리는** 사고가 난다. 종료는 매니저 LIFO 로 lock(나중 등록) → cache(먼저 등록)
|
|
26
|
+
* 순서라, lock 이 먼저 끊긴 뒤 Redis 어댑터가 자기 client 를 quit 한다. 그래서 본 어댑터의
|
|
27
|
+
* `_disconnect` 는 리스너 정리만 하고 client 는 건드리지 않는다(소유권 경계 명시).
|
|
28
|
+
*
|
|
29
|
+
* # redlock 'error' 이벤트 (Node EventEmitter 주의)
|
|
30
|
+
* `Redlock extends EventEmitter` 이고 백그라운드 오류(예: `using` 자동 연장 실패, 만료된 락 release
|
|
31
|
+
* 실패)를 'error' 로 emit 한다. **리스너가 없으면 Node 가 프로세스를 죽인다**(unhandled 'error').
|
|
32
|
+
* `_connect` 에서 리스너를 부착해 비치명적 오류를 표면화(console.warn)한다 — 삼키지 않되 치명으로
|
|
33
|
+
* 격상하지도 않는다(락은 TTL 로 자동 만료되는 안전망이 있음).
|
|
34
|
+
*
|
|
35
|
+
* # 설정 (services.locks.<key>) — ADR-113
|
|
36
|
+
* ```js
|
|
37
|
+
* services: {
|
|
38
|
+
* caches: { sess: { driver: 'redis', url: 'redis://localhost:6379/0' } },
|
|
39
|
+
* locks: {
|
|
40
|
+
* main: {
|
|
41
|
+
* driver: 'redlock',
|
|
42
|
+
* redis: 'sess', // services.caches 의 Redis 어댑터 key 참조(필수)
|
|
43
|
+
* options: { // redlock Settings passthrough(선택)
|
|
44
|
+
* driftFactor: 0.01,
|
|
45
|
+
* retryCount: 10,
|
|
46
|
+
* retryDelay: 200, // ms
|
|
47
|
+
* retryJitter: 200, // ms
|
|
48
|
+
* automaticExtensionThreshold: 500, // ms (using 자동 연장 임계)
|
|
49
|
+
* },
|
|
50
|
+
* },
|
|
51
|
+
* },
|
|
52
|
+
* }
|
|
53
|
+
* ```
|
|
54
|
+
*
|
|
55
|
+
* @module adapters/redlock-adapter
|
|
56
|
+
*/
|
|
57
|
+
import { MegaValidationError } from '../errors/http-errors.js'
|
|
58
|
+
import { MegaLockAdapter } from './mega-lock-adapter.js'
|
|
59
|
+
import { assertPlainObject, assertNonNegativeInt } from './adapter-options.js'
|
|
60
|
+
import * as AdapterManager from './adapter-manager.js'
|
|
61
|
+
import * as Registry from './registry.js'
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* redlock Settings 부분집합. redlock 패키지의 `Settings` interface 는 **type-only export** 라 패키지
|
|
65
|
+
* `exports` 맵(→ .d.ts 없는 esm .js)을 통과하면 TS 에 안 보인다(런타임 값 있는 class 만 보임). 그래서
|
|
66
|
+
* 같은 shape 를 로컬 typedef 로 둔다(redlock 정의와 1:1, dist/index.d.ts 확인).
|
|
67
|
+
* @typedef {object} RedlockSettings
|
|
68
|
+
* @property {number} [driftFactor] - 시계 드리프트 보정 계수 [0,1).
|
|
69
|
+
* @property {number} [retryCount] - 경합 시 재시도 횟수.
|
|
70
|
+
* @property {number} [retryDelay] - 재시도 간 지연(ms).
|
|
71
|
+
* @property {number} [retryJitter] - 재시도 지연 지터(ms).
|
|
72
|
+
* @property {number} [automaticExtensionThreshold] - `using` 자동 연장 임계(ms).
|
|
73
|
+
*/
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* redlock `using` routine 이 받는 중단 신호 (redlock 의 `RedlockAbortSignal` 과 동일 shape — 위와 같은
|
|
77
|
+
* type-only export 사유로 로컬 정의). `aborted` 가 true 면 자동 연장 실패로 배타성 상실, `error` 에 사유.
|
|
78
|
+
* @typedef {AbortSignal & { error?: Error }} RedlockAbortSignal
|
|
79
|
+
*/
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* redlock Settings 의 알려진 키 (5종). 그 외 키는 오타로 보고 거부한다(fail-fast).
|
|
83
|
+
* @type {ReadonlySet<string>}
|
|
84
|
+
*/
|
|
85
|
+
const KNOWN_SETTINGS = new Set(['driftFactor', 'retryCount', 'retryDelay', 'retryJitter', 'automaticExtensionThreshold'])
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @typedef {object} RedlockConfig
|
|
89
|
+
* @property {string} [driver] - 'redlock' (매니저가 사용 — 어댑터는 무시).
|
|
90
|
+
* @property {string} redis - 참조할 Redis 캐시 어댑터 key (services.caches.<key>). 필수.
|
|
91
|
+
* @property {Partial<RedlockSettings>} [options] - redlock Settings passthrough(driftFactor/retryCount/retryDelay/retryJitter/automaticExtensionThreshold).
|
|
92
|
+
*/
|
|
93
|
+
|
|
94
|
+
export class MegaRedlockAdapter extends MegaLockAdapter {
|
|
95
|
+
/** @type {import('redlock').default | null} 연결된 redlock 인스턴스 (connect 후에만). */
|
|
96
|
+
#redlock = null
|
|
97
|
+
/** @type {string} 참조 Redis 캐시 어댑터 key (services.caches.<key>). */
|
|
98
|
+
#redisKey
|
|
99
|
+
/** @type {Partial<RedlockSettings>} 생성자에서 검증·고정한 redlock Settings. */
|
|
100
|
+
#settings
|
|
101
|
+
/** @type {(err: Error) => void} redlock 'error' 리스너(off 로 정확히 제거하려고 1개 보관). */
|
|
102
|
+
#onRedlockError
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @param {RedlockConfig} [config] - services.locks.<key> 설정.
|
|
106
|
+
* @throws {MegaValidationError} `lock.redis_required` - `redis` 키 누락/비문자열.
|
|
107
|
+
* @throws {MegaValidationError} `adapter.invalid_option` - options 타입/알 수 없는 settings 키/음수.
|
|
108
|
+
*/
|
|
109
|
+
constructor(config = /** @type {any} */ ({})) {
|
|
110
|
+
super(config)
|
|
111
|
+
|
|
112
|
+
const redisKey = config.redis
|
|
113
|
+
if (typeof redisKey !== 'string' || redisKey.length === 0) {
|
|
114
|
+
throw new MegaValidationError(
|
|
115
|
+
'lock.redis_required',
|
|
116
|
+
'redlock: "redis" is required — it must be the key of a Redis cache adapter declared in services.caches.',
|
|
117
|
+
{ details: { driver: 'redlock' } },
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
this.#redisKey = redisKey
|
|
121
|
+
this.#settings = MegaRedlockAdapter.#normalizeSettings(config.options)
|
|
122
|
+
|
|
123
|
+
// 'error' 리스너를 한 번만 만들어 보관 — _disconnect 에서 off 로 정확히 같은 참조를 제거하기 위함.
|
|
124
|
+
this.#onRedlockError = (err) => {
|
|
125
|
+
// redlock 은 EventEmitter 라 백그라운드 오류를 'error' 로 emit 한다(리스너 없으면 프로세스 사망).
|
|
126
|
+
//
|
|
127
|
+
// `ResourceLockedError`(자원이 이미 잠김/quorum 미달)는 **정상적인 경합 결과**다 — acquire 는 이걸
|
|
128
|
+
// 호출자에게 throw 로, `using` 은 abort signal 로 이미 전달한다. 따라서 'error' 채널의 중복 emit 만
|
|
129
|
+
// 흘려보낸다(삼키는 게 아니라 호출자가 다른 경로로 이미 받음). 만약 매 경합마다
|
|
130
|
+
// warn 하면 락 경합이 잦은 프로덕션 로그가 범람한다(우회 아닌, 문서화된 의도적 필터).
|
|
131
|
+
if (err && /** @type {any} */ (err).name === 'ResourceLockedError') return
|
|
132
|
+
// 그 외(네트워크·Redis 다운·스크립트 오류 등)는 진짜 예상 밖 백그라운드 오류 — 표면화하되
|
|
133
|
+
// 치명 격상은 안 함(락은 TTL 자동 만료 안전망).
|
|
134
|
+
console.warn(`[MegaRedlockAdapter] redlock background error (non-fatal — locks auto-expire via TTL):`, err)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* redlock Settings 검증·정규화. options 없으면 빈 객체(=redlock 디폴트 사용). 알 수 없는 키는 오타로
|
|
140
|
+
* 보고 거부, 숫자 4종은 음 아닌 정수, driftFactor 는 [0,1) 유한수.
|
|
141
|
+
* @param {unknown} options @returns {Partial<RedlockSettings>}
|
|
142
|
+
*/
|
|
143
|
+
static #normalizeSettings(options) {
|
|
144
|
+
if (options === undefined) return {}
|
|
145
|
+
assertPlainObject('options', options, { driver: 'redlock' })
|
|
146
|
+
const opts = /** @type {Record<string, unknown>} */ (options)
|
|
147
|
+
for (const key of Object.keys(opts)) {
|
|
148
|
+
if (!KNOWN_SETTINGS.has(key)) {
|
|
149
|
+
throw new MegaValidationError(
|
|
150
|
+
'adapter.invalid_option',
|
|
151
|
+
`redlock: unknown option "${key}". Known: ${[...KNOWN_SETTINGS].join(', ')}.`,
|
|
152
|
+
{ details: { driver: 'redlock', option: key } },
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// retryCount/retryDelay/retryJitter/automaticExtensionThreshold 는 음 아닌 정수(ms 또는 횟수).
|
|
157
|
+
for (const key of ['retryCount', 'retryDelay', 'retryJitter', 'automaticExtensionThreshold']) {
|
|
158
|
+
assertNonNegativeInt(key, opts[key], { driver: 'redlock' })
|
|
159
|
+
}
|
|
160
|
+
// driftFactor 는 시계 드리프트 보정 계수 — [0,1) 유한수.
|
|
161
|
+
if (opts.driftFactor !== undefined) {
|
|
162
|
+
const df = opts.driftFactor
|
|
163
|
+
if (typeof df !== 'number' || !Number.isFinite(df) || df < 0 || df >= 1) {
|
|
164
|
+
throw new MegaValidationError('adapter.invalid_option', `redlock "driftFactor" must be a finite number in [0, 1) (got: ${df}).`, {
|
|
165
|
+
details: { driver: 'redlock', option: 'driftFactor', value: df },
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return /** @type {Partial<RedlockSettings>} */ ({ ...opts })
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* 참조 Redis 어댑터의 native client 를 빌려 redlock 인스턴스를 만든다.
|
|
174
|
+
*
|
|
175
|
+
* driver(`redlock`)는 **connect 시점에 lazy import** — 모듈 import(배럴 자기등록)만으로는 redlock 을
|
|
176
|
+
* 로드하지 않아 미사용 환경이 설치를 강제받지 않는다(다른 어댑터 정합).
|
|
177
|
+
*
|
|
178
|
+
* @protected
|
|
179
|
+
* @returns {Promise<void>}
|
|
180
|
+
* @throws {MegaConfigError} `adapter.not_registered` - 참조 Redis 어댑터 미등록(매니저).
|
|
181
|
+
* @throws {MegaValidationError} `lock.redis_client_required` - 참조 어댑터의 native 가 Redis client 가 아님.
|
|
182
|
+
*/
|
|
183
|
+
async _connect() {
|
|
184
|
+
// 참조한 Redis 캐시 어댑터를 매니저에서 찾는다(미등록이면 adapter.not_registered throw). 그 다음
|
|
185
|
+
// connect 를 보장한다 — connect 는 멱등·race-guard 라, connectAll 의 병렬 connect 와 섞여도
|
|
186
|
+
// 정확히 1회만 실연결되고 본 await 는 같은 promise 를 공유한다(부팅 순서 의존 제거).
|
|
187
|
+
const cacheAdapter = AdapterManager.get('cache', this.#redisKey)
|
|
188
|
+
await cacheAdapter.connect()
|
|
189
|
+
const client = cacheAdapter.native // ioredis Redis client (connect 후에만 유효)
|
|
190
|
+
|
|
191
|
+
// 빌려온 client 가 redlock 이 요구하는 Redis 인터페이스(lua eval)인지 부팅 시 검증 — 비-Redis
|
|
192
|
+
// 캐시(예: file 어댑터)를 redis 로 지정한 사고를 첫 acquire 가 아니라 부팅에서 잡는다(fail-fast).
|
|
193
|
+
if (typeof client?.eval !== 'function' || typeof client?.evalsha !== 'function') {
|
|
194
|
+
throw new MegaValidationError(
|
|
195
|
+
'lock.redis_client_required',
|
|
196
|
+
`redlock: cache adapter "${this.#redisKey}" did not provide a Redis client (its native handle has no eval/evalsha). "redis" must reference a Redis cache adapter.`,
|
|
197
|
+
{ details: { driver: 'redlock', redis: this.#redisKey } },
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const { default: Redlock } = await import('redlock')
|
|
202
|
+
const redlock = new Redlock([client], this.#settings)
|
|
203
|
+
// EventEmitter — 리스너 없으면 'error' emit 시 프로세스가 죽는다. 반드시 부착(위 클래스 docstring).
|
|
204
|
+
redlock.on('error', this.#onRedlockError)
|
|
205
|
+
this.#redlock = redlock
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* redlock 정리 — 'error' 리스너 제거 + 참조 해제. **빌려온 공유 client 는 건드리지 않는다**
|
|
210
|
+
* (소유권은 Redis 어댑터, 매니저 LIFO 로 별도 disconnect — 위 클래스 docstring "공유 client lifecycle").
|
|
211
|
+
* @protected
|
|
212
|
+
* @returns {Promise<void>}
|
|
213
|
+
*/
|
|
214
|
+
async _disconnect() {
|
|
215
|
+
if (this.#redlock !== null) {
|
|
216
|
+
const redlock = this.#redlock
|
|
217
|
+
this.#redlock = null
|
|
218
|
+
redlock.off('error', this.#onRedlockError)
|
|
219
|
+
// redlock.quit() 은 의도적으로 호출하지 않음 — 공유 ioredis client 는 Redis 어댑터가 소유·종료.
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* raw redlock handle (ADR-009). 베이스 `native` getter 가 connected 상태에서만 호출한다.
|
|
225
|
+
* @protected
|
|
226
|
+
* @returns {import('redlock').default}
|
|
227
|
+
*/
|
|
228
|
+
_native() {
|
|
229
|
+
if (this.#redlock === null) {
|
|
230
|
+
// 베이스 native getter 가 state 검증을 먼저 하므로 정상 경로에선 도달 안 함 — 방어.
|
|
231
|
+
return this._notImplemented('native')
|
|
232
|
+
}
|
|
233
|
+
return this.#redlock
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* 헬스 체크 — 락 가용성은 곧 참조 Redis 의 가용성이라 그 어댑터의 healthCheck 에 위임한다.
|
|
238
|
+
* 실패는 throw 없이 `ok:false` + 사유(베이스 계약).
|
|
239
|
+
* @returns {Promise<{ ok: boolean, driver: 'redlock', state: string, redis: string, redisOk?: boolean, error?: string }>}
|
|
240
|
+
*/
|
|
241
|
+
async healthCheck() {
|
|
242
|
+
if (this.state !== 'connected' || this.#redlock === null) {
|
|
243
|
+
return { ok: false, driver: 'redlock', state: this.state, redis: this.#redisKey }
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
const cacheAdapter = AdapterManager.get('cache', this.#redisKey)
|
|
247
|
+
const health = await cacheAdapter.healthCheck()
|
|
248
|
+
return { ok: !!health?.ok, driver: 'redlock', state: this.state, redis: this.#redisKey, redisOk: !!health?.ok }
|
|
249
|
+
} catch (err) {
|
|
250
|
+
return { ok: false, driver: 'redlock', state: this.state, redis: this.#redisKey, error: err instanceof Error ? err.message : String(err) }
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* 누적 통계 + redlock 특화(driver/redis 키/settings). settings 는 숫자 계수뿐이라 시크릿 없음.
|
|
256
|
+
* @returns {ReturnType<import('./mega-adapter.js').MegaAdapter['getStats']> & { driver: string, redis: string, settings: Partial<RedlockSettings> }}
|
|
257
|
+
*/
|
|
258
|
+
getStats() {
|
|
259
|
+
return {
|
|
260
|
+
...super.getStats(),
|
|
261
|
+
driver: 'redlock',
|
|
262
|
+
redis: this.#redisKey,
|
|
263
|
+
settings: { ...this.#settings },
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
268
|
+
// MegaLockAdapter 인터페이스 — acquire / release / extend / withLock
|
|
269
|
+
// hook + 상태검증 + stats 는 `_instrument` 가 처리(ADR-077).
|
|
270
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* 락 획득 — `redlock.acquire([key], ttl, settings)`. 경합 시 retry(opts) 소진 후 실패하면 redlock 이
|
|
274
|
+
* `ExecutionError` 를 throw(정상 도메인 결과 — 호출부가 처리).
|
|
275
|
+
*
|
|
276
|
+
* @param {string} key - 락 자원 키.
|
|
277
|
+
* @param {{ ttl?: number, retryCount?: number, retryDelay?: number, retryJitter?: number }} [opts]
|
|
278
|
+
* - `ttl`(ms, 양의 정수 필수). retry 3종은 이번 호출 한정 override(미지정 시 생성자 settings 사용).
|
|
279
|
+
* @returns {Promise<import('redlock').Lock>} redlock Lock 핸들.
|
|
280
|
+
*/
|
|
281
|
+
async acquire(key, opts = {}) {
|
|
282
|
+
// ttl·retry 검증은 _instrument 밖 — 잘못된 인자는 네트워크 I/O·hook·stats 누적 전에 fail-fast
|
|
283
|
+
// (인자 오류는 어댑터 "호출"이 아니라 프로그래밍 오류, redis-adapter set 과 동일 패턴).
|
|
284
|
+
this._assertLockTtl(opts.ttl)
|
|
285
|
+
const settings = this.#perCallSettings(opts)
|
|
286
|
+
return this._instrument('acquire', { key, ttl: opts.ttl }, async () => {
|
|
287
|
+
const redlock = /** @type {import('redlock').default} */ (this.#redlock)
|
|
288
|
+
return redlock.acquire([key], /** @type {number} */ (opts.ttl), settings)
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* 락 해제 — `lock.release()`. 이미 만료/탈취된 락 release 는 redlock 이 'error' 로 표면화하거나
|
|
294
|
+
* throw 할 수 있다(비치명 — 락은 어차피 만료됨). 본 메서드는 정상 해제 경로를 instrument 한다.
|
|
295
|
+
*
|
|
296
|
+
* @param {import('redlock').Lock} lock - {@link acquire}/{@link extend} 가 돌려준 핸들.
|
|
297
|
+
* @returns {Promise<void>}
|
|
298
|
+
*/
|
|
299
|
+
async release(lock) {
|
|
300
|
+
this.#assertLock(lock, 'release')
|
|
301
|
+
return this._instrument('release', { resources: lock.resources }, async () => {
|
|
302
|
+
await lock.release()
|
|
303
|
+
})
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* 락 TTL 연장 — `redlock.extend(lock, ttl)`. **새 Lock 핸들을 반환**한다(redlock 이 연장 시 새 핸들을
|
|
308
|
+
* 만듦) — 이후 release/extend 는 반환된 핸들을 써야 한다(기존 핸들 무효, ADR-113).
|
|
309
|
+
*
|
|
310
|
+
* @param {import('redlock').Lock} lock @param {number} ttl - 추가 보유 시간(ms, 양의 정수).
|
|
311
|
+
* @returns {Promise<import('redlock').Lock>} 연장된 새 Lock 핸들.
|
|
312
|
+
*/
|
|
313
|
+
async extend(lock, ttl) {
|
|
314
|
+
this.#assertLock(lock, 'extend')
|
|
315
|
+
this._assertLockTtl(ttl)
|
|
316
|
+
return this._instrument('extend', { resources: lock.resources, ttl }, async () => {
|
|
317
|
+
const redlock = /** @type {import('redlock').default} */ (this.#redlock)
|
|
318
|
+
return redlock.extend(lock, ttl)
|
|
319
|
+
})
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* convenience — `redlock.using([key], ttl, settings, routine)`. 락 획득 + **자동 연장**
|
|
324
|
+
* (automaticExtensionThreshold 임박 시 자동 extend) + 종료 시 자동 release(routine 이 throw 해도 보장).
|
|
325
|
+
*
|
|
326
|
+
* routine 은 `(signal)` 을 받는다 — `signal.aborted` 가 true 면 자동 연장이 실패해 더는 배타성을
|
|
327
|
+
* 보장할 수 없는 상태다(`signal.error` 에 사유). routine 은 이때 즉시 중단하고 예외 상황으로 처리해야
|
|
328
|
+
* 한다(베이스 `withLock` 의 `(lock)` 시그니처와 다름 — auto-extension 모델 차이, ADR-113).
|
|
329
|
+
*
|
|
330
|
+
* @template T
|
|
331
|
+
* @param {string} key
|
|
332
|
+
* @param {{ ttl?: number, retryCount?: number, retryDelay?: number, retryJitter?: number }} opts - `ttl`(ms) 필수.
|
|
333
|
+
* @param {(signal: RedlockAbortSignal) => Promise<T>} fn - 임계구역 routine.
|
|
334
|
+
* @returns {Promise<T>} routine 반환값.
|
|
335
|
+
*/
|
|
336
|
+
async withLock(key, opts, fn) {
|
|
337
|
+
this._assertLockTtl(opts?.ttl)
|
|
338
|
+
if (typeof fn !== 'function') {
|
|
339
|
+
throw new MegaValidationError('lock.invalid_routine', `redlock withLock("${key}"): routine (3rd arg) must be a function.`, {
|
|
340
|
+
details: { driver: 'redlock', key, type: typeof fn },
|
|
341
|
+
})
|
|
342
|
+
}
|
|
343
|
+
const settings = this.#perCallSettings(opts)
|
|
344
|
+
return this._instrument('withLock', { key, ttl: opts.ttl }, async () => {
|
|
345
|
+
const redlock = /** @type {import('redlock').default} */ (this.#redlock)
|
|
346
|
+
return redlock.using([key], /** @type {number} */ (opts.ttl), settings, fn)
|
|
347
|
+
})
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* acquire/withLock 의 이번-호출 한정 retry override 를 redlock Settings 부분객체로 만든다. 비어 있으면
|
|
352
|
+
* redlock 이 생성자 settings(또는 라이브러리 디폴트)를 쓴다. 음수 등은 acquire 진입 전 검증.
|
|
353
|
+
* @param {{ retryCount?: number, retryDelay?: number, retryJitter?: number }} opts
|
|
354
|
+
* @returns {Partial<RedlockSettings>}
|
|
355
|
+
*/
|
|
356
|
+
#perCallSettings(opts) {
|
|
357
|
+
/** @type {Record<string, number>} */
|
|
358
|
+
const s = {}
|
|
359
|
+
for (const key of ['retryCount', 'retryDelay', 'retryJitter']) {
|
|
360
|
+
const v = /** @type {any} */ (opts)[key]
|
|
361
|
+
if (v !== undefined) {
|
|
362
|
+
assertNonNegativeInt(key, v, { driver: 'redlock' })
|
|
363
|
+
s[key] = v
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return s
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* lock 핸들 형태 가드 — redlock Lock 은 `resources` 배열을 갖는다. 잘못된 인자를 네트워크 호출 전에
|
|
371
|
+
* 명확한 에러로 거부(추측으로 driver 에 넘기지 않음).
|
|
372
|
+
* @param {any} lock @param {string} method @returns {void}
|
|
373
|
+
*/
|
|
374
|
+
#assertLock(lock, method) {
|
|
375
|
+
if (lock === null || typeof lock !== 'object' || !Array.isArray(lock.resources)) {
|
|
376
|
+
throw new MegaValidationError('lock.invalid_lock', `redlock ${method}(): expected a Lock handle from acquire()/extend().`, {
|
|
377
|
+
details: { driver: 'redlock', method },
|
|
378
|
+
})
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// 빌트인 driver 자기등록 (ADR-044) — 배럴(`adapters/index.js`)이 본 모듈을 import 하면 트리거된다.
|
|
384
|
+
// redlock 은 _connect() 의 lazy import 까지 로드되지 않으므로 등록은 안전(미사용 환경 비강제).
|
|
385
|
+
Registry.register('redlock', MegaRedlockAdapter)
|