@cartanova/qgrid-cli 1.7.3 → 1.8.0
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/bundle/dist/application/qgrid/qgrid.dispatcher.js +45 -32
- package/bundle/dist/application/qgrid/qgrid.frame.js +7 -22
- package/bundle/dist/application/qgrid/qgrid.types.js +10 -3
- package/bundle/dist/application/qgrid/token-subscriber.js +149 -0
- package/bundle/dist/application/qgrid/token-trigger-setup.js +69 -0
- package/bundle/dist/application/sonamu.generated.sso.js +1 -1
- package/bundle/dist/application/token/token.types.js +1 -1
- package/bundle/dist/i18n/sd.generated.js +2 -2
- package/bundle/dist/migrations/20260410171405_create__request_logs.js +1 -1
- package/bundle/dist/migrations/20260410171406_create__tokens.js +1 -1
- package/bundle/dist/migrations/20260411120624_alter_tokens_alter2.js +1 -1
- package/bundle/dist/migrations/20260415134649_alter_request_logs_alter1.js +1 -1
- package/bundle/dist/migrations/20260421223627_alter_request_logs_add1_alter1.js +1 -1
- package/bundle/dist/migrations/20260423180818_alter_request_logs_add1_alter1.js +1 -1
- package/bundle/dist/migrations/20260423182017_alter_request_logs_add2_drop1_alter1.js +1 -1
- package/bundle/dist/migrations/20260423183613_alter_request_logs_add1_alter1.js +1 -1
- package/bundle/dist/sonamu.config.js +22 -36
- package/bundle/dist/testing/setup-mocks.js +1 -1
- package/bundle/dist/utils/subset-loaders.js +1 -1
- package/bundle/src/application/qgrid/qgrid.dispatcher.ts +68 -32
- package/bundle/src/application/qgrid/qgrid.frame.ts +8 -25
- package/bundle/src/application/qgrid/qgrid.types.ts +9 -0
- package/bundle/src/application/qgrid/token-subscriber.ts +176 -0
- package/bundle/src/application/qgrid/token-trigger-setup.ts +80 -0
- package/bundle/src/application/request-log/request-log.entity.json +3 -1
- package/bundle/src/application/sonamu.generated.sso.ts +5 -5
- package/bundle/src/application/token/token.types.ts +1 -1
- package/bundle/src/i18n/sd.generated.ts +1 -1
- package/bundle/src/migrations/20260410171405_create__request_logs.ts +1 -1
- package/bundle/src/migrations/20260410171406_create__tokens.ts +1 -1
- package/bundle/src/migrations/20260411120624_alter_tokens_alter2.ts +1 -1
- package/bundle/src/migrations/20260415134649_alter_request_logs_alter1.ts +1 -1
- package/bundle/src/migrations/20260421223627_alter_request_logs_add1_alter1.ts +1 -1
- package/bundle/src/migrations/20260423180818_alter_request_logs_add1_alter1.ts +1 -1
- package/bundle/src/migrations/20260423182017_alter_request_logs_add2_drop1_alter1.ts +1 -1
- package/bundle/src/migrations/20260423183613_alter_request_logs_add1_alter1.ts +1 -1
- package/bundle/src/sonamu.config.ts +33 -39
- package/bundle/src/testing/setup-mocks.ts +1 -1
- package/bundle/src/typings/fastify.d.ts +1 -1
- package/bundle/src/typings/sonamu.d.ts +3 -2
- package/bundle/src/utils/subset-loaders.ts +1 -1
- package/bundle/web-dist/client/assets/{index-DUmwX7rR.js → index-DzEE3oyG.js} +3 -3
- package/bundle/web-dist/client/assets/{logs-ci_3vzTT.js → logs-Bn56eAAL.js} +1 -1
- package/bundle/web-dist/client/assets/{routes-Ch5LFN0K.js → routes-BCEPwpzm.js} +1 -1
- package/bundle/web-dist/client/assets/{sd.generated-CIOZewtL.js → sd.generated-C9WnMJm4.js} +1 -1
- package/bundle/web-dist/client/assets/{services.generated-BrB-RELO.js → services.generated-CWN1-bYW.js} +1 -1
- package/bundle/web-dist/client/assets/{show-D3N15Krg.js → show-Ch7LEdBK.js} +1 -1
- package/bundle/web-dist/client/assets/tokens-DPvuQIs2.js +1 -0
- package/bundle/web-dist/client/index.html +2 -2
- package/bundle/web-dist/server/assets/{copy-CKCGxwII.js → copy-SIUvcEcZ.js} +1 -1
- package/bundle/web-dist/server/assets/{lazyRouteComponent-BlvOfJIF.js → lazyRouteComponent-CcyhCAoL.js} +2 -2
- package/bundle/web-dist/server/assets/{logs-CDacVwTQ.js → logs-D1ezT2IS.js} +3 -3
- package/bundle/web-dist/server/assets/{routes-BLm-zF86.js → routes-CtTFrsIz.js} +2 -2
- package/bundle/web-dist/server/assets/{sd.generated-CIg_jf8e.js → sd.generated-CkiEsOwK.js} +1 -1
- package/bundle/web-dist/server/assets/{services.generated-wCfUuY7Z.js → services.generated-D4asAlWb.js} +1 -1
- package/bundle/web-dist/server/assets/{shim-Dq_Izqnc.js → shim-BYpcQGN1.js} +1 -1
- package/bundle/web-dist/server/assets/{show-DjJyZunM.js → show-Bz0If-L7.js} +4 -4
- package/bundle/web-dist/server/assets/{tokens-rQIIgqMo.js → tokens-BBBfLC7N.js} +11 -4
- package/bundle/web-dist/server/entry-server.generated.js +7 -7
- package/package.json +1 -1
- package/bundle/web-dist/client/assets/tokens-BAlIaa6X.js +0 -1
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* - 매 요청마다 새 claude CLI 프로세스 spawn → 응답 후 종료
|
|
5
5
|
* - system 은 --append-system-prompt 로 분리 전달 (user turn 오염 방지)
|
|
6
6
|
* - least-used round-robin 으로 토큰 선택
|
|
7
|
+
* - 메모리 캐시 (Map<id, TokenSubsetA>) 는 TokenSubscriber 가 pg LISTEN/NOTIFY 로 갱신
|
|
8
|
+
* - query 시 expires_at 비교 → 임박 시 preemptive refresh
|
|
7
9
|
* - QuotaError 는 그대로 상위 전파 (자동 failover 없음, UI 에서 수동 토글)
|
|
8
10
|
*
|
|
9
11
|
* env allowlist: PATH, TMPDIR, CLAUDE_CODE_OAUTH_TOKEN + CLAUDE_CODE_DISABLE_*
|
|
@@ -13,8 +15,10 @@ import { mkdirSync, writeFileSync } from "node:fs";
|
|
|
13
15
|
|
|
14
16
|
import { getLogger } from "@logtape/logtape";
|
|
15
17
|
|
|
18
|
+
import { type TokenSubsetA } from "../sonamu.generated";
|
|
16
19
|
import { type CliResult, type QueryInput, type TokenStats } from "./qgrid.types";
|
|
17
20
|
import { maskToken, ProcessError, QuotaError, TimeoutError } from "./qgrid.types";
|
|
21
|
+
import { type TokenSubscriber } from "./token-subscriber";
|
|
18
22
|
|
|
19
23
|
const logger = getLogger(["qgrid"]);
|
|
20
24
|
|
|
@@ -32,11 +36,19 @@ const QGRID_CLAUDE_SETTINGS = {
|
|
|
32
36
|
cleanupPeriodDays: 1,
|
|
33
37
|
};
|
|
34
38
|
|
|
35
|
-
|
|
36
|
-
|
|
39
|
+
// 토큰 만료 임박 임계값 — token.expiredAt이 1분 안에 만료된다면 query 시 체크하고 refresh.
|
|
40
|
+
const REFRESH_SAFETY_MS = 60_000;
|
|
41
|
+
|
|
42
|
+
export class QgridDispatcherClass {
|
|
43
|
+
tokens = new Map<number, TokenSubsetA>();
|
|
44
|
+
|
|
45
|
+
// key기반(tokenName) 누적 카운터. token 이 OAuth refresh 로 로테이트되도 tokenName 은 불변이라 카운터 유지됨
|
|
37
46
|
requestCounts = new Map<string, number>();
|
|
38
47
|
rrIndex = 0;
|
|
39
48
|
|
|
49
|
+
// sonamu.config onStart 에서 처리하는 변수
|
|
50
|
+
subscriber: TokenSubscriber | null = null;
|
|
51
|
+
|
|
40
52
|
constructor() {
|
|
41
53
|
mkdirSync(`${CLAUDE_CWD}/.claude`, { recursive: true });
|
|
42
54
|
writeFileSync(
|
|
@@ -45,51 +57,75 @@ class QgridDispatcherClass {
|
|
|
45
57
|
);
|
|
46
58
|
}
|
|
47
59
|
|
|
60
|
+
countOf(name: string): number {
|
|
61
|
+
return this.requestCounts.get(name) ?? 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// TokenSubscriber 콜백 — 캐시 mutation
|
|
65
|
+
upsertCache(id: number, row: TokenSubsetA): void {
|
|
66
|
+
this.tokens.set(id, row);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
removeCache(id: number): void {
|
|
70
|
+
this.tokens.delete(id);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
replaceCache(rows: TokenSubsetA[]): void {
|
|
74
|
+
this.tokens = new Map(rows.map((r) => [r.id, r]));
|
|
75
|
+
}
|
|
76
|
+
|
|
48
77
|
getStats(): TokenStats[] {
|
|
49
|
-
return [...this.tokens.
|
|
50
|
-
token,
|
|
51
|
-
name,
|
|
52
|
-
requests: this.
|
|
78
|
+
return [...this.tokens.values()].map((r) => ({
|
|
79
|
+
token: r.token,
|
|
80
|
+
name: r.name,
|
|
81
|
+
requests: this.countOf(r.name),
|
|
53
82
|
}));
|
|
54
83
|
}
|
|
55
84
|
|
|
56
|
-
selectToken():
|
|
57
|
-
const
|
|
58
|
-
if (
|
|
85
|
+
selectToken(): TokenSubsetA | null {
|
|
86
|
+
const rows = [...this.tokens.values()];
|
|
87
|
+
if (rows.length === 0) return null;
|
|
59
88
|
|
|
60
|
-
const minCount = Math.min(...
|
|
61
|
-
const idle =
|
|
89
|
+
const minCount = Math.min(...rows.map((r) => this.countOf(r.name)));
|
|
90
|
+
const idle = rows.filter((r) => this.countOf(r.name) === minCount);
|
|
62
91
|
const picked = idle[this.rrIndex % idle.length]!;
|
|
63
92
|
this.rrIndex++;
|
|
64
|
-
return
|
|
93
|
+
return picked;
|
|
65
94
|
}
|
|
66
95
|
|
|
67
96
|
async query(input: QueryInput, timeoutMs?: number): Promise<CliResult> {
|
|
68
|
-
const
|
|
69
|
-
if (!
|
|
97
|
+
const electedToken = this.selectToken();
|
|
98
|
+
if (!electedToken) throw new QuotaError("No tokens available");
|
|
70
99
|
|
|
71
100
|
// await 전에 count 선반영. 병렬 요청이 동시에 도착해도 각자 다른 토큰을 고르도록.
|
|
72
|
-
this.requestCounts.set(
|
|
73
|
-
|
|
74
|
-
logger.info(`→ ${sel.name} (model: ${input.model ?? DEFAULT_MODEL})`);
|
|
75
|
-
|
|
76
|
-
const result = await executeClaude(input, sel.token, timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
77
|
-
return { ...result, tokenName: sel.name, model: input.model ?? DEFAULT_MODEL };
|
|
78
|
-
}
|
|
101
|
+
this.requestCounts.set(electedToken.name, this.countOf(electedToken.name) + 1);
|
|
79
102
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
103
|
+
let token = electedToken.token;
|
|
104
|
+
// expires_at 임박이면 refresh.
|
|
105
|
+
// refresh 후 DB save → trigger NOTIFY → subscriber 가 받아 캐시 갱신 (다른 dispatcher 도 동기화)
|
|
106
|
+
if (
|
|
107
|
+
electedToken.expires_at &&
|
|
108
|
+
Number(electedToken.expires_at) - Date.now() < REFRESH_SAFETY_MS &&
|
|
109
|
+
electedToken.refresh_token
|
|
110
|
+
) {
|
|
111
|
+
try {
|
|
112
|
+
const { QgridFrame } = await import("./qgrid.frame");
|
|
113
|
+
token = await QgridFrame.refreshToken({
|
|
114
|
+
id: electedToken.id,
|
|
115
|
+
token: electedToken.token,
|
|
116
|
+
name: electedToken.name,
|
|
117
|
+
refresh_token: electedToken.refresh_token,
|
|
118
|
+
});
|
|
119
|
+
} catch (e) {
|
|
120
|
+
logger.warn(`refresh failed for ${electedToken.name}: ${(e as Error).message}`);
|
|
121
|
+
// refresh 실패 시 기존 token 으로 진행. executeClaude 가 401 받으면 caller 처리.
|
|
122
|
+
}
|
|
123
|
+
}
|
|
85
124
|
|
|
86
|
-
|
|
87
|
-
this.tokens.delete(token);
|
|
88
|
-
this.requestCounts.delete(token);
|
|
89
|
-
}
|
|
125
|
+
logger.info(`→ ${electedToken.name} (model: ${input.model ?? DEFAULT_MODEL})`);
|
|
90
126
|
|
|
91
|
-
|
|
92
|
-
return
|
|
127
|
+
const result = await executeClaude(input, token, timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
128
|
+
return { ...result, tokenName: electedToken.name, model: input.model ?? DEFAULT_MODEL };
|
|
93
129
|
}
|
|
94
130
|
}
|
|
95
131
|
|
|
@@ -85,7 +85,6 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
85
85
|
...(refreshToken && refreshToken.length > 0 ? { refresh_token: refreshToken } : {}),
|
|
86
86
|
},
|
|
87
87
|
]);
|
|
88
|
-
QgridDispatcher.addToken(token, name);
|
|
89
88
|
return { added: true };
|
|
90
89
|
}
|
|
91
90
|
|
|
@@ -109,28 +108,19 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
109
108
|
...(hasRefreshToken ? { refresh_token: refreshToken } : {}),
|
|
110
109
|
},
|
|
111
110
|
]);
|
|
112
|
-
|
|
113
|
-
if (hasNewToken) {
|
|
114
|
-
QgridDispatcher.removeToken(token);
|
|
115
|
-
QgridDispatcher.addToken(newToken, name ?? entry.name);
|
|
116
|
-
}
|
|
117
111
|
return { updated: true };
|
|
118
112
|
}
|
|
119
113
|
|
|
120
114
|
@api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
|
|
121
115
|
async removeToken(token: string): Promise<{ removed: boolean }> {
|
|
122
|
-
if (!QgridDispatcher.hasToken(token)) return { removed: false };
|
|
123
|
-
|
|
124
116
|
const entry = await TokenModel.findByToken("A", token);
|
|
125
|
-
if (entry)
|
|
126
|
-
|
|
117
|
+
if (!entry) return { removed: false };
|
|
118
|
+
await TokenModel.del([entry.id]);
|
|
127
119
|
return { removed: true };
|
|
128
120
|
}
|
|
129
121
|
|
|
130
122
|
/**
|
|
131
|
-
*
|
|
132
|
-
* 토큰 활성화/비활성화 토글 (dispatcher 등록/제거 & DB의 active 필드 업데이트)
|
|
133
|
-
* @returns 토큰 활성화 여부
|
|
123
|
+
* 토큰 활성화/비활성화 토글 DB의 active 필드 업데이트
|
|
134
124
|
*/
|
|
135
125
|
@api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
|
|
136
126
|
async toggleToken(id: number): Promise<{ active: boolean }> {
|
|
@@ -139,13 +129,6 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
139
129
|
|
|
140
130
|
const newActive = !entry.active;
|
|
141
131
|
await TokenModel.save([{ id, token: entry.token, active: newActive, name: entry.name }]);
|
|
142
|
-
|
|
143
|
-
if (newActive) {
|
|
144
|
-
QgridDispatcher.addToken(entry.token, entry.name);
|
|
145
|
-
} else {
|
|
146
|
-
QgridDispatcher.removeToken(entry.token);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
132
|
return { active: newActive };
|
|
150
133
|
}
|
|
151
134
|
|
|
@@ -181,7 +164,6 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
181
164
|
if (tokens.accountUuid) {
|
|
182
165
|
const oldEntries = await TokenModel.findByAccountUuid("A", tokens.accountUuid);
|
|
183
166
|
if (oldEntries.length > 0) {
|
|
184
|
-
for (const old of oldEntries) QgridDispatcher.removeToken(old.token);
|
|
185
167
|
await TokenModel.del(oldEntries.map((o) => o.id));
|
|
186
168
|
}
|
|
187
169
|
}
|
|
@@ -195,7 +177,6 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
195
177
|
account_uuid: tokens.accountUuid,
|
|
196
178
|
},
|
|
197
179
|
]);
|
|
198
|
-
QgridDispatcher.addToken(tokens.accessToken, pending.name);
|
|
199
180
|
|
|
200
181
|
return reply.redirect(`/?oauth=success&name=${encodeURIComponent(pending.name)}`);
|
|
201
182
|
} catch (e) {
|
|
@@ -251,14 +232,16 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
251
232
|
name: token.name ?? "",
|
|
252
233
|
},
|
|
253
234
|
]);
|
|
254
|
-
QgridDispatcher.removeToken(token.token);
|
|
255
|
-
QgridDispatcher.addToken(refreshed.accessToken, token.name);
|
|
256
235
|
return refreshed.accessToken;
|
|
257
236
|
}
|
|
258
237
|
|
|
259
238
|
@api({ httpMethod: "GET", clients: ["axios", "tanstack-query"] })
|
|
260
239
|
async health(): Promise<HealthResponse> {
|
|
261
|
-
return {
|
|
240
|
+
return {
|
|
241
|
+
status: "ok",
|
|
242
|
+
activeTokens: QgridDispatcher.tokens.size,
|
|
243
|
+
subscriber: QgridDispatcher.subscriber?.status() ?? null,
|
|
244
|
+
};
|
|
262
245
|
}
|
|
263
246
|
}
|
|
264
247
|
|
|
@@ -82,9 +82,18 @@ export type UsageResponse = z.infer<typeof UsageResponse>;
|
|
|
82
82
|
|
|
83
83
|
// ─── Health ───
|
|
84
84
|
|
|
85
|
+
export const SubscriberStatus = z.object({
|
|
86
|
+
connected: z.boolean(),
|
|
87
|
+
connectedAt: z.date().nullable(),
|
|
88
|
+
lastReconcileAt: z.date().nullable(),
|
|
89
|
+
attempt: z.number(),
|
|
90
|
+
});
|
|
91
|
+
export type SubscriberStatus = z.infer<typeof SubscriberStatus>;
|
|
92
|
+
|
|
85
93
|
export const HealthResponse = z.object({
|
|
86
94
|
status: z.string(),
|
|
87
95
|
activeTokens: z.number(),
|
|
96
|
+
subscriber: SubscriberStatus.nullable(),
|
|
88
97
|
});
|
|
89
98
|
export type HealthResponse = z.infer<typeof HealthResponse>;
|
|
90
99
|
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/** tokens_changed LISTEN subscriber. */
|
|
2
|
+
import { getLogger } from "@logtape/logtape";
|
|
3
|
+
import { Client, type ClientConfig } from "pg";
|
|
4
|
+
|
|
5
|
+
import { TokenModel } from "../token/token.model";
|
|
6
|
+
import { type QgridDispatcherClass } from "./qgrid.dispatcher";
|
|
7
|
+
import { type SubscriberStatus } from "./qgrid.types";
|
|
8
|
+
|
|
9
|
+
const logger = getLogger(["qgrid", "subscriber"]);
|
|
10
|
+
|
|
11
|
+
const TOKENS_CHANGED = "tokens_changed";
|
|
12
|
+
const RECONCILE_INTERVAL_MS = 10 * 60 * 1000;
|
|
13
|
+
const CONNECTION_TIMEOUT_MS = 5_000;
|
|
14
|
+
const BACKOFF_BASE_MS = 500;
|
|
15
|
+
const BACKOFF_CAP_MS = 30_000;
|
|
16
|
+
|
|
17
|
+
type Payload = { op: "INSERT" | "UPDATE" | "DELETE"; id: number };
|
|
18
|
+
|
|
19
|
+
export class TokenSubscriber {
|
|
20
|
+
client: Client | null = null;
|
|
21
|
+
reconcileTimer: NodeJS.Timeout | null = null;
|
|
22
|
+
reconnectTimer: NodeJS.Timeout | null = null;
|
|
23
|
+
shutdownRequested = false;
|
|
24
|
+
attempt = 0;
|
|
25
|
+
connectedAt: Date | null = null;
|
|
26
|
+
lastReconcileAt: Date | null = null;
|
|
27
|
+
|
|
28
|
+
constructor(
|
|
29
|
+
public connConfig: ClientConfig,
|
|
30
|
+
public dispatcher: QgridDispatcherClass,
|
|
31
|
+
) {}
|
|
32
|
+
|
|
33
|
+
async start(): Promise<boolean> {
|
|
34
|
+
this.shutdownRequested = false;
|
|
35
|
+
this.ensureReconcileTimer();
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
await this.connectAndReconcile();
|
|
39
|
+
return true;
|
|
40
|
+
} catch (e) {
|
|
41
|
+
logger.warn(`subscriber start failed: ${(e as Error).message}, scheduling retry`);
|
|
42
|
+
this.retryLater();
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async stop(): Promise<void> {
|
|
48
|
+
this.shutdownRequested = true;
|
|
49
|
+
if (this.reconcileTimer) {
|
|
50
|
+
clearInterval(this.reconcileTimer);
|
|
51
|
+
this.reconcileTimer = null;
|
|
52
|
+
}
|
|
53
|
+
if (this.reconnectTimer) {
|
|
54
|
+
clearTimeout(this.reconnectTimer);
|
|
55
|
+
this.reconnectTimer = null;
|
|
56
|
+
}
|
|
57
|
+
await this.closeClient();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
status(): SubscriberStatus {
|
|
61
|
+
return {
|
|
62
|
+
connected: this.client !== null && this.reconnectTimer === null,
|
|
63
|
+
connectedAt: this.connectedAt,
|
|
64
|
+
lastReconcileAt: this.lastReconcileAt,
|
|
65
|
+
attempt: this.attempt,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
ensureReconcileTimer(): void {
|
|
70
|
+
if (this.reconcileTimer) return;
|
|
71
|
+
|
|
72
|
+
// LISTEN/NOTIFY 는 끊긴 동안 유실될 수 있어 주기적으로 DB 기준으로 맞춘다.
|
|
73
|
+
this.reconcileTimer = setInterval(() => {
|
|
74
|
+
this.reconcile().catch((e) =>
|
|
75
|
+
logger.warn(`periodic reconcile failed: ${(e as Error).message}`),
|
|
76
|
+
);
|
|
77
|
+
}, RECONCILE_INTERVAL_MS);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async connectAndReconcile(): Promise<void> {
|
|
81
|
+
await this.connectAndListen();
|
|
82
|
+
await this.reconcile();
|
|
83
|
+
this.attempt = 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async connectAndListen(): Promise<void> {
|
|
87
|
+
const client = new Client({
|
|
88
|
+
...this.connConfig,
|
|
89
|
+
connectionTimeoutMillis: CONNECTION_TIMEOUT_MS,
|
|
90
|
+
keepAlive: true,
|
|
91
|
+
keepAliveInitialDelayMillis: 10_000,
|
|
92
|
+
application_name: "qgrid-listener",
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
client.on("error", (e) => {
|
|
96
|
+
logger.warn(`subscriber error: ${e.message}`);
|
|
97
|
+
this.retryLater();
|
|
98
|
+
});
|
|
99
|
+
client.on("end", () => {
|
|
100
|
+
if (!this.shutdownRequested) this.retryLater();
|
|
101
|
+
});
|
|
102
|
+
client.on("notification", (msg) => {
|
|
103
|
+
if (msg.channel !== TOKENS_CHANGED || !msg.payload) return;
|
|
104
|
+
this.handleNotification(msg.payload).catch((e) =>
|
|
105
|
+
logger.warn(`handle NOTIFY failed: ${(e as Error).message}`),
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
await client.connect();
|
|
111
|
+
await client.query("SET statement_timeout = 0");
|
|
112
|
+
await client.query("SET idle_in_transaction_session_timeout = 0");
|
|
113
|
+
await client.query(`LISTEN ${client.escapeIdentifier(TOKENS_CHANGED)}`);
|
|
114
|
+
} catch (e) {
|
|
115
|
+
client.removeAllListeners();
|
|
116
|
+
await client.end().catch(() => {});
|
|
117
|
+
throw e;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
this.client = client;
|
|
121
|
+
this.connectedAt = new Date();
|
|
122
|
+
logger.info(`subscribed to ${TOKENS_CHANGED}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
retryLater(): void {
|
|
126
|
+
if (this.shutdownRequested) return;
|
|
127
|
+
if (this.reconnectTimer) return;
|
|
128
|
+
|
|
129
|
+
void this.closeClient();
|
|
130
|
+
this.attempt += 1;
|
|
131
|
+
const cap = Math.min(BACKOFF_BASE_MS * 2 ** this.attempt, BACKOFF_CAP_MS);
|
|
132
|
+
const delay = Math.floor(Math.random() * cap);
|
|
133
|
+
logger.info(`reconnecting in ${delay}ms (attempt ${this.attempt})`);
|
|
134
|
+
this.reconnectTimer = setTimeout(() => {
|
|
135
|
+
this.reconnectTimer = null;
|
|
136
|
+
this.connectAndReconcile().catch((e) => {
|
|
137
|
+
logger.warn(`subscriber reconnect failed: ${(e as Error).message}`);
|
|
138
|
+
this.retryLater();
|
|
139
|
+
});
|
|
140
|
+
}, delay);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async closeClient(): Promise<void> {
|
|
144
|
+
const client = this.client;
|
|
145
|
+
if (!client) return;
|
|
146
|
+
|
|
147
|
+
this.client = null;
|
|
148
|
+
client.removeAllListeners();
|
|
149
|
+
await client.end().catch(() => {});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async handleNotification(payloadJson: string): Promise<void> {
|
|
153
|
+
const payload = JSON.parse(payloadJson) as Payload;
|
|
154
|
+
if (payload.op === "DELETE") {
|
|
155
|
+
this.dispatcher.removeCache(payload.id);
|
|
156
|
+
logger.info(`NOTIFY ${payload.op} id=${payload.id} → removed from cache`);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const row = await TokenModel.findOne("A", { id: payload.id });
|
|
160
|
+
if (!row || !row.active) {
|
|
161
|
+
this.dispatcher.removeCache(payload.id);
|
|
162
|
+
logger.info(
|
|
163
|
+
`NOTIFY ${payload.op} id=${payload.id} (${row?.name ?? "?"}) active=${row?.active ?? "missing"} → removed from cache`,
|
|
164
|
+
);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
this.dispatcher.upsertCache(payload.id, row);
|
|
168
|
+
logger.info(`NOTIFY ${payload.op} id=${payload.id} (${row.name}) active=true → cache upserted`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async reconcile(): Promise<void> {
|
|
172
|
+
const rows = await TokenModel.findActive("A");
|
|
173
|
+
this.dispatcher.replaceCache(rows);
|
|
174
|
+
this.lastReconcileAt = new Date();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/** One-time setup for public.tokens -> tokens_changed NOTIFY triggers. */
|
|
2
|
+
import { getLogger } from "@logtape/logtape";
|
|
3
|
+
import { Client, type ClientConfig } from "pg";
|
|
4
|
+
|
|
5
|
+
const logger = getLogger(["qgrid", "trigger-setup"]);
|
|
6
|
+
|
|
7
|
+
const CONNECTION_TIMEOUT_MS = 5_000;
|
|
8
|
+
const SETUP_LOCK_TIMEOUT_MS = 3_000;
|
|
9
|
+
const SETUP_STATEMENT_TIMEOUT_MS = 10_000;
|
|
10
|
+
|
|
11
|
+
const ADVISORY_LOCK_CLASS_ID = 717;
|
|
12
|
+
const ADVISORY_LOCK_OBJECT_ID = 44900;
|
|
13
|
+
|
|
14
|
+
const SETUP_SQL = `
|
|
15
|
+
CREATE OR REPLACE FUNCTION public.tokens_notify() RETURNS trigger
|
|
16
|
+
LANGUAGE plpgsql
|
|
17
|
+
SET search_path = pg_catalog, public
|
|
18
|
+
AS $func$
|
|
19
|
+
BEGIN
|
|
20
|
+
PERFORM pg_notify('tokens_changed', json_build_object(
|
|
21
|
+
'op', TG_OP,
|
|
22
|
+
'id', COALESCE(NEW.id, OLD.id)
|
|
23
|
+
)::text);
|
|
24
|
+
RETURN COALESCE(NEW, OLD);
|
|
25
|
+
END;
|
|
26
|
+
$func$;
|
|
27
|
+
|
|
28
|
+
DROP TRIGGER IF EXISTS tokens_changed_trigger ON public.tokens;
|
|
29
|
+
DROP TRIGGER IF EXISTS tokens_changed ON public.tokens;
|
|
30
|
+
|
|
31
|
+
CREATE OR REPLACE TRIGGER tokens_changed_ins_del
|
|
32
|
+
AFTER INSERT OR DELETE ON public.tokens
|
|
33
|
+
FOR EACH ROW EXECUTE FUNCTION public.tokens_notify();
|
|
34
|
+
|
|
35
|
+
CREATE OR REPLACE TRIGGER tokens_changed_upd
|
|
36
|
+
AFTER UPDATE ON public.tokens
|
|
37
|
+
FOR EACH ROW
|
|
38
|
+
WHEN (
|
|
39
|
+
OLD.active IS DISTINCT FROM NEW.active OR
|
|
40
|
+
OLD.token IS DISTINCT FROM NEW.token OR
|
|
41
|
+
OLD.refresh_token IS DISTINCT FROM NEW.refresh_token OR
|
|
42
|
+
OLD.expires_at IS DISTINCT FROM NEW.expires_at OR
|
|
43
|
+
OLD.name IS DISTINCT FROM NEW.name
|
|
44
|
+
)
|
|
45
|
+
EXECUTE FUNCTION public.tokens_notify();
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
export async function ensureTokensTrigger(connConfig: ClientConfig): Promise<void> {
|
|
49
|
+
const client = new Client({
|
|
50
|
+
...connConfig,
|
|
51
|
+
connectionTimeoutMillis: CONNECTION_TIMEOUT_MS,
|
|
52
|
+
application_name: "qgrid-trigger-setup",
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
let lockAcquired = false;
|
|
56
|
+
try {
|
|
57
|
+
await client.connect();
|
|
58
|
+
await client.query("SET search_path TO public");
|
|
59
|
+
await client.query(`SET lock_timeout = '${SETUP_LOCK_TIMEOUT_MS}ms'`);
|
|
60
|
+
await client.query(`SET statement_timeout = '${SETUP_STATEMENT_TIMEOUT_MS}ms'`);
|
|
61
|
+
await client.query("SELECT pg_advisory_lock($1, $2)", [
|
|
62
|
+
ADVISORY_LOCK_CLASS_ID,
|
|
63
|
+
ADVISORY_LOCK_OBJECT_ID,
|
|
64
|
+
]);
|
|
65
|
+
lockAcquired = true;
|
|
66
|
+
|
|
67
|
+
await client.query(SETUP_SQL);
|
|
68
|
+
logger.info("trigger ensured (public.tokens_notify + tokens_changed_* triggers)");
|
|
69
|
+
} finally {
|
|
70
|
+
if (lockAcquired) {
|
|
71
|
+
await client
|
|
72
|
+
.query("SELECT pg_advisory_unlock($1, $2)", [
|
|
73
|
+
ADVISORY_LOCK_CLASS_ID,
|
|
74
|
+
ADVISORY_LOCK_OBJECT_ID,
|
|
75
|
+
])
|
|
76
|
+
.catch(() => {});
|
|
77
|
+
}
|
|
78
|
+
await client.end().catch(() => {});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -97,7 +97,9 @@
|
|
|
97
97
|
"duration_ms",
|
|
98
98
|
"cost_usd"
|
|
99
99
|
],
|
|
100
|
-
"cone": {
|
|
100
|
+
"cone": {
|
|
101
|
+
"note": "리스트 조회용 Preview 서브셋. TEXT 컬럼(user_prompt, system_prompt, response) 제외"
|
|
102
|
+
}
|
|
101
103
|
}
|
|
102
104
|
},
|
|
103
105
|
"enums": {
|
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
* 직접 수정하지 마세요.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { PuriWrapper, DatabaseSchemaExtend, PuriLoaderQueries } from "sonamu";
|
|
6
|
+
import { type PuriWrapper, type DatabaseSchemaExtend, type PuriLoaderQueries } from "sonamu";
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
|
-
RequestLogSubsetKey,
|
|
10
|
-
TokenSubsetKey,
|
|
11
|
-
RequestLogBaseSchema,
|
|
12
|
-
TokenBaseSchema,
|
|
9
|
+
type RequestLogSubsetKey,
|
|
10
|
+
type TokenSubsetKey,
|
|
11
|
+
type RequestLogBaseSchema,
|
|
12
|
+
type TokenBaseSchema,
|
|
13
13
|
} from "./sonamu.generated";
|
|
14
14
|
|
|
15
15
|
// SubsetQuery: RequestLog
|