diff --git a/apps/api/src/provider/controllers/bid-screening/bid-screening.controller.ts b/apps/api/src/provider/controllers/bid-screening/bid-screening.controller.ts new file mode 100644 index 0000000000..6b63c325d2 --- /dev/null +++ b/apps/api/src/provider/controllers/bid-screening/bid-screening.controller.ts @@ -0,0 +1,13 @@ +import { singleton } from "tsyringe"; + +import type { BidScreeningRequest, BidScreeningResponse } from "@src/provider/http-schemas/bid-screening.schema"; +import { BidScreeningService } from "@src/provider/services/bid-screening/bid-screening.service"; + +@singleton() +export class BidScreeningController { + constructor(private readonly bidScreeningService: BidScreeningService) {} + + async screen(input: BidScreeningRequest["data"]): Promise { + return { data: await this.bidScreeningService.findMatchingProviders(input) }; + } +} diff --git a/apps/api/src/provider/http-schemas/bid-screening.schema.ts b/apps/api/src/provider/http-schemas/bid-screening.schema.ts new file mode 100644 index 0000000000..d83b507236 --- /dev/null +++ b/apps/api/src/provider/http-schemas/bid-screening.schema.ts @@ -0,0 +1,75 @@ +import { z } from "zod"; + +const GpuAttributesSchema = z.object({ + vendor: z.string(), + model: z.string().optional(), + interface: z.string().optional(), + memorySize: z.string().optional() +}); + +const ResourceUnitSchema = z + .object({ + cpu: z.number().int().positive(), + memory: z.number().int().positive(), + gpu: z.number().int().min(0), + gpuAttributes: GpuAttributesSchema.optional(), + ephemeralStorage: z.number().int().positive(), + persistentStorage: z.number().int().positive().optional(), + persistentStorageClass: z.enum(["beta1", "beta2", "beta3"]).optional(), + count: z.number().int().positive() + }) + .refine(data => data.gpu === 0 || data.gpuAttributes !== undefined, { + message: "gpuAttributes is required when gpu > 0", + path: ["gpuAttributes"] + }); + +const PlacementRequirementsSchema = z.object({ + attributes: z + .array(z.object({ key: z.string(), value: z.string() })) + .max(20) + .default([]), + signedBy: z + .object({ + allOf: z.array(z.string()).max(10).default([]), + anyOf: z.array(z.string()).max(10).default([]) + }) + .default({}) +}); + +export const BidScreeningRequestSchema = z.object({ + data: z.object({ + resources: z.array(ResourceUnitSchema).min(1).max(20), + requirements: PlacementRequirementsSchema.default({}), + limit: z.number().int().min(1).max(200).default(50) + }) +}); + +export type BidScreeningRequest = z.infer; + +const ProviderMatchSchema = z.object({ + owner: z.string(), + hostUri: z.string(), + leaseCount: z.number(), + availableCpu: z.number(), + availableMemory: z.number(), + availableGpu: z.number(), + availableEphemeralStorage: z.number(), + availablePersistentStorage: z.number() +}); + +const ConstraintSchema = z.object({ + name: z.string(), + count: z.number(), + actionableFeedback: z.string() +}); + +export const BidScreeningResponseSchema = z.object({ + data: z.object({ + providers: z.array(ProviderMatchSchema), + total: z.number(), + queryTimeMs: z.number(), + constraints: z.array(ConstraintSchema).optional() + }) +}); + +export type BidScreeningResponse = z.infer; diff --git a/apps/api/src/provider/routes/bid-screening/bid-screening.router.ts b/apps/api/src/provider/routes/bid-screening/bid-screening.router.ts new file mode 100644 index 0000000000..8132c11c98 --- /dev/null +++ b/apps/api/src/provider/routes/bid-screening/bid-screening.router.ts @@ -0,0 +1,42 @@ +import { container } from "tsyringe"; + +import { createRoute } from "@src/core/lib/create-route/create-route"; +import { OpenApiHonoHandler } from "@src/core/services/open-api-hono-handler/open-api-hono-handler"; +import { SECURITY_NONE } from "@src/core/services/openapi-docs/openapi-security"; +import { BidScreeningController } from "@src/provider/controllers/bid-screening/bid-screening.controller"; +import { BidScreeningRequestSchema, BidScreeningResponseSchema } from "@src/provider/http-schemas/bid-screening.schema"; + +export const bidScreeningRouter = new OpenApiHonoHandler(); + +const postRoute = createRoute({ + method: "post", + path: "/v1/bid-screening", + summary: "Pre-filter providers by resource capacity (Stage 1 bid screening)", + tags: ["Providers"], + security: SECURITY_NONE, + request: { + body: { + content: { + "application/json": { + schema: BidScreeningRequestSchema + } + } + } + }, + responses: { + 200: { + description: "Matching providers ranked by lease count and available resources", + content: { + "application/json": { + schema: BidScreeningResponseSchema + } + } + } + } +}); + +bidScreeningRouter.openapi(postRoute, async function routeBidScreening(c) { + const { data } = c.req.valid("json"); + const result = await container.resolve(BidScreeningController).screen(data); + return c.json(result, 200); +}); diff --git a/apps/api/src/provider/routes/index.ts b/apps/api/src/provider/routes/index.ts index 7f384f1917..9f91b087de 100644 --- a/apps/api/src/provider/routes/index.ts +++ b/apps/api/src/provider/routes/index.ts @@ -8,3 +8,4 @@ export * from "@src/provider/routes/provider-versions/provider-versions.router"; export * from "@src/provider/routes/provider-graph-data/provider-graph-data.router"; export * from "@src/provider/routes/provider-deployments/provider-deployments.router"; export * from "@src/provider/routes/jwt-token/jwt-token.router"; +export * from "@src/provider/routes/bid-screening/bid-screening.router"; diff --git a/apps/api/src/provider/services/bid-screening/bid-screening.service.spec.ts b/apps/api/src/provider/services/bid-screening/bid-screening.service.spec.ts new file mode 100644 index 0000000000..50652c0ffb --- /dev/null +++ b/apps/api/src/provider/services/bid-screening/bid-screening.service.spec.ts @@ -0,0 +1,408 @@ +import type { Sequelize } from "sequelize"; +import { describe, expect, it } from "vitest"; +import { mock } from "vitest-mock-extended"; + +import type { BidScreeningRequest } from "@src/provider/http-schemas/bid-screening.schema"; +import { BidScreeningService } from "./bid-screening.service"; + +describe(BidScreeningService.name, () => { + describe("aggregateResources", () => { + it("passes single resource unit values through correctly", () => { + const { service } = setup(); + + const result = service.aggregateResources([{ cpu: 1000, memory: 512, gpu: 0, ephemeralStorage: 1024, count: 1 }]); + + expect(result.totalCpu).toBe(1000); + expect(result.totalMemory).toBe(512); + expect(result.totalGpu).toBe(0); + expect(result.totalEphemeralStorage).toBe(1024); + expect(result.totalPersistentStorage).toBe(0); + expect(result.maxPerReplicaGpu).toBe(0); + expect(result.hasGpuAttributes).toBe(false); + expect(result.hasPersistentStorage).toBe(false); + }); + + it("multiplies resources by replica count", () => { + const { service } = setup(); + + const result = service.aggregateResources([{ cpu: 500, memory: 256, gpu: 2, gpuAttributes: { vendor: "nvidia" }, ephemeralStorage: 512, count: 3 }]); + + expect(result.totalCpu).toBe(1500); + expect(result.totalMemory).toBe(768); + expect(result.totalGpu).toBe(6); + expect(result.totalEphemeralStorage).toBe(1536); + expect(result.maxPerReplicaGpu).toBe(2); + }); + + it("sums totals across multiple resource units", () => { + const { service } = setup(); + + const result = service.aggregateResources([ + { cpu: 1000, memory: 512, gpu: 0, ephemeralStorage: 1024, count: 2 }, + { cpu: 500, memory: 256, gpu: 0, ephemeralStorage: 512, count: 1 } + ]); + + expect(result.totalCpu).toBe(2500); + expect(result.totalMemory).toBe(1280); + expect(result.totalEphemeralStorage).toBe(2560); + }); + + it("tracks GPU attributes including vendor and model", () => { + const { service } = setup(); + + const result = service.aggregateResources([ + { + cpu: 1000, + memory: 512, + gpu: 4, + gpuAttributes: { vendor: "nvidia", model: "a100", interface: "pcie", memorySize: "80Gi" }, + ephemeralStorage: 1024, + count: 1 + } + ]); + + expect(result.hasGpuAttributes).toBe(true); + expect(result.gpuVendor).toBe("nvidia"); + expect(result.gpuModel).toBe("a100"); + expect(result.gpuInterface).toBe("pcie"); + expect(result.gpuMemorySize).toBe("80Gi"); + expect(result.maxPerReplicaGpu).toBe(4); + }); + + it("tracks persistent storage and storage class", () => { + const { service } = setup(); + + const result = service.aggregateResources([ + { + cpu: 1000, + memory: 512, + gpu: 0, + ephemeralStorage: 1024, + persistentStorage: 2048, + persistentStorageClass: "beta3", + count: 1 + } + ]); + + expect(result.hasPersistentStorage).toBe(true); + expect(result.totalPersistentStorage).toBe(2048); + expect(result.persistentStorageClass).toBe("beta3"); + }); + + it("uses max GPU per-replica across resource units, not sum", () => { + const { service } = setup(); + + const result = service.aggregateResources([ + { cpu: 1000, memory: 512, gpu: 2, gpuAttributes: { vendor: "nvidia" }, ephemeralStorage: 1024, count: 1 }, + { cpu: 1000, memory: 512, gpu: 8, gpuAttributes: { vendor: "amd" }, ephemeralStorage: 1024, count: 1 }, + { cpu: 1000, memory: 512, gpu: 4, gpuAttributes: { vendor: "nvidia" }, ephemeralStorage: 1024, count: 1 } + ]); + + expect(result.maxPerReplicaGpu).toBe(8); + expect(result.totalGpu).toBe(14); + }); + }); + + describe("buildQuery", () => { + it("builds CPU-only workload with minimal JOINs and no GPU/storage/attribute tables", () => { + const { service } = setup(); + const agg = service.aggregateResources([{ cpu: 1000, memory: 512, gpu: 0, ephemeralStorage: 1024, count: 1 }]); + + const { sql, replacements } = service.buildQuery(agg, defaultRequirements(), 50, false); + + expect(sql).toContain('"providerSnapshot"'); + expect(sql).not.toContain('"providerSnapshotNode"'); + expect(sql).not.toContain('"providerSnapshotNodeGPU"'); + expect(sql).not.toContain('"providerSnapshotStorage"'); + expect(sql).not.toContain('"providerAttribute"'); + expect(sql).not.toContain('"providerAttributeSignature"'); + expect(replacements).toMatchObject({ totalCpu: 1000, totalMemory: 512, totalEphemeralStorage: 1024 }); + }); + + it("adds providerSnapshotNode and providerSnapshotNodeGPU JOINs for GPU with vendor+model", () => { + const { service } = setup(); + const agg = service.aggregateResources([ + { cpu: 1000, memory: 512, gpu: 4, gpuAttributes: { vendor: "nvidia", model: "a100" }, ephemeralStorage: 1024, count: 1 } + ]); + + const { sql, replacements } = service.buildQuery(agg, defaultRequirements(), 50, false); + + expect(sql).toContain('"providerSnapshotNode"'); + expect(sql).toContain('"providerSnapshotNodeGPU"'); + expect(sql).toContain("gpu.vendor = :gpuVendor"); + expect(sql).toContain("gpu.name = :gpuModel"); + expect(replacements).toMatchObject({ gpuVendor: "nvidia", gpuModel: "a100" }); + }); + + it("adds vendor filter but not model filter when only vendor is provided", () => { + const { service } = setup(); + const agg = service.aggregateResources([{ cpu: 1000, memory: 512, gpu: 1, gpuAttributes: { vendor: "nvidia" }, ephemeralStorage: 1024, count: 1 }]); + + const { sql, replacements } = service.buildQuery(agg, defaultRequirements(), 50, false); + + expect(sql).toContain("gpu.vendor = :gpuVendor"); + expect(sql).not.toContain("gpu.name = :gpuModel"); + expect(replacements).toHaveProperty("gpuVendor", "nvidia"); + expect(replacements).not.toHaveProperty("gpuModel"); + }); + + it("adds all 4 GPU attribute filters when all are provided", () => { + const { service } = setup(); + const agg = service.aggregateResources([ + { + cpu: 1000, + memory: 512, + gpu: 2, + gpuAttributes: { vendor: "nvidia", model: "a100", interface: "pcie", memorySize: "80Gi" }, + ephemeralStorage: 1024, + count: 1 + } + ]); + + const { sql, replacements } = service.buildQuery(agg, defaultRequirements(), 50, false); + + expect(sql).toContain("gpu.vendor = :gpuVendor"); + expect(sql).toContain("gpu.name = :gpuModel"); + expect(sql).toContain("gpu.interface = :gpuInterface"); + expect(sql).toContain('gpu."memorySize" = :gpuMemorySize'); + expect(replacements).toMatchObject({ gpuVendor: "nvidia", gpuModel: "a100", gpuInterface: "pcie", gpuMemorySize: "80Gi" }); + }); + + it("adds providerSnapshotStorage JOIN with class and capacity WHERE for persistent storage with class", () => { + const { service } = setup(); + const agg = service.aggregateResources([ + { + cpu: 1000, + memory: 512, + gpu: 0, + ephemeralStorage: 1024, + persistentStorage: 2048, + persistentStorageClass: "beta3", + count: 1 + } + ]); + + const { sql, replacements } = service.buildQuery(agg, defaultRequirements(), 50, false); + + expect(sql).toContain('"providerSnapshotStorage"'); + expect(sql).toContain("pss.class = :storageClass"); + expect(sql).toContain("(pss.allocatable - pss.allocated) >= :totalPersistentStorage"); + expect(replacements).toMatchObject({ storageClass: "beta3", totalPersistentStorage: 2048 }); + }); + + it("checks availablePersistentStorage without pss JOIN when no storage class is provided", () => { + const { service } = setup(); + // persistentStorage > 0 but no persistentStorageClass — resource unit without class + // We need to manually craft agg since schema requires class when storage is set in a unit + // Directly test the buildQuery path: hasPersistentStorage=true, persistentStorageClass=undefined + const agg = { + totalCpu: 1000, + totalMemory: 512, + totalGpu: 0, + totalEphemeralStorage: 1024, + totalPersistentStorage: 2048, + maxPerReplicaGpu: 0, + hasGpuAttributes: false, + hasPersistentStorage: true, + persistentStorageClass: undefined + }; + + const { sql, replacements } = service.buildQuery(agg, defaultRequirements(), 50, false); + + expect(sql).toContain('ps."availablePersistentStorage" >= :totalPersistentStorage'); + expect(sql).not.toContain('"providerSnapshotStorage"'); + expect(sql).not.toContain("pss.class = :storageClass"); + expect(replacements).toHaveProperty("totalPersistentStorage", 2048); + }); + + it("adds providerAttribute JOIN and HAVING COUNT FILTER per attribute key=value", () => { + const { service } = setup(); + const agg = service.aggregateResources([{ cpu: 1000, memory: 512, gpu: 0, ephemeralStorage: 1024, count: 1 }]); + const requirements: BidScreeningRequest["data"]["requirements"] = { + attributes: [ + { key: "region", value: "us-east" }, + { key: "tier", value: "premium" } + ], + signedBy: { allOf: [], anyOf: [] } + }; + + const { sql, replacements } = service.buildQuery(agg, requirements, 50, false); + + expect(sql).toContain('"providerAttribute"'); + expect(sql).toContain('COUNT(*) FILTER (WHERE pa."key" = :attrKey0 AND pa."value" = :attrVal0) > 0'); + expect(sql).toContain('COUNT(*) FILTER (WHERE pa."key" = :attrKey1 AND pa."value" = :attrVal1) > 0'); + expect(replacements).toMatchObject({ attrKey0: "region", attrVal0: "us-east", attrKey1: "tier", attrVal1: "premium" }); + }); + + it("adds providerAttributeSignature JOIN and auditor IN clause for signedBy anyOf", () => { + const { service } = setup(); + const agg = service.aggregateResources([{ cpu: 1000, memory: 512, gpu: 0, ephemeralStorage: 1024, count: 1 }]); + const requirements: BidScreeningRequest["data"]["requirements"] = { + attributes: [], + signedBy: { allOf: [], anyOf: ["auditor1", "auditor2"] } + }; + + const { sql, replacements } = service.buildQuery(agg, requirements, 50, false); + + expect(sql).toContain('"providerAttributeSignature"'); + expect(sql).toContain("pas.auditor IN (:anyOfAuditors)"); + expect(replacements).toMatchObject({ anyOfAuditors: ["auditor1", "auditor2"] }); + }); + + it("adds per-auditor COUNT FILTER in HAVING for signedBy allOf", () => { + const { service } = setup(); + const agg = service.aggregateResources([{ cpu: 1000, memory: 512, gpu: 0, ephemeralStorage: 1024, count: 1 }]); + const requirements: BidScreeningRequest["data"]["requirements"] = { + attributes: [], + signedBy: { allOf: ["auditor-a", "auditor-b"], anyOf: [] } + }; + + const { sql, replacements } = service.buildQuery(agg, requirements, 50, false); + + expect(sql).toContain('"providerAttributeSignature"'); + expect(sql).toContain("COUNT(*) FILTER (WHERE pas.auditor = :allOfAuditor0) > 0"); + expect(sql).toContain("COUNT(*) FILTER (WHERE pas.auditor = :allOfAuditor1) > 0"); + expect(sql).not.toContain("pas.auditor IN (:anyOfAuditors)"); + expect(replacements).toMatchObject({ allOfAuditor0: "auditor-a", allOfAuditor1: "auditor-b" }); + }); + + it("combines GPU, persistent storage, attributes, and signedBy — all JOINs present", () => { + const { service } = setup(); + const agg = service.aggregateResources([ + { + cpu: 1000, + memory: 512, + gpu: 2, + gpuAttributes: { vendor: "nvidia", model: "a100" }, + ephemeralStorage: 1024, + persistentStorage: 2048, + persistentStorageClass: "beta3", + count: 1 + } + ]); + const requirements: BidScreeningRequest["data"]["requirements"] = { + attributes: [{ key: "region", value: "us-east" }], + signedBy: { allOf: [], anyOf: ["auditor1"] } + }; + + const { sql } = service.buildQuery(agg, requirements, 50, false); + + expect(sql).toContain('"providerSnapshotNode"'); + expect(sql).toContain('"providerSnapshotNodeGPU"'); + expect(sql).toContain('"providerSnapshotStorage"'); + expect(sql).toContain('"providerAttribute"'); + expect(sql).toContain('"providerAttributeSignature"'); + }); + + it("wraps in SELECT COUNT(*) AS total and omits LIMIT when isCount=true", () => { + const { service } = setup(); + const agg = service.aggregateResources([{ cpu: 1000, memory: 512, gpu: 0, ephemeralStorage: 1024, count: 1 }]); + + const { sql, replacements } = service.buildQuery(agg, defaultRequirements(), undefined, true); + + expect(sql).toContain("SELECT COUNT(*) AS total FROM ("); + expect(sql).not.toContain("LIMIT"); + expect(replacements).not.toHaveProperty("limit"); + }); + + it("includes default limit in replacements for non-count query", () => { + const { service } = setup(); + const agg = service.aggregateResources([{ cpu: 1000, memory: 512, gpu: 0, ephemeralStorage: 1024, count: 1 }]); + + const { replacements } = service.buildQuery(agg, defaultRequirements(), 50, false); + + expect(replacements).toHaveProperty("limit", 50); + }); + }); + + describe("findMatchingProviders", () => { + it("returns providers and total count from DB queries", async () => { + const { service, chainDb } = setup(); + const mockProviders = [ + { + owner: "akash1abc", + hostUri: "https://provider1.example.com", + leaseCount: 5, + availableCpu: 10000, + availableMemory: 8192, + availableGpu: 0, + availableEphemeralStorage: 51200, + availablePersistentStorage: 0 + } + ]; + chainDb.query.mockResolvedValueOnce([{ total: "5" }] as never).mockResolvedValueOnce(mockProviders as never); + + const result = await service.findMatchingProviders({ + resources: [{ cpu: 1000, memory: 512, gpu: 0, ephemeralStorage: 1024, count: 1 }], + requirements: defaultRequirements(), + limit: 50 + }); + + expect(result.total).toBe(5); + expect(result.providers).toEqual(mockProviders); + expect(typeof result.queryTimeMs).toBe("number"); + }); + + it("runs constraint diagnosis when total is 0", async () => { + const { service, chainDb } = setup(); + chainDb.query + .mockResolvedValueOnce([{ total: "0" }] as never) + .mockResolvedValueOnce([] as never) + // diagnoseConstraints: baseline first, then cpu/memory/ephemeral in parallel + .mockResolvedValueOnce([{ c: "72" }] as never) + .mockResolvedValueOnce([{ c: "60" }] as never) + .mockResolvedValueOnce([{ c: "50" }] as never) + .mockResolvedValueOnce([{ c: "40" }] as never); + + const result = await service.findMatchingProviders({ + resources: [{ cpu: 1000, memory: 512, gpu: 0, ephemeralStorage: 1024, count: 1 }], + requirements: defaultRequirements(), + limit: 50 + }); + + expect(result.total).toBe(0); + expect(result.constraints).toBeDefined(); + expect(result.constraints).toHaveLength(4); + expect(result.constraints![0]).toMatchObject({ name: "Online providers (baseline)", count: 72 }); + }); + + it("does not run constraint diagnosis when total is greater than 0", async () => { + const { service, chainDb } = setup(); + chainDb.query.mockResolvedValueOnce([{ total: "3" }] as never).mockResolvedValueOnce([] as never); + + const result = await service.findMatchingProviders({ + resources: [{ cpu: 1000, memory: 512, gpu: 0, ephemeralStorage: 1024, count: 1 }], + requirements: defaultRequirements(), + limit: 50 + }); + + // Only 2 calls: count + main — no diagnosis calls + expect(chainDb.query).toHaveBeenCalledTimes(2); + expect(result.constraints).toBeUndefined(); + }); + + it("uses default limit of 50 when not provided", async () => { + const { service, chainDb } = setup(); + chainDb.query.mockResolvedValueOnce([{ total: "1" }] as never).mockResolvedValueOnce([] as never); + + await service.findMatchingProviders({ + resources: [{ cpu: 1000, memory: 512, gpu: 0, ephemeralStorage: 1024, count: 1 }], + requirements: defaultRequirements() + }); + + const mainQueryCall = chainDb.query.mock.calls[1]; + const mainQueryOptions = mainQueryCall[1] as { replacements: Record }; + expect(mainQueryOptions.replacements).toHaveProperty("limit", 50); + }); + }); + + function setup() { + const chainDb = mock(); + const service = new BidScreeningService(chainDb); + return { service, chainDb }; + } + + function defaultRequirements(): BidScreeningRequest["data"]["requirements"] { + return { attributes: [], signedBy: { allOf: [], anyOf: [] } }; + } +}); diff --git a/apps/api/src/provider/services/bid-screening/bid-screening.service.ts b/apps/api/src/provider/services/bid-screening/bid-screening.service.ts new file mode 100644 index 0000000000..976ffb1afc --- /dev/null +++ b/apps/api/src/provider/services/bid-screening/bid-screening.service.ts @@ -0,0 +1,435 @@ +import { QueryTypes, Sequelize } from "sequelize"; +import { inject, singleton } from "tsyringe"; + +import { CHAIN_DB } from "@src/chain"; +import type { BidScreeningRequest } from "@src/provider/http-schemas/bid-screening.schema"; + +interface ProviderMatchRow { + owner: string; + hostUri: string; + leaseCount: number; + availableCpu: number; + availableMemory: number; + availableGpu: number; + availableEphemeralStorage: number; + availablePersistentStorage: number; +} + +interface ConstraintCheckRow { + c: string; +} + +export interface Constraint { + name: string; + count: number; + actionableFeedback: string; +} + +export interface BidScreeningResult { + providers: ProviderMatchRow[]; + total: number; + queryTimeMs: number; + constraints?: Constraint[]; +} + +interface AggregatedResources { + totalCpu: number; + totalMemory: number; + totalGpu: number; + totalEphemeralStorage: number; + totalPersistentStorage: number; + maxPerReplicaGpu: number; + hasGpuAttributes: boolean; + gpuVendor?: string; + gpuModel?: string; + gpuInterface?: string; + gpuMemorySize?: string; + hasPersistentStorage: boolean; + persistentStorageClass?: string; +} + +@singleton() +export class BidScreeningService { + readonly #chainDb: Sequelize; + + constructor(@inject(CHAIN_DB) chainDb: Sequelize) { + this.#chainDb = chainDb; + } + + async findMatchingProviders(input: BidScreeningRequest["data"]): Promise { + const agg = this.aggregateResources(input.resources); + const limit = input.limit ?? 50; + + const start = performance.now(); + + const { sql: countSql, replacements: countReplacements } = this.buildQuery(agg, input.requirements, undefined, true); + const { sql: mainSql, replacements: mainReplacements } = this.buildQuery(agg, input.requirements, limit, false); + + const [[countRow], providers] = await Promise.all([ + this.#chainDb.query<{ total: string }>(countSql, { type: QueryTypes.SELECT, replacements: countReplacements }), + this.#chainDb.query(mainSql, { type: QueryTypes.SELECT, replacements: mainReplacements }) + ]); + + const total = Number(countRow?.total ?? 0); + + const result: BidScreeningResult = { providers, total, queryTimeMs: 0 }; + + if (total === 0) { + result.constraints = await this.diagnoseConstraints(agg, input.requirements); + } + + result.queryTimeMs = Math.round((performance.now() - start) * 100) / 100; + + return result; + } + + aggregateResources(resources: BidScreeningRequest["data"]["resources"]): AggregatedResources { + let totalCpu = 0; + let totalMemory = 0; + let totalGpu = 0; + let totalEphemeralStorage = 0; + let totalPersistentStorage = 0; + let maxPerReplicaGpu = 0; + let gpuVendor: string | undefined; + let gpuModel: string | undefined; + let gpuInterface: string | undefined; + let gpuMemorySize: string | undefined; + let persistentStorageClass: string | undefined; + + for (const ru of resources) { + totalCpu += ru.cpu * ru.count; + totalMemory += ru.memory * ru.count; + totalGpu += ru.gpu * ru.count; + totalEphemeralStorage += ru.ephemeralStorage * ru.count; + totalPersistentStorage += (ru.persistentStorage ?? 0) * ru.count; + + if (ru.gpu > 0) { + maxPerReplicaGpu = Math.max(maxPerReplicaGpu, ru.gpu); + // Akash SDL uses a single GPU spec per placement — last resource unit's attributes win + if (ru.gpuAttributes) { + gpuVendor = ru.gpuAttributes.vendor; + gpuModel = ru.gpuAttributes.model; + gpuInterface = ru.gpuAttributes.interface; + gpuMemorySize = ru.gpuAttributes.memorySize; + } + } + + if (ru.persistentStorage && ru.persistentStorageClass) { + persistentStorageClass = ru.persistentStorageClass; + } + } + + return { + totalCpu, + totalMemory, + totalGpu, + totalEphemeralStorage, + totalPersistentStorage, + maxPerReplicaGpu, + hasGpuAttributes: gpuVendor !== undefined, + gpuVendor, + gpuModel, + gpuInterface, + gpuMemorySize, + hasPersistentStorage: totalPersistentStorage > 0, + persistentStorageClass + }; + } + + buildQuery( + agg: AggregatedResources, + requirements: BidScreeningRequest["data"]["requirements"], + limit: number | undefined, + isCount: boolean + ): { sql: string; replacements: Record } { + const replacements: Record = { + totalCpu: agg.totalCpu, + totalMemory: agg.totalMemory, + totalEphemeralStorage: agg.totalEphemeralStorage + }; + + const joins: string[] = [`INNER JOIN "providerSnapshot" ps ON ps.id = p."lastSuccessfulSnapshotId"`]; + + const wheres: string[] = [ + `p."deletedHeight" IS NULL`, + `p."isOnline" = true`, + `ps."availableCPU" >= :totalCpu`, + `ps."availableMemory" >= :totalMemory`, + `ps."availableEphemeralStorage" >= :totalEphemeralStorage` + ]; + + if (agg.totalGpu > 0) { + replacements.totalGpu = agg.totalGpu; + wheres.push(`ps."availableGPU" >= :totalGpu`); + } + + if (agg.hasPersistentStorage) { + replacements.totalPersistentStorage = agg.totalPersistentStorage; + wheres.push(`ps."availablePersistentStorage" >= :totalPersistentStorage`); + } + + if (agg.totalGpu > 0 && agg.hasGpuAttributes) { + joins.push(`INNER JOIN "providerSnapshotNode" psn ON psn."snapshotId" = ps.id`); + joins.push(`INNER JOIN "providerSnapshotNodeGPU" gpu ON gpu."snapshotNodeId" = psn.id`); + + replacements.gpuVendor = agg.gpuVendor; + wheres.push(`gpu.vendor = :gpuVendor`); + + if (agg.gpuModel) { + replacements.gpuModel = agg.gpuModel; + wheres.push(`gpu.name = :gpuModel`); + } + if (agg.gpuInterface) { + replacements.gpuInterface = agg.gpuInterface; + wheres.push(`gpu.interface = :gpuInterface`); + } + if (agg.gpuMemorySize) { + replacements.gpuMemorySize = agg.gpuMemorySize; + wheres.push(`gpu."memorySize" = :gpuMemorySize`); + } + + replacements.perNodeGpu = agg.maxPerReplicaGpu; + wheres.push(`(psn."gpuAllocatable" - psn."gpuAllocated") >= :perNodeGpu`); + } + + if (agg.hasPersistentStorage && agg.persistentStorageClass) { + replacements.storageClass = agg.persistentStorageClass; + joins.push(`INNER JOIN "providerSnapshotStorage" pss ON pss."snapshotId" = ps.id`); + wheres.push(`pss.class = :storageClass`); + wheres.push(`(pss.allocatable - pss.allocated) >= :totalPersistentStorage`); + } + + const havingClauses: string[] = []; + + if (requirements.attributes.length > 0) { + joins.push(`INNER JOIN "providerAttribute" pa ON pa.provider = p.owner`); + requirements.attributes.forEach((attr, i) => { + replacements[`attrKey${i}`] = attr.key; + replacements[`attrVal${i}`] = attr.value; + havingClauses.push(`COUNT(*) FILTER (WHERE pa."key" = :attrKey${i} AND pa."value" = :attrVal${i}) > 0`); + }); + } + + if (requirements.signedBy.anyOf.length > 0 || requirements.signedBy.allOf.length > 0) { + joins.push(`INNER JOIN "providerAttributeSignature" pas ON pas.provider = p.owner`); + + if (requirements.signedBy.anyOf.length > 0) { + replacements.anyOfAuditors = requirements.signedBy.anyOf; + wheres.push(`pas.auditor IN (:anyOfAuditors)`); + } + + requirements.signedBy.allOf.forEach((auditor, i) => { + replacements[`allOfAuditor${i}`] = auditor; + havingClauses.push(`COUNT(*) FILTER (WHERE pas.auditor = :allOfAuditor${i}) > 0`); + }); + } + + const havingClause = havingClauses.length > 0 ? `HAVING ${havingClauses.join(" AND ")}` : ""; + + const groupByColumns = [ + `p.owner`, + `p."hostUri"`, + `ps."leaseCount"`, + `ps."availableCPU"`, + `ps."availableMemory"`, + `ps."availableGPU"`, + `ps."availableEphemeralStorage"`, + `ps."availablePersistentStorage"` + ].join(", "); + + if (isCount) { + const sql = ` + SELECT COUNT(*) AS total FROM ( + SELECT p.owner + FROM provider p + ${joins.join("\n")} + WHERE ${wheres.join("\n AND ")} + GROUP BY ${groupByColumns} + ${havingClause} + ) sub + `; + return { sql, replacements }; + } + + replacements.limit = limit; + const sql = ` + SELECT + p.owner, + p."hostUri", + COALESCE(ps."leaseCount", 0) AS "leaseCount", + ps."availableCPU" AS "availableCpu", + ps."availableMemory" AS "availableMemory", + ps."availableGPU" AS "availableGpu", + ps."availableEphemeralStorage" AS "availableEphemeralStorage", + ps."availablePersistentStorage" AS "availablePersistentStorage" + FROM provider p + ${joins.join("\n")} + WHERE ${wheres.join("\n AND ")} + GROUP BY ${groupByColumns} + ${havingClause} + ORDER BY COALESCE(ps."leaseCount", 0) DESC, + ps."availableCPU" DESC + LIMIT :limit + `; + return { sql, replacements }; + } + + async diagnoseConstraints(agg: AggregatedResources, requirements: BidScreeningRequest["data"]["requirements"]): Promise { + const checks: { name: string; sql: string; replacements: Record; feedback: string }[] = [ + { + name: "Online providers (baseline)", + sql: `SELECT COUNT(*) AS c FROM provider p + INNER JOIN "providerSnapshot" ps ON ps.id = p."lastSuccessfulSnapshotId" + WHERE p."deletedHeight" IS NULL AND p."isOnline" = true`, + replacements: {}, + feedback: "" + }, + { + name: "CPU available", + sql: `SELECT COUNT(*) AS c FROM provider p + INNER JOIN "providerSnapshot" ps ON ps.id = p."lastSuccessfulSnapshotId" + WHERE p."deletedHeight" IS NULL AND p."isOnline" = true + AND ps."availableCPU" >= :totalCpu`, + replacements: { totalCpu: agg.totalCpu }, + feedback: `Reduce CPU — exceeds most providers' available capacity` + }, + { + name: "Memory available", + sql: `SELECT COUNT(*) AS c FROM provider p + INNER JOIN "providerSnapshot" ps ON ps.id = p."lastSuccessfulSnapshotId" + WHERE p."deletedHeight" IS NULL AND p."isOnline" = true + AND ps."availableMemory" >= :totalMemory`, + replacements: { totalMemory: agg.totalMemory }, + feedback: `Reduce memory — exceeds most providers' available memory` + }, + { + name: "Ephemeral storage available", + sql: `SELECT COUNT(*) AS c FROM provider p + INNER JOIN "providerSnapshot" ps ON ps.id = p."lastSuccessfulSnapshotId" + WHERE p."deletedHeight" IS NULL AND p."isOnline" = true + AND ps."availableEphemeralStorage" >= :totalEphemeralStorage`, + replacements: { totalEphemeralStorage: agg.totalEphemeralStorage }, + feedback: `Reduce ephemeral storage — exceeds most providers' available storage` + } + ]; + + if (agg.totalGpu > 0) { + checks.push({ + name: "GPU count available", + sql: `SELECT COUNT(*) AS c FROM provider p + INNER JOIN "providerSnapshot" ps ON ps.id = p."lastSuccessfulSnapshotId" + WHERE p."deletedHeight" IS NULL AND p."isOnline" = true + AND ps."availableGPU" >= :totalGpu`, + replacements: { totalGpu: agg.totalGpu }, + feedback: `Reduce GPU count or replica count` + }); + } + + if (agg.totalGpu > 0 && agg.hasGpuAttributes) { + const gpuReplacements: Record = { gpuVendor: agg.gpuVendor }; + let gpuWhere = `gpu.vendor = :gpuVendor`; + let modelDesc = agg.gpuVendor!; + + if (agg.gpuModel) { + gpuReplacements.gpuModel = agg.gpuModel; + gpuWhere += ` AND gpu.name = :gpuModel`; + modelDesc += `/${agg.gpuModel}`; + } + + checks.push({ + name: `GPU model (${modelDesc})`, + sql: `SELECT COUNT(DISTINCT p.owner) AS c FROM provider p + INNER JOIN "providerSnapshot" ps ON ps.id = p."lastSuccessfulSnapshotId" + INNER JOIN "providerSnapshotNode" psn ON psn."snapshotId" = ps.id + INNER JOIN "providerSnapshotNodeGPU" gpu ON gpu."snapshotNodeId" = psn.id + WHERE p."deletedHeight" IS NULL AND p."isOnline" = true + AND (psn."gpuAllocatable" - psn."gpuAllocated") > 0 + AND ${gpuWhere}`, + replacements: gpuReplacements, + feedback: `No providers have ${modelDesc} GPUs available — try a different model` + }); + + checks.push({ + name: `GPU per-node (${agg.maxPerReplicaGpu}x ${modelDesc})`, + sql: `SELECT COUNT(DISTINCT p.owner) AS c FROM provider p + INNER JOIN "providerSnapshot" ps ON ps.id = p."lastSuccessfulSnapshotId" + INNER JOIN "providerSnapshotNode" psn ON psn."snapshotId" = ps.id + INNER JOIN "providerSnapshotNodeGPU" gpu ON gpu."snapshotNodeId" = psn.id + WHERE p."deletedHeight" IS NULL AND p."isOnline" = true + AND ${gpuWhere} + AND (psn."gpuAllocatable" - psn."gpuAllocated") >= :perNodeGpu`, + replacements: { ...gpuReplacements, perNodeGpu: agg.maxPerReplicaGpu }, + feedback: `No single node has ${agg.maxPerReplicaGpu}x ${modelDesc} GPUs free — reduce GPU count per replica` + }); + } + + if (agg.hasPersistentStorage) { + const storageReplacements: Record = { totalPersistentStorage: agg.totalPersistentStorage }; + let storageWhere = `(pss.allocatable - pss.allocated) >= :totalPersistentStorage`; + + if (agg.persistentStorageClass) { + storageReplacements.storageClass = agg.persistentStorageClass; + storageWhere += ` AND pss.class = :storageClass`; + } + + checks.push({ + name: `Persistent storage`, + sql: `SELECT COUNT(DISTINCT p.owner) AS c FROM provider p + INNER JOIN "providerSnapshot" ps ON ps.id = p."lastSuccessfulSnapshotId" + INNER JOIN "providerSnapshotStorage" pss ON pss."snapshotId" = ps.id + WHERE p."deletedHeight" IS NULL AND p."isOnline" = true + AND ${storageWhere}`, + replacements: storageReplacements, + feedback: `Reduce persistent storage or try a different class (beta3/nvme has the most providers)` + }); + } + + if (requirements.attributes.length > 0) { + for (const attr of requirements.attributes) { + checks.push({ + name: `Attribute ${attr.key}=${attr.value}`, + sql: `SELECT COUNT(DISTINCT p.owner) AS c FROM provider p + INNER JOIN "providerAttribute" pa ON pa.provider = p.owner + WHERE p."deletedHeight" IS NULL AND p."isOnline" = true + AND pa."key" = :key AND pa."value" = :value`, + replacements: { key: attr.key, value: attr.value }, + feedback: `No providers have attribute ${attr.key}=${attr.value}` + }); + } + } + + if (requirements.signedBy.anyOf.length > 0) { + checks.push({ + name: "Auditor signature (anyOf)", + sql: `SELECT COUNT(DISTINCT p.owner) AS c FROM provider p + INNER JOIN "providerAttributeSignature" pas ON pas.provider = p.owner + WHERE p."deletedHeight" IS NULL AND p."isOnline" = true + AND pas.auditor IN (:auditors)`, + replacements: { auditors: requirements.signedBy.anyOf }, + feedback: `Few providers are signed by the required auditor(s)` + }); + } + + const [baselineRow] = await this.#chainDb.query(checks[0].sql, { + replacements: checks[0].replacements, + type: QueryTypes.SELECT + }); + const baselineCount = Number(baselineRow.c); + + if (baselineCount === 0) { + return [{ name: checks[0].name, count: 0, actionableFeedback: "No providers are currently online" }]; + } + + const remaining = await Promise.all( + checks.slice(1).map(async check => { + const [row] = await this.#chainDb.query(check.sql, { + replacements: check.replacements, + type: QueryTypes.SELECT + }); + return { name: check.name, count: Number(row.c), actionableFeedback: check.feedback }; + }) + ); + + return [{ name: checks[0].name, count: baselineCount, actionableFeedback: checks[0].feedback }, ...remaining]; + } +} diff --git a/apps/api/src/rest-app.ts b/apps/api/src/rest-app.ts index 7bf74d66d3..61f827764a 100644 --- a/apps/api/src/rest-app.ts +++ b/apps/api/src/rest-app.ts @@ -64,6 +64,7 @@ import { pricingRouter } from "./pricing"; import { proposalsRouter } from "./proposal"; import { auditorsRouter, + bidScreeningRouter, providerAttributesSchemaRouter, providerDashboardRouter, providerDeploymentsRouter, @@ -132,6 +133,7 @@ const openApiHonoHandlers: OpenApiHonoHandler[] = [ certificateRouter, getBalancesRouter, providersRouter, + bidScreeningRouter, auditorsRouter, providerAttributesSchemaRouter, providerRegionsRouter,