mirror of
https://github.com/supabase/supabase.git
synced 2026-09-08 19:08:44 +08:00
## Problem On 4XL and larger compute sizes, Supabase disk IO is sustained rather than burst-budget based. Baseline equals max, so there is no extra burst balance to track (the instance can still be throttled if it hits its configured IOPS or throughput limit, but that is unrelated to a burst credit pool). The "Disk IO Burst Balance" / "Disk IO % Remaining" / "Disk IO % Consumed" charts in custom reports are therefore meaningless on 4XL+, yet they are currently still offered in the picker and rendered as empty/misleading charts. Custom reports persist their layout, so a report created when a project was on a smaller compute and later upgraded to 4XL+ can still contain a saved burst chart that we need to handle gracefully. ## Fix - Add a shared \`hasBurstableIO(infra_compute_size)\` helper in \`DiskManagement.utils.ts\` (replaces the local \`BURSTABLE_IO_VARIANTS\` set previously defined inline in \`database-charts.ts\`). - Filter \`disk_io_budget\` and \`disk_io_consumption\` out of the custom report chart picker (\`MetricOptions\`) when the project is on a non-burstable compute size. - In \`ReportBlock\`, detect saved burst charts on a 4XL+ project and render a new \`UnavailableChartBlock\` that explains the chart no longer applies and can be removed. - The database observability burst balance chart already gated on \`hasBurstableIO\`; updated to use the shared helper. - Infrastructure activity page already has equivalent handling via the dedicated-IO admonition, so no change needed there. Linear: FDBKPRI-1404 ## Test plan - [ ] On a project below 4XL, the custom report picker still lists "Disk IO % Remaining" and "Disk IO % Consumed", and they render normally - [ ] On a 4XL+ project, neither metric appears in the picker - [ ] Open an existing custom report that contains a saved burst balance chart on a 4XL+ project, and confirm the placeholder block renders with a clear explanation and a remove action - [ ] Database observability page on 4XL+: the burst balance chart remains hidden, all other charts render <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Disk IO metrics are now filtered based on your project’s compute size so only relevant charts appear. * Metrics tied to burstable IO are hidden when the current instance does not support burstable IO; deprecated metrics remain excluded. * When a disk IO chart isn’t available for your instance, the UI shows an explanatory unavailable-chart block with text about burst-balance limits for very large instances and optional actions to remove the unavailable chart. <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46327?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
463 lines
15 KiB
TypeScript
463 lines
15 KiB
TypeScript
import {
|
|
COMPUTE_BASELINE_IOPS,
|
|
COMPUTE_MAX_IOPS,
|
|
computeInstanceAddonVariantIdSchema,
|
|
} from 'shared-data'
|
|
|
|
import {
|
|
ComputeInstanceAddonVariantId,
|
|
ComputeInstanceSize,
|
|
InfraInstanceSize,
|
|
} from './DiskManagement.types'
|
|
import { DISK_LIMITS, DISK_PRICING, DiskType, PLAN_DETAILS } from './ui/DiskManagement.constants'
|
|
import { ProjectDetail } from '@/data/projects/project-detail-query'
|
|
import { PlanId, ProjectAddonVariantMeta } from '@/data/subscriptions/types'
|
|
import { INSTANCE_MICRO_SPECS, INSTANCE_NANO_SPECS } from '@/lib/constants'
|
|
|
|
// Included disk size only applies to primary, not replicas
|
|
export const calculateDiskSizePrice = ({
|
|
planId,
|
|
oldSize,
|
|
oldStorageType,
|
|
newSize,
|
|
newStorageType,
|
|
numReplicas = 0,
|
|
}: {
|
|
planId: string
|
|
oldSize: number
|
|
oldStorageType: DiskType
|
|
newSize: number
|
|
newStorageType: DiskType
|
|
numReplicas?: number
|
|
}) => {
|
|
const oldPricePerGB = DISK_PRICING[oldStorageType]?.storage ?? 0
|
|
const newPricePerGB = DISK_PRICING[newStorageType]?.storage ?? 0
|
|
const { includedDiskGB } = PLAN_DETAILS?.[planId as keyof typeof PLAN_DETAILS]
|
|
|
|
const oldPrice = Math.max(oldSize - includedDiskGB[oldStorageType], 0) * oldPricePerGB
|
|
const oldPriceReplica = oldSize * 1.25 * oldPricePerGB
|
|
const newPrice = Math.max(newSize - includedDiskGB[newStorageType], 0) * newPricePerGB
|
|
const newPriceReplica = newSize * 1.25 * newPricePerGB
|
|
|
|
return {
|
|
oldPrice: (oldPrice + numReplicas * oldPriceReplica).toFixed(2),
|
|
newPrice: (newPrice + numReplicas * newPriceReplica).toFixed(2),
|
|
}
|
|
}
|
|
|
|
export const calculateComputeSizePrice = ({
|
|
availableOptions,
|
|
oldComputeSize,
|
|
newComputeSize,
|
|
plan,
|
|
}: {
|
|
availableOptions: {
|
|
identifier: string
|
|
price: number
|
|
}[]
|
|
oldComputeSize: string
|
|
newComputeSize: string
|
|
plan: PlanId
|
|
}) => {
|
|
let _oldComputeSize = oldComputeSize
|
|
|
|
if (plan !== 'free' && oldComputeSize === 'ci_nano') {
|
|
/**
|
|
* override the old compute size to micro if the plan is not free
|
|
* this is to handle the case in which nano compute is a paid entity
|
|
*/
|
|
_oldComputeSize = 'ci_micro'
|
|
}
|
|
|
|
const oldPrice = availableOptions?.find((x) => x.identifier === _oldComputeSize)?.price ?? 0
|
|
const newPrice = availableOptions?.find((x) => x.identifier === newComputeSize)?.price ?? 0
|
|
|
|
const oldPriceMonthly = oldPrice * 720
|
|
const newPriceMonthly = newPrice * 720
|
|
|
|
return {
|
|
oldPrice: oldPriceMonthly.toFixed(2),
|
|
newPrice: newPriceMonthly.toFixed(2),
|
|
}
|
|
}
|
|
|
|
// Included IOPS applies to both primary and replicas
|
|
export const calculateIOPSPrice = ({
|
|
oldStorageType,
|
|
oldProvisionedIOPS,
|
|
newStorageType,
|
|
newProvisionedIOPS,
|
|
numReplicas = 0,
|
|
}: {
|
|
oldStorageType: DiskType
|
|
oldProvisionedIOPS: number
|
|
newStorageType: DiskType
|
|
newProvisionedIOPS: number
|
|
numReplicas?: number
|
|
}) => {
|
|
if (newStorageType === DiskType.GP3) {
|
|
const oldChargeableIOPS = Math.max(
|
|
0,
|
|
oldProvisionedIOPS - DISK_LIMITS[DiskType.GP3].includedIops
|
|
)
|
|
const newChargeableIOPS = Math.max(
|
|
0,
|
|
newProvisionedIOPS - DISK_LIMITS[DiskType.GP3].includedIops
|
|
)
|
|
const oldPrice = oldChargeableIOPS * (DISK_PRICING[oldStorageType]?.iops ?? 0)
|
|
const newPrice = newChargeableIOPS * (DISK_PRICING[newStorageType]?.iops ?? 0)
|
|
|
|
return {
|
|
oldPrice: (oldPrice * (1 + numReplicas)).toFixed(2),
|
|
newPrice: (newPrice * (1 + numReplicas)).toFixed(2),
|
|
}
|
|
} else {
|
|
const oldPrice =
|
|
oldStorageType === 'gp3'
|
|
? (oldProvisionedIOPS - DISK_LIMITS[oldStorageType].includedIops) *
|
|
DISK_PRICING[oldStorageType].iops
|
|
: oldProvisionedIOPS * (DISK_PRICING[oldStorageType]?.iops ?? 0)
|
|
const newPrice = newProvisionedIOPS * (DISK_PRICING[newStorageType]?.iops ?? 0)
|
|
return {
|
|
oldPrice: (oldPrice * (1 + numReplicas)).toFixed(2),
|
|
newPrice: (newPrice * (1 + numReplicas)).toFixed(2),
|
|
}
|
|
}
|
|
}
|
|
|
|
// This is only applicable for GP3 storage type, no need to consider IO2 at all
|
|
// Also assumes that disk size is > 400 GB (separate requirement to update throughput)
|
|
// Also, included throughput applies to both primary and replicas
|
|
export const calculateThroughputPrice = ({
|
|
storageType,
|
|
newThroughput,
|
|
oldThroughput,
|
|
numReplicas = 0,
|
|
}: {
|
|
storageType: DiskType
|
|
newThroughput: number
|
|
oldThroughput: number
|
|
numReplicas?: number
|
|
}) => {
|
|
if (storageType === DiskType.GP3 && newThroughput) {
|
|
const oldChargeableThroughput = Math.max(
|
|
0,
|
|
oldThroughput - DISK_LIMITS[DiskType.GP3].includedThroughput
|
|
)
|
|
const newChargeableThroughput = Math.max(
|
|
0,
|
|
newThroughput - DISK_LIMITS[DiskType.GP3].includedThroughput
|
|
)
|
|
const oldPrice = oldChargeableThroughput * DISK_PRICING[DiskType.GP3].throughput
|
|
const newPrice = newChargeableThroughput * DISK_PRICING[DiskType.GP3].throughput
|
|
|
|
return {
|
|
oldPrice: (oldPrice * (1 + numReplicas)).toFixed(2),
|
|
newPrice: (newPrice * (1 + numReplicas)).toFixed(2),
|
|
}
|
|
}
|
|
return { oldPrice: '0.00', newPrice: '0.00' }
|
|
}
|
|
|
|
export type ComputeAddonVariant = {
|
|
identifier: ComputeInstanceAddonVariantId
|
|
name: string
|
|
price_description: string
|
|
price: number
|
|
price_interval: 'hourly' | 'monthly'
|
|
price_type: string
|
|
meta?: ProjectAddonVariantMeta
|
|
}
|
|
|
|
type AvailableAddon = {
|
|
type: string
|
|
variants: Array<{
|
|
identifier: string
|
|
name: string
|
|
price_description: string
|
|
price: number
|
|
price_interval: 'hourly' | 'monthly'
|
|
price_type: string
|
|
meta?: unknown
|
|
}>
|
|
}
|
|
|
|
const isProjectAddonVariantMeta = (meta: unknown): meta is ProjectAddonVariantMeta => {
|
|
if (typeof meta !== 'object' || meta === null) return false
|
|
|
|
const obj = meta as Record<string, unknown>
|
|
|
|
// Validate supported_cloud_providers is an array if present (used at line 200)
|
|
if ('supported_cloud_providers' in obj && !Array.isArray(obj.supported_cloud_providers)) {
|
|
return false
|
|
}
|
|
|
|
// Check for at least one expected property to ensure it's likely a real ProjectAddonVariantMeta
|
|
const hasExpectedProperty =
|
|
'cpu_cores' in obj ||
|
|
'memory_gb' in obj ||
|
|
'cpu_dedicated' in obj ||
|
|
'baseline_disk_io_mbs' in obj ||
|
|
'max_disk_io_mbs' in obj ||
|
|
'connections_direct' in obj ||
|
|
'connections_pooler' in obj ||
|
|
'backup_duration_days' in obj ||
|
|
'supported_cloud_providers' in obj
|
|
|
|
return hasExpectedProperty
|
|
}
|
|
|
|
export function getAvailableComputeOptions(
|
|
availableAddons: AvailableAddon[],
|
|
projectCloudProvider?: string
|
|
) {
|
|
const computeAddon = availableAddons.find((addon) => addon.type === 'compute_instance')
|
|
const computeOptions: ComputeAddonVariant[] =
|
|
computeAddon?.variants.flatMap((option) => {
|
|
const parsedId = computeInstanceAddonVariantIdSchema.safeParse(option.identifier)
|
|
if (!parsedId.success) return []
|
|
|
|
if (projectCloudProvider && isProjectAddonVariantMeta(option.meta)) {
|
|
const isSupported =
|
|
!option.meta.supported_cloud_providers ||
|
|
option.meta.supported_cloud_providers.includes(projectCloudProvider)
|
|
if (!isSupported) return []
|
|
}
|
|
|
|
return [
|
|
{
|
|
...option,
|
|
identifier: parsedId.data,
|
|
meta: isProjectAddonVariantMeta(option.meta) ? option.meta : undefined,
|
|
},
|
|
]
|
|
}) ?? []
|
|
|
|
function hasMicroOptionFromApi() {
|
|
return (computeAddon?.variants ?? []).some((variant) => variant.identifier === 'ci_micro')
|
|
}
|
|
|
|
// Backwards comp until API is deployed
|
|
if (!hasMicroOptionFromApi()) {
|
|
// Unshift to push to start of array
|
|
computeOptions.unshift({
|
|
identifier: 'ci_micro',
|
|
name: 'Micro',
|
|
price_description: '$0.01344/hour (~$10/month)',
|
|
price: 0.01344,
|
|
price_interval: 'hourly',
|
|
price_type: 'usage',
|
|
meta: {
|
|
cpu_cores: INSTANCE_MICRO_SPECS.cpu_cores,
|
|
cpu_dedicated: INSTANCE_MICRO_SPECS.cpu_dedicated,
|
|
memory_gb: INSTANCE_MICRO_SPECS.memory_gb,
|
|
baseline_disk_io_mbs: INSTANCE_MICRO_SPECS.baseline_disk_io_mbs,
|
|
max_disk_io_mbs: INSTANCE_MICRO_SPECS.max_disk_io_mbs,
|
|
connections_direct: INSTANCE_MICRO_SPECS.connections_direct,
|
|
connections_pooler: INSTANCE_MICRO_SPECS.connections_pooler,
|
|
} as ProjectAddonVariantMeta,
|
|
})
|
|
}
|
|
|
|
computeOptions.unshift({
|
|
identifier: 'ci_nano',
|
|
name: 'Nano',
|
|
price_description: '$0/hour (~$0/month)',
|
|
price: 0,
|
|
price_interval: 'hourly',
|
|
price_type: 'usage',
|
|
// @ts-ignore API types it as Record<string, never>
|
|
meta: {
|
|
cpu_cores: INSTANCE_NANO_SPECS.cpu_cores,
|
|
cpu_dedicated: INSTANCE_NANO_SPECS.cpu_dedicated,
|
|
memory_gb: INSTANCE_NANO_SPECS.memory_gb,
|
|
baseline_disk_io_mbs: INSTANCE_NANO_SPECS.baseline_disk_io_mbs,
|
|
max_disk_io_mbs: INSTANCE_NANO_SPECS.max_disk_io_mbs,
|
|
connections_direct: INSTANCE_NANO_SPECS.connections_direct,
|
|
connections_pooler: INSTANCE_NANO_SPECS.connections_pooler,
|
|
} as ProjectAddonVariantMeta,
|
|
})
|
|
|
|
return computeOptions
|
|
}
|
|
|
|
export const calculateMaxIopsAllowedForDiskSizeWithGp3 = (totalSize: number) => {
|
|
return Math.max(3000, Math.min(500 * totalSize, 16000))
|
|
}
|
|
|
|
export const calculateDiskSizeRequiredForIopsWithGp3 = (iops: number) => {
|
|
return Math.max(1, Math.ceil(iops / 500))
|
|
}
|
|
|
|
export const calculateMaxIopsAllowedForDiskSizeWithio2 = (totalSize: number) => {
|
|
return Math.min(500 * totalSize, 256000)
|
|
}
|
|
|
|
export const calculateDiskSizeRequiredForIopsWithIo2 = (iops: number) => {
|
|
return Math.max(4, Math.ceil(iops / 1000))
|
|
}
|
|
|
|
export const calculateMaxThroughput = (iops: number) => {
|
|
return Math.min(0.256 * iops, 1000)
|
|
}
|
|
|
|
export const calculateIopsRequiredForThroughput = (throughput: number) => {
|
|
return Math.max(125, Math.ceil(throughput / 0.256))
|
|
}
|
|
|
|
export const calculateBaselineIopsForComputeSize = (computeSize: string): number => {
|
|
const parsed = computeInstanceAddonVariantIdSchema.safeParse(computeSize)
|
|
if (!parsed.success) return 0
|
|
return COMPUTE_BASELINE_IOPS[parsed.data] ?? 0
|
|
}
|
|
|
|
export const calculateMaxIopsForComputeSize = (computeSize: string): number => {
|
|
const parsed = computeInstanceAddonVariantIdSchema.safeParse(computeSize)
|
|
if (!parsed.success) return 0
|
|
return COMPUTE_MAX_IOPS[parsed.data] ?? 0
|
|
}
|
|
|
|
export const calculateComputeSizeRequiredForIops = (
|
|
iops: number
|
|
): ComputeInstanceAddonVariantId | undefined => {
|
|
type ComputeSizeMax = { size: ComputeInstanceAddonVariantId; maxIops: number }
|
|
|
|
const computeSizes: ComputeSizeMax[] = Object.entries(COMPUTE_MAX_IOPS)
|
|
.map((entry) => {
|
|
const [size, maxIops] = entry
|
|
const parsedSize = computeInstanceAddonVariantIdSchema.safeParse(size)
|
|
if (!parsedSize.success) return undefined
|
|
return { size: parsedSize.data, maxIops: Number(maxIops) }
|
|
})
|
|
.filter((value): value is ComputeSizeMax => value !== undefined)
|
|
.sort((a, b) => a.maxIops - b.maxIops)
|
|
|
|
for (const { size, maxIops } of computeSizes) {
|
|
if (iops <= maxIops) {
|
|
return size
|
|
}
|
|
}
|
|
|
|
const fallbackSize = computeSizes[computeSizes.length - 1]?.size
|
|
if (!fallbackSize) return undefined
|
|
return fallbackSize
|
|
}
|
|
|
|
export const calculateDiskSizeRequiredForIops = (provisionedIOPS: number): number | undefined => {
|
|
if (!provisionedIOPS) {
|
|
console.error('IOPS is required')
|
|
return undefined
|
|
}
|
|
|
|
if (isNaN(provisionedIOPS) || provisionedIOPS < 0) {
|
|
console.error('IOPS must be a non-negative number')
|
|
return undefined
|
|
}
|
|
|
|
if (provisionedIOPS > 256000) {
|
|
console.error('Maximum allowed IOPS is 256000')
|
|
return undefined
|
|
}
|
|
|
|
return Math.max(1, Math.ceil(provisionedIOPS / 1000))
|
|
}
|
|
|
|
export const formatComputeName = (compute: string) => {
|
|
return compute.toUpperCase().replace('CI_', '')
|
|
}
|
|
|
|
const infraToAddonVariant: Record<InfraInstanceSize, ComputeInstanceAddonVariantId> = {
|
|
pico: 'ci_nano',
|
|
nano: 'ci_nano',
|
|
micro: 'ci_micro',
|
|
small: 'ci_small',
|
|
medium: 'ci_medium',
|
|
large: 'ci_large',
|
|
xlarge: 'ci_xlarge',
|
|
'2xlarge': 'ci_2xlarge',
|
|
'4xlarge': 'ci_4xlarge',
|
|
'8xlarge': 'ci_8xlarge',
|
|
'12xlarge': 'ci_12xlarge',
|
|
'16xlarge': 'ci_16xlarge',
|
|
'24xlarge': 'ci_24xlarge',
|
|
'24xlarge_optimized_memory': 'ci_24xlarge_optimized_memory',
|
|
'24xlarge_optimized_cpu': 'ci_24xlarge_optimized_cpu',
|
|
'24xlarge_high_memory': 'ci_24xlarge_high_memory',
|
|
'48xlarge': 'ci_48xlarge',
|
|
'48xlarge_optimized_memory': 'ci_48xlarge_optimized_memory',
|
|
'48xlarge_optimized_cpu': 'ci_48xlarge_optimized_cpu',
|
|
'48xlarge_high_memory': 'ci_48xlarge_high_memory',
|
|
}
|
|
|
|
const isInfraInstanceSize = (value: string): value is InfraInstanceSize =>
|
|
Object.prototype.hasOwnProperty.call(infraToAddonVariant, value)
|
|
|
|
export const mapComputeSizeNameToAddonVariantId = (
|
|
computeSize: ProjectDetail['infra_compute_size']
|
|
): ComputeInstanceAddonVariantId => {
|
|
const fallback: InfraInstanceSize = 'nano'
|
|
const matchedSize = computeSize && isInfraInstanceSize(computeSize) ? computeSize : undefined
|
|
const sizeKey = matchedSize ?? fallback
|
|
return infraToAddonVariant[sizeKey]
|
|
}
|
|
|
|
// Compute variants below 4XL run on EBS instances that draw on a burst credit
|
|
// pool for disk IO, so burst balance metrics only make sense for these. 4XL
|
|
// and larger instances have sustained disk IO at their baseline (baseline
|
|
// equals max) and don't expose a burst budget.
|
|
const BURSTABLE_IO_VARIANTS: ReadonlySet<ComputeInstanceAddonVariantId> = new Set([
|
|
'ci_nano',
|
|
'ci_micro',
|
|
'ci_small',
|
|
'ci_medium',
|
|
'ci_large',
|
|
'ci_xlarge',
|
|
'ci_2xlarge',
|
|
])
|
|
|
|
export const hasBurstableIO = (computeSize: ProjectDetail['infra_compute_size']): boolean => {
|
|
// Don't fall back to the nano default that mapComputeSizeNameToAddonVariantId
|
|
// uses: if compute metadata is missing or unrecognized, treat the project as
|
|
// non-burstable so we don't accidentally surface burst-only charts.
|
|
if (!computeSize || !isInfraInstanceSize(computeSize)) return false
|
|
return BURSTABLE_IO_VARIANTS.has(infraToAddonVariant[computeSize])
|
|
}
|
|
|
|
const addonVariantToComputeSize: Record<ComputeInstanceAddonVariantId, ComputeInstanceSize> = {
|
|
ci_nano: 'Nano',
|
|
ci_micro: 'Micro',
|
|
ci_small: 'Small',
|
|
ci_medium: 'Medium',
|
|
ci_large: 'Large',
|
|
ci_xlarge: 'XL',
|
|
ci_2xlarge: '2XL',
|
|
ci_4xlarge: '4XL',
|
|
ci_8xlarge: '8XL',
|
|
ci_12xlarge: '12XL',
|
|
ci_16xlarge: '16XL',
|
|
ci_24xlarge: '24XL',
|
|
ci_24xlarge_optimized_memory: '24XL - Optimized Memory',
|
|
ci_24xlarge_optimized_cpu: '24XL - Optimized CPU',
|
|
ci_24xlarge_high_memory: '24XL - High Memory',
|
|
ci_48xlarge: '48XL',
|
|
ci_48xlarge_optimized_memory: '48XL - Optimized Memory',
|
|
ci_48xlarge_optimized_cpu: '48XL - Optimized CPU',
|
|
ci_48xlarge_high_memory: '48XL - High Memory',
|
|
}
|
|
|
|
export const mapAddOnVariantIdToComputeSize = (
|
|
addonVariantId: ComputeInstanceAddonVariantId = 'ci_nano'
|
|
): ComputeInstanceSize => {
|
|
const parsed = computeInstanceAddonVariantIdSchema.safeParse(addonVariantId)
|
|
if (!parsed.success) return addonVariantToComputeSize.ci_nano
|
|
return addonVariantToComputeSize[parsed.data]
|
|
}
|
|
|
|
export const formatNumber = (num: number): string => {
|
|
return num.toLocaleString('en-US')
|
|
}
|
|
|
|
export const showMicroUpgrade = (plan: PlanId, infraComputeSize: InfraInstanceSize): boolean => {
|
|
return plan !== 'free' && infraComputeSize === 'nano'
|
|
}
|