@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.
- package/dist/__fixtures__/testnats.d.ts +9 -0
- package/dist/__fixtures__/testnats.js +26 -0
- package/dist/backoff.d.ts +20 -0
- package/dist/backoff.js +31 -0
- package/dist/channel.d.ts +52 -0
- package/dist/channel.js +17 -0
- package/dist/codec.d.ts +8 -0
- package/dist/codec.js +57 -0
- package/dist/cron.d.ts +7 -0
- package/dist/cron.js +88 -0
- package/dist/dlq.d.ts +18 -0
- package/dist/dlq.js +70 -0
- package/dist/duration.d.ts +5 -0
- package/dist/duration.js +33 -0
- package/dist/events.d.ts +45 -0
- package/dist/events.js +104 -0
- package/dist/hub.d.ts +33 -0
- package/dist/hub.js +247 -0
- package/dist/index.d.ts +22 -1
- package/dist/index.js +47 -4
- package/dist/jobs.d.ts +75 -0
- package/dist/jobs.js +138 -0
- package/dist/keys.d.ts +15 -0
- package/dist/keys.js +43 -0
- package/dist/lease.d.ts +31 -0
- package/dist/lease.js +86 -0
- package/dist/listeners.d.ts +37 -0
- package/dist/listeners.js +89 -0
- package/dist/nats.d.ts +28 -0
- package/dist/nats.js +61 -0
- package/dist/outbox.d.ts +34 -0
- package/dist/outbox.js +130 -0
- package/dist/outboxContext.d.ts +8 -0
- package/dist/outboxContext.js +16 -0
- package/dist/presence.d.ts +26 -0
- package/dist/presence.js +0 -0
- package/dist/protocol.d.ts +96 -0
- package/dist/protocol.js +66 -0
- package/dist/runtime.d.ts +39 -0
- package/dist/runtime.js +129 -0
- package/dist/schedule.d.ts +71 -0
- package/dist/schedule.js +143 -0
- package/dist/streams.d.ts +37 -0
- package/dist/streams.js +83 -0
- package/dist/work.d.ts +52 -0
- package/dist/work.js +88 -0
- package/dist/worker.d.ts +71 -0
- package/dist/worker.js +176 -0
- package/package.json +5 -1
package/dist/protocol.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// @gaonjs/async · 실시간 와이어 프로토콜 (§7 · errata E-2 2026-07-23)
|
|
2
|
+
//
|
|
3
|
+
// 경로별 메시지 형식을 한곳에 모은다:
|
|
4
|
+
// 1) 클라이언트 ↔ 웹서버 (WebSocket JSON 프레임)
|
|
5
|
+
// 2) 웹서버 → 허브 (프레즌스 명령 · **TCP 지속 연결 · 라인 구분 JSON**)
|
|
6
|
+
// 3) 허브 → 전 웹서버 (프레즌스 델타 브로드캐스트 · NATS pub/sub)
|
|
7
|
+
// 4) 채널 브로드캐스트 (웹서버 ↔ 웹서버 · NATS pub/sub)
|
|
8
|
+
//
|
|
9
|
+
// errata E-2: 웹서버↔허브는 **TCP 지속 연결**이다(소켓 close = 즉시 죽음
|
|
10
|
+
// 감지). NATS 는 broadcast(허브→웹서버 델타)와 KV 영속·리더선출 전용.
|
|
11
|
+
// (구 방식의 NATS request/reply 명령 경로는 HubCommand+TCP 로 대체됐다.)
|
|
12
|
+
import { encodeSegment } from './keys.js';
|
|
13
|
+
// ─── NATS subject 상수 ──────────────────────────────────────────────────
|
|
14
|
+
/** 웹서버 → 허브 프레즌스 명령(허브가 큐 구독). */
|
|
15
|
+
export const HUB_PRESENCE_SUBJECT = 'gaon.hub.presence';
|
|
16
|
+
/** 허브 → 전 서버 프레즌스 델타(채널별). 채널명은 내부에서 인코딩한다. */
|
|
17
|
+
export function presenceEventSubject(channel) {
|
|
18
|
+
return `gaon.presence.${encodeSegment(channel)}`;
|
|
19
|
+
}
|
|
20
|
+
/** 리더 허브가 자신의 TCP 엔드포인트를 공지하는 KV 버킷·키(멀티호스트 발견). */
|
|
21
|
+
export const HUB_ENDPOINT_BUCKET = 'gaon_hub';
|
|
22
|
+
export const HUB_ENDPOINT_KEY = 'endpoint';
|
|
23
|
+
// ─── TCP 라인 프레이밍 (라인 구분 JSON) ─────────────────────────────────
|
|
24
|
+
/** 값을 개행 종결 JSON 한 줄로 인코딩(TCP 전송 단위). */
|
|
25
|
+
export function encodeLine(value) {
|
|
26
|
+
return JSON.stringify(value) + '\n';
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* TCP 청크를 누적해 완결된 라인(JSON)만 파싱해 내는 디코더를 만든다.
|
|
30
|
+
* 소켓은 메시지 경계를 보장하지 않으므로(청크 분할·병합) 개행으로 잘라
|
|
31
|
+
* 완성된 줄만 넘긴다. 파싱 불가한 줄은 건너뛴다.
|
|
32
|
+
*/
|
|
33
|
+
export function createLineDecoder() {
|
|
34
|
+
let buf = '';
|
|
35
|
+
return (chunk) => {
|
|
36
|
+
buf += chunk;
|
|
37
|
+
const out = [];
|
|
38
|
+
let idx;
|
|
39
|
+
while ((idx = buf.indexOf('\n')) >= 0) {
|
|
40
|
+
const line = buf.slice(0, idx);
|
|
41
|
+
buf = buf.slice(idx + 1);
|
|
42
|
+
if (!line.trim())
|
|
43
|
+
continue;
|
|
44
|
+
try {
|
|
45
|
+
out.push(JSON.parse(line));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// 손상 라인 무시(다음 라인부터 정상 복구)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** JSON 을 NATS 페이로드(Uint8Array)로. */
|
|
55
|
+
export function encodeJson(value) {
|
|
56
|
+
return new TextEncoder().encode(JSON.stringify(value));
|
|
57
|
+
}
|
|
58
|
+
/** NATS 페이로드(Uint8Array)를 JSON 으로. 실패 시 undefined. */
|
|
59
|
+
export function decodeJson(bytes) {
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(new TextDecoder().decode(bytes));
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { GaonNats } from './nats.js';
|
|
2
|
+
import type { ChannelDef } from './channel.js';
|
|
3
|
+
import { type PresenceClient } from './presence.js';
|
|
4
|
+
/** 전송 계층이 구현하는 소켓 인터페이스. */
|
|
5
|
+
export interface SocketAdapter {
|
|
6
|
+
send(text: string): void;
|
|
7
|
+
close(code?: number, reason?: string): void;
|
|
8
|
+
}
|
|
9
|
+
export interface ChannelRuntimeOptions {
|
|
10
|
+
readonly nats: GaonNats;
|
|
11
|
+
/** 이 웹서버 식별자. */
|
|
12
|
+
readonly server: string;
|
|
13
|
+
/** 프레즌스 클라이언트(주입). 생략 시 내부 생성. */
|
|
14
|
+
readonly presence?: PresenceClient;
|
|
15
|
+
/** 허브 TCP 주소 'host:port'(errata E-2). 생략 시 NATS KV 공지 발견. */
|
|
16
|
+
readonly hubAddr?: string;
|
|
17
|
+
/** 프레즌스 ping 주기(ms) — 반열림 백스톱. */
|
|
18
|
+
readonly heartbeatMs?: number;
|
|
19
|
+
}
|
|
20
|
+
export interface JoinRequest<User = unknown> {
|
|
21
|
+
readonly channel: string;
|
|
22
|
+
readonly def: ChannelDef<User>;
|
|
23
|
+
readonly member: string;
|
|
24
|
+
readonly user: User | null;
|
|
25
|
+
readonly query: Readonly<Record<string, string>>;
|
|
26
|
+
readonly socket: SocketAdapter;
|
|
27
|
+
}
|
|
28
|
+
export interface Connection {
|
|
29
|
+
/** 클라이언트가 보낸 원시 텍스트 프레임을 처리한다. */
|
|
30
|
+
receive(raw: string): Promise<void>;
|
|
31
|
+
/** 연결 종료 — 프레즌스 해제·onLeave·구독 정리. */
|
|
32
|
+
leave(): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
export interface ChannelRuntime {
|
|
35
|
+
/** 인가 실패 시 소켓을 닫고 null 을 반환한다. */
|
|
36
|
+
join<User>(req: JoinRequest<User>): Promise<Connection | null>;
|
|
37
|
+
close(): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
export declare function createChannelRuntime(opts: ChannelRuntimeOptions): Promise<ChannelRuntime>;
|
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// @gaonjs/async · 채널 런타임 (웹서버 측 · §7)
|
|
2
|
+
//
|
|
3
|
+
// 웹서버(serve 프로세스)마다 하나. 채널 정의(channel())의 훅을 구동하고,
|
|
4
|
+
// 브로드캐스트는 NATS pub/sub 으로 전 인스턴스에 팬아웃하며(별도 어댑터
|
|
5
|
+
// 불필요 §7 line 891), 프레즌스는 허브와 동기화한다. 소켓 전송은 어댑터로
|
|
6
|
+
// 주입받아 전송 계층(@fastify/websocket)과 분리한다 — 런타임 자체는 실
|
|
7
|
+
// NATS 로 소켓 없이도 테스트할 수 있다.
|
|
8
|
+
import { channelSubject } from './keys.js';
|
|
9
|
+
import { encodeJson, decodeJson } from './protocol.js';
|
|
10
|
+
import { createPresenceClient } from './presence.js';
|
|
11
|
+
export async function createChannelRuntime(opts) {
|
|
12
|
+
const nats = opts.nats;
|
|
13
|
+
const presence = opts.presence ??
|
|
14
|
+
(await createPresenceClient({
|
|
15
|
+
nats,
|
|
16
|
+
server: opts.server,
|
|
17
|
+
hubAddr: opts.hubAddr,
|
|
18
|
+
heartbeatMs: opts.heartbeatMs,
|
|
19
|
+
}));
|
|
20
|
+
const ownPresence = !opts.presence;
|
|
21
|
+
const channels = new Map();
|
|
22
|
+
const sendFrame = (socket, frame) => {
|
|
23
|
+
socket.send(JSON.stringify(frame));
|
|
24
|
+
};
|
|
25
|
+
const deliverToChannel = (channel, frame) => {
|
|
26
|
+
const state = channels.get(channel);
|
|
27
|
+
if (!state)
|
|
28
|
+
return;
|
|
29
|
+
for (const c of state.conns)
|
|
30
|
+
sendFrame(c.socket, frame);
|
|
31
|
+
};
|
|
32
|
+
/** 채널 인프라(브로드캐스트·프레즌스 구독)를 필요 시 최초 1회 만든다. */
|
|
33
|
+
const ensureChannel = (channel) => {
|
|
34
|
+
const existing = channels.get(channel);
|
|
35
|
+
if (existing)
|
|
36
|
+
return existing;
|
|
37
|
+
// 브로드캐스트: 모든 서버가 같은 subject 를 구독 → 발행 1건이 전
|
|
38
|
+
// 인스턴스의 로컬 소켓으로 팬아웃(발행자 자신 포함, 중복 없음).
|
|
39
|
+
const broadcastSub = nats.nc.subscribe(channelSubject(channel));
|
|
40
|
+
(async () => {
|
|
41
|
+
for await (const m of broadcastSub) {
|
|
42
|
+
const env = decodeJson(m.data);
|
|
43
|
+
if (env)
|
|
44
|
+
deliverToChannel(channel, { t: 'msg', data: env.data });
|
|
45
|
+
}
|
|
46
|
+
})();
|
|
47
|
+
// 프레즌스 델타: 허브 방송을 로컬 소켓으로 전달.
|
|
48
|
+
const unsubPresence = presence.subscribe(channel, (ev) => {
|
|
49
|
+
if (ev.type === 'join')
|
|
50
|
+
deliverToChannel(channel, { t: 'presence:join', member: ev.member });
|
|
51
|
+
else
|
|
52
|
+
deliverToChannel(channel, { t: 'presence:leave', member: ev.member });
|
|
53
|
+
});
|
|
54
|
+
const state = { conns: new Set(), broadcastSub, unsubPresence };
|
|
55
|
+
channels.set(channel, state);
|
|
56
|
+
return state;
|
|
57
|
+
};
|
|
58
|
+
const teardownIfEmpty = (channel) => {
|
|
59
|
+
const state = channels.get(channel);
|
|
60
|
+
if (!state || state.conns.size > 0)
|
|
61
|
+
return;
|
|
62
|
+
state.broadcastSub.unsubscribe();
|
|
63
|
+
state.unsubPresence();
|
|
64
|
+
channels.delete(channel);
|
|
65
|
+
};
|
|
66
|
+
return {
|
|
67
|
+
async join(req) {
|
|
68
|
+
// 런타임은 채널을 타입 소거해 다룬다(전송 계층이 User 를 알 필요 없음).
|
|
69
|
+
const def = req.def;
|
|
70
|
+
const authCtx = { channel: req.channel, user: req.user, query: req.query };
|
|
71
|
+
if (def.authorize) {
|
|
72
|
+
const ok = await def.authorize(authCtx);
|
|
73
|
+
if (!ok) {
|
|
74
|
+
sendFrame(req.socket, { t: 'error', message: '채널 인가 거부' });
|
|
75
|
+
req.socket.close(4401, 'unauthorized');
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const info = def.presenceInfo ? def.presenceInfo(authCtx) : {};
|
|
80
|
+
const ctx = {
|
|
81
|
+
channel: req.channel,
|
|
82
|
+
member: req.member,
|
|
83
|
+
user: req.user,
|
|
84
|
+
query: req.query,
|
|
85
|
+
send: (data) => sendFrame(req.socket, { t: 'msg', data }),
|
|
86
|
+
broadcast: (data) => {
|
|
87
|
+
const env = { data };
|
|
88
|
+
nats.nc.publish(channelSubject(req.channel), encodeJson(env));
|
|
89
|
+
},
|
|
90
|
+
presence: () => presence.list(req.channel),
|
|
91
|
+
};
|
|
92
|
+
const state = ensureChannel(req.channel);
|
|
93
|
+
const conn = { member: req.member, socket: req.socket, ctx, def };
|
|
94
|
+
state.conns.add(conn);
|
|
95
|
+
// 허브에 등록(KV 반영까지 대기) → 초기 스냅샷을 KV 권위에서 읽어 전송.
|
|
96
|
+
await presence.join(req.channel, req.member, info);
|
|
97
|
+
const members = await presence.list(req.channel);
|
|
98
|
+
sendFrame(req.socket, { t: 'joined', channel: req.channel, member: req.member });
|
|
99
|
+
sendFrame(req.socket, { t: 'presence', members });
|
|
100
|
+
await def.onJoin?.(ctx);
|
|
101
|
+
let left = false;
|
|
102
|
+
return {
|
|
103
|
+
async receive(raw) {
|
|
104
|
+
const msg = decodeJson(new TextEncoder().encode(raw));
|
|
105
|
+
if (msg?.t === 'msg')
|
|
106
|
+
await def.onMessage?.(ctx, msg.data);
|
|
107
|
+
},
|
|
108
|
+
async leave() {
|
|
109
|
+
if (left)
|
|
110
|
+
return;
|
|
111
|
+
left = true;
|
|
112
|
+
await presence.leave(req.channel, req.member);
|
|
113
|
+
await def.onLeave?.(ctx);
|
|
114
|
+
state.conns.delete(conn);
|
|
115
|
+
teardownIfEmpty(req.channel);
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
},
|
|
119
|
+
async close() {
|
|
120
|
+
for (const [, state] of channels) {
|
|
121
|
+
state.broadcastSub.unsubscribe();
|
|
122
|
+
state.unsubPresence();
|
|
123
|
+
}
|
|
124
|
+
channels.clear();
|
|
125
|
+
if (ownPresence)
|
|
126
|
+
await presence.close();
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { GaonNats } from './nats.js';
|
|
2
|
+
import { type CronMatcher } from './cron.js';
|
|
3
|
+
/** 스케줄 대상(잡). later() 만 있으면 된다 — 순환 의존을 피해 최소 형태로. */
|
|
4
|
+
export interface Schedulable {
|
|
5
|
+
readonly name: string;
|
|
6
|
+
later(...args: never[]): Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
interface EveryEntry {
|
|
9
|
+
readonly kind: 'every';
|
|
10
|
+
readonly intervalMs: number;
|
|
11
|
+
readonly job: Schedulable;
|
|
12
|
+
readonly label: string;
|
|
13
|
+
}
|
|
14
|
+
interface CronEntry {
|
|
15
|
+
readonly kind: 'cron';
|
|
16
|
+
readonly matcher: CronMatcher;
|
|
17
|
+
readonly job: Schedulable;
|
|
18
|
+
readonly label: string;
|
|
19
|
+
}
|
|
20
|
+
export type ScheduleEntry = EveryEntry | CronEntry;
|
|
21
|
+
/** 하루 중 시각 지정 빌더(`s.daily.at('04:00', Job)`). */
|
|
22
|
+
export interface DailyBuilder {
|
|
23
|
+
at(hhmm: string, job: Schedulable): void;
|
|
24
|
+
}
|
|
25
|
+
export interface ScheduleBuilder {
|
|
26
|
+
/** 고정 간격 반복. '10m'·30_000(ms). 스케줄러 시작 후 interval 마다. */
|
|
27
|
+
every(interval: string | number, job: Schedulable): void;
|
|
28
|
+
/** 매일 지정 시각(로컬 타임존). 'HH:MM'. */
|
|
29
|
+
readonly daily: DailyBuilder;
|
|
30
|
+
/** 크론식(5필드). 탈출구. */
|
|
31
|
+
cron(expr: string, job: Schedulable): void;
|
|
32
|
+
}
|
|
33
|
+
export interface ScheduleDef {
|
|
34
|
+
readonly entries: readonly ScheduleEntry[];
|
|
35
|
+
}
|
|
36
|
+
/** 스케줄을 선언한다. 콜백에서 s.every/daily.at/cron 으로 항목을 등록한다. */
|
|
37
|
+
export declare function schedule(build: (s: ScheduleBuilder) => void): ScheduleDef;
|
|
38
|
+
export type SchedulerEvent = {
|
|
39
|
+
readonly kind: 'leader';
|
|
40
|
+
readonly leader: boolean;
|
|
41
|
+
} | {
|
|
42
|
+
readonly kind: 'fired';
|
|
43
|
+
readonly job: string;
|
|
44
|
+
readonly label: string;
|
|
45
|
+
} | {
|
|
46
|
+
readonly kind: 'error';
|
|
47
|
+
readonly error: string;
|
|
48
|
+
};
|
|
49
|
+
export interface SchedulerOptions {
|
|
50
|
+
readonly nats: GaonNats;
|
|
51
|
+
readonly def: ScheduleDef;
|
|
52
|
+
/** 인스턴스 식별자(리스 값). */
|
|
53
|
+
readonly id: string;
|
|
54
|
+
/** 리스 TTL(ms). 기본 leaseLeader 기본(5000). */
|
|
55
|
+
readonly ttlMs?: number;
|
|
56
|
+
/** 틱 주기(ms). 기본 1000 — 분 경계 크론을 놓치지 않게 1초 이하 권장. */
|
|
57
|
+
readonly tickMs?: number;
|
|
58
|
+
/** 시각 주입(테스트 결정성). 기본 Date.now. */
|
|
59
|
+
now?(): number;
|
|
60
|
+
onEvent?(e: SchedulerEvent): void;
|
|
61
|
+
}
|
|
62
|
+
export interface SchedulerHandle {
|
|
63
|
+
readonly isLeader: boolean;
|
|
64
|
+
stop(): Promise<void>;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* 스케줄러를 시작한다. 리더로 선출된 인스턴스만 틱을 발행한다. 리더십을
|
|
68
|
+
* 잃으면 발행을 멈추고, 되찾으면 재개한다(중복 발행 없음).
|
|
69
|
+
*/
|
|
70
|
+
export declare function runScheduler(opts: SchedulerOptions): Promise<SchedulerHandle>;
|
|
71
|
+
export {};
|
package/dist/schedule.js
ADDED
|
@@ -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>;
|
package/dist/streams.js
ADDED
|
@@ -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>;
|