mega-framework 0.1.10 → 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/lock-controller.js +117 -0
- package/sample/crud/apps/main/locales/server/en.json +31 -1
- package/sample/crud/apps/main/locales/server/ko.json +31 -1
- package/sample/crud/apps/main/public/js/bus-demo.js +131 -0
- package/sample/crud/apps/main/public/js/lock-demo.js +122 -0
- package/sample/crud/apps/main/routes/bus.js +43 -0
- package/sample/crud/apps/main/routes/lock.js +35 -0
- package/sample/crud/apps/main/services/jobs-demo-service.js +21 -14
- package/sample/crud/apps/main/views/bus/index.ejs +80 -0
- package/sample/crud/apps/main/views/layouts/main.ejs +2 -0
- package/sample/crud/apps/main/views/lock/index.ejs +99 -0
- package/sample/crud/docs/guide/03-service-model-db.md +48 -0
- package/sample/crud/docs/guide/05-scheduler-job-worker.md +3 -1
- package/sample/crud/docs/guide/09-distributed-lock-and-bus.md +224 -0
- package/sample/crud/docs/guide/10-multi-app.md +74 -0
- package/sample/crud/mega.config.js +32 -0
- package/sample/crud/package.json +3 -2
- package/sample/multi/.env +16 -0
- package/sample/multi/.env.example +17 -0
- package/sample/multi/README.md +54 -0
- package/sample/multi/apps/admin/app.config.js +24 -0
- package/sample/multi/apps/admin/controllers/admin-controller.js +42 -0
- package/sample/multi/apps/admin/public/js/admin.js +31 -0
- package/sample/multi/apps/admin/routes/pages.js +11 -0
- package/sample/multi/apps/admin/views/index.ejs +33 -0
- package/sample/multi/apps/web/app.config.js +30 -0
- package/sample/multi/apps/web/controllers/web-controller.js +45 -0
- package/sample/multi/apps/web/public/js/web.js +24 -0
- package/sample/multi/apps/web/routes/pages.js +13 -0
- package/sample/multi/apps/web/views/index.ejs +51 -0
- package/sample/multi/mega.config.js +42 -0
- package/sample/multi/package.json +20 -0
- package/sample/simple/package.json +2 -2
- package/src/adapters/nats-adapter.js +39 -44
- package/src/adapters/nats-codec.js +38 -0
- package/src/cli/commands/scaffold.js +1 -0
- package/src/cli/index.js +9 -1
- package/src/core/app-registry.js +69 -0
- package/src/core/boot.js +99 -0
- package/src/core/bus/cluster-bus.js +190 -0
- package/src/core/bus/contract.js +123 -0
- package/src/core/bus/index.js +285 -0
- package/src/core/bus/memory-bus.js +103 -0
- package/src/core/bus/nats-bus.js +203 -0
- package/src/core/config-validator.js +118 -1
- package/src/core/ctx-builder.js +14 -1
- package/src/core/index.js +2 -0
- package/src/core/lock/cluster-lock.js +174 -0
- package/src/core/lock/contract.js +123 -0
- package/src/core/lock/fifo-waitlist.js +93 -0
- package/src/core/lock/index.js +292 -0
- package/src/core/lock/memory-lock.js +162 -0
- package/src/core/lock/redis-lock.js +276 -0
- package/src/core/mega-app.js +29 -0
- package/src/core/migration/generate.js +1 -1
- package/src/core/migration/journal.js +1 -1
- package/src/core/scope-registry.js +9 -0
- package/src/eslint-plugin/no-direct-model-import.js +2 -2
- package/src/index.js +2 -0
- package/src/lib/mega-job-queue.js +71 -47
- package/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,292 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* 분산 락 — LockManager(사용자 API) + 자동 폴백 factory + `ctx.lock` 부착 (ADR-226).
|
|
4
|
+
*
|
|
5
|
+
* # 두 층
|
|
6
|
+
* - **driver**(redis/cluster/memory): 저수준 상호배제만(`acquire/release/extend/forceRelease/stats`).
|
|
7
|
+
* {@link import('./contract.js').LockDriver} 계약.
|
|
8
|
+
* - **LockManager**: driver 위에 사용자 ergonomics 를 얹는다 — `with`(자동 해제)/`acquire`(throw on
|
|
9
|
+
* fail)/`tryAcquire`(null on fail)/watchdog 자동 연장/Lock 핸들 구성. watchdog 은 manager-level 이라
|
|
10
|
+
* 3 driver 가 공짜로 얻는다(FIFO·fence 만 driver-level).
|
|
11
|
+
*
|
|
12
|
+
* # 자동 폴백 (`driver: 'auto'`, 기본)
|
|
13
|
+
* redis cache 어댑터 활성 → **redis**(진짜 분산). 없고 cluster 워커 → **cluster**(단일 노드 한정, 경고).
|
|
14
|
+
* 둘 다 없음 → **memory**(분산 미보장, 경고). 명시 driver 는 그대로 쓰되, 전제(redis 어댑터/cluster 모드)가
|
|
15
|
+
* 없으면 **fail-fast**(P7 — 조용한 다운그레이드로 분산 보장이 깨지는 것을 막는다).
|
|
16
|
+
*
|
|
17
|
+
* # `ctx.lock` 통합
|
|
18
|
+
* `ctx.lock` 은 종전대로 `(alias) => MegaLockAdapter`(redlock, ADR-113) 콜러블이고, 여기에
|
|
19
|
+
* {@link attachLockApi} 가 `.with/.acquire/.tryAcquire/.forceRelease/.stats` 를 **얹는다**. 그래서
|
|
20
|
+
* `ctx.lock(alias)`(스케줄러 등 기존 사용처)와 `ctx.lock.with(...)`(신규)이 한 객체에 공존한다.
|
|
21
|
+
*
|
|
22
|
+
* @module core/lock
|
|
23
|
+
*/
|
|
24
|
+
import { MegaConflictError, MegaValidationError } from '../../errors/http-errors.js'
|
|
25
|
+
import { assertLockKey, normalizeLockOpts, DEFAULT_TTL_MS, DEFAULT_WAIT_MS, WATCHDOG_RATIO } from './contract.js'
|
|
26
|
+
import { MemoryLockDriver } from './memory-lock.js'
|
|
27
|
+
import { ClusterLockDriver } from './cluster-lock.js'
|
|
28
|
+
import { RedisLockDriver } from './redis-lock.js'
|
|
29
|
+
|
|
30
|
+
export { MemoryLockDriver, ClusterLockDriver, RedisLockDriver }
|
|
31
|
+
|
|
32
|
+
/** watchdog 최소 주기(ms) — 아주 짧은 ttl 에서도 폭주하지 않게 하한. */
|
|
33
|
+
const MIN_WATCHDOG_MS = 50
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 프로세스 전역 활성 manager — boot 가 1회 설정하고, ctx-builder 가 `ctx.lock` 에 API 를 얹을 때 읽는다.
|
|
37
|
+
* (어댑터 매니저와 동류의 부팅-설정 싱글톤. 테스트는 setLockManager(null) 로 격리.)
|
|
38
|
+
* @type {LockManager | null}
|
|
39
|
+
*/
|
|
40
|
+
let activeManager = null
|
|
41
|
+
|
|
42
|
+
/** 활성 락 manager 설정(boot lock 스테이지). null 로 해제(셧다운/테스트). @param {LockManager | null} manager @returns {void} */
|
|
43
|
+
export function setLockManager(manager) {
|
|
44
|
+
activeManager = manager
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 현재 활성 락 manager(없으면 null — `ctx.lock.with` 미부착). @returns {LockManager | null} */
|
|
48
|
+
export function getLockManager() {
|
|
49
|
+
return activeManager
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 사용자 분산 락 API. driver 한 개를 감싸 `with/acquire/tryAcquire/forceRelease/stats` 를 제공한다.
|
|
54
|
+
*/
|
|
55
|
+
export class LockManager {
|
|
56
|
+
/** @type {import('./contract.js').LockDriver} */ #driver
|
|
57
|
+
/** @type {{ ttl: number, waitMs: number, fifo: boolean }} config 유래 기본 옵션. */ #defaults
|
|
58
|
+
/** @type {any} pino 호환 로거(옵션). */ #logger
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {import('./contract.js').LockDriver} driver - 선택된 저수준 driver.
|
|
62
|
+
* @param {{ defaults: { ttl: number, waitMs: number, fifo: boolean }, logger?: any }} opts
|
|
63
|
+
*/
|
|
64
|
+
constructor(driver, { defaults, logger }) {
|
|
65
|
+
this.#driver = driver
|
|
66
|
+
this.#defaults = defaults
|
|
67
|
+
this.#logger = logger
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** @returns {string} 활성 driver 이름('redis'|'cluster'|'memory'). */
|
|
71
|
+
get driverName() {
|
|
72
|
+
return this.#driver.name
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 락 획득(대기 후 실패 시 throw) — `try/finally` 로 직접 release 하는 용도.
|
|
77
|
+
* @param {string} key - 자원 키(비어있지 않은 공백 없는 문자열).
|
|
78
|
+
* @param {import('./contract.js').LockOpts} [opts] - ttl/waitMs/fifo/fence/extendable.
|
|
79
|
+
* @returns {Promise<import('./contract.js').LockHandle>}
|
|
80
|
+
* @throws {MegaConflictError} `lock.not_acquired` - waitMs 안에 못 잡음(자원이 잠겨있음 — 409).
|
|
81
|
+
* @throws {MegaValidationError} key/opts 부적합.
|
|
82
|
+
*/
|
|
83
|
+
async acquire(key, opts) {
|
|
84
|
+
assertLockKey(key)
|
|
85
|
+
const n = normalizeLockOpts(this.#defaults, opts)
|
|
86
|
+
this.#logger?.debug?.({ key, driver: this.#driver.name, ttl: n.ttl, waitMs: n.waitMs }, 'lock.acquire enter')
|
|
87
|
+
const dl = await this.#driver.acquire(key, n)
|
|
88
|
+
if (!dl) {
|
|
89
|
+
this.#logger?.debug?.({ key, driver: this.#driver.name }, 'lock.acquire failed (contended)')
|
|
90
|
+
throw new MegaConflictError('lock.not_acquired', `could not acquire lock '${key}' within ${n.waitMs}ms (contended).`, { details: { key, waitMs: n.waitMs, driver: this.#driver.name } })
|
|
91
|
+
}
|
|
92
|
+
this.#logger?.debug?.({ key, driver: this.#driver.name, fence: dl.fence }, 'lock.acquire done')
|
|
93
|
+
return this.#wrap(key, dl, n)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 락을 즉시 1회만 시도(대기 없음) — 못 잡으면 null(throw 아님).
|
|
98
|
+
* @param {string} key @param {import('./contract.js').LockOpts} [opts]
|
|
99
|
+
* @returns {Promise<import('./contract.js').LockHandle | null>}
|
|
100
|
+
*/
|
|
101
|
+
async tryAcquire(key, opts) {
|
|
102
|
+
assertLockKey(key)
|
|
103
|
+
const n = normalizeLockOpts(this.#defaults, { ...opts, waitMs: 0 }) // waitMs 강제 0.
|
|
104
|
+
const dl = await this.#driver.acquire(key, n)
|
|
105
|
+
return dl ? this.#wrap(key, dl, n) : null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 락 보호 임계구역 실행 — 획득 → fn 실행 → **반드시 해제**(성공/예외 무관). 가장 권장되는 사용법.
|
|
110
|
+
* @template T
|
|
111
|
+
* @param {string} key
|
|
112
|
+
* @param {import('./contract.js').LockOpts | ((lock: import('./contract.js').LockHandle) => Promise<T> | T)} optsOrFn -
|
|
113
|
+
* 옵션, 또는 옵션을 생략하고 바로 fn(2-인자 형태 `with(key, fn)`).
|
|
114
|
+
* @param {(lock: import('./contract.js').LockHandle) => Promise<T> | T} [maybeFn] - 3-인자 형태의 fn.
|
|
115
|
+
* @returns {Promise<T>} fn 의 반환값.
|
|
116
|
+
*/
|
|
117
|
+
async with(key, optsOrFn, maybeFn) {
|
|
118
|
+
const fn = typeof optsOrFn === 'function' ? optsOrFn : maybeFn
|
|
119
|
+
const opts = typeof optsOrFn === 'function' ? undefined : optsOrFn
|
|
120
|
+
if (typeof fn !== 'function') {
|
|
121
|
+
throw new MegaValidationError('lock.invalid_callback', 'ctx.lock.with(key, [opts], fn) requires a function. Got: ' + typeof fn)
|
|
122
|
+
}
|
|
123
|
+
const lock = await this.acquire(key, opts)
|
|
124
|
+
try {
|
|
125
|
+
return await fn(lock)
|
|
126
|
+
} finally {
|
|
127
|
+
await lock.release()
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 관리용 강제 해제(토큰 무관) — 운영 개입(고아 락 정리)용. 주의: 정상 보유자의 임계구역을 깰 수 있다.
|
|
133
|
+
* @param {string} key @returns {Promise<void>}
|
|
134
|
+
*/
|
|
135
|
+
async forceRelease(key) {
|
|
136
|
+
assertLockKey(key)
|
|
137
|
+
this.#logger?.warn?.({ key, driver: this.#driver.name }, 'lock.forceRelease — admin override')
|
|
138
|
+
await this.#driver.forceRelease(key)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** @returns {Promise<{ driver: string, active: number, waiting: number }>} 현재 보유/대기 수. */
|
|
142
|
+
async stats() {
|
|
143
|
+
return this.#driver.stats()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 정리 — driver 자원(타이머·구독·IPC) 해제. graceful shutdown 에서 호출. @returns {Promise<void>} */
|
|
147
|
+
async close() {
|
|
148
|
+
await this.#driver.close()
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* driver 결과를 사용자 Lock 핸들로 감싼다 — release(idempotent + watchdog 정지)/extend, extendable 이면
|
|
153
|
+
* watchdog 자동 연장 타이머 부착.
|
|
154
|
+
* @param {string} key @param {import('./contract.js').DriverLock} dl @param {import('./contract.js').NormalizedLockOpts} n
|
|
155
|
+
* @returns {import('./contract.js').LockHandle}
|
|
156
|
+
*/
|
|
157
|
+
#wrap(key, dl, n) {
|
|
158
|
+
const driver = this.#driver
|
|
159
|
+
const logger = this.#logger
|
|
160
|
+
let released = false
|
|
161
|
+
/** @type {ReturnType<typeof setInterval> | null} */
|
|
162
|
+
let watchdog = null
|
|
163
|
+
const stopWatchdog = () => {
|
|
164
|
+
if (watchdog) {
|
|
165
|
+
clearInterval(watchdog)
|
|
166
|
+
watchdog = null
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (n.extendable) {
|
|
170
|
+
const periodMs = Math.max(MIN_WATCHDOG_MS, Math.floor(n.ttl * WATCHDOG_RATIO))
|
|
171
|
+
watchdog = setInterval(() => {
|
|
172
|
+
// 비동기 — interval 콜백은 await 불가라 then/catch 로(부유 프라미스 방지).
|
|
173
|
+
driver
|
|
174
|
+
.extend(key, dl.token, n.ttl)
|
|
175
|
+
.then((ok) => {
|
|
176
|
+
if (!ok) {
|
|
177
|
+
stopWatchdog()
|
|
178
|
+
logger?.warn?.({ key, driver: driver.name }, 'lock watchdog: extend returned false — lock lost, critical section no longer protected')
|
|
179
|
+
}
|
|
180
|
+
})
|
|
181
|
+
.catch((e) => {
|
|
182
|
+
stopWatchdog()
|
|
183
|
+
logger?.error?.({ key, driver: driver.name, err: e }, 'lock watchdog: extend threw — stopping auto-extension')
|
|
184
|
+
})
|
|
185
|
+
}, periodMs)
|
|
186
|
+
watchdog.unref?.()
|
|
187
|
+
}
|
|
188
|
+
/** @type {import('./contract.js').LockHandle} */
|
|
189
|
+
const handle = {
|
|
190
|
+
key,
|
|
191
|
+
token: dl.token,
|
|
192
|
+
extend: async (ttl) => driver.extend(key, dl.token, ttl ?? n.ttl),
|
|
193
|
+
release: async () => {
|
|
194
|
+
if (released) return // idempotent — 중복 release 안전.
|
|
195
|
+
released = true
|
|
196
|
+
stopWatchdog()
|
|
197
|
+
await driver.release(key, dl.token)
|
|
198
|
+
},
|
|
199
|
+
}
|
|
200
|
+
if (dl.fence !== undefined) handle.fence = dl.fence
|
|
201
|
+
return handle
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* config 의 lock 섹션에서 manager 기본 옵션을 뽑는다.
|
|
207
|
+
* @param {{ ttl?: number, waitMs?: number, fifo?: boolean }} lockConfig
|
|
208
|
+
* @returns {{ ttl: number, waitMs: number, fifo: boolean }}
|
|
209
|
+
*/
|
|
210
|
+
function resolveDefaults(lockConfig) {
|
|
211
|
+
return {
|
|
212
|
+
ttl: Number.isInteger(lockConfig.ttl) ? /** @type {number} */ (lockConfig.ttl) : DEFAULT_TTL_MS,
|
|
213
|
+
waitMs: Number.isInteger(lockConfig.waitMs) ? /** @type {number} */ (lockConfig.waitMs) : DEFAULT_WAIT_MS,
|
|
214
|
+
fifo: lockConfig.fifo === true,
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* 요청 driver(또는 'auto')와 가용 자원으로 실제 driver 를 고른다.
|
|
220
|
+
* @param {string} requested - 'auto'|'redis'|'cluster'|'memory'.
|
|
221
|
+
* @param {{ logger?: any, redisClient: any, isClusterWorker: boolean }} env
|
|
222
|
+
* @returns {import('./contract.js').LockDriver}
|
|
223
|
+
* @throws {MegaValidationError} 명시 driver 의 전제가 없거나(P7 fail-fast) 알 수 없는 driver.
|
|
224
|
+
*/
|
|
225
|
+
function selectDriver(requested, { logger, redisClient, isClusterWorker }) {
|
|
226
|
+
switch (requested) {
|
|
227
|
+
case 'memory':
|
|
228
|
+
return new MemoryLockDriver()
|
|
229
|
+
case 'cluster':
|
|
230
|
+
if (!isClusterWorker) {
|
|
231
|
+
throw new MegaValidationError('lock.driver_unavailable', "lock.driver='cluster' requires running as a cluster worker (mega start --cluster=N). Use 'auto' or 'memory' for single-process.", { details: { requested } })
|
|
232
|
+
}
|
|
233
|
+
return new ClusterLockDriver()
|
|
234
|
+
case 'redis':
|
|
235
|
+
if (!redisClient) {
|
|
236
|
+
throw new MegaValidationError('lock.driver_unavailable', "lock.driver='redis' requires a redis cache adapter — set config lock.cache to a redis cache alias.", { details: { requested } })
|
|
237
|
+
}
|
|
238
|
+
return new RedisLockDriver({ client: redisClient })
|
|
239
|
+
case 'auto': {
|
|
240
|
+
if (redisClient) {
|
|
241
|
+
logger?.debug?.({ driver: 'redis' }, 'lock: auto-selected redis driver')
|
|
242
|
+
return new RedisLockDriver({ client: redisClient })
|
|
243
|
+
}
|
|
244
|
+
if (isClusterWorker) {
|
|
245
|
+
logger?.warn?.('lock: auto-selected cluster driver (no redis cache) — mutual exclusion is single-node only, NOT across nodes. Use redis for true distributed locking.')
|
|
246
|
+
return new ClusterLockDriver()
|
|
247
|
+
}
|
|
248
|
+
logger?.warn?.('lock: auto-selected memory driver (no redis, no cluster) — NOT safe for distributed/multi-process deployments. Use redis (or cluster) in production.')
|
|
249
|
+
return new MemoryLockDriver()
|
|
250
|
+
}
|
|
251
|
+
default:
|
|
252
|
+
throw new MegaValidationError('lock.invalid_driver', `unknown lock.driver '${requested}'. Use 'auto' | 'redis' | 'cluster' | 'memory'.`, { details: { requested } })
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* 분산 락 manager 를 만든다 — driver 자동 폴백 포함. boot 가 1회 호출해 `ctx.lock` 에 부착한다.
|
|
258
|
+
*
|
|
259
|
+
* @param {{ driver?: string, ttl?: number, waitMs?: number, fifo?: boolean, cache?: string }} [lockConfig] -
|
|
260
|
+
* config 의 `lock` 섹션. `cache` 는 redis driver 가 빌릴 cache 어댑터 alias(redis/auto 일 때 의미).
|
|
261
|
+
* @param {{ logger?: any, redisClient?: any, isClusterWorker?: boolean }} [deps] -
|
|
262
|
+
* - `redisClient` boot 가 cache 어댑터에서 빌려 넘긴 ioredis 클라이언트(없으면 null).
|
|
263
|
+
* - `isClusterWorker` 현재 프로세스가 cluster 워커인지(`cluster.isWorker`).
|
|
264
|
+
* @returns {LockManager}
|
|
265
|
+
*/
|
|
266
|
+
export function createLockManager(lockConfig = {}, deps = {}) {
|
|
267
|
+
const { logger, redisClient = null, isClusterWorker = false } = deps
|
|
268
|
+
const requested = lockConfig.driver ?? 'auto'
|
|
269
|
+
const driver = selectDriver(requested, { logger, redisClient, isClusterWorker })
|
|
270
|
+
const manager = new LockManager(driver, { defaults: resolveDefaults(lockConfig), logger })
|
|
271
|
+
logger?.debug?.({ requested, selected: driver.name }, 'lock: manager created')
|
|
272
|
+
return manager
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* `ctx.lock` 콜러블에 사용자 API 메서드를 얹는다 — `ctx.lock(alias)`(기존)와 `ctx.lock.with(...)`(신규) 공존.
|
|
277
|
+
* boot 가 manager 생성 후 각 앱의 lock accessor 에 1회 호출한다.
|
|
278
|
+
*
|
|
279
|
+
* @param {Function & Record<string, any>} lockAccessor - `(alias) => MegaLockAdapter` 콜러블(ctx-builder 산출).
|
|
280
|
+
* @param {LockManager} manager
|
|
281
|
+
* @returns {Function & Record<string, any>} 같은 accessor(체이닝용).
|
|
282
|
+
*/
|
|
283
|
+
export function attachLockApi(lockAccessor, manager) {
|
|
284
|
+
// manager 메서드로 바로 위임(얇은 forwarder). 콜러블 함수 객체에 메서드를 얹어 `ctx.lock(alias)`(함수
|
|
285
|
+
// 호출)와 `ctx.lock.with(...)`(메서드)을 공존시킨다 — manager 메서드를 bind 해 this 고정.
|
|
286
|
+
lockAccessor.with = manager.with.bind(manager)
|
|
287
|
+
lockAccessor.acquire = manager.acquire.bind(manager)
|
|
288
|
+
lockAccessor.tryAcquire = manager.tryAcquire.bind(manager)
|
|
289
|
+
lockAccessor.forceRelease = manager.forceRelease.bind(manager)
|
|
290
|
+
lockAccessor.stats = manager.stats.bind(manager)
|
|
291
|
+
return lockAccessor
|
|
292
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* MemoryLockDriver — 단일 프로세스 in-memory 분산 락 driver (ADR-226).
|
|
4
|
+
*
|
|
5
|
+
* `Map` 기반 상호배제. **단일 프로세스 안에서만** 직렬화하므로 멀티 프로세스·멀티 노드에서는 상호배제를
|
|
6
|
+
* 보장하지 못한다 — 그래서 factory 가 memory 를 고를 때 부팅 경고를 낸다(`src/core/lock/index.js`).
|
|
7
|
+
* 개발/테스트, 또는 분산이 불필요한 단일 인스턴스 배포용. JS 단일 스레드라 acquire/release 사이에 끼어드는
|
|
8
|
+
* race 가 없다(이벤트루프 원자성).
|
|
9
|
+
*
|
|
10
|
+
* # FIFO — 항상 공정
|
|
11
|
+
* 대기자를 **도착 순서 큐**로 관리해, 보유자가 풀리거나 TTL 만료되면 큐의 맨 앞 대기자에게 넘긴다.
|
|
12
|
+
* 따라서 memory driver 는 `fifo` 옵션과 무관하게 늘 FIFO(공정) — 기아가 없다. `fifo:true` 의 순서
|
|
13
|
+
* 보장은 그대로 만족하고, `fifo:false` 도 더 강한 보장(공정)을 줄 뿐이다.
|
|
14
|
+
*
|
|
15
|
+
* # TTL 만료
|
|
16
|
+
* 보유 시 `setTimeout(ttl)` 로 만료 타이머를 건다(unref — 프로세스 종료를 막지 않음). 만료되면 보유를
|
|
17
|
+
* 비우고 다음 대기자에게 넘긴다. `extend` 는 타이머를 재설정한다.
|
|
18
|
+
*
|
|
19
|
+
* driver 계약은 {@link import('./contract.js').LockDriver} 를 따른다(typedef — 런타임 implements 아님).
|
|
20
|
+
* @module core/lock/memory-lock
|
|
21
|
+
*/
|
|
22
|
+
import { generateToken } from './contract.js'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {Object} HeldLock - 보유 중 락 레코드.
|
|
26
|
+
* @property {string} token @property {number} expiresAt @property {ReturnType<typeof setTimeout>} timer @property {number} [fence]
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {Object} Waiter - 대기 큐 항목.
|
|
31
|
+
* @property {number} ttl @property {boolean} fence @property {(lock: import('./contract.js').DriverLock | null) => void} resolve
|
|
32
|
+
* @property {ReturnType<typeof setTimeout> | null} timer
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
export class MemoryLockDriver {
|
|
36
|
+
/** @type {Map<string, HeldLock>} key → 보유 락. */ #held = new Map()
|
|
37
|
+
/** @type {Map<string, Waiter[]>} key → FIFO 대기 큐. */ #queues = new Map()
|
|
38
|
+
/** @type {Map<string, number>} key → fence 카운터(단조 증가, 보유 사이클 무관 유지). */ #fences = new Map()
|
|
39
|
+
|
|
40
|
+
/** @type {'memory'} */
|
|
41
|
+
get name() {
|
|
42
|
+
return 'memory'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 락 획득 — 즉시 가능하면 grant, 아니면 waitMs 안에서 FIFO 큐 대기.
|
|
47
|
+
* @param {string} key @param {import('./contract.js').NormalizedLockOpts} opts
|
|
48
|
+
* @returns {Promise<import('./contract.js').DriverLock | null>}
|
|
49
|
+
*/
|
|
50
|
+
async acquire(key, { ttl, waitMs, fence }) {
|
|
51
|
+
if (!this.#held.has(key)) return this.#grant(key, ttl, fence)
|
|
52
|
+
if (waitMs === 0) return null // tryAcquire — 즉시 실패.
|
|
53
|
+
return new Promise((resolve) => {
|
|
54
|
+
/** @type {Waiter} */
|
|
55
|
+
const waiter = { ttl, fence, resolve, timer: null }
|
|
56
|
+
waiter.timer = setTimeout(() => {
|
|
57
|
+
// waitMs 초과 — 큐에서 빼고 null(획득 실패). 경합은 정상 결과라 throw 안 함.
|
|
58
|
+
const q = this.#queues.get(key)
|
|
59
|
+
if (q) {
|
|
60
|
+
const i = q.indexOf(waiter)
|
|
61
|
+
if (i >= 0) q.splice(i, 1)
|
|
62
|
+
if (q.length === 0) this.#queues.delete(key)
|
|
63
|
+
}
|
|
64
|
+
resolve(null)
|
|
65
|
+
}, waitMs)
|
|
66
|
+
waiter.timer.unref?.()
|
|
67
|
+
const q = this.#queues.get(key) ?? []
|
|
68
|
+
q.push(waiter)
|
|
69
|
+
this.#queues.set(key, q)
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 락 부여 — 토큰 생성 + 만료 타이머 + (fence 면) 단조 카운터 증가.
|
|
75
|
+
* @param {string} key @param {number} ttl @param {boolean} fence
|
|
76
|
+
* @returns {import('./contract.js').DriverLock}
|
|
77
|
+
*/
|
|
78
|
+
#grant(key, ttl, fence) {
|
|
79
|
+
const token = generateToken()
|
|
80
|
+
/** @type {number | undefined} */
|
|
81
|
+
let fenceVal
|
|
82
|
+
if (fence) {
|
|
83
|
+
fenceVal = (this.#fences.get(key) ?? 0) + 1
|
|
84
|
+
this.#fences.set(key, fenceVal)
|
|
85
|
+
}
|
|
86
|
+
const timer = setTimeout(() => this.#expire(key), ttl)
|
|
87
|
+
timer.unref?.()
|
|
88
|
+
this.#held.set(key, { token, expiresAt: Date.now() + ttl, timer, fence: fenceVal })
|
|
89
|
+
return fenceVal === undefined ? { token } : { token, fence: fenceVal }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 만료/해제 후 다음 대기자에게 넘긴다(FIFO). @param {string} key @returns {void} */
|
|
93
|
+
#grantNextWaiter(key) {
|
|
94
|
+
const q = this.#queues.get(key)
|
|
95
|
+
if (!q || q.length === 0) return
|
|
96
|
+
const waiter = /** @type {Waiter} */ (q.shift())
|
|
97
|
+
if (q.length === 0) this.#queues.delete(key)
|
|
98
|
+
if (waiter.timer) clearTimeout(waiter.timer)
|
|
99
|
+
waiter.resolve(this.#grant(key, waiter.ttl, waiter.fence))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** TTL 만료 — 보유 비우고 다음 대기자에게. @param {string} key @returns {void} */
|
|
103
|
+
#expire(key) {
|
|
104
|
+
this.#held.delete(key)
|
|
105
|
+
this.#grantNextWaiter(key)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 락 해제 — 토큰이 현재 보유자일 때만. 해제 후 다음 대기자 승계.
|
|
110
|
+
* @param {string} key @param {string} token @returns {Promise<boolean>}
|
|
111
|
+
*/
|
|
112
|
+
async release(key, token) {
|
|
113
|
+
const h = this.#held.get(key)
|
|
114
|
+
if (!h || h.token !== token) return false // 이미 만료/타인 보유 — idempotent.
|
|
115
|
+
clearTimeout(h.timer)
|
|
116
|
+
this.#held.delete(key)
|
|
117
|
+
this.#grantNextWaiter(key)
|
|
118
|
+
return true
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* TTL 연장 — 토큰이 보유자일 때만 만료 타이머 재설정.
|
|
123
|
+
* @param {string} key @param {string} token @param {number} ttl @returns {Promise<boolean>}
|
|
124
|
+
*/
|
|
125
|
+
async extend(key, token, ttl) {
|
|
126
|
+
const h = this.#held.get(key)
|
|
127
|
+
if (!h || h.token !== token) return false // 락을 잃음(만료/탈취) — watchdog 가 false 로 인지.
|
|
128
|
+
clearTimeout(h.timer)
|
|
129
|
+
h.expiresAt = Date.now() + ttl
|
|
130
|
+
h.timer = setTimeout(() => this.#expire(key), ttl)
|
|
131
|
+
h.timer.unref?.()
|
|
132
|
+
return true
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 강제 해제(관리용, 토큰 무관) — 보유 비우고 다음 대기자 승계. @param {string} key @returns {Promise<void>} */
|
|
136
|
+
async forceRelease(key) {
|
|
137
|
+
const h = this.#held.get(key)
|
|
138
|
+
if (h) clearTimeout(h.timer)
|
|
139
|
+
this.#held.delete(key)
|
|
140
|
+
this.#grantNextWaiter(key)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** @returns {Promise<{ driver: string, active: number, waiting: number }>} 보유/대기 수. */
|
|
144
|
+
async stats() {
|
|
145
|
+
let waiting = 0
|
|
146
|
+
for (const q of this.#queues.values()) waiting += q.length
|
|
147
|
+
return { driver: 'memory', active: this.#held.size, waiting }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 정리 — 모든 만료 타이머 해제 + 대기자에 null 응답(graceful shutdown). @returns {Promise<void>} */
|
|
151
|
+
async close() {
|
|
152
|
+
for (const h of this.#held.values()) clearTimeout(h.timer)
|
|
153
|
+
for (const q of this.#queues.values()) {
|
|
154
|
+
for (const w of q) {
|
|
155
|
+
if (w.timer) clearTimeout(w.timer)
|
|
156
|
+
w.resolve(null)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
this.#held.clear()
|
|
160
|
+
this.#queues.clear()
|
|
161
|
+
}
|
|
162
|
+
}
|