From 9000f41653386b56ea345a06a7b512646300864c Mon Sep 17 00:00:00 2001 From: Sergii Date: Fri, 30 Jan 2026 02:54:39 +0100 Subject: [PATCH 1/2] refactor: optimizes gpu-prices endpoint --- .../deployment/deployment.repository.ts | 61 +++-- .../src/gpu/repositories/day.repository.ts | 4 +- .../api/src/gpu/services/gpu-price.service.ts | 248 +++++++++--------- 3 files changed, 173 insertions(+), 140 deletions(-) diff --git a/apps/api/src/deployment/repositories/deployment/deployment.repository.ts b/apps/api/src/deployment/repositories/deployment/deployment.repository.ts index 370b6e8799..0ace0ad4b0 100644 --- a/apps/api/src/deployment/repositories/deployment/deployment.repository.ts +++ b/apps/api/src/deployment/repositories/deployment/deployment.repository.ts @@ -104,10 +104,11 @@ export class DeploymentRepository { return deployments ? (deployments as unknown as StaleDeploymentsOutput[]) : []; } - async findAllWithGpuResources(minHeight: number) { - return await Deployment.findAll({ - attributes: ["id", "owner"], - where: { createdHeight: { [Op.gte]: minHeight } }, + async *findAllWithGpuResources(options: { minHeight: number }) { + // First, get all deployment IDs matching the criteria (lightweight query) + const recordsWithIds = await Deployment.findAll({ + attributes: ["id"], + where: { createdHeight: { [Op.gte]: options.minHeight } }, include: [ { attributes: [], @@ -121,24 +122,44 @@ export class DeploymentRepository { where: { gpuUnits: 1 } } ] - }, - { - attributes: ["height", "data", "type"], - model: AkashMessage, - as: "relatedMessages", - where: { - type: { - [Op.or]: ["/akash.market.v1beta4.MsgCreateBid", "/akash.market.v1beta5.MsgCreateBid"] - }, - height: { [Op.gte]: minHeight } - }, - include: [ - { model: Block, attributes: ["height", "dayId", "datetime"], required: true }, - { model: Transaction, attributes: ["hash"], required: true } - ] } - ] + ], + raw: true }); + + const ids = recordsWithIds.map(r => r.id); + const BATCH_SIZE = 200; + + // Iterate over IDs in batches and yield each deployment + for (let i = 0; i < ids.length; i += BATCH_SIZE) { + const batchIds = ids.slice(i, i + BATCH_SIZE); + + const batch = await Deployment.findAll({ + attributes: ["id", "owner"], + where: { id: { [Op.in]: batchIds } }, + include: [ + { + attributes: ["height", "data", "type"], + model: AkashMessage, + as: "relatedMessages", + where: { + type: { + [Op.or]: ["/akash.market.v1beta4.MsgCreateBid", "/akash.market.v1beta5.MsgCreateBid"] + }, + height: { [Op.gte]: options.minHeight } + }, + include: [ + { model: Block, attributes: ["height", "dayId", "datetime"], required: true }, + { model: Transaction, attributes: ["hash"], required: true } + ] + } + ] + }); + + for (const deployment of batch) { + yield deployment; + } + } } async findByIdWithGroups(owner: string, dseq: string): Promise { diff --git a/apps/api/src/gpu/repositories/day.repository.ts b/apps/api/src/gpu/repositories/day.repository.ts index 427f42cce4..e2da673b2b 100644 --- a/apps/api/src/gpu/repositories/day.repository.ts +++ b/apps/api/src/gpu/repositories/day.repository.ts @@ -4,7 +4,7 @@ import { singleton } from "tsyringe"; @singleton() export class DayRepository { - async getDaysAfter(date: Date) { - return await Day.findAll({ where: { date: { [Op.gte]: date } } }); + async getDaysAfter(date: Date): Promise { + return await Day.findAll({ where: { date: { [Op.gte]: date } }, raw: true }); } } diff --git a/apps/api/src/gpu/services/gpu-price.service.ts b/apps/api/src/gpu/services/gpu-price.service.ts index fcf17bdca5..db75d03995 100644 --- a/apps/api/src/gpu/services/gpu-price.service.ts +++ b/apps/api/src/gpu/services/gpu-price.service.ts @@ -1,5 +1,6 @@ import { MsgCreateBid as MsgCreateBidV4 } from "@akashnetwork/akash-api/akash/market/v1beta4"; import { MsgCreateBid as MsgCreateBidV5 } from "@akashnetwork/chain-sdk/private-types/akash.v1beta5"; +import { Day } from "@akashnetwork/database/dbSchemas/base"; import { addDays, minutesToSeconds } from "date-fns"; import { inject, injectable } from "tsyringe"; @@ -11,6 +12,7 @@ import { LoggerService } from "@src/core"; import { DeploymentRepository } from "@src/deployment/repositories/deployment/deployment.repository"; import { GpuRepository } from "@src/gpu/repositories/gpu.repository"; import type { GpuBidType, GpuProviderType, GpuWithPricesType, ProviderWithBestBid } from "@src/gpu/types/gpu.type"; +import { forEachInChunks } from "@src/utils/array/array"; import { averageBlockCountInAMonth, averageBlockCountInAnHour } from "@src/utils/constants"; import { average, median, round, weightedAverage } from "@src/utils/math"; import { decodeMsg, uint8arrayToString } from "@src/utils/protobuf"; @@ -54,77 +56,79 @@ export class GpuPriceService { throw new Error("No block found"); } - // Fetch all deployments with GPU resources created during this period and their related MsgCreateBid messages - const deployments = await this.deploymentRepository.findAllWithGpuResources(firstBlockToUse.height); - // Fetch all days for the period to have historical AKT prices const days = await this.dayRepository.getDaysAfter(addDays(new Date(), -(daysToInclude + 2))); + const daysById = days.reduce((group, day) => group.set(day.id, day), new Map()); - // Decode the MsgCreateBid messages and calculate the hourly and monthly price for each bid - const gpuBids = deployments - .flatMap(d => - d.relatedMessages.map(x => { - const day = days.find(day => day.id === x.block.dayId); - - // Determine the message version and decode accordingly - let decodedBid: MsgCreateBidV4 | MsgCreateBidV5; - let provider: string; - - if (x.type.includes("v1beta5")) { - decodedBid = decodeMsg(this.#typeRegistry, `/${MsgCreateBidV5.$type}`, x.data) as MsgCreateBidV5; - provider = decodedBid.id?.provider || ""; - } else { - decodedBid = decodeMsg(this.#typeRegistry, `/${MsgCreateBidV4.$type}`, x.data) as MsgCreateBidV4; - provider = decodedBid.provider || ""; - } - - if (!day || !day.aktPrice) return null; // Ignore bids for days where we don't have the AKT price - - if (decodedBid?.price?.denom !== "uakt") return null; // Ignore USDC bids for simplicity - - return { - height: x.height, - txHash: x.transaction.hash, - datetime: x.block.datetime, - provider: provider, - aktTokenPrice: day.aktPrice, - hourlyPrice: this.blockPriceToHourlyPrice(parseFloat(decodedBid.price.amount), day?.aktPrice), - monthlyPrice: this.blockPriceToMonthlyPrice(parseFloat(decodedBid.price.amount), day?.aktPrice), - hourlyPriceUakt: this.blockPriceToHourlyUakt(parseFloat(decodedBid.price.amount)), - monthlyPriceUakt: this.blockPriceToMonthlyUakt(parseFloat(decodedBid.price.amount)), - deployment: { - owner: d.owner, - cpuUnits: decodedBid.resourcesOffer - .flatMap((r: any) => (r.resources?.cpu?.units?.val ? parseInt(uint8arrayToString(r.resources.cpu.units.val)) : 0)) - .reduce((a: number, b: number) => a + b, 0), - memoryUnits: decodedBid.resourcesOffer - .flatMap((r: any) => (r.resources?.memory?.quantity?.val ? parseInt(uint8arrayToString(r.resources.memory.quantity.val)) : 0)) - .reduce((a: number, b: number) => a + b, 0), - storageUnits: decodedBid.resourcesOffer - .flatMap((r: any) => r.resources?.storage) - .map((s: any) => (s?.quantity?.val ? parseInt(uint8arrayToString(s.quantity.val)) : 0)) - .reduce((a: number, b: number) => a + b, 0), - gpus: decodedBid.resourcesOffer - .filter((x: any) => (x.resources?.gpu?.units?.val ? parseInt(uint8arrayToString(x.resources.gpu.units.val)) : 0) > 0) - .flatMap((r: any) => (r.resources?.gpu?.attributes ? this.getGpusFromAttributes(r.resources.gpu.attributes) : [])) - }, - data: decodedBid - }; - }) - ) - .filter(x => x) - .filter(x => x?.deployment?.gpus?.length === 1) as GpuBidType[]; // Ignore bids for deployments with more than 1 GPU - + // Fetch all deployments with GPU resources created during this period and their related MsgCreateBid messages + const deployments = this.deploymentRepository.findAllWithGpuResources({ minHeight: firstBlockToUse.height }); const gpuModels: GpuWithPricesType[] = gpus.map(x => ({ ...x, prices: [] as GpuBidType[] })); + const gpusByModelAndVendor = Object.groupBy(gpuModels, x => `${x.vendor}-${x.model}`); + + for await (const d of deployments) { + // Decode the MsgCreateBid messages and calculate the hourly and monthly price for each bid + d.relatedMessages.forEach(x => { + const day = daysById.get(x.block.dayId); + + // Determine the message version and decode accordingly + let decodedBid: MsgCreateBidV4 | MsgCreateBidV5; + let provider: string; + + if (x.type.includes("v1beta5")) { + decodedBid = decodeMsg(this.#typeRegistry, `/${MsgCreateBidV5.$type}`, x.data) as MsgCreateBidV5; + provider = decodedBid.id?.provider || ""; + } else { + decodedBid = decodeMsg(this.#typeRegistry, `/${MsgCreateBidV4.$type}`, x.data) as MsgCreateBidV4; + provider = decodedBid.provider || ""; + } + + if (!day || !day.aktPrice) return; // Ignore bids for days where we don't have the AKT price + + if (decodedBid?.price?.denom !== "uakt") return; // Ignore USDC bids for simplicity + + const bidGpus = decodedBid.resourcesOffer + .filter((x: any) => (x.resources?.gpu?.units?.val ? parseInt(uint8arrayToString(x.resources.gpu.units.val)) : 0) > 0) + .flatMap((r: any) => (r.resources?.gpu?.attributes ? this.getGpusFromAttributes(r.resources.gpu.attributes) : [])); + + // Ignore bids for deployments with more than 1 GPU + if (bidGpus.length !== 1) return; + + const bid = { + height: x.height, + txHash: x.transaction.hash, + datetime: x.block.datetime, + provider: provider, + aktTokenPrice: day.aktPrice, + hourlyPrice: this.blockPriceToHourlyPrice(parseFloat(decodedBid.price.amount), day?.aktPrice), + monthlyPrice: this.blockPriceToMonthlyPrice(parseFloat(decodedBid.price.amount), day?.aktPrice), + hourlyPriceUakt: this.blockPriceToHourlyUakt(parseFloat(decodedBid.price.amount)), + monthlyPriceUakt: this.blockPriceToMonthlyUakt(parseFloat(decodedBid.price.amount)), + deployment: { + owner: d.owner, + cpuUnits: decodedBid.resourcesOffer + .flatMap((r: any) => (r.resources?.cpu?.units?.val ? parseInt(uint8arrayToString(r.resources.cpu.units.val)) : 0)) + .reduce((a: number, b: number) => a + b, 0), + memoryUnits: decodedBid.resourcesOffer + .flatMap((r: any) => (r.resources?.memory?.quantity?.val ? parseInt(uint8arrayToString(r.resources.memory.quantity.val)) : 0)) + .reduce((a: number, b: number) => a + b, 0), + storageUnits: decodedBid.resourcesOffer + .flatMap((r: any) => r.resources?.storage) + .map((s: any) => (s?.quantity?.val ? parseInt(uint8arrayToString(s.quantity.val)) : 0)) + .reduce((a: number, b: number) => a + b, 0), + gpus: bidGpus + }, + data: decodedBid + } as GpuBidType; - // Add bids to their corresponding GPU models - for (const bid of gpuBids) { - const gpu = bid.deployment.gpus[0]; - const matchingGpuModels = gpuModels.filter(x => x.vendor === gpu.vendor && x.model === gpu.model); + const gpu = bid.deployment.gpus[0]; + const matchingGpuModels = gpusByModelAndVendor[`${gpu.vendor}-${gpu.model}`]; + if (!matchingGpuModels) return; - for (const gpuModel of matchingGpuModels) { - gpuModel.prices.push(bid); - } + // Add bids to their corresponding GPU models + for (const gpuModel of matchingGpuModels) { + gpuModel.prices.push(bid); + } + }); } // Sort GPUs by vendor, model, ram, interface @@ -132,65 +136,73 @@ export class GpuPriceService { (a, b) => a.vendor.localeCompare(b.vendor) || a.model.localeCompare(b.model) || a.ram.localeCompare(b.ram) || a.interface.localeCompare(b.interface) ); - const totalAllocatable = gpuModels.map(x => x.allocatable).reduce((a, b) => a + b, 0); - const totalAllocated = gpuModels.map(x => x.allocated).reduce((a, b) => a + b, 0); - - return { - availability: { - total: totalAllocatable, - available: totalAllocatable - totalAllocated - }, - models: gpuModels.map(x => { - /* - For each providers get their most relevent bid based on this order of priority: + let totalAllocatable = 0; + let totalAllocated = 0; + const models: unknown[] = []; + await forEachInChunks(gpuModels, async x => { + totalAllocatable += x.allocatable; + totalAllocated += x.allocated; + /* + For each providers get their most relevant bid based on this order of priority: 1- Most recent bid from the pricing bot (those deployment have tiny cpu/ram/storage specs to improve gpu price accuracy) 2- Cheapest bid with matching ram and interface 3- Cheapest bid with matching ram 4- Cheapest remaining bid 5- If no bids are found, increase search range from 14 to 31 days and repeat steps 2-4 */ - const providersWithBestBid = x.providers - .map(p => { - const providerBids = x.prices.filter(b => b.provider === p.owner); - const providerBidsLast14d = providerBids.filter(x => x.datetime > addDays(new Date(), -14)); - - const pricingBotAddress = this.#gpuConfig.PRICING_BOT_ADDRESS; - const bidsFromPricingBot = providerBids.filter(x => x.deployment.owner === pricingBotAddress && x.deployment.cpuUnits === 100); - - let bestBid = null; - if (bidsFromPricingBot.length > 0) { - bestBid = bidsFromPricingBot.sort((a, b) => b.height - a.height)[0]; - } else { - bestBid = this.findBestProviderBid(providerBidsLast14d, x) ?? this.findBestProviderBid(providerBids, x); - } - - return { - provider: p, - bestBid: bestBid - }; - }) - .filter(x => x.bestBid) as ProviderWithBestBid[]; - - return { - vendor: x.vendor, - model: x.model, - ram: x.ram, - interface: x.interface, - availability: { - total: x.allocatable, - available: x.allocatable - x.allocated - }, - providerAvailability: { - total: x.providers.length, - available: x.availableProviders.length, - providers: debug ? x.providers : undefined - }, - price: this.getPricing(providersWithBestBid), - priceUakt: this.getPricingUakt(providersWithBestBid), - bidCount: debug ? x.prices.length : undefined, - providersWithBestBid: debug ? providersWithBestBid : undefined - }; - }) + + const lastBidsForPeriod = addDays(new Date(), -14); + const pricingBotAddress = this.#gpuConfig.PRICING_BOT_ADDRESS; + const pricesByProvider = Map.groupBy(x.prices, b => b.provider); + + const providersWithBestBid: ProviderWithBestBid[] = []; + x.providers.forEach(p => { + const providerBids = pricesByProvider.get(p.owner) ?? []; + const providerBidsLast14d = providerBids.filter(b => b.datetime > lastBidsForPeriod); + const bidsFromPricingBot = providerBids.filter(b => b.deployment.owner === pricingBotAddress && b.deployment.cpuUnits === 100); + + let bestBid = null; + if (bidsFromPricingBot.length > 0) { + bestBid = bidsFromPricingBot.reduce((best, bid) => (bid.height > best.height ? bid : best)); + } else { + bestBid = this.findBestProviderBid(providerBidsLast14d, x) ?? this.findBestProviderBid(providerBids, x); + } + + if (bestBid) { + providersWithBestBid.push({ + provider: p, + bestBid: bestBid + }); + } + }); + + models.push({ + vendor: x.vendor, + model: x.model, + ram: x.ram, + interface: x.interface, + availability: { + total: x.allocatable, + available: x.allocatable - x.allocated + }, + providerAvailability: { + total: x.providers.length, + available: x.availableProviders.length, + providers: debug ? x.providers : undefined + }, + price: this.getPricing(providersWithBestBid), + priceUakt: this.getPricingUakt(providersWithBestBid), + bidCount: debug ? x.prices.length : undefined, + providersWithBestBid: debug ? providersWithBestBid : undefined + }); + }); + + return { + availability: { + total: totalAllocatable, + available: totalAllocatable - totalAllocated + }, + models }; } From 6e74e2137eb41654468825ebbaeac24aa292629d Mon Sep 17 00:00:00 2001 From: Sergii Date: Fri, 30 Jan 2026 08:08:00 +0100 Subject: [PATCH 2/2] test: add unit tests for gpu-prices --- .../api/src/gpu/controllers/gpu.controller.ts | 2 +- .../gpu-price/gpu-price.service.spec.ts | 806 ++++++++++++++++++ .../{ => gpu-price}/gpu-price.service.ts | 14 +- apps/api/src/gpu/types/gpu.type.ts | 29 + 4 files changed, 843 insertions(+), 8 deletions(-) create mode 100644 apps/api/src/gpu/services/gpu-price/gpu-price.service.spec.ts rename apps/api/src/gpu/services/{ => gpu-price}/gpu-price.service.ts (96%) diff --git a/apps/api/src/gpu/controllers/gpu.controller.ts b/apps/api/src/gpu/controllers/gpu.controller.ts index 56b40278f4..4b7871892d 100644 --- a/apps/api/src/gpu/controllers/gpu.controller.ts +++ b/apps/api/src/gpu/controllers/gpu.controller.ts @@ -2,7 +2,7 @@ import { singleton } from "tsyringe"; import { GpuBreakdownQuery } from "@src/gpu/http-schemas/gpu.schema"; import { GpuService } from "@src/gpu/services/gpu.service"; -import { GpuPriceService } from "@src/gpu/services/gpu-price.service"; +import { GpuPriceService } from "@src/gpu/services/gpu-price/gpu-price.service"; @singleton() export class GpuController { diff --git a/apps/api/src/gpu/services/gpu-price/gpu-price.service.spec.ts b/apps/api/src/gpu/services/gpu-price/gpu-price.service.spec.ts new file mode 100644 index 0000000000..6af33ba10c --- /dev/null +++ b/apps/api/src/gpu/services/gpu-price/gpu-price.service.spec.ts @@ -0,0 +1,806 @@ +import { MsgCreateBid as MsgCreateBidV4 } from "@akashnetwork/akash-api/akash/market/v1beta4"; +import { MsgCreateBid as MsgCreateBidV5 } from "@akashnetwork/chain-sdk/private-types/akash.v1beta5"; +import type { AkashBlock } from "@akashnetwork/database/dbSchemas/akash"; +import type { Day } from "@akashnetwork/database/dbSchemas/base"; +import { faker } from "@faker-js/faker"; +import { subDays } from "date-fns"; +import { mock } from "jest-mock-extended"; +import { container } from "tsyringe"; + +import type { Registry } from "@src/billing/providers/type-registry.provider"; +import { TYPE_REGISTRY } from "@src/billing/providers/type-registry.provider"; +import type { AkashBlockRepository } from "@src/block/repositories/akash-block/akash-block.repository"; +import { cacheEngine } from "@src/caching/helpers"; +import type { LoggerService } from "@src/core"; +import type { DeploymentRepository } from "@src/deployment/repositories/deployment/deployment.repository"; +import type { GpuConfig } from "../../config/env.config"; +import type { DayRepository } from "../../repositories/day.repository"; +import type { GpuRepository } from "../../repositories/gpu.repository"; +import type { GpuType } from "../../types/gpu.type"; +import { GpuPriceService } from "./gpu-price.service"; + +describe(GpuPriceService.name, () => { + beforeEach(() => { + cacheEngine.clearAllKeyInCache(); + }); + + afterEach(() => { + cacheEngine.clearAllKeyInCache(); + }); + + describe("getGpuPrices", () => { + it("returns empty availability when no GPUs available", async () => { + const { service } = setup({ + gpusForPricing: [], + deploymentsWithGpu: [], + days: [] + }); + + const result = await service.getGpuPrices(false); + + expect(result.availability.total).toBe(0); + expect(result.availability.available).toBe(0); + expect(result.models).toHaveLength(0); + }); + + it("returns GPU models with availability data", async () => { + const gpus = [createGpuType({ allocatable: 10, allocated: 3 }), createGpuType({ allocatable: 5, allocated: 2 })]; + + const { service } = setup({ + gpusForPricing: gpus, + deploymentsWithGpu: [], + days: [] + }); + + const result = await service.getGpuPrices(false); + + expect(result.availability.total).toBe(15); + expect(result.availability.available).toBe(10); + expect(result.models).toHaveLength(2); + }); + + it("calculates prices from bids for matching GPU models", async () => { + const provider1 = createProvider(); + const gpu = createGpuType({ + vendor: "nvidia", + model: "a100", + ram: "80Gi", + interface: "pcie", + allocatable: 5, + allocated: 2, + providers: [provider1] + }); + + const bidData = createMsgCreateBidV5({ + provider: provider1.owner, + gpuVendor: "nvidia", + gpuModel: "a100", + gpuRam: "80Gi", + gpuInterface: "pcie" + }); + + const aktPrice = 3.5; + const days = [createDay({ aktPrice })]; + const deployment = createDeploymentWithBid({ + dayId: days[0].id, + bidData: bidData.encoded, + bidType: `/akash.market.v1beta5.MsgCreateBid` + }); + + const { service } = setup({ + gpusForPricing: [gpu], + deploymentsWithGpu: [deployment], + days + }); + + const result = await service.getGpuPrices(false); + + expect(result.models).toHaveLength(1); + expect(result.models[0].vendor).toBe("nvidia"); + expect(result.models[0].model).toBe("a100"); + expect(result.models[0].price).not.toBeNull(); + expect(result.models[0].price?.currency).toBe("USD"); + expect(result.models[0].priceUakt).not.toBeNull(); + expect(result.models[0].priceUakt?.currency).toBe("uakt"); + }); + + it("ignores bids with USDC denomination", async () => { + const provider = createProvider(); + const gpu = createGpuType({ + vendor: "nvidia", + model: "h100", + providers: [provider] + }); + + const bidData = createMsgCreateBidV5({ + provider: provider.owner, + gpuVendor: "nvidia", + gpuModel: "h100", + denom: "ibc/usdc" + }); + + const days = [createDay({ aktPrice: 3.0 })]; + const deployment = createDeploymentWithBid({ + dayId: days[0].id, + bidData: bidData.encoded, + bidType: `/akash.market.v1beta5.MsgCreateBid` + }); + + const { service } = setup({ + gpusForPricing: [gpu], + deploymentsWithGpu: [deployment], + days + }); + + const result = await service.getGpuPrices(false); + + expect(result.models[0].price).toBeNull(); + expect(result.models[0].priceUakt).toBeNull(); + }); + + it("ignores bids for days without AKT price", async () => { + const provider = createProvider(); + const gpu = createGpuType({ + vendor: "nvidia", + model: "rtx4090", + providers: [provider] + }); + + const bidData = createMsgCreateBidV5({ + provider: provider.owner, + gpuVendor: "nvidia", + gpuModel: "rtx4090" + }); + + const days = [createDay({ aktPrice: null })]; + const deployment = createDeploymentWithBid({ + dayId: days[0].id, + bidData: bidData.encoded, + bidType: `/akash.market.v1beta5.MsgCreateBid` + }); + + const { service } = setup({ + gpusForPricing: [gpu], + deploymentsWithGpu: [deployment], + days + }); + + const result = await service.getGpuPrices(false); + + expect(result.models[0].price).toBeNull(); + }); + + it("ignores bids for deployments with multiple GPU types", async () => { + const provider = createProvider(); + const gpu = createGpuType({ + vendor: "nvidia", + model: "a100", + providers: [provider] + }); + + const bidData = createMsgCreateBidV5WithMultipleGpuTypes({ + provider: provider.owner, + gpuTypes: [ + { vendor: "nvidia", model: "a100", ram: "80Gi", interface: "pcie" }, + { vendor: "nvidia", model: "h100", ram: "80Gi", interface: "pcie" } + ] + }); + + const days = [createDay({ aktPrice: 3.0 })]; + const deployment = createDeploymentWithBid({ + dayId: days[0].id, + bidData: bidData.encoded, + bidType: `/akash.market.v1beta5.MsgCreateBid` + }); + + const { service } = setup({ + gpusForPricing: [gpu], + deploymentsWithGpu: [deployment], + days + }); + + const result = await service.getGpuPrices(false); + + expect(result.models[0].price).toBeNull(); + }); + + it("supports v1beta4 MsgCreateBid messages", async () => { + const provider = createProvider(); + const gpu = createGpuType({ + vendor: "nvidia", + model: "a100", + ram: "40Gi", + interface: "pcie", + providers: [provider] + }); + + const bidData = createMsgCreateBidV4({ + provider: provider.owner, + gpuVendor: "nvidia", + gpuModel: "a100", + gpuRam: "40Gi", + gpuInterface: "pcie" + }); + + const days = [createDay({ aktPrice: 3.0 })]; + const deployment = createDeploymentWithBid({ + dayId: days[0].id, + bidData: bidData.encoded, + bidType: `/akash.market.v1beta4.MsgCreateBid` + }); + + const { service } = setup({ + gpusForPricing: [gpu], + deploymentsWithGpu: [deployment], + days + }); + + const result = await service.getGpuPrices(false); + + expect(result.models[0].price).not.toBeNull(); + }); + + it("prefers bids from pricing bot when available", async () => { + const provider = createProvider(); + const pricingBotAddress = "akash1pricingbot123"; + const gpu = createGpuType({ + vendor: "nvidia", + model: "h100", + providers: [provider] + }); + + const regularBid = createMsgCreateBidV5({ + provider: provider.owner, + gpuVendor: "nvidia", + gpuModel: "h100", + priceAmount: "1000" + }); + + const pricingBotBid = createMsgCreateBidV5({ + provider: provider.owner, + gpuVendor: "nvidia", + gpuModel: "h100", + priceAmount: "500", + cpuUnits: 100 + }); + + const days = [createDay({ aktPrice: 3.0 })]; + const regularDeployment = createDeploymentWithBid({ + owner: faker.string.alphanumeric(43), + dayId: days[0].id, + bidData: regularBid.encoded, + bidType: `/akash.market.v1beta5.MsgCreateBid`, + height: 100 + }); + + const pricingBotDeployment = createDeploymentWithBid({ + owner: pricingBotAddress, + dayId: days[0].id, + bidData: pricingBotBid.encoded, + bidType: `/akash.market.v1beta5.MsgCreateBid`, + height: 200 + }); + + const { service } = setup({ + gpusForPricing: [gpu], + deploymentsWithGpu: [regularDeployment, pricingBotDeployment], + days, + pricingBotAddress + }); + + const result = await service.getGpuPrices(true); + + expect(result.models[0].providersWithBestBid).toHaveLength(1); + expect(result.models[0].providersWithBestBid![0].bestBid.deployment.owner).toBe(pricingBotAddress); + }); + + it("includes debug information when debug flag is true", async () => { + const provider = createProvider(); + const gpu = createGpuType({ + vendor: "nvidia", + model: "a100", + providers: [provider] + }); + + const bidData = createMsgCreateBidV5({ + provider: provider.owner, + gpuVendor: "nvidia", + gpuModel: "a100" + }); + + const days = [createDay({ aktPrice: 3.0 })]; + const deployment = createDeploymentWithBid({ + dayId: days[0].id, + bidData: bidData.encoded, + bidType: `/akash.market.v1beta5.MsgCreateBid` + }); + + const { service } = setup({ + gpusForPricing: [gpu], + deploymentsWithGpu: [deployment], + days + }); + + const result = await service.getGpuPrices(true); + + expect(result.models[0].bidCount).toBeDefined(); + expect(result.models[0].providersWithBestBid).toBeDefined(); + expect(result.models[0].providerAvailability.providers).toBeDefined(); + }); + + it("excludes debug information when debug flag is false", async () => { + const gpu = createGpuType(); + + const { service } = setup({ + gpusForPricing: [gpu], + deploymentsWithGpu: [], + days: [] + }); + + const result = await service.getGpuPrices(false); + + expect(result.models[0].bidCount).toBeUndefined(); + expect(result.models[0].providersWithBestBid).toBeUndefined(); + expect(result.models[0].providerAvailability.providers).toBeUndefined(); + }); + + it("sorts GPU models by vendor, model, ram, interface", async () => { + const gpus = [ + createGpuType({ vendor: "nvidia", model: "h100", ram: "80Gi", interface: "sxm" }), + createGpuType({ vendor: "amd", model: "mi300", ram: "192Gi", interface: "pcie" }), + createGpuType({ vendor: "nvidia", model: "a100", ram: "40Gi", interface: "pcie" }), + createGpuType({ vendor: "nvidia", model: "a100", ram: "80Gi", interface: "pcie" }) + ]; + + const { service } = setup({ + gpusForPricing: gpus, + deploymentsWithGpu: [], + days: [] + }); + + const result = await service.getGpuPrices(false); + + expect(result.models[0]).toMatchObject({ vendor: "amd", model: "mi300" }); + expect(result.models[1]).toMatchObject({ vendor: "nvidia", model: "a100", ram: "40Gi" }); + expect(result.models[2]).toMatchObject({ vendor: "nvidia", model: "a100", ram: "80Gi" }); + expect(result.models[3]).toMatchObject({ vendor: "nvidia", model: "h100" }); + }); + + it("throws error when no block is found", async () => { + const { service, akashBlockRepository } = setup({ + gpusForPricing: [], + deploymentsWithGpu: [], + days: [] + }); + + akashBlockRepository.getFirstBlockAfter.mockResolvedValue(null); + + await expect(service.getGpuPrices(false)).rejects.toThrow("No block found"); + }); + + it("calculates price statistics correctly for multiple providers", async () => { + const provider1 = createProvider({ allocatable: 10 }); + const provider2 = createProvider({ allocatable: 5 }); + + const gpu = createGpuType({ + vendor: "nvidia", + model: "a100", + ram: "80Gi", + interface: "pcie", + allocatable: 15, + allocated: 0, + providers: [provider1, provider2] + }); + + const bid1 = createMsgCreateBidV5({ + provider: provider1.owner, + gpuVendor: "nvidia", + gpuModel: "a100", + gpuRam: "80Gi", + gpuInterface: "pcie", + priceAmount: "100" + }); + + const bid2 = createMsgCreateBidV5({ + provider: provider2.owner, + gpuVendor: "nvidia", + gpuModel: "a100", + gpuRam: "80Gi", + gpuInterface: "pcie", + priceAmount: "200" + }); + + const days = [createDay({ aktPrice: 1.0 })]; + const deployment1 = createDeploymentWithBid({ + dayId: days[0].id, + bidData: bid1.encoded, + bidType: `/akash.market.v1beta5.MsgCreateBid` + }); + + const deployment2 = createDeploymentWithBid({ + dayId: days[0].id, + bidData: bid2.encoded, + bidType: `/akash.market.v1beta5.MsgCreateBid` + }); + + const { service } = setup({ + gpusForPricing: [gpu], + deploymentsWithGpu: [deployment1, deployment2], + days + }); + + const result = await service.getGpuPrices(false); + + expect(result.models[0].price).not.toBeNull(); + expect(result.models[0].price?.min).toBeDefined(); + expect(result.models[0].price?.max).toBeDefined(); + expect(result.models[0].price?.avg).toBeDefined(); + expect(result.models[0].price?.med).toBeDefined(); + expect(result.models[0].price?.weightedAverage).toBeDefined(); + }); + + it("matches SXM interface variants correctly", async () => { + const provider = createProvider(); + const gpu = createGpuType({ + vendor: "nvidia", + model: "h100", + interface: "sxm5", + providers: [provider] + }); + + const bidData = createMsgCreateBidV5({ + provider: provider.owner, + gpuVendor: "nvidia", + gpuModel: "h100", + gpuInterface: "sxm" + }); + + const days = [createDay({ aktPrice: 3.0 })]; + const deployment = createDeploymentWithBid({ + dayId: days[0].id, + bidData: bidData.encoded, + bidType: `/akash.market.v1beta5.MsgCreateBid` + }); + + const { service } = setup({ + gpusForPricing: [gpu], + deploymentsWithGpu: [deployment], + days + }); + + const result = await service.getGpuPrices(true); + + expect(result.models[0].providersWithBestBid).toHaveLength(1); + }); + }); + + function setup(input: { + gpusForPricing: GpuType[]; + deploymentsWithGpu: ReturnType[]; + days: ReturnType[]; + pricingBotAddress?: string; + }) { + const gpuRepository = mock(); + const deploymentRepository = mock(); + const akashBlockRepository = mock(); + const dayRepository = mock(); + const logger = mock(); + + const gpuConfig: GpuConfig = { + PROVIDER_UPTIME_GRACE_PERIOD_MINUTES: 180, + PRICING_BOT_ADDRESS: input.pricingBotAddress ?? "akash1pas6v0905jgyznpvnjhg7tsthuyqek60gkz7uf" + }; + + const typeRegistry = container.resolve(TYPE_REGISTRY); + + gpuRepository.getGpusForPricing.mockResolvedValue(input.gpusForPricing); + akashBlockRepository.getFirstBlockAfter.mockResolvedValue({ height: 1000 } as AkashBlock); + deploymentRepository.findAllWithGpuResources.mockReturnValue( + (async function* () { + for (const deployment of input.deploymentsWithGpu) { + yield deployment; + } + })() as never + ); + dayRepository.getDaysAfter.mockResolvedValue(input.days as Day[]); + + const service = new GpuPriceService(gpuRepository, deploymentRepository, akashBlockRepository, dayRepository, gpuConfig, typeRegistry, logger); + + return { service, gpuRepository, deploymentRepository, akashBlockRepository, dayRepository, logger }; + } + + function createProvider(overrides?: Partial) { + return { + owner: faker.string.alphanumeric(43), + hostUri: faker.internet.url(), + allocated: 0, + allocatable: overrides?.allocatable ?? 5, + ...overrides + }; + } + + function createGpuType(overrides?: Partial): GpuType { + const provider = overrides?.providers?.[0] ?? createProvider(); + return { + vendor: faker.helpers.arrayElement(["nvidia", "amd"]), + model: faker.helpers.arrayElement(["a100", "h100", "rtx4090"]), + ram: faker.helpers.arrayElement(["40Gi", "80Gi", "24Gi"]), + interface: faker.helpers.arrayElement(["pcie", "sxm"]), + allocatable: 5, + allocated: 0, + providers: [provider], + availableProviders: [provider], + ...overrides + }; + } + + function createDay(overrides?: { aktPrice?: number | null }) { + const id = faker.string.uuid(); + return { + id, + date: subDays(new Date(), faker.number.int({ min: 1, max: 30 })), + aktPrice: overrides && "aktPrice" in overrides ? overrides.aktPrice : 3.0 + }; + } + + function createDeploymentWithBid(input: { owner?: string; dayId: string; bidData: Uint8Array; bidType: string; height?: number }) { + const height = input.height ?? faker.number.int({ min: 1000, max: 100000 }); + return { + id: faker.string.uuid(), + owner: input.owner ?? faker.string.alphanumeric(43), + relatedMessages: [ + { + height, + data: input.bidData, + type: input.bidType, + block: { + height, + dayId: input.dayId, + datetime: subDays(new Date(), faker.number.int({ min: 1, max: 14 })) + }, + transaction: { + hash: faker.string.hexadecimal({ length: 64 }) + } + } + ] + }; + } + + function createMsgCreateBidV5(input: { + provider: string; + gpuVendor: string; + gpuModel: string; + gpuRam?: string; + gpuInterface?: string; + gpuCount?: number; + priceAmount?: string; + denom?: string; + cpuUnits?: number; + }) { + const gpuCount = input.gpuCount ?? 1; + const cpuUnits = input.cpuUnits ?? 1000; + const memoryUnits = 1073741824; + const storageUnits = 10737418240; + const gpuRam = input.gpuRam ?? "80Gi"; + const gpuInterface = input.gpuInterface ?? "pcie"; + + const gpuAttributes = [ + { + key: `vendor/${input.gpuVendor}/model/${input.gpuModel}/ram/${gpuRam}/interface/${gpuInterface}`, + value: "true" + } + ]; + + const bid = MsgCreateBidV5.fromPartial({ + id: { + owner: faker.string.alphanumeric(43), + dseq: faker.number.int({ min: 1, max: 1000000 }), + gseq: 1, + oseq: 1, + bseq: 1, + provider: input.provider + }, + price: { + denom: input.denom ?? "uakt", + amount: input.priceAmount ?? faker.number.int({ min: 100, max: 10000 }).toString() + }, + deposit: { + sources: [] + }, + resourcesOffer: [ + { + resources: { + id: 1, + cpu: { + units: { + val: new TextEncoder().encode(cpuUnits.toString()) + }, + attributes: [] + }, + memory: { + quantity: { + val: new TextEncoder().encode(memoryUnits.toString()) + }, + attributes: [] + }, + storage: [ + { + name: "default", + quantity: { + val: new TextEncoder().encode(storageUnits.toString()) + }, + attributes: [] + } + ], + gpu: { + units: { + val: new TextEncoder().encode(gpuCount.toString()) + }, + attributes: gpuAttributes + }, + endpoints: [] + }, + count: 1 + } + ] + }); + + const encoded = MsgCreateBidV5.encode(bid).finish(); + return { bid, encoded }; + } + + function createMsgCreateBidV5WithMultipleGpuTypes(input: { + provider: string; + gpuTypes: { vendor: string; model: string; ram: string; interface: string }[]; + }) { + const cpuUnits = 1000; + const memoryUnits = 1073741824; + const storageUnits = 10737418240; + + const gpuAttributes = input.gpuTypes.map(gpu => ({ + key: `vendor/${gpu.vendor}/model/${gpu.model}/ram/${gpu.ram}/interface/${gpu.interface}`, + value: "true" + })); + + const bid = MsgCreateBidV5.fromPartial({ + id: { + owner: faker.string.alphanumeric(43), + dseq: faker.number.int({ min: 1, max: 1000000 }), + gseq: 1, + oseq: 1, + bseq: 1, + provider: input.provider + }, + price: { + denom: "uakt", + amount: faker.number.int({ min: 100, max: 10000 }).toString() + }, + deposit: { + sources: [] + }, + resourcesOffer: [ + { + resources: { + id: 1, + cpu: { + units: { + val: new TextEncoder().encode(cpuUnits.toString()) + }, + attributes: [] + }, + memory: { + quantity: { + val: new TextEncoder().encode(memoryUnits.toString()) + }, + attributes: [] + }, + storage: [ + { + name: "default", + quantity: { + val: new TextEncoder().encode(storageUnits.toString()) + }, + attributes: [] + } + ], + gpu: { + units: { + val: new TextEncoder().encode(input.gpuTypes.length.toString()) + }, + attributes: gpuAttributes + }, + endpoints: [] + }, + count: 1 + } + ] + }); + + const encoded = MsgCreateBidV5.encode(bid).finish(); + return { bid, encoded }; + } + + function createMsgCreateBidV4(input: { + provider: string; + gpuVendor: string; + gpuModel: string; + gpuRam?: string; + gpuInterface?: string; + gpuCount?: number; + priceAmount?: string; + denom?: string; + cpuUnits?: number; + }) { + const gpuCount = input.gpuCount ?? 1; + const cpuUnits = input.cpuUnits ?? 1000; + const memoryUnits = 1073741824; + const storageUnits = 10737418240; + const gpuRam = input.gpuRam ?? "80Gi"; + const gpuInterface = input.gpuInterface ?? "pcie"; + + const gpuAttributes = [ + { + $type: "akash.base.v1beta3.Attribute" as const, + key: `vendor/${input.gpuVendor}/model/${input.gpuModel}/ram/${gpuRam}/interface/${gpuInterface}`, + value: "true" + } + ]; + + const bid = MsgCreateBidV4.fromPartial({ + order: { + owner: faker.string.alphanumeric(43), + dseq: faker.number.int({ min: 1, max: 1000000 }), + gseq: 1, + oseq: 1 + }, + provider: input.provider, + price: { + denom: input.denom ?? "uakt", + amount: input.priceAmount ?? faker.number.int({ min: 100, max: 10000 }).toString() + }, + deposit: { + denom: "uakt", + amount: "5000000" + }, + resourcesOffer: [ + { + resources: { + id: 1, + cpu: { + units: { + val: new TextEncoder().encode(cpuUnits.toString()) + }, + attributes: [] + }, + memory: { + quantity: { + val: new TextEncoder().encode(memoryUnits.toString()) + }, + attributes: [] + }, + storage: [ + { + name: "default", + quantity: { + val: new TextEncoder().encode(storageUnits.toString()) + }, + attributes: [] + } + ], + gpu: { + units: { + val: new TextEncoder().encode(gpuCount.toString()) + }, + attributes: gpuAttributes + }, + endpoints: [] + }, + count: 1 + } + ] + }); + + const encoded = MsgCreateBidV4.encode(bid).finish(); + return { bid, encoded }; + } +}); diff --git a/apps/api/src/gpu/services/gpu-price.service.ts b/apps/api/src/gpu/services/gpu-price/gpu-price.service.ts similarity index 96% rename from apps/api/src/gpu/services/gpu-price.service.ts rename to apps/api/src/gpu/services/gpu-price/gpu-price.service.ts index db75d03995..7cf7024d94 100644 --- a/apps/api/src/gpu/services/gpu-price.service.ts +++ b/apps/api/src/gpu/services/gpu-price/gpu-price.service.ts @@ -11,14 +11,14 @@ import { Memoize } from "@src/caching/helpers"; import { LoggerService } from "@src/core"; import { DeploymentRepository } from "@src/deployment/repositories/deployment/deployment.repository"; import { GpuRepository } from "@src/gpu/repositories/gpu.repository"; -import type { GpuBidType, GpuProviderType, GpuWithPricesType, ProviderWithBestBid } from "@src/gpu/types/gpu.type"; +import type { GpuBidType, GpuPriceModel, GpuProviderType, GpuWithPricesType, ProviderWithBestBid } from "@src/gpu/types/gpu.type"; import { forEachInChunks } from "@src/utils/array/array"; import { averageBlockCountInAMonth, averageBlockCountInAnHour } from "@src/utils/constants"; import { average, median, round, weightedAverage } from "@src/utils/math"; import { decodeMsg, uint8arrayToString } from "@src/utils/protobuf"; -import type { GpuConfig } from "../config/env.config"; -import { GPU_CONFIG } from "../providers/config.provider"; -import { DayRepository } from "../repositories/day.repository"; +import type { GpuConfig } from "../../config/env.config"; +import { GPU_CONFIG } from "../../providers/config.provider"; +import { DayRepository } from "../../repositories/day.repository"; @injectable() export class GpuPriceService { @@ -138,7 +138,7 @@ export class GpuPriceService { let totalAllocatable = 0; let totalAllocated = 0; - const models: unknown[] = []; + const models: GpuPriceModel[] = []; await forEachInChunks(gpuModels, async x => { totalAllocatable += x.allocatable; totalAllocated += x.allocated; @@ -218,7 +218,7 @@ export class GpuPriceService { const prices = providersWithBestBid.map(x => x.bestBid.hourlyPrice); return { - currency: "USD", + currency: "USD" as const, min: Math.min(...prices), max: Math.max(...prices), avg: round(average(prices), 2), @@ -243,7 +243,7 @@ export class GpuPriceService { const prices = providersWithBestBid.map(x => x.bestBid.hourlyPriceUakt); return { - currency: "uakt", + currency: "uakt" as const, min: Math.min(...prices), max: Math.max(...prices), avg: round(average(prices), 2), diff --git a/apps/api/src/gpu/types/gpu.type.ts b/apps/api/src/gpu/types/gpu.type.ts index b83092bc3d..975fdfdf6a 100644 --- a/apps/api/src/gpu/types/gpu.type.ts +++ b/apps/api/src/gpu/types/gpu.type.ts @@ -51,3 +51,32 @@ export type ProviderWithBestBid = { provider: GpuProviderType; bestBid: GpuBidType; }; + +export type GpuPricingStats = { + currency: T; + min: number; + max: number; + avg: number; + weightedAverage: number; + med: number; +} | null; + +export type GpuPriceModel = { + vendor: string; + model: string; + ram: string; + interface: string; + availability: { + total: number; + available: number; + }; + providerAvailability: { + total: number; + available: number; + providers?: GpuProviderType[]; + }; + price: GpuPricingStats<"USD">; + priceUakt: GpuPricingStats<"uakt">; + bidCount?: number; + providersWithBestBid?: ProviderWithBestBid[]; +};