@cartanova/qgrid-cli 2.4.4 → 2.4.5

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.
@@ -6,6 +6,9 @@
6
6
  * - TokenSubscriber 이벤트로 worker pool 동기화
7
7
  * - backpressure: 큐 full 또는 timeout 시 SERVER_BUSY
8
8
  */
9
+ import { readFileSync } from "node:fs";
10
+ import { freemem } from "node:os";
11
+
9
12
  import { getLogger } from "@logtape/logtape";
10
13
 
11
14
  import { QuotaThresholdExceededError } from "../../../application/qgrid/qgrid.types";
@@ -46,24 +49,106 @@ export class ImageGenerationError extends Error {
46
49
  }
47
50
 
48
51
  const DEFAULT_EFFORT = "low";
49
- const MAX_WORKERS_PER_TOKEN = 5;
50
- const WORKERS_PER_TOKEN = Math.min(
51
- Number(process.env.QGRID_WORKERS_PER_TOKEN ?? 3),
52
- MAX_WORKERS_PER_TOKEN,
53
- );
52
+ export const MAX_OPENAI_WORKERS_PER_TOKEN = 20;
53
+ const DEFAULT_OPENAI_MIN_WORKERS_PER_TOKEN = 5;
54
+ const DEFAULT_OPENAI_MAX_WORKERS_PER_TOKEN = 15;
54
55
  const QUEUE_TIMEOUT_MS = 60_000;
55
56
  const MAX_QUEUE_SIZE = 50;
56
57
  const SPAWN_INTERVAL_MS = 500;
57
58
 
59
+ export const OPENAI_WORKER_BASE_RSS_GIB = 0.71;
60
+ export const OPENAI_WORKER_RSS_GIB = 0.157;
61
+
62
+ export type OpenAIWorkerPoolConfig = {
63
+ autoscale: boolean;
64
+ minWorkersPerToken: number;
65
+ maxWorkersPerToken: number;
66
+ scaleIntervalMs: number;
67
+ scaleDownIdleMs: number;
68
+ maxEstimatedRssGiB: number;
69
+ minHostAvailableGiB: number;
70
+ };
71
+
72
+ function boundedInteger(
73
+ value: string | undefined,
74
+ fallback: number,
75
+ min: number,
76
+ max: number,
77
+ ): number {
78
+ const parsed = Number(value);
79
+ if (!Number.isFinite(parsed)) return fallback;
80
+ return Math.max(min, Math.min(Math.floor(parsed), max));
81
+ }
82
+
83
+ function boundedNumber(value: string | undefined, fallback: number, min: number): number {
84
+ const parsed = Number(value);
85
+ if (!Number.isFinite(parsed)) return fallback;
86
+ return Math.max(min, parsed);
87
+ }
88
+
89
+ export function resolveOpenAIWorkerPoolConfig(
90
+ env: Record<string, string | undefined> = process.env,
91
+ ): OpenAIWorkerPoolConfig {
92
+ const autoscale = env.QGRID_OPENAI_AUTOSCALE !== "false" && env.QGRID_OPENAI_AUTOSCALE !== "0";
93
+ const legacyWorkers = boundedInteger(
94
+ env.QGRID_WORKERS_PER_TOKEN,
95
+ DEFAULT_OPENAI_MIN_WORKERS_PER_TOKEN,
96
+ 1,
97
+ MAX_OPENAI_WORKERS_PER_TOKEN,
98
+ );
99
+ const minWorkersPerToken = boundedInteger(
100
+ env.QGRID_OPENAI_MIN_WORKERS_PER_TOKEN,
101
+ legacyWorkers,
102
+ 1,
103
+ MAX_OPENAI_WORKERS_PER_TOKEN,
104
+ );
105
+ const configuredMax = boundedInteger(
106
+ env.QGRID_OPENAI_MAX_WORKERS_PER_TOKEN,
107
+ autoscale ? DEFAULT_OPENAI_MAX_WORKERS_PER_TOKEN : minWorkersPerToken,
108
+ 1,
109
+ MAX_OPENAI_WORKERS_PER_TOKEN,
110
+ );
111
+
112
+ return {
113
+ autoscale,
114
+ minWorkersPerToken,
115
+ maxWorkersPerToken: autoscale
116
+ ? Math.max(minWorkersPerToken, configuredMax)
117
+ : minWorkersPerToken,
118
+ scaleIntervalMs: boundedInteger(env.QGRID_OPENAI_SCALE_INTERVAL_MS, 5_000, 250, 300_000),
119
+ scaleDownIdleMs: boundedInteger(
120
+ env.QGRID_OPENAI_SCALE_DOWN_IDLE_MS,
121
+ 10 * 60_000,
122
+ 1_000,
123
+ 24 * 60 * 60_000,
124
+ ),
125
+ maxEstimatedRssGiB: boundedNumber(env.QGRID_OPENAI_MAX_ESTIMATED_RSS_GIB, 16, 1),
126
+ minHostAvailableGiB: boundedNumber(env.QGRID_OPENAI_MIN_HOST_AVAILABLE_GIB, 20, 0),
127
+ };
128
+ }
129
+
130
+ export function estimateOpenAIWorkerRssGiB(totalWorkers: number): number {
131
+ return OPENAI_WORKER_BASE_RSS_GIB + OPENAI_WORKER_RSS_GIB * totalWorkers;
132
+ }
133
+
134
+ function readHostAvailableGiB(): number {
135
+ try {
136
+ const match = readFileSync("/proc/meminfo", "utf8").match(/^MemAvailable:\s+(\d+)\s+kB$/m);
137
+ if (match?.[1]) return Number(match[1]) / 1024 / 1024;
138
+ } catch {}
139
+ return freemem() / 1024 / 1024 / 1024;
140
+ }
141
+
58
142
  // thread 재사용(prompt cache 고정). 끄면 기존 "매 turn 새 thread + history inject" 동작.
59
143
  const THREAD_REUSE_ENABLED = process.env.QGRID_OPENAI_THREAD_REUSE !== "false";
60
144
  // 좌표 worker 가 busy 일 때 free 를 기다리는 최대 시간. 초과 시 새 thread 로 폴백.
61
145
  const REUSE_WORKER_WAIT_MS = 5_000;
62
146
  const REUSE_WORKER_POLL_MS = 50;
63
147
 
64
- // workerId 합성: tokenId 와 workerIndex(0..4) 를 하나의 안정 숫자로 인코딩.
65
- function makeWorkerId(tokenId: number, workerIndex: number): number {
66
- return tokenId * 10 + workerIndex;
148
+ // workerId 합성: tokenId 와 workerIndex(0..19) 를 하나의 안정 숫자로 인코딩.
149
+ const OPENAI_WORKER_ID_STRIDE = 100;
150
+ export function makeOpenAIWorkerId(tokenId: number, workerIndex: number): number {
151
+ return tokenId * OPENAI_WORKER_ID_STRIDE + workerIndex;
67
152
  }
68
153
 
69
154
  function sleep(ms: number): Promise<void> {
@@ -92,8 +177,10 @@ type QueueItem = {
92
177
 
93
178
  type TokenMetadata = {
94
179
  name: string;
180
+ credentials: OpenAICredentials;
95
181
  quotaThreshold?: number | null;
96
182
  weight: number;
183
+ active: boolean;
97
184
  };
98
185
 
99
186
  type TokenEligibility = {
@@ -107,16 +194,33 @@ export class OpenAIDispatcher implements ProviderDispatcher {
107
194
  workerPool = new Map<number, CodexAppServerWorker[]>();
108
195
  tokenMetadata = new Map<number, TokenMetadata>();
109
196
  queue: QueueItem[] = [];
197
+ readonly poolConfig: OpenAIWorkerPoolConfig;
110
198
  private readonly weightedSelector = new SmoothWeightedRoundRobin();
111
199
  private readonly workerCursorByToken = new Map<number, number>();
200
+ private readonly getHostAvailableGiB: () => number;
112
201
  private draining = false;
113
202
  private drainAgain = false;
203
+ private scalerTimer: ReturnType<typeof setInterval> | null = null;
204
+ private scalingPromise: Promise<void> | null = null;
205
+ private lastRequestAt = Date.now();
206
+ private stopped = false;
207
+
208
+ constructor(
209
+ poolConfig: OpenAIWorkerPoolConfig = resolveOpenAIWorkerPoolConfig(),
210
+ getHostAvailableGiB: () => number = readHostAvailableGiB,
211
+ ) {
212
+ this.poolConfig = poolConfig;
213
+ this.getHostAvailableGiB = getHostAvailableGiB;
214
+ }
114
215
 
115
216
  async start(): Promise<void> {
217
+ this.stopped = false;
116
218
  const { rows } = await TokenModel.findMany("A");
117
219
  const openaiTokens = rows.filter((t) => t.provider === "openai");
118
220
  logger.info(
119
- `starting ${openaiTokens.length} openai tokens (${WORKERS_PER_TOKEN} workers each)`,
221
+ this.poolConfig.autoscale
222
+ ? `starting ${openaiTokens.length} openai tokens (${this.poolConfig.minWorkersPerToken}-${this.poolConfig.maxWorkersPerToken} workers each, autoscale)`
223
+ : `starting ${openaiTokens.length} openai tokens (${this.poolConfig.minWorkersPerToken} workers each)`,
120
224
  );
121
225
 
122
226
  await Promise.allSettled(
@@ -131,9 +235,23 @@ export class OpenAIDispatcher implements ProviderDispatcher {
131
235
  if (!t.active) this.onTokenDeactivated(t.id);
132
236
  }),
133
237
  );
238
+
239
+ if (this.poolConfig.autoscale) {
240
+ this.scalerTimer = setInterval(
241
+ () => this.requestAutoscaleEvaluation(),
242
+ this.poolConfig.scaleIntervalMs,
243
+ );
244
+ this.scalerTimer.unref?.();
245
+ }
134
246
  }
135
247
 
136
248
  async stop(): Promise<void> {
249
+ this.stopped = true;
250
+ if (this.scalerTimer) {
251
+ clearInterval(this.scalerTimer);
252
+ this.scalerTimer = null;
253
+ }
254
+ await this.scalingPromise?.catch(() => {});
137
255
  this.rejectAllQueued("DISPATCHER_SHUTDOWN");
138
256
  const kills = [...this.workerPool.values()].flat().map((w) => w.kill());
139
257
  await Promise.allSettled(kills);
@@ -154,7 +272,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
154
272
  quotaThreshold?: number | null,
155
273
  weight = 1,
156
274
  ): Promise<void> {
157
- this.setTokenMetadata(id, name, quotaThreshold, weight);
275
+ this.setTokenMetadata(id, name, credentials, quotaThreshold, weight);
158
276
  if (this.workerPool.has(id)) return;
159
277
  await this.spawnWorkers(id, name, credentials, quotaThreshold, weight);
160
278
  }
@@ -186,7 +304,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
186
304
  ): Promise<void> {
187
305
  const existing = this.workerPool.get(id) ?? [];
188
306
  const renamed = existing.some((w) => w.tokenName !== name);
189
- this.setTokenMetadata(id, name, quotaThreshold, weight);
307
+ this.setTokenMetadata(id, name, credentials, quotaThreshold, weight);
190
308
  if (renamed) this.rateLimitsCache.delete(id);
191
309
 
192
310
  if (existing.length === 0) {
@@ -210,6 +328,8 @@ export class OpenAIDispatcher implements ProviderDispatcher {
210
328
  onTokenDeactivated(id: number): void {
211
329
  const workers = this.workerPool.get(id) ?? [];
212
330
  const tokenName = workers[0]?.tokenName;
331
+ const metadata = this.tokenMetadata.get(id);
332
+ if (metadata) metadata.active = false;
213
333
  const changed = workers.some((worker) => worker.active);
214
334
  workers.forEach((w) => {
215
335
  w.active = false;
@@ -225,6 +345,8 @@ export class OpenAIDispatcher implements ProviderDispatcher {
225
345
  onTokenActivated(id: number): void {
226
346
  const workers = this.workerPool.get(id) ?? [];
227
347
  const tokenName = workers[0]?.tokenName;
348
+ const metadata = this.tokenMetadata.get(id);
349
+ if (metadata) metadata.active = true;
228
350
  const changed = workers.some((worker) => !worker.active);
229
351
  workers.forEach((w) => {
230
352
  w.active = true;
@@ -268,6 +390,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
268
390
  // ── Generate ────────────────────────────────────────────────────
269
391
 
270
392
  async generate(req: GenerateRequest): Promise<GenerateResult> {
393
+ this.lastRequestAt = Date.now();
271
394
  const excludedTokenIds = new Set<number>();
272
395
  // thread 재사용 경로: 좌표 worker 를 점유해 기존 thread 에 turn 만 실행.
273
396
  const reuseWorker = await this.acquireReuseWorker(req, excludedTokenIds);
@@ -292,6 +415,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
292
415
  }
293
416
 
294
417
  async generateStream(req: GenerateRequest, cb: GenerateStreamCallbacks): Promise<void> {
418
+ this.lastRequestAt = Date.now();
295
419
  // 이미지 생성은 non-stream 전용(R2): codex 가 이미지 델타를 주지 않고 완성 base64 를
296
420
  // 한 번에 준다. 스트리밍 경로로 이미지 요청이 오면 명시적 에러.
297
421
  if (req.imageGeneration) {
@@ -354,7 +478,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
354
478
  findWorkerById(workerId: number): CodexAppServerWorker | null {
355
479
  for (const workers of this.workerPool.values()) {
356
480
  for (const w of workers) {
357
- if (makeWorkerId(w.tokenId, w.workerIndex) === workerId) return w;
481
+ if (makeOpenAIWorkerId(w.tokenId, w.workerIndex) === workerId) return w;
358
482
  }
359
483
  }
360
484
  return null;
@@ -438,6 +562,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
438
562
  };
439
563
 
440
564
  this.queue.push(item);
565
+ this.requestAutoscaleEvaluation();
441
566
  });
442
567
  }
443
568
 
@@ -560,7 +685,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
560
685
  ttftMs: result.ttftMs,
561
686
  model: result.model,
562
687
  threadCoord: {
563
- workerId: makeWorkerId(worker.tokenId, worker.workerIndex),
688
+ workerId: makeOpenAIWorkerId(worker.tokenId, worker.workerIndex),
564
689
  threadId: result.threadId,
565
690
  epoch: worker.epoch,
566
691
  },
@@ -589,7 +714,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
589
714
  ...result,
590
715
  tokenName: worker.tokenName,
591
716
  threadCoord: {
592
- workerId: makeWorkerId(worker.tokenId, worker.workerIndex),
717
+ workerId: makeOpenAIWorkerId(worker.tokenId, worker.workerIndex),
593
718
  threadId: resolvedThreadId,
594
719
  epoch: worker.epoch,
595
720
  },
@@ -608,9 +733,9 @@ export class OpenAIDispatcher implements ProviderDispatcher {
608
733
  quotaThreshold?: number | null,
609
734
  weight = 1,
610
735
  ): Promise<void> {
611
- this.setTokenMetadata(tokenId, tokenName, quotaThreshold, weight);
736
+ this.setTokenMetadata(tokenId, tokenName, credentials, quotaThreshold, weight);
612
737
  const workers: CodexAppServerWorker[] = [];
613
- for (let i = 0; i < WORKERS_PER_TOKEN; i++) {
738
+ for (let i = 0; i < this.poolConfig.minWorkersPerToken; i++) {
614
739
  if (i > 0) await sleep(SPAWN_INTERVAL_MS);
615
740
  const worker = await this.spawnSingleWorker(tokenId, tokenName, credentials, i);
616
741
  if (worker) workers.push(worker);
@@ -653,6 +778,143 @@ export class OpenAIDispatcher implements ProviderDispatcher {
653
778
  }
654
779
  }
655
780
 
781
+ async evaluateAutoscaling(): Promise<void> {
782
+ if (!this.poolConfig.autoscale || this.stopped) return;
783
+ if (this.queue.length > 0) {
784
+ await this.scaleUpOneStep();
785
+ return;
786
+ }
787
+ if (Date.now() - this.lastRequestAt >= this.poolConfig.scaleDownIdleMs) {
788
+ await this.scaleDownOneStep();
789
+ }
790
+ }
791
+
792
+ async scaleUpOneStep(): Promise<void> {
793
+ if (!this.poolConfig.autoscale || this.stopped) return;
794
+ await this.runScaling(async () => {
795
+ const candidates = [...this.tokenMetadata.entries()].filter(([tokenId, metadata]) => {
796
+ if (!metadata.active) return false;
797
+ return (this.workerPool.get(tokenId)?.length ?? 0) < this.poolConfig.maxWorkersPerToken;
798
+ });
799
+ if (candidates.length === 0) return;
800
+
801
+ const projectedWorkerCount = this.workerCount + candidates.length;
802
+ const projectedRssGiB = estimateOpenAIWorkerRssGiB(projectedWorkerCount);
803
+ if (projectedRssGiB > this.poolConfig.maxEstimatedRssGiB) {
804
+ logger.warn(
805
+ `openai autoscale blocked: estimated RSS ${projectedRssGiB.toFixed(2)}GiB exceeds ${this.poolConfig.maxEstimatedRssGiB.toFixed(2)}GiB`,
806
+ );
807
+ return;
808
+ }
809
+
810
+ const hostAvailableGiB = this.getHostAvailableGiB();
811
+ if (hostAvailableGiB < this.poolConfig.minHostAvailableGiB) {
812
+ logger.warn(
813
+ `openai autoscale blocked: host available ${hostAvailableGiB.toFixed(2)}GiB below ${this.poolConfig.minHostAvailableGiB.toFixed(2)}GiB`,
814
+ );
815
+ return;
816
+ }
817
+
818
+ const spawned = await Promise.all(
819
+ candidates.map(async ([tokenId, metadata]) => {
820
+ const workers = this.workerPool.get(tokenId) ?? [];
821
+ const workerIndex = this.nextAvailableWorkerIndex(workers);
822
+ if (workerIndex >= this.poolConfig.maxWorkersPerToken) return null;
823
+ const worker = await this.spawnSingleWorker(
824
+ tokenId,
825
+ metadata.name,
826
+ metadata.credentials,
827
+ workerIndex,
828
+ );
829
+ if (!worker) return null;
830
+
831
+ const currentMetadata = this.tokenMetadata.get(tokenId);
832
+ const currentWorkers = this.workerPool.get(tokenId);
833
+ if (
834
+ !currentMetadata?.active ||
835
+ currentMetadata !== metadata ||
836
+ !currentWorkers ||
837
+ currentWorkers.some((entry) => entry.workerIndex === workerIndex)
838
+ ) {
839
+ await worker.kill().catch(() => {});
840
+ return null;
841
+ }
842
+ currentWorkers.push(worker);
843
+ currentWorkers.sort((a, b) => a.workerIndex - b.workerIndex);
844
+ return worker;
845
+ }),
846
+ );
847
+
848
+ const added = spawned.filter((worker) => worker !== null).length;
849
+ if (added > 0) {
850
+ logger.info(
851
+ `openai autoscale up: +${added} workers (${this.workerCount} total, queue ${this.queue.length})`,
852
+ );
853
+ this.requestDrainQueue();
854
+ if (this.queue.length > 0) {
855
+ const timer = setTimeout(
856
+ () => this.requestAutoscaleEvaluation(),
857
+ Math.min(this.poolConfig.scaleIntervalMs, 1_000),
858
+ );
859
+ timer.unref?.();
860
+ }
861
+ }
862
+ });
863
+ }
864
+
865
+ async scaleDownOneStep(): Promise<void> {
866
+ if (!this.poolConfig.autoscale || this.stopped) return;
867
+ await this.runScaling(async () => {
868
+ const removed: CodexAppServerWorker[] = [];
869
+ for (const tokenId of this.tokenMetadata.keys()) {
870
+ const workers = this.workerPool.get(tokenId) ?? [];
871
+ if (workers.length <= this.poolConfig.minWorkersPerToken) continue;
872
+
873
+ const worker = workers
874
+ .toSorted((a, b) => b.workerIndex - a.workerIndex)
875
+ .find((entry) => entry.hasCapacity);
876
+ if (!worker) continue;
877
+
878
+ this.workerPool.set(
879
+ tokenId,
880
+ workers.filter((entry) => entry !== worker),
881
+ );
882
+ removed.push(worker);
883
+ }
884
+
885
+ if (removed.length === 0) return;
886
+ await Promise.allSettled(removed.map((worker) => worker.kill()));
887
+ logger.info(`openai autoscale down: -${removed.length} workers (${this.workerCount} total)`);
888
+ });
889
+ }
890
+
891
+ private nextAvailableWorkerIndex(workers: CodexAppServerWorker[]): number {
892
+ const used = new Set(workers.map((worker) => worker.workerIndex));
893
+ for (let index = 0; index < this.poolConfig.maxWorkersPerToken; index++) {
894
+ if (!used.has(index)) return index;
895
+ }
896
+ return this.poolConfig.maxWorkersPerToken;
897
+ }
898
+
899
+ private requestAutoscaleEvaluation(): void {
900
+ if (!this.poolConfig.autoscale || this.stopped) return;
901
+ void this.evaluateAutoscaling().catch((e) =>
902
+ logger.warn(`openai autoscale evaluation failed: ${(e as Error).message}`),
903
+ );
904
+ }
905
+
906
+ private async runScaling(task: () => Promise<void>): Promise<void> {
907
+ if (this.scalingPromise) {
908
+ await this.scalingPromise;
909
+ return;
910
+ }
911
+ const scaling = task().finally(() => {
912
+ if (this.scalingPromise === scaling) this.scalingPromise = null;
913
+ });
914
+ this.scalingPromise = scaling;
915
+ await scaling;
916
+ }
917
+
656
918
  // ── Rate limits ─────────────────────────────────────────────────
657
919
 
658
920
  rateLimitsCache = new Map<number, { data: GetAccountRateLimitsResponse; cachedAt: number }>();
@@ -679,10 +941,17 @@ export class OpenAIDispatcher implements ProviderDispatcher {
679
941
  private setTokenMetadata(
680
942
  id: number,
681
943
  name: string,
944
+ credentials: OpenAICredentials,
682
945
  quotaThreshold: number | null | undefined,
683
946
  weight: number,
684
947
  ): void {
685
- this.tokenMetadata.set(id, { name, quotaThreshold, weight });
948
+ this.tokenMetadata.set(id, {
949
+ name,
950
+ credentials,
951
+ quotaThreshold,
952
+ weight,
953
+ active: this.tokenMetadata.get(id)?.active ?? true,
954
+ });
686
955
  this.weightedSelector.setToken(id, weight);
687
956
  }
688
957