@cartanova/qgrid-cli 2.5.8 → 2.5.9

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.
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * OpenAIDispatcher — codex app-server worker pool + 토큰 라우팅.
3
3
  *
4
- * - 토큰당 CodexAppServerWorker 자동 확장 (기본 5~15, hard cap 20)
4
+ * - 토큰당 CodexAppServerWorker 자동 확장 (기본 1~3, hard cap 20)
5
5
  * - idle worker round-robin 선택 + 큐 대기 (전부 busy 시)
6
6
  * - TokenSubscriber 이벤트로 worker pool 동기화
7
7
  * - backpressure: 큐 full 또는 timeout 시 SERVER_BUSY
@@ -127,6 +127,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
127
127
  queue: QueueItem[] = [];
128
128
  readonly poolConfig: OpenAIWorkerPoolConfig;
129
129
  private readonly quotaBlockedTokenIds = new Set<number>();
130
+ private readonly startingTokenIds = new Set<number>();
130
131
  private readonly rateLimitsGenerationByToken = new Map<number, number>();
131
132
  private readonly weightedSelector = new SmoothWeightedRoundRobin();
132
133
  private readonly workerCursorByToken = new Map<number, number>();
@@ -169,13 +170,12 @@ export class OpenAIDispatcher implements ProviderDispatcher {
169
170
  }),
170
171
  );
171
172
 
172
- if (this.poolConfig.autoscale) {
173
- this.scalerTimer = setInterval(
174
- () => this.requestAutoscaleEvaluation(),
175
- this.poolConfig.scaleIntervalMs,
176
- );
177
- this.scalerTimer.unref?.();
178
- }
173
+ // autoscale off도 fixed min의 health maintenance는 계속 필요하다.
174
+ this.scalerTimer = setInterval(
175
+ () => this.requestAutoscaleEvaluation(),
176
+ this.poolConfig.scaleIntervalMs,
177
+ );
178
+ this.scalerTimer.unref?.();
179
179
  }
180
180
 
181
181
  async stop(): Promise<void> {
@@ -196,6 +196,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
196
196
  this.rateLimitsCache.clear();
197
197
  this.rateLimitsGenerationByToken.clear();
198
198
  this.quotaBlockedTokenIds.clear();
199
+ this.startingTokenIds.clear();
199
200
  }
200
201
 
201
202
  // ── Token events (from TokenSubscriber) ─────────────────────────
@@ -224,7 +225,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
224
225
  this.invalidateRateLimitsCache(id);
225
226
  this.quotaBlockedTokenIds.delete(id);
226
227
  await Promise.allSettled(workers.map((w) => w.kill()));
227
- if (this.getAllReadyActiveWorkers().length === 0) {
228
+ if (!this.hasActiveTokenCandidate(new Set())) {
228
229
  this.rejectAllQueued("NO_OPENAI_WORKERS");
229
230
  }
230
231
  if (workers.length > 0) {
@@ -276,7 +277,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
276
277
  w.active = false;
277
278
  });
278
279
  if (changed) this.weightedSelector.resetScores();
279
- if (this.getAllReadyActiveWorkers().length === 0) {
280
+ if (!this.hasActiveTokenCandidate(new Set())) {
280
281
  this.rejectAllQueued("NO_ACTIVE_WORKERS");
281
282
  }
282
283
  const label = tokenName ?? `token ${id}`;
@@ -499,8 +500,15 @@ export class OpenAIDispatcher implements ProviderDispatcher {
499
500
  excludedTokenIds = new Set<number>(),
500
501
  ): Promise<T> {
501
502
  const eligibility = await this.resolveTokenEligibility(excludedTokenIds);
502
- if (eligibility.readyActiveTokenIds.size === 0) throw new Error("NO_OPENAI_WORKERS");
503
- if (eligibility.eligibleTokenIds.size === 0) this.throwQuotaThresholdExceeded(eligibility);
503
+ if (
504
+ eligibility.readyActiveTokenIds.size === 0 &&
505
+ !this.hasActiveTokenCandidate(excludedTokenIds)
506
+ ) {
507
+ throw new Error("NO_OPENAI_WORKERS");
508
+ }
509
+ if (eligibility.readyActiveTokenIds.size > 0 && eligibility.eligibleTokenIds.size === 0) {
510
+ this.throwQuotaThresholdExceeded(eligibility);
511
+ }
504
512
  if (this.queue.length >= MAX_QUEUE_SIZE) {
505
513
  throw new Error("SERVER_BUSY");
506
514
  }
@@ -527,6 +535,9 @@ export class OpenAIDispatcher implements ProviderDispatcher {
527
535
  };
528
536
 
529
537
  this.queue.push(item);
538
+ // eligibility 확인과 queue 삽입 사이에 worker가 ready/idle이 된 경우의 lost wake-up을
539
+ // 닫는다. fixed mode에서도 idle worker가 즉시 queue를 가져가야 한다.
540
+ this.requestDrainQueue();
530
541
  this.requestAutoscaleEvaluation();
531
542
  });
532
543
  }
@@ -698,14 +709,31 @@ export class OpenAIDispatcher implements ProviderDispatcher {
698
709
  quotaThreshold?: number | null,
699
710
  weight = 1,
700
711
  ): Promise<void> {
712
+ if (this.startingTokenIds.has(tokenId)) return;
713
+ this.startingTokenIds.add(tokenId);
701
714
  this.setTokenMetadata(tokenId, tokenName, credentials, quotaThreshold, weight);
715
+ const expectedMetadata = this.tokenMetadata.get(tokenId);
702
716
  const workers: CodexAppServerWorker[] = [];
703
- for (let i = 0; i < this.poolConfig.minWorkersPerToken; i++) {
704
- if (i > 0) await sleep(SPAWN_INTERVAL_MS);
705
- const worker = await this.spawnSingleWorker(tokenId, tokenName, credentials, i);
706
- if (worker) workers.push(worker);
717
+ try {
718
+ for (let i = 0; i < this.poolConfig.minWorkersPerToken; i++) {
719
+ if (i > 0) await sleep(SPAWN_INTERVAL_MS);
720
+ const worker = await this.spawnSingleWorker(tokenId, tokenName, credentials, i);
721
+ if (worker) workers.push(worker);
722
+ }
723
+
724
+ if (
725
+ !this.stopped &&
726
+ expectedMetadata?.active &&
727
+ this.tokenMetadata.get(tokenId) === expectedMetadata &&
728
+ !this.workerPool.has(tokenId)
729
+ ) {
730
+ if (workers.length > 0) this.workerPool.set(tokenId, workers);
731
+ } else {
732
+ await Promise.allSettled(workers.map((worker) => worker.kill()));
733
+ }
734
+ } finally {
735
+ this.startingTokenIds.delete(tokenId);
707
736
  }
708
- if (workers.length > 0) this.workerPool.set(tokenId, workers);
709
737
  }
710
738
 
711
739
  async spawnSingleWorker(
@@ -724,6 +752,13 @@ export class OpenAIDispatcher implements ProviderDispatcher {
724
752
  });
725
753
 
726
754
  worker.onReady = () => this.requestDrainQueue();
755
+ worker.onTerminalFailure = () => {
756
+ void this.handleWorkerTerminalFailure(worker).catch((e) =>
757
+ logger.warn(
758
+ `terminal worker cleanup failed: ${tokenName}[${workerIndex}]: ${(e as Error).message}`,
759
+ ),
760
+ );
761
+ };
727
762
 
728
763
  worker.setServerRequestHandler(async (method) => {
729
764
  if (method === "account/chatgptAuthTokens/refresh") {
@@ -744,9 +779,13 @@ export class OpenAIDispatcher implements ProviderDispatcher {
744
779
  }
745
780
 
746
781
  async evaluateAutoscaling(): Promise<void> {
782
+ if (this.stopped) return;
783
+ const minimumDeficitTokenIds = await this.ensureMinimumWorkersOneStep();
747
784
  if (!this.poolConfig.autoscale || this.stopped) return;
748
785
  if (this.queue.length > 0) {
749
- await this.scaleUpOneStep();
786
+ // baseline 복구를 시도한 token만 같은 evaluation의 demand expansion에서 제외한다.
787
+ // 다른 정상 token의 확장까지 막지는 않는다.
788
+ await this.scaleUpOneStep(minimumDeficitTokenIds);
750
789
  return;
751
790
  }
752
791
  if (Date.now() - this.lastRequestAt >= this.poolConfig.scaleDownIdleMs) {
@@ -754,11 +793,18 @@ export class OpenAIDispatcher implements ProviderDispatcher {
754
793
  }
755
794
  }
756
795
 
757
- async scaleUpOneStep(): Promise<void> {
796
+ async scaleUpOneStep(excludedTokenIds = new Set<number>()): Promise<void> {
758
797
  if (!this.poolConfig.autoscale || this.stopped) return;
759
798
  await this.runScaling(async () => {
760
799
  const candidates = [...this.tokenMetadata.entries()].filter(([tokenId, metadata]) => {
761
- if (!metadata.active || this.quotaBlockedTokenIds.has(tokenId)) return false;
800
+ if (
801
+ !metadata.active ||
802
+ this.startingTokenIds.has(tokenId) ||
803
+ excludedTokenIds.has(tokenId) ||
804
+ this.quotaBlockedTokenIds.has(tokenId)
805
+ ) {
806
+ return false;
807
+ }
762
808
  return (this.workerPool.get(tokenId)?.length ?? 0) < this.poolConfig.maxWorkersPerToken;
763
809
  });
764
810
  if (candidates.length === 0) return;
@@ -781,34 +827,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
781
827
  }
782
828
 
783
829
  const spawned = await Promise.all(
784
- candidates.map(async ([tokenId, metadata]) => {
785
- const workers = this.workerPool.get(tokenId) ?? [];
786
- const workerIndex = this.nextAvailableWorkerIndex(workers);
787
- if (workerIndex >= this.poolConfig.maxWorkersPerToken) return null;
788
- const worker = await this.spawnSingleWorker(
789
- tokenId,
790
- metadata.name,
791
- metadata.credentials,
792
- workerIndex,
793
- );
794
- if (!worker) return null;
795
-
796
- const currentMetadata = this.tokenMetadata.get(tokenId);
797
- const currentWorkers = this.workerPool.get(tokenId);
798
- if (
799
- !currentMetadata?.active ||
800
- this.quotaBlockedTokenIds.has(tokenId) ||
801
- currentMetadata !== metadata ||
802
- !currentWorkers ||
803
- currentWorkers.some((entry) => entry.workerIndex === workerIndex)
804
- ) {
805
- await worker.kill().catch(() => {});
806
- return null;
807
- }
808
- currentWorkers.push(worker);
809
- currentWorkers.sort((a, b) => a.workerIndex - b.workerIndex);
810
- return worker;
811
- }),
830
+ candidates.map(([tokenId, metadata]) => this.spawnAndCommitWorker(tokenId, metadata, true)),
812
831
  );
813
832
 
814
833
  const added = spawned.filter((worker) => worker !== null).length;
@@ -828,6 +847,38 @@ export class OpenAIDispatcher implements ProviderDispatcher {
828
847
  });
829
848
  }
830
849
 
850
+ async ensureMinimumWorkersOneStep(): Promise<Set<number>> {
851
+ const minimumDeficitTokenIds = new Set<number>();
852
+ if (this.stopped) return minimumDeficitTokenIds;
853
+ await this.runScaling(async () => {
854
+ const candidates = [...this.tokenMetadata.entries()].filter(([tokenId, metadata]) => {
855
+ if (!metadata.active || this.startingTokenIds.has(tokenId)) return false;
856
+ return (this.workerPool.get(tokenId)?.length ?? 0) < this.poolConfig.minWorkersPerToken;
857
+ });
858
+ if (candidates.length === 0) return;
859
+ candidates.forEach(([tokenId]) => minimumDeficitTokenIds.add(tokenId));
860
+
861
+ const spawned = await Promise.all(
862
+ candidates.map(([tokenId, metadata]) =>
863
+ this.spawnAndCommitWorker(tokenId, metadata, false),
864
+ ),
865
+ );
866
+ const added = spawned.filter((worker) => worker !== null).length;
867
+ if (added > 0) {
868
+ logger.info(`openai worker pool repaired: +${added} workers (${this.workerCount} total)`);
869
+ this.requestDrainQueue();
870
+ if (this.queue.length > 0) {
871
+ const timer = setTimeout(
872
+ () => this.requestAutoscaleEvaluation(),
873
+ Math.min(this.poolConfig.scaleIntervalMs, 1_000),
874
+ );
875
+ timer.unref?.();
876
+ }
877
+ }
878
+ });
879
+ return minimumDeficitTokenIds;
880
+ }
881
+
831
882
  async scaleDownOneStep(): Promise<void> {
832
883
  if (!this.poolConfig.autoscale || this.stopped) return;
833
884
  await this.runScaling(async () => {
@@ -862,8 +913,65 @@ export class OpenAIDispatcher implements ProviderDispatcher {
862
913
  return this.poolConfig.maxWorkersPerToken;
863
914
  }
864
915
 
916
+ private async spawnAndCommitWorker(
917
+ tokenId: number,
918
+ metadata: TokenMetadata,
919
+ respectQuotaBlock: boolean,
920
+ ): Promise<CodexAppServerWorker | null> {
921
+ const expectedWorkers = this.workerPool.get(tokenId);
922
+ const workerIndex = this.nextAvailableWorkerIndex(expectedWorkers ?? []);
923
+ if (workerIndex >= this.poolConfig.maxWorkersPerToken) return null;
924
+
925
+ const worker = await this.spawnSingleWorker(
926
+ tokenId,
927
+ metadata.name,
928
+ metadata.credentials,
929
+ workerIndex,
930
+ );
931
+ if (!worker) return null;
932
+
933
+ const currentMetadata = this.tokenMetadata.get(tokenId);
934
+ const currentWorkers = this.workerPool.get(tokenId);
935
+ if (
936
+ !currentMetadata?.active ||
937
+ currentMetadata !== metadata ||
938
+ currentWorkers !== expectedWorkers ||
939
+ (respectQuotaBlock && this.quotaBlockedTokenIds.has(tokenId)) ||
940
+ currentWorkers?.some((entry) => entry.workerIndex === workerIndex)
941
+ ) {
942
+ await worker.kill().catch(() => {});
943
+ return null;
944
+ }
945
+
946
+ const nextWorkers = currentWorkers ?? [];
947
+ nextWorkers.push(worker);
948
+ nextWorkers.sort((a, b) => a.workerIndex - b.workerIndex);
949
+ if (!currentWorkers) this.workerPool.set(tokenId, nextWorkers);
950
+ return worker;
951
+ }
952
+
953
+ async handleWorkerTerminalFailure(worker: CodexAppServerWorker): Promise<void> {
954
+ const workers = this.workerPool.get(worker.tokenId);
955
+ if (!workers?.includes(worker)) return;
956
+
957
+ await worker.kill().catch(() => {});
958
+
959
+ const currentWorkers = this.workerPool.get(worker.tokenId);
960
+ if (!currentWorkers?.includes(worker)) return;
961
+ const remaining = currentWorkers.filter((entry) => entry !== worker);
962
+ if (remaining.length > 0) {
963
+ this.workerPool.set(worker.tokenId, remaining);
964
+ } else {
965
+ this.workerPool.delete(worker.tokenId);
966
+ }
967
+ logger.warn(
968
+ `terminal worker removed: ${worker.tokenName}[${worker.workerIndex}] (${remaining.length} remaining)`,
969
+ );
970
+ this.requestAutoscaleEvaluation();
971
+ }
972
+
865
973
  private requestAutoscaleEvaluation(): void {
866
- if (!this.poolConfig.autoscale || this.stopped) return;
974
+ if (this.stopped) return;
867
975
  void this.evaluateAutoscaling().catch((e) =>
868
976
  logger.warn(`openai autoscale evaluation failed: ${(e as Error).message}`),
869
977
  );
@@ -936,6 +1044,12 @@ export class OpenAIDispatcher implements ProviderDispatcher {
936
1044
  this.weightedSelector.setToken(id, weight);
937
1045
  }
938
1046
 
1047
+ private hasActiveTokenCandidate(excludedTokenIds: Set<number>): boolean {
1048
+ return [...this.tokenMetadata.entries()].some(
1049
+ ([tokenId, metadata]) => metadata.active && !excludedTokenIds.has(tokenId),
1050
+ );
1051
+ }
1052
+
939
1053
  private getQuotaThreshold(tokenId: number): number | null | undefined {
940
1054
  return this.tokenMetadata.get(tokenId)?.quotaThreshold;
941
1055
  }