From 72cee416380b092458e3adbe8c528670cdfe4280 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Thu, 9 Apr 2026 23:59:47 -0500 Subject: [PATCH 1/9] docs(provider): add bid precheck stage 1 spec and plan (CON-187) --- .../plans/2026-04-09-bid-precheck-stage1.md | 1188 +++++++++++++++++ .../2026-04-09-bid-precheck-stage1-design.md | 206 +++ 2 files changed, 1394 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-09-bid-precheck-stage1.md create mode 100644 docs/superpowers/specs/2026-04-09-bid-precheck-stage1-design.md diff --git a/docs/superpowers/plans/2026-04-09-bid-precheck-stage1.md b/docs/superpowers/plans/2026-04-09-bid-precheck-stage1.md new file mode 100644 index 0000000000..a1238fa7ec --- /dev/null +++ b/docs/superpowers/plans/2026-04-09-bid-precheck-stage1.md @@ -0,0 +1,1188 @@ +# Bid Precheck Stage 1 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a `POST /v1/bid-precheck` API endpoint that filters providers from the indexer database by resource capacity, GPU model, storage class, attributes, and auditor signatures — returning ranked candidates for Stage 2 provider-side bid screening. + +**Architecture:** New service in the provider feature module (`apps/api/src/provider/`) using raw SQL via the existing `CHAIN_DB` Sequelize connection. Follows the same route → controller → service pattern as other provider endpoints. The service builds dynamic SQL with conditional JOINs/WHERE/HAVING clauses based on the input GroupSpec. + +**Tech Stack:** Hono + @hono/zod-openapi, Zod schemas, tsyringe DI, Sequelize raw queries, Vitest + vitest-mock-extended + +**Spec:** `docs/superpowers/specs/2026-04-09-bid-precheck-stage1-design.md` + +--- + +## File Structure + +| Action | File | Responsibility | +|--------|------|----------------| +| Create | `src/provider/http-schemas/bid-precheck.schema.ts` | Zod request/response schemas | +| Create | `src/provider/services/bid-precheck/bid-precheck.service.ts` | SQL query builder + executor | +| Create | `src/provider/services/bid-precheck/bid-precheck.service.spec.ts` | Unit tests | +| Create | `src/provider/controllers/bid-precheck/bid-precheck.controller.ts` | Thin controller | +| Create | `src/provider/routes/bid-precheck/bid-precheck.router.ts` | POST route definition | +| Modify | `src/provider/routes/index.ts` | Export new router | +| Modify | `src/provider/index.ts` | Re-export (if needed) | +| Modify | `src/rest-app.ts` | Register router in openApiHonoHandlers | + +All paths below are relative to `apps/api/`. + +--- + +### Task 1: Create branch + +- [ ] **Step 1: Create feature branch** + +```bash +git checkout -b feat/provider-bid-precheck-stage1 main +``` + +- [ ] **Step 2: Commit the spec and plan docs** + +```bash +git add docs/superpowers/specs/2026-04-09-bid-precheck-stage1-design.md docs/superpowers/plans/2026-04-09-bid-precheck-stage1.md +git commit -m "docs(provider): add bid precheck stage 1 spec and plan (CON-187)" +``` + +--- + +### Task 2: Zod request/response schemas + +**Files:** +- Create: `src/provider/http-schemas/bid-precheck.schema.ts` + +- [ ] **Step 1: Create the schema file** + +```typescript +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() })).default([]), + signedBy: z + .object({ + allOf: z.array(z.string()).default([]), + anyOf: z.array(z.string()).default([]) + }) + .default({}) +}); + +export const BidPrecheckRequestSchema = z.object({ + data: z.object({ + resources: z.array(ResourceUnitSchema).min(1), + requirements: PlacementRequirementsSchema.default({}), + limit: z.number().int().min(1).max(200).default(50) + }) +}); + +export type BidPrecheckRequest = 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 BidPrecheckResponseSchema = z.object({ + data: z.object({ + providers: z.array(ProviderMatchSchema), + total: z.number(), + queryTimeMs: z.number(), + constraints: z.array(ConstraintSchema).optional() + }) +}); + +export type BidPrecheckResponse = z.infer; +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +Run: `cd apps/api && npx tsc --noEmit` +Expected: No errors related to bid-precheck schema. + +- [ ] **Step 3: Commit** + +```bash +git add src/provider/http-schemas/bid-precheck.schema.ts +git commit -m "feat(provider): add bid precheck Zod request/response schemas" +``` + +--- + +### Task 3: Bid precheck service — query builder + +**Files:** +- Create: `src/provider/services/bid-precheck/bid-precheck.service.ts` + +- [ ] **Step 1: Create the service file** + +```typescript +import { QueryTypes, Sequelize } from "sequelize"; +import { inject, singleton } from "tsyringe"; + +import { CHAIN_DB } from "@src/chain"; +import type { BidPrecheckRequest } from "@src/provider/http-schemas/bid-precheck.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 BidPrecheckResult { + 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 BidPrecheckService { + readonly #chainDb: Sequelize; + + constructor(@inject(CHAIN_DB) chainDb: Sequelize) { + this.#chainDb = chainDb; + } + + async findMatchingProviders(input: BidPrecheckRequest["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 queryTimeMs = Math.round((performance.now() - start) * 100) / 100; + const total = Number(countRow?.total ?? 0); + + const result: BidPrecheckResult = { providers, total, queryTimeMs }; + + if (total === 0) { + result.constraints = await this.diagnoseConstraints(agg, input.requirements); + } + + return result; + } + + aggregateResources(resources: BidPrecheckRequest["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); + 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: BidPrecheckRequest["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`); + } + + // GPU model matching via node-level data + 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`); + } + + // Persistent storage class matching + 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`); + } + + // Provider attribute matching + 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`); + }); + } + + // Auditor signature matching + 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: BidPrecheckRequest["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 results: Constraint[] = []; + for (const check of checks) { + const [row] = await this.#chainDb.query(check.sql, { + replacements: check.replacements, + type: QueryTypes.SELECT + }); + results.push({ + name: check.name, + count: Number(row.c), + actionableFeedback: check.feedback + }); + } + + return results; + } +} +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +Run: `cd apps/api && npx tsc --noEmit` +Expected: No errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/provider/services/bid-precheck/bid-precheck.service.ts +git commit -m "feat(provider): add bid precheck service with SQL query builder" +``` + +--- + +### Task 4: Bid precheck service — unit tests + +**Files:** +- Create: `src/provider/services/bid-precheck/bid-precheck.service.spec.ts` + +This task creates the full test suite. The tests mock the `CHAIN_DB` Sequelize instance and verify SQL generation + parameter binding. Uses the `setup()` pattern per project convention. + +- [ ] **Step 1: Create the test file** + +```typescript +import { QueryTypes } from "sequelize"; +import type { Sequelize } from "sequelize"; +import { mock } from "vitest-mock-extended"; + +import type { BidPrecheckRequest } from "@src/provider/http-schemas/bid-precheck.schema"; +import { BidPrecheckService } from "./bid-precheck.service"; + +describe(BidPrecheckService.name, () => { + describe("aggregateResources", () => { + it("aggregates single resource unit correctly", () => { + const { service } = setup(); + const result = service.aggregateResources([ + { cpu: 1000, memory: 1073741824, gpu: 0, ephemeralStorage: 1073741824, count: 1 } + ]); + + expect(result.totalCpu).toBe(1000); + expect(result.totalMemory).toBe(1073741824); + expect(result.totalGpu).toBe(0); + expect(result.totalEphemeralStorage).toBe(1073741824); + expect(result.totalPersistentStorage).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: 536870912, gpu: 0, ephemeralStorage: 1073741824, count: 10 } + ]); + + expect(result.totalCpu).toBe(5000); + expect(result.totalMemory).toBe(5368709120); + expect(result.totalEphemeralStorage).toBe(10737418240); + }); + + it("sums across multiple resource units", () => { + const { service } = setup(); + const result = service.aggregateResources([ + { cpu: 1000, memory: 1073741824, gpu: 0, ephemeralStorage: 1073741824, count: 2 }, + { cpu: 2000, memory: 2147483648, gpu: 0, ephemeralStorage: 2147483648, count: 3 } + ]); + + expect(result.totalCpu).toBe(1000 * 2 + 2000 * 3); + expect(result.totalMemory).toBe(1073741824 * 2 + 2147483648 * 3); + }); + + it("tracks GPU attributes and per-replica GPU count", () => { + const { service } = setup(); + const result = service.aggregateResources([ + { + cpu: 4000, memory: 17179869184, gpu: 2, ephemeralStorage: 107374182400, count: 3, + gpuAttributes: { vendor: "nvidia", model: "a100" } + } + ]); + + expect(result.totalGpu).toBe(6); + expect(result.maxPerReplicaGpu).toBe(2); + expect(result.hasGpuAttributes).toBe(true); + expect(result.gpuVendor).toBe("nvidia"); + expect(result.gpuModel).toBe("a100"); + }); + + it("tracks persistent storage and class", () => { + const { service } = setup(); + const result = service.aggregateResources([ + { + cpu: 1000, memory: 1073741824, gpu: 0, ephemeralStorage: 1073741824, count: 1, + persistentStorage: 10737418240, persistentStorageClass: "beta3" + } + ]); + + expect(result.hasPersistentStorage).toBe(true); + expect(result.totalPersistentStorage).toBe(10737418240); + expect(result.persistentStorageClass).toBe("beta3"); + }); + + it("uses max per-replica GPU across resource units", () => { + const { service } = setup(); + const result = service.aggregateResources([ + { cpu: 1000, memory: 1073741824, gpu: 1, ephemeralStorage: 1073741824, count: 2, gpuAttributes: { vendor: "nvidia" } }, + { cpu: 1000, memory: 1073741824, gpu: 4, ephemeralStorage: 1073741824, count: 1, gpuAttributes: { vendor: "nvidia" } } + ]); + + expect(result.totalGpu).toBe(1 * 2 + 4 * 1); + expect(result.maxPerReplicaGpu).toBe(4); + }); + }); + + describe("buildQuery", () => { + it("builds minimal query for CPU-only workload", () => { + const { service } = setup(); + const agg = service.aggregateResources([ + { cpu: 1000, memory: 1073741824, gpu: 0, ephemeralStorage: 1073741824, count: 1 } + ]); + const requirements = defaultRequirements(); + const { sql, replacements } = service.buildQuery(agg, requirements, 50, false); + + expect(sql).toContain(`"availableCPU" >= :totalCpu`); + expect(sql).toContain(`"availableMemory" >= :totalMemory`); + expect(sql).toContain(`"availableEphemeralStorage" >= :totalEphemeralStorage`); + expect(sql).not.toContain(`"providerSnapshotNodeGPU"`); + expect(sql).not.toContain(`"providerSnapshotStorage" pss`); + expect(sql).not.toContain(`"providerAttribute"`); + expect(sql).not.toContain(`"providerAttributeSignature"`); + expect(sql).toContain(`LIMIT :limit`); + expect(replacements.totalCpu).toBe(1000); + expect(replacements.limit).toBe(50); + }); + + it("includes GPU JOINs and filters for GPU workload", () => { + const { service } = setup(); + const agg = service.aggregateResources([ + { cpu: 4000, memory: 17179869184, gpu: 1, ephemeralStorage: 107374182400, count: 1, gpuAttributes: { vendor: "nvidia", model: "a100" } } + ]); + const { sql, replacements } = service.buildQuery(agg, defaultRequirements(), 50, false); + + expect(sql).toContain(`"providerSnapshotNode" psn`); + expect(sql).toContain(`"providerSnapshotNodeGPU" gpu`); + expect(sql).toContain(`gpu.vendor = :gpuVendor`); + expect(sql).toContain(`gpu.name = :gpuModel`); + expect(sql).toContain(`(psn."gpuAllocatable" - psn."gpuAllocated") >= :perNodeGpu`); + expect(replacements.gpuVendor).toBe("nvidia"); + expect(replacements.gpuModel).toBe("a100"); + expect(replacements.perNodeGpu).toBe(1); + }); + + it("includes GPU vendor-only filter when model is omitted", () => { + const { service } = setup(); + const agg = service.aggregateResources([ + { cpu: 4000, memory: 17179869184, gpu: 1, ephemeralStorage: 107374182400, count: 1, gpuAttributes: { vendor: "nvidia" } } + ]); + const { sql, replacements } = service.buildQuery(agg, defaultRequirements(), 50, false); + + expect(sql).toContain(`gpu.vendor = :gpuVendor`); + expect(sql).not.toContain(`gpu.name = :gpuModel`); + expect(replacements.gpuVendor).toBe("nvidia"); + expect(replacements).not.toHaveProperty("gpuModel"); + }); + + it("includes all GPU attributes when specified", () => { + const { service } = setup(); + const agg = service.aggregateResources([ + { + cpu: 4000, memory: 17179869184, gpu: 1, ephemeralStorage: 107374182400, count: 1, + gpuAttributes: { vendor: "nvidia", model: "a100", interface: "PCIe", memorySize: "80Gi" } + } + ]); + const { sql, replacements } = service.buildQuery(agg, defaultRequirements(), 50, false); + + expect(sql).toContain(`gpu.interface = :gpuInterface`); + expect(sql).toContain(`gpu."memorySize" = :gpuMemorySize`); + expect(replacements.gpuInterface).toBe("PCIe"); + expect(replacements.gpuMemorySize).toBe("80Gi"); + }); + + it("includes persistent storage class filter", () => { + const { service } = setup(); + const agg = service.aggregateResources([ + { cpu: 1000, memory: 1073741824, gpu: 0, ephemeralStorage: 1073741824, count: 1, persistentStorage: 10737418240, persistentStorageClass: "beta2" } + ]); + const { sql, replacements } = service.buildQuery(agg, defaultRequirements(), 50, false); + + expect(sql).toContain(`"providerSnapshotStorage" pss`); + expect(sql).toContain(`pss.class = :storageClass`); + expect(sql).toContain(`(pss.allocatable - pss.allocated) >= :totalPersistentStorage`); + expect(replacements.storageClass).toBe("beta2"); + }); + + it("includes persistent storage capacity without class filter when class omitted", () => { + const { service } = setup(); + const agg = service.aggregateResources([ + { cpu: 1000, memory: 1073741824, gpu: 0, ephemeralStorage: 1073741824, count: 1, persistentStorage: 10737418240 } + ]); + const { sql } = service.buildQuery(agg, defaultRequirements(), 50, false); + + expect(sql).toContain(`"availablePersistentStorage" >= :totalPersistentStorage`); + expect(sql).not.toContain(`"providerSnapshotStorage" pss`); + }); + + it("includes attribute HAVING clauses", () => { + const { service } = setup(); + const agg = service.aggregateResources([ + { cpu: 1000, memory: 1073741824, gpu: 0, ephemeralStorage: 1073741824, count: 1 } + ]); + const requirements = { + attributes: [ + { key: "region", value: "us-west" }, + { key: "organization", value: "overclock" } + ], + signedBy: { allOf: [], anyOf: [] } + }; + const { sql, replacements } = service.buildQuery(agg, requirements, 50, false); + + expect(sql).toContain(`"providerAttribute" pa`); + expect(sql).toContain(`HAVING`); + expect(sql).toContain(`pa."key" = :attrKey0 AND pa."value" = :attrVal0`); + expect(sql).toContain(`pa."key" = :attrKey1 AND pa."value" = :attrVal1`); + expect(replacements.attrKey0).toBe("region"); + expect(replacements.attrVal0).toBe("us-west"); + expect(replacements.attrKey1).toBe("organization"); + expect(replacements.attrVal1).toBe("overclock"); + }); + + it("includes signedBy anyOf filter", () => { + const { service } = setup(); + const agg = service.aggregateResources([ + { cpu: 1000, memory: 1073741824, gpu: 0, ephemeralStorage: 1073741824, count: 1 } + ]); + const requirements = { + attributes: [], + signedBy: { allOf: [], anyOf: ["akash1auditor1", "akash1auditor2"] } + }; + const { sql, replacements } = service.buildQuery(agg, requirements, 50, false); + + expect(sql).toContain(`"providerAttributeSignature" pas`); + expect(sql).toContain(`pas.auditor IN (:anyOfAuditors)`); + expect(replacements.anyOfAuditors).toEqual(["akash1auditor1", "akash1auditor2"]); + }); + + it("includes signedBy allOf HAVING clauses", () => { + const { service } = setup(); + const agg = service.aggregateResources([ + { cpu: 1000, memory: 1073741824, gpu: 0, ephemeralStorage: 1073741824, count: 1 } + ]); + const requirements = { + attributes: [], + signedBy: { allOf: ["akash1auditorA", "akash1auditorB"], anyOf: [] } + }; + const { sql, replacements } = service.buildQuery(agg, requirements, 50, false); + + expect(sql).toContain(`HAVING`); + expect(sql).toContain(`pas.auditor = :allOfAuditor0`); + expect(sql).toContain(`pas.auditor = :allOfAuditor1`); + expect(replacements.allOfAuditor0).toBe("akash1auditorA"); + expect(replacements.allOfAuditor1).toBe("akash1auditorB"); + }); + + it("builds count query wrapping the main query", () => { + const { service } = setup(); + const agg = service.aggregateResources([ + { cpu: 1000, memory: 1073741824, gpu: 0, ephemeralStorage: 1073741824, 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("combines GPU + persistent storage + attributes + signedBy", () => { + const { service } = setup(); + const agg = service.aggregateResources([ + { + cpu: 4000, memory: 17179869184, gpu: 1, ephemeralStorage: 107374182400, count: 1, + gpuAttributes: { vendor: "nvidia", model: "a100" }, + persistentStorage: 10737418240, persistentStorageClass: "beta3" + } + ]); + const requirements = { + attributes: [{ key: "region", value: "us-west" }], + signedBy: { allOf: [], anyOf: ["akash1auditor1"] } + }; + const { sql } = service.buildQuery(agg, requirements, 50, false); + + expect(sql).toContain(`"providerSnapshotNodeGPU" gpu`); + expect(sql).toContain(`"providerSnapshotStorage" pss`); + expect(sql).toContain(`"providerAttribute" pa`); + expect(sql).toContain(`"providerAttributeSignature" pas`); + expect(sql).toContain(`HAVING`); + }); + }); + + describe("findMatchingProviders", () => { + it("returns providers and total count", async () => { + const { service, chainDb } = setup(); + const mockProviders = [ + { owner: "akash1abc", hostUri: "https://p1.com", leaseCount: 10, availableCpu: 4000, availableMemory: 8589934592, availableGpu: 0, availableEphemeralStorage: 32212254720, availablePersistentStorage: 0 } + ]; + + chainDb.query + .mockResolvedValueOnce([{ total: "5" }]) + .mockResolvedValueOnce(mockProviders); + + const result = await service.findMatchingProviders({ + resources: [{ cpu: 500, memory: 536870912, gpu: 0, ephemeralStorage: 1073741824, count: 1 }], + requirements: defaultRequirements(), + limit: 50 + }); + + expect(result.providers).toEqual(mockProviders); + expect(result.total).toBe(5); + expect(result.queryTimeMs).toBeGreaterThanOrEqual(0); + expect(result.constraints).toBeUndefined(); + }); + + it("runs constraint diagnosis when total is 0", async () => { + const { service, chainDb } = setup(); + + // Count query returns 0 + chainDb.query + .mockResolvedValueOnce([{ total: "0" }]) + .mockResolvedValueOnce([]); + + // Diagnosis queries: baseline, CPU, memory, ephemeral storage + chainDb.query + .mockResolvedValueOnce([{ c: "72" }]) + .mockResolvedValueOnce([{ c: "50" }]) + .mockResolvedValueOnce([{ c: "45" }]) + .mockResolvedValueOnce([{ c: "60" }]); + + const result = await service.findMatchingProviders({ + resources: [{ cpu: 256000, memory: 549755813888, gpu: 0, ephemeralStorage: 1099511627776, count: 1 }], + requirements: defaultRequirements(), + limit: 50 + }); + + expect(result.total).toBe(0); + expect(result.constraints).toBeDefined(); + expect(result.constraints!.length).toBe(4); + expect(result.constraints![0].name).toBe("Online providers (baseline)"); + }); + + it("does not run diagnosis when total > 0", async () => { + const { service, chainDb } = setup(); + + chainDb.query + .mockResolvedValueOnce([{ total: "10" }]) + .mockResolvedValueOnce([{ owner: "akash1abc", hostUri: "https://p1.com", leaseCount: 5, availableCpu: 2000, availableMemory: 4294967296, availableGpu: 0, availableEphemeralStorage: 10737418240, availablePersistentStorage: 0 }]); + + const result = await service.findMatchingProviders({ + resources: [{ cpu: 1000, memory: 1073741824, gpu: 0, ephemeralStorage: 1073741824, count: 1 }], + requirements: defaultRequirements(), + limit: 50 + }); + + expect(result.constraints).toBeUndefined(); + // Only 2 calls: count + main query + expect(chainDb.query).toHaveBeenCalledTimes(2); + }); + + it("uses default limit of 50 when not specified", async () => { + const { service, chainDb } = setup(); + + chainDb.query + .mockResolvedValueOnce([{ total: "1" }]) + .mockResolvedValueOnce([]); + + await service.findMatchingProviders({ + resources: [{ cpu: 1000, memory: 1073741824, gpu: 0, ephemeralStorage: 1073741824, count: 1 }], + requirements: defaultRequirements() + } as BidPrecheckRequest["data"]); + + const mainCall = chainDb.query.mock.calls[1]; + expect(mainCall[1]).toHaveProperty("replacements"); + expect((mainCall[1] as { replacements: Record }).replacements.limit).toBe(50); + }); + }); + + function defaultRequirements(): BidPrecheckRequest["data"]["requirements"] { + return { attributes: [], signedBy: { allOf: [], anyOf: [] } }; + } + + function setup() { + const chainDb = mock(); + const service = new BidPrecheckService(chainDb); + return { service, chainDb }; + } +}); +``` + +- [ ] **Step 2: Run tests to verify they pass** + +Run: `cd apps/api && npx vitest run src/provider/services/bid-precheck/bid-precheck.service.spec.ts` +Expected: All tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add src/provider/services/bid-precheck/bid-precheck.service.spec.ts +git commit -m "test(provider): add bid precheck service unit tests" +``` + +--- + +### Task 5: Controller + +**Files:** +- Create: `src/provider/controllers/bid-precheck/bid-precheck.controller.ts` + +- [ ] **Step 1: Create the controller file** + +```typescript +import { singleton } from "tsyringe"; + +import type { BidPrecheckRequest, BidPrecheckResponse } from "@src/provider/http-schemas/bid-precheck.schema"; +import { BidPrecheckService } from "@src/provider/services/bid-precheck/bid-precheck.service"; + +@singleton() +export class BidPrecheckController { + constructor(private readonly bidPrecheckService: BidPrecheckService) {} + + async precheck(input: BidPrecheckRequest["data"]): Promise { + const result = await this.bidPrecheckService.findMatchingProviders(input); + + return { + data: { + providers: result.providers, + total: result.total, + queryTimeMs: result.queryTimeMs, + constraints: result.constraints + } + }; + } +} +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +Run: `cd apps/api && npx tsc --noEmit` +Expected: No errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/provider/controllers/bid-precheck/bid-precheck.controller.ts +git commit -m "feat(provider): add bid precheck controller" +``` + +--- + +### Task 6: Router and registration + +**Files:** +- Create: `src/provider/routes/bid-precheck/bid-precheck.router.ts` +- Modify: `src/provider/routes/index.ts` +- Modify: `src/rest-app.ts` + +- [ ] **Step 1: Create the router file** + +```typescript +import { container } from "tsyringe"; + +import { BidPrecheckController } from "@src/provider/controllers/bid-precheck/bid-precheck.controller"; +import { BidPrecheckRequestSchema, BidPrecheckResponseSchema } from "@src/provider/http-schemas/bid-precheck.schema"; +import { createRoute } from "@src/core/lib/create-route/create-route"; +import { OpenApiHonoHandler } from "@src/core/services/open-api-hono-handler/open-api-hono-handler"; + +export const bidPrecheckRouter = new OpenApiHonoHandler(); + +const postRoute = createRoute({ + method: "post", + path: "/v1/bid-precheck", + summary: "Pre-filter providers by resource capacity (Stage 1 bid screening)", + tags: ["Providers"], + request: { + body: { + content: { + "application/json": { + schema: BidPrecheckRequestSchema + } + } + } + }, + responses: { + 200: { + description: "Matching providers ranked by lease count and available resources", + content: { + "application/json": { + schema: BidPrecheckResponseSchema + } + } + } + } +}); + +bidPrecheckRouter.openapi(postRoute, async function routeBidPrecheck(c) { + const { data } = c.req.valid("json"); + const result = await container.resolve(BidPrecheckController).precheck(data); + return c.json(result, 200); +}); +``` + +- [ ] **Step 2: Add export to provider routes index** + +Add to `src/provider/routes/index.ts`: + +```typescript +export * from "@src/provider/routes/bid-precheck/bid-precheck.router"; +``` + +- [ ] **Step 3: Register router in rest-app.ts** + +In `src/rest-app.ts`, add the import alongside other provider imports: + +```typescript +import { + auditorsRouter, + bidPrecheckRouter, + providerAttributesSchemaRouter, + // ... rest of existing imports +} from "./provider"; +``` + +Add `bidPrecheckRouter` to the `openApiHonoHandlers` array (after `providersRouter`): + +```typescript +const openApiHonoHandlers: OpenApiHonoHandler[] = [ + // ... existing entries + providersRouter, + bidPrecheckRouter, + auditorsRouter, + // ... rest +``` + +- [ ] **Step 4: Verify TypeScript compiles** + +Run: `cd apps/api && npx tsc --noEmit` +Expected: No errors. + +- [ ] **Step 5: Run all provider tests to ensure nothing is broken** + +Run: `cd apps/api && npx vitest run src/provider/` +Expected: All tests pass. + +- [ ] **Step 6: Run linting** + +Run: `cd apps/api && npm run lint -- --quiet` +Expected: No errors. + +- [ ] **Step 7: Commit** + +```bash +git add src/provider/routes/bid-precheck/bid-precheck.router.ts src/provider/routes/index.ts src/rest-app.ts +git commit -m "feat(provider): add POST /v1/bid-precheck route and register in app" +``` + +--- + +### Task 7: Manual smoke test + +- [ ] **Step 1: Start the API locally (or verify it builds)** + +Run: `cd apps/api && npm run build` +Expected: Build succeeds. + +- [ ] **Step 2: Test with curl (if running locally with DB access)** + +```bash +curl -X POST http://localhost:3080/v1/bid-precheck \ + -H "Content-Type: application/json" \ + -d '{ + "data": { + "resources": [{ + "cpu": 1000, + "memory": 1073741824, + "gpu": 0, + "ephemeralStorage": 1073741824, + "count": 1 + }], + "requirements": { + "attributes": [], + "signedBy": { "allOf": [], "anyOf": [] } + } + } + }' +``` + +Expected: JSON response with `data.providers[]`, `data.total`, `data.queryTimeMs`. + +- [ ] **Step 3: Final commit (if any lint/build fixes needed)** + +```bash +git add -A +git commit -m "fix(provider): address lint/build issues in bid precheck" +``` diff --git a/docs/superpowers/specs/2026-04-09-bid-precheck-stage1-design.md b/docs/superpowers/specs/2026-04-09-bid-precheck-stage1-design.md new file mode 100644 index 0000000000..7e75ffe4f3 --- /dev/null +++ b/docs/superpowers/specs/2026-04-09-bid-precheck-stage1-design.md @@ -0,0 +1,206 @@ +# Bid Precheck Stage 1 — Database Pre-filtering API + +**Issue:** CON-187 (part of CON-186) +**Date:** 2026-04-09 + +## Context + +When a user submits a deployment on Akash, every online provider receives the order and decides whether to bid. With growing provider count (currently ~72 online, potentially 1000+), calling each provider's bid-screening endpoint is not scalable. + +Stage 1 pre-filters providers using our indexer database to narrow the candidate set before Stage 2 calls the provider's `/v1/bid-screening` gRPC/REST endpoint (akash-network/provider#386) for real inventory checks and pricing. + +## Endpoint + +``` +POST /v1/bid-precheck +``` + +No authentication required (`SECURITY_NONE`) — read-only public data, same as `GET /v1/providers`. + +## Request Schema + +Mirrors the provider proto `GroupSpec` → `ResourceUnit` → `Resources` structure so the frontend can reuse the same shape for both Stage 1 and Stage 2. + +```typescript +BidPrecheckRequest { + resources: ResourceUnit[] // GroupSpec.resources (repeated ResourceUnit) + requirements: { // GroupSpec.requirements (PlacementRequirements) + attributes: { key: string, value: string }[] + signedBy: { allOf: string[], anyOf: string[] } + } + limit?: number // default 50, max 200 +} + +ResourceUnit { + cpu: number // millicpu (1000 = 1 vCPU) + memory: number // bytes + gpu: number // count per replica (0 for no GPU) + gpuAttributes?: { // required when gpu > 0 + vendor: string // e.g. "nvidia" + model?: string // e.g. "rtx4090", "a100" + interface?: string // e.g. "PCIe" + memorySize?: string // e.g. "24Gi" + } + ephemeralStorage: number // bytes + persistentStorage?: number // bytes, omit if none + persistentStorageClass?: "beta1" | "beta2" | "beta3" // hdd, ssd, nvme + count: number // replica count — multiplies ALL resources +} +``` + +**Resource aggregation:** For each resource unit, total = per-replica value * count. Across all resource units, totals are summed for the provider-level capacity check. Per-node GPU checks use the per-replica `gpu` value (not the total). + +## Response Schema + +```typescript +BidPrecheckResponse { + providers: ProviderMatch[] + total: number // count before LIMIT (enables "showing 50 of 127") + queryTimeMs: number // for observability + constraints?: Constraint[] // only populated when total === 0 +} + +ProviderMatch { + owner: string // provider address + hostUri: string // provider endpoint URL + leaseCount: number + availableCpu: number // millicpu + availableMemory: number // bytes + availableGpu: number + availableEphemeralStorage: number // bytes + availablePersistentStorage: number // bytes +} + +Constraint { + name: string // e.g. "GPU model (nvidia/h100)" + count: number // providers passing this filter alone + actionableFeedback: string // user-facing suggestion +} +``` + +## Query Logic + +### Main query + +Single SQL query against the chain indexer database (Sequelize raw query via `@inject(CHAIN_DB)`). + +**Base filters (always applied):** +- `provider.deletedHeight IS NULL` — active providers only +- `provider.isOnline = true` — currently reachable +- JOIN `providerSnapshot` via `lastSuccessfulSnapshotId` +- `availableCPU >= totalCpu` +- `availableMemory >= totalMemory` +- `availableEphemeralStorage >= totalEphemeralStorage` + +**Conditional filters:** +- **GPU (totalGpu > 0):** `availableGPU >= totalGpu` + JOIN `providerSnapshotNode` and `providerSnapshotNodeGPU` for vendor/model/interface/memorySize matching + per-node available GPU check (`gpuAllocatable - gpuAllocated >= perReplicaGpu`) +- **Persistent storage:** `availablePersistentStorage >= totalPersistentStorage` + JOIN `providerSnapshotStorage` for class matching and per-class capacity check +- **Attributes:** JOIN `providerAttribute` + HAVING clause with COUNT FILTER for each key=value pair +- **Auditor signatures (anyOf):** JOIN `providerAttributeSignature` + `auditor IN (:anyOfAuditors)` +- **Auditor signatures (allOf):** HAVING clause ensuring every auditor is present + +**Ordering:** `leaseCount DESC, availableCPU DESC` — prioritizes battle-tested providers with the most capacity. + +**Pagination:** `LIMIT :limit` (default 50, max 200). + +### Count query + +Runs the same WHERE/JOIN/HAVING logic but `SELECT COUNT(DISTINCT p.owner)` to get the true total before LIMIT. + +### Constraint diagnosis (only when total === 0) + +Runs independent single-constraint queries to identify which filter is the blocker. Each query checks one constraint against the online provider baseline. Reports count and percentage for each, with actionable feedback for blockers (0 providers) and narrow filters (<5 providers). + +## Architecture + +### File structure (new files in `apps/api/src/provider/`) + +``` +http-schemas/bid-precheck.schema.ts # Zod request/response schemas +routes/bid-precheck/bid-precheck.router.ts # POST route definition +controllers/bid-precheck/bid-precheck.controller.ts +services/bid-precheck/bid-precheck.service.ts +services/bid-precheck/bid-precheck.service.spec.ts # unit tests +``` + +### Dependency flow + +``` +bid-precheck.router.ts + → container.resolve(BidPrecheckController) + → BidPrecheckService(@inject(CHAIN_DB) chainDb: Sequelize) + → buildStage1Query(spec) → SQL string + replacements + → chainDb.query(sql, replacements) + → if total === 0: diagnoseConstraints(spec) +``` + +### Integration with existing code + +- Router registered in `apps/api/src/provider/routes/index.ts` alongside existing provider routes +- Uses same `createRoute` + `OpenApiHonoHandler` pattern +- Injects `CHAIN_DB` (Sequelize) directly — no new DB connection needed +- `@singleton()` service, same as other provider services + +## Mapping to Provider Bid Screening (PR 386) + +| Provider CheckBidEligibility step | Stage 1 DB equivalent | Stage 2 (future) | +|---|---|---| +| `gspec.MatchAttributes(providerAttrs)` | `providerAttribute` JOIN + HAVING | - | +| `bidAttrs.SubsetOf(gspec.Requirements.Attributes)` | - | Provider call | +| `gspec.MatchResourcesRequirements(attr)` — auditor sigs | `providerAttributeSignature` JOIN | - | +| `maxGroupVolumes` check | - | Provider call | +| `gspec.Requirements.SignedBy` | `providerAttributeSignature` HAVING | - | +| `gspec.ValidateBasic()` | Zod schema validation | - | +| `DryRunReserve` (real inventory) | `providerSnapshot.available*` approximation | Provider call | +| `CalculatePrice` | - | Provider call (returns `DecCoin`) | +| Hostname availability | - | Provider call | + +Stage 1 is a superset filter — it may include providers that would fail Stage 2, but should never exclude providers that would pass. + +## Test Strategy + +Unit tests for `BidPrecheckService` — mock `CHAIN_DB` Sequelize instance, verify SQL generation and parameter binding. + +### Test cases (~25 tests) + +**Resource aggregation:** +1. Single resource unit — CPU, memory, storage passed correctly +2. Multi-replica — resources multiplied by count +3. Multiple resource units — totals summed across units +4. GPU per-node check uses per-replica count, not total + +**Filter generation:** +5. CPU-only (no GPU, no persistent storage) — minimal JOINs +6. GPU with vendor only (no model) — vendor filter, no model filter +7. GPU with vendor + model — both filters applied +8. GPU with all attributes (vendor, model, interface, memorySize) +9. Persistent storage with class — class filter + capacity check +10. Persistent storage without class — capacity only, no class filter +11. Attributes — single attribute HAVING clause +12. Multiple attributes — multiple HAVING conditions ANDed +13. SignedBy anyOf — auditor IN clause +14. SignedBy allOf — HAVING with per-auditor COUNT +15. Combined: GPU + persistent storage + attributes + signedBy + +**Limit handling:** +16. Default limit (50) when not specified +17. Custom limit respected +18. Limit clamped to max 200 + +**Constraint diagnosis:** +19. Runs only when main query returns 0 results +20. Does not run when results > 0 +21. Each constraint checked independently against baseline +22. Actionable feedback populated for blockers + +**Edge cases:** +23. Empty attributes array — no attribute JOIN +24. Empty signedBy (allOf=[], anyOf=[]) — no signature JOIN +25. Zero GPU — no GPU JOINs +26. All zeros except CPU/memory/storage — minimal query + +**Input validation (Zod schema):** +27. Missing required fields rejected +28. gpu > 0 without gpuAttributes — rejected +29. Invalid persistentStorageClass — rejected +30. Empty resources array — rejected From 076e04433432cdbe306cdf397528d3029bc9f6b8 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Fri, 10 Apr 2026 00:01:42 -0500 Subject: [PATCH 2/9] feat(provider): add bid precheck Zod request/response schemas --- .../http-schemas/bid-precheck.schema.ts | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 apps/api/src/provider/http-schemas/bid-precheck.schema.ts diff --git a/apps/api/src/provider/http-schemas/bid-precheck.schema.ts b/apps/api/src/provider/http-schemas/bid-precheck.schema.ts new file mode 100644 index 0000000000..bf1601cea2 --- /dev/null +++ b/apps/api/src/provider/http-schemas/bid-precheck.schema.ts @@ -0,0 +1,72 @@ +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() })).default([]), + signedBy: z + .object({ + allOf: z.array(z.string()).default([]), + anyOf: z.array(z.string()).default([]) + }) + .default({}) +}); + +export const BidPrecheckRequestSchema = z.object({ + data: z.object({ + resources: z.array(ResourceUnitSchema).min(1), + requirements: PlacementRequirementsSchema.default({}), + limit: z.number().int().min(1).max(200).default(50) + }) +}); + +export type BidPrecheckRequest = 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 BidPrecheckResponseSchema = z.object({ + data: z.object({ + providers: z.array(ProviderMatchSchema), + total: z.number(), + queryTimeMs: z.number(), + constraints: z.array(ConstraintSchema).optional() + }) +}); + +export type BidPrecheckResponse = z.infer; From a4efb3fb8ed91b28cb5758fd3537aa1c6e4684ff Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Fri, 10 Apr 2026 00:04:35 -0500 Subject: [PATCH 3/9] feat(provider): add bid precheck service with SQL query builder --- .../bid-precheck/bid-precheck.service.ts | 430 ++++++++++++++++++ 1 file changed, 430 insertions(+) create mode 100644 apps/api/src/provider/services/bid-precheck/bid-precheck.service.ts diff --git a/apps/api/src/provider/services/bid-precheck/bid-precheck.service.ts b/apps/api/src/provider/services/bid-precheck/bid-precheck.service.ts new file mode 100644 index 0000000000..90324ddf85 --- /dev/null +++ b/apps/api/src/provider/services/bid-precheck/bid-precheck.service.ts @@ -0,0 +1,430 @@ +import { QueryTypes, Sequelize } from "sequelize"; +import { inject, singleton } from "tsyringe"; + +import { CHAIN_DB } from "@src/chain"; +import type { BidPrecheckRequest } from "@src/provider/http-schemas/bid-precheck.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 BidPrecheckResult { + 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 BidPrecheckService { + readonly #chainDb: Sequelize; + + constructor(@inject(CHAIN_DB) chainDb: Sequelize) { + this.#chainDb = chainDb; + } + + async findMatchingProviders(input: BidPrecheckRequest["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 queryTimeMs = Math.round((performance.now() - start) * 100) / 100; + const total = Number(countRow?.total ?? 0); + + const result: BidPrecheckResult = { providers, total, queryTimeMs }; + + if (total === 0) { + result.constraints = await this.diagnoseConstraints(agg, input.requirements); + } + + return result; + } + + aggregateResources(resources: BidPrecheckRequest["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); + 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: BidPrecheckRequest["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`); + } + + // GPU model matching via node-level data + 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`); + } + + // Persistent storage class matching + 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`); + } + + // Provider attribute matching + 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`); + }); + } + + // Auditor signature matching + 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: BidPrecheckRequest["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 results: Constraint[] = []; + for (const check of checks) { + const [row] = await this.#chainDb.query(check.sql, { + replacements: check.replacements, + type: QueryTypes.SELECT + }); + results.push({ + name: check.name, + count: Number(row.c), + actionableFeedback: check.feedback + }); + } + + return results; + } +} From 4d628c3ca57ecfc9b0ed88b9ec87dd6770aaa551 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Fri, 10 Apr 2026 00:06:04 -0500 Subject: [PATCH 4/9] refactor(provider): rename bid-precheck to bid-screening to match provider endpoint --- ...recheck.schema.ts => bid-screening.schema.ts} | 8 ++++---- .../bid-screening.service.ts} | 16 ++++++++-------- .../2026-04-09-bid-precheck-stage1-design.md | 2 +- 3 files changed, 13 insertions(+), 13 deletions(-) rename apps/api/src/provider/http-schemas/{bid-precheck.schema.ts => bid-screening.schema.ts} (87%) rename apps/api/src/provider/services/{bid-precheck/bid-precheck.service.ts => bid-screening/bid-screening.service.ts} (96%) diff --git a/apps/api/src/provider/http-schemas/bid-precheck.schema.ts b/apps/api/src/provider/http-schemas/bid-screening.schema.ts similarity index 87% rename from apps/api/src/provider/http-schemas/bid-precheck.schema.ts rename to apps/api/src/provider/http-schemas/bid-screening.schema.ts index bf1601cea2..58dea6577a 100644 --- a/apps/api/src/provider/http-schemas/bid-precheck.schema.ts +++ b/apps/api/src/provider/http-schemas/bid-screening.schema.ts @@ -33,7 +33,7 @@ const PlacementRequirementsSchema = z.object({ .default({}) }); -export const BidPrecheckRequestSchema = z.object({ +export const BidScreeningRequestSchema = z.object({ data: z.object({ resources: z.array(ResourceUnitSchema).min(1), requirements: PlacementRequirementsSchema.default({}), @@ -41,7 +41,7 @@ export const BidPrecheckRequestSchema = z.object({ }) }); -export type BidPrecheckRequest = z.infer; +export type BidScreeningRequest = z.infer; const ProviderMatchSchema = z.object({ owner: z.string(), @@ -60,7 +60,7 @@ const ConstraintSchema = z.object({ actionableFeedback: z.string() }); -export const BidPrecheckResponseSchema = z.object({ +export const BidScreeningResponseSchema = z.object({ data: z.object({ providers: z.array(ProviderMatchSchema), total: z.number(), @@ -69,4 +69,4 @@ export const BidPrecheckResponseSchema = z.object({ }) }); -export type BidPrecheckResponse = z.infer; +export type BidScreeningResponse = z.infer; diff --git a/apps/api/src/provider/services/bid-precheck/bid-precheck.service.ts b/apps/api/src/provider/services/bid-screening/bid-screening.service.ts similarity index 96% rename from apps/api/src/provider/services/bid-precheck/bid-precheck.service.ts rename to apps/api/src/provider/services/bid-screening/bid-screening.service.ts index 90324ddf85..a092ed1d08 100644 --- a/apps/api/src/provider/services/bid-precheck/bid-precheck.service.ts +++ b/apps/api/src/provider/services/bid-screening/bid-screening.service.ts @@ -2,7 +2,7 @@ import { QueryTypes, Sequelize } from "sequelize"; import { inject, singleton } from "tsyringe"; import { CHAIN_DB } from "@src/chain"; -import type { BidPrecheckRequest } from "@src/provider/http-schemas/bid-precheck.schema"; +import type { BidScreeningRequest } from "@src/provider/http-schemas/bid-screening.schema"; interface ProviderMatchRow { owner: string; @@ -25,7 +25,7 @@ export interface Constraint { actionableFeedback: string; } -export interface BidPrecheckResult { +export interface BidScreeningResult { providers: ProviderMatchRow[]; total: number; queryTimeMs: number; @@ -49,14 +49,14 @@ interface AggregatedResources { } @singleton() -export class BidPrecheckService { +export class BidScreeningService { readonly #chainDb: Sequelize; constructor(@inject(CHAIN_DB) chainDb: Sequelize) { this.#chainDb = chainDb; } - async findMatchingProviders(input: BidPrecheckRequest["data"]): Promise { + async findMatchingProviders(input: BidScreeningRequest["data"]): Promise { const agg = this.aggregateResources(input.resources); const limit = input.limit ?? 50; @@ -73,7 +73,7 @@ export class BidPrecheckService { const queryTimeMs = Math.round((performance.now() - start) * 100) / 100; const total = Number(countRow?.total ?? 0); - const result: BidPrecheckResult = { providers, total, queryTimeMs }; + const result: BidScreeningResult = { providers, total, queryTimeMs }; if (total === 0) { result.constraints = await this.diagnoseConstraints(agg, input.requirements); @@ -82,7 +82,7 @@ export class BidPrecheckService { return result; } - aggregateResources(resources: BidPrecheckRequest["data"]["resources"]): AggregatedResources { + aggregateResources(resources: BidScreeningRequest["data"]["resources"]): AggregatedResources { let totalCpu = 0; let totalMemory = 0; let totalGpu = 0; @@ -136,7 +136,7 @@ export class BidPrecheckService { buildQuery( agg: AggregatedResources, - requirements: BidPrecheckRequest["data"]["requirements"], + requirements: BidScreeningRequest["data"]["requirements"], limit: number | undefined, isCount: boolean ): { sql: string; replacements: Record } { @@ -276,7 +276,7 @@ export class BidPrecheckService { return { sql, replacements }; } - async diagnoseConstraints(agg: AggregatedResources, requirements: BidPrecheckRequest["data"]["requirements"]): Promise { + async diagnoseConstraints(agg: AggregatedResources, requirements: BidScreeningRequest["data"]["requirements"]): Promise { const checks: { name: string; sql: string; replacements: Record; feedback: string }[] = [ { name: "Online providers (baseline)", diff --git a/docs/superpowers/specs/2026-04-09-bid-precheck-stage1-design.md b/docs/superpowers/specs/2026-04-09-bid-precheck-stage1-design.md index 7e75ffe4f3..52f7879bd8 100644 --- a/docs/superpowers/specs/2026-04-09-bid-precheck-stage1-design.md +++ b/docs/superpowers/specs/2026-04-09-bid-precheck-stage1-design.md @@ -12,7 +12,7 @@ Stage 1 pre-filters providers using our indexer database to narrow the candidate ## Endpoint ``` -POST /v1/bid-precheck +POST /v1/bid-screening ``` No authentication required (`SECURITY_NONE`) — read-only public data, same as `GET /v1/providers`. From f8f4b4560afc13ca84e7c20faa3195aeb2ef8e67 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Fri, 10 Apr 2026 00:08:22 -0500 Subject: [PATCH 5/9] test(provider): add bid screening service unit tests --- .../bid-screening.service.spec.ts | 411 ++++++++++++++++++ 1 file changed, 411 insertions(+) create mode 100644 apps/api/src/provider/services/bid-screening/bid-screening.service.spec.ts 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..798e98e3b1 --- /dev/null +++ b/apps/api/src/provider/services/bid-screening/bid-screening.service.spec.ts @@ -0,0 +1,411 @@ +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(); + // count query returns 0 + chainDb.query + .mockResolvedValueOnce([{ total: "0" }] as never) + .mockResolvedValueOnce([] as never) + // diagnoseConstraints: 4 baseline checks (online, cpu, memory, ephemeral) + .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(), + limit: 50 + }); + + // The main query (2nd call) replacements should include limit: 50 + 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: [] } }; + } +}); From 1e8e748d98be02655f0f021ce72e62b22ac40d6c Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Fri, 10 Apr 2026 00:11:15 -0500 Subject: [PATCH 6/9] feat(provider): add POST /v1/bid-screening route, controller, and app registration --- .../bid-screening/bid-screening.controller.ts | 22 ++++++++++ .../bid-screening/bid-screening.router.ts | 42 +++++++++++++++++++ apps/api/src/provider/routes/index.ts | 1 + apps/api/src/rest-app.ts | 2 + 4 files changed, 67 insertions(+) create mode 100644 apps/api/src/provider/controllers/bid-screening/bid-screening.controller.ts create mode 100644 apps/api/src/provider/routes/bid-screening/bid-screening.router.ts 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..7e41e967c5 --- /dev/null +++ b/apps/api/src/provider/controllers/bid-screening/bid-screening.controller.ts @@ -0,0 +1,22 @@ +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 { + const result = await this.bidScreeningService.findMatchingProviders(input); + + return { + data: { + providers: result.providers, + total: result.total, + queryTimeMs: result.queryTimeMs, + constraints: result.constraints + } + }; + } +} 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/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, From 1e359c1821611636e22a9cb0335b3d30a5cbe8a4 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:34:08 -0500 Subject: [PATCH 7/9] fix(provider): add max bounds to bid screening array inputs --- .../src/provider/http-schemas/bid-screening.schema.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/api/src/provider/http-schemas/bid-screening.schema.ts b/apps/api/src/provider/http-schemas/bid-screening.schema.ts index 58dea6577a..d83b507236 100644 --- a/apps/api/src/provider/http-schemas/bid-screening.schema.ts +++ b/apps/api/src/provider/http-schemas/bid-screening.schema.ts @@ -24,18 +24,21 @@ const ResourceUnitSchema = z }); const PlacementRequirementsSchema = z.object({ - attributes: z.array(z.object({ key: z.string(), value: z.string() })).default([]), + attributes: z + .array(z.object({ key: z.string(), value: z.string() })) + .max(20) + .default([]), signedBy: z .object({ - allOf: z.array(z.string()).default([]), - anyOf: z.array(z.string()).default([]) + 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), + resources: z.array(ResourceUnitSchema).min(1).max(20), requirements: PlacementRequirementsSchema.default({}), limit: z.number().int().min(1).max(200).default(50) }) From ff6c9ce99912114b55d89bf5d9fa95d6198dd5e1 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:45:23 -0500 Subject: [PATCH 8/9] refactor(provider): simplify controller, parallelize diagnosis, include diagnosis in queryTimeMs --- .../bid-screening/bid-screening.controller.ts | 11 +---- .../bid-screening.service.spec.ts | 7 +--- .../bid-screening/bid-screening.service.ts | 41 +++++++++++-------- 3 files changed, 26 insertions(+), 33 deletions(-) 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 index 7e41e967c5..6b63c325d2 100644 --- a/apps/api/src/provider/controllers/bid-screening/bid-screening.controller.ts +++ b/apps/api/src/provider/controllers/bid-screening/bid-screening.controller.ts @@ -8,15 +8,6 @@ export class BidScreeningController { constructor(private readonly bidScreeningService: BidScreeningService) {} async screen(input: BidScreeningRequest["data"]): Promise { - const result = await this.bidScreeningService.findMatchingProviders(input); - - return { - data: { - providers: result.providers, - total: result.total, - queryTimeMs: result.queryTimeMs, - constraints: result.constraints - } - }; + return { data: await this.bidScreeningService.findMatchingProviders(input) }; } } 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 index 798e98e3b1..50652c0ffb 100644 --- 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 @@ -345,11 +345,10 @@ describe(BidScreeningService.name, () => { it("runs constraint diagnosis when total is 0", async () => { const { service, chainDb } = setup(); - // count query returns 0 chainDb.query .mockResolvedValueOnce([{ total: "0" }] as never) .mockResolvedValueOnce([] as never) - // diagnoseConstraints: 4 baseline checks (online, cpu, memory, ephemeral) + // diagnoseConstraints: baseline first, then cpu/memory/ephemeral in parallel .mockResolvedValueOnce([{ c: "72" }] as never) .mockResolvedValueOnce([{ c: "60" }] as never) .mockResolvedValueOnce([{ c: "50" }] as never) @@ -388,11 +387,9 @@ describe(BidScreeningService.name, () => { await service.findMatchingProviders({ resources: [{ cpu: 1000, memory: 512, gpu: 0, ephemeralStorage: 1024, count: 1 }], - requirements: defaultRequirements(), - limit: 50 + requirements: defaultRequirements() }); - // The main query (2nd call) replacements should include limit: 50 const mainQueryCall = chainDb.query.mock.calls[1]; const mainQueryOptions = mainQueryCall[1] as { replacements: Record }; expect(mainQueryOptions.replacements).toHaveProperty("limit", 50); 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 index a092ed1d08..976ffb1afc 100644 --- a/apps/api/src/provider/services/bid-screening/bid-screening.service.ts +++ b/apps/api/src/provider/services/bid-screening/bid-screening.service.ts @@ -70,15 +70,16 @@ export class BidScreeningService { this.#chainDb.query(mainSql, { type: QueryTypes.SELECT, replacements: mainReplacements }) ]); - const queryTimeMs = Math.round((performance.now() - start) * 100) / 100; const total = Number(countRow?.total ?? 0); - const result: BidScreeningResult = { providers, total, queryTimeMs }; + 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; } @@ -104,6 +105,7 @@ export class BidScreeningService { 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; @@ -166,7 +168,6 @@ export class BidScreeningService { wheres.push(`ps."availablePersistentStorage" >= :totalPersistentStorage`); } - // GPU model matching via node-level data 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`); @@ -191,7 +192,6 @@ export class BidScreeningService { wheres.push(`(psn."gpuAllocatable" - psn."gpuAllocated") >= :perNodeGpu`); } - // Persistent storage class matching if (agg.hasPersistentStorage && agg.persistentStorageClass) { replacements.storageClass = agg.persistentStorageClass; joins.push(`INNER JOIN "providerSnapshotStorage" pss ON pss."snapshotId" = ps.id`); @@ -199,7 +199,6 @@ export class BidScreeningService { wheres.push(`(pss.allocatable - pss.allocated) >= :totalPersistentStorage`); } - // Provider attribute matching const havingClauses: string[] = []; if (requirements.attributes.length > 0) { @@ -211,7 +210,6 @@ export class BidScreeningService { }); } - // Auditor signature matching if (requirements.signedBy.anyOf.length > 0 || requirements.signedBy.allOf.length > 0) { joins.push(`INNER JOIN "providerAttributeSignature" pas ON pas.provider = p.owner`); @@ -412,19 +410,26 @@ export class BidScreeningService { }); } - const results: Constraint[] = []; - for (const check of checks) { - const [row] = await this.#chainDb.query(check.sql, { - replacements: check.replacements, - type: QueryTypes.SELECT - }); - results.push({ - name: check.name, - count: Number(row.c), - actionableFeedback: check.feedback - }); + 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" }]; } - return results; + 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]; } } From 0b7bf4ac833e9b6eb87921947dab8173641915a7 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:47:21 -0500 Subject: [PATCH 9/9] chore(provider): remove spec and plan docs from branch --- .../plans/2026-04-09-bid-precheck-stage1.md | 1188 ----------------- .../2026-04-09-bid-precheck-stage1-design.md | 206 --- 2 files changed, 1394 deletions(-) delete mode 100644 docs/superpowers/plans/2026-04-09-bid-precheck-stage1.md delete mode 100644 docs/superpowers/specs/2026-04-09-bid-precheck-stage1-design.md diff --git a/docs/superpowers/plans/2026-04-09-bid-precheck-stage1.md b/docs/superpowers/plans/2026-04-09-bid-precheck-stage1.md deleted file mode 100644 index a1238fa7ec..0000000000 --- a/docs/superpowers/plans/2026-04-09-bid-precheck-stage1.md +++ /dev/null @@ -1,1188 +0,0 @@ -# Bid Precheck Stage 1 Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Build a `POST /v1/bid-precheck` API endpoint that filters providers from the indexer database by resource capacity, GPU model, storage class, attributes, and auditor signatures — returning ranked candidates for Stage 2 provider-side bid screening. - -**Architecture:** New service in the provider feature module (`apps/api/src/provider/`) using raw SQL via the existing `CHAIN_DB` Sequelize connection. Follows the same route → controller → service pattern as other provider endpoints. The service builds dynamic SQL with conditional JOINs/WHERE/HAVING clauses based on the input GroupSpec. - -**Tech Stack:** Hono + @hono/zod-openapi, Zod schemas, tsyringe DI, Sequelize raw queries, Vitest + vitest-mock-extended - -**Spec:** `docs/superpowers/specs/2026-04-09-bid-precheck-stage1-design.md` - ---- - -## File Structure - -| Action | File | Responsibility | -|--------|------|----------------| -| Create | `src/provider/http-schemas/bid-precheck.schema.ts` | Zod request/response schemas | -| Create | `src/provider/services/bid-precheck/bid-precheck.service.ts` | SQL query builder + executor | -| Create | `src/provider/services/bid-precheck/bid-precheck.service.spec.ts` | Unit tests | -| Create | `src/provider/controllers/bid-precheck/bid-precheck.controller.ts` | Thin controller | -| Create | `src/provider/routes/bid-precheck/bid-precheck.router.ts` | POST route definition | -| Modify | `src/provider/routes/index.ts` | Export new router | -| Modify | `src/provider/index.ts` | Re-export (if needed) | -| Modify | `src/rest-app.ts` | Register router in openApiHonoHandlers | - -All paths below are relative to `apps/api/`. - ---- - -### Task 1: Create branch - -- [ ] **Step 1: Create feature branch** - -```bash -git checkout -b feat/provider-bid-precheck-stage1 main -``` - -- [ ] **Step 2: Commit the spec and plan docs** - -```bash -git add docs/superpowers/specs/2026-04-09-bid-precheck-stage1-design.md docs/superpowers/plans/2026-04-09-bid-precheck-stage1.md -git commit -m "docs(provider): add bid precheck stage 1 spec and plan (CON-187)" -``` - ---- - -### Task 2: Zod request/response schemas - -**Files:** -- Create: `src/provider/http-schemas/bid-precheck.schema.ts` - -- [ ] **Step 1: Create the schema file** - -```typescript -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() })).default([]), - signedBy: z - .object({ - allOf: z.array(z.string()).default([]), - anyOf: z.array(z.string()).default([]) - }) - .default({}) -}); - -export const BidPrecheckRequestSchema = z.object({ - data: z.object({ - resources: z.array(ResourceUnitSchema).min(1), - requirements: PlacementRequirementsSchema.default({}), - limit: z.number().int().min(1).max(200).default(50) - }) -}); - -export type BidPrecheckRequest = 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 BidPrecheckResponseSchema = z.object({ - data: z.object({ - providers: z.array(ProviderMatchSchema), - total: z.number(), - queryTimeMs: z.number(), - constraints: z.array(ConstraintSchema).optional() - }) -}); - -export type BidPrecheckResponse = z.infer; -``` - -- [ ] **Step 2: Verify TypeScript compiles** - -Run: `cd apps/api && npx tsc --noEmit` -Expected: No errors related to bid-precheck schema. - -- [ ] **Step 3: Commit** - -```bash -git add src/provider/http-schemas/bid-precheck.schema.ts -git commit -m "feat(provider): add bid precheck Zod request/response schemas" -``` - ---- - -### Task 3: Bid precheck service — query builder - -**Files:** -- Create: `src/provider/services/bid-precheck/bid-precheck.service.ts` - -- [ ] **Step 1: Create the service file** - -```typescript -import { QueryTypes, Sequelize } from "sequelize"; -import { inject, singleton } from "tsyringe"; - -import { CHAIN_DB } from "@src/chain"; -import type { BidPrecheckRequest } from "@src/provider/http-schemas/bid-precheck.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 BidPrecheckResult { - 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 BidPrecheckService { - readonly #chainDb: Sequelize; - - constructor(@inject(CHAIN_DB) chainDb: Sequelize) { - this.#chainDb = chainDb; - } - - async findMatchingProviders(input: BidPrecheckRequest["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 queryTimeMs = Math.round((performance.now() - start) * 100) / 100; - const total = Number(countRow?.total ?? 0); - - const result: BidPrecheckResult = { providers, total, queryTimeMs }; - - if (total === 0) { - result.constraints = await this.diagnoseConstraints(agg, input.requirements); - } - - return result; - } - - aggregateResources(resources: BidPrecheckRequest["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); - 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: BidPrecheckRequest["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`); - } - - // GPU model matching via node-level data - 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`); - } - - // Persistent storage class matching - 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`); - } - - // Provider attribute matching - 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`); - }); - } - - // Auditor signature matching - 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: BidPrecheckRequest["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 results: Constraint[] = []; - for (const check of checks) { - const [row] = await this.#chainDb.query(check.sql, { - replacements: check.replacements, - type: QueryTypes.SELECT - }); - results.push({ - name: check.name, - count: Number(row.c), - actionableFeedback: check.feedback - }); - } - - return results; - } -} -``` - -- [ ] **Step 2: Verify TypeScript compiles** - -Run: `cd apps/api && npx tsc --noEmit` -Expected: No errors. - -- [ ] **Step 3: Commit** - -```bash -git add src/provider/services/bid-precheck/bid-precheck.service.ts -git commit -m "feat(provider): add bid precheck service with SQL query builder" -``` - ---- - -### Task 4: Bid precheck service — unit tests - -**Files:** -- Create: `src/provider/services/bid-precheck/bid-precheck.service.spec.ts` - -This task creates the full test suite. The tests mock the `CHAIN_DB` Sequelize instance and verify SQL generation + parameter binding. Uses the `setup()` pattern per project convention. - -- [ ] **Step 1: Create the test file** - -```typescript -import { QueryTypes } from "sequelize"; -import type { Sequelize } from "sequelize"; -import { mock } from "vitest-mock-extended"; - -import type { BidPrecheckRequest } from "@src/provider/http-schemas/bid-precheck.schema"; -import { BidPrecheckService } from "./bid-precheck.service"; - -describe(BidPrecheckService.name, () => { - describe("aggregateResources", () => { - it("aggregates single resource unit correctly", () => { - const { service } = setup(); - const result = service.aggregateResources([ - { cpu: 1000, memory: 1073741824, gpu: 0, ephemeralStorage: 1073741824, count: 1 } - ]); - - expect(result.totalCpu).toBe(1000); - expect(result.totalMemory).toBe(1073741824); - expect(result.totalGpu).toBe(0); - expect(result.totalEphemeralStorage).toBe(1073741824); - expect(result.totalPersistentStorage).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: 536870912, gpu: 0, ephemeralStorage: 1073741824, count: 10 } - ]); - - expect(result.totalCpu).toBe(5000); - expect(result.totalMemory).toBe(5368709120); - expect(result.totalEphemeralStorage).toBe(10737418240); - }); - - it("sums across multiple resource units", () => { - const { service } = setup(); - const result = service.aggregateResources([ - { cpu: 1000, memory: 1073741824, gpu: 0, ephemeralStorage: 1073741824, count: 2 }, - { cpu: 2000, memory: 2147483648, gpu: 0, ephemeralStorage: 2147483648, count: 3 } - ]); - - expect(result.totalCpu).toBe(1000 * 2 + 2000 * 3); - expect(result.totalMemory).toBe(1073741824 * 2 + 2147483648 * 3); - }); - - it("tracks GPU attributes and per-replica GPU count", () => { - const { service } = setup(); - const result = service.aggregateResources([ - { - cpu: 4000, memory: 17179869184, gpu: 2, ephemeralStorage: 107374182400, count: 3, - gpuAttributes: { vendor: "nvidia", model: "a100" } - } - ]); - - expect(result.totalGpu).toBe(6); - expect(result.maxPerReplicaGpu).toBe(2); - expect(result.hasGpuAttributes).toBe(true); - expect(result.gpuVendor).toBe("nvidia"); - expect(result.gpuModel).toBe("a100"); - }); - - it("tracks persistent storage and class", () => { - const { service } = setup(); - const result = service.aggregateResources([ - { - cpu: 1000, memory: 1073741824, gpu: 0, ephemeralStorage: 1073741824, count: 1, - persistentStorage: 10737418240, persistentStorageClass: "beta3" - } - ]); - - expect(result.hasPersistentStorage).toBe(true); - expect(result.totalPersistentStorage).toBe(10737418240); - expect(result.persistentStorageClass).toBe("beta3"); - }); - - it("uses max per-replica GPU across resource units", () => { - const { service } = setup(); - const result = service.aggregateResources([ - { cpu: 1000, memory: 1073741824, gpu: 1, ephemeralStorage: 1073741824, count: 2, gpuAttributes: { vendor: "nvidia" } }, - { cpu: 1000, memory: 1073741824, gpu: 4, ephemeralStorage: 1073741824, count: 1, gpuAttributes: { vendor: "nvidia" } } - ]); - - expect(result.totalGpu).toBe(1 * 2 + 4 * 1); - expect(result.maxPerReplicaGpu).toBe(4); - }); - }); - - describe("buildQuery", () => { - it("builds minimal query for CPU-only workload", () => { - const { service } = setup(); - const agg = service.aggregateResources([ - { cpu: 1000, memory: 1073741824, gpu: 0, ephemeralStorage: 1073741824, count: 1 } - ]); - const requirements = defaultRequirements(); - const { sql, replacements } = service.buildQuery(agg, requirements, 50, false); - - expect(sql).toContain(`"availableCPU" >= :totalCpu`); - expect(sql).toContain(`"availableMemory" >= :totalMemory`); - expect(sql).toContain(`"availableEphemeralStorage" >= :totalEphemeralStorage`); - expect(sql).not.toContain(`"providerSnapshotNodeGPU"`); - expect(sql).not.toContain(`"providerSnapshotStorage" pss`); - expect(sql).not.toContain(`"providerAttribute"`); - expect(sql).not.toContain(`"providerAttributeSignature"`); - expect(sql).toContain(`LIMIT :limit`); - expect(replacements.totalCpu).toBe(1000); - expect(replacements.limit).toBe(50); - }); - - it("includes GPU JOINs and filters for GPU workload", () => { - const { service } = setup(); - const agg = service.aggregateResources([ - { cpu: 4000, memory: 17179869184, gpu: 1, ephemeralStorage: 107374182400, count: 1, gpuAttributes: { vendor: "nvidia", model: "a100" } } - ]); - const { sql, replacements } = service.buildQuery(agg, defaultRequirements(), 50, false); - - expect(sql).toContain(`"providerSnapshotNode" psn`); - expect(sql).toContain(`"providerSnapshotNodeGPU" gpu`); - expect(sql).toContain(`gpu.vendor = :gpuVendor`); - expect(sql).toContain(`gpu.name = :gpuModel`); - expect(sql).toContain(`(psn."gpuAllocatable" - psn."gpuAllocated") >= :perNodeGpu`); - expect(replacements.gpuVendor).toBe("nvidia"); - expect(replacements.gpuModel).toBe("a100"); - expect(replacements.perNodeGpu).toBe(1); - }); - - it("includes GPU vendor-only filter when model is omitted", () => { - const { service } = setup(); - const agg = service.aggregateResources([ - { cpu: 4000, memory: 17179869184, gpu: 1, ephemeralStorage: 107374182400, count: 1, gpuAttributes: { vendor: "nvidia" } } - ]); - const { sql, replacements } = service.buildQuery(agg, defaultRequirements(), 50, false); - - expect(sql).toContain(`gpu.vendor = :gpuVendor`); - expect(sql).not.toContain(`gpu.name = :gpuModel`); - expect(replacements.gpuVendor).toBe("nvidia"); - expect(replacements).not.toHaveProperty("gpuModel"); - }); - - it("includes all GPU attributes when specified", () => { - const { service } = setup(); - const agg = service.aggregateResources([ - { - cpu: 4000, memory: 17179869184, gpu: 1, ephemeralStorage: 107374182400, count: 1, - gpuAttributes: { vendor: "nvidia", model: "a100", interface: "PCIe", memorySize: "80Gi" } - } - ]); - const { sql, replacements } = service.buildQuery(agg, defaultRequirements(), 50, false); - - expect(sql).toContain(`gpu.interface = :gpuInterface`); - expect(sql).toContain(`gpu."memorySize" = :gpuMemorySize`); - expect(replacements.gpuInterface).toBe("PCIe"); - expect(replacements.gpuMemorySize).toBe("80Gi"); - }); - - it("includes persistent storage class filter", () => { - const { service } = setup(); - const agg = service.aggregateResources([ - { cpu: 1000, memory: 1073741824, gpu: 0, ephemeralStorage: 1073741824, count: 1, persistentStorage: 10737418240, persistentStorageClass: "beta2" } - ]); - const { sql, replacements } = service.buildQuery(agg, defaultRequirements(), 50, false); - - expect(sql).toContain(`"providerSnapshotStorage" pss`); - expect(sql).toContain(`pss.class = :storageClass`); - expect(sql).toContain(`(pss.allocatable - pss.allocated) >= :totalPersistentStorage`); - expect(replacements.storageClass).toBe("beta2"); - }); - - it("includes persistent storage capacity without class filter when class omitted", () => { - const { service } = setup(); - const agg = service.aggregateResources([ - { cpu: 1000, memory: 1073741824, gpu: 0, ephemeralStorage: 1073741824, count: 1, persistentStorage: 10737418240 } - ]); - const { sql } = service.buildQuery(agg, defaultRequirements(), 50, false); - - expect(sql).toContain(`"availablePersistentStorage" >= :totalPersistentStorage`); - expect(sql).not.toContain(`"providerSnapshotStorage" pss`); - }); - - it("includes attribute HAVING clauses", () => { - const { service } = setup(); - const agg = service.aggregateResources([ - { cpu: 1000, memory: 1073741824, gpu: 0, ephemeralStorage: 1073741824, count: 1 } - ]); - const requirements = { - attributes: [ - { key: "region", value: "us-west" }, - { key: "organization", value: "overclock" } - ], - signedBy: { allOf: [], anyOf: [] } - }; - const { sql, replacements } = service.buildQuery(agg, requirements, 50, false); - - expect(sql).toContain(`"providerAttribute" pa`); - expect(sql).toContain(`HAVING`); - expect(sql).toContain(`pa."key" = :attrKey0 AND pa."value" = :attrVal0`); - expect(sql).toContain(`pa."key" = :attrKey1 AND pa."value" = :attrVal1`); - expect(replacements.attrKey0).toBe("region"); - expect(replacements.attrVal0).toBe("us-west"); - expect(replacements.attrKey1).toBe("organization"); - expect(replacements.attrVal1).toBe("overclock"); - }); - - it("includes signedBy anyOf filter", () => { - const { service } = setup(); - const agg = service.aggregateResources([ - { cpu: 1000, memory: 1073741824, gpu: 0, ephemeralStorage: 1073741824, count: 1 } - ]); - const requirements = { - attributes: [], - signedBy: { allOf: [], anyOf: ["akash1auditor1", "akash1auditor2"] } - }; - const { sql, replacements } = service.buildQuery(agg, requirements, 50, false); - - expect(sql).toContain(`"providerAttributeSignature" pas`); - expect(sql).toContain(`pas.auditor IN (:anyOfAuditors)`); - expect(replacements.anyOfAuditors).toEqual(["akash1auditor1", "akash1auditor2"]); - }); - - it("includes signedBy allOf HAVING clauses", () => { - const { service } = setup(); - const agg = service.aggregateResources([ - { cpu: 1000, memory: 1073741824, gpu: 0, ephemeralStorage: 1073741824, count: 1 } - ]); - const requirements = { - attributes: [], - signedBy: { allOf: ["akash1auditorA", "akash1auditorB"], anyOf: [] } - }; - const { sql, replacements } = service.buildQuery(agg, requirements, 50, false); - - expect(sql).toContain(`HAVING`); - expect(sql).toContain(`pas.auditor = :allOfAuditor0`); - expect(sql).toContain(`pas.auditor = :allOfAuditor1`); - expect(replacements.allOfAuditor0).toBe("akash1auditorA"); - expect(replacements.allOfAuditor1).toBe("akash1auditorB"); - }); - - it("builds count query wrapping the main query", () => { - const { service } = setup(); - const agg = service.aggregateResources([ - { cpu: 1000, memory: 1073741824, gpu: 0, ephemeralStorage: 1073741824, 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("combines GPU + persistent storage + attributes + signedBy", () => { - const { service } = setup(); - const agg = service.aggregateResources([ - { - cpu: 4000, memory: 17179869184, gpu: 1, ephemeralStorage: 107374182400, count: 1, - gpuAttributes: { vendor: "nvidia", model: "a100" }, - persistentStorage: 10737418240, persistentStorageClass: "beta3" - } - ]); - const requirements = { - attributes: [{ key: "region", value: "us-west" }], - signedBy: { allOf: [], anyOf: ["akash1auditor1"] } - }; - const { sql } = service.buildQuery(agg, requirements, 50, false); - - expect(sql).toContain(`"providerSnapshotNodeGPU" gpu`); - expect(sql).toContain(`"providerSnapshotStorage" pss`); - expect(sql).toContain(`"providerAttribute" pa`); - expect(sql).toContain(`"providerAttributeSignature" pas`); - expect(sql).toContain(`HAVING`); - }); - }); - - describe("findMatchingProviders", () => { - it("returns providers and total count", async () => { - const { service, chainDb } = setup(); - const mockProviders = [ - { owner: "akash1abc", hostUri: "https://p1.com", leaseCount: 10, availableCpu: 4000, availableMemory: 8589934592, availableGpu: 0, availableEphemeralStorage: 32212254720, availablePersistentStorage: 0 } - ]; - - chainDb.query - .mockResolvedValueOnce([{ total: "5" }]) - .mockResolvedValueOnce(mockProviders); - - const result = await service.findMatchingProviders({ - resources: [{ cpu: 500, memory: 536870912, gpu: 0, ephemeralStorage: 1073741824, count: 1 }], - requirements: defaultRequirements(), - limit: 50 - }); - - expect(result.providers).toEqual(mockProviders); - expect(result.total).toBe(5); - expect(result.queryTimeMs).toBeGreaterThanOrEqual(0); - expect(result.constraints).toBeUndefined(); - }); - - it("runs constraint diagnosis when total is 0", async () => { - const { service, chainDb } = setup(); - - // Count query returns 0 - chainDb.query - .mockResolvedValueOnce([{ total: "0" }]) - .mockResolvedValueOnce([]); - - // Diagnosis queries: baseline, CPU, memory, ephemeral storage - chainDb.query - .mockResolvedValueOnce([{ c: "72" }]) - .mockResolvedValueOnce([{ c: "50" }]) - .mockResolvedValueOnce([{ c: "45" }]) - .mockResolvedValueOnce([{ c: "60" }]); - - const result = await service.findMatchingProviders({ - resources: [{ cpu: 256000, memory: 549755813888, gpu: 0, ephemeralStorage: 1099511627776, count: 1 }], - requirements: defaultRequirements(), - limit: 50 - }); - - expect(result.total).toBe(0); - expect(result.constraints).toBeDefined(); - expect(result.constraints!.length).toBe(4); - expect(result.constraints![0].name).toBe("Online providers (baseline)"); - }); - - it("does not run diagnosis when total > 0", async () => { - const { service, chainDb } = setup(); - - chainDb.query - .mockResolvedValueOnce([{ total: "10" }]) - .mockResolvedValueOnce([{ owner: "akash1abc", hostUri: "https://p1.com", leaseCount: 5, availableCpu: 2000, availableMemory: 4294967296, availableGpu: 0, availableEphemeralStorage: 10737418240, availablePersistentStorage: 0 }]); - - const result = await service.findMatchingProviders({ - resources: [{ cpu: 1000, memory: 1073741824, gpu: 0, ephemeralStorage: 1073741824, count: 1 }], - requirements: defaultRequirements(), - limit: 50 - }); - - expect(result.constraints).toBeUndefined(); - // Only 2 calls: count + main query - expect(chainDb.query).toHaveBeenCalledTimes(2); - }); - - it("uses default limit of 50 when not specified", async () => { - const { service, chainDb } = setup(); - - chainDb.query - .mockResolvedValueOnce([{ total: "1" }]) - .mockResolvedValueOnce([]); - - await service.findMatchingProviders({ - resources: [{ cpu: 1000, memory: 1073741824, gpu: 0, ephemeralStorage: 1073741824, count: 1 }], - requirements: defaultRequirements() - } as BidPrecheckRequest["data"]); - - const mainCall = chainDb.query.mock.calls[1]; - expect(mainCall[1]).toHaveProperty("replacements"); - expect((mainCall[1] as { replacements: Record }).replacements.limit).toBe(50); - }); - }); - - function defaultRequirements(): BidPrecheckRequest["data"]["requirements"] { - return { attributes: [], signedBy: { allOf: [], anyOf: [] } }; - } - - function setup() { - const chainDb = mock(); - const service = new BidPrecheckService(chainDb); - return { service, chainDb }; - } -}); -``` - -- [ ] **Step 2: Run tests to verify they pass** - -Run: `cd apps/api && npx vitest run src/provider/services/bid-precheck/bid-precheck.service.spec.ts` -Expected: All tests pass. - -- [ ] **Step 3: Commit** - -```bash -git add src/provider/services/bid-precheck/bid-precheck.service.spec.ts -git commit -m "test(provider): add bid precheck service unit tests" -``` - ---- - -### Task 5: Controller - -**Files:** -- Create: `src/provider/controllers/bid-precheck/bid-precheck.controller.ts` - -- [ ] **Step 1: Create the controller file** - -```typescript -import { singleton } from "tsyringe"; - -import type { BidPrecheckRequest, BidPrecheckResponse } from "@src/provider/http-schemas/bid-precheck.schema"; -import { BidPrecheckService } from "@src/provider/services/bid-precheck/bid-precheck.service"; - -@singleton() -export class BidPrecheckController { - constructor(private readonly bidPrecheckService: BidPrecheckService) {} - - async precheck(input: BidPrecheckRequest["data"]): Promise { - const result = await this.bidPrecheckService.findMatchingProviders(input); - - return { - data: { - providers: result.providers, - total: result.total, - queryTimeMs: result.queryTimeMs, - constraints: result.constraints - } - }; - } -} -``` - -- [ ] **Step 2: Verify TypeScript compiles** - -Run: `cd apps/api && npx tsc --noEmit` -Expected: No errors. - -- [ ] **Step 3: Commit** - -```bash -git add src/provider/controllers/bid-precheck/bid-precheck.controller.ts -git commit -m "feat(provider): add bid precheck controller" -``` - ---- - -### Task 6: Router and registration - -**Files:** -- Create: `src/provider/routes/bid-precheck/bid-precheck.router.ts` -- Modify: `src/provider/routes/index.ts` -- Modify: `src/rest-app.ts` - -- [ ] **Step 1: Create the router file** - -```typescript -import { container } from "tsyringe"; - -import { BidPrecheckController } from "@src/provider/controllers/bid-precheck/bid-precheck.controller"; -import { BidPrecheckRequestSchema, BidPrecheckResponseSchema } from "@src/provider/http-schemas/bid-precheck.schema"; -import { createRoute } from "@src/core/lib/create-route/create-route"; -import { OpenApiHonoHandler } from "@src/core/services/open-api-hono-handler/open-api-hono-handler"; - -export const bidPrecheckRouter = new OpenApiHonoHandler(); - -const postRoute = createRoute({ - method: "post", - path: "/v1/bid-precheck", - summary: "Pre-filter providers by resource capacity (Stage 1 bid screening)", - tags: ["Providers"], - request: { - body: { - content: { - "application/json": { - schema: BidPrecheckRequestSchema - } - } - } - }, - responses: { - 200: { - description: "Matching providers ranked by lease count and available resources", - content: { - "application/json": { - schema: BidPrecheckResponseSchema - } - } - } - } -}); - -bidPrecheckRouter.openapi(postRoute, async function routeBidPrecheck(c) { - const { data } = c.req.valid("json"); - const result = await container.resolve(BidPrecheckController).precheck(data); - return c.json(result, 200); -}); -``` - -- [ ] **Step 2: Add export to provider routes index** - -Add to `src/provider/routes/index.ts`: - -```typescript -export * from "@src/provider/routes/bid-precheck/bid-precheck.router"; -``` - -- [ ] **Step 3: Register router in rest-app.ts** - -In `src/rest-app.ts`, add the import alongside other provider imports: - -```typescript -import { - auditorsRouter, - bidPrecheckRouter, - providerAttributesSchemaRouter, - // ... rest of existing imports -} from "./provider"; -``` - -Add `bidPrecheckRouter` to the `openApiHonoHandlers` array (after `providersRouter`): - -```typescript -const openApiHonoHandlers: OpenApiHonoHandler[] = [ - // ... existing entries - providersRouter, - bidPrecheckRouter, - auditorsRouter, - // ... rest -``` - -- [ ] **Step 4: Verify TypeScript compiles** - -Run: `cd apps/api && npx tsc --noEmit` -Expected: No errors. - -- [ ] **Step 5: Run all provider tests to ensure nothing is broken** - -Run: `cd apps/api && npx vitest run src/provider/` -Expected: All tests pass. - -- [ ] **Step 6: Run linting** - -Run: `cd apps/api && npm run lint -- --quiet` -Expected: No errors. - -- [ ] **Step 7: Commit** - -```bash -git add src/provider/routes/bid-precheck/bid-precheck.router.ts src/provider/routes/index.ts src/rest-app.ts -git commit -m "feat(provider): add POST /v1/bid-precheck route and register in app" -``` - ---- - -### Task 7: Manual smoke test - -- [ ] **Step 1: Start the API locally (or verify it builds)** - -Run: `cd apps/api && npm run build` -Expected: Build succeeds. - -- [ ] **Step 2: Test with curl (if running locally with DB access)** - -```bash -curl -X POST http://localhost:3080/v1/bid-precheck \ - -H "Content-Type: application/json" \ - -d '{ - "data": { - "resources": [{ - "cpu": 1000, - "memory": 1073741824, - "gpu": 0, - "ephemeralStorage": 1073741824, - "count": 1 - }], - "requirements": { - "attributes": [], - "signedBy": { "allOf": [], "anyOf": [] } - } - } - }' -``` - -Expected: JSON response with `data.providers[]`, `data.total`, `data.queryTimeMs`. - -- [ ] **Step 3: Final commit (if any lint/build fixes needed)** - -```bash -git add -A -git commit -m "fix(provider): address lint/build issues in bid precheck" -``` diff --git a/docs/superpowers/specs/2026-04-09-bid-precheck-stage1-design.md b/docs/superpowers/specs/2026-04-09-bid-precheck-stage1-design.md deleted file mode 100644 index 52f7879bd8..0000000000 --- a/docs/superpowers/specs/2026-04-09-bid-precheck-stage1-design.md +++ /dev/null @@ -1,206 +0,0 @@ -# Bid Precheck Stage 1 — Database Pre-filtering API - -**Issue:** CON-187 (part of CON-186) -**Date:** 2026-04-09 - -## Context - -When a user submits a deployment on Akash, every online provider receives the order and decides whether to bid. With growing provider count (currently ~72 online, potentially 1000+), calling each provider's bid-screening endpoint is not scalable. - -Stage 1 pre-filters providers using our indexer database to narrow the candidate set before Stage 2 calls the provider's `/v1/bid-screening` gRPC/REST endpoint (akash-network/provider#386) for real inventory checks and pricing. - -## Endpoint - -``` -POST /v1/bid-screening -``` - -No authentication required (`SECURITY_NONE`) — read-only public data, same as `GET /v1/providers`. - -## Request Schema - -Mirrors the provider proto `GroupSpec` → `ResourceUnit` → `Resources` structure so the frontend can reuse the same shape for both Stage 1 and Stage 2. - -```typescript -BidPrecheckRequest { - resources: ResourceUnit[] // GroupSpec.resources (repeated ResourceUnit) - requirements: { // GroupSpec.requirements (PlacementRequirements) - attributes: { key: string, value: string }[] - signedBy: { allOf: string[], anyOf: string[] } - } - limit?: number // default 50, max 200 -} - -ResourceUnit { - cpu: number // millicpu (1000 = 1 vCPU) - memory: number // bytes - gpu: number // count per replica (0 for no GPU) - gpuAttributes?: { // required when gpu > 0 - vendor: string // e.g. "nvidia" - model?: string // e.g. "rtx4090", "a100" - interface?: string // e.g. "PCIe" - memorySize?: string // e.g. "24Gi" - } - ephemeralStorage: number // bytes - persistentStorage?: number // bytes, omit if none - persistentStorageClass?: "beta1" | "beta2" | "beta3" // hdd, ssd, nvme - count: number // replica count — multiplies ALL resources -} -``` - -**Resource aggregation:** For each resource unit, total = per-replica value * count. Across all resource units, totals are summed for the provider-level capacity check. Per-node GPU checks use the per-replica `gpu` value (not the total). - -## Response Schema - -```typescript -BidPrecheckResponse { - providers: ProviderMatch[] - total: number // count before LIMIT (enables "showing 50 of 127") - queryTimeMs: number // for observability - constraints?: Constraint[] // only populated when total === 0 -} - -ProviderMatch { - owner: string // provider address - hostUri: string // provider endpoint URL - leaseCount: number - availableCpu: number // millicpu - availableMemory: number // bytes - availableGpu: number - availableEphemeralStorage: number // bytes - availablePersistentStorage: number // bytes -} - -Constraint { - name: string // e.g. "GPU model (nvidia/h100)" - count: number // providers passing this filter alone - actionableFeedback: string // user-facing suggestion -} -``` - -## Query Logic - -### Main query - -Single SQL query against the chain indexer database (Sequelize raw query via `@inject(CHAIN_DB)`). - -**Base filters (always applied):** -- `provider.deletedHeight IS NULL` — active providers only -- `provider.isOnline = true` — currently reachable -- JOIN `providerSnapshot` via `lastSuccessfulSnapshotId` -- `availableCPU >= totalCpu` -- `availableMemory >= totalMemory` -- `availableEphemeralStorage >= totalEphemeralStorage` - -**Conditional filters:** -- **GPU (totalGpu > 0):** `availableGPU >= totalGpu` + JOIN `providerSnapshotNode` and `providerSnapshotNodeGPU` for vendor/model/interface/memorySize matching + per-node available GPU check (`gpuAllocatable - gpuAllocated >= perReplicaGpu`) -- **Persistent storage:** `availablePersistentStorage >= totalPersistentStorage` + JOIN `providerSnapshotStorage` for class matching and per-class capacity check -- **Attributes:** JOIN `providerAttribute` + HAVING clause with COUNT FILTER for each key=value pair -- **Auditor signatures (anyOf):** JOIN `providerAttributeSignature` + `auditor IN (:anyOfAuditors)` -- **Auditor signatures (allOf):** HAVING clause ensuring every auditor is present - -**Ordering:** `leaseCount DESC, availableCPU DESC` — prioritizes battle-tested providers with the most capacity. - -**Pagination:** `LIMIT :limit` (default 50, max 200). - -### Count query - -Runs the same WHERE/JOIN/HAVING logic but `SELECT COUNT(DISTINCT p.owner)` to get the true total before LIMIT. - -### Constraint diagnosis (only when total === 0) - -Runs independent single-constraint queries to identify which filter is the blocker. Each query checks one constraint against the online provider baseline. Reports count and percentage for each, with actionable feedback for blockers (0 providers) and narrow filters (<5 providers). - -## Architecture - -### File structure (new files in `apps/api/src/provider/`) - -``` -http-schemas/bid-precheck.schema.ts # Zod request/response schemas -routes/bid-precheck/bid-precheck.router.ts # POST route definition -controllers/bid-precheck/bid-precheck.controller.ts -services/bid-precheck/bid-precheck.service.ts -services/bid-precheck/bid-precheck.service.spec.ts # unit tests -``` - -### Dependency flow - -``` -bid-precheck.router.ts - → container.resolve(BidPrecheckController) - → BidPrecheckService(@inject(CHAIN_DB) chainDb: Sequelize) - → buildStage1Query(spec) → SQL string + replacements - → chainDb.query(sql, replacements) - → if total === 0: diagnoseConstraints(spec) -``` - -### Integration with existing code - -- Router registered in `apps/api/src/provider/routes/index.ts` alongside existing provider routes -- Uses same `createRoute` + `OpenApiHonoHandler` pattern -- Injects `CHAIN_DB` (Sequelize) directly — no new DB connection needed -- `@singleton()` service, same as other provider services - -## Mapping to Provider Bid Screening (PR 386) - -| Provider CheckBidEligibility step | Stage 1 DB equivalent | Stage 2 (future) | -|---|---|---| -| `gspec.MatchAttributes(providerAttrs)` | `providerAttribute` JOIN + HAVING | - | -| `bidAttrs.SubsetOf(gspec.Requirements.Attributes)` | - | Provider call | -| `gspec.MatchResourcesRequirements(attr)` — auditor sigs | `providerAttributeSignature` JOIN | - | -| `maxGroupVolumes` check | - | Provider call | -| `gspec.Requirements.SignedBy` | `providerAttributeSignature` HAVING | - | -| `gspec.ValidateBasic()` | Zod schema validation | - | -| `DryRunReserve` (real inventory) | `providerSnapshot.available*` approximation | Provider call | -| `CalculatePrice` | - | Provider call (returns `DecCoin`) | -| Hostname availability | - | Provider call | - -Stage 1 is a superset filter — it may include providers that would fail Stage 2, but should never exclude providers that would pass. - -## Test Strategy - -Unit tests for `BidPrecheckService` — mock `CHAIN_DB` Sequelize instance, verify SQL generation and parameter binding. - -### Test cases (~25 tests) - -**Resource aggregation:** -1. Single resource unit — CPU, memory, storage passed correctly -2. Multi-replica — resources multiplied by count -3. Multiple resource units — totals summed across units -4. GPU per-node check uses per-replica count, not total - -**Filter generation:** -5. CPU-only (no GPU, no persistent storage) — minimal JOINs -6. GPU with vendor only (no model) — vendor filter, no model filter -7. GPU with vendor + model — both filters applied -8. GPU with all attributes (vendor, model, interface, memorySize) -9. Persistent storage with class — class filter + capacity check -10. Persistent storage without class — capacity only, no class filter -11. Attributes — single attribute HAVING clause -12. Multiple attributes — multiple HAVING conditions ANDed -13. SignedBy anyOf — auditor IN clause -14. SignedBy allOf — HAVING with per-auditor COUNT -15. Combined: GPU + persistent storage + attributes + signedBy - -**Limit handling:** -16. Default limit (50) when not specified -17. Custom limit respected -18. Limit clamped to max 200 - -**Constraint diagnosis:** -19. Runs only when main query returns 0 results -20. Does not run when results > 0 -21. Each constraint checked independently against baseline -22. Actionable feedback populated for blockers - -**Edge cases:** -23. Empty attributes array — no attribute JOIN -24. Empty signedBy (allOf=[], anyOf=[]) — no signature JOIN -25. Zero GPU — no GPU JOINs -26. All zeros except CPU/memory/storage — minimal query - -**Input validation (Zod schema):** -27. Missing required fields rejected -28. gpu > 0 without gpuAttributes — rejected -29. Invalid persistentStorageClass — rejected -30. Empty resources array — rejected