mega-framework 0.1.9 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -4
- package/package.json +23 -21
- package/sample/crud/.env +10 -2
- package/sample/crud/.env.example +8 -0
- package/sample/crud/apps/main/controllers/bus-controller.js +122 -0
- package/sample/crud/apps/main/controllers/jobs-controller.js +22 -2
- package/sample/crud/apps/main/controllers/lock-controller.js +117 -0
- package/sample/crud/apps/main/jobs/email-job.js +37 -2
- package/sample/crud/apps/main/locales/server/en.json +36 -1
- package/sample/crud/apps/main/locales/server/ko.json +36 -1
- package/sample/crud/apps/main/public/js/bus-demo.js +131 -0
- package/sample/crud/apps/main/public/js/lock-demo.js +122 -0
- package/sample/crud/apps/main/routes/bus.js +43 -0
- package/sample/crud/apps/main/routes/lock.js +35 -0
- package/sample/crud/apps/main/services/jobs-demo-service.js +22 -15
- package/sample/crud/apps/main/views/bus/index.ejs +80 -0
- package/sample/crud/apps/main/views/jobs/index.ejs +9 -3
- package/sample/crud/apps/main/views/layouts/main.ejs +2 -0
- package/sample/crud/apps/main/views/lock/index.ejs +99 -0
- package/sample/crud/docs/guide/03-service-model-db.md +48 -0
- package/sample/crud/docs/guide/05-scheduler-job-worker.md +29 -2
- package/sample/crud/docs/guide/09-distributed-lock-and-bus.md +224 -0
- package/sample/crud/docs/guide/10-multi-app.md +74 -0
- package/sample/crud/mega.config.js +32 -0
- package/sample/crud/package.json +3 -2
- package/sample/multi/.env +16 -0
- package/sample/multi/.env.example +17 -0
- package/sample/multi/README.md +54 -0
- package/sample/multi/apps/admin/app.config.js +24 -0
- package/sample/multi/apps/admin/controllers/admin-controller.js +42 -0
- package/sample/multi/apps/admin/public/js/admin.js +31 -0
- package/sample/multi/apps/admin/routes/pages.js +11 -0
- package/sample/multi/apps/admin/views/index.ejs +33 -0
- package/sample/multi/apps/web/app.config.js +30 -0
- package/sample/multi/apps/web/controllers/web-controller.js +45 -0
- package/sample/multi/apps/web/public/js/web.js +24 -0
- package/sample/multi/apps/web/routes/pages.js +13 -0
- package/sample/multi/apps/web/views/index.ejs +51 -0
- package/sample/multi/mega.config.js +42 -0
- package/sample/multi/package.json +20 -0
- package/sample/simple/package.json +2 -2
- package/src/adapters/nats-adapter.js +39 -44
- package/src/adapters/nats-codec.js +38 -0
- package/src/cli/commands/scaffold.js +1 -0
- package/src/cli/index.js +50 -1
- package/src/core/app-registry.js +69 -0
- package/src/core/boot.js +99 -0
- package/src/core/bus/cluster-bus.js +190 -0
- package/src/core/bus/contract.js +123 -0
- package/src/core/bus/index.js +285 -0
- package/src/core/bus/memory-bus.js +103 -0
- package/src/core/bus/nats-bus.js +203 -0
- package/src/core/config-validator.js +118 -1
- package/src/core/ctx-builder.js +14 -1
- package/src/core/index.js +2 -0
- package/src/core/lock/cluster-lock.js +174 -0
- package/src/core/lock/contract.js +123 -0
- package/src/core/lock/fifo-waitlist.js +93 -0
- package/src/core/lock/index.js +292 -0
- package/src/core/lock/memory-lock.js +162 -0
- package/src/core/lock/redis-lock.js +276 -0
- package/src/core/mega-app.js +29 -0
- package/src/core/migration/generate.js +1 -1
- package/src/core/migration/journal.js +1 -1
- package/src/core/scope-registry.js +9 -0
- package/src/eslint-plugin/no-direct-model-import.js +2 -2
- package/src/index.js +2 -0
- package/src/lib/mega-job-queue.js +71 -47
- package/types/adapters/mega-adapter.d.ts +1 -1
- package/types/adapters/nats-adapter.d.ts +4 -4
- package/types/adapters/nats-codec.d.ts +13 -0
- package/types/adapters/redlock-adapter.d.ts +1 -1
- package/types/core/app-registry.d.ts +22 -0
- package/types/core/bus/cluster-bus.d.ts +45 -0
- package/types/core/bus/contract.d.ts +164 -0
- package/types/core/bus/index.d.ts +100 -0
- package/types/core/bus/memory-bus.d.ts +45 -0
- package/types/core/bus/nats-bus.d.ts +41 -0
- package/types/core/index.d.ts +1 -0
- package/types/core/lock/cluster-lock.d.ts +44 -0
- package/types/core/lock/contract.d.ts +181 -0
- package/types/core/lock/fifo-waitlist.d.ts +38 -0
- package/types/core/lock/index.d.ts +96 -0
- package/types/core/lock/memory-lock.d.ts +58 -0
- package/types/core/lock/redis-lock.d.ts +43 -0
- package/types/core/mega-app.d.ts +10 -0
- package/types/core/scope-registry.d.ts +6 -0
- package/types/index.d.ts +1 -1
- package/types/lib/mega-job-queue.d.ts +27 -4
- package/sample/simple/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* ClusterLockDriver — Node `cluster` IPC 기반 분산 락 driver (ADR-226).
|
|
4
|
+
*
|
|
5
|
+
* redis 가 없지만 **클러스터**(`mega start --cluster=N`)로 도는 환경용. 같은 노드의 워커들이 **master
|
|
6
|
+
* 프로세스에 위임**해 직렬화한다 — 워커가 IPC 로 master 에 acquire/release 를 요청하고, master 는
|
|
7
|
+
* {@link import('./memory-lock.js').MemoryLockDriver} 한 개로 실제 상호배제를 관리한 뒤 응답한다.
|
|
8
|
+
*
|
|
9
|
+
* # ⚠️ 멀티 노드 미보장
|
|
10
|
+
* cluster IPC 는 **한 노드(master + 그 워커들)** 안에서만 닿는다. 노드가 여러 대면 master 도 여러 개라
|
|
11
|
+
* 서로의 락을 모른다 — **노드 간 상호배제는 보장하지 못한다**. 진짜 분산 락은 redis driver 를 쓴다.
|
|
12
|
+
* factory 가 cluster driver 를 고를 때 이 한계를 부팅 경고로 알린다.
|
|
13
|
+
*
|
|
14
|
+
* # 구성
|
|
15
|
+
* - **master 측**: {@link installClusterLockMaster} 를 master 프로세스에서 1회 호출 — 워커 메시지를
|
|
16
|
+
* 받아 MemoryLockDriver 로 처리하고 회신한다(`mega start` 클러스터 분기, metrics aggregator 옆).
|
|
17
|
+
* - **worker 측**: {@link ClusterLockDriver} 가 `process.send` 로 요청하고 correlation id 로 응답을 매칭한다.
|
|
18
|
+
* acquire 의 waitMs 대기·FIFO 큐·TTL 만료는 전부 master 의 MemoryLockDriver 가 처리하므로 worker 는
|
|
19
|
+
* 얇은 RPC 클라이언트다.
|
|
20
|
+
*
|
|
21
|
+
* # 워커 crash
|
|
22
|
+
* 워커가 락 보유 중 죽으면 master 의 메모리 락은 **TTL 만료**로 풀린다(안전망). 즉시 정리는 안 함 —
|
|
23
|
+
* TTL 을 임계구역 예상보다 넉넉히 잡는 것이 운영 권고(memory/redis 와 동일).
|
|
24
|
+
*
|
|
25
|
+
* @module core/lock/cluster-lock
|
|
26
|
+
*/
|
|
27
|
+
import nodeCluster from 'node:cluster'
|
|
28
|
+
import { MemoryLockDriver } from './memory-lock.js'
|
|
29
|
+
|
|
30
|
+
/** IPC 메시지 마커 — 다른 cluster 메시지(metrics 등)와 섞이지 않게 한다. */
|
|
31
|
+
const LOCK_MSG = 'mega:lock'
|
|
32
|
+
|
|
33
|
+
/** master 에 responder 가 이미 설치됐는지(멱등 가드). @type {boolean} */
|
|
34
|
+
let masterInstalled = false
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* master 프로세스에 락 responder 를 설치한다(멱등). 워커들의 IPC 요청을 한 MemoryLockDriver 로 직렬화한다.
|
|
38
|
+
* `mega start` 클러스터 분기에서 master 가 1회 호출한다.
|
|
39
|
+
*
|
|
40
|
+
* @param {import('node:cluster').Cluster} [cluster] - 테스트 주입용(기본 node:cluster).
|
|
41
|
+
* @returns {MemoryLockDriver | null} 설치된 master driver(테스트/관측용). 이미 설치됐으면 null.
|
|
42
|
+
*/
|
|
43
|
+
export function installClusterLockMaster(cluster = nodeCluster) {
|
|
44
|
+
if (masterInstalled) return null
|
|
45
|
+
masterInstalled = true
|
|
46
|
+
const driver = new MemoryLockDriver()
|
|
47
|
+
cluster.on('message', (worker, msg) => {
|
|
48
|
+
void handleMasterMessage(driver, worker, msg)
|
|
49
|
+
})
|
|
50
|
+
return driver
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* master 측 메시지 1건 처리 — op 를 MemoryLockDriver 로 위임하고 결과를 워커에 회신. 절대 throw 안 함
|
|
55
|
+
* (worker 에러 응답으로 표면화). acquire 의 waitMs 대기는 driver 가 hold 하므로 응답이 그만큼 늦게 간다.
|
|
56
|
+
* @param {MemoryLockDriver} driver @param {import('node:cluster').Worker} worker @param {any} msg
|
|
57
|
+
* @returns {Promise<void>}
|
|
58
|
+
*/
|
|
59
|
+
async function handleMasterMessage(driver, worker, msg) {
|
|
60
|
+
if (!msg || msg.t !== LOCK_MSG || msg.id === undefined) return
|
|
61
|
+
const { id, op, key, token, ttl, opts } = msg
|
|
62
|
+
try {
|
|
63
|
+
/** @type {any} */
|
|
64
|
+
let result
|
|
65
|
+
if (op === 'acquire') result = await driver.acquire(key, opts)
|
|
66
|
+
else if (op === 'release') result = await driver.release(key, token)
|
|
67
|
+
else if (op === 'extend') result = await driver.extend(key, token, ttl)
|
|
68
|
+
else if (op === 'forceRelease') {
|
|
69
|
+
await driver.forceRelease(key)
|
|
70
|
+
result = null
|
|
71
|
+
} else if (op === 'stats') result = await driver.stats()
|
|
72
|
+
else {
|
|
73
|
+
worker.send?.({ t: LOCK_MSG, id, error: `unknown lock op: ${op}` })
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
worker.send?.({ t: LOCK_MSG, id, result })
|
|
77
|
+
} catch (e) {
|
|
78
|
+
// master 측 처리 실패 — 워커에 에러로 전달(silent 금지). 워커의 #request 가 reject 한다.
|
|
79
|
+
worker.send?.({ t: LOCK_MSG, id, error: e instanceof Error ? e.message : String(e) })
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 테스트 격리용 — master responder 설치 플래그 초기화. @returns {void} */
|
|
84
|
+
export function _resetClusterLockMaster() {
|
|
85
|
+
masterInstalled = false
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 워커 측 cluster 락 driver — master 에 IPC 위임하는 얇은 RPC 클라이언트.
|
|
90
|
+
* driver 계약은 {@link import('./contract.js').LockDriver} 를 따른다(typedef — 런타임 implements 아님).
|
|
91
|
+
*/
|
|
92
|
+
export class ClusterLockDriver {
|
|
93
|
+
/** @type {Map<string, { resolve: (v: any) => void, reject: (e: any) => void }>} id → 대기 응답. */ #pending = new Map()
|
|
94
|
+
/** @type {number} 요청 시퀀스(pid 와 합쳐 전역 유일 id). */ #seq = 0
|
|
95
|
+
/** @type {NodeJS.Process} master 와의 IPC 채널(cluster 워커면 process). */ #proc
|
|
96
|
+
/** @type {(msg: any) => void} 'message' 리스너 참조(close 에서 제거). */ #onMessage
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @param {{ proc?: NodeJS.Process }} [opts] - `proc` 테스트 주입용(기본 전역 process). cluster 워커면
|
|
100
|
+
* `process.send` 로 master 에 닿는다.
|
|
101
|
+
* @throws {Error} proc.send 가 없으면(=cluster 워커가 아님) — factory 가 cluster 워커일 때만 생성한다.
|
|
102
|
+
*/
|
|
103
|
+
constructor({ proc = process } = {}) {
|
|
104
|
+
if (typeof proc.send !== 'function') {
|
|
105
|
+
throw new Error('ClusterLockDriver requires an IPC channel (process.send) — only valid in a cluster worker.')
|
|
106
|
+
}
|
|
107
|
+
this.#proc = proc
|
|
108
|
+
this.#onMessage = (msg) => {
|
|
109
|
+
if (!msg || msg.t !== LOCK_MSG || msg.id === undefined) return
|
|
110
|
+
const p = this.#pending.get(msg.id)
|
|
111
|
+
if (!p) return
|
|
112
|
+
this.#pending.delete(msg.id)
|
|
113
|
+
if (msg.error !== undefined) p.reject(new Error(`cluster lock master error: ${msg.error}`))
|
|
114
|
+
else p.resolve(msg.result)
|
|
115
|
+
}
|
|
116
|
+
proc.on('message', this.#onMessage)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** @type {'cluster'} */
|
|
120
|
+
get name() {
|
|
121
|
+
return 'cluster'
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* master 에 op 를 보내고 응답을 correlation id 로 기다린다.
|
|
126
|
+
* @param {string} op @param {Record<string, any>} payload @returns {Promise<any>}
|
|
127
|
+
*/
|
|
128
|
+
#request(op, payload) {
|
|
129
|
+
return new Promise((resolve, reject) => {
|
|
130
|
+
const id = `${process.pid}:${++this.#seq}`
|
|
131
|
+
this.#pending.set(id, { resolve, reject })
|
|
132
|
+
try {
|
|
133
|
+
this.#proc.send?.({ t: LOCK_MSG, id, op, ...payload })
|
|
134
|
+
} catch (e) {
|
|
135
|
+
// IPC 채널 단절 등 — 즉시 reject(대기 누수 방지).
|
|
136
|
+
this.#pending.delete(id)
|
|
137
|
+
reject(e instanceof Error ? e : new Error(String(e)))
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** @param {string} key @param {import('./contract.js').NormalizedLockOpts} opts @returns {Promise<import('./contract.js').DriverLock | null>} */
|
|
143
|
+
async acquire(key, opts) {
|
|
144
|
+
return this.#request('acquire', { key, opts })
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** @param {string} key @param {string} token @returns {Promise<boolean>} */
|
|
148
|
+
async release(key, token) {
|
|
149
|
+
return this.#request('release', { key, token })
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** @param {string} key @param {string} token @param {number} ttl @returns {Promise<boolean>} */
|
|
153
|
+
async extend(key, token, ttl) {
|
|
154
|
+
return this.#request('extend', { key, token, ttl })
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** @param {string} key @returns {Promise<void>} */
|
|
158
|
+
async forceRelease(key) {
|
|
159
|
+
await this.#request('forceRelease', { key })
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** @returns {Promise<{ driver: string, active: number, waiting: number }>} */
|
|
163
|
+
async stats() {
|
|
164
|
+
const s = await this.#request('stats', {})
|
|
165
|
+
return { driver: 'cluster', active: s.active, waiting: s.waiting }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** 정리 — IPC 리스너 제거 + 대기 요청에 null 응답(누수 방지). @returns {Promise<void>} */
|
|
169
|
+
async close() {
|
|
170
|
+
this.#proc.off?.('message', this.#onMessage)
|
|
171
|
+
for (const p of this.#pending.values()) p.resolve(null)
|
|
172
|
+
this.#pending.clear()
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* 분산 락 공통 contract — driver 인터페이스 + Lock 핸들 shape + opts 검증 + 토큰 생성 (ADR-226).
|
|
4
|
+
*
|
|
5
|
+
* `ctx.lock.with/.acquire/.tryAcquire` 사용자 API 는 {@link import('./index.js').LockManager} 가 제공하고,
|
|
6
|
+
* 실제 상호배제는 **driver**(redis/cluster/memory)가 담당한다. driver 는 저장/조정만 하고(아래 LockDriver),
|
|
7
|
+
* watchdog 자동 연장·Lock 핸들 구성·tryAcquire(=waitMs 0) 같은 사용자 ergonomics 는 manager 가 driver
|
|
8
|
+
* 위에 얹는다. 그래서 FIFO·fence 처럼 저장소-특화 동작만 driver 가 구현하고, 나머지는 한 곳(manager)에서
|
|
9
|
+
* 모든 driver 에 공통 적용된다.
|
|
10
|
+
*
|
|
11
|
+
* # 기존 `ctx.lock(alias)` 와의 관계 (ADR-113 ↔ ADR-226)
|
|
12
|
+
* `ctx.lock` 은 **callable object** 다 — `ctx.lock(alias)` 는 종전대로 설정된 `MegaLockAdapter`(redlock)를
|
|
13
|
+
* 반환하고(스케줄러·기존 사용처 무변경), 거기에 `.with/.acquire/...` 메서드가 붙어 사용자 분산 락 API 가
|
|
14
|
+
* 된다. 둘은 별개 하위시스템이다(전자=설정형 alias 어댑터, 후자=자동폴백 driver).
|
|
15
|
+
*
|
|
16
|
+
* @module core/lock/contract
|
|
17
|
+
*/
|
|
18
|
+
import { randomBytes } from 'node:crypto'
|
|
19
|
+
import { MegaValidationError } from '../../errors/http-errors.js'
|
|
20
|
+
|
|
21
|
+
/** 락 TTL 디폴트(ms) — 임계구역 보호 시간. config `lock.ttl` 로 조정. */
|
|
22
|
+
export const DEFAULT_TTL_MS = 5000
|
|
23
|
+
/** 획득 대기 디폴트(ms) — 경합 시 이만큼 기다린다(0=즉시 실패). config `lock.waitMs` 로 조정. */
|
|
24
|
+
export const DEFAULT_WAIT_MS = 1000
|
|
25
|
+
/** watchdog 자동 연장 주기 = ttl × 이 비율. 만료 전에 넉넉히 갱신(절반). */
|
|
26
|
+
export const WATCHDOG_RATIO = 0.5
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {Object} LockOpts - acquire/with/tryAcquire 옵션(정규화 전).
|
|
30
|
+
* @property {number} [ttl] - 락 보유 시간(ms, 양의 정수). 미지정 시 manager 디폴트.
|
|
31
|
+
* @property {number} [waitMs] - 획득 대기 상한(ms, 0 이상). 0 = 즉시(tryAcquire 와 동일). 미지정 시 디폴트.
|
|
32
|
+
* @property {boolean} [fifo] - true 면 대기자를 도착 순서대로 깨운다(기아 방지). driver 가 지원해야 함.
|
|
33
|
+
* @property {boolean} [fence] - true 면 단조 증가 fence 토큰을 함께 반환(외부 시스템의 stale-writer 차단용).
|
|
34
|
+
* @property {boolean} [extendable] - true 면 manager 가 watchdog 으로 ttl 절반마다 자동 연장(긴 임계구역).
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @typedef {Object} NormalizedLockOpts - 검증·기본값 적용된 옵션.
|
|
39
|
+
* @property {number} ttl @property {number} waitMs @property {boolean} fifo @property {boolean} fence @property {boolean} extendable
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @typedef {Object} DriverLock - driver.acquire 성공 결과(저수준 — manager 가 LockHandle 로 감싼다).
|
|
44
|
+
* @property {string} token - 이 보유를 식별하는 랜덤 토큰(release/extend 가 소유 검증에 쓴다).
|
|
45
|
+
* @property {number} [fence] - fence 토큰(단조 증가). `fence:true` 일 때만.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @typedef {Object} LockHandle - 사용자에게 반환되는 락 핸들(manager 구성).
|
|
50
|
+
* @property {string} key - 잠근 자원 키.
|
|
51
|
+
* @property {string} token - 보유 토큰.
|
|
52
|
+
* @property {number} [fence] - fence 토큰(`fence:true` 일 때만).
|
|
53
|
+
* @property {() => Promise<void>} release - 락 해제(idempotent — 이미 풀렸어도 안전). watchdog 도 멈춘다.
|
|
54
|
+
* @property {(ttl?: number) => Promise<boolean>} extend - TTL 연장. 아직 보유 중이면 true, 잃었으면 false.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @typedef {Object} LockDriver - driver 가 구현하는 저수준 인터페이스(manager 가 호출).
|
|
59
|
+
* @property {string} name - 'redis' | 'cluster' | 'memory'.
|
|
60
|
+
* @property {(key: string, opts: NormalizedLockOpts) => Promise<DriverLock | null>} acquire -
|
|
61
|
+
* waitMs 안에 획득하면 `{token, fence?}`, 못 잡으면 `null`(throw 아님 — 경합은 정상 결과).
|
|
62
|
+
* @property {(key: string, token: string) => Promise<boolean>} release - 토큰이 현재 보유자면 풀고 true,
|
|
63
|
+
* 아니면(이미 만료/타인 보유) false. 절대 throw 안 함.
|
|
64
|
+
* @property {(key: string, token: string, ttl: number) => Promise<boolean>} extend - 토큰이 보유자면 ttl
|
|
65
|
+
* 갱신 후 true, 아니면 false.
|
|
66
|
+
* @property {(key: string) => Promise<void>} forceRelease - 관리용 강제 해제(토큰 무관). 대기자 깨움 포함.
|
|
67
|
+
* @property {() => Promise<{ driver: string, active: number, waiting: number }>} stats - 현재 보유/대기 수.
|
|
68
|
+
* @property {() => Promise<void>} close - 타이머·구독·연결참조 정리(graceful shutdown).
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 락 보유 토큰 생성 — 충돌 사실상 불가한 랜덤 24-hex. release/extend 가 소유권 검증에 쓴다.
|
|
73
|
+
* @returns {string}
|
|
74
|
+
*/
|
|
75
|
+
export function generateToken() {
|
|
76
|
+
return randomBytes(12).toString('hex')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* acquire/with/tryAcquire 옵션을 검증·정규화한다(fail-fast — 잘못된 입력은 락 시도 전에 throw).
|
|
81
|
+
* @param {{ ttl: number, waitMs: number, fifo: boolean }} defaults - manager 디폴트(config 유래).
|
|
82
|
+
* @param {LockOpts} [opts] - 사용자 옵션.
|
|
83
|
+
* @returns {NormalizedLockOpts}
|
|
84
|
+
* @throws {MegaValidationError} `lock.invalid_opts` - ttl/waitMs 가 정수 범위를 벗어나거나 boolean 아닌 플래그.
|
|
85
|
+
*/
|
|
86
|
+
export function normalizeLockOpts(defaults, opts = {}) {
|
|
87
|
+
const ttl = opts.ttl ?? defaults.ttl
|
|
88
|
+
if (!Number.isInteger(ttl) || ttl <= 0) {
|
|
89
|
+
throw new MegaValidationError('lock.invalid_opts', `lock ttl must be a positive integer (ms). Got: ${ttl}.`, { details: { ttl } })
|
|
90
|
+
}
|
|
91
|
+
const waitMs = opts.waitMs ?? defaults.waitMs
|
|
92
|
+
if (!Number.isInteger(waitMs) || waitMs < 0) {
|
|
93
|
+
throw new MegaValidationError('lock.invalid_opts', `lock waitMs must be an integer >= 0 (ms, 0 = no wait). Got: ${waitMs}.`, { details: { waitMs } })
|
|
94
|
+
}
|
|
95
|
+
for (const flag of /** @type {const} */ (['fifo', 'fence', 'extendable'])) {
|
|
96
|
+
if (opts[flag] !== undefined && typeof opts[flag] !== 'boolean') {
|
|
97
|
+
throw new MegaValidationError('lock.invalid_opts', `lock '${flag}' must be a boolean. Got: ${typeof opts[flag]}.`, { details: { flag, value: opts[flag] } })
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
ttl,
|
|
102
|
+
waitMs,
|
|
103
|
+
fifo: opts.fifo ?? defaults.fifo,
|
|
104
|
+
fence: opts.fence === true,
|
|
105
|
+
extendable: opts.extendable === true,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 락 key 검증 — 비어있지 않은 문자열. 저장소 키/redis subject 로 쓰이므로 제어문자·공백을 막는다.
|
|
111
|
+
* @param {string} key
|
|
112
|
+
* @returns {void}
|
|
113
|
+
* @throws {MegaValidationError} `lock.invalid_key`
|
|
114
|
+
*/
|
|
115
|
+
export function assertLockKey(key) {
|
|
116
|
+
if (typeof key !== 'string' || key.length === 0) {
|
|
117
|
+
throw new MegaValidationError('lock.invalid_key', `lock key must be a non-empty string. Got: ${typeof key}.`, { details: { key } })
|
|
118
|
+
}
|
|
119
|
+
if (/\s/.test(key)) {
|
|
120
|
+
// 공백·제어문자만 금지(redis 키/pub-sub 채널 안전). ':' '.' '-' 같은 일반 키 구분자는 허용.
|
|
121
|
+
throw new MegaValidationError('lock.invalid_key', `lock key must not contain whitespace or control characters. Got: ${JSON.stringify(key)}.`, { details: { key } })
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* FifoWaitlist — redis Sorted Set 기반 FIFO 대기열 (ADR-226, redis-lock driver 전용).
|
|
4
|
+
*
|
|
5
|
+
* `fifo:true` 락은 경합 시 **도착 순서**대로 깨워야 한다(기아 방지). redis 에는 프로세스 경계를 넘는
|
|
6
|
+
* 공유 큐가 필요하므로 **Sorted Set** 으로 순서를 매기고, 대기자는 자신이 **큐의 맨 앞(head)** 이 됐을
|
|
7
|
+
* 때만 락 `SET NX` 를 시도한다. 해제 알림은 redis Pub/Sub 가 깨운다(redis-lock.js).
|
|
8
|
+
*
|
|
9
|
+
* # 점수 = 전역 INCR 시퀀스 (ms 타임스탬프 아님)
|
|
10
|
+
* 타임스탬프(ms)를 점수로 쓰면 같은 ms 에 합류한 대기자들의 순서가 비결정적(점수 동률 → member 사전순)이
|
|
11
|
+
* 되어 FIFO 가 깨진다. 그래서 점수는 redis `INCR <seqKey>` 의 **전역 단조 시퀀스**를 쓴다 — 모든
|
|
12
|
+
* 프로세스가 한 카운터를 공유하므로 합류 순서가 곧 전역 총순서다(진짜 분산 FIFO).
|
|
13
|
+
*
|
|
14
|
+
* # stale 항목 reaping (member 에 deadline 인코딩)
|
|
15
|
+
* 대기자 프로세스가 `leave` 전에 죽으면 그 항목이 head 에 남아 뒤를 막는다. 시퀀스 점수로는 "오래됨"을
|
|
16
|
+
* 판단할 수 없으므로, member 를 `"<token>:<deadlineMs>"` 로 인코딩한다. head 의 deadline 이 현재보다
|
|
17
|
+
* 과거면(대기 시한 초과 — 정상 대기자라면 스스로 떠났을 것) 버려진 것으로 보고 `ZREM` 후 다음 head 를 본다.
|
|
18
|
+
*
|
|
19
|
+
* @module core/lock/fifo-waitlist
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @typedef {Object} RedisLike - 이 헬퍼가 쓰는 ioredis 메서드 부분집합(테스트 fake 도 충족).
|
|
24
|
+
* @property {(key: string) => Promise<number>} incr
|
|
25
|
+
* @property {(key: string, score: number, member: string) => Promise<any>} zadd
|
|
26
|
+
* @property {(key: string, member: string) => Promise<any>} zrem
|
|
27
|
+
* @property {(key: string, start: number, stop: number) => Promise<string[]>} zrange
|
|
28
|
+
* @property {(key: string) => Promise<number>} zcard
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** reaping 무한루프 방지 상한(연속 stale head 청소 횟수). */
|
|
32
|
+
const MAX_REAP_PER_CHECK = 16
|
|
33
|
+
|
|
34
|
+
export class FifoWaitlist {
|
|
35
|
+
/** @type {RedisLike} */ #client
|
|
36
|
+
/** @type {(key: string) => string} key → waitlist zset 키. */ #zkey
|
|
37
|
+
/** @type {string} 전역 시퀀스 카운터 키(INCR). */ #seqKey
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {RedisLike} client - ioredis 호환 클라이언트(명령용, redis-lock 과 공유).
|
|
41
|
+
* @param {{ zkey: (key: string) => string, seqKey: string }} opts -
|
|
42
|
+
* `zkey` 락 key → zset 키 매퍼, `seqKey` 전역 시퀀스 카운터 키(네임스페이스 일원화).
|
|
43
|
+
*/
|
|
44
|
+
constructor(client, { zkey, seqKey }) {
|
|
45
|
+
this.#client = client
|
|
46
|
+
this.#zkey = zkey
|
|
47
|
+
this.#seqKey = seqKey
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 대기열에 합류 — 전역 INCR 시퀀스를 점수로 부여해 도착 순서를 확정한다.
|
|
52
|
+
* @param {string} key @param {string} token @param {number} deadlineMs - 이 대기자의 시한(now+waitMs); reaping 판정용.
|
|
53
|
+
* @returns {Promise<string>} 등록된 member 문자열(leave 에 그대로 넘겨 정확히 제거).
|
|
54
|
+
*/
|
|
55
|
+
async join(key, token, deadlineMs) {
|
|
56
|
+
const seq = Number(await this.#client.incr(this.#seqKey))
|
|
57
|
+
const member = `${token}:${deadlineMs}`
|
|
58
|
+
await this.#client.zadd(this.#zkey(key), seq, member)
|
|
59
|
+
return member
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 내가 큐의 head 인지 — 시한 지난(버려진) head 를 청소한 뒤 맨 앞 token 과 비교.
|
|
64
|
+
* @param {string} key @param {string} token @param {number} nowMs - 현재 시각(Date.now()).
|
|
65
|
+
* @returns {Promise<boolean>}
|
|
66
|
+
*/
|
|
67
|
+
async isHead(key, token, nowMs) {
|
|
68
|
+
for (let i = 0; i < MAX_REAP_PER_CHECK; i++) {
|
|
69
|
+
const head = await this.#client.zrange(this.#zkey(key), 0, 0)
|
|
70
|
+
if (head.length === 0) return false
|
|
71
|
+
const member = head[0]
|
|
72
|
+
const sep = member.lastIndexOf(':')
|
|
73
|
+
const deadline = Number(member.slice(sep + 1))
|
|
74
|
+
if (deadline && deadline < nowMs) {
|
|
75
|
+
// 시한 초과 head = 떠나지 못하고 죽은 대기자 → 청소하고 다음 head 확인.
|
|
76
|
+
await this.#client.zrem(this.#zkey(key), member)
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
return member.slice(0, sep) === token
|
|
80
|
+
}
|
|
81
|
+
return false
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 대기열에서 이탈(획득 성공/타임아웃/취소 시). @param {string} key @param {string} member - join 이 돌려준 값. @returns {Promise<void>} */
|
|
85
|
+
async leave(key, member) {
|
|
86
|
+
await this.#client.zrem(this.#zkey(key), member)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 현재 대기자 수. @param {string} key @returns {Promise<number>} */
|
|
90
|
+
async size(key) {
|
|
91
|
+
return this.#client.zcard(this.#zkey(key))
|
|
92
|
+
}
|
|
93
|
+
}
|