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.
Files changed (87) hide show
  1. package/README.md +14 -4
  2. package/package.json +23 -21
  3. package/sample/crud/.env +10 -2
  4. package/sample/crud/.env.example +8 -0
  5. package/sample/crud/apps/main/controllers/bus-controller.js +122 -0
  6. package/sample/crud/apps/main/controllers/lock-controller.js +117 -0
  7. package/sample/crud/apps/main/locales/server/en.json +31 -1
  8. package/sample/crud/apps/main/locales/server/ko.json +31 -1
  9. package/sample/crud/apps/main/public/js/bus-demo.js +131 -0
  10. package/sample/crud/apps/main/public/js/lock-demo.js +122 -0
  11. package/sample/crud/apps/main/routes/bus.js +43 -0
  12. package/sample/crud/apps/main/routes/lock.js +35 -0
  13. package/sample/crud/apps/main/services/jobs-demo-service.js +21 -14
  14. package/sample/crud/apps/main/views/bus/index.ejs +80 -0
  15. package/sample/crud/apps/main/views/layouts/main.ejs +2 -0
  16. package/sample/crud/apps/main/views/lock/index.ejs +99 -0
  17. package/sample/crud/docs/guide/03-service-model-db.md +48 -0
  18. package/sample/crud/docs/guide/05-scheduler-job-worker.md +3 -1
  19. package/sample/crud/docs/guide/09-distributed-lock-and-bus.md +224 -0
  20. package/sample/crud/docs/guide/10-multi-app.md +74 -0
  21. package/sample/crud/mega.config.js +32 -0
  22. package/sample/crud/package.json +3 -2
  23. package/sample/multi/.env +16 -0
  24. package/sample/multi/.env.example +17 -0
  25. package/sample/multi/README.md +54 -0
  26. package/sample/multi/apps/admin/app.config.js +24 -0
  27. package/sample/multi/apps/admin/controllers/admin-controller.js +42 -0
  28. package/sample/multi/apps/admin/public/js/admin.js +31 -0
  29. package/sample/multi/apps/admin/routes/pages.js +11 -0
  30. package/sample/multi/apps/admin/views/index.ejs +33 -0
  31. package/sample/multi/apps/web/app.config.js +30 -0
  32. package/sample/multi/apps/web/controllers/web-controller.js +45 -0
  33. package/sample/multi/apps/web/public/js/web.js +24 -0
  34. package/sample/multi/apps/web/routes/pages.js +13 -0
  35. package/sample/multi/apps/web/views/index.ejs +51 -0
  36. package/sample/multi/mega.config.js +42 -0
  37. package/sample/multi/package.json +20 -0
  38. package/sample/simple/package.json +2 -2
  39. package/src/adapters/nats-adapter.js +39 -44
  40. package/src/adapters/nats-codec.js +38 -0
  41. package/src/cli/commands/scaffold.js +1 -0
  42. package/src/cli/index.js +9 -1
  43. package/src/core/app-registry.js +69 -0
  44. package/src/core/boot.js +99 -0
  45. package/src/core/bus/cluster-bus.js +190 -0
  46. package/src/core/bus/contract.js +123 -0
  47. package/src/core/bus/index.js +285 -0
  48. package/src/core/bus/memory-bus.js +103 -0
  49. package/src/core/bus/nats-bus.js +203 -0
  50. package/src/core/config-validator.js +118 -1
  51. package/src/core/ctx-builder.js +14 -1
  52. package/src/core/index.js +2 -0
  53. package/src/core/lock/cluster-lock.js +174 -0
  54. package/src/core/lock/contract.js +123 -0
  55. package/src/core/lock/fifo-waitlist.js +93 -0
  56. package/src/core/lock/index.js +292 -0
  57. package/src/core/lock/memory-lock.js +162 -0
  58. package/src/core/lock/redis-lock.js +276 -0
  59. package/src/core/mega-app.js +29 -0
  60. package/src/core/migration/generate.js +1 -1
  61. package/src/core/migration/journal.js +1 -1
  62. package/src/core/scope-registry.js +9 -0
  63. package/src/eslint-plugin/no-direct-model-import.js +2 -2
  64. package/src/index.js +2 -0
  65. package/src/lib/mega-job-queue.js +71 -47
  66. package/types/adapters/mega-adapter.d.ts +1 -1
  67. package/types/adapters/nats-adapter.d.ts +4 -4
  68. package/types/adapters/nats-codec.d.ts +13 -0
  69. package/types/adapters/redlock-adapter.d.ts +1 -1
  70. package/types/core/app-registry.d.ts +22 -0
  71. package/types/core/bus/cluster-bus.d.ts +45 -0
  72. package/types/core/bus/contract.d.ts +164 -0
  73. package/types/core/bus/index.d.ts +100 -0
  74. package/types/core/bus/memory-bus.d.ts +45 -0
  75. package/types/core/bus/nats-bus.d.ts +41 -0
  76. package/types/core/index.d.ts +1 -0
  77. package/types/core/lock/cluster-lock.d.ts +44 -0
  78. package/types/core/lock/contract.d.ts +181 -0
  79. package/types/core/lock/fifo-waitlist.d.ts +38 -0
  80. package/types/core/lock/index.d.ts +96 -0
  81. package/types/core/lock/memory-lock.d.ts +58 -0
  82. package/types/core/lock/redis-lock.d.ts +43 -0
  83. package/types/core/mega-app.d.ts +10 -0
  84. package/types/core/scope-registry.d.ts +6 -0
  85. package/types/index.d.ts +1 -1
  86. package/types/lib/mega-job-queue.d.ts +27 -4
  87. 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
+ }