mega-framework 0.1.9 → 0.1.10
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/apps/main/controllers/jobs-controller.js +22 -2
- package/sample/crud/apps/main/jobs/email-job.js +37 -2
- package/sample/crud/apps/main/locales/server/en.json +5 -0
- package/sample/crud/apps/main/locales/server/ko.json +5 -0
- package/sample/crud/apps/main/services/jobs-demo-service.js +1 -1
- package/sample/crud/apps/main/views/jobs/index.ejs +9 -3
- package/sample/crud/docs/guide/05-scheduler-job-worker.md +26 -1
- package/src/cli/index.js +41 -0
package/package.json
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
import { currentUser } from '../middleware/web-auth.js'
|
|
9
9
|
|
|
10
10
|
/** 발송 시뮬레이션 모드 화이트리스트(폼 입력 검증). */
|
|
11
|
-
const MODES = ['ok', 'flaky', 'fail']
|
|
11
|
+
const MODES = ['ok', 'flaky', 'fail', 'hang']
|
|
12
|
+
/** 'hang' 모드 지연 상한(ms) — 폼 입력 검증(10분). 잡 클래스 timeoutMs(5s)와 별개의 입력 가드. */
|
|
13
|
+
const MAX_DELAY_MS = 600_000
|
|
12
14
|
/** 알림 쿼리(?notice=) → 로케일 키 화이트리스트. */
|
|
13
15
|
const NOTICE_KEYS = new Set(['enqueued'])
|
|
14
16
|
|
|
@@ -37,7 +39,25 @@ export class JobsController {
|
|
|
37
39
|
const to = rawTo.length > 0 ? rawTo : 'demo@example.com'
|
|
38
40
|
// 잡 식별자 — 시도 카운터/이벤트 키에 쓰인다. 같은 페이로드 중복 enqueue 도 서로 구분되게 유니크하게 만든다.
|
|
39
41
|
const id = `email-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
|
40
|
-
|
|
42
|
+
/** @type {{ id: string, to: string, mode: string, delayMs?: number }} */
|
|
43
|
+
const payload = { id, to, mode }
|
|
44
|
+
// hang 모드만 delayMs 를 싣는다. 미입력·무효(<=0)면 생략 → 잡 클래스가 기본 지연(8s)으로 timeout 을 시연.
|
|
45
|
+
if (mode === 'hang') {
|
|
46
|
+
const delayMs = JobsController.#parseDelayMs(body.delayMs)
|
|
47
|
+
if (delayMs > 0) payload.delayMs = delayMs
|
|
48
|
+
}
|
|
49
|
+
await ctx.services.jobsDemo.enqueue(payload)
|
|
41
50
|
return reply.redirect('/demo/jobs?notice=enqueued')
|
|
42
51
|
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 폼 delayMs(문자열) → [0, MAX_DELAY_MS] 정수. 비정수·음수는 0 으로, 초과는 상한으로 보정한다. 폼 POST 라
|
|
55
|
+
* AJV body 스키마 대신 컨트롤러에서 검증한다(기존 mode/to 처리와 동일 스타일 — _csrf/AJV 순서 이슈 회피).
|
|
56
|
+
* @param {unknown} raw @returns {number} 0 = 미지정(잡 클래스 기본 지연 사용).
|
|
57
|
+
*/
|
|
58
|
+
static #parseDelayMs(raw) {
|
|
59
|
+
const n = Number.parseInt(typeof raw === 'string' ? raw : String(raw ?? ''), 10)
|
|
60
|
+
if (!Number.isFinite(n) || n <= 0) return 0
|
|
61
|
+
return Math.min(n, MAX_DELAY_MS)
|
|
62
|
+
}
|
|
43
63
|
}
|
|
@@ -6,6 +6,24 @@ const FLAKY_SUCCEED_ON = 2
|
|
|
6
6
|
/** 시도 카운터 키 TTL(초) — 데모 잡은 짧게 살고 사라진다. */
|
|
7
7
|
const ATTEMPT_TTL = 600
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* EmailJob run 전체(재시도 포함) 실행 상한(ms) — 'hang' 모드 timeout 시연용 backstop. run 이 이 값을 넘게
|
|
11
|
+
* 돌면 MegaJobQueue 가 timeout 으로 판정해 잡을 실패시키고 DLQ 로 보낸다. ok/flaky/fail 의 실 실행 시간
|
|
12
|
+
* (< 3s — fail 의 백오프 최악치 ~3s 포함)보다 충분히 크게 둬(5s) 기존 모드엔 영향이 없게 한다.
|
|
13
|
+
*/
|
|
14
|
+
const RUN_TIMEOUT_MS = 5000
|
|
15
|
+
/** 'hang' 모드 기본 지연(ms) — RUN_TIMEOUT_MS 보다 커서 그냥 enqueue 해도 timeout 을 시연한다. */
|
|
16
|
+
const HANG_DEFAULT_DELAY_MS = 8000
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 지정 ms 만큼 멈춘다('hang' 모드 시뮬레이션). 프레임워크는 timeout 시 진행 중 run 을 **중단하지 않으므로**
|
|
20
|
+
* (abort 없음 — run 은 멱등 설계), 이 sleep 은 timeout 후에도 백그라운드에서 끝까지 흐른다.
|
|
21
|
+
* @param {number} ms @returns {Promise<void>}
|
|
22
|
+
*/
|
|
23
|
+
function sleep(ms) {
|
|
24
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
25
|
+
}
|
|
26
|
+
|
|
9
27
|
/**
|
|
10
28
|
* EmailJob — /demo/jobs 데모 잡(ADR-028/119). `mega worker` 프로세스(ecosystem instances:2)가 소비한다
|
|
11
29
|
* (config.jobs). 실제 메일은 보내지 않고 발송을 **시뮬레이션**하며, payload.mode 로 세 가지 흐름을 시연한다:
|
|
@@ -14,6 +32,10 @@ const ATTEMPT_TTL = 600
|
|
|
14
32
|
* - `flaky` : 1번째 시도는 throw(일시 실패) → MegaJobQueue 가 재시도(p-retry, static retries/backoff) →
|
|
15
33
|
* 2번째 시도에 성공. "일시 오류는 그냥 throw 하면 재시도된다"(MegaJob 정본)를 보여준다.
|
|
16
34
|
* - `fail` : 매 시도 throw(영구 실패) → 재시도 소진 후 DLQ(`<subject>.dlq`)로 격리된다.
|
|
35
|
+
* - `hang` : payload.delayMs 만큼 잠든다(끝나지 않는 잡 시뮬레이션). delayMs 가 `static timeoutMs`(5s)를
|
|
36
|
+
* 넘으면 MegaJobQueue 가 run 전체에 건 상한을 초과로 판정해 잡을 실패시키고 DLQ 로 보낸다.
|
|
37
|
+
* timeout 후에도 run 은 백그라운드에서 계속 흐르므로(abort 없음) delayMs 뒤 'sent' 가 뒤늦게
|
|
38
|
+
* 남는다 — "run 은 멱등하게 설계하라"는 정본 경고를 눈으로 보여준다.
|
|
17
39
|
*
|
|
18
40
|
* 각 시도를 'demo' 캐시(redis)에 기록해(시도 카운터 + 이벤트 LIST) 웹 페이지가 처리 타임라인을 보여준다.
|
|
19
41
|
* NATS durable consumer group(같은 durable 이름)이라 instances:2 워커가 메시지를 자연 분산 처리한다(중복 X).
|
|
@@ -25,6 +47,9 @@ export class EmailJob extends MegaJob {
|
|
|
25
47
|
// 추가 재시도 2회(첫 시도 포함 최대 3회). 데모 체감용으로 백오프를 짧게 둔다(기본 1s~30s 대신 0.5s~2s).
|
|
26
48
|
static retries = 2
|
|
27
49
|
static backoff = { type: 'exponential', initial: 500, max: 2000 }
|
|
50
|
+
// run 전체(재시도 포함) 상한 — 'hang' 모드 timeout 시연(미지정이면 큐 디폴트 30분이라 체감 불가). ok/flaky/
|
|
51
|
+
// fail 은 < 3s 라 영향 없다(fail 백오프 최악치 ~3s + 마진). 초과 run 은 timeout 실패 → DLQ.
|
|
52
|
+
static timeoutMs = RUN_TIMEOUT_MS
|
|
28
53
|
|
|
29
54
|
/** 이벤트 LIST 키(LPUSH → 최신이 앞). 웹이 LRANGE 로 읽는다. */
|
|
30
55
|
static EVENTS_KEY = 'demo:jobs:events'
|
|
@@ -33,7 +58,7 @@ export class EmailJob extends MegaJob {
|
|
|
33
58
|
|
|
34
59
|
/**
|
|
35
60
|
* 메시지 1건 처리. throw 하면 MegaJobQueue 가 재시도/DLQ 를 담당하므로 일시·영구 실패는 그냥 throw 한다.
|
|
36
|
-
* @param {{ id: string, to: string, subject?: string, mode: 'ok'|'flaky'|'fail' }} payload
|
|
61
|
+
* @param {{ id: string, to: string, subject?: string, mode: 'ok'|'flaky'|'fail'|'hang', delayMs?: number }} payload
|
|
37
62
|
* @param {Record<string, any>} ctx - worker 프로세스 컨텍스트(ctx.cache('demo') 글로벌 키).
|
|
38
63
|
* @returns {Promise<{ id: string, status: 'sent', attempt: number }>}
|
|
39
64
|
* @throws {Error} flaky 의 1번째 시도, fail 의 모든 시도 — 재시도/DLQ 트리거.
|
|
@@ -46,6 +71,16 @@ export class EmailJob extends MegaJob {
|
|
|
46
71
|
await redis.expire(`demo:jobs:attempt:${id}`, ATTEMPT_TTL)
|
|
47
72
|
ctx.log?.debug?.({ id, mode, attempt }, 'email-job.run')
|
|
48
73
|
|
|
74
|
+
if (mode === 'hang') {
|
|
75
|
+
// 오래 끄는 잡 — delayMs 가 static timeoutMs(5s)를 넘으면 큐가 run 상한 초과로 판정해 잡을 실패시키고
|
|
76
|
+
// DLQ 로 보낸다(fail(phase:'run') + dlq, ADR-223 로깅으로 표면화). timeout 후에도 이 run 은 백그라운드
|
|
77
|
+
// 에서 계속 흘러(abort 없음) delayMs 뒤 'sent' 를 뒤늦게 남긴다 — 멱등 설계 경고를 눈으로 보여준다.
|
|
78
|
+
const delayMs = Number.isInteger(payload.delayMs) ? /** @type {number} */ (payload.delayMs) : HANG_DEFAULT_DELAY_MS
|
|
79
|
+
await EmailJob.#logEvent(redis, { id, to, mode, attempt, status: 'running' })
|
|
80
|
+
await sleep(delayMs)
|
|
81
|
+
await EmailJob.#logEvent(redis, { id, to, mode, attempt, status: 'sent' })
|
|
82
|
+
return { id, status: 'sent', attempt }
|
|
83
|
+
}
|
|
49
84
|
if (mode === 'flaky' && attempt < FLAKY_SUCCEED_ON) {
|
|
50
85
|
await EmailJob.#logEvent(redis, { id, to, mode, attempt, status: 'retry' })
|
|
51
86
|
throw new Error(`EmailJob ${id}: simulated transient failure on attempt ${attempt} (will retry)`)
|
|
@@ -62,7 +97,7 @@ export class EmailJob extends MegaJob {
|
|
|
62
97
|
/**
|
|
63
98
|
* 처리 이벤트 1건을 redis LIST 머리에 넣고 최근 N건만 남긴다.
|
|
64
99
|
* @param {any} redis - ioredis 핸들(ctx.cache('demo').native).
|
|
65
|
-
* @param {{ id: string, to: string, mode: string, attempt: number, status: 'retry'|'failed'|'sent' }} event
|
|
100
|
+
* @param {{ id: string, to: string, mode: string, attempt: number, status: 'retry'|'failed'|'sent'|'running' }} event
|
|
66
101
|
* @returns {Promise<void>}
|
|
67
102
|
*/
|
|
68
103
|
static async #logEvent(redis, event) {
|
|
@@ -251,12 +251,16 @@
|
|
|
251
251
|
"jobs_reload": "Reload",
|
|
252
252
|
"jobs_field_to": "Recipient",
|
|
253
253
|
"jobs_field_mode": "Mode",
|
|
254
|
+
"jobs_field_delay": "Delay (ms) — hang mode only",
|
|
255
|
+
"jobs_field_delay_hint": "Leave blank for the default 8000ms. Exceeding 5000ms (timeoutMs) isolates the job to the DLQ on timeout.",
|
|
254
256
|
"jobs_mode_ok": "Success",
|
|
255
257
|
"jobs_mode_flaky": "Retry",
|
|
256
258
|
"jobs_mode_fail": "Permanent failure",
|
|
259
|
+
"jobs_mode_hang": "Timeout",
|
|
257
260
|
"jobs_mode_ok_hint": "succeeds on the first attempt",
|
|
258
261
|
"jobs_mode_flaky_hint": "1st attempt fails → retry → 2nd succeeds",
|
|
259
262
|
"jobs_mode_fail_hint": "every attempt fails → DLQ after retries are exhausted",
|
|
263
|
+
"jobs_mode_hang_hint": "hangs for delayMs → exceeds 5s (timeoutMs) → timeout → DLQ",
|
|
260
264
|
"jobs_dlq_title": "DLQ (isolated jobs)",
|
|
261
265
|
"jobs_dlq_desc": "Where permanently failed jobs land after retries are exhausted (a NATS stream). For analysis and reprocessing.",
|
|
262
266
|
"jobs_dlq_empty": "No jobs have reached the DLQ yet.",
|
|
@@ -274,6 +278,7 @@
|
|
|
274
278
|
"jobs_status_sent": "sent",
|
|
275
279
|
"jobs_status_retry": "retry",
|
|
276
280
|
"jobs_status_failed": "failed",
|
|
281
|
+
"jobs_status_running": "running",
|
|
277
282
|
"jobs_notice_enqueued": "Job enqueued. Once a worker processes it, it appears in the events below.",
|
|
278
283
|
"worker_title": "CPU worker demo (MegaWorker)",
|
|
279
284
|
"worker_subtitle": "Runs CPU-bound work like N rounds of SHA-256 in a worker_threads pool. A heartbeat confirms the server still answers other requests instantly while computing (main thread non-blocking).",
|
|
@@ -251,12 +251,16 @@
|
|
|
251
251
|
"jobs_reload": "새로고침",
|
|
252
252
|
"jobs_field_to": "받는 사람",
|
|
253
253
|
"jobs_field_mode": "모드",
|
|
254
|
+
"jobs_field_delay": "지연(ms) — hang 모드 전용",
|
|
255
|
+
"jobs_field_delay_hint": "비워두면 기본 8000ms. 5000ms(timeoutMs) 초과 시 timeout 으로 DLQ 격리됩니다.",
|
|
254
256
|
"jobs_mode_ok": "성공",
|
|
255
257
|
"jobs_mode_flaky": "재시도",
|
|
256
258
|
"jobs_mode_fail": "영구 실패",
|
|
259
|
+
"jobs_mode_hang": "타임아웃",
|
|
257
260
|
"jobs_mode_ok_hint": "첫 시도에 바로 성공",
|
|
258
261
|
"jobs_mode_flaky_hint": "1번째 시도 실패 → 재시도 → 2번째 성공",
|
|
259
262
|
"jobs_mode_fail_hint": "매 시도 실패 → 재시도 소진 후 DLQ 격리",
|
|
263
|
+
"jobs_mode_hang_hint": "delayMs 만큼 지연 → 5초(timeoutMs) 초과 시 timeout → DLQ",
|
|
260
264
|
"jobs_dlq_title": "DLQ (격리된 잡)",
|
|
261
265
|
"jobs_dlq_desc": "재시도를 모두 소진한 영구 실패 잡이 모이는 곳입니다(NATS 스트림). 원인 분석·재처리용.",
|
|
262
266
|
"jobs_dlq_empty": "아직 DLQ 로 간 잡이 없습니다.",
|
|
@@ -274,6 +278,7 @@
|
|
|
274
278
|
"jobs_status_sent": "발송됨",
|
|
275
279
|
"jobs_status_retry": "재시도",
|
|
276
280
|
"jobs_status_failed": "실패",
|
|
281
|
+
"jobs_status_running": "처리 중",
|
|
277
282
|
"jobs_notice_enqueued": "잡을 큐에 넣었습니다. 워커가 처리하면 아래 이벤트에 나타납니다.",
|
|
278
283
|
"worker_title": "CPU 워커 데모 (MegaWorker)",
|
|
279
284
|
"worker_subtitle": "SHA-256 N회 반복 같은 CPU-bound 작업을 worker_threads 풀에서 돌립니다. 계산 도중에도 서버가 다른 요청에 즉시 응답하는지(메인 스레드 non-block) 하트비트로 확인합니다.",
|
|
@@ -25,7 +25,7 @@ const DLQ_STREAM = `MEGA_JOBS_${EmailJob.subject.replace(/[^A-Za-z0-9_-]/g, '_')
|
|
|
25
25
|
export class JobsDemoService extends MegaService {
|
|
26
26
|
/**
|
|
27
27
|
* EmailJob 1건을 큐에 넣는다(JetStream publish — 서버에 영속 저장 후 워커가 가져감).
|
|
28
|
-
* @param {{ id: string, to: string, subject?: string, mode: 'ok'|'flaky'|'fail' }} payload
|
|
28
|
+
* @param {{ id: string, to: string, subject?: string, mode: 'ok'|'flaky'|'fail'|'hang', delayMs?: number }} payload
|
|
29
29
|
* @returns {Promise<{ seq: number, stream: string, duplicate: boolean }>}
|
|
30
30
|
*/
|
|
31
31
|
async enqueue(payload) {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
<% layout('layouts/main') %>
|
|
2
2
|
<%
|
|
3
3
|
function fmt(d) { return new Date(d).toLocaleString('ko-KR', { hour12: false }) }
|
|
4
|
-
function statusBadge(s) { return s === 'sent' ? 'text-bg-success' : (s === 'retry' ? 'text-bg-warning' : 'text-bg-danger') }
|
|
5
|
-
function modeBadge(m) { return m === 'ok' ? 'text-bg-success' : (m === 'flaky' ? 'text-bg-warning' : 'text-bg-danger') }
|
|
4
|
+
function statusBadge(s) { return s === 'sent' ? 'text-bg-success' : (s === 'retry' ? 'text-bg-warning' : (s === 'running' ? 'text-bg-info' : 'text-bg-danger')) }
|
|
5
|
+
function modeBadge(m) { return m === 'ok' ? 'text-bg-success' : (m === 'flaky' ? 'text-bg-warning' : (m === 'hang' ? 'text-bg-info' : 'text-bg-danger')) }
|
|
6
6
|
%>
|
|
7
7
|
|
|
8
8
|
<div class="mb-4">
|
|
@@ -39,9 +39,15 @@
|
|
|
39
39
|
<div class="form-text small">
|
|
40
40
|
<%= t('jobs_mode_ok', { defaultValue: '성공' }) %>: <%= t('jobs_mode_ok_hint', { defaultValue: '첫 시도에 바로 성공' }) %><br />
|
|
41
41
|
<%= t('jobs_mode_flaky', { defaultValue: '재시도' }) %>: <%= t('jobs_mode_flaky_hint', { defaultValue: '1번째 시도 실패 → 재시도 → 2번째 성공' }) %><br />
|
|
42
|
-
<%= t('jobs_mode_fail', { defaultValue: '영구 실패' }) %>: <%= t('jobs_mode_fail_hint', { defaultValue: '매 시도 실패 → 재시도 소진 후 DLQ 격리' })
|
|
42
|
+
<%= t('jobs_mode_fail', { defaultValue: '영구 실패' }) %>: <%= t('jobs_mode_fail_hint', { defaultValue: '매 시도 실패 → 재시도 소진 후 DLQ 격리' }) %><br />
|
|
43
|
+
<%= t('jobs_mode_hang', { defaultValue: '타임아웃' }) %>: <%= t('jobs_mode_hang_hint', { defaultValue: 'delayMs 만큼 지연 → 5초(timeoutMs) 초과 시 timeout → DLQ' }) %>
|
|
43
44
|
</div>
|
|
44
45
|
</div>
|
|
46
|
+
<div class="mb-3">
|
|
47
|
+
<label for="delayMs" class="form-label small"><%= t('jobs_field_delay', { defaultValue: '지연(ms) — hang 모드 전용' }) %></label>
|
|
48
|
+
<input type="number" class="form-control form-control-sm" id="delayMs" name="delayMs" min="0" max="600000" step="500" placeholder="8000" />
|
|
49
|
+
<div class="form-text small"><%= t('jobs_field_delay_hint', { defaultValue: '비워두면 기본 8000ms. 5000ms(timeoutMs) 초과 시 timeout 으로 DLQ 격리됩니다.' }) %></div>
|
|
50
|
+
</div>
|
|
45
51
|
<button type="submit" class="btn btn-primary btn-sm"><%= t('jobs_enqueue_btn', { defaultValue: '큐에 넣기' }) %></button>
|
|
46
52
|
<a href="/demo/jobs" class="btn btn-outline-secondary btn-sm ms-1"><%= t('jobs_reload', { defaultValue: '새로고침' }) %></a>
|
|
47
53
|
</form>
|
|
@@ -139,12 +139,17 @@ export class EmailJob extends MegaJob {
|
|
|
139
139
|
static concurrency = 2
|
|
140
140
|
static retries = 2 // 추가 재시도 2회 (첫 시도 포함 최대 3회)
|
|
141
141
|
static backoff = { type: 'exponential', initial: 500, max: 2000 }
|
|
142
|
+
static timeoutMs = 5000 // run 전체 상한 — 초과 시 timeout → DLQ (2-6)
|
|
142
143
|
|
|
143
144
|
async run(payload, ctx) {
|
|
144
145
|
const { id, to, mode } = payload
|
|
145
146
|
const redis = ctx.cache('demo').native
|
|
146
147
|
const attempt = await redis.incr(`demo:jobs:attempt:${id}`)
|
|
147
148
|
|
|
149
|
+
if (mode === 'hang') {
|
|
150
|
+
await new Promise((r) => setTimeout(r, payload.delayMs ?? 8000)) // 5s(timeoutMs) 초과 → timeout
|
|
151
|
+
return { id, status: 'sent', attempt } // timeout 후 백그라운드에서 늦게 완료
|
|
152
|
+
}
|
|
148
153
|
if (mode === 'flaky' && attempt < 2) {
|
|
149
154
|
throw new Error(`transient failure on attempt ${attempt} (will retry)`) // 재시도 트리거
|
|
150
155
|
}
|
|
@@ -163,6 +168,7 @@ export class EmailJob extends MegaJob {
|
|
|
163
168
|
| `concurrency` | `1` | 동시 처리 메시지 수(consumer `max_ack_pending`). **양의 정수만** — 0/음수는 NATS 가 "무제한" 으로 해석하는 풋건이라 거부. ⚠️ **워커 그룹(전 인스턴스) 합산 상한** — 인스턴스를 늘려도 합산 in-flight 가 이 값을 못 넘으므로 처리량 증설 시 함께 키울 것 |
|
|
164
169
|
| `retries` | `3` | run 실패 시 **추가** 재시도 횟수(첫 시도 제외) |
|
|
165
170
|
| `backoff` | `{ type:'exponential', initial:1000, max:30000 }` | 지수 백오프. **현재 `'exponential'` 만 지원**, `initial`/`max` 는 ms |
|
|
171
|
+
| `timeoutMs` | 큐 디폴트(30분) | run 전체(재시도 포함) 실행 상한(ms). 초과 시 timeout 실패 → DLQ. `0` = 무제한. 행(hang)된 잡이 lease 를 영구 갱신하며 메시지를 점유하는 것을 막는 backstop (2-6) |
|
|
166
172
|
|
|
167
173
|
> **핵심**: 일시·영구 오류는 그냥 `throw` 하면 된다. `MegaJobQueue` 가 `static retries`/`static backoff` 만큼 인프로세스 지수 백오프(p-retry, factor=2·jitter on 고정)로 재시도하고, 모두 실패하면 DLQ 로 보낸다.
|
|
168
174
|
|
|
@@ -224,6 +230,25 @@ const info = await jsm.streams.info('MEGA_JOBS_demo_email_DLQ') // count = inf
|
|
|
224
230
|
const msg = await jsm.streams.getMessage(DLQ_STREAM, { last_by_subj: 'demo.email.dlq' })
|
|
225
231
|
```
|
|
226
232
|
|
|
233
|
+
### 2-6. timeoutMs — 행(hang)된 잡 backstop
|
|
234
|
+
|
|
235
|
+
`run()` 이 실패(throw)는 안 하는데 **끝나지도 않으면**(외부 호출이 hang, 무한 루프 등) `working()` 하트비트가 ack lease 를 영원히 갱신해 메시지가 재전달도 DLQ 도 못 가는 **영구 점유**가 된다. `static timeoutMs`(또는 큐 디폴트 30분)가 이 backstop이다 — run 전체(재시도 포함)에 상한을 걸어 초과하면 잡을 **실패로 판정**해 DLQ 로 라우팅한다.
|
|
236
|
+
|
|
237
|
+
`/demo/jobs` 의 **`hang` 모드**가 이를 시연한다. `delayMs`(기본 8000ms)만큼 자는 잡을 enqueue하면, EmailJob 의 `static timeoutMs = 5000` 을 넘는 순간 큐가 timeout으로 판정한다:
|
|
238
|
+
|
|
239
|
+
```
|
|
240
|
+
WARN job ... (없음 — hang 은 throw 안 함, retry 이벤트 없음)
|
|
241
|
+
ERROR job failed subject=demo.email phase=run err=… exceeded run timeout (5000ms) …
|
|
242
|
+
WARN job routed to DLQ subject=demo.email dlqSubject=demo.email.dlq
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
- DLQ 봉투의 `error.message` = `exceeded run timeout (5000ms)` — 페이지 DLQ 카드에 표시된다.
|
|
246
|
+
- **⚠️ timeout 후에도 진행 중 run 은 중단되지 않는다**(abort 없음 — JS 는 협조적 취소만 가능). hang 잡은 백그라운드에서 `delayMs` 뒤 끝까지 흘러 'sent' 이벤트를 **뒤늦게** 남긴다. 그래서 **run 은 멱등(idempotent)하게 설계**해야 한다 — 같은 잡이 timeout 후 백그라운드 완료 + (max_deliver 재전달 시) 재실행돼도 안전하도록.
|
|
247
|
+
- 진짜 backstop은 `static timeoutMs` 가 아니라도 큐 디폴트(30분)로 항상 걸려 있다. 데모는 체감을 위해 5s 로 줄였을 뿐이다(`0` = 무제한으로 끌 수 있으나 행 잡 점유 위험을 떠안는다).
|
|
248
|
+
- 만약 abandoned된(timeout 패배) run 이 나중에 **throw** 하면, 그 늦은 실패는 `fail(phase:'abandoned-run')` 이벤트로 별도 표면화된다(잡은 이미 DLQ 라우팅됐으므로 처리 흐름엔 영향 없음).
|
|
249
|
+
|
|
250
|
+
위 워커 로그는 호스트(`mega worker`)가 잡 이벤트를 구독해 남긴 것이다(ADR-223) — `fail`=error, `dlq`/`retry`=warn, `start`/`done`=debug.
|
|
251
|
+
|
|
227
252
|
---
|
|
228
253
|
|
|
229
254
|
## 3. Worker (worker_threads, MegaWorker, ADR-124)
|
|
@@ -337,7 +362,7 @@ module.exports = {
|
|
|
337
362
|
}
|
|
338
363
|
```
|
|
339
364
|
|
|
340
|
-
잡 메트릭(ADR-132)은 `mega worker` 호스트가 `MegaMetrics.subscribeJobs(worker)` 로 구독한다 — `health.exposeMetrics` 옵트인 시 enqueue(dispatch)부터 done/retry/fail/dlq 까지 Prometheus 로 집계되고, 옵트인 OFF 면 no-op 이다.
|
|
365
|
+
잡 메트릭(ADR-132)은 `mega worker` 호스트가 `MegaMetrics.subscribeJobs(worker)` 로 구독한다 — `health.exposeMetrics` 옵트인 시 enqueue(dispatch)부터 done/retry/fail/dlq 까지 Prometheus 로 집계되고, 옵트인 OFF 면 no-op 이다. 메트릭과 **독립적으로**, 호스트는 같은 이벤트를 **로그로도** 남긴다(ADR-223) — `fail`=error, `dlq`/`retry`=warn, `start`/`done`=debug. 메트릭을 꺼도 잡 실패·DLQ 격리는 로그에 남는다.
|
|
341
366
|
|
|
342
367
|
---
|
|
343
368
|
|
package/src/cli/index.js
CHANGED
|
@@ -691,6 +691,39 @@ async function wireHostLogger(global, injectedLogger) {
|
|
|
691
691
|
return appLogger
|
|
692
692
|
}
|
|
693
693
|
|
|
694
|
+
/**
|
|
695
|
+
* 잡 워커의 큐 길목 이벤트를 호스트 로거로 흘린다(ADR-223). `MegaJobWorker` 는 순수 런타임이라 logger 를
|
|
696
|
+
* 모르고 이벤트만 재방출하므로, 호스트가 구독하지 않으면 production 에서 잡 실패·DLQ 격리가 로그에 안 남는다.
|
|
697
|
+
* 레벨: `fail`=error(최종 실패), `dlq`=warn(격리됨), `retry`=warn(재시도), `start`/`done`=debug(prod silent).
|
|
698
|
+
* `dispatch`(enqueue)는 producer(웹) 측 이벤트라 워커 호스트에선 발화하지 않아 구독하지 않는다. 핵심 식별
|
|
699
|
+
* 필드만 박는다(전체 페이로드·결과 금지, P5). `log` 가 없으면(로거 미구성) 옵셔널 체이닝으로 no-op.
|
|
700
|
+
* @param {import('../lib/mega-job-worker.js').MegaJobWorker} worker
|
|
701
|
+
* @param {{ debug?: Function, warn?: Function, error?: Function }|null|undefined} log
|
|
702
|
+
* @returns {void}
|
|
703
|
+
*/
|
|
704
|
+
function subscribeJobLogging(worker, log) {
|
|
705
|
+
worker.on('fail', (e) => log?.error?.({ err: e?.error, subject: e?.subject, seq: e?.seq, phase: e?.phase }, 'job failed'))
|
|
706
|
+
worker.on('dlq', (e) => log?.warn?.({ subject: e?.subject, dlqSubject: e?.dlqSubject, seq: e?.seq }, 'job routed to DLQ'))
|
|
707
|
+
worker.on('retry', (e) => log?.warn?.({ err: e?.error, subject: e?.subject, seq: e?.seq, attempt: e?.attempt, retriesLeft: e?.retriesLeft }, 'job retry'))
|
|
708
|
+
worker.on('start', (e) => log?.debug?.({ subject: e?.subject, seq: e?.seq }, 'job start'))
|
|
709
|
+
worker.on('done', (e) => log?.debug?.({ subject: e?.subject, seq: e?.seq }, 'job done'))
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* 스케줄러의 길목 이벤트를 호스트 로거로 흘린다(ADR-223 — 잡 워커와 같은 갭). `MegaScheduler` 도 이벤트
|
|
714
|
+
* (run/skip/done/fail)만 노출하므로 호스트가 구독해 로그로 남긴다. 레벨: `fail`=error, 나머지는 debug
|
|
715
|
+
* (특히 `skip`=락 미획득은 클러스터 정상 동작이라 debug). `key`(락 키)는 클러스터 leader 추적에 유용.
|
|
716
|
+
* @param {import('../lib/mega-schedule.js').MegaScheduler} scheduler
|
|
717
|
+
* @param {{ debug?: Function, warn?: Function, error?: Function }|null|undefined} log
|
|
718
|
+
* @returns {void}
|
|
719
|
+
*/
|
|
720
|
+
function subscribeScheduleLogging(scheduler, log) {
|
|
721
|
+
scheduler.on('fail', (e) => log?.error?.({ err: e?.error, name: e?.name, key: e?.key, phase: e?.phase }, 'schedule failed'))
|
|
722
|
+
scheduler.on('skip', (e) => log?.debug?.({ name: e?.name, key: e?.key, reason: e?.reason }, 'schedule skipped'))
|
|
723
|
+
scheduler.on('run', (e) => log?.debug?.({ name: e?.name, key: e?.key }, 'schedule run'))
|
|
724
|
+
scheduler.on('done', (e) => log?.debug?.({ name: e?.name, key: e?.key }, 'schedule done'))
|
|
725
|
+
}
|
|
726
|
+
|
|
694
727
|
/**
|
|
695
728
|
* `mega worker` 호스트 골격 — config + 어댑터 connect + ctx + `MegaJobWorker` 인스턴스 + graceful.
|
|
696
729
|
* 등록 소스 = `config.jobs` + 플러그인 `mega.jobs.register` 분(`collectRegistrations`, ADR-123).
|
|
@@ -712,6 +745,11 @@ export async function runWorkerHost(projectRoot, logger) {
|
|
|
712
745
|
// 옵트인 OFF 면 subscribeJobs 는 no-op() 을 반환한다. start 전에 구독해 enqueue(dispatch)부터 잡는다. 구독은
|
|
713
746
|
// MegaMetrics.shutdown(boot 가 'mega-metrics' MegaShutdown hook 으로 등록)에서 일괄 해제되므로 별도 등록 불필요.
|
|
714
747
|
MegaMetrics.subscribeJobs(worker)
|
|
748
|
+
// 잡 길목 로깅 (ADR-223) — MegaJobWorker 는 큐 이벤트를 재방출만 하고 logger 를 모른다(순수 런타임). 호스트가
|
|
749
|
+
// 구독해 로그로 남기지 않으면 production 에서 잡 영구 실패·DLQ 격리가 로그에 0 줄로 남아(메트릭/데모 페이지로만
|
|
750
|
+
// 보임) 운영자가 실패 신호를 못 받는다(mega-job-queue 설계: "fail/dlq 구독이 사실상 필수"). P5(에러 핸들러·
|
|
751
|
+
// async 경계 로그). 메트릭(subscribeJobs)과 독립적이라 둘 다 둔다. 페이로드 전체가 아닌 핵심 필드만 박는다(P5).
|
|
752
|
+
subscribeJobLogging(worker, log)
|
|
715
753
|
// 등록 소스(ADR-123) = config.jobs(정적) + 플러그인 host.listJobs()(동적). register 가 subject/bus
|
|
716
754
|
// 미선언·중복을 부팅 시 fail-fast.
|
|
717
755
|
const jobs = collectRegistrations(global, host, 'jobs')
|
|
@@ -740,6 +778,9 @@ export async function runSchedulerHost(projectRoot, logger) {
|
|
|
740
778
|
const { global, host, ctx } = await prepareRuntime(projectRoot, { ping: false, logger })
|
|
741
779
|
const log = await wireHostLogger(global, logger)
|
|
742
780
|
const scheduler = new MegaScheduler({ ctx })
|
|
781
|
+
// 스케줄 길목 로깅 (ADR-223) — 워커와 같은 갭: MegaScheduler 도 이벤트(run/skip/done/fail)를 노출만 하고
|
|
782
|
+
// logger 를 모른다. 호스트가 구독하지 않으면 production 에서 스케줄 실패가 로그에 안 남는다. P5.
|
|
783
|
+
subscribeScheduleLogging(scheduler, log)
|
|
743
784
|
// 등록 소스(ADR-123) = config.schedules(정적) + 플러그인 host.listSchedules()(동적). register 가
|
|
744
785
|
// cron 미선언·중복을 부팅 시 fail-fast.
|
|
745
786
|
const schedules = collectRegistrations(global, host, 'schedules')
|