@gaonjs/async 0.1.3 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/__fixtures__/testnats.d.ts +9 -0
  2. package/dist/__fixtures__/testnats.js +26 -0
  3. package/dist/backoff.d.ts +20 -0
  4. package/dist/backoff.js +31 -0
  5. package/dist/channel.d.ts +52 -0
  6. package/dist/channel.js +17 -0
  7. package/dist/codec.d.ts +8 -0
  8. package/dist/codec.js +57 -0
  9. package/dist/cron.d.ts +7 -0
  10. package/dist/cron.js +88 -0
  11. package/dist/dlq.d.ts +18 -0
  12. package/dist/dlq.js +70 -0
  13. package/dist/duration.d.ts +5 -0
  14. package/dist/duration.js +33 -0
  15. package/dist/events.d.ts +45 -0
  16. package/dist/events.js +104 -0
  17. package/dist/hub.d.ts +33 -0
  18. package/dist/hub.js +247 -0
  19. package/dist/index.d.ts +22 -1
  20. package/dist/index.js +47 -4
  21. package/dist/jobs.d.ts +75 -0
  22. package/dist/jobs.js +138 -0
  23. package/dist/keys.d.ts +15 -0
  24. package/dist/keys.js +43 -0
  25. package/dist/lease.d.ts +31 -0
  26. package/dist/lease.js +86 -0
  27. package/dist/listeners.d.ts +37 -0
  28. package/dist/listeners.js +89 -0
  29. package/dist/nats.d.ts +28 -0
  30. package/dist/nats.js +61 -0
  31. package/dist/outbox.d.ts +34 -0
  32. package/dist/outbox.js +130 -0
  33. package/dist/outboxContext.d.ts +8 -0
  34. package/dist/outboxContext.js +16 -0
  35. package/dist/presence.d.ts +26 -0
  36. package/dist/presence.js +0 -0
  37. package/dist/protocol.d.ts +96 -0
  38. package/dist/protocol.js +66 -0
  39. package/dist/runtime.d.ts +39 -0
  40. package/dist/runtime.js +129 -0
  41. package/dist/schedule.d.ts +71 -0
  42. package/dist/schedule.js +143 -0
  43. package/dist/streams.d.ts +37 -0
  44. package/dist/streams.js +83 -0
  45. package/dist/work.d.ts +52 -0
  46. package/dist/work.js +88 -0
  47. package/dist/worker.d.ts +71 -0
  48. package/dist/worker.js +176 -0
  49. package/package.json +5 -1
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,176 @@
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 { jetstreamManager } from '@nats-io/jetstream';
19
+ import { JOBS_STREAM, DLQ_SUBJECT, jobConsumerName, jobSubject, toNanos, ensureJobsStream, ensureDlqStream, } from './streams.js';
20
+ import { encodePayloadBytes, decodePayloadBytes } from './codec.js';
21
+ import { backoffDelayMs } from './backoff.js';
22
+ import { configureJobs, getJob, registeredQueues, registeredJobs, enqueueRaw, DEFAULT_QUEUE, } from './jobs.js';
23
+ const DEFAULT_ACK_WAIT_MS = 30000;
24
+ const DEFAULT_DRAIN_MS = 30000;
25
+ /**
26
+ * 워커를 시작한다. registeredQueues() 의 각 큐에 컨슈머를 붙인다 —
27
+ * 호출 전에 잡 파일을 import 해 레지스트리를 채워 두어야 한다.
28
+ */
29
+ export async function runWorker(opts) {
30
+ const ackWaitMs = opts.ackWaitMs ?? DEFAULT_ACK_WAIT_MS;
31
+ const drainTimeoutMs = opts.drainTimeoutMs ?? DEFAULT_DRAIN_MS;
32
+ const emit = (e) => opts.onEvent?.(e);
33
+ // enqueue 전송도 워커에서 필요하다(재시도 재적재·잡→잡 큐잉).
34
+ configureJobs(opts.nats, opts.tuning);
35
+ await ensureJobsStream(opts.nats, opts.tuning);
36
+ await ensureDlqStream(opts.nats, opts.tuning);
37
+ const jsm = await jetstreamManager(opts.nats.nc);
38
+ const toDlq = async (rec) => {
39
+ await opts.nats.js.publish(DLQ_SUBJECT, encodePayloadBytes(rec), { msgID: `${rec.id}#dead` });
40
+ };
41
+ // 한 메시지 처리: notBefore·성공·재시도·DLQ 규칙을 적용한다.
42
+ const handleMessage = async (m) => {
43
+ let msg;
44
+ try {
45
+ msg = decodePayloadBytes(m.data);
46
+ }
47
+ catch {
48
+ // 포이즌(파싱 불가) — 무한 재전달을 막으려 즉시 폐기한다.
49
+ emit({ kind: 'skipped', name: '(unparseable)', id: '?' });
50
+ m.term();
51
+ return;
52
+ }
53
+ const now = Date.now();
54
+ if (now < msg.notBefore) {
55
+ // 아직 실행 시각 전 — 남은 시간만큼 정확히 미룬다(지연·백오프).
56
+ const delayMs = msg.notBefore - now;
57
+ emit({ kind: 'deferred', job: msg.name, id: msg.id, delayMs });
58
+ m.nak(delayMs);
59
+ return;
60
+ }
61
+ const def = getJob(msg.name);
62
+ if (!def) {
63
+ // 이 워커에 해당 잡이 등록돼 있지 않다 — 다른 워커 몫일 수 있으니
64
+ // 잠시 뒤 재전달되게 미룬다(포이즌 취급하지 않는다).
65
+ emit({ kind: 'skipped', name: msg.name, id: msg.id });
66
+ m.nak(1000);
67
+ return;
68
+ }
69
+ const attempt = msg.attempt + 1;
70
+ const maxAttempts = 1 + (def.options.retries ?? 3);
71
+ emit({ kind: 'processing', job: msg.name, id: msg.id, attempt });
72
+ // 긴 잡이 ack_wait 를 넘겨 조기 재전달되지 않게 주기적으로 working() 을
73
+ // 보내 리스를 연장한다.
74
+ const heartbeat = setInterval(() => {
75
+ try {
76
+ m.working();
77
+ }
78
+ catch {
79
+ // 이미 ack/term 된 뒤면 무시.
80
+ }
81
+ }, Math.max(1000, Math.floor(ackWaitMs / 2)));
82
+ try {
83
+ const run = def.handler;
84
+ await run(...msg.args);
85
+ clearInterval(heartbeat);
86
+ m.ack();
87
+ emit({ kind: 'succeeded', job: msg.name, id: msg.id });
88
+ }
89
+ catch (err) {
90
+ clearInterval(heartbeat);
91
+ const error = err instanceof Error ? err.message : String(err);
92
+ if (attempt < maxAttempts) {
93
+ const delayMs = backoffDelayMs(attempt, def.options);
94
+ // attempt·notBefore 를 올려 재적재하고 현재 메시지는 ack 로 치운다.
95
+ await enqueueRaw({ ...msg, attempt, notBefore: Date.now() + delayMs });
96
+ m.ack();
97
+ emit({ kind: 'retrying', job: msg.name, id: msg.id, attempt, delayMs });
98
+ }
99
+ else {
100
+ await toDlq({
101
+ id: msg.id,
102
+ name: msg.name,
103
+ queue: msg.queue,
104
+ args: msg.args,
105
+ attempts: attempt,
106
+ error,
107
+ failedAt: Date.now(),
108
+ enqueuedAt: msg.enqueuedAt,
109
+ });
110
+ m.term();
111
+ emit({ kind: 'dead', job: msg.name, id: msg.id, attempts: attempt, error });
112
+ }
113
+ }
114
+ };
115
+ // 큐마다 컨슈머를 붙이고 동시성 제한 소비 루프를 돈다.
116
+ const queues = registeredQueues();
117
+ if (queues.length === 0)
118
+ queues.push(DEFAULT_QUEUE);
119
+ const closers = [];
120
+ const inflightAll = new Set();
121
+ for (const queue of queues) {
122
+ const durable = jobConsumerName(queue);
123
+ // 큐 동시성: 이 큐에 속한 잡 중 최댓값(없으면 워커 기본).
124
+ const declared = registeredJobs()
125
+ .filter((d) => d.queue === queue)
126
+ .map((d) => d.options.concurrency ?? 0);
127
+ const perQueueConc = Math.max(0, ...declared) || (opts.concurrency ?? 1);
128
+ await jsm.consumers.add(JOBS_STREAM, {
129
+ durable_name: durable,
130
+ filter_subject: jobSubject(queue),
131
+ ack_policy: 'explicit',
132
+ ack_wait: toNanos(ackWaitMs),
133
+ // 정상 재시도는 우리가 재적재로 처리한다. 네이티브 재전달은 크래시
134
+ // 복구용이라 상한만 둔다(포이즌 크래시 루프 방지).
135
+ max_deliver: 25,
136
+ max_ack_pending: 1000,
137
+ });
138
+ const consumer = await opts.nats.js.consumers.get(JOBS_STREAM, durable);
139
+ const messages = await consumer.consume({ max_messages: perQueueConc });
140
+ const loop = (async () => {
141
+ for await (const m of messages) {
142
+ const p = handleMessage(m).finally(() => inflightAll.delete(p));
143
+ inflightAll.add(p);
144
+ if (inflightAll.size >= perQueueConc)
145
+ await Promise.race(inflightAll);
146
+ }
147
+ })();
148
+ closers.push(async () => {
149
+ // 신규 pull 중단(진행 중인 in-flight 는 아래에서 대기).
150
+ messages.stop();
151
+ await loop.catch(() => { });
152
+ });
153
+ }
154
+ let stopped = false;
155
+ return {
156
+ get inflight() {
157
+ return inflightAll.size;
158
+ },
159
+ async stop() {
160
+ if (stopped)
161
+ return;
162
+ stopped = true;
163
+ // 1) 신규 pull 중단.
164
+ for (const close of closers)
165
+ await close();
166
+ // 2) 진행 잡 완료 대기(상한). 남으면 ack_wait 로 스트림에 되돌아간다.
167
+ const deadline = Date.now() + drainTimeoutMs;
168
+ while (inflightAll.size > 0 && Date.now() < deadline) {
169
+ await Promise.race([...inflightAll, delay(200)]);
170
+ }
171
+ },
172
+ };
173
+ }
174
+ function delay(ms) {
175
+ return new Promise((r) => setTimeout(r, ms));
176
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaonjs/async",
3
- "version": "0.1.3",
3
+ "version": "0.2.1",
4
4
  "description": "Gaon NATS 통합: 잡·이벤트·스케줄러·채널(ws)·허브(프레즌스) (구현 예정)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -24,6 +24,10 @@
24
24
  "README.md"
25
25
  ],
26
26
  "dependencies": {
27
+ "@nats-io/transport-node": "^3.1.0",
28
+ "@nats-io/jetstream": "^3.1.0",
29
+ "@nats-io/kv": "^3.1.0",
30
+ "kysely": "^0.29.4",
27
31
  "@gaonjs/core": "0.1.3"
28
32
  },
29
33
  "scripts": {