@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.
package/dist/hub.js CHANGED
@@ -1,126 +1,227 @@
1
- // @gaonjs/async · 허브 서버 (§7 실시간 · 질문 19)
1
+ // @gaonjs/async · 허브 서버 (§7 실시간 · errata E-2 2026-07-23)
2
2
  //
3
- // 허브는 접속자 목록의 **단일 권위이자 중계**다. 웹서버들이 보내는
4
- // 프레즌스 명령(join/leave/heartbeat)을 받아 **NATS KV 에 영속**하고(허브만
5
- // KV 쓴다), 변경 델타를 웹서버로 브로드캐스트한다. HA 는 스케줄러와
6
- // 동일한 **리스 기반 리더 선출**(active-standby) 리더만 명령을 처리한다.
3
+ // 허브는 접속자 목록의 **단일 권위이자 중계**다. 웹서버들은 허브에 **TCP
4
+ // 지속 연결**로 붙어(라인 구분 JSON) 프레즌스 명령(register/join/leave/
5
+ // unregister/ping)을 보낸다. 허브는 이를 **NATS KV 영속**(허브만 KV
6
+ // 쓴다)하고, 변경 델타를 **NATS pub/sub**(broadcast 전용)으로 웹서버에
7
+ // 중계한다. HA 는 리스 기반 리더 선출(active-standby) — **리더만 TCP 를
8
+ // bind** 한다.
7
9
  //
8
- // - 재시작 복원: 리더가 되면 KV 기존 로스터로 생존 추적을 재구축한다.
9
- // 접속자 목록 자체는 KV 남아 있으므로 허브가 죽어도 유실되지 않는다
10
- // (웹서버의 list() KV 직접 읽는다).
11
- // - 생존 추적: 웹서버가 죽어 하트비트가 끊긴 멤버는 임계 시간 후 만료시켜
12
- // leave 로 처리한다(§7 line 898).
10
+ // 즉시 죽음 감지(errata E-2 핵심): 웹서버 프로세스가 죽거나 파티션되면
11
+ // TCP 소켓이 끊기고, 허브는 `socket.on('close')` 에서 **그 서버의 모든
12
+ // 멤버를 즉시 정리**(KV delete + leave 델타)한다 — 하트비트 타임아웃을
13
+ // 기다리지 않는다. 반쯤 열린 TCP(파티션에서 FIN 유실)는 레벨 ping
14
+ // 타임아웃(서버당 1건)으로 백스톱한다.
15
+ //
16
+ // - 재시작 복원: 리더가 되면 KV 로스터로 서버별 멤버 맵을 재구축한다. 접속자
17
+ // 목록은 KV 에 남아 허브가 죽어도 유실되지 않는다(웹서버 list() 는 KV 직접
18
+ // 읽기). 재접속 안 되는 서버(허브+서버 동시 장애)는 grace 후 회수한다.
19
+ import { createServer } from 'node:net';
13
20
  import { leaseLeader } from './lease.js';
14
21
  import { PRESENCE_BUCKET } from './presence.js';
15
22
  import { presenceKey, parsePresenceKey } from './keys.js';
16
- import { HUB_PRESENCE_SUBJECT, presenceEventSubject, encodeJson, decodeJson, } from './protocol.js';
17
- /** now 주입 없이 테스트 결정성을 위해 Date.now 를 감싼다. */
23
+ import { presenceEventSubject, encodeJson, decodeJson, createLineDecoder, HUB_ENDPOINT_BUCKET, HUB_ENDPOINT_KEY, } from './protocol.js';
18
24
  const now = () => Date.now();
19
- /**
20
- * 허브를 시작한다. 리더가 되기 전에는 대기하고, 리더가 되면 프레즌스
21
- * 명령 구독·KV 권위 갱신·델타 중계·만료 스윕을 켠다.
22
- */
25
+ const DEFAULT_PORT = 4001;
23
26
  export async function runHub(opts) {
24
- const memberTimeoutMs = opts.memberTimeoutMs ?? 30000;
25
- const sweepMs = opts.sweepMs ?? 5000;
27
+ const port = opts.port ?? DEFAULT_PORT;
28
+ const host = opts.host ?? '0.0.0.0';
29
+ const advertiseAddr = opts.advertiseAddr ?? `127.0.0.1:${port}`;
30
+ const pingTimeoutMs = opts.pingTimeoutMs ?? 10000;
31
+ const sweepMs = opts.sweepMs ?? 2000;
32
+ const reclaimGraceMs = opts.reclaimGraceMs ?? 15000;
26
33
  const kv = await opts.nats.kv(PRESENCE_BUCKET);
27
- // 리더일 때만 살아 있는 자원.
28
- let sub;
34
+ // ── 리더일 때만 살아 있는 상태 ──
35
+ let tcp;
36
+ let boundPort;
29
37
  let sweepTimer;
30
- // 멤버별 마지막 생존 시각(KV ts). 리더 승격 시 KV 에서 복원.
31
- const lastSeen = new Map();
38
+ // 소켓 serverId, serverId → (presenceKey{channel,member}), 소켓 무활동 추적.
39
+ const socketServer = new Map();
40
+ const serverMembers = new Map();
41
+ const socketSeen = new Map();
42
+ // 복원됐지만 아직 재접속(register) 안 된 서버 → 복원 시각(회수 유예 판단).
43
+ const unclaimedSince = new Map();
44
+ // 재접속 reconcile: register~synced 사이, 아직 재join 안 된 옛 멤버 키(소켓별).
45
+ const reconciling = new Map();
46
+ const emitError = (err) => opts.onError?.(err instanceof Error ? err : new Error(String(err)));
32
47
  const broadcast = (channel, ev) => {
33
48
  opts.nats.nc.publish(presenceEventSubject(channel), encodeJson(ev));
34
49
  };
35
- const applyJoin = async (channel, member, info, server) => {
36
- const key = presenceKey(channel, member);
37
- const rec = { info, server, ts: now() };
38
- await kv.put(key, encodeJson(rec));
39
- lastSeen.set(key, rec.ts);
40
- broadcast(channel, { type: 'join', channel, member: { id: member, info } });
41
- };
42
- const applyLeave = async (channel, member) => {
43
- const key = presenceKey(channel, member);
44
- await kv.delete(key).catch(() => { });
45
- lastSeen.delete(key);
50
+ const delMember = async (channel, member) => {
51
+ await kv.delete(presenceKey(channel, member)).catch(() => { });
46
52
  broadcast(channel, { type: 'leave', channel, member });
47
53
  };
48
- const applyHeartbeat = async (channel, member) => {
49
- const key = presenceKey(channel, member);
50
- const entry = await kv.get(key);
51
- if (!entry)
52
- return; // join 을 놓친 하트비트는 무시(다음 join 에서 정합).
53
- const rec = decodeJson(entry.value);
54
- if (!rec)
54
+ /** 서버의 모든 멤버를 즉시 정리(KV delete + leave 델타). */
55
+ const cleanupServer = async (server) => {
56
+ const members = serverMembers.get(server);
57
+ serverMembers.delete(server);
58
+ unclaimedSince.delete(server);
59
+ if (!members)
55
60
  return;
56
- const bumped = { ...rec, ts: now() };
57
- await kv.put(key, encodeJson(bumped));
58
- lastSeen.set(key, bumped.ts);
59
- };
60
- const handleCommand = async (cmd) => {
61
- if (cmd.op === 'join')
62
- await applyJoin(cmd.channel, cmd.member, cmd.info, cmd.server);
63
- else if (cmd.op === 'leave')
64
- await applyLeave(cmd.channel, cmd.member);
65
- else
66
- await applyHeartbeat(cmd.channel, cmd.member);
61
+ for (const { channel, member } of members.values())
62
+ await delMember(channel, member);
67
63
  };
68
- const sweep = async () => {
69
- const cutoff = now() - memberTimeoutMs;
70
- for (const [key, ts] of [...lastSeen.entries()]) {
71
- if (ts < cutoff) {
72
- const { channel, member } = parsePresenceKey(key);
73
- await applyLeave(channel, member);
64
+ const handleCommand = async (socket, cmd) => {
65
+ socketSeen.set(socket, now());
66
+ switch (cmd.op) {
67
+ case 'register': {
68
+ // 재접속·failover 복원 대비: 멤버를 지우지 않고 보존한다(flicker 방지).
69
+ // 곧 오는 resync(join)로 재확인하고, 재join 안 된 멤버는 synced 에서 정리.
70
+ socketServer.set(socket, cmd.server);
71
+ const existing = serverMembers.get(cmd.server);
72
+ if (existing)
73
+ reconciling.set(socket, new Set(existing.keys()));
74
+ else
75
+ serverMembers.set(cmd.server, new Map());
76
+ unclaimedSince.delete(cmd.server);
77
+ break;
74
78
  }
79
+ case 'join': {
80
+ const server = socketServer.get(socket);
81
+ if (!server)
82
+ break;
83
+ const key = presenceKey(cmd.channel, cmd.member);
84
+ const map = serverMembers.get(server);
85
+ const isNew = !map?.has(key);
86
+ map?.set(key, { channel: cmd.channel, member: cmd.member });
87
+ reconciling.get(socket)?.delete(key); // 재확인된 멤버 — 정리 대상에서 제외
88
+ await kv.put(presenceKey(cmd.channel, cmd.member), encodeJson({ info: cmd.info, server, ts: now() }));
89
+ // 새 멤버만 델타 방송(재접속 시 기존 멤버는 중복 방송 안 함).
90
+ if (isNew)
91
+ broadcast(cmd.channel, { type: 'join', channel: cmd.channel, member: { id: cmd.member, info: cmd.info } });
92
+ break;
93
+ }
94
+ case 'leave': {
95
+ const server = socketServer.get(socket);
96
+ if (!server)
97
+ break;
98
+ serverMembers.get(server)?.delete(presenceKey(cmd.channel, cmd.member));
99
+ await delMember(cmd.channel, cmd.member);
100
+ break;
101
+ }
102
+ case 'synced': {
103
+ // resync 완료 — 재확인 안 된 옛 멤버(재접속 중 떠남)를 정리.
104
+ const server = socketServer.get(socket);
105
+ const pending = reconciling.get(socket);
106
+ reconciling.delete(socket);
107
+ if (server && pending) {
108
+ const map = serverMembers.get(server);
109
+ for (const key of pending) {
110
+ const m = map?.get(key);
111
+ map?.delete(key);
112
+ if (m)
113
+ await delMember(m.channel, m.member);
114
+ }
115
+ }
116
+ break;
117
+ }
118
+ case 'unregister': {
119
+ // graceful 종료 — 그 서버 전원 즉시 정리.
120
+ await cleanupServer(cmd.server);
121
+ break;
122
+ }
123
+ case 'ping':
124
+ break; // socketSeen 갱신만(위)
75
125
  }
76
126
  };
77
- // 리더 승격: KV 로스터로 생존 추적 재구축(재시작 복원).
127
+ const onSocket = (socket) => {
128
+ socket.setNoDelay(true);
129
+ socketSeen.set(socket, now());
130
+ const decode = createLineDecoder();
131
+ socket.setEncoding('utf8');
132
+ socket.on('data', (chunk) => {
133
+ for (const cmd of decode(chunk))
134
+ void handleCommand(socket, cmd).catch(emitError);
135
+ });
136
+ const onGone = () => {
137
+ const server = socketServer.get(socket);
138
+ socketServer.delete(socket);
139
+ socketSeen.delete(socket);
140
+ reconciling.delete(socket);
141
+ // ★ 즉시 죽음 감지: 소켓이 끊기면 그 서버 전 멤버를 곧바로 정리한다.
142
+ if (server)
143
+ void cleanupServer(server).catch(emitError);
144
+ };
145
+ socket.on('close', onGone);
146
+ socket.on('error', () => {
147
+ /* close 가 뒤따르며 onGone 이 정리한다 */
148
+ });
149
+ };
150
+ /** 반쯤 열린 소켓(파티션) 백스톱 + 미회수 복원 서버 회수. */
151
+ const sweep = () => {
152
+ const t = now();
153
+ for (const [socket, seen] of [...socketSeen.entries()]) {
154
+ if (t - seen > pingTimeoutMs)
155
+ socket.destroy(); // → close → cleanupServer
156
+ }
157
+ for (const [server, since] of [...unclaimedSince.entries()]) {
158
+ if (t - since > reclaimGraceMs)
159
+ void cleanupServer(server).catch(emitError);
160
+ }
161
+ };
162
+ /** 리더 승격: KV 로스터로 서버별 멤버 맵을 재구축(재시작 복원). */
78
163
  const restoreFromKv = async () => {
79
- lastSeen.clear();
80
- const iter = await kv.keys();
81
- for await (const key of iter) {
164
+ serverMembers.clear();
165
+ unclaimedSince.clear();
166
+ for await (const key of await kv.keys()) {
82
167
  const entry = await kv.get(key);
83
168
  if (!entry)
84
169
  continue;
85
170
  const rec = decodeJson(entry.value);
86
- if (rec)
87
- lastSeen.set(key, rec.ts);
171
+ if (!rec)
172
+ continue;
173
+ const { channel, member } = parsePresenceKey(key);
174
+ if (!serverMembers.has(rec.server)) {
175
+ serverMembers.set(rec.server, new Map());
176
+ unclaimedSince.set(rec.server, now()); // 재접속 없으면 grace 후 회수
177
+ }
178
+ serverMembers.get(rec.server).set(key, { channel, member });
88
179
  }
89
180
  };
181
+ const announceEndpoint = async () => {
182
+ const ekv = await opts.nats.kv(HUB_ENDPOINT_BUCKET);
183
+ await ekv.put(HUB_ENDPOINT_KEY, encodeJson({ addr: advertiseAddr })).catch(() => { });
184
+ };
185
+ const clearEndpoint = async () => {
186
+ const ekv = await opts.nats.kv(HUB_ENDPOINT_BUCKET);
187
+ await ekv.delete(HUB_ENDPOINT_KEY).catch(() => { });
188
+ };
90
189
  const startLeading = async () => {
91
- // 구독을 먼저 켜서 승격 직후 도착하는 명령을 놓치지 않는다(복원은 그 뒤).
92
- // 리더만 구독한다 — 대기 인스턴스는 명령을 처리하지 않는다. 큐 그룹으로
93
- // 리더 교대 순간의 중복 처리를 막는다.
94
- const subscription = opts.nats.nc.subscribe(HUB_PRESENCE_SUBJECT, { queue: 'gaon-hub' });
95
- sub = subscription;
96
- (async () => {
97
- for await (const m of subscription) {
98
- const cmd = decodeJson(m.data);
99
- if (!cmd)
100
- continue;
101
- try {
102
- await handleCommand(cmd);
103
- }
104
- finally {
105
- // join/leave 는 request/reply — 반영 후 ack. heartbeat 는 reply 없음.
106
- if (m.reply)
107
- m.respond(encodeJson({ ok: true }));
108
- }
109
- }
110
- })();
111
- sweepTimer = setInterval(() => void sweep(), sweepMs);
112
- // KV 로스터로 생존 추적 재구축(재시작 복원). 명령 처리와 병행돼도
113
- // 안전하다(둘 다 lastSeen 을 덮어쓸 뿐).
114
190
  await restoreFromKv();
191
+ await new Promise((resolve, reject) => {
192
+ const server = createServer(onSocket);
193
+ server.on('error', (err) => {
194
+ emitError(err);
195
+ reject(err);
196
+ });
197
+ server.listen(port, host, () => {
198
+ const addr = server.address();
199
+ boundPort = typeof addr === 'object' && addr ? addr.port : port;
200
+ tcp = server;
201
+ resolve();
202
+ });
203
+ });
204
+ sweepTimer = setInterval(sweep, sweepMs);
205
+ await announceEndpoint();
115
206
  opts.onState?.({ leader: true });
116
207
  };
117
- const stopLeading = () => {
118
- sub?.unsubscribe();
119
- sub = undefined;
208
+ const stopLeading = async () => {
120
209
  if (sweepTimer)
121
210
  clearInterval(sweepTimer);
122
211
  sweepTimer = undefined;
123
- lastSeen.clear();
212
+ for (const socket of [...socketServer.keys()])
213
+ socket.destroy();
214
+ socketServer.clear();
215
+ socketSeen.clear();
216
+ serverMembers.clear();
217
+ unclaimedSince.clear();
218
+ await clearEndpoint();
219
+ if (tcp) {
220
+ const server = tcp;
221
+ tcp = undefined;
222
+ boundPort = undefined;
223
+ await new Promise((resolve) => server.close(() => resolve()));
224
+ }
124
225
  opts.onState?.({ leader: false });
125
226
  };
126
227
  const lease = await leaseLeader({
@@ -128,15 +229,18 @@ export async function runHub(opts) {
128
229
  key: 'hub.leader',
129
230
  id: opts.id,
130
231
  ttlMs: opts.ttlMs,
131
- onElected: () => void startLeading(),
132
- onRevoked: () => stopLeading(),
232
+ onElected: () => void startLeading().catch(emitError),
233
+ onRevoked: () => void stopLeading().catch(emitError),
133
234
  });
134
235
  return {
135
236
  get isLeader() {
136
237
  return lease.isLeader;
137
238
  },
239
+ get port() {
240
+ return boundPort;
241
+ },
138
242
  async stop() {
139
- stopLeading();
243
+ await stopLeading();
140
244
  await lease.stop();
141
245
  },
142
246
  };
package/dist/index.d.ts CHANGED
@@ -7,3 +7,17 @@ export { createPresenceClient, PRESENCE_BUCKET, type PresenceClient, type Presen
7
7
  export { createChannelRuntime, type ChannelRuntime, type ChannelRuntimeOptions, type SocketAdapter, type JoinRequest, type Connection, } from './runtime.js';
8
8
  export { runHub, type HubOptions, type HubHandle } from './hub.js';
9
9
  export { HUB_PRESENCE_SUBJECT, presenceEventSubject, encodeJson, decodeJson, type ClientMessage, type ServerFrame, type PresenceCommand, type PresenceEvent, type BroadcastEnvelope, } from './protocol.js';
10
+ export { job, configureJobs, resetJobs, registerJob, getJob, registeredJobs, registeredQueues, isJobDef, enqueueRaw, DEFAULT_QUEUE, JOB_BRAND, type JobDef, type JobOptions, type JobMessage, } from './jobs.js';
11
+ export { backoffDelayMs, DEFAULT_BACKOFF_MS, DEFAULT_RETRIES, type BackoffOptions, } from './backoff.js';
12
+ export { parseDuration } from './duration.js';
13
+ export { encodePayload, decodePayload, encodePayloadBytes, decodePayloadBytes, } from './codec.js';
14
+ export { ensureJobsStream, ensureDlqStream, ensureEventsStream, jobSubject, eventSubject, JOBS_STREAM, DLQ_STREAM, EVENTS_STREAM, DLQ_SUBJECT, type StreamTuning, } from './streams.js';
15
+ export { runWorker, type WorkerOptions, type WorkerHandle, type WorkerEvent, type DlqRecord, } from './worker.js';
16
+ export { listDlq, findDlq, retryDlq, purgeDlq, type DlqEntry, } from './dlq.js';
17
+ export { event, on, configureEvents, resetEvents, registeredListeners, isListener, reidentifyListener, publishToSubject, LISTENER_BRAND, type EventDef, type Listener, type PayloadOf, } from './events.js';
18
+ export { runWork, type WorkOptions, type WorkHandle, type WorkEvent } from './work.js';
19
+ export { runListeners, type ListenersOptions, type ListenersHandle, type ListenerEvent, } from './listeners.js';
20
+ export { runInTransaction, runOutboxRelay, ensureOutboxTable, OUTBOX_TABLE, type RelayOptions, type RelayHandle, } from './outbox.js';
21
+ export { currentOutbox, runWithOutbox, type OutboxStager } from './outboxContext.js';
22
+ export { schedule, runScheduler, type ScheduleDef, type ScheduleEntry, type ScheduleBuilder, type Schedulable, type SchedulerOptions, type SchedulerHandle, type SchedulerEvent, } from './schedule.js';
23
+ export { parseCron, type CronMatcher } from './cron.js';
package/dist/index.js CHANGED
@@ -22,3 +22,30 @@ export { createChannelRuntime, } from './runtime.js';
22
22
  export { runHub } from './hub.js';
23
23
  // 와이어 프로토콜 (프레임·subject·직렬화)
24
24
  export { HUB_PRESENCE_SUBJECT, presenceEventSubject, encodeJson, decodeJson, } from './protocol.js';
25
+ // ── 비동기 배터리 (M7) ───────────────────────────────────────────────────
26
+ // 잡 레이어 (§7 · job()·enqueue·큐)
27
+ export { job, configureJobs, resetJobs, registerJob, getJob, registeredJobs, registeredQueues, isJobDef, enqueueRaw, DEFAULT_QUEUE, JOB_BRAND, } from './jobs.js';
28
+ // 백오프 곡선 (§7 · 재시도)
29
+ export { backoffDelayMs, DEFAULT_BACKOFF_MS, DEFAULT_RETRIES, } from './backoff.js';
30
+ // 기간 문자열 파서 (지연·스케줄)
31
+ export { parseDuration } from './duration.js';
32
+ // 잡·이벤트 페이로드 코덱 (bigint·Date 왕복)
33
+ export { encodePayload, decodePayload, encodePayloadBytes, decodePayloadBytes, } from './codec.js';
34
+ // JetStream 스트림 토폴로지
35
+ export { ensureJobsStream, ensureDlqStream, ensureEventsStream, jobSubject, eventSubject, JOBS_STREAM, DLQ_STREAM, EVENTS_STREAM, DLQ_SUBJECT, } from './streams.js';
36
+ // 잡 워커 (§7 · pull 컨슈머·재시도·DLQ·graceful drain)
37
+ export { runWorker, } from './worker.js';
38
+ // DLQ 조회·재적재 (§7 · gaon jobs list/retry)
39
+ export { listDlq, findDlq, retryDlq, purgeDlq, } from './dlq.js';
40
+ // 이벤트 버스 (§7 · event()·on()·emit)
41
+ export { event, on, configureEvents, resetEvents, registeredListeners, isListener, reidentifyListener, publishToSubject, LISTENER_BRAND, } from './events.js';
42
+ // 워커 프로세스 런타임 (§7 · gaon work 조립 · graceful drain)
43
+ export { runWork } from './work.js';
44
+ // 리스너 소비 (§7 · durable 컨슈머·재시도)
45
+ export { runListeners, } from './listeners.js';
46
+ // 아웃박스 패턴 (§7 · 트랜잭션 정합·릴레이 · v1 코어 내장)
47
+ export { runInTransaction, runOutboxRelay, ensureOutboxTable, OUTBOX_TABLE, } from './outbox.js';
48
+ export { currentOutbox, runWithOutbox } from './outboxContext.js';
49
+ // 스케줄러 (§7 · schedule DSL·크론·리더 선출 단일 발행)
50
+ export { schedule, runScheduler, } from './schedule.js';
51
+ export { parseCron } from './cron.js';
package/dist/jobs.d.ts ADDED
@@ -0,0 +1,75 @@
1
+ import type { GaonNats } from './nats.js';
2
+ import type { BackoffOptions } from './backoff.js';
3
+ import { type StreamTuning } from './streams.js';
4
+ /** 큐 기본 이름. `{ queue }` 미지정 시. */
5
+ export declare const DEFAULT_QUEUE = "default";
6
+ /** 잡 옵션. 재시도·백오프·큐·동시성(§7 line 883). */
7
+ export interface JobOptions extends BackoffOptions {
8
+ /** 잡 이름. 생략 시 파일 로더가 파일명에서 채운다(파일=등록 관례). */
9
+ readonly name?: string;
10
+ /** 처리 큐. 생략 시 'default'. 큐별로 컨슈머·동시성이 분리된다. */
11
+ readonly queue?: string;
12
+ /** 최대 재시도(최초 실행 제외). 총 시도 = 1 + retries. 기본 3. */
13
+ readonly retries?: number;
14
+ /** 이 큐 워커의 동시 처리 수. 기본 1. worker 가 소비한다. */
15
+ readonly concurrency?: number;
16
+ }
17
+ /** 큐로 오가는 잡 메시지(와이어). */
18
+ export interface JobMessage {
19
+ readonly id: string;
20
+ readonly name: string;
21
+ readonly queue: string;
22
+ readonly args: readonly unknown[];
23
+ /** 방금까지 소진한 시도 번호(0=최초 실행 전). 재시도마다 +1. */
24
+ readonly attempt: number;
25
+ /** 실행 하한 시각(epoch ms). 지연·백오프를 표현한다. */
26
+ readonly notBefore: number;
27
+ readonly enqueuedAt: number;
28
+ }
29
+ /** 파일 로더가 export 중에서 잡 정의를 식별하는 브랜드. */
30
+ export declare const JOB_BRAND: unique symbol;
31
+ export interface JobDef<Args extends readonly unknown[]> {
32
+ readonly [JOB_BRAND]: true;
33
+ /** 잡 이름(해석된 최종값). 미해석 상태에서 접근하면 수리 안내 에러. */
34
+ readonly name: string;
35
+ readonly queue: string;
36
+ readonly options: JobOptions;
37
+ readonly handler: (...args: Args) => Promise<void> | void;
38
+ /** 즉시 큐잉. 인자는 handler 시그니처 그대로. */
39
+ later(...args: Args): Promise<void>;
40
+ /** 지연 큐잉. '10m'·30_000(ms) 뒤 실행. */
41
+ in(delay: string | number, ...args: Args): Promise<void>;
42
+ /** 지정 시각에 실행(Date). 과거면 즉시. */
43
+ at(when: Date, ...args: Args): Promise<void>;
44
+ /**
45
+ * 파일명 기반 이름을 1회 부여한다(파일 로더 전용). 이미 이름이 있으면
46
+ * 무시한다. 이름을 붙이면서 워커 조회용 레지스트리에도 등록한다. 이
47
+ * 객체를 그대로 mutate 하므로, 로드 후 `SendWelcomeMail.later()` 가
48
+ * serve·work 어느 프로세스에서든 동작한다.
49
+ */
50
+ assignName(name: string): void;
51
+ }
52
+ /**
53
+ * enqueue 전송을 설정한다(부팅 시 1회 — 웹서버·워커 공통). 이후 `.later()`
54
+ * 등이 이 NATS 로 잡을 publish 한다. 잡 스트림은 첫 enqueue 때 보장한다.
55
+ */
56
+ export declare function configureJobs(nats: GaonNats, tuning?: StreamTuning): void;
57
+ /** 설정 해제(테스트 teardown). */
58
+ export declare function resetJobs(): void;
59
+ /**
60
+ * 잡을 정의한다. 반환 객체를 `domain/jobs/*.ts` 에서 export 하면 등록이다.
61
+ * 이름은 옵션으로 주거나(명시), 파일 로더가 파일명에서 채운다.
62
+ */
63
+ export declare function job<Args extends readonly unknown[]>(handler: (...args: Args) => Promise<void> | void, options?: JobOptions): JobDef<Args>;
64
+ /** 잡을 레지스트리에 등록(워커가 핸들러 조회에 쓴다). 이름 필수. */
65
+ export declare function registerJob<Args extends readonly unknown[]>(def: JobDef<Args>): void;
66
+ /** 등록된 잡 조회(워커). */
67
+ export declare function getJob(name: string): JobDef<never> | undefined;
68
+ /** 등록된 잡의 큐 집합(워커가 컨슈머를 만들 때 쓴다). */
69
+ export declare function registeredQueues(): string[];
70
+ /** 등록된 전 잡(진단·워커). */
71
+ export declare function registeredJobs(): JobDef<never>[];
72
+ /** 값이 잡 정의인가(파일 로더가 export 를 훑을 때). */
73
+ export declare function isJobDef(value: unknown): value is JobDef<readonly unknown[]>;
74
+ /** 저수준 재적재(워커 재시도·CLI retry 공용). notBefore·attempt 를 명시한다. */
75
+ export declare function enqueueRaw(msg: JobMessage): Promise<void>;
package/dist/jobs.js ADDED
@@ -0,0 +1,138 @@
1
+ // @gaonjs/async · 잡 레이어 (§7 M7 · line 781~796)
2
+ //
3
+ // 잡은 도메인 소속이다 — `domain/jobs/` 에 파일을 놓으면 등록이고, 어느
4
+ // 앱에서 큐잉하든 같은 워커(`gaon work`)가 처리한다. 모델·서비스와 같은
5
+ // 함수/객체 스타일이다(데코레이터 금지).
6
+ //
7
+ // export const SendWelcomeMail = job(async (userId: bigint) => { ... },
8
+ // { retries: 3 })
9
+ // await SendWelcomeMail.later(user.id) // 즉시 큐잉(인자 타입 추론)
10
+ // await SendWelcomeMail.in('10m', user.id) // 지연 실행
11
+ //
12
+ // 전송은 JetStream 이다. enqueue 는 잡 메시지를 큐 subject 로 publish 하고,
13
+ // 워커가 pull 컨슈머로 당겨 처리한다(worker.ts). 지연·재시도는 메시지의
14
+ // notBefore(실행 하한 시각)로 표현하고, 재시도는 attempt 를 올려 재적재한다.
15
+ import { randomUUID } from 'node:crypto';
16
+ import { parseDuration } from './duration.js';
17
+ import { encodePayloadBytes } from './codec.js';
18
+ import { ensureJobsStream, jobSubject } from './streams.js';
19
+ /** 큐 기본 이름. `{ queue }` 미지정 시. */
20
+ export const DEFAULT_QUEUE = 'default';
21
+ // 잡 이름 → 정의. 워커가 수신 메시지의 name 으로 핸들러를 찾는다. enqueue
22
+ // 측은 이 레지스트리가 없어도 되지만(name/queue 만 알면 publish 가능),
23
+ // 워커 측은 반드시 잡 파일을 import 해 여기에 채워야 한다.
24
+ const registry = new Map();
25
+ /** 파일 로더가 export 중에서 잡 정의를 식별하는 브랜드. */
26
+ export const JOB_BRAND = Symbol.for('gaonjs.async.job');
27
+ let runtime;
28
+ /**
29
+ * enqueue 전송을 설정한다(부팅 시 1회 — 웹서버·워커 공통). 이후 `.later()`
30
+ * 등이 이 NATS 로 잡을 publish 한다. 잡 스트림은 첫 enqueue 때 보장한다.
31
+ */
32
+ export function configureJobs(nats, tuning = {}) {
33
+ runtime = { nats, tuning, ensured: false };
34
+ }
35
+ /** 설정 해제(테스트 teardown). */
36
+ export function resetJobs() {
37
+ runtime = undefined;
38
+ registry.clear();
39
+ }
40
+ function requireRuntime() {
41
+ if (!runtime) {
42
+ throw new Error(`[@gaonjs/async] 잡 전송이 설정되지 않았습니다.\n` +
43
+ `→ 부팅 코드에서 configureJobs(await connectNats()) 를 호출하세요\n` +
44
+ ` (gaon serve·work 는 자동으로 호출합니다).`);
45
+ }
46
+ return runtime;
47
+ }
48
+ async function publishJob(msg) {
49
+ const rt = requireRuntime();
50
+ if (!rt.ensured) {
51
+ await ensureJobsStream(rt.nats, rt.tuning);
52
+ rt.ensured = true;
53
+ }
54
+ // msgID = `<잡 id>#<attempt>` → 중복 제거 창 안의 재적재(크래시로 같은
55
+ // 메시지를 두 번 재처리해 재시도가 두 번 발행되는 경우)를 흡수한다.
56
+ // attempt 를 포함해 정상 재시도(attempt 증가)는 별개 메시지로 남긴다.
57
+ await rt.nats.js.publish(jobSubject(msg.queue), encodePayloadBytes(msg), {
58
+ msgID: `${msg.id}#${msg.attempt}`,
59
+ });
60
+ }
61
+ function enqueue(name, queue, args, notBefore) {
62
+ const now = Date.now();
63
+ return publishJob({
64
+ id: randomUUID(),
65
+ name,
66
+ queue,
67
+ args,
68
+ attempt: 0,
69
+ notBefore: Math.max(now, notBefore),
70
+ enqueuedAt: now,
71
+ });
72
+ }
73
+ /**
74
+ * 잡을 정의한다. 반환 객체를 `domain/jobs/*.ts` 에서 export 하면 등록이다.
75
+ * 이름은 옵션으로 주거나(명시), 파일 로더가 파일명에서 채운다.
76
+ */
77
+ export function job(handler, options = {}) {
78
+ const queue = options.queue ?? DEFAULT_QUEUE;
79
+ let resolved = options.name ?? '';
80
+ const def = {
81
+ [JOB_BRAND]: true,
82
+ get name() {
83
+ if (!resolved) {
84
+ throw new Error(`[@gaonjs/async] 잡 이름이 없습니다.\n` +
85
+ `→ job(fn, { name: '...' }) 로 이름을 주거나 domain/jobs/ 에 파일로 두세요.`);
86
+ }
87
+ return resolved;
88
+ },
89
+ queue,
90
+ options,
91
+ handler,
92
+ later(...args) {
93
+ return enqueue(this.name, queue, args, Date.now());
94
+ },
95
+ in(delay, ...args) {
96
+ return enqueue(this.name, queue, args, Date.now() + parseDuration(delay));
97
+ },
98
+ at(when, ...args) {
99
+ return enqueue(this.name, queue, args, when.getTime());
100
+ },
101
+ assignName(name) {
102
+ if (resolved)
103
+ return;
104
+ resolved = name;
105
+ registerJob(def);
106
+ },
107
+ };
108
+ if (resolved)
109
+ registerJob(def);
110
+ return def;
111
+ }
112
+ /** 잡을 레지스트리에 등록(워커가 핸들러 조회에 쓴다). 이름 필수. */
113
+ export function registerJob(def) {
114
+ registry.set(def.name, def);
115
+ }
116
+ /** 등록된 잡 조회(워커). */
117
+ export function getJob(name) {
118
+ return registry.get(name);
119
+ }
120
+ /** 등록된 잡의 큐 집합(워커가 컨슈머를 만들 때 쓴다). */
121
+ export function registeredQueues() {
122
+ const set = new Set();
123
+ for (const def of registry.values())
124
+ set.add(def.queue);
125
+ return [...set];
126
+ }
127
+ /** 등록된 전 잡(진단·워커). */
128
+ export function registeredJobs() {
129
+ return [...registry.values()];
130
+ }
131
+ /** 값이 잡 정의인가(파일 로더가 export 를 훑을 때). */
132
+ export function isJobDef(value) {
133
+ return typeof value === 'object' && value !== null && value[JOB_BRAND] === true;
134
+ }
135
+ /** 저수준 재적재(워커 재시도·CLI retry 공용). notBefore·attempt 를 명시한다. */
136
+ export function enqueueRaw(msg) {
137
+ return publishJob(msg);
138
+ }
@@ -0,0 +1,37 @@
1
+ import type { GaonNats } from './nats.js';
2
+ import { type StreamTuning } from './streams.js';
3
+ export type ListenerEvent = {
4
+ readonly kind: 'handled';
5
+ readonly listener: string;
6
+ readonly event: string;
7
+ } | {
8
+ readonly kind: 'retrying';
9
+ readonly listener: string;
10
+ readonly event: string;
11
+ readonly attempt: number;
12
+ } | {
13
+ readonly kind: 'dropped';
14
+ readonly listener: string;
15
+ readonly event: string;
16
+ readonly error: string;
17
+ } | {
18
+ readonly kind: 'error';
19
+ readonly error: string;
20
+ };
21
+ export interface ListenersOptions {
22
+ readonly nats: GaonNats;
23
+ /** ack 대기(ms). 기본 30000. */
24
+ readonly ackWaitMs?: number;
25
+ /** 리스너 최대 재전달(크래시·실패 합산). 기본 6. */
26
+ readonly maxDeliver?: number;
27
+ /** graceful drain 상한(ms). 기본 30000. */
28
+ readonly drainTimeoutMs?: number;
29
+ readonly tuning?: StreamTuning;
30
+ onEvent?(e: ListenerEvent): void;
31
+ }
32
+ export interface ListenersHandle {
33
+ stop(): Promise<void>;
34
+ readonly inflight: number;
35
+ }
36
+ /** 등록된 리스너들을 이벤트 스트림에 붙여 소비를 시작한다. */
37
+ export declare function runListeners(opts: ListenersOptions): Promise<ListenersHandle>;