Improve cluster allocation strategy.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,11 +1,20 @@
|
||||
import { Injectable, NotFoundException, Logger, BadRequestException, Inject, forwardRef } from '@nestjs/common';
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
Logger,
|
||||
BadRequestException,
|
||||
Inject,
|
||||
forwardRef,
|
||||
OnModuleDestroy,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Repository, DataSource, In } from 'typeorm';
|
||||
import * as k8s from '@kubernetes/client-node';
|
||||
import * as crypto from 'crypto';
|
||||
import { Cluster, ClusterHealthStatus } from './entities/cluster.entity';
|
||||
import { ClusterPool } from './entities/cluster-pool.entity';
|
||||
import { ClusterPool, PoolStrategy } from './entities/cluster-pool.entity';
|
||||
import { ClusterHealth } from './entities/cluster-health.entity';
|
||||
import { ClusterAllocationLog } from './entities/cluster-allocation-log.entity';
|
||||
import { CreateClusterDto, UpdateClusterDto } from './dto/cluster.dto';
|
||||
@@ -15,10 +24,21 @@ import { ElasticsearchService } from '../kubernetes/elasticsearch.service';
|
||||
import { CreateApplicationDto } from '../applications/dto/application.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ClustersService {
|
||||
export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(ClustersService.name);
|
||||
private roundRobinIndex = 0;
|
||||
private poolRoundRobinIndices = new Map<string, number>();
|
||||
private weightedRoundRobinState = new Map<string, number>();
|
||||
private clusterHealthCache = new Map<string, { cluster: Cluster; cachedAt: number }>();
|
||||
private healthCheckTimer?: NodeJS.Timeout;
|
||||
private readonly cacheTtlMs =
|
||||
Number(process.env.CLUSTER_HEALTH_CACHE_TTL_MS || 60_000);
|
||||
private readonly overloadedCpuThreshold =
|
||||
Number(process.env.CLUSTER_OVERLOADED_CPU_THRESHOLD || 0.85);
|
||||
private readonly overloadedMemoryThreshold =
|
||||
Number(process.env.CLUSTER_OVERLOADED_MEMORY_THRESHOLD || 0.85);
|
||||
private readonly overloadedPodThreshold =
|
||||
Number(process.env.CLUSTER_OVERLOADED_POD_THRESHOLD || 0.85);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Cluster)
|
||||
@@ -35,6 +55,22 @@ export class ClustersService {
|
||||
private elasticsearchService: ElasticsearchService,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
const intervalMs = Number(process.env.CLUSTER_HEALTH_INTERVAL_MS || 120_000);
|
||||
this.healthCheckTimer = setInterval(() => {
|
||||
this.refreshAllClusterHealth().catch((err) => {
|
||||
this.logger.warn(`Periodic cluster health refresh failed: ${err.message}`);
|
||||
});
|
||||
}, intervalMs);
|
||||
this.healthCheckTimer.unref?.();
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
if (this.healthCheckTimer) {
|
||||
clearInterval(this.healthCheckTimer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection to a Kubernetes cluster using its kubeconfig.
|
||||
* Calls the /version endpoint to verify the cluster is reachable.
|
||||
@@ -238,6 +274,11 @@ export class ClustersService {
|
||||
async selectClusterForApplication(
|
||||
dto: CreateApplicationDto,
|
||||
userId: string,
|
||||
options: {
|
||||
excludeClusterIds?: string[];
|
||||
applicationId?: string;
|
||||
reason?: string;
|
||||
} = {},
|
||||
): Promise<{ cluster: Cluster; pool?: ClusterPool; allocationLogId: string }> {
|
||||
const estimatedRequest = this.estimateApplicationRequest(dto);
|
||||
let pool = dto.poolId
|
||||
@@ -256,15 +297,7 @@ export class ClustersService {
|
||||
});
|
||||
}
|
||||
|
||||
const candidateQuery = this.clustersRepository
|
||||
.createQueryBuilder('cluster')
|
||||
.where('cluster.status = :status', { status: ClusterStatus.ACTIVE });
|
||||
|
||||
if (pool?.clusterIds?.length) {
|
||||
candidateQuery.andWhere('cluster.id IN (:...clusterIds)', { clusterIds: pool.clusterIds });
|
||||
}
|
||||
|
||||
const candidates = await candidateQuery.getMany();
|
||||
const candidates = await this.getCachedHealthyClusters(pool || undefined, options.excludeClusterIds || []);
|
||||
const appCounts = await this.getAppCounts(candidates.map((cluster) => cluster.id));
|
||||
const candidateScores: Record<string, any>[] = [];
|
||||
const rejectionReasons: Record<string, any>[] = [];
|
||||
@@ -276,14 +309,18 @@ export class ClustersService {
|
||||
continue;
|
||||
}
|
||||
|
||||
const score = this.scoreCluster(cluster, estimatedRequest, appCounts.get(cluster.id) || 0, pool?.strategy || 'weighted-resource');
|
||||
const strategy = this.resolveStrategy(pool?.strategy);
|
||||
const appCount = appCounts.get(cluster.id) || 0;
|
||||
const score = this.scoreCluster(cluster, estimatedRequest, appCount, strategy, (dto as any).region);
|
||||
const resourceMetrics = this.getResourceMetrics(cluster, estimatedRequest, appCount);
|
||||
candidateScores.push({
|
||||
clusterId: cluster.id,
|
||||
clusterName: cluster.name,
|
||||
score,
|
||||
weight: cluster.weight || 1,
|
||||
appCount: appCounts.get(cluster.id) || 0,
|
||||
appCount,
|
||||
healthStatus: cluster.healthStatus,
|
||||
utilization: resourceMetrics.utilization,
|
||||
availableResources: cluster.availableResources || null,
|
||||
});
|
||||
}
|
||||
@@ -292,13 +329,14 @@ export class ClustersService {
|
||||
const log = await this.allocationLogsRepository.save(this.allocationLogsRepository.create({
|
||||
userId,
|
||||
poolId: pool?.id,
|
||||
applicationId: options.applicationId,
|
||||
selectedClusterId: null,
|
||||
strategy: pool?.strategy || 'weighted-resource',
|
||||
strategy: this.resolveStrategy(pool?.strategy),
|
||||
estimatedRequest,
|
||||
candidateScores,
|
||||
rejectionReasons,
|
||||
status: 'failed',
|
||||
message: 'No active healthy cluster has enough estimated capacity',
|
||||
message: options.reason || 'No active healthy cluster has enough estimated capacity',
|
||||
}));
|
||||
throw new BadRequestException(
|
||||
`No active healthy cluster has enough capacity for this application (allocation log: ${log.id})`,
|
||||
@@ -310,13 +348,14 @@ export class ClustersService {
|
||||
const log = await this.allocationLogsRepository.save(this.allocationLogsRepository.create({
|
||||
userId,
|
||||
poolId: pool?.id,
|
||||
applicationId: options.applicationId,
|
||||
selectedClusterId: selected.id,
|
||||
strategy: pool?.strategy || 'weighted-resource',
|
||||
strategy: this.resolveStrategy(pool?.strategy),
|
||||
estimatedRequest,
|
||||
candidateScores,
|
||||
rejectionReasons,
|
||||
status: 'success',
|
||||
message: `Selected ${selected.name}`,
|
||||
message: options.reason ? `${options.reason}: selected ${selected.name}` : `Selected ${selected.name}`,
|
||||
}));
|
||||
|
||||
this.logger.log(`Allocator selected cluster "${selected.name}" for user ${userId} (score ${candidateScores[0].score.toFixed(2)})`);
|
||||
@@ -335,6 +374,48 @@ export class ClustersService {
|
||||
});
|
||||
}
|
||||
|
||||
async markAllocationFailure(
|
||||
applicationId: string,
|
||||
clusterId: string | undefined,
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
if (clusterId) {
|
||||
await this.clustersRepository.update(clusterId, {
|
||||
healthStatus: 'degraded',
|
||||
healthMessage: message,
|
||||
lastHealthCheckedAt: new Date(),
|
||||
});
|
||||
this.clusterHealthCache.delete(clusterId);
|
||||
}
|
||||
|
||||
const latest = await this.allocationLogsRepository.findOne({
|
||||
where: {
|
||||
applicationId,
|
||||
...(clusterId ? { selectedClusterId: clusterId } : {}),
|
||||
},
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
if (latest) {
|
||||
await this.allocationLogsRepository.update(latest.id, {
|
||||
status: 'failed',
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async chooseFallbackClusterForApplication(
|
||||
app: CreateApplicationDto & { id: string; userId: string; clusterId?: string; poolId?: string },
|
||||
failedClusterIds: string[],
|
||||
reason: string,
|
||||
): Promise<{ cluster: Cluster; pool?: ClusterPool; allocationLogId: string }> {
|
||||
return this.selectClusterForApplication(app, app.userId, {
|
||||
excludeClusterIds: failedClusterIds,
|
||||
applicationId: app.id,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the optimal cluster using load-balancing strategy.
|
||||
* Strategy: 'least-apps' — picks the active cluster with fewest deployed applications.
|
||||
@@ -593,18 +674,32 @@ export class ClustersService {
|
||||
/**
|
||||
* Get resource usage for a specific cluster — nodes, total CPU/memory, pod counts.
|
||||
*/
|
||||
async getClusterResources(id: string): Promise<{
|
||||
async getClusterResources(id: string, options: { allowCached?: boolean } = {}): Promise<{
|
||||
nodes: { name: string; status: string; roles: string; cpuCapacity: string; memoryCapacity: string; cpuAllocatable: string; memoryAllocatable: string; }[];
|
||||
totalCpuCapacity: string;
|
||||
totalMemoryCapacity: string;
|
||||
totalCpuAllocatable: string;
|
||||
totalMemoryAllocatable: string;
|
||||
totalCpuRequested?: string;
|
||||
totalMemoryRequested?: string;
|
||||
cpuUtilization?: number;
|
||||
memoryUtilization?: number;
|
||||
podUtilization?: number;
|
||||
podCapacity?: number;
|
||||
podCount: number;
|
||||
nodeCount: number;
|
||||
readyNodeCount?: number;
|
||||
appCount: number;
|
||||
}> {
|
||||
const cluster = await this.findOne(id);
|
||||
if (
|
||||
options.allowCached !== false &&
|
||||
cluster.availableResources &&
|
||||
cluster.lastHealthCheckedAt &&
|
||||
Date.now() - new Date(cluster.lastHealthCheckedAt).getTime() < this.cacheTtlMs
|
||||
) {
|
||||
return cluster.availableResources as any;
|
||||
}
|
||||
|
||||
const kc = new k8s.KubeConfig();
|
||||
kc.loadFromString(cluster.kubeconfig);
|
||||
@@ -647,6 +742,14 @@ export class ClustersService {
|
||||
// Get all pods count
|
||||
const podsRes = await coreApi.listPodForAllNamespaces();
|
||||
const podCount = podsRes.body.items.length;
|
||||
let totalCpuRequested = 0;
|
||||
let totalMemoryRequested = 0;
|
||||
for (const pod of podsRes.body.items) {
|
||||
for (const container of pod.spec?.containers || []) {
|
||||
totalCpuRequested += this.parseCpuToMillicores(container.resources?.requests?.cpu || '0');
|
||||
totalMemoryRequested += this.parseMemoryToMi(container.resources?.requests?.memory || '0');
|
||||
}
|
||||
}
|
||||
|
||||
// Get app count for this cluster
|
||||
const appCountResult = await this.dataSource.query(
|
||||
@@ -656,12 +759,19 @@ export class ClustersService {
|
||||
const appCount = parseInt(appCountResult[0]?.count || '0', 10);
|
||||
|
||||
const readyNodeCount = nodes.filter((node) => node.status === 'Ready').length;
|
||||
const podCapacity = nodes.length * 110;
|
||||
const resources = {
|
||||
nodes,
|
||||
totalCpuCapacity: `${totalCpuCap}m`,
|
||||
totalMemoryCapacity: `${totalMemCap.toFixed(0)}Mi`,
|
||||
totalCpuAllocatable: `${totalCpuAlloc}m`,
|
||||
totalMemoryAllocatable: `${totalMemAlloc.toFixed(0)}Mi`,
|
||||
totalCpuRequested: `${totalCpuRequested}m`,
|
||||
totalMemoryRequested: `${totalMemoryRequested.toFixed(0)}Mi`,
|
||||
cpuUtilization: this.utilizationRatio(totalCpuRequested, totalCpuAlloc),
|
||||
memoryUtilization: this.utilizationRatio(totalMemoryRequested, totalMemAlloc),
|
||||
podUtilization: this.utilizationRatio(podCount, podCapacity),
|
||||
podCapacity,
|
||||
podCount,
|
||||
nodeCount: nodes.length,
|
||||
readyNodeCount,
|
||||
@@ -680,6 +790,16 @@ export class ClustersService {
|
||||
lastHealthCheckedAt: new Date(),
|
||||
availableResources: resources as any,
|
||||
});
|
||||
this.clusterHealthCache.set(id, {
|
||||
cluster: {
|
||||
...cluster,
|
||||
healthStatus,
|
||||
healthMessage,
|
||||
lastHealthCheckedAt: new Date(),
|
||||
availableResources: resources,
|
||||
},
|
||||
cachedAt: Date.now(),
|
||||
});
|
||||
await this.recordHealthSnapshot(cluster, { status: healthStatus, message: healthMessage, resources });
|
||||
|
||||
return resources;
|
||||
@@ -690,6 +810,7 @@ export class ClustersService {
|
||||
healthMessage: err.message,
|
||||
lastHealthCheckedAt: new Date(),
|
||||
});
|
||||
this.clusterHealthCache.delete(id);
|
||||
await this.recordHealthSnapshot(cluster, { status: 'unhealthy', message: err.message });
|
||||
throw new BadRequestException(`Cannot fetch resources: ${err.message}`);
|
||||
}
|
||||
@@ -927,30 +1048,106 @@ export class ClustersService {
|
||||
};
|
||||
}
|
||||
|
||||
private async getCachedHealthyClusters(
|
||||
pool?: ClusterPool,
|
||||
excludeClusterIds: string[] = [],
|
||||
): Promise<Cluster[]> {
|
||||
const query = this.clustersRepository
|
||||
.createQueryBuilder('cluster')
|
||||
.where('cluster.status = :status', { status: ClusterStatus.ACTIVE });
|
||||
|
||||
if (pool?.clusterIds?.length) {
|
||||
query.andWhere('cluster.id IN (:...clusterIds)', { clusterIds: pool.clusterIds });
|
||||
}
|
||||
|
||||
if (excludeClusterIds.length > 0) {
|
||||
query.andWhere('cluster.id NOT IN (:...excludeClusterIds)', { excludeClusterIds });
|
||||
}
|
||||
|
||||
const clusters = await query.getMany();
|
||||
const healthy: Cluster[] = [];
|
||||
for (const cluster of clusters) {
|
||||
const cached = await this.getClusterSnapshot(cluster);
|
||||
if (cached.status === ClusterStatus.ACTIVE && cached.healthStatus === 'healthy') {
|
||||
healthy.push(cached);
|
||||
}
|
||||
}
|
||||
return healthy;
|
||||
}
|
||||
|
||||
private async getClusterSnapshot(cluster: Cluster): Promise<Cluster> {
|
||||
const cached = this.clusterHealthCache.get(cluster.id);
|
||||
if (cached && Date.now() - cached.cachedAt < this.cacheTtlMs) {
|
||||
return cached.cluster;
|
||||
}
|
||||
|
||||
if (
|
||||
cluster.availableResources &&
|
||||
cluster.lastHealthCheckedAt &&
|
||||
Date.now() - new Date(cluster.lastHealthCheckedAt).getTime() < this.cacheTtlMs
|
||||
) {
|
||||
this.clusterHealthCache.set(cluster.id, { cluster, cachedAt: Date.now() });
|
||||
return cluster;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.getClusterResources(cluster.id, { allowCached: false });
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`Health refresh failed for cluster "${cluster.name}": ${err.message}`);
|
||||
}
|
||||
|
||||
const fresh = await this.clustersRepository.findOne({ where: { id: cluster.id } });
|
||||
const snapshot = fresh || cluster;
|
||||
this.clusterHealthCache.set(cluster.id, { cluster: snapshot, cachedAt: Date.now() });
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async refreshAllClusterHealth(): Promise<void> {
|
||||
const clusters = await this.clustersRepository.find({
|
||||
where: [
|
||||
{ status: ClusterStatus.ACTIVE },
|
||||
{ status: ClusterStatus.MAINTENANCE },
|
||||
],
|
||||
});
|
||||
|
||||
for (const cluster of clusters) {
|
||||
try {
|
||||
await this.getClusterResources(cluster.id, { allowCached: false });
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`Cluster health refresh failed for "${cluster.name}": ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getClusterRejectionReason(cluster: Cluster, estimatedRequest: Record<string, any>): string | null {
|
||||
if (cluster.status !== ClusterStatus.ACTIVE) {
|
||||
return `status=${cluster.status}`;
|
||||
}
|
||||
|
||||
if (cluster.healthStatus && !['healthy', 'unknown'].includes(cluster.healthStatus)) {
|
||||
if (cluster.healthStatus !== 'healthy') {
|
||||
return `health=${cluster.healthStatus}`;
|
||||
}
|
||||
|
||||
const resources = cluster.availableResources || {};
|
||||
const cpuFree = this.parseCpuToMillicores(resources.totalCpuAllocatable || resources.cpuAllocatable || '0');
|
||||
const memoryFree = this.parseMemoryToMi(resources.totalMemoryAllocatable || resources.memoryAllocatable || '0');
|
||||
const podCount = Number(resources.podCount || 0);
|
||||
const nodeCount = Number(resources.nodeCount || 0);
|
||||
const podCapacity = Number(resources.podCapacity || nodeCount * 110 || 0);
|
||||
const metrics = this.getResourceMetrics(cluster, estimatedRequest, 0);
|
||||
const { available, utilization } = metrics;
|
||||
|
||||
if (cpuFree > 0 && cpuFree < estimatedRequest.cpuMillicores) {
|
||||
return `insufficient cpu (${cpuFree}m < ${estimatedRequest.cpuMillicores}m)`;
|
||||
if (available.cpuMillicores > 0 && available.cpuMillicores < estimatedRequest.cpuMillicores) {
|
||||
return `insufficient cpu (${available.cpuMillicores}m < ${estimatedRequest.cpuMillicores}m)`;
|
||||
}
|
||||
if (memoryFree > 0 && memoryFree < estimatedRequest.memoryMi) {
|
||||
return `insufficient memory (${memoryFree}Mi < ${estimatedRequest.memoryMi}Mi)`;
|
||||
if (available.memoryMi > 0 && available.memoryMi < estimatedRequest.memoryMi) {
|
||||
return `insufficient memory (${available.memoryMi}Mi < ${estimatedRequest.memoryMi}Mi)`;
|
||||
}
|
||||
if (podCapacity > 0 && podCount + estimatedRequest.podEstimate > podCapacity) {
|
||||
return `pod pressure (${podCount}/${podCapacity})`;
|
||||
if (available.pods > 0 && available.pods < estimatedRequest.podEstimate) {
|
||||
return `pod pressure (${available.pods} pods available < ${estimatedRequest.podEstimate})`;
|
||||
}
|
||||
if (utilization.cpu >= this.overloadedCpuThreshold) {
|
||||
return `cpu overloaded (${Math.round(utilization.cpu * 100)}%)`;
|
||||
}
|
||||
if (utilization.memory >= this.overloadedMemoryThreshold) {
|
||||
return `memory overloaded (${Math.round(utilization.memory * 100)}%)`;
|
||||
}
|
||||
if (utilization.pods >= this.overloadedPodThreshold) {
|
||||
return `pod overloaded (${Math.round(utilization.pods * 100)}%)`;
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -960,34 +1157,111 @@ export class ClustersService {
|
||||
cluster: Cluster,
|
||||
estimatedRequest: Record<string, any>,
|
||||
appCount: number,
|
||||
strategy: string,
|
||||
strategy: PoolStrategy,
|
||||
desiredRegion?: string,
|
||||
): number {
|
||||
const metrics = this.getResourceMetrics(cluster, estimatedRequest, appCount);
|
||||
const capacityScore = metrics.capacityScore;
|
||||
const appPenalty = Math.min(appCount, 100) * 0.75;
|
||||
|
||||
if (strategy === 'round-robin') {
|
||||
const idx = this.poolRoundRobinIndices.get('allocator') || 0;
|
||||
this.poolRoundRobinIndices.set('allocator', idx + 1);
|
||||
return 1000 - idx;
|
||||
return this.nextRoundRobinScore('allocator');
|
||||
}
|
||||
if (strategy === 'weighted-round-robin') {
|
||||
return this.nextWeightedRoundRobinScore(cluster);
|
||||
}
|
||||
if (strategy === 'least-apps') {
|
||||
return 1000 - appPenalty + capacityScore * 100;
|
||||
}
|
||||
if (strategy === 'least-loaded') {
|
||||
return 1000 + capacityScore * 200 - metrics.utilization.average * 100 - appPenalty;
|
||||
}
|
||||
if (strategy === 'region-based') {
|
||||
const regionBoost = desiredRegion && cluster.region === desiredRegion ? 250 : 0;
|
||||
return regionBoost + (cluster.weight || 1) * 50 + capacityScore * 150 - appPenalty;
|
||||
}
|
||||
|
||||
return (cluster.weight || 1) * 100 + capacityScore * 100 - appPenalty;
|
||||
}
|
||||
|
||||
private getResourceMetrics(
|
||||
cluster: Cluster,
|
||||
estimatedRequest: Record<string, any>,
|
||||
appCount: number,
|
||||
): {
|
||||
available: { cpuMillicores: number; memoryMi: number; storageMi: number; pods: number };
|
||||
utilization: { cpu: number; memory: number; storage: number; pods: number; appPressure: number; average: number };
|
||||
capacityScore: number;
|
||||
} {
|
||||
const resources = cluster.availableResources || {};
|
||||
const cpuFree = this.parseCpuToMillicores(resources.totalCpuAllocatable || resources.cpuAllocatable || '0');
|
||||
const memoryFree = this.parseMemoryToMi(resources.totalMemoryAllocatable || resources.memoryAllocatable || '0');
|
||||
const storageFree = this.parseStorageToMi(resources.storageAllocatable || resources.storageFree || '0');
|
||||
const cpuCapacity = this.parseCpuToMillicores(resources.totalCpuAllocatable || resources.cpuAllocatable || resources.totalCpuCapacity || '0');
|
||||
const memoryCapacity = this.parseMemoryToMi(resources.totalMemoryAllocatable || resources.memoryAllocatable || resources.totalMemoryCapacity || '0');
|
||||
const storageCapacity = this.parseStorageToMi(resources.storageAllocatable || resources.storageCapacity || '0');
|
||||
const cpuRequested = this.parseCpuToMillicores(resources.totalCpuRequested || resources.cpuRequested || '0');
|
||||
const memoryRequested = this.parseMemoryToMi(resources.totalMemoryRequested || resources.memoryRequested || '0');
|
||||
const storageUsed = this.parseStorageToMi(resources.storageUsed || '0');
|
||||
const podCount = Number(resources.podCount || 0);
|
||||
const nodeCount = Number(resources.nodeCount || 0);
|
||||
const podCapacity = Number(resources.podCapacity || nodeCount * 110 || 0);
|
||||
|
||||
const available = {
|
||||
cpuMillicores: Math.max(cpuCapacity - cpuRequested, 0),
|
||||
memoryMi: Math.max(memoryCapacity - memoryRequested, 0),
|
||||
storageMi: storageCapacity > 0 ? Math.max(storageCapacity - storageUsed, 0) : 0,
|
||||
pods: podCapacity > 0 ? Math.max(podCapacity - podCount, 0) : 0,
|
||||
};
|
||||
const utilization = {
|
||||
cpu: this.utilizationRatio(cpuRequested, cpuCapacity),
|
||||
memory: this.utilizationRatio(memoryRequested, memoryCapacity),
|
||||
storage: this.utilizationRatio(storageUsed, storageCapacity),
|
||||
pods: this.utilizationRatio(podCount, podCapacity),
|
||||
appPressure: Math.min(appCount / 100, 1),
|
||||
average: 0,
|
||||
};
|
||||
utilization.average = (
|
||||
utilization.cpu * 0.35 +
|
||||
utilization.memory * 0.35 +
|
||||
utilization.pods * 0.2 +
|
||||
utilization.appPressure * 0.1
|
||||
);
|
||||
|
||||
const capacityScore =
|
||||
this.ratioScore(cpuFree, estimatedRequest.cpuMillicores) * 0.35 +
|
||||
this.ratioScore(memoryFree, estimatedRequest.memoryMi) * 0.35 +
|
||||
this.ratioScore(storageFree, estimatedRequest.storageMi) * 0.15 +
|
||||
(podCapacity > 0 ? Math.max(0, 1 - podCount / podCapacity) : 0.7) * 0.15;
|
||||
const appPenalty = Math.min(appCount, 100) * 0.75;
|
||||
this.ratioScore(available.cpuMillicores, estimatedRequest.cpuMillicores) * 0.35 +
|
||||
this.ratioScore(available.memoryMi, estimatedRequest.memoryMi) * 0.35 +
|
||||
this.ratioScore(available.storageMi, estimatedRequest.storageMi) * 0.1 +
|
||||
this.ratioScore(available.pods, estimatedRequest.podEstimate) * 0.2;
|
||||
|
||||
if (strategy === 'least-apps') {
|
||||
return 1000 - appPenalty + capacityScore * 100;
|
||||
}
|
||||
return { available, utilization, capacityScore };
|
||||
}
|
||||
|
||||
return (cluster.weight || 1) * 100 + capacityScore * 100 - appPenalty;
|
||||
private resolveStrategy(strategy?: string): PoolStrategy {
|
||||
const strategies: PoolStrategy[] = [
|
||||
'round-robin',
|
||||
'weighted-round-robin',
|
||||
'least-apps',
|
||||
'least-loaded',
|
||||
'weighted-resource',
|
||||
'region-based',
|
||||
];
|
||||
return strategies.includes(strategy as PoolStrategy) ? strategy as PoolStrategy : 'weighted-resource';
|
||||
}
|
||||
|
||||
private nextRoundRobinScore(scope: string): number {
|
||||
const idx = this.poolRoundRobinIndices.get(scope) || 0;
|
||||
this.poolRoundRobinIndices.set(scope, idx + 1);
|
||||
return 1000 - idx;
|
||||
}
|
||||
|
||||
private nextWeightedRoundRobinScore(cluster: Cluster): number {
|
||||
const current = this.weightedRoundRobinState.get(cluster.id) || 0;
|
||||
const next = current + Math.max(cluster.weight || 1, 1);
|
||||
this.weightedRoundRobinState.set(cluster.id, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
private utilizationRatio(used: number, capacity: number): number {
|
||||
if (!capacity || capacity <= 0) return 0;
|
||||
return Math.min(Math.max(used / capacity, 0), 1);
|
||||
}
|
||||
|
||||
private ratioScore(available: number, required: number): number {
|
||||
@@ -1004,6 +1278,7 @@ export class ClustersService {
|
||||
SELECT "clusterId", COUNT(*) as count
|
||||
FROM applications
|
||||
WHERE "clusterId" = ANY($1)
|
||||
AND "lifecycleStatus" = 'active'
|
||||
GROUP BY "clusterId"
|
||||
`, [clusterIds]);
|
||||
return new Map(rows.map((row) => [row.clusterId, parseInt(row.count, 10)]));
|
||||
|
||||
Reference in New Issue
Block a user