Files
cloud-orchestrator/src/utils/bandwidth.ts
kappa 8c543eeaa5 feat: improve recommendation diversity and KRW rounding
- Add spec diversity: recommend Budget/Balanced/Premium tiers instead of same spec
- Add bandwidth-based filtering: prioritize servers with adequate transfer allowance
- Fix KRW rounding: server price 500원, TB cost 500원, GB cost 1원
- Add bandwidth warning to infrastructure_tips when traffic exceeds 2x included

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 14:44:34 +09:00

400 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Bandwidth estimation utility functions
*/
import type { BandwidthEstimate, BandwidthInfo, UseCaseConfig } from '../types';
import { USE_CASE_CONFIGS } from '../config';
/**
* Find use case configuration by matching patterns
*/
export function findUseCaseConfig(useCase: string): UseCaseConfig {
const useCaseLower = useCase.toLowerCase();
for (const config of USE_CASE_CONFIGS) {
if (config.patterns.test(useCaseLower)) {
return config;
}
}
// Default configuration
return {
category: 'default',
patterns: /.*/,
dauMultiplier: { min: 10, max: 14 },
activeRatio: 0.5,
cdnCacheHitRate: 0.50 // 기본값: 50%
};
}
/**
* Get CDN cache hit rate based on use case
* Returns default rate for the use case category
*/
export function getCdnCacheHitRate(useCase: string): number {
return findUseCaseConfig(useCase).cdnCacheHitRate;
}
/**
* Get DAU multiplier based on use case (how many daily active users per concurrent user)
*/
export function getDauMultiplier(useCase: string): { min: number; max: number } {
return findUseCaseConfig(useCase).dauMultiplier;
}
/**
* Get active user ratio (what percentage of DAU actually performs the bandwidth-heavy action)
*/
export function getActiveUserRatio(useCase: string): number {
return findUseCaseConfig(useCase).activeRatio;
}
export interface CdnOptions {
enabled?: boolean; // CDN 사용 여부 (기본: true)
cacheHitRate?: number; // 캐시 히트율 override (0.0-1.0)
}
/**
* Estimate monthly bandwidth based on concurrent users and use case
* @param concurrentUsers Expected concurrent users
* @param useCase Use case description
* @param trafficPattern Traffic pattern (steady, spiky, growing)
* @param cdnOptions CDN configuration options
*/
export function estimateBandwidth(
concurrentUsers: number,
useCase: string,
trafficPattern?: string,
cdnOptions?: CdnOptions
): BandwidthEstimate {
const useCaseLower = useCase.toLowerCase();
// Get use case configuration
const config = findUseCaseConfig(useCase);
const useCaseCategory = config.category;
// Calculate DAU estimate from concurrent users with use-case-specific multipliers
const dauMultiplier = config.dauMultiplier;
const estimatedDauMin = Math.round(concurrentUsers * dauMultiplier.min);
const estimatedDauMax = Math.round(concurrentUsers * dauMultiplier.max);
const dailyUniqueVisitors = Math.round((estimatedDauMin + estimatedDauMax) / 2);
const activeUserRatio = config.activeRatio;
const activeDau = Math.round(dailyUniqueVisitors * activeUserRatio);
// Traffic pattern adjustment
let patternMultiplier = 1.0;
if (trafficPattern === 'spiky') {
patternMultiplier = 1.5; // Account for peak loads
} else if (trafficPattern === 'growing') {
patternMultiplier = 1.3; // Headroom for growth
}
let dailyBandwidthGB: number;
let bandwidthModel: string;
// ========== IMPROVED BANDWIDTH MODELS ==========
// Each use case uses the most appropriate calculation method
switch (useCaseCategory) {
case 'video': {
// VIDEO/STREAMING: Bitrate-based model
const is4K = /4k|uhd|ultra/i.test(useCaseLower);
const bitrateGBperHour = is4K ? 11.25 : 2.25; // 4K vs HD
const avgWatchTimeHours = is4K ? 1.0 : 1.5;
const gbPerActiveUser = bitrateGBperHour * avgWatchTimeHours;
dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier;
bandwidthModel = `bitrate-based: ${activeDau} active × ${bitrateGBperHour} GB/hr × ${avgWatchTimeHours}hr`;
break;
}
case 'file': {
// FILE DOWNLOAD: File-size based model
const isLargeFiles = /iso|video|backup|대용량/.test(useCaseLower);
const avgFileSizeGB = isLargeFiles ? 2.0 : 0.2;
const downloadsPerUser = isLargeFiles ? 1 : 3;
const gbPerActiveUser = avgFileSizeGB * downloadsPerUser;
dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier;
bandwidthModel = `file-based: ${activeDau} active × ${avgFileSizeGB} GB × ${downloadsPerUser} downloads`;
break;
}
case 'gaming': {
// GAMING: Session-duration based model
const isMinecraft = /minecraft|마인크래프트/.test(useCaseLower);
const mbPerHour = isMinecraft ? 150 : 80;
const avgSessionHours = isMinecraft ? 3 : 2.5;
const gbPerActiveUser = (mbPerHour * avgSessionHours) / 1024;
dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier;
bandwidthModel = `session-based: ${activeDau} active × ${mbPerHour} MB/hr × ${avgSessionHours}hr`;
break;
}
case 'api': {
// API/SAAS: Request-based model
const avgRequestKB = 20;
const requestsPerUserPerDay = 1000;
const gbPerActiveUser = (avgRequestKB * requestsPerUserPerDay) / (1024 * 1024);
dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier;
bandwidthModel = `request-based: ${activeDau} active × ${avgRequestKB}KB × ${requestsPerUserPerDay} req`;
break;
}
case 'ecommerce': {
// E-COMMERCE: Page-based model (images heavy)
const avgPageSizeMB = 2.5;
const pagesPerSession = 20;
const gbPerActiveUser = (avgPageSizeMB * pagesPerSession) / 1024;
dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier;
bandwidthModel = `page-based: ${activeDau} active × ${avgPageSizeMB}MB × ${pagesPerSession} pages`;
break;
}
case 'forum': {
// FORUM/COMMUNITY: Page-based model (text + some images)
const avgPageSizeMB = 0.7;
const pagesPerSession = 30;
const gbPerActiveUser = (avgPageSizeMB * pagesPerSession) / 1024;
dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier;
bandwidthModel = `page-based: ${activeDau} active × ${avgPageSizeMB}MB × ${pagesPerSession} pages`;
break;
}
case 'blog': {
// STATIC/BLOG: Lightweight page-based model
const avgPageSizeMB = 1.5;
const pagesPerSession = 4;
const gbPerActiveUser = (avgPageSizeMB * pagesPerSession) / 1024;
dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier;
bandwidthModel = `page-based: ${activeDau} active × ${avgPageSizeMB}MB × ${pagesPerSession} pages`;
break;
}
case 'chat': {
// CHAT/MESSAGING: Message-based model
const textBandwidthMB = (3 * 200) / 1024; // 3KB × 200 messages
const attachmentBandwidthMB = 20; // occasional images/files
const gbPerActiveUser = (textBandwidthMB + attachmentBandwidthMB) / 1024;
dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier;
bandwidthModel = `message-based: ${activeDau} active × ~20MB/user (text+attachments)`;
break;
}
default: {
// DEFAULT: General web app (page-based)
const avgPageSizeMB = 1.0;
const pagesPerSession = 10;
const gbPerActiveUser = (avgPageSizeMB * pagesPerSession) / 1024;
dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier;
bandwidthModel = `page-based (default): ${activeDau} active × ${avgPageSizeMB}MB × ${pagesPerSession} pages`;
break;
}
}
// CDN configuration
const cdnEnabled = cdnOptions?.enabled !== false; // 기본값: true
const cdnCacheHitRate = cdnOptions?.cacheHitRate ?? config.cdnCacheHitRate;
console.log(`[Bandwidth] Model: ${bandwidthModel}`);
console.log(`[Bandwidth] DAU: ${dailyUniqueVisitors} (${dauMultiplier.min}-${dauMultiplier.max}x), Active: ${activeDau} (${(activeUserRatio * 100).toFixed(0)}%), Daily: ${dailyBandwidthGB.toFixed(1)} GB`);
// Monthly bandwidth (gross - before CDN)
const grossMonthlyGB = dailyBandwidthGB * 30;
const grossMonthlyTB = grossMonthlyGB / 1024;
// Origin bandwidth (after CDN cache hit)
const originMonthlyTB = cdnEnabled
? grossMonthlyTB * (1 - cdnCacheHitRate)
: grossMonthlyTB;
const originMonthlyGB = originMonthlyTB * 1024;
// Use origin traffic for categorization (actual server load)
const monthlyGB = originMonthlyGB;
const monthlyTB = originMonthlyTB;
console.log(`[Bandwidth] CDN: ${cdnEnabled ? 'enabled' : 'disabled'}, Hit Rate: ${(cdnCacheHitRate * 100).toFixed(0)}%`);
console.log(`[Bandwidth] Gross: ${grossMonthlyTB.toFixed(1)} TB → Origin: ${originMonthlyTB.toFixed(1)} TB (${((1 - cdnCacheHitRate) * 100).toFixed(0)}%)`);
// Categorize based on ORIGIN traffic (actual server load)
let category: 'light' | 'moderate' | 'heavy' | 'very_heavy';
let description: string;
if (monthlyTB < 0.5) {
category = 'light';
description = cdnEnabled
? `${grossMonthlyTB.toFixed(1)}TB 중 원본 서버 ~${Math.round(monthlyGB)}GB/월 (CDN ${(cdnCacheHitRate * 100).toFixed(0)}% 캐시)`
: `~${Math.round(monthlyGB)} GB/month - Most VPS plans include sufficient bandwidth`;
} else if (monthlyTB < 2) {
category = 'moderate';
description = cdnEnabled
? `${grossMonthlyTB.toFixed(1)}TB 중 원본 서버 ~${monthlyTB.toFixed(1)}TB/월 (CDN ${(cdnCacheHitRate * 100).toFixed(0)}% 캐시)`
: `~${monthlyTB.toFixed(1)} TB/month - Check provider bandwidth limits`;
} else if (monthlyTB < 6) {
category = 'heavy';
description = cdnEnabled
? `${grossMonthlyTB.toFixed(1)}TB 중 원본 서버 ~${monthlyTB.toFixed(1)}TB/월 (CDN ${(cdnCacheHitRate * 100).toFixed(0)}% 캐시) - 대역폭 여유 있는 플랜 권장`
: `~${monthlyTB.toFixed(1)} TB/month - Prefer providers with generous bandwidth (Linode: 1-6TB included)`;
} else {
category = 'very_heavy';
description = cdnEnabled
? `${grossMonthlyTB.toFixed(1)}TB 중 원본 서버 ~${monthlyTB.toFixed(1)}TB/월 (CDN ${(cdnCacheHitRate * 100).toFixed(0)}% 캐시) - 고대역폭 플랜 필수`
: `~${monthlyTB.toFixed(1)} TB/month - HIGH BANDWIDTH: Linode strongly recommended for cost savings`;
}
return {
monthly_gb: Math.round(monthlyGB),
monthly_tb: Math.round(monthlyTB * 10) / 10,
daily_gb: Math.round(dailyBandwidthGB * 10) / 10,
category,
description,
estimated_dau_min: estimatedDauMin,
estimated_dau_max: estimatedDauMax,
active_ratio: activeUserRatio,
// CDN breakdown
cdn_enabled: cdnEnabled,
cdn_cache_hit_rate: cdnCacheHitRate,
gross_monthly_tb: Math.round(grossMonthlyTB * 10) / 10,
origin_monthly_tb: Math.round(originMonthlyTB * 10) / 10
};
}
/**
* Get provider bandwidth allocation based on memory size
* Returns included transfer in TB/month
*/
export function getProviderBandwidthAllocation(providerName: string, memoryGb: number): {
included_tb: number;
overage_per_gb: number;
overage_per_tb: number;
} {
const provider = providerName.toLowerCase();
if (provider.includes('linode')) {
// Linode: roughly 1TB per 1GB RAM (Nanode 1GB = 1TB, 2GB = 2TB, etc.)
const includedTb = Math.min(Math.max(memoryGb, 1), 20);
return {
included_tb: includedTb,
overage_per_gb: 0.005, // $0.005/GB = $5/TB
overage_per_tb: 5
};
} else if (provider.includes('vultr')) {
// Vultr: varies by plan, roughly 1-2TB for small, up to 10TB for large
let includedTb: number;
if (memoryGb <= 2) includedTb = 1;
else if (memoryGb <= 4) includedTb = 2;
else if (memoryGb <= 8) includedTb = 3;
else if (memoryGb <= 16) includedTb = 4;
else if (memoryGb <= 32) includedTb = 5;
else includedTb = Math.min(memoryGb / 4, 10);
return {
included_tb: includedTb,
overage_per_gb: 0.01, // $0.01/GB = $10/TB
overage_per_tb: 10
};
} else {
// Default/Other providers: conservative estimate
return {
included_tb: Math.min(memoryGb, 5),
overage_per_gb: 0.01,
overage_per_tb: 10
};
}
}
/**
* Calculate bandwidth cost info for a server
* Uses actual DB values from anvil_transfer_pricing when available
* @param server Server object
* @param bandwidthEstimate Bandwidth estimate
* @param lang Language code (ko = KRW, others = USD)
* @param exchangeRate Exchange rate (USD to KRW)
*/
export function calculateBandwidthInfo(
server: import('../types').Server,
bandwidthEstimate: BandwidthEstimate,
lang: string = 'en',
exchangeRate: number = 1
): BandwidthInfo {
// Use actual DB values if available (Anvil servers), fallback to provider-based estimation
let includedTb: number;
let overagePerGbUsd: number;
let overagePerTbUsd: number;
if (server.transfer_tb !== null && server.transfer_price_per_gb !== null) {
// Use actual values from anvil_instances + anvil_transfer_pricing
includedTb = server.transfer_tb;
overagePerGbUsd = server.transfer_price_per_gb;
overagePerTbUsd = server.transfer_price_per_gb * 1024;
} else {
// Fallback to provider-based estimation for non-Anvil servers
const allocation = getProviderBandwidthAllocation(server.provider_name, server.memory_gb);
includedTb = allocation.included_tb;
overagePerGbUsd = allocation.overage_per_gb;
overagePerTbUsd = allocation.overage_per_tb;
}
const estimatedTb = bandwidthEstimate.monthly_tb;
const overageTb = Math.max(0, estimatedTb - includedTb);
const overageCostUsd = overageTb * overagePerTbUsd;
// Get server price in USD for total calculation
const serverPriceUsd = server.currency === 'KRW'
? server.monthly_price / exchangeRate
: server.monthly_price;
const totalCostUsd = serverPriceUsd + overageCostUsd;
// Convert to KRW if Korean language, round to nearest 100
const isKorean = lang === 'ko';
const currency: 'USD' | 'KRW' = isKorean ? 'KRW' : 'USD';
// KRW 반올림: GB당 1원, TB당 500원, 총 비용 1원
const toKrw = (usd: number) => Math.round(usd * exchangeRate); // 1원 단위
const toKrw500 = (usd: number) => Math.round((usd * exchangeRate) / 500) * 500; // 500원 단위
const overagePerGb = isKorean ? toKrw(overagePerGbUsd) : overagePerGbUsd;
const overagePerTb = isKorean ? toKrw500(overagePerTbUsd) : overagePerTbUsd; // 500원 단위
const overageCost = isKorean ? toKrw500(overageCostUsd) : overageCostUsd; // 500원 단위
// totalCost: 서버 가격(500원) + 초과 비용(500원)
const totalCost = isKorean ? server.monthly_price + toKrw500(overageCostUsd) : totalCostUsd;
// CDN savings calculation
const cdnEnabled = bandwidthEstimate.cdn_enabled;
const cdnCacheHitRate = bandwidthEstimate.cdn_cache_hit_rate;
const grossMonthlyTb = bandwidthEstimate.gross_monthly_tb;
const cdnSavingsTb = cdnEnabled ? grossMonthlyTb - estimatedTb : 0;
const cdnSavingsCostUsd = cdnSavingsTb * overagePerTbUsd;
const cdnSavingsCost = isKorean ? toKrw500(cdnSavingsCostUsd) : cdnSavingsCostUsd; // 500원 단위
let warning: string | undefined;
if (overageTb > includedTb) {
const costStr = isKorean ? `${overageCost.toLocaleString()}` : `$${overageCost.toFixed(0)}`;
warning = cdnEnabled
? `⚠️ CDN 적용 후에도 원본 서버 트래픽(${estimatedTb.toFixed(1)}TB)이 기본 포함량(${includedTb}TB)의 2배 이상입니다. 상위 플랜을 고려하세요.`
: `⚠️ 예상 트래픽(${estimatedTb.toFixed(1)}TB)이 기본 포함량(${includedTb}TB)의 2배 이상입니다. 상위 플랜을 고려하세요.`;
} else if (overageTb > 0) {
const costStr = isKorean ? `${overageCost.toLocaleString()}` : `$${overageCost.toFixed(0)}`;
warning = cdnEnabled
? `예상 초과 트래픽: ${overageTb.toFixed(1)}TB (CDN ${(cdnCacheHitRate * 100).toFixed(0)}% 캐시 적용 후, 추가 비용 ~${costStr}/월)`
: `예상 초과 트래픽: ${overageTb.toFixed(1)}TB (추가 비용 ~${costStr}/월)`;
}
return {
included_transfer_tb: includedTb,
overage_cost_per_gb: overagePerGb, // 반올림 없음
overage_cost_per_tb: overagePerTb, // 반올림 없음
estimated_monthly_tb: Math.round(estimatedTb * 10) / 10,
estimated_overage_tb: Math.round(overageTb * 10) / 10,
estimated_overage_cost: overageCost, // 반올림 없음
total_estimated_cost: totalCost, // 서버 가격(반올림) + 초과 비용(반올림 X)
currency,
warning,
// CDN breakdown
cdn_enabled: cdnEnabled,
cdn_cache_hit_rate: cdnCacheHitRate,
gross_monthly_tb: Math.round(grossMonthlyTb * 10) / 10,
cdn_savings_tb: Math.round(cdnSavingsTb * 10) / 10,
cdn_savings_cost: cdnSavingsCost // 반올림 없음
};
}