mega-framework 0.1.3 → 0.1.5
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/package.json +1 -1
- package/sample/crud/yarn.lock +1 -1
- package/src/adapters/file-adapter.js +5 -0
- package/src/adapters/mega-cache-adapter.js +6 -2
- package/src/adapters/nats-adapter.js +6 -1
- package/src/cli/commands/console-cmd.js +4 -2
- package/src/cli/commands/new.js +115 -67
- package/src/cli/commands/scaffold.js +6 -12
- package/src/cli/index.js +1 -1
- package/src/core/mega-app.js +11 -3
- package/src/core/mega-cluster.js +27 -17
- package/src/core/ws-upgrade.js +19 -1
- package/src/lib/asp/nonce-cache.js +12 -0
- package/src/lib/logger/telegram-core.js +33 -5
- package/src/lib/logger/telegram-transport.js +22 -2
- package/src/lib/mega-job-queue.js +22 -1
- package/src/lib/mega-logger.js +41 -2
- package/src/lib/mega-shutdown.js +46 -2
- package/sample/crud/test/apps/main/auth-flow.integration.test.js +0 -177
- package/sample/crud/test/apps/main/auth-service.test.js +0 -93
- package/sample/crud/test/apps/main/chat-channel.test.js +0 -149
- package/sample/crud/test/apps/main/cron-demo-service.test.js +0 -93
- package/sample/crud/test/apps/main/demo-flow.integration.test.js +0 -386
- package/sample/crud/test/apps/main/email-job.test.js +0 -76
- package/sample/crud/test/apps/main/guide-service.test.js +0 -68
- package/sample/crud/test/apps/main/hash-task.test.js +0 -30
- package/sample/crud/test/apps/main/jobs-demo-service.test.js +0 -88
- package/sample/crud/test/apps/main/logs-demo-service.test.js +0 -85
- package/sample/crud/test/apps/main/metrics-demo-service.test.js +0 -90
- package/sample/crud/test/apps/main/note-service.test.js +0 -68
- package/sample/crud/test/apps/main/perf-service.test.js +0 -121
- package/sample/crud/test/apps/main/perf.integration.test.js +0 -202
- package/sample/crud/test/apps/main/redis-demo-service.test.js +0 -98
- package/sample/crud/test/apps/main/tracing-demo-service.test.js +0 -90
- package/sample/crud/test/apps/main/upload-demo-service.test.js +0 -61
- package/sample/crud/test/apps/main/user-service.test.js +0 -65
- package/sample/crud/test/apps/main/ws-chat.integration.test.js +0 -233
- package/templates/project/app.config.tpl +0 -8
- package/templates/project/app.config.views.tpl +0 -37
- package/templates/project/ecosystem.config.tpl +0 -10
- package/templates/project/env.tpl +0 -12
- package/templates/project/gitignore.tpl +0 -8
- package/templates/project/locales/client/en.json.tpl +0 -3
- package/templates/project/locales/client/ko.json.tpl +0 -3
- package/templates/project/locales/server/en.json.tpl +0 -17
- package/templates/project/locales/server/ko.json.tpl +0 -17
- package/templates/project/mega.config.tpl +0 -11
- package/templates/project/package.tpl +0 -25
- package/templates/project/public/css/app.css +0 -101
- package/templates/project/public/js/app.js +0 -54
- package/templates/project/public/js/theme-init.js +0 -12
- package/templates/project/public/vendor/bootstrap/bootstrap.bundle.min.js +0 -7
- package/templates/project/public/vendor/bootstrap/bootstrap.min.css +0 -6
- package/templates/project/readme.tpl +0 -48
- package/templates/project/route.test.tpl +0 -13
- package/templates/project/route.test.views.tpl +0 -15
- package/templates/project/route.tpl +0 -10
- package/templates/project/route.views.tpl +0 -10
- package/templates/project/views/index.ejs.tpl +0 -58
- package/templates/project/views/layout.ejs.tpl +0 -73
- package/templates/project/vitest.config.tpl +0 -8
package/package.json
CHANGED
package/sample/crud/yarn.lock
CHANGED
|
@@ -1234,7 +1234,7 @@ marked@^18.0.5:
|
|
|
1234
1234
|
integrity sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==
|
|
1235
1235
|
|
|
1236
1236
|
"mega-framework@file:../..":
|
|
1237
|
-
version "0.1.
|
|
1237
|
+
version "0.1.4"
|
|
1238
1238
|
dependencies:
|
|
1239
1239
|
"@fastify/cookie" "^11.0.2"
|
|
1240
1240
|
"@fastify/cors" "^11.2.0"
|
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
* 같은 `get/set/del/has` API 를 로컬 파일로 만족시킨다 — driver 키 한 줄만 바꿔 환경 전환(ADR-082).
|
|
7
7
|
* **신규 의존성 0** (`node:fs/promises`/`node:path`/`node:crypto` 표준만).
|
|
8
8
|
*
|
|
9
|
+
* ⚠️ **만료 파일 능동 정리 없음(디스크 증가 주의)**: 만료 envelope 은 **재접근(get/has) 시에만 lazy 삭제**된다.
|
|
10
|
+
* set 후 다시 조회되지 않는 만료 키는 파일로 영구 잔존하므로, **짧은 TTL + 높은 키 카디널리티**(요청별 캐시 키
|
|
11
|
+
* 등) 워크로드에선 디스크/inode 가 누적될 수 있다. 그래서 file 캐시는 **dev / 단일·단명 인스턴스 권장**이며,
|
|
12
|
+
* 프로덕션 장기 구동에는 redis 캐시 또는 외부 정리(cron unlink)를 전제로 한다(능동 sweep 은 후속 과제).
|
|
13
|
+
*
|
|
9
14
|
* # 표준 표면 (MegaCacheAdapter 상속)
|
|
10
15
|
* - `_connect()` — `fs.mkdir(basePath, { recursive: true })` 로 디렉토리 보장.
|
|
11
16
|
* - `_disconnect()`— no-op (파일시스템은 close 개념 없음).
|
|
@@ -3,8 +3,12 @@
|
|
|
3
3
|
* MegaCacheAdapter — key-value 캐시 표준 인터페이스 (추상, 08-class-specs §3.4).
|
|
4
4
|
*
|
|
5
5
|
* `ctx.cache(key)` 가 본 베이스 인스턴스를 반환. 구체: `MegaRedisAdapter` /
|
|
6
|
-
* `MegaFileAdapter` (ADR-082).
|
|
7
|
-
*
|
|
6
|
+
* `MegaFileAdapter` (ADR-082).
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ **키 네임스페이스(ADR-064)**: ADR-064 가 `mega:cache:<appName>:<key>` 자동 prefix 를 *결정*했으나
|
|
9
|
+
* **현재 코어에 미구현**이다(get/set/del 이 raw `key` 를 그대로 사용). 멀티앱이 같은 redis 를 공유하면
|
|
10
|
+
* 키 충돌 위험이 있으니, **현재는 사용자가 키에 앱별 네임스페이스를 직접 붙여야 한다**. 자동 prefix 구현은
|
|
11
|
+
* 후속 과제(ADR-064 open). 단일앱(현 sample)에선 무관.
|
|
8
12
|
*
|
|
9
13
|
* @module adapters/mega-cache-adapter
|
|
10
14
|
*/
|
|
@@ -295,11 +295,16 @@ export class MegaNatsAdapter extends MegaBusAdapter {
|
|
|
295
295
|
* 잡 처리 등록 — **queue group** 구독(같은 queue 구독자끼리 load-balance). 기본 queue 이름은 jobName
|
|
296
296
|
* (같은 잡의 모든 워커가 한 그룹).
|
|
297
297
|
*
|
|
298
|
+
* ⚠️ **전달 보장 = at-most-once(비영속, H5)**: 이건 **core NATS** queue group 이라 메시지가 디스크에
|
|
299
|
+
* 남지 않는다 — 구독자가 없거나 처리 중 죽으면 그 메시지는 **유실**된다(재시도·DLQ 없음). 유실이
|
|
300
|
+
* 치명적이거나 영속 큐/재시도/DLQ 가 필요하면 **`MegaJobQueue`(JetStream, at-least-once, ADR-028/112)**
|
|
301
|
+
* 를 쓴다. `ctx.bus(x).process` 와 `mega worker`(JetStream) 는 **전달 보장이 다르므로 혼용 금지**.
|
|
302
|
+
*
|
|
298
303
|
* **subscription 핸들을 반환하지 않는 건 의도적**(L-2): worker 는 앱 수명 동안 상주하는 것이
|
|
299
304
|
* 정상이고(개별 잡 처리 등록을 런타임에 떼었다 붙였다 하는 패턴은 비표준), 정리는 disconnect 시
|
|
300
305
|
* `_disconnect()` 의 `nc.drain()` 이 **모든 구독을 일괄 비우며** 처리한다. 개별 unsubscribe 가
|
|
301
306
|
* 필요한 일시 구독은 `subscribe()`(핸들 반환)를 쓴다 — `process` 는 fire-and-forget `enqueue` 와
|
|
302
|
-
* 대칭인 상주 worker 용이다.
|
|
307
|
+
* 대칭인 상주 worker 용이다.
|
|
303
308
|
*
|
|
304
309
|
* @param {string} jobName
|
|
305
310
|
* @param {(payload: any) => any} handler
|
|
@@ -30,7 +30,7 @@ function defaultReplFactory() {
|
|
|
30
30
|
* @param {(msg: string) => void} [deps.out]
|
|
31
31
|
* @param {() => (Promise<void> | void)} [deps.shutdown] - 주입용(테스트). REPL 종료 시 호출하는 graceful
|
|
32
32
|
* shutdown 트리거. 기본 {@link MegaShutdown.now}(등록 hook 실행 후 process.exit).
|
|
33
|
-
* @param {(opts: { signals?: string[] }) => void} [deps.setupSignals] - 주입용(테스트). 시그널 핸들러 등록.
|
|
33
|
+
* @param {(opts: { signals?: string[], globalErrorHandlers?: boolean }) => void} [deps.setupSignals] - 주입용(테스트). 시그널 핸들러 등록.
|
|
34
34
|
* 기본 {@link MegaShutdown.setupSignals}.
|
|
35
35
|
* @returns {Promise<{ ctx: Record<string, any>, config: object, server: { context: Record<string, any> } }>}
|
|
36
36
|
*/
|
|
@@ -44,7 +44,9 @@ export async function startConsole(
|
|
|
44
44
|
// 두면 REPL 종료 후에도 열린 핸들(DB 풀·redis·wsHub listen)로 프로세스가 행하고 어댑터가 미정리된다.
|
|
45
45
|
// 따라서 SIGTERM 은 graceful shutdown 으로 받는다. SIGINT 은 REPL 이 소유(빈 줄 클리어 / 이중 입력 시
|
|
46
46
|
// 'exit')하므로 가로채지 않는다 — 가로채면 한 번의 Ctrl-C 가 콘솔을 죽인다(ADR-167).
|
|
47
|
-
|
|
47
|
+
// REPL 은 전역 에러 핸들러를 끈다(globalErrorHandlers:false) — 대화형에서 사용자 코드 예외가
|
|
48
|
+
// 프로세스를 graceful shutdown 시키면 안 되고, REPL 자체 에러 복구를 방해하지 않기 위함(ADR-178).
|
|
49
|
+
setupSignals({ signals: ['SIGTERM'], globalErrorHandlers: false })
|
|
48
50
|
const server = replFactory()
|
|
49
51
|
Object.assign(server.context, { ctx, config: global, mega: { config: global, host } })
|
|
50
52
|
// REPL 종료(.exit / Ctrl-D / 이중 Ctrl-C)는 'exit' 이벤트로 온다 — 이때 graceful shutdown 으로 어댑터 등을
|
package/src/cli/commands/new.js
CHANGED
|
@@ -1,26 +1,94 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
/**
|
|
3
|
-
* `mega new <project>` —
|
|
3
|
+
* `mega new <project>` — `sample/crud` 데모앱을 그대로 스캐폴드한다(ADR-179, 기존 미니멀/--views 모드 대체).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* 빈 폴더에 14개 기능(auth·notes/users CRUD·WS 채팅·jobs·cron·metrics·logs·perf·redis·tracing·upload·
|
|
6
|
+
* worker·guide)을 모두 갖춘 멀티앱 데모를 찍어낸다. 원본은 레포의 `sample/crud`(npm `files` 에 포함돼
|
|
7
|
+
* 패키지에도 동봉) 트리를 **바이트 그대로 복사**하고, 프로젝트 이름이 박힌 소수 파일(package.json·README·
|
|
8
|
+
* ecosystem·.env)만 토큰 치환한다 — `sample-crud` → 프로젝트명, dep `file:../..` → 실제 버전(`^x.y.z`).
|
|
9
|
+
* 로컬 인프라 기동용 루트 `docker-compose.yml` 도 함께 복사한다(개발자 학습용, ADR-103).
|
|
10
|
+
*
|
|
11
|
+
* 복사 제외: 빌드 산출물(node_modules·coverage)·로크파일(package-lock/yarn.lock)·런타임 로그(*.log)·VCS,
|
|
12
|
+
* 그리고 런타임 업로드 데이터(`var/uploads/*` — 개인정보 포함 가능). 업로드 데모가 쓰는 디렉토리는
|
|
13
|
+
* 빈 `.gitkeep` 으로만 만든다. 신규 dep 설치는 하지 않고(네트워크 회피) 안내만 출력한다.
|
|
10
14
|
*
|
|
11
15
|
* @module cli/commands/new
|
|
12
16
|
*/
|
|
13
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
14
|
-
import { dirname, join, resolve } from 'node:path'
|
|
17
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
18
|
+
import { dirname, join, relative, resolve, sep } from 'node:path'
|
|
15
19
|
import { fileURLToPath } from 'node:url'
|
|
16
|
-
import { nameVariants
|
|
20
|
+
import { nameVariants } from '../template-engine.js'
|
|
21
|
+
|
|
22
|
+
/** 이 파일이 있는 디렉토리(src/cli/commands). 아래 원본 경로의 기준점. */
|
|
23
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
24
|
+
/** 스캐폴드 원본 = 레포의 `sample/crud` 데모앱(src/cli/commands 기준 3단계 위 + sample/crud). */
|
|
25
|
+
const SAMPLE_ROOT = resolve(HERE, '../../../sample/crud')
|
|
26
|
+
/** 루트 `docker-compose.yml` — 로컬 인프라(postgres·mariadb·mongo·redis·nats·zipkin·otel) 기동용. */
|
|
27
|
+
const COMPOSE_SRC = resolve(HERE, '../../../docker-compose.yml')
|
|
28
|
+
/** 프레임워크 자체 package.json — 스캐폴드 dep 버전 핀(`^x.y.z`) 의 소스. */
|
|
29
|
+
const FRAMEWORK_PKG = resolve(HERE, '../../../package.json')
|
|
30
|
+
|
|
31
|
+
/** 복사 제외 디렉토리/파일 basename — 빌드 산출물·로크파일·VCS·런타임 데이터 디렉토리. */
|
|
32
|
+
const EXCLUDE_NAMES = new Set([
|
|
33
|
+
'node_modules',
|
|
34
|
+
'.git',
|
|
35
|
+
'coverage',
|
|
36
|
+
'.nyc_output',
|
|
37
|
+
'.vitest',
|
|
38
|
+
'package-lock.json',
|
|
39
|
+
'yarn.lock',
|
|
40
|
+
'pnpm-lock.yaml',
|
|
41
|
+
'.DS_Store',
|
|
42
|
+
])
|
|
43
|
+
/** 복사 제외 상대경로 prefix — 런타임 업로드 산출물(개인정보 포함 가능, 대신 .gitkeep 만 생성). */
|
|
44
|
+
const EXCLUDE_PREFIXES = ['var/uploads']
|
|
45
|
+
/** 프로젝트 이름 토큰 치환을 적용할 텍스트 파일(그 외는 바이트 그대로 복사 — 바이너리 보존). */
|
|
46
|
+
const TRANSFORM_FILES = new Set(['package.json', 'README.md', 'ecosystem.config.cjs', '.env', '.env.example'])
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 한 상대경로가 복사 제외 대상인지.
|
|
50
|
+
* @param {string} rel - POSIX 슬래시 상대경로.
|
|
51
|
+
* @returns {boolean}
|
|
52
|
+
*/
|
|
53
|
+
function isExcluded(rel) {
|
|
54
|
+
if (rel.endsWith('.log')) return true
|
|
55
|
+
if (EXCLUDE_PREFIXES.some((p) => rel === p || rel.startsWith(`${p}/`))) return true
|
|
56
|
+
return rel.split('/').some((seg) => EXCLUDE_NAMES.has(seg))
|
|
57
|
+
}
|
|
17
58
|
|
|
18
|
-
/**
|
|
19
|
-
|
|
59
|
+
/**
|
|
60
|
+
* 디렉토리를 재귀 순회해 복사 대상 파일의 상대경로(POSIX 슬래시) 목록을 만든다. 제외 디렉토리는
|
|
61
|
+
* 통째로 건너뛴다(node_modules 등 큰 트리 순회 비용 회피).
|
|
62
|
+
* @param {string} dir - 현재 순회 디렉토리(절대).
|
|
63
|
+
* @param {string} base - 상대경로 기준 루트(절대).
|
|
64
|
+
* @param {string[]} acc - 누적 결과.
|
|
65
|
+
* @returns {string[]}
|
|
66
|
+
*/
|
|
67
|
+
function walk(dir, base, acc) {
|
|
68
|
+
for (const ent of readdirSync(dir, { withFileTypes: true })) {
|
|
69
|
+
const abs = join(dir, ent.name)
|
|
70
|
+
const rel = relative(base, abs).split(sep).join('/')
|
|
71
|
+
if (isExcluded(rel)) continue
|
|
72
|
+
if (ent.isDirectory()) walk(abs, base, acc)
|
|
73
|
+
else acc.push(rel)
|
|
74
|
+
}
|
|
75
|
+
return acc
|
|
76
|
+
}
|
|
20
77
|
|
|
21
|
-
/**
|
|
22
|
-
|
|
23
|
-
|
|
78
|
+
/**
|
|
79
|
+
* 토큰 치환 — `sample-crud` 를 프로젝트명으로, package.json 의 모노레포 dep(`file:../..`)을 실제 버전으로.
|
|
80
|
+
* @param {string} rel - 파일 상대경로.
|
|
81
|
+
* @param {string} content - 원본 텍스트.
|
|
82
|
+
* @param {{ name: string, version: string }} vars
|
|
83
|
+
* @returns {string}
|
|
84
|
+
*/
|
|
85
|
+
function transformText(rel, content, vars) {
|
|
86
|
+
let out = content.replaceAll('sample-crud', vars.name)
|
|
87
|
+
if (rel === 'package.json') {
|
|
88
|
+
// 모노레포 로컬 링크 → 퍼블리시된 프레임워크 버전 핀. 스캐폴드된 프로젝트는 독립 패키지라 file: 가 안 풀린다.
|
|
89
|
+
out = out.replace('"file:../.."', `"^${vars.version}"`)
|
|
90
|
+
}
|
|
91
|
+
return out
|
|
24
92
|
}
|
|
25
93
|
|
|
26
94
|
/**
|
|
@@ -28,74 +96,54 @@ function renderProject(file, vars) {
|
|
|
28
96
|
* @param {string} targetDir - 프로젝트 루트 절대/상대 경로.
|
|
29
97
|
* @param {object} [opts]
|
|
30
98
|
* @param {string} [opts.name] - 프로젝트 이름(기본: targetDir 의 마지막 세그먼트).
|
|
31
|
-
* @param {boolean} [opts.ejsMate] - EJS + ejs-mate 뷰 골격 포함(기본 false).
|
|
32
99
|
* @param {boolean} [opts.force] - 기존 파일 덮어쓰기(기본 false — 건너뜀).
|
|
33
100
|
* @returns {{ root: string, written: string[], skipped: string[] }}
|
|
34
101
|
*/
|
|
35
|
-
export function scaffoldProject(targetDir, { name,
|
|
102
|
+
export function scaffoldProject(targetDir, { name, force = false } = {}) {
|
|
36
103
|
const root = resolve(targetDir)
|
|
37
104
|
const projectName = nameVariants(name ?? root.split(/[/\\]/).pop() ?? 'mega-app').kebab
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
/** @type {Array<{ rel: string, tpl: string }>} */
|
|
42
|
-
const files = [
|
|
43
|
-
{ rel: 'mega.config.js', tpl: 'mega.config.tpl' },
|
|
44
|
-
{ rel: 'apps/main/app.config.js', tpl: ejsMate ? 'app.config.views.tpl' : 'app.config.tpl' },
|
|
45
|
-
{ rel: 'apps/main/routes/index.js', tpl: ejsMate ? 'route.views.tpl' : 'route.tpl' },
|
|
46
|
-
{ rel: 'test/apps/main/index.test.js', tpl: ejsMate ? 'route.test.views.tpl' : 'route.test.tpl' },
|
|
47
|
-
{ rel: 'package.json', tpl: 'package.tpl' },
|
|
48
|
-
{ rel: 'README.md', tpl: 'readme.tpl' },
|
|
49
|
-
{ rel: '.env.example', tpl: 'env.tpl' },
|
|
50
|
-
{ rel: '.gitignore', tpl: 'gitignore.tpl' },
|
|
51
|
-
{ rel: 'vitest.config.js', tpl: 'vitest.config.tpl' },
|
|
52
|
-
{ rel: 'ecosystem.config.cjs', tpl: 'ecosystem.config.tpl' },
|
|
53
|
-
]
|
|
54
|
-
if (ejsMate) {
|
|
55
|
-
files.push(
|
|
56
|
-
{ rel: 'apps/main/views/layouts/main.ejs', tpl: 'views/layout.ejs.tpl' },
|
|
57
|
-
{ rel: 'apps/main/views/index.ejs', tpl: 'views/index.ejs.tpl' },
|
|
58
|
-
{ rel: 'apps/main/locales/server/ko.json', tpl: 'locales/server/ko.json.tpl' },
|
|
59
|
-
{ rel: 'apps/main/locales/server/en.json', tpl: 'locales/server/en.json.tpl' },
|
|
60
|
-
{ rel: 'apps/main/locales/client/ko.json', tpl: 'locales/client/ko.json.tpl' },
|
|
61
|
-
{ rel: 'apps/main/locales/client/en.json', tpl: 'locales/client/en.json.tpl' },
|
|
62
|
-
)
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// 정적 자산 — 토큰 치환 없이 바이트 그대로 복사한다(vendored Bootstrap 5 min 번들 + 브랜드 CSS/JS).
|
|
66
|
-
// 이들은 템플릿이 아니라 정적 파일이므로 renderTemplate(`{{token}}`)을 거치지 않는다.
|
|
67
|
-
/** @type {Array<{ rel: string, src: string }>} */
|
|
68
|
-
const assets = ejsMate
|
|
69
|
-
? [
|
|
70
|
-
{ rel: 'apps/main/public/vendor/bootstrap/bootstrap.min.css', src: 'public/vendor/bootstrap/bootstrap.min.css' },
|
|
71
|
-
{ rel: 'apps/main/public/vendor/bootstrap/bootstrap.bundle.min.js', src: 'public/vendor/bootstrap/bootstrap.bundle.min.js' },
|
|
72
|
-
{ rel: 'apps/main/public/css/app.css', src: 'public/css/app.css' },
|
|
73
|
-
{ rel: 'apps/main/public/js/app.js', src: 'public/js/app.js' },
|
|
74
|
-
{ rel: 'apps/main/public/js/theme-init.js', src: 'public/js/theme-init.js' },
|
|
75
|
-
]
|
|
76
|
-
: []
|
|
105
|
+
/** @type {string} */
|
|
106
|
+
const version = JSON.parse(readFileSync(FRAMEWORK_PKG, 'utf8')).version
|
|
107
|
+
const vars = { name: projectName, version }
|
|
77
108
|
|
|
78
109
|
/** @type {string[]} */ const written = []
|
|
79
110
|
/** @type {string[]} */ const skipped = []
|
|
80
|
-
|
|
81
|
-
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 출력 파일 1개를 기록(존재 + !force 면 skip).
|
|
114
|
+
* @param {string} out - 출력 절대경로.
|
|
115
|
+
* @param {() => void} write - 실제 기록 동작.
|
|
116
|
+
*/
|
|
117
|
+
const emit = (out, write) => {
|
|
82
118
|
if (existsSync(out) && !force) {
|
|
83
119
|
skipped.push(out)
|
|
84
|
-
|
|
120
|
+
return
|
|
85
121
|
}
|
|
86
122
|
mkdirSync(dirname(out), { recursive: true })
|
|
87
|
-
|
|
123
|
+
write()
|
|
88
124
|
written.push(out)
|
|
89
125
|
}
|
|
90
|
-
|
|
126
|
+
|
|
127
|
+
// 1) sample/crud 트리 복사(제외 규칙 적용). 토큰 치환 대상만 텍스트로 렌더, 그 외는 바이트 그대로.
|
|
128
|
+
for (const rel of walk(SAMPLE_ROOT, SAMPLE_ROOT, [])) {
|
|
129
|
+
const src = join(SAMPLE_ROOT, rel)
|
|
91
130
|
const out = join(root, rel)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
131
|
+
emit(out, () => {
|
|
132
|
+
if (TRANSFORM_FILES.has(rel)) {
|
|
133
|
+
writeFileSync(out, transformText(rel, readFileSync(src, 'utf8'), vars))
|
|
134
|
+
} else {
|
|
135
|
+
copyFileSync(src, out)
|
|
136
|
+
}
|
|
137
|
+
})
|
|
99
138
|
}
|
|
139
|
+
|
|
140
|
+
// 2) 루트 docker-compose.yml — 로컬 인프라(개발자 학습용). 프로젝트 이름과 무관해 그대로 복사.
|
|
141
|
+
const compose = join(root, 'docker-compose.yml')
|
|
142
|
+
emit(compose, () => copyFileSync(COMPOSE_SRC, compose))
|
|
143
|
+
|
|
144
|
+
// 3) 업로드 데모가 쓰는 디렉토리 placeholder — 실제 업로드 데이터(개인정보 PDF 등)는 동봉하지 않는다.
|
|
145
|
+
const gitkeep = join(root, 'var/uploads/.gitkeep')
|
|
146
|
+
emit(gitkeep, () => writeFileSync(gitkeep, ''))
|
|
147
|
+
|
|
100
148
|
return { root, written, skipped }
|
|
101
149
|
}
|
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
* @module cli/commands/scaffold
|
|
8
8
|
*/
|
|
9
9
|
import { Command } from 'commander'
|
|
10
|
-
import prompts from 'prompts'
|
|
11
10
|
import { generate, GENERATOR_KINDS } from '../generators/index.js'
|
|
12
11
|
import { scaffoldProject } from './new.js'
|
|
13
12
|
import { runRoutesCommand } from './routes.js'
|
|
@@ -50,21 +49,16 @@ export async function runScaffoldCommand(argv, { out, err, projectRoot, logger }
|
|
|
50
49
|
|
|
51
50
|
program
|
|
52
51
|
.command('new <project>')
|
|
53
|
-
.description('
|
|
54
|
-
.option('--views', 'EJS + ejs-mate 뷰 골격 포함')
|
|
52
|
+
.description('sample/crud 데모앱(14기능) 전체를 빈 폴더에 스캐폴드')
|
|
55
53
|
.option('--force', '기존 파일 덮어쓰기')
|
|
56
|
-
.action(
|
|
57
|
-
// --views 미지정 + 대화형 터미널이면 ejs-mate 옵트인을 묻는다(비-TTY/CI 는 기본 false).
|
|
58
|
-
let ejsMate = opts.views === true
|
|
59
|
-
if (opts.views === undefined && process.stdin.isTTY) {
|
|
60
|
-
const ans = /** @type {any} */ (await prompts({ type: 'confirm', name: 'views', message: 'EJS + ejs-mate 뷰 골격을 포함할까요?', initial: false }))
|
|
61
|
-
ejsMate = ans.views === true
|
|
62
|
-
}
|
|
54
|
+
.action((/** @type {string} */ project, /** @type {any} */ opts) => {
|
|
63
55
|
const target = project === '.' ? projectRoot : `${projectRoot}/${project}`
|
|
64
|
-
const r = scaffoldProject(target, { name: project === '.' ? undefined : project,
|
|
56
|
+
const r = scaffoldProject(target, { name: project === '.' ? undefined : project, force: opts.force === true })
|
|
65
57
|
out(`mega: scaffolded project at ${r.root}`)
|
|
66
58
|
reportFiles(out, r, r.root)
|
|
67
|
-
|
|
59
|
+
// 풀 데모는 로컬 인프라(postgres·redis·mongo·nats)가 있어야 부팅된다 — docker compose 로 띄운 뒤 마이그레이션.
|
|
60
|
+
const cd = project === '.' ? '' : ` cd ${project}\n`
|
|
61
|
+
out(`\n다음 단계:\n${cd} npm install\n docker compose up -d # 로컬 인프라 기동(postgres·redis·mongo·nats)\n npm run migrate # DB 스키마 생성\n npm run dev # 개발 서버`)
|
|
68
62
|
})
|
|
69
63
|
|
|
70
64
|
program
|
package/src/cli/index.js
CHANGED
|
@@ -45,7 +45,7 @@ Usage:
|
|
|
45
45
|
mega migrate [--db KEY] [--root DIR] pending 마이그레이션 일괄 적용(up)
|
|
46
46
|
mega migrate:down [--db KEY] 마지막 적용 마이그레이션 1개 롤백(down)
|
|
47
47
|
mega migrate:status [--db KEY] 적용/미적용 마이그레이션 목록
|
|
48
|
-
mega new <project> [--
|
|
48
|
+
mega new <project> [--force] sample/crud 데모앱(14기능) 전체를 스캐폴드
|
|
49
49
|
mega g <kind> <name> [--app A] [--version vN] 코드+테스트 생성 (별칭: generate)
|
|
50
50
|
[--kind K] [--lng L] [--force]
|
|
51
51
|
mega routes [--root DIR] 등록 라우트 트리 출력
|
package/src/core/mega-app.js
CHANGED
|
@@ -968,9 +968,9 @@ export class MegaApp {
|
|
|
968
968
|
}
|
|
969
969
|
// redis(cluster-wide) — 다른 워커/허브의 세션까지.
|
|
970
970
|
if (this._wsRoster) {
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
971
|
+
// 여러 채널 redis 조회를 **병렬화**(Promise.all) — onConnect 핫패스의 직렬 라운드트립 누적 제거(Med).
|
|
972
|
+
const lists = await Promise.all([...want].map((ch) => this._wsRoster.list(ch)))
|
|
973
|
+
for (const list of lists) for (const m of list) if (!out.has(m.sessionId)) out.set(m.sessionId, m)
|
|
974
974
|
}
|
|
975
975
|
return [...out.values()]
|
|
976
976
|
}
|
|
@@ -1259,6 +1259,14 @@ export class MegaApp {
|
|
|
1259
1259
|
this._hubBridgeId = null
|
|
1260
1260
|
MegaShutdown.unregister(`mega-hublink:${this.name}`)
|
|
1261
1261
|
}
|
|
1262
|
+
// NATS 클러스터 정리(ADR-176) — 구독·heartbeat/sweep 타이머·roster 멤버 해제. boot 의 shutdown hook 과
|
|
1263
|
+
// **양쪽**에서 정리해야 시그널을 안 거치는 종료(server.close()/프로그램적 재시작/멀티앱 부분 종료)에서도
|
|
1264
|
+
// 누수가 없다(_hubLink/_wsRoster 와 동일 대칭, H1). stop() 은 _isStarted 가드로 멱등이라 중복 안전.
|
|
1265
|
+
if (this._wsCluster) {
|
|
1266
|
+
await this._wsCluster.stop().catch((err) => this.fastify.log?.warn?.({ err, app: this.name }, 'ws-cluster stop failed'))
|
|
1267
|
+
this._wsCluster = null
|
|
1268
|
+
MegaShutdown.unregister(`mega-ws-cluster:${this.name}`)
|
|
1269
|
+
}
|
|
1262
1270
|
// redis roster 정리(ADR-177) — heartbeat 중지 + 로컬 멤버 즉시 제거. _sessionConns 를 비우기 **전에**
|
|
1263
1271
|
// 호출해야 stop() 이 localRosterMembers 로 자기 멤버를 redis 에서 뺄 수 있다.
|
|
1264
1272
|
if (this._wsRoster) {
|
package/src/core/mega-cluster.js
CHANGED
|
@@ -50,10 +50,16 @@ export class MegaCluster {
|
|
|
50
50
|
/** @type {(() => Promise<void>) | null} */
|
|
51
51
|
this._workerFn = null
|
|
52
52
|
|
|
53
|
-
// M1 — respawn backoff (crash-loop 보호)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
53
|
+
// M1 — respawn backoff (crash-loop 보호). 멀티워커 정확성: 전역 카운터 + 정상종료-리셋은 한 워커의
|
|
54
|
+
// 정상 종료가 다른 워커들의 누적 빠른-crash 카운트를 통째로 지워 crash-loop 를 마스킹한다. 그래서
|
|
55
|
+
// **빠른 crash 타임스탬프의 슬라이딩 윈도우**로 판정한다(윈도우 내 N회 → 포기). 정상 종료는 카운트에
|
|
56
|
+
// 기여하지 않고, 오래된 빠른-crash 는 윈도우에서 자연 소거된다(안정화되면 자동 리셋).
|
|
57
|
+
this._maxRapidRespawn = 5 // 윈도우 내 5번 빠른 crash 면 중단
|
|
58
|
+
this._minRespawnIntervalMs = 1000 // 이 lifetime 미만 crash 는 "너무 빠름"
|
|
59
|
+
/** @type {number[]} 윈도우 내 빠른-crash 타임스탬프(ms). */
|
|
60
|
+
this._rapidCrashTimes = []
|
|
61
|
+
/** @type {ReturnType<typeof setTimeout>|null} SIGTERM grace 강제-kill 타이머(전원 정상종료 시 취소). */
|
|
62
|
+
this._graceTimer = null
|
|
57
63
|
}
|
|
58
64
|
|
|
59
65
|
/**
|
|
@@ -103,13 +109,15 @@ export class MegaCluster {
|
|
|
103
109
|
this._shuttingDown = true
|
|
104
110
|
console.log(`[mega-cluster] received ${sig}, broadcasting shutdown to ${this._workers.size} workers (grace ${this._gracePeriodMs}ms)`)
|
|
105
111
|
this._broadcastShutdown()
|
|
106
|
-
// grace 초과 시 강제 kill
|
|
107
|
-
|
|
112
|
+
// grace 초과 시 강제 kill. 핸들을 보관해 전원 정상종료(exit 핸들러) 시 취소 — 정상 종료가 이 타이머의
|
|
113
|
+
// exit(1) 과 경합해 exit code 가 흔들리지 않게 한다(k8s/systemd 종료 판정 노이즈 제거).
|
|
114
|
+
this._graceTimer = setTimeout(() => {
|
|
108
115
|
for (const w of this._workers) {
|
|
109
116
|
try { w.kill('SIGKILL') } catch (err) { console.warn('[mega-cluster] SIGKILL failed:', err.message) }
|
|
110
117
|
}
|
|
111
118
|
this._proc.exit(1)
|
|
112
|
-
}, this._gracePeriodMs)
|
|
119
|
+
}, this._gracePeriodMs)
|
|
120
|
+
this._graceTimer.unref()
|
|
113
121
|
}
|
|
114
122
|
this._proc.on('SIGTERM', () => onSignal('SIGTERM'))
|
|
115
123
|
this._proc.on('SIGINT', () => onSignal('SIGINT'))
|
|
@@ -124,33 +132,35 @@ export class MegaCluster {
|
|
|
124
132
|
this._workers.delete(worker)
|
|
125
133
|
if (this._shuttingDown) {
|
|
126
134
|
if (this._workers.size === 0) {
|
|
135
|
+
if (this._graceTimer) clearTimeout(this._graceTimer) // 전원 정상종료 — 강제-kill 타이머 취소(exit code 경합 제거).
|
|
136
|
+
this._graceTimer = null
|
|
127
137
|
console.log('[mega-cluster] all workers exited, primary exiting 0')
|
|
128
138
|
this._proc.exit(0)
|
|
129
139
|
}
|
|
130
140
|
return
|
|
131
141
|
}
|
|
132
142
|
if (this._respawn) {
|
|
133
|
-
// M1 — respawn backoff:
|
|
143
|
+
// M1 — respawn backoff: 빠른 crash 의 슬라이딩 윈도우로 crash-loop 판정(멀티워커 정확).
|
|
134
144
|
const now = Date.now()
|
|
135
145
|
const lastBirth = worker._megaBirthAt ?? now
|
|
136
146
|
const lifetimeMs = now - lastBirth
|
|
147
|
+
// 윈도우 = maxRapidRespawn 회의 빠른 재시작이 걸릴 시간 + 여유. 이보다 오래된 빠른-crash 는 소거.
|
|
148
|
+
const windowMs = this._minRespawnIntervalMs * this._maxRapidRespawn * 2
|
|
149
|
+
this._rapidCrashTimes = this._rapidCrashTimes.filter((t) => now - t < windowMs)
|
|
137
150
|
if (lifetimeMs < this._minRespawnIntervalMs) {
|
|
138
|
-
this.
|
|
139
|
-
if (this.
|
|
140
|
-
// H-1 — crash-loop 포기 시 그냥 return 하면 이벤트루프가 비어 exit 0(성공)으로
|
|
141
|
-
//
|
|
142
|
-
// 명시적 exit 1 로 "비정상 종료" 를 외부 supervisor 에 알려 재시작을 유도한다.
|
|
151
|
+
this._rapidCrashTimes.push(now) // 빠른 crash 만 윈도우에 기록(정상 lifetime 은 기여 X).
|
|
152
|
+
if (this._rapidCrashTimes.length >= this._maxRapidRespawn) {
|
|
153
|
+
// H-1 — crash-loop 포기 시 그냥 return 하면 이벤트루프가 비어 exit 0(성공)으로 보인다 →
|
|
154
|
+
// systemd/k8s 가 "정상 종료" 로 오판. 명시적 exit 1 로 비정상 종료를 알려 재시작을 유도한다.
|
|
143
155
|
console.error(
|
|
144
|
-
`[mega-cluster] rapid crash-loop detected (${this.
|
|
156
|
+
`[mega-cluster] rapid crash-loop detected (${this._rapidCrashTimes.length} rapid crashes within ${windowMs}ms), giving up — exiting 1. Last exit: code=${code}, signal=${signal}.`,
|
|
145
157
|
)
|
|
146
158
|
this._proc.exit(1)
|
|
147
159
|
return // 실 process.exit 은 즉시 종료하나, 주입된 fake 는 계속 실행되므로 명시적 중단
|
|
148
160
|
}
|
|
149
|
-
} else {
|
|
150
|
-
this._respawnTooFast = 0 // 정상 lifetime 이면 카운터 reset
|
|
151
161
|
}
|
|
152
162
|
console.warn(
|
|
153
|
-
`[mega-cluster] worker ${worker.process.pid} died (code=${code}, signal=${signal}, lifetime=${lifetimeMs}ms), respawning (rapid-
|
|
163
|
+
`[mega-cluster] worker ${worker.process.pid} died (code=${code}, signal=${signal}, lifetime=${lifetimeMs}ms), respawning (rapid-crashes=${this._rapidCrashTimes.length}/${this._maxRapidRespawn} in ${windowMs}ms)`,
|
|
154
164
|
)
|
|
155
165
|
this._forkWorker()
|
|
156
166
|
} else {
|
package/src/core/ws-upgrade.js
CHANGED
|
@@ -46,6 +46,12 @@ export const CLOSE_CODE_DECRYPT_FAILED = 4500
|
|
|
46
46
|
*/
|
|
47
47
|
export const CLOSE_CODE_INTERNAL_ERROR = 1011
|
|
48
48
|
|
|
49
|
+
/** 느린 소비자 백프레셔 close code (RFC 6455 §7.4.1 표준 1013 "Try Again Later", Med). */
|
|
50
|
+
export const CLOSE_CODE_SLOW_CONSUMER = 1013
|
|
51
|
+
|
|
52
|
+
/** send 버퍼(bufferedAmount) 기본 상한(바이트). 초과 = 소비자가 ack 를 못 따라옴 → 연결 종료(서버 OOM 방지). */
|
|
53
|
+
export const DEFAULT_MAX_BUFFERED_BYTES = 16 * 1024 * 1024
|
|
54
|
+
|
|
49
55
|
/**
|
|
50
56
|
* WS 프레임 코덱 — 평문/암호 와이어 변환을 추상화한다.
|
|
51
57
|
* @typedef {Object} WsFrameCodec
|
|
@@ -93,11 +99,13 @@ export class MegaWsConnection {
|
|
|
93
99
|
/**
|
|
94
100
|
* @param {import('ws').WebSocket} rawSocket
|
|
95
101
|
* @param {WsFrameCodec} codec
|
|
96
|
-
* @param {{ id?: string, ns?: string, path: string }} meta
|
|
102
|
+
* @param {{ id?: string, ns?: string, path: string, maxBufferedBytes?: number }} meta
|
|
97
103
|
*/
|
|
98
104
|
constructor(rawSocket, codec, meta) {
|
|
99
105
|
/** @type {import('ws').WebSocket} */
|
|
100
106
|
this._raw = rawSocket
|
|
107
|
+
/** @type {number} send 버퍼 상한(바이트) — 초과 시 느린 소비자로 보고 연결 종료(백프레셔). */
|
|
108
|
+
this._maxBufferedBytes = Number.isFinite(meta.maxBufferedBytes) ? /** @type {number} */ (meta.maxBufferedBytes) : DEFAULT_MAX_BUFFERED_BYTES
|
|
101
109
|
/** @type {WsFrameCodec} */
|
|
102
110
|
this._codec = codec
|
|
103
111
|
/** @type {string} 연결 식별자 (ULID). */
|
|
@@ -136,6 +144,16 @@ export class MegaWsConnection {
|
|
|
136
144
|
* @returns {void}
|
|
137
145
|
*/
|
|
138
146
|
send(fields) {
|
|
147
|
+
// 백프레셔 가드(Med): 송신 버퍼가 상한을 넘으면 소비자가 ack 를 못 따라오는 것 → 더 쌓지 않고 연결을
|
|
148
|
+
// 종료한다(느린 소비자 몇 개가 fan-out 으로 서버 힙을 무한 적재→OOM 시키는 것 방지). close code 1013.
|
|
149
|
+
if (this._raw.bufferedAmount > this._maxBufferedBytes) {
|
|
150
|
+
try {
|
|
151
|
+
this._raw.close(CLOSE_CODE_SLOW_CONSUMER, 'backpressure: send buffer exceeded')
|
|
152
|
+
} catch {
|
|
153
|
+
// 이미 닫히는 중이면 close 는 무의미 — 무시(비치명적, 다음 send 는 isOpen 가드로 걸러짐).
|
|
154
|
+
}
|
|
155
|
+
return
|
|
156
|
+
}
|
|
139
157
|
// ns 자동 주입 (L1): 명시 안 됐고 연결 ns 가 있으면 채운다. 명시값은 덮어쓰지 않음.
|
|
140
158
|
const withNs = fields.ns === undefined && this.ns !== undefined ? { ...fields, ns: this.ns } : fields
|
|
141
159
|
const env = createWsMessage(withNs)
|
|
@@ -22,6 +22,9 @@ const NONCE_KEY_PREFIX = 'asp:nonce:'
|
|
|
22
22
|
/** 기본 TTL — drift 윈도우(±60s) 의 2배 (ADR-058: EX 120). */
|
|
23
23
|
const DEFAULT_TTL_SEC = 120
|
|
24
24
|
|
|
25
|
+
/** in-memory nonce store 의 lazy sweep 주기 — insert 이 이 횟수만큼 누적되면 만료 일괄 제거(Med). */
|
|
26
|
+
const SWEEP_EVERY_OPS = 500
|
|
27
|
+
|
|
25
28
|
/**
|
|
26
29
|
* MegaAspNonceCache — nonce SETNX 래퍼.
|
|
27
30
|
*/
|
|
@@ -62,6 +65,8 @@ export class MegaMemoryNonceStore {
|
|
|
62
65
|
constructor() {
|
|
63
66
|
/** @type {Map<string, number>} key → 만료 epoch ms */
|
|
64
67
|
this._store = new Map()
|
|
68
|
+
/** @type {number} 마지막 sweep 이후 insert 횟수 — SWEEP_EVERY_OPS 마다 만료 일괄 제거. */
|
|
69
|
+
this._opsSinceSweep = 0
|
|
65
70
|
}
|
|
66
71
|
|
|
67
72
|
/**
|
|
@@ -71,6 +76,13 @@ export class MegaMemoryNonceStore {
|
|
|
71
76
|
*/
|
|
72
77
|
async setIfNotExists(key, ttlSec) {
|
|
73
78
|
const now = Date.now()
|
|
79
|
+
// lazy 주기 sweep(Med) — setIfNotExists 가 키 단건만 만료 갱신하므로, 다시 안 들어오는 nonce 는 영구
|
|
80
|
+
// 잔존(메모리 릭)한다. 타이머 없이 insert 누적이 임계를 넘을 때마다 만료를 일괄 제거해 Map 을 bound 한다
|
|
81
|
+
// (타이머 lifecycle/dispose 불필요 → 안전). 활성(미만료) nonce 는 TTL 로 자연 bound.
|
|
82
|
+
if (++this._opsSinceSweep >= SWEEP_EVERY_OPS) {
|
|
83
|
+
this.evictExpired(now)
|
|
84
|
+
this._opsSinceSweep = 0
|
|
85
|
+
}
|
|
74
86
|
const exp = this._store.get(key)
|
|
75
87
|
if (exp !== undefined && exp > now) return false
|
|
76
88
|
this._store.set(key, now + ttlSec * 1000)
|
|
@@ -76,16 +76,31 @@ const LEVEL_NAMES = /** @type {Record<number, string>} */ ({
|
|
|
76
76
|
* rename·rm 된 inode 로 가서 항목이 **유실**된다(rename-claim 의 "유실 0" 이 깨지는 좁은 창). 그래서 큐
|
|
77
77
|
* 연산을 promise 체인 mutex 로 **직렬화**해 인터리브를 원천 차단한다. 큐 연산은 드물어(전송 실패 시
|
|
78
78
|
* append + 주기 drain) 직렬화 비용은 무시 가능. rename-claim 은 cross-process 안전용으로 그대로 둔다.
|
|
79
|
+
*
|
|
80
|
+
* # 상한(cap) — 무한 증가 방지 (H4, 최적화 리포트)
|
|
81
|
+
* 텔레그램이 장기 장애(토큰 만료·차단·네트워크 단절)면 실패분이 무한 적재되고, drain 이 파일 전체를
|
|
82
|
+
* 메모리로 읽어 재시도→또 실패→전량 재append 한다. 그래서 `drain` 이 **maxAge 만료 + maxEntries 상한**을
|
|
83
|
+
* 적용해(오래된 것부터 드롭, 드롭 수는 `onDrop` 으로 관측) 디스크·드레인 메모리를 함께 bound 한다.
|
|
84
|
+
* write 경로 append rate 는 transport throttle 로 이미 제한되므로 drain 시점 cap 으로 충분하다.
|
|
79
85
|
*/
|
|
80
86
|
export class RetryQueue {
|
|
81
87
|
/**
|
|
82
88
|
* @param {string} filePath - JSONL 큐 파일 경로(없으면 비어 있음).
|
|
89
|
+
* @param {{ maxEntries?: number, maxAgeMs?: number, onDrop?: (count: number) => void }} [opts]
|
|
90
|
+
* maxEntries: 보관 최대 항목 수(기본 5000, 0=무제한). maxAgeMs: 항목 최대 보존 ms(기본 24h, 0=무제한).
|
|
91
|
+
* onDrop: cap 으로 드롭된 수 통지(관측용).
|
|
83
92
|
*/
|
|
84
|
-
constructor(filePath) {
|
|
93
|
+
constructor(filePath, { maxEntries = 5000, maxAgeMs = 86_400_000, onDrop } = {}) {
|
|
85
94
|
/** @type {string} */
|
|
86
95
|
this._file = filePath
|
|
87
96
|
/** @type {Promise<unknown>} 직렬화 락의 꼬리 — append/drain/clear 를 이 체인에 줄세운다. */
|
|
88
97
|
this._tail = Promise.resolve()
|
|
98
|
+
/** @type {number} 보관 최대 항목 수(0=무제한). */
|
|
99
|
+
this._maxEntries = Number.isInteger(maxEntries) && maxEntries > 0 ? maxEntries : 0
|
|
100
|
+
/** @type {number} 항목 최대 보존 ms(0=무제한). */
|
|
101
|
+
this._maxAgeMs = Number.isInteger(maxAgeMs) && maxAgeMs > 0 ? maxAgeMs : 0
|
|
102
|
+
/** @type {((count: number) => void) | null} cap 드롭 수 통지. */
|
|
103
|
+
this._onDrop = typeof onDrop === 'function' ? onDrop : null
|
|
89
104
|
}
|
|
90
105
|
|
|
91
106
|
/**
|
|
@@ -134,19 +149,32 @@ export class RetryQueue {
|
|
|
134
149
|
}
|
|
135
150
|
const raw = await readFile(claim, 'utf8')
|
|
136
151
|
await rm(claim, { force: true })
|
|
137
|
-
|
|
138
|
-
|
|
152
|
+
const now = Date.now()
|
|
153
|
+
/** @type {Array<{ text: string, ts: number }>} */
|
|
154
|
+
let entries = []
|
|
139
155
|
for (const line of raw.split('\n')) {
|
|
140
156
|
if (!line.trim()) continue
|
|
141
157
|
try {
|
|
142
158
|
const obj = JSON.parse(line)
|
|
143
|
-
if (typeof obj.text === 'string')
|
|
159
|
+
if (typeof obj.text === 'string') entries.push({ text: obj.text, ts: typeof obj.ts === 'number' ? obj.ts : now })
|
|
144
160
|
} catch {
|
|
145
161
|
// 손상된 라인은 건너뛴다 — 큐 전체를 막지 않기 위함(비치명적, 다음 항목 계속).
|
|
146
162
|
continue
|
|
147
163
|
}
|
|
148
164
|
}
|
|
149
|
-
|
|
165
|
+
// cap(H4) — maxAge 만료 + maxEntries 상한. 오래된 것부터 드롭(최신 보존). 드롭 수는 onDrop 으로 관측.
|
|
166
|
+
let dropped = 0
|
|
167
|
+
if (this._maxAgeMs > 0) {
|
|
168
|
+
const before = entries.length
|
|
169
|
+
entries = entries.filter((e) => now - e.ts <= this._maxAgeMs)
|
|
170
|
+
dropped += before - entries.length
|
|
171
|
+
}
|
|
172
|
+
if (this._maxEntries > 0 && entries.length > this._maxEntries) {
|
|
173
|
+
dropped += entries.length - this._maxEntries
|
|
174
|
+
entries = entries.slice(entries.length - this._maxEntries) // 최신 maxEntries 만 유지.
|
|
175
|
+
}
|
|
176
|
+
if (dropped > 0 && this._onDrop) this._onDrop(dropped)
|
|
177
|
+
return entries.map((e) => e.text)
|
|
150
178
|
})
|
|
151
179
|
}
|
|
152
180
|
|