@gaonjs/async 0.2.0 → 0.2.2

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.
@@ -0,0 +1,143 @@
1
+ // @gaonjs/async · 스케줄러 (§7 M7 · line 838~856)
2
+ //
3
+ // 반복 작업은 `domain/schedule.ts` 한 파일에 선언한다. 크론을 몰라도 되는
4
+ // 자연스러운 API 를 기본으로, 크론식도 받는다. 실행 대상은 **항상 잡**이다
5
+ // (인라인 함수 금지 — 실패·재시도·관측을 잡 레이어로 일원화, line 853).
6
+ //
7
+ // export default schedule((s) => {
8
+ // s.every('10m', CleanupExpiredSessions)
9
+ // s.daily.at('04:00', SendDailyDigest)
10
+ // s.cron('0 9 * * 1', SendWeeklyReport)
11
+ // })
12
+ //
13
+ // 다중 인스턴스에서 같은 스케줄이 두 번 발행되지 않도록, NATS KV 리스로
14
+ // 리더를 선출해 리더 인스턴스만 틱을 발행한다(line 855 · leaseLeader 재사용).
15
+ // 발행 = 대상 잡의 `.later()` 큐잉이다.
16
+ import { leaseLeader } from './lease.js';
17
+ import { parseCron } from './cron.js';
18
+ import { parseDuration } from './duration.js';
19
+ /** 스케줄을 선언한다. 콜백에서 s.every/daily.at/cron 으로 항목을 등록한다. */
20
+ export function schedule(build) {
21
+ const entries = [];
22
+ const builder = {
23
+ every(interval, job) {
24
+ const intervalMs = parseDuration(interval);
25
+ if (intervalMs <= 0)
26
+ throw new Error(`[@gaonjs/async] every 간격은 0보다 커야 합니다: ${interval}`);
27
+ entries.push({ kind: 'every', intervalMs, job, label: `every ${interval} → ${job.name}` });
28
+ },
29
+ daily: {
30
+ at(hhmm, job) {
31
+ const m = /^(\d{1,2}):(\d{2})$/.exec(hhmm.trim());
32
+ if (!m) {
33
+ throw new Error(`[@gaonjs/async] daily.at 시각 형식이 잘못됐습니다: '${hhmm}'\n` + `→ 'HH:MM' 로 쓰세요 — 예: '04:00'.`);
34
+ }
35
+ const hh = Number(m[1]);
36
+ const mm = Number(m[2]);
37
+ if (hh > 23 || mm > 59) {
38
+ throw new Error(`[@gaonjs/async] daily.at 시각이 범위를 벗어났습니다: '${hhmm}'`);
39
+ }
40
+ entries.push({
41
+ kind: 'cron',
42
+ matcher: parseCron(`${mm} ${hh} * * *`),
43
+ job,
44
+ label: `daily ${hhmm} → ${job.name}`,
45
+ });
46
+ },
47
+ },
48
+ cron(expr, job) {
49
+ entries.push({ kind: 'cron', matcher: parseCron(expr), job, label: `cron '${expr}' → ${job.name}` });
50
+ },
51
+ };
52
+ build(builder);
53
+ return { entries };
54
+ }
55
+ const DEFAULT_TICK_MS = 1000;
56
+ /**
57
+ * 스케줄러를 시작한다. 리더로 선출된 인스턴스만 틱을 발행한다. 리더십을
58
+ * 잃으면 발행을 멈추고, 되찾으면 재개한다(중복 발행 없음).
59
+ */
60
+ export async function runScheduler(opts) {
61
+ const tickMs = opts.tickMs ?? DEFAULT_TICK_MS;
62
+ const nowFn = opts.now ?? (() => Date.now());
63
+ const emit = (e) => opts.onEvent?.(e);
64
+ // every 항목의 다음 발행 시각, cron 항목의 마지막 발행 분(중복 방지).
65
+ const nextEvery = new Map();
66
+ const lastCronMinute = new Map();
67
+ let leading = false;
68
+ let timer;
69
+ let stopped = false;
70
+ const resetSchedules = () => {
71
+ const now = nowFn();
72
+ nextEvery.clear();
73
+ lastCronMinute.clear();
74
+ for (const e of opts.def.entries) {
75
+ if (e.kind === 'every')
76
+ nextEvery.set(e, now + e.intervalMs);
77
+ }
78
+ };
79
+ const fire = async (job, label) => {
80
+ try {
81
+ await job.later();
82
+ emit({ kind: 'fired', job: job.name, label });
83
+ }
84
+ catch (err) {
85
+ emit({ kind: 'error', error: err instanceof Error ? err.message : String(err) });
86
+ }
87
+ };
88
+ const tick = async () => {
89
+ if (!leading || stopped)
90
+ return;
91
+ const now = nowFn();
92
+ const minute = Math.floor(now / 60000);
93
+ for (const e of opts.def.entries) {
94
+ if (e.kind === 'every') {
95
+ const due = nextEvery.get(e) ?? now + e.intervalMs;
96
+ if (now >= due) {
97
+ nextEvery.set(e, due + e.intervalMs);
98
+ await fire(e.job, e.label);
99
+ }
100
+ }
101
+ else {
102
+ // 분 경계 한 번만 발행: 이 분에 아직 안 쐈고 매처가 맞으면.
103
+ if (lastCronMinute.get(e) !== minute && e.matcher.matches(new Date(now))) {
104
+ lastCronMinute.set(e, minute);
105
+ await fire(e.job, e.label);
106
+ }
107
+ }
108
+ }
109
+ };
110
+ const loop = () => {
111
+ void tick().finally(() => {
112
+ if (!stopped)
113
+ timer = setTimeout(loop, tickMs);
114
+ });
115
+ };
116
+ const lease = await leaseLeader({
117
+ nats: opts.nats,
118
+ key: 'scheduler.leader',
119
+ id: opts.id,
120
+ ttlMs: opts.ttlMs,
121
+ onElected: () => {
122
+ leading = true;
123
+ resetSchedules();
124
+ emit({ kind: 'leader', leader: true });
125
+ },
126
+ onRevoked: () => {
127
+ leading = false;
128
+ emit({ kind: 'leader', leader: false });
129
+ },
130
+ });
131
+ loop();
132
+ return {
133
+ get isLeader() {
134
+ return lease.isLeader;
135
+ },
136
+ async stop() {
137
+ stopped = true;
138
+ if (timer)
139
+ clearTimeout(timer);
140
+ await lease.stop();
141
+ },
142
+ };
143
+ }
@@ -0,0 +1,37 @@
1
+ import type { GaonNats } from './nats.js';
2
+ export declare const JOBS_STREAM = "GAON_JOBS";
3
+ export declare const DLQ_STREAM = "GAON_JOBS_DLQ";
4
+ export declare const EVENTS_STREAM = "GAON_EVENTS";
5
+ /** 잡 subject 접두. 큐별로 `gaon.jobs.<queue>` 하나. */
6
+ export declare const JOBS_SUBJECT_PREFIX = "gaon.jobs";
7
+ /** DLQ 는 단일 subject. */
8
+ export declare const DLQ_SUBJECT = "gaon.dlq.jobs";
9
+ /** 이벤트 subject 접두. 이벤트명별 `gaon.events.<name>`. */
10
+ export declare const EVENTS_SUBJECT_PREFIX = "gaon.events";
11
+ /** 큐 이름 → 잡 subject. 큐 이름은 임의 문자열이라 인코딩한다(keys.ts 근거). */
12
+ export declare function jobSubject(queue: string): string;
13
+ /** 큐 컨슈머의 durable 이름. */
14
+ export declare function jobConsumerName(queue: string): string;
15
+ /** 이벤트명 → subject. */
16
+ export declare function eventSubject(name: string): string;
17
+ /** 리스너 컨슈머의 durable 이름(리스너 키별로 유일). */
18
+ export declare function listenerConsumerName(listenerId: string): string;
19
+ /** ms → JetStream nanos(정수). */
20
+ export declare function toNanos(ms: number): number;
21
+ export interface StreamTuning {
22
+ /** 잡 중복 제거 창(ms). 크래시 재처리 시 재적재 중복을 흡수한다. 기본 120000. */
23
+ readonly dedupeWindowMs?: number;
24
+ /** DLQ 보존 기간(ms). 기본 14일. */
25
+ readonly dlqMaxAgeMs?: number;
26
+ /** 이벤트 보존 기간(ms). 기본 7일. */
27
+ readonly eventsMaxAgeMs?: number;
28
+ }
29
+ /**
30
+ * 잡 스트림을 보장한다(워크큐 보존). ack 된 메시지는 즉시 삭제되므로
31
+ * 큐가 밀리지 않는다. 재적재 중복 방어를 위해 중복 제거 창을 둔다.
32
+ */
33
+ export declare function ensureJobsStream(nats: GaonNats, tuning?: StreamTuning): Promise<void>;
34
+ /** DLQ 스트림을 보장한다(한도 보존 — 사람이 확인·재적재할 때까지 유지). */
35
+ export declare function ensureDlqStream(nats: GaonNats, tuning?: StreamTuning): Promise<void>;
36
+ /** 이벤트 스트림을 보장한다(한도 보존). 리스너별 durable 컨슈머가 소비한다. */
37
+ export declare function ensureEventsStream(nats: GaonNats, tuning?: StreamTuning): Promise<void>;
@@ -0,0 +1,83 @@
1
+ // @gaonjs/async · JetStream 스트림·컨슈머 토폴로지 (§7 M7)
2
+ //
3
+ // 비동기 배터리의 세 스트림을 여기서 선언·보장한다:
4
+ // - GAON_JOBS 잡 큐. 워크큐 보존(ack 되면 삭제) — 큐별 컨슈머가
5
+ // 겹치지 않는 subject 필터로 소비한다.
6
+ // - GAON_JOBS_DLQ 최대 재시도를 소진한 잡. 한도 보존(사람이 확인·재적재).
7
+ // - GAON_EVENTS 도메인 이벤트 pub/sub. 리스너별 durable 컨슈머가
8
+ // 각자 소비하므로 한도 보존(interest 아님 — 리스너가
9
+ // 나중에 붙어도 과거 이벤트를 받게).
10
+ //
11
+ // 스트림 add 는 멱등이다(같은 설정으로 다시 부르면 no-op). 워커·enqueue
12
+ // 진입점에서 부팅 시 한 번 보장한다.
13
+ import { jetstreamManager } from '@nats-io/jetstream';
14
+ import { encodeSegment } from './keys.js';
15
+ export const JOBS_STREAM = 'GAON_JOBS';
16
+ export const DLQ_STREAM = 'GAON_JOBS_DLQ';
17
+ export const EVENTS_STREAM = 'GAON_EVENTS';
18
+ /** 잡 subject 접두. 큐별로 `gaon.jobs.<queue>` 하나. */
19
+ export const JOBS_SUBJECT_PREFIX = 'gaon.jobs';
20
+ /** DLQ 는 단일 subject. */
21
+ export const DLQ_SUBJECT = 'gaon.dlq.jobs';
22
+ /** 이벤트 subject 접두. 이벤트명별 `gaon.events.<name>`. */
23
+ export const EVENTS_SUBJECT_PREFIX = 'gaon.events';
24
+ /** 큐 이름 → 잡 subject. 큐 이름은 임의 문자열이라 인코딩한다(keys.ts 근거). */
25
+ export function jobSubject(queue) {
26
+ return `${JOBS_SUBJECT_PREFIX}.${encodeSegment(queue)}`;
27
+ }
28
+ /** 큐 컨슈머의 durable 이름. */
29
+ export function jobConsumerName(queue) {
30
+ return `gaon-jobs-${encodeSegment(queue)}`;
31
+ }
32
+ /** 이벤트명 → subject. */
33
+ export function eventSubject(name) {
34
+ return `${EVENTS_SUBJECT_PREFIX}.${encodeSegment(name)}`;
35
+ }
36
+ /** 리스너 컨슈머의 durable 이름(리스너 키별로 유일). */
37
+ export function listenerConsumerName(listenerId) {
38
+ return `gaon-listener-${encodeSegment(listenerId)}`;
39
+ }
40
+ /** ms → JetStream nanos(정수). */
41
+ export function toNanos(ms) {
42
+ return Math.round(ms * 1e6);
43
+ }
44
+ const DEFAULT_DEDUPE_MS = 120000;
45
+ const DEFAULT_DLQ_MAX_AGE_MS = 14 * 24 * 3600 * 1000;
46
+ const DEFAULT_EVENTS_MAX_AGE_MS = 7 * 24 * 3600 * 1000;
47
+ /**
48
+ * 잡 스트림을 보장한다(워크큐 보존). ack 된 메시지는 즉시 삭제되므로
49
+ * 큐가 밀리지 않는다. 재적재 중복 방어를 위해 중복 제거 창을 둔다.
50
+ */
51
+ export async function ensureJobsStream(nats, tuning = {}) {
52
+ const jsm = await jetstreamManager(nats.nc);
53
+ await jsm.streams.add({
54
+ name: JOBS_STREAM,
55
+ subjects: [`${JOBS_SUBJECT_PREFIX}.>`],
56
+ retention: 'workqueue',
57
+ discard: 'old',
58
+ duplicate_window: toNanos(tuning.dedupeWindowMs ?? DEFAULT_DEDUPE_MS),
59
+ });
60
+ }
61
+ /** DLQ 스트림을 보장한다(한도 보존 — 사람이 확인·재적재할 때까지 유지). */
62
+ export async function ensureDlqStream(nats, tuning = {}) {
63
+ const jsm = await jetstreamManager(nats.nc);
64
+ await jsm.streams.add({
65
+ name: DLQ_STREAM,
66
+ subjects: [DLQ_SUBJECT],
67
+ retention: 'limits',
68
+ max_age: toNanos(tuning.dlqMaxAgeMs ?? DEFAULT_DLQ_MAX_AGE_MS),
69
+ });
70
+ }
71
+ /** 이벤트 스트림을 보장한다(한도 보존). 리스너별 durable 컨슈머가 소비한다. */
72
+ export async function ensureEventsStream(nats, tuning = {}) {
73
+ const jsm = await jetstreamManager(nats.nc);
74
+ await jsm.streams.add({
75
+ name: EVENTS_STREAM,
76
+ subjects: [`${EVENTS_SUBJECT_PREFIX}.>`],
77
+ retention: 'limits',
78
+ max_age: toNanos(tuning.eventsMaxAgeMs ?? DEFAULT_EVENTS_MAX_AGE_MS),
79
+ // 중복 제거 창: emit 멱등(같은 event id 재발행)과 아웃박스 릴레이의
80
+ // "발행 후 표시 사이 크래시" 재발행을 흡수한다(at-least-once → 사실상 1회).
81
+ duplicate_window: toNanos(tuning.dedupeWindowMs ?? DEFAULT_DEDUPE_MS),
82
+ });
83
+ }
package/dist/work.d.ts ADDED
@@ -0,0 +1,52 @@
1
+ import type { Kysely } from 'kysely';
2
+ import type { GaonNats } from './nats.js';
3
+ import { type WorkerHandle, type WorkerEvent } from './worker.js';
4
+ import { type ListenerEvent } from './listeners.js';
5
+ import { type SchedulerEvent, type ScheduleDef } from './schedule.js';
6
+ import type { StreamTuning } from './streams.js';
7
+ export type WorkEvent = {
8
+ readonly kind: 'worker';
9
+ readonly event: WorkerEvent;
10
+ } | {
11
+ readonly kind: 'listener';
12
+ readonly event: ListenerEvent;
13
+ } | {
14
+ readonly kind: 'scheduler';
15
+ readonly event: SchedulerEvent;
16
+ } | {
17
+ readonly kind: 'relay';
18
+ readonly count: number;
19
+ } | {
20
+ readonly kind: 'ready';
21
+ readonly jobs: number;
22
+ readonly listeners: number;
23
+ readonly scheduled: number;
24
+ };
25
+ export interface WorkOptions {
26
+ readonly nats: GaonNats;
27
+ /** 인스턴스 식별자(스케줄러 리스 값). */
28
+ readonly id: string;
29
+ /** 아웃박스 릴레이용 DB(생략 시 릴레이 미기동). */
30
+ readonly db?: Kysely<any>;
31
+ /** 스케줄 정의(생략 시 스케줄러 미기동). */
32
+ readonly schedule?: ScheduleDef;
33
+ /** 큐 기본 동시성. */
34
+ readonly concurrency?: number;
35
+ /** ack 대기(ms) — 초과 시 크래시로 보고 재전달(크래시 복구). 기본 30000. */
36
+ readonly ackWaitMs?: number;
37
+ /** graceful drain 상한(ms). 워커·리스너 공통. 기본 30000. */
38
+ readonly drainTimeoutMs?: number;
39
+ /** 릴레이 폴링 주기(ms). */
40
+ readonly relayPollMs?: number;
41
+ readonly tuning?: StreamTuning;
42
+ onEvent?(e: WorkEvent): void;
43
+ }
44
+ export interface WorkHandle {
45
+ stop(): Promise<void>;
46
+ readonly worker: WorkerHandle;
47
+ }
48
+ /**
49
+ * 워커 런타임을 조립·기동한다. 호출 전에 도메인 파일(잡·리스너·스케줄)을
50
+ * import 해 레지스트리를 채워 두어야 한다(파일=등록).
51
+ */
52
+ export declare function runWork(opts: WorkOptions): Promise<WorkHandle>;
package/dist/work.js ADDED
@@ -0,0 +1,88 @@
1
+ // @gaonjs/async · 워커 프로세스 런타임 (§7 M7 · line 863~885)
2
+ //
3
+ // `gaon work` 가 띄우는 운영 런타임. 도메인의 잡·리스너·스케줄·아웃박스를
4
+ // 한 프로세스에 조립한다:
5
+ // - 잡 워커(runWorker) — 큐 소비·재시도·DLQ
6
+ // - 리스너(runListeners) — 이벤트 소비
7
+ // - 스케줄러(runScheduler) — 리더 선출 단일 발행(선택)
8
+ // - 아웃박스 릴레이(runOutboxRelay) — DB→NATS 발행(DB 있을 때만)
9
+ //
10
+ // Graceful drain: stop() 이 전 구성요소를 순서대로 내린다 — 스케줄러(신규
11
+ // 틱 중단·리더 반납) → 릴레이 → 리스너 → 워커(진행 잡 완료 대기). 워커·
12
+ // 리스너가 진행 중 작업을 상한까지 기다리므로, 종료가 진행 잡을 자르지 않는다.
13
+ import { configureJobs } from './jobs.js';
14
+ import { configureEvents } from './events.js';
15
+ import { runWorker } from './worker.js';
16
+ import { runListeners } from './listeners.js';
17
+ import { runScheduler } from './schedule.js';
18
+ import { runOutboxRelay, ensureOutboxTable } from './outbox.js';
19
+ import { registeredListeners } from './events.js';
20
+ import { registeredJobs } from './jobs.js';
21
+ /**
22
+ * 워커 런타임을 조립·기동한다. 호출 전에 도메인 파일(잡·리스너·스케줄)을
23
+ * import 해 레지스트리를 채워 두어야 한다(파일=등록).
24
+ */
25
+ export async function runWork(opts) {
26
+ const emit = (e) => opts.onEvent?.(e);
27
+ // enqueue·emit 전송 배선(재시도 재적재·잡→잡·즉시 발행 경로).
28
+ configureJobs(opts.nats, opts.tuning);
29
+ configureEvents(opts.nats, opts.tuning);
30
+ const worker = await runWorker({
31
+ nats: opts.nats,
32
+ concurrency: opts.concurrency,
33
+ ackWaitMs: opts.ackWaitMs,
34
+ drainTimeoutMs: opts.drainTimeoutMs,
35
+ tuning: opts.tuning,
36
+ onEvent: (event) => emit({ kind: 'worker', event }),
37
+ });
38
+ const listeners = registeredListeners().length
39
+ ? await runListeners({
40
+ nats: opts.nats,
41
+ ackWaitMs: opts.ackWaitMs,
42
+ drainTimeoutMs: opts.drainTimeoutMs,
43
+ tuning: opts.tuning,
44
+ onEvent: (event) => emit({ kind: 'listener', event }),
45
+ })
46
+ : undefined;
47
+ let relay;
48
+ if (opts.db) {
49
+ await ensureOutboxTable(opts.db);
50
+ relay = runOutboxRelay(opts.db, {
51
+ pollMs: opts.relayPollMs,
52
+ onRelayed: (count) => emit({ kind: 'relay', count }),
53
+ });
54
+ }
55
+ let scheduler;
56
+ if (opts.schedule && opts.schedule.entries.length > 0) {
57
+ scheduler = await runScheduler({
58
+ nats: opts.nats,
59
+ def: opts.schedule,
60
+ id: opts.id,
61
+ onEvent: (event) => emit({ kind: 'scheduler', event }),
62
+ });
63
+ }
64
+ emit({
65
+ kind: 'ready',
66
+ jobs: registeredJobs().length,
67
+ listeners: registeredListeners().length,
68
+ scheduled: opts.schedule?.entries.length ?? 0,
69
+ });
70
+ let stopped = false;
71
+ return {
72
+ worker,
73
+ async stop() {
74
+ if (stopped)
75
+ return;
76
+ stopped = true;
77
+ // 스케줄러 먼저 — 신규 틱 중단·리더 반납(다른 인스턴스가 즉시 승계).
78
+ if (scheduler)
79
+ await scheduler.stop();
80
+ if (relay)
81
+ await relay.stop();
82
+ // 리스너·워커는 진행 중 작업을 상한까지 기다린 뒤 내려간다.
83
+ if (listeners)
84
+ await listeners.stop();
85
+ await worker.stop();
86
+ },
87
+ };
88
+ }
@@ -0,0 +1,71 @@
1
+ import type { GaonNats } from './nats.js';
2
+ import { type StreamTuning } from './streams.js';
3
+ /** DLQ 레코드(사람이 확인·재적재). */
4
+ export interface DlqRecord {
5
+ readonly id: string;
6
+ readonly name: string;
7
+ readonly queue: string;
8
+ readonly args: readonly unknown[];
9
+ readonly attempts: number;
10
+ readonly error: string;
11
+ readonly failedAt: number;
12
+ readonly enqueuedAt: number;
13
+ }
14
+ export type WorkerEvent = {
15
+ readonly kind: 'processing';
16
+ readonly job: string;
17
+ readonly id: string;
18
+ readonly attempt: number;
19
+ } | {
20
+ readonly kind: 'succeeded';
21
+ readonly job: string;
22
+ readonly id: string;
23
+ } | {
24
+ readonly kind: 'retrying';
25
+ readonly job: string;
26
+ readonly id: string;
27
+ readonly attempt: number;
28
+ readonly delayMs: number;
29
+ } | {
30
+ readonly kind: 'deferred';
31
+ readonly job: string;
32
+ readonly id: string;
33
+ readonly delayMs: number;
34
+ } | {
35
+ readonly kind: 'dead';
36
+ readonly job: string;
37
+ readonly id: string;
38
+ readonly attempts: number;
39
+ readonly error: string;
40
+ } | {
41
+ readonly kind: 'skipped';
42
+ readonly name: string;
43
+ readonly id: string;
44
+ } | {
45
+ readonly kind: 'error';
46
+ readonly error: string;
47
+ };
48
+ export interface WorkerOptions {
49
+ readonly nats: GaonNats;
50
+ /** 큐별 기본 동시성. 잡 옵션의 concurrency 가 우선. 기본 1. */
51
+ readonly concurrency?: number;
52
+ /** ack 대기(ms) — 이 시간 안에 ack/nak 없으면 크래시로 보고 재전달. 기본 30000. */
53
+ readonly ackWaitMs?: number;
54
+ /** graceful drain 상한(ms). 기본 30000. */
55
+ readonly drainTimeoutMs?: number;
56
+ /** 스트림 튜닝(중복 창 등). */
57
+ readonly tuning?: StreamTuning;
58
+ /** 진행 이벤트 통지(로깅·테스트). */
59
+ onEvent?(e: WorkerEvent): void;
60
+ }
61
+ export interface WorkerHandle {
62
+ /** graceful drain: 신규 pull 중단 → 진행 잡 완료 대기 → 종료. */
63
+ stop(): Promise<void>;
64
+ /** 현재 처리 중(in-flight) 잡 수. */
65
+ readonly inflight: number;
66
+ }
67
+ /**
68
+ * 워커를 시작한다. registeredQueues() 의 각 큐에 컨슈머를 붙인다 —
69
+ * 호출 전에 잡 파일을 import 해 레지스트리를 채워 두어야 한다.
70
+ */
71
+ export declare function runWorker(opts: WorkerOptions): Promise<WorkerHandle>;
package/dist/worker.js ADDED
@@ -0,0 +1,179 @@
1
+ // @gaonjs/async · 잡 워커 (§7 M7 · line 858~886)
2
+ //
3
+ // `gaon work` 프로세스의 심장. 등록된 잡의 큐마다 JetStream pull 컨슈머를
4
+ // 만들어 메시지를 당겨 처리한다. 처리 규칙:
5
+ // - notBefore 미도래(지연·백오프 대기) → nak(남은 시간)로 정확히 그 시각에
6
+ // 재전달되게 미룬다(§7 line 792 "NAK 지연 기반").
7
+ // - 성공 → ack(워크큐 보존이라 삭제된다).
8
+ // - 실패 & 재시도 여분 있음 → attempt+1·notBefore=now+백오프 로 재적재 후 ack.
9
+ // - 실패 & 재시도 소진 → DLQ 로 옮기고 term(더 재전달 안 함).
10
+ // - 파싱 불가(포이즌) → 즉시 term.
11
+ //
12
+ // JetStream 의 네이티브 재전달(ack_wait·max_deliver)은 정상 재시도가 아니라
13
+ // **크래시 복구**용이다: 워커가 잡을 붙든 채 죽으면 ack_wait 만료로 스트림에
14
+ // 되돌아가 다른 워커가 다시 처리한다(at-least-once — 잡은 멱등 권장).
15
+ //
16
+ // Graceful drain(§7 line 885): 종료 시그널 → 신규 pull 중단 → 진행 잡 완료
17
+ // 대기(timeout) → 종료. 미완료 잡은 ack 타임아웃으로 스트림에 남는다.
18
+ import { runWithLogContext } from '@gaonjs/core';
19
+ import { jetstreamManager } from '@nats-io/jetstream';
20
+ import { JOBS_STREAM, DLQ_SUBJECT, jobConsumerName, jobSubject, toNanos, ensureJobsStream, ensureDlqStream, } from './streams.js';
21
+ import { encodePayloadBytes, decodePayloadBytes } from './codec.js';
22
+ import { backoffDelayMs } from './backoff.js';
23
+ import { configureJobs, getJob, registeredQueues, registeredJobs, enqueueRaw, DEFAULT_QUEUE, } from './jobs.js';
24
+ const DEFAULT_ACK_WAIT_MS = 30000;
25
+ const DEFAULT_DRAIN_MS = 30000;
26
+ /**
27
+ * 워커를 시작한다. registeredQueues() 의 각 큐에 컨슈머를 붙인다 —
28
+ * 호출 전에 잡 파일을 import 해 레지스트리를 채워 두어야 한다.
29
+ */
30
+ export async function runWorker(opts) {
31
+ const ackWaitMs = opts.ackWaitMs ?? DEFAULT_ACK_WAIT_MS;
32
+ const drainTimeoutMs = opts.drainTimeoutMs ?? DEFAULT_DRAIN_MS;
33
+ const emit = (e) => opts.onEvent?.(e);
34
+ // enqueue 전송도 워커에서 필요하다(재시도 재적재·잡→잡 큐잉).
35
+ configureJobs(opts.nats, opts.tuning);
36
+ await ensureJobsStream(opts.nats, opts.tuning);
37
+ await ensureDlqStream(opts.nats, opts.tuning);
38
+ const jsm = await jetstreamManager(opts.nats.nc);
39
+ const toDlq = async (rec) => {
40
+ await opts.nats.js.publish(DLQ_SUBJECT, encodePayloadBytes(rec), { msgID: `${rec.id}#dead` });
41
+ };
42
+ // 한 메시지 처리: notBefore·성공·재시도·DLQ 규칙을 적용한다.
43
+ const handleMessage = async (m) => {
44
+ let msg;
45
+ try {
46
+ msg = decodePayloadBytes(m.data);
47
+ }
48
+ catch {
49
+ // 포이즌(파싱 불가) — 무한 재전달을 막으려 즉시 폐기한다.
50
+ emit({ kind: 'skipped', name: '(unparseable)', id: '?' });
51
+ m.term();
52
+ return;
53
+ }
54
+ const now = Date.now();
55
+ if (now < msg.notBefore) {
56
+ // 아직 실행 시각 전 — 남은 시간만큼 정확히 미룬다(지연·백오프).
57
+ const delayMs = msg.notBefore - now;
58
+ emit({ kind: 'deferred', job: msg.name, id: msg.id, delayMs });
59
+ m.nak(delayMs);
60
+ return;
61
+ }
62
+ const def = getJob(msg.name);
63
+ if (!def) {
64
+ // 이 워커에 해당 잡이 등록돼 있지 않다 — 다른 워커 몫일 수 있으니
65
+ // 잠시 뒤 재전달되게 미룬다(포이즌 취급하지 않는다).
66
+ emit({ kind: 'skipped', name: msg.name, id: msg.id });
67
+ m.nak(1000);
68
+ return;
69
+ }
70
+ const attempt = msg.attempt + 1;
71
+ const maxAttempts = 1 + (def.options.retries ?? 3);
72
+ emit({ kind: 'processing', job: msg.name, id: msg.id, attempt });
73
+ // 긴 잡이 ack_wait 를 넘겨 조기 재전달되지 않게 주기적으로 working() 을
74
+ // 보내 리스를 연장한다.
75
+ const heartbeat = setInterval(() => {
76
+ try {
77
+ m.working();
78
+ }
79
+ catch {
80
+ // 이미 ack/term 된 뒤면 무시.
81
+ }
82
+ }, Math.max(1000, Math.floor(ackWaitMs / 2)));
83
+ try {
84
+ const run = def.handler;
85
+ // 잡 실행을 로그 컨텍스트로 감싼다(§7) — 잡 본문의 모든 log.* 에 잡
86
+ // 이름·상관 ID·attempt 가 자동으로 붙어 HTTP 요청과 같은 추적성을 준다.
87
+ await runWithLogContext({ requestId: msg.id, jobName: msg.name, attempt }, () => run(...msg.args));
88
+ clearInterval(heartbeat);
89
+ m.ack();
90
+ emit({ kind: 'succeeded', job: msg.name, id: msg.id });
91
+ }
92
+ catch (err) {
93
+ clearInterval(heartbeat);
94
+ const error = err instanceof Error ? err.message : String(err);
95
+ if (attempt < maxAttempts) {
96
+ const delayMs = backoffDelayMs(attempt, def.options);
97
+ // attempt·notBefore 를 올려 재적재하고 현재 메시지는 ack 로 치운다.
98
+ await enqueueRaw({ ...msg, attempt, notBefore: Date.now() + delayMs });
99
+ m.ack();
100
+ emit({ kind: 'retrying', job: msg.name, id: msg.id, attempt, delayMs });
101
+ }
102
+ else {
103
+ await toDlq({
104
+ id: msg.id,
105
+ name: msg.name,
106
+ queue: msg.queue,
107
+ args: msg.args,
108
+ attempts: attempt,
109
+ error,
110
+ failedAt: Date.now(),
111
+ enqueuedAt: msg.enqueuedAt,
112
+ });
113
+ m.term();
114
+ emit({ kind: 'dead', job: msg.name, id: msg.id, attempts: attempt, error });
115
+ }
116
+ }
117
+ };
118
+ // 큐마다 컨슈머를 붙이고 동시성 제한 소비 루프를 돈다.
119
+ const queues = registeredQueues();
120
+ if (queues.length === 0)
121
+ queues.push(DEFAULT_QUEUE);
122
+ const closers = [];
123
+ const inflightAll = new Set();
124
+ for (const queue of queues) {
125
+ const durable = jobConsumerName(queue);
126
+ // 큐 동시성: 이 큐에 속한 잡 중 최댓값(없으면 워커 기본).
127
+ const declared = registeredJobs()
128
+ .filter((d) => d.queue === queue)
129
+ .map((d) => d.options.concurrency ?? 0);
130
+ const perQueueConc = Math.max(0, ...declared) || (opts.concurrency ?? 1);
131
+ await jsm.consumers.add(JOBS_STREAM, {
132
+ durable_name: durable,
133
+ filter_subject: jobSubject(queue),
134
+ ack_policy: 'explicit',
135
+ ack_wait: toNanos(ackWaitMs),
136
+ // 정상 재시도는 우리가 재적재로 처리한다. 네이티브 재전달은 크래시
137
+ // 복구용이라 상한만 둔다(포이즌 크래시 루프 방지).
138
+ max_deliver: 25,
139
+ max_ack_pending: 1000,
140
+ });
141
+ const consumer = await opts.nats.js.consumers.get(JOBS_STREAM, durable);
142
+ const messages = await consumer.consume({ max_messages: perQueueConc });
143
+ const loop = (async () => {
144
+ for await (const m of messages) {
145
+ const p = handleMessage(m).finally(() => inflightAll.delete(p));
146
+ inflightAll.add(p);
147
+ if (inflightAll.size >= perQueueConc)
148
+ await Promise.race(inflightAll);
149
+ }
150
+ })();
151
+ closers.push(async () => {
152
+ // 신규 pull 중단(진행 중인 in-flight 는 아래에서 대기).
153
+ messages.stop();
154
+ await loop.catch(() => { });
155
+ });
156
+ }
157
+ let stopped = false;
158
+ return {
159
+ get inflight() {
160
+ return inflightAll.size;
161
+ },
162
+ async stop() {
163
+ if (stopped)
164
+ return;
165
+ stopped = true;
166
+ // 1) 신규 pull 중단.
167
+ for (const close of closers)
168
+ await close();
169
+ // 2) 진행 잡 완료 대기(상한). 남으면 ack_wait 로 스트림에 되돌아간다.
170
+ const deadline = Date.now() + drainTimeoutMs;
171
+ while (inflightAll.size > 0 && Date.now() < deadline) {
172
+ await Promise.race([...inflightAll, delay(200)]);
173
+ }
174
+ },
175
+ };
176
+ }
177
+ function delay(ms) {
178
+ return new Promise((r) => setTimeout(r, ms));
179
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaonjs/async",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Gaon NATS 통합: 잡·이벤트·스케줄러·채널(ws)·허브(프레즌스) (구현 예정)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,7 +27,8 @@
27
27
  "@nats-io/transport-node": "^3.1.0",
28
28
  "@nats-io/jetstream": "^3.1.0",
29
29
  "@nats-io/kv": "^3.1.0",
30
- "@gaonjs/core": "0.1.3"
30
+ "kysely": "^0.29.4",
31
+ "@gaonjs/core": "0.1.4"
31
32
  },
32
33
  "scripts": {
33
34
  "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json"