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
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* @module core/config-validator
|
|
12
12
|
*/
|
|
13
13
|
import { MegaConfigError } from '../errors/config-error.js'
|
|
14
|
-
import { GLOBAL_ONLY_KEYS, APP_ONLY_KEYS, SHARED_REFERENCE_KEYS } from './scope-registry.js'
|
|
14
|
+
import { GLOBAL_ONLY_KEYS, APP_ONLY_KEYS, SHARED_REFERENCE_KEYS, DUAL_SCOPE_KEYS } from './scope-registry.js'
|
|
15
15
|
import { checkCompressionConfig } from './ws-compression.js'
|
|
16
16
|
|
|
17
17
|
/**
|
|
@@ -81,6 +81,16 @@ export function validateGlobalConfig(globalConfig) {
|
|
|
81
81
|
validateWsClusterConfig(globalConfig.wsCluster, globalConfig.services?.buses)
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
// 4c) lock 검증 (ADR-226) — 분산 락 driver 자동폴백 + 기본 옵션. 잘못된 설정은 부팅 시 fail-fast.
|
|
85
|
+
if (globalConfig.lock !== undefined) {
|
|
86
|
+
validateLockConfig(globalConfig.lock, globalConfig.services?.caches)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 4d) bus 검증 (ADR-227) — 메시지 버스 driver 자동폴백 + 기본 옵션. 잘못된 설정은 부팅 시 fail-fast.
|
|
90
|
+
if (globalConfig.bus !== undefined) {
|
|
91
|
+
validateBusConfig(globalConfig.bus, globalConfig.services?.buses)
|
|
92
|
+
}
|
|
93
|
+
|
|
84
94
|
// 5) jobs / schedules / workers 명시 등록 배열 검증 (M-2 + ADR-124 / 04-data-models §1.1).
|
|
85
95
|
// GLOBAL_ONLY_KEYS 에 키만 등록돼 있고 shape 검증이 없어, `jobs: SendEmailJob`(배열 잊음)이나
|
|
86
96
|
// 원소가 클래스 아닌 값이면 흡수 로직이 silent drop 했다. 부팅 시 fail-fast 로 드러낸다.
|
|
@@ -212,6 +222,103 @@ function validateWsClusterConfig(wsCluster, buses) {
|
|
|
212
222
|
}
|
|
213
223
|
}
|
|
214
224
|
|
|
225
|
+
/** lock.driver 허용값 (ADR-226). */
|
|
226
|
+
const LOCK_DRIVERS = Object.freeze(['auto', 'redis', 'cluster', 'memory'])
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* `lock` config 검증 (ADR-226). 분산 락 manager 자동배선의 트리거라 잘못된 설정을 부팅 시 fail-fast 한다.
|
|
230
|
+
* (1) object 여야 한다.
|
|
231
|
+
* (2) `driver` 가 있으면 'auto'|'redis'|'cluster'|'memory' 중 하나.
|
|
232
|
+
* (3) `ttl`/`waitMs` 가 있으면 음 아닌 정수(ttl 은 양의 정수, waitMs 는 0 이상).
|
|
233
|
+
* (4) `fifo` 가 있으면 boolean.
|
|
234
|
+
* (5) `cache` 가 있으면 string + `services.caches` 에 선언된 키(redis driver 가 빌릴 cache alias).
|
|
235
|
+
*
|
|
236
|
+
* @param {any} lock - globalConfig.lock.
|
|
237
|
+
* @param {Record<string, any>|undefined} caches - globalConfig.services.caches.
|
|
238
|
+
* @throws {MegaConfigError} 위 위반 시.
|
|
239
|
+
*/
|
|
240
|
+
function validateLockConfig(lock, caches) {
|
|
241
|
+
if (typeof lock !== 'object' || lock === null || Array.isArray(lock)) {
|
|
242
|
+
throw new MegaConfigError('config.lock_invalid', 'lock must be an object ({ driver?, ttl?, waitMs?, fifo?, cache? }).', {
|
|
243
|
+
details: { type: Array.isArray(lock) ? 'array' : typeof lock },
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
if (lock.driver !== undefined && !LOCK_DRIVERS.includes(lock.driver)) {
|
|
247
|
+
throw new MegaConfigError('config.lock_driver_invalid', `lock.driver must be one of [${LOCK_DRIVERS.join(', ')}], got '${lock.driver}'.`, {
|
|
248
|
+
details: { driver: lock.driver },
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
if (lock.ttl !== undefined && (!Number.isInteger(lock.ttl) || lock.ttl <= 0)) {
|
|
252
|
+
throw new MegaConfigError('config.lock_ttl_invalid', 'lock.ttl must be a positive integer (ms).', { details: { ttl: lock.ttl } })
|
|
253
|
+
}
|
|
254
|
+
if (lock.waitMs !== undefined && (!Number.isInteger(lock.waitMs) || lock.waitMs < 0)) {
|
|
255
|
+
throw new MegaConfigError('config.lock_waitms_invalid', 'lock.waitMs must be an integer >= 0 (ms, 0 = no wait).', { details: { waitMs: lock.waitMs } })
|
|
256
|
+
}
|
|
257
|
+
if (lock.fifo !== undefined && typeof lock.fifo !== 'boolean') {
|
|
258
|
+
throw new MegaConfigError('config.lock_fifo_invalid', 'lock.fifo must be a boolean.', { details: { fifo: lock.fifo } })
|
|
259
|
+
}
|
|
260
|
+
if (lock.cache !== undefined) {
|
|
261
|
+
if (typeof lock.cache !== 'string' || lock.cache.length === 0) {
|
|
262
|
+
throw new MegaConfigError('config.lock_cache_invalid', 'lock.cache must be a non-empty string (a global services.caches key).', { details: { cache: lock.cache } })
|
|
263
|
+
}
|
|
264
|
+
if (!caches?.[lock.cache]) {
|
|
265
|
+
throw new MegaConfigError(
|
|
266
|
+
'config.lock_cache_not_found',
|
|
267
|
+
`lock.cache '${lock.cache}' is not defined in services.caches. Declared: [${Object.keys(caches ?? {}).join(', ') || '(none)'}].`,
|
|
268
|
+
{ details: { cache: lock.cache, declared: Object.keys(caches ?? {}) } },
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** bus.driver 허용값 (ADR-227). */
|
|
275
|
+
const BUS_DRIVERS = Object.freeze(['auto', 'nats', 'cluster', 'memory'])
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* `bus` config 검증 (ADR-227). 메시지 버스 manager 자동배선의 트리거라 잘못된 설정을 부팅 시 fail-fast 한다.
|
|
279
|
+
* (1) object 여야 한다.
|
|
280
|
+
* (2) `driver` 가 있으면 'auto'|'nats'|'cluster'|'memory' 중 하나.
|
|
281
|
+
* (3) `prefix` 가 있으면 string.
|
|
282
|
+
* (4) `defaultPersist` 가 있으면 boolean.
|
|
283
|
+
* (5) `requestTimeoutMs` 가 있으면 양의 정수.
|
|
284
|
+
* (6) `nats` 가 있으면 string + `services.buses` 에 선언된 키(nats driver 가 빌릴 버스 alias).
|
|
285
|
+
*
|
|
286
|
+
* @param {any} bus - globalConfig.bus.
|
|
287
|
+
* @param {Record<string, any>|undefined} buses - globalConfig.services.buses.
|
|
288
|
+
* @throws {MegaConfigError} 위 위반 시.
|
|
289
|
+
*/
|
|
290
|
+
function validateBusConfig(bus, buses) {
|
|
291
|
+
if (typeof bus !== 'object' || bus === null || Array.isArray(bus)) {
|
|
292
|
+
throw new MegaConfigError('config.bus_invalid', 'bus must be an object ({ driver?, nats?, prefix?, defaultPersist?, requestTimeoutMs? }).', {
|
|
293
|
+
details: { type: Array.isArray(bus) ? 'array' : typeof bus },
|
|
294
|
+
})
|
|
295
|
+
}
|
|
296
|
+
if (bus.driver !== undefined && !BUS_DRIVERS.includes(bus.driver)) {
|
|
297
|
+
throw new MegaConfigError('config.bus_driver_invalid', `bus.driver must be one of [${BUS_DRIVERS.join(', ')}], got '${bus.driver}'.`, { details: { driver: bus.driver } })
|
|
298
|
+
}
|
|
299
|
+
if (bus.prefix !== undefined && typeof bus.prefix !== 'string') {
|
|
300
|
+
throw new MegaConfigError('config.bus_prefix_invalid', 'bus.prefix must be a string (subject namespace, e.g. "app.").', { details: { prefix: bus.prefix } })
|
|
301
|
+
}
|
|
302
|
+
if (bus.defaultPersist !== undefined && typeof bus.defaultPersist !== 'boolean') {
|
|
303
|
+
throw new MegaConfigError('config.bus_persist_invalid', 'bus.defaultPersist must be a boolean.', { details: { defaultPersist: bus.defaultPersist } })
|
|
304
|
+
}
|
|
305
|
+
if (bus.requestTimeoutMs !== undefined && (!Number.isInteger(bus.requestTimeoutMs) || bus.requestTimeoutMs <= 0)) {
|
|
306
|
+
throw new MegaConfigError('config.bus_timeout_invalid', 'bus.requestTimeoutMs must be a positive integer (ms).', { details: { requestTimeoutMs: bus.requestTimeoutMs } })
|
|
307
|
+
}
|
|
308
|
+
if (bus.nats !== undefined) {
|
|
309
|
+
if (typeof bus.nats !== 'string' || bus.nats.length === 0) {
|
|
310
|
+
throw new MegaConfigError('config.bus_nats_invalid', 'bus.nats must be a non-empty string (a global services.buses key).', { details: { nats: bus.nats } })
|
|
311
|
+
}
|
|
312
|
+
if (!buses?.[bus.nats]) {
|
|
313
|
+
throw new MegaConfigError(
|
|
314
|
+
'config.bus_nats_not_found',
|
|
315
|
+
`bus.nats '${bus.nats}' is not defined in services.buses. Declared: [${Object.keys(buses ?? {}).join(', ') || '(none)'}].`,
|
|
316
|
+
{ details: { nats: bus.nats, declared: Object.keys(buses ?? {}) } },
|
|
317
|
+
)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
215
322
|
/**
|
|
216
323
|
* `jobs`/`schedules`/`workers` 같은 "클래스(함수) 배열" config 키를 검증한다 (M-2/ADR-124). 미정의면
|
|
217
324
|
* 통과(선택 키). 정의됐으면 (1) 배열이어야 하고 (2) 모든 원소가 함수(클래스)여야 한다 — 위반 시 부팅 fail-fast.
|
|
@@ -260,6 +367,7 @@ export function validateAppConfig(appConfig, expectedFolderName, globalConfig) {
|
|
|
260
367
|
// 1) 알 수 없는 키 + 잘못된 스코프
|
|
261
368
|
for (const key of Object.keys(appConfig)) {
|
|
262
369
|
if (APP_ONLY_KEYS.includes(key)) continue
|
|
370
|
+
if (DUAL_SCOPE_KEYS.includes(key)) continue // lock/bus 는 글로벌·앱 양쪽 허용(ADR-229) — 아래서 shape 검증.
|
|
263
371
|
if (GLOBAL_ONLY_KEYS.includes(key)) {
|
|
264
372
|
throw new MegaConfigError(
|
|
265
373
|
'config.wrong_scope',
|
|
@@ -290,6 +398,15 @@ export function validateAppConfig(appConfig, expectedFolderName, globalConfig) {
|
|
|
290
398
|
)
|
|
291
399
|
}
|
|
292
400
|
|
|
401
|
+
// 2a) 앱별 lock/bus(ADR-229) — 글로벌과 같은 shape 규칙. cache/nats 는 **글로벌 services** 의 키를 참조한다
|
|
402
|
+
// (services 는 글로벌 정의라 앱 lock.cache/bus.nats 도 globalConfig.services 를 본다).
|
|
403
|
+
if (appConfig.lock !== undefined) {
|
|
404
|
+
validateLockConfig(appConfig.lock, globalConfig?.services?.caches)
|
|
405
|
+
}
|
|
406
|
+
if (appConfig.bus !== undefined) {
|
|
407
|
+
validateBusConfig(appConfig.bus, globalConfig?.services?.buses)
|
|
408
|
+
}
|
|
409
|
+
|
|
293
410
|
// 2b) bridgeHub(WS Hub 브릿지, ADR-065/176) 검증 + wsCluster 상호배타.
|
|
294
411
|
// 클러스터 전송은 앱당 **하나**다 — bridgeHub(WS Hub) 와 global wsCluster(NATS) 를 동시에 쓰면
|
|
295
412
|
// app.broadcast 가 양쪽으로 나가 이중 전파된다. 부팅 시 fail-fast 로 막는다(boot 가 둘 중 하나만 배선).
|
package/src/core/ctx-builder.js
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { MegaConfigError } from '../errors/config-error.js'
|
|
20
20
|
import * as AdapterManager from '../adapters/adapter-manager.js'
|
|
21
|
+
import { getLockManager, attachLockApi } from './lock/index.js'
|
|
22
|
+
import { getBusManager, attachBusApi } from './bus/index.js'
|
|
21
23
|
import { contextProxy as workersContext, PROXY_PROTOCOL_KEYS } from './workers-manager.js'
|
|
22
24
|
import { tracer as megaTracer } from '../lib/mega-tracing.js'
|
|
23
25
|
|
|
@@ -160,7 +162,18 @@ export function buildAdapterAccessors(aliasMaps = {}, appName = '(unknown)') {
|
|
|
160
162
|
return AdapterManager.get(domain, globalKey) // 별명은 있으나 인스턴스 미등록이면 여기서 throw
|
|
161
163
|
}
|
|
162
164
|
}
|
|
163
|
-
|
|
165
|
+
// lock accessor 는 종전대로 `(alias) => MegaLockAdapter` 콜러블이되, 분산 락 manager 가 설정돼 있으면
|
|
166
|
+
// 거기에 `.with/.acquire/.tryAcquire/...` 사용자 API 를 얹는다(ADR-226). `ctx.lock(alias)`(스케줄러 등
|
|
167
|
+
// 기존 사용처)와 `ctx.lock.with(...)`(신규)이 한 콜러블에 공존한다.
|
|
168
|
+
const lock = make('lock')
|
|
169
|
+
const manager = getLockManager()
|
|
170
|
+
if (manager) attachLockApi(/** @type {any} */ (lock), manager)
|
|
171
|
+
// bus accessor 도 동일 — `ctx.bus(alias)`(설정형 어댑터)에 `.emit/.on/.off/.request/.with` 메시지 버스
|
|
172
|
+
// 사용자 API 를 얹는다(ADR-227). 콜러블 공존(락 ADR-226 과 같은 패턴).
|
|
173
|
+
const bus = make('bus')
|
|
174
|
+
const busManager = getBusManager()
|
|
175
|
+
if (busManager) attachBusApi(/** @type {any} */ (bus), busManager)
|
|
176
|
+
return { db: make('db'), cache: make('cache'), bus, lock }
|
|
164
177
|
}
|
|
165
178
|
|
|
166
179
|
/**
|
package/src/core/index.js
CHANGED
|
@@ -9,6 +9,8 @@ export { MegaCluster } from './mega-cluster.js'
|
|
|
9
9
|
export { loadRoutes } from './routes-loader.js'
|
|
10
10
|
// 중앙 부팅 orchestrator (ADR-123)
|
|
11
11
|
export { bootApp, buildBootContext } from './boot.js'
|
|
12
|
+
// ctx 없는 영역(백그라운드·외부 콜백·테스트)의 booted MegaApp 접근점 (ADR-228)
|
|
13
|
+
export { getApp, hasApp } from './app-registry.js'
|
|
12
14
|
export { wrapEnvelope, errorEnvelope, synthesizeEnvelopeResponseSchema, ENVELOPE_MARK } from './envelope.js'
|
|
13
15
|
// HTTP 라이프사이클 Pipeline — before/transform/after 합성 정본 (ADR-185)
|
|
14
16
|
export { buildHttpPipeline, wrapPreHandler, composeTransform, composeAfter } from './pipeline.js'
|
|
@@ -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
|
+
}
|