From 979b8e4afe4b5a7cb6863be8d9610e2a84a8031e Mon Sep 17 00:00:00 2001 From: Okeke Chinedu Emmanuel Date: Fri, 29 May 2026 15:45:28 +0100 Subject: [PATCH] Optimize job search query plans --- .../migration.sql | 31 ++++ backend/prisma/schema.prisma | 5 + backend/src/config/db.ts | 57 +++++- backend/src/index.ts | 1 + backend/src/routes/jobs.ts | 141 +++++++-------- backend/src/routes/state.ts | 106 +++++++++++ backend/src/utils/jobSearchPlan.ts | 166 ++++++++++++++++++ 7 files changed, 416 insertions(+), 91 deletions(-) create mode 100644 backend/prisma/migrations/20260529000000_optimize_job_search_plans/migration.sql create mode 100644 backend/src/utils/jobSearchPlan.ts diff --git a/backend/prisma/migrations/20260529000000_optimize_job_search_plans/migration.sql b/backend/prisma/migrations/20260529000000_optimize_job_search_plans/migration.sql new file mode 100644 index 00000000..ed831120 --- /dev/null +++ b/backend/prisma/migrations/20260529000000_optimize_job_search_plans/migration.sql @@ -0,0 +1,31 @@ +-- BE-API-099: indexes for bounded job search/filter plans. +-- These indexes are intentionally created concurrently so production deploys do +-- not block writes while the planner learns cheaper paths for /api/v1/jobs. +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_jobs_created_id_desc + ON jobs (created_at DESC, id DESC); + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_jobs_status_created_id_desc + ON jobs (status, created_at DESC, id DESC); + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_jobs_budget_created_id_desc + ON jobs (budget_usdc DESC, created_at DESC, id DESC); + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_jobs_deadline_created_id_desc + ON jobs (deadline_at, created_at DESC, id DESC) + WHERE deadline_at IS NOT NULL; + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_jobs_skills_gin + ON jobs USING gin (skills); + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_jobs_search_tsv_gin + ON jobs USING gin ( + to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(description, '')) + ); + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_jobs_title_trgm + ON jobs USING gin (title gin_trgm_ops); + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_jobs_description_trgm + ON jobs USING gin (description gin_trgm_ops); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 21a66ef9..bb5fe44f 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -214,6 +214,11 @@ model jobs { @@index([client_address], map: "idx_jobs_client_address") @@index([freelancer_address], map: "idx_jobs_freelancer_address") @@index([status], map: "idx_jobs_status") + @@index([created_at(sort: Desc), id(sort: Desc)], map: "idx_jobs_created_id_desc") + @@index([status, created_at(sort: Desc), id(sort: Desc)], map: "idx_jobs_status_created_id_desc") + @@index([budget_usdc(sort: Desc), created_at(sort: Desc), id(sort: Desc)], map: "idx_jobs_budget_created_id_desc") + @@index([deadline_at, created_at(sort: Desc), id(sort: Desc)], map: "idx_jobs_deadline_created_id_desc") + @@index([skills], map: "idx_jobs_skills_gin", type: Gin) } model milestone_events { diff --git a/backend/src/config/db.ts b/backend/src/config/db.ts index 2d752773..821f88a7 100644 --- a/backend/src/config/db.ts +++ b/backend/src/config/db.ts @@ -11,13 +11,28 @@ const connectionString = process.env.DATABASE_URL; // --------------------------------------------------------------------------- // Pool configuration — tuneable via environment variables // --------------------------------------------------------------------------- -const POOL_MAX = parseInt(process.env.POOL_MAX_CONNECTIONS || "20", 10); -const POOL_MIN = parseInt(process.env.POOL_MIN_CONNECTIONS || "2", 10); -const POOL_IDLE_TIMEOUT_MS = parseInt(process.env.POOL_IDLE_TIMEOUT_MS || "30000", 10); -const POOL_CONNECTION_TIMEOUT_MS = parseInt(process.env.POOL_CONNECTION_TIMEOUT_MS || "5000", 10); -const POOL_HEALTH_CHECK_INTERVAL_MS = parseInt(process.env.POOL_HEALTH_CHECK_INTERVAL_MS || "30000", 10); -const POOL_CONNECT_RETRY_LIMIT = parseInt(process.env.POOL_CONNECT_RETRY_LIMIT || "3", 10); -const POOL_CONNECT_RETRY_BASE_DELAY_MS = parseInt(process.env.POOL_CONNECT_RETRY_BASE_DELAY_MS || "500", 10); +function positiveIntEnv(name: string, fallback: number): number { + const raw = process.env[name]; + const parsed = raw === undefined ? fallback : Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + console.warn(`[POOL] Invalid ${name}=${raw}; using ${fallback}`); + return fallback; + } + return parsed; +} + +const POOL_MAX = positiveIntEnv("POOL_MAX_CONNECTIONS", 20); +const POOL_MIN = positiveIntEnv("POOL_MIN_CONNECTIONS", 2); +const POOL_IDLE_TIMEOUT_MS = positiveIntEnv("POOL_IDLE_TIMEOUT_MS", 30000); +const POOL_CONNECTION_TIMEOUT_MS = positiveIntEnv("POOL_CONNECTION_TIMEOUT_MS", 5000); +const POOL_HEALTH_CHECK_INTERVAL_MS = positiveIntEnv("POOL_HEALTH_CHECK_INTERVAL_MS", 30000); +const POOL_CONNECT_RETRY_LIMIT = positiveIntEnv("POOL_CONNECT_RETRY_LIMIT", 3); +const POOL_CONNECT_RETRY_BASE_DELAY_MS = positiveIntEnv("POOL_CONNECT_RETRY_BASE_DELAY_MS", 500); +const POOL_MAX_USES = positiveIntEnv("POOL_MAX_USES", 7500); +const POOL_MAX_LIFETIME_SECONDS = positiveIntEnv("POOL_MAX_LIFETIME_SECONDS", 1800); +const POOL_STATEMENT_TIMEOUT_MS = positiveIntEnv("POOL_STATEMENT_TIMEOUT_MS", 5000); +const POOL_LOCK_TIMEOUT_MS = positiveIntEnv("POOL_LOCK_TIMEOUT_MS", 1000); +const POOL_IDLE_IN_TX_TIMEOUT_MS = positiveIntEnv("POOL_IDLE_IN_TX_TIMEOUT_MS", 5000); // --------------------------------------------------------------------------- // Build the pool with resilient options @@ -28,6 +43,15 @@ export const pool = new Pool({ min: POOL_MIN, idleTimeoutMillis: POOL_IDLE_TIMEOUT_MS, connectionTimeoutMillis: POOL_CONNECTION_TIMEOUT_MS, + maxUses: POOL_MAX_USES, + maxLifetimeSeconds: POOL_MAX_LIFETIME_SECONDS, + statement_timeout: POOL_STATEMENT_TIMEOUT_MS, + query_timeout: POOL_STATEMENT_TIMEOUT_MS + 500, + lock_timeout: POOL_LOCK_TIMEOUT_MS, + idle_in_transaction_session_timeout: POOL_IDLE_IN_TX_TIMEOUT_MS, + application_name: process.env.PGAPPNAME || "lance-backend-api", + keepAlive: true, + keepAliveInitialDelayMillis: 10_000, allowExitOnIdle: false, // Keep the pool alive even when the event loop has no other work }); @@ -36,9 +60,14 @@ export const pool = new Pool({ // --------------------------------------------------------------------------- pool.on("connect", (client: PoolClient) => { client - .query("SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL READ COMMITTED") + .query(` + SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL READ COMMITTED; + SET statement_timeout = ${POOL_STATEMENT_TIMEOUT_MS}; + SET lock_timeout = ${POOL_LOCK_TIMEOUT_MS}; + SET idle_in_transaction_session_timeout = ${POOL_IDLE_IN_TX_TIMEOUT_MS}; + `) .catch((err) => { - console.error("[POOL] Failed to configure transaction isolation:", err.message); + console.error("[POOL] Failed to configure session safety settings:", err.message); }); if (process.env.NODE_ENV !== "production") { @@ -80,6 +109,11 @@ export interface PoolHealthStats { minConnections: number; idleTimeoutMs: number; connectionTimeoutMs: number; + statementTimeoutMs: number; + lockTimeoutMs: number; + idleInTransactionTimeoutMs: number; + maxUses: number; + maxLifetimeSeconds: number; healthCheckIntervalMs: number; lastHealthCheckAt: string | null; lastHealthCheckOk: boolean; @@ -104,6 +138,11 @@ export function getPoolHealthStats(): PoolHealthStats { minConnections: POOL_MIN, idleTimeoutMs: POOL_IDLE_TIMEOUT_MS, connectionTimeoutMs: POOL_CONNECTION_TIMEOUT_MS, + statementTimeoutMs: POOL_STATEMENT_TIMEOUT_MS, + lockTimeoutMs: POOL_LOCK_TIMEOUT_MS, + idleInTransactionTimeoutMs: POOL_IDLE_IN_TX_TIMEOUT_MS, + maxUses: POOL_MAX_USES, + maxLifetimeSeconds: POOL_MAX_LIFETIME_SECONDS, healthCheckIntervalMs: POOL_HEALTH_CHECK_INTERVAL_MS, lastHealthCheckAt: lastHealthCheckAt ? lastHealthCheckAt.toISOString() : null, lastHealthCheckOk, diff --git a/backend/src/index.ts b/backend/src/index.ts index 207d68b4..d4a6bbe3 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -21,6 +21,7 @@ import bulkRoutes from "./routes/bulk"; import poolRoutes from "./routes/pool"; import stateRoutes from "./routes/state"; import { pool } from "./config/db"; +import { startStorageCleanup, stopStorageCleanup } from "./utils/storage-cleanup"; dotenv.config(); diff --git a/backend/src/routes/jobs.ts b/backend/src/routes/jobs.ts index d8292b8e..45f052bf 100644 --- a/backend/src/routes/jobs.ts +++ b/backend/src/routes/jobs.ts @@ -6,15 +6,21 @@ import milestonesRoutes from "./milestones"; import deliverablesRoutes from "./deliverables"; import jobDisputesRoutes from "./job-disputes"; import { logger } from "../utils/tracing"; +import { buildJobSearchQuery, executeReadOnlyJobSearch } from "../utils/jobSearchPlan"; const router = Router(); +function positiveTimeoutMs(name: string, fallback: number): number { + const parsed = Number.parseInt(process.env[name] || String(fallback), 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + // Validation schemas const getJobsQuerySchema = z.object({ query: z.string().optional(), status: z.string().optional(), tag: z.string().optional(), - sort: z.string().optional(), + sort: z.enum(["created_at", "budget"]).default("created_at"), limit: z.coerce.number().int().min(1).max(100).default(25), cursor_created_at: z.coerce.date().optional(), cursor_id: z.string().uuid().optional(), @@ -48,6 +54,7 @@ function serializeJob(row: any) { // GET /api/v1/jobs router.get("/", async (req: Request, res: Response) => { + const startedAt = Date.now(); try { const query = getJobsQuerySchema.parse(req.query); @@ -65,91 +72,61 @@ router.get("/", async (req: Request, res: Response) => { return res.status(400).json({ error: "min_budget cannot be greater than max_budget" }); } - const conditions: string[] = []; - const params: any[] = []; - const addParam = (value: any): string => { - params.push(value); - return `$${params.length}`; - }; - - if (query.query || (query.tag && query.tag !== "all")) { - const searchTerm = query.query || query.tag; - const placeholder = addParam(`%${searchTerm}%`); - conditions.push(`(title ILIKE ${placeholder} OR description ILIKE ${placeholder})`); - } + const builtQuery = buildJobSearchQuery(query); + const client = await pool.connect(); + + try { + // Read-only transaction settings are local to this request and protect + // the pool from expensive ad-hoc filters under concurrency. + await client.query("BEGIN READ ONLY ISOLATION LEVEL READ COMMITTED"); + await client.query(`SET LOCAL statement_timeout = ${positiveTimeoutMs("JOB_SEARCH_STATEMENT_TIMEOUT_MS", 1500)}`); + const result = await executeReadOnlyJobSearch(client, builtQuery); + await client.query("COMMIT"); + + const rows = result.rows; + const hasNext = rows.length > query.limit; + const items = (hasNext ? rows.slice(0, query.limit) : rows).map(serializeJob); + const cursorSource = hasNext ? items[items.length - 1] : null; + + logger.info("Paginated jobs queried", { + returned: items.length, + hasNext, + status: query.status || "any", + sort: query.sort, + planKey: builtQuery.planKey, + poolTotal: pool.totalCount, + poolIdle: pool.idleCount, + poolWaiting: pool.waitingCount, + durationMs: Date.now() - startedAt, + }); - if (query.status) { - conditions.push(`status = ${addParam(query.status)}`); - } - if (query.min_budget !== undefined) { - conditions.push(`budget_usdc >= ${addParam(query.min_budget)}`); - } - if (query.max_budget !== undefined) { - conditions.push(`budget_usdc <= ${addParam(query.max_budget)}`); - } - if (query.skills) { - const skills = query.skills - .split(",") - .map((skill) => skill.trim()) - .filter(Boolean); - if (skills.length > 0) { - conditions.push(`skills && ${addParam(skills)}::text[]`); - } - } - if (query.deadline_before) { - conditions.push(`deadline_at <= ${addParam(query.deadline_before)}`); - } - if (query.cursor_created_at && query.cursor_id) { - conditions.push( - `(created_at, id) < (${addParam(query.cursor_created_at)}, ${addParam(query.cursor_id)}::uuid)` - ); + return res.status(200).json({ + items, + next_cursor: cursorSource + ? { + created_at: cursorSource.created_at, + id: cursorSource.id, + } + : null, + limit: query.limit, + }); + } catch (error) { + await client.query("ROLLBACK").catch(() => undefined); + throw error; + } finally { + client.release(); } - - const whereSql = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; - const orderSql = - query.sort === "budget" - ? "ORDER BY budget_usdc DESC, created_at DESC, id DESC" - : "ORDER BY created_at DESC, id DESC"; - const limitPlaceholder = addParam(query.limit + 1); - - const result = await pool.query( - `SELECT id, title, description, budget_usdc, milestones, client_address, - freelancer_address, status, metadata_hash, on_chain_job_id, skills, deadline_at, - created_at, updated_at - FROM jobs - ${whereSql} - ${orderSql} - LIMIT ${limitPlaceholder}`, - params - ); - - const rows = result.rows; - const hasNext = rows.length > query.limit; - const items = (hasNext ? rows.slice(0, query.limit) : rows).map(serializeJob); - const cursorSource = hasNext ? items[items.length - 1] : null; - - logger.info("Paginated jobs queried", { - returned: items.length, - hasNext, - status: query.status || "any", - sort: query.sort || "created_at", - }); - - res.json({ - items, - next_cursor: cursorSource - ? { - created_at: cursorSource.created_at, - id: cursorSource.id, - } - : null, - limit: query.limit, - }); - } catch (error) { + } catch (error: any) { if (error instanceof z.ZodError) { return res.status(400).json({ error: error.issues }); } - console.error("GET /jobs error:", error); + logger.error("GET /jobs error", { + error: error.message || String(error), + durationMs: Date.now() - startedAt, + poolTotal: pool.totalCount, + poolIdle: pool.idleCount, + poolWaiting: pool.waitingCount, + }); res.status(500).json({ error: "Internal server error" }); } }); @@ -159,7 +136,7 @@ router.post("/", async (req: Request, res: Response) => { try { const data = createJobSchema.parse(req.body); - const result = await prisma.$transaction(async (tx) => { + const result = await prisma.$transaction(async (tx: any) => { const job = await tx.jobs.create({ data: { title: data.title, diff --git a/backend/src/routes/state.ts b/backend/src/routes/state.ts index 8d3e7546..3c40ed08 100644 --- a/backend/src/routes/state.ts +++ b/backend/src/routes/state.ts @@ -1,10 +1,31 @@ import { Router, Request, Response } from "express"; import { z } from "zod"; import { pool } from "../config/db"; +import { buildJobSearchQuery, summarizePlan } from "../utils/jobSearchPlan"; import { logger } from "../utils/tracing"; const router = Router(); +function positiveTimeoutMs(name: string, fallback: number): number { + const parsed = Number.parseInt(process.env[name] || String(fallback), 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + + +const jobSearchPlanQuerySchema = z.object({ + query: z.string().optional(), + status: z.string().optional(), + tag: z.string().optional(), + sort: z.enum(["created_at", "budget"]).default("created_at"), + limit: z.coerce.number().int().min(1).max(100).default(25), + cursor_created_at: z.coerce.date().optional(), + cursor_id: z.string().uuid().optional(), + min_budget: z.coerce.number().int().nonnegative().optional(), + max_budget: z.coerce.number().int().nonnegative().optional(), + skills: z.string().optional(), + deadline_before: z.coerce.date().optional(), +}); + const recoveryQuerySchema = z.object({ status: z.enum(["pending", "committed", "failed", "abandoned"]).optional(), limit: z.coerce.number().int().min(1).max(200).default(50), @@ -54,4 +75,89 @@ router.get("/write-recovery", async (req: Request, res: Response) => { } }); +/** + * GET /api/v1/state/job-search-plan + * + * Audits the planner cost for the same bounded SQL used by GET /api/v1/jobs. + * The endpoint runs EXPLAIN without ANALYZE inside a read-only transaction so + * diagnostics cannot mutate state or hold write locks during production checks. + */ +router.get("/job-search-plan", async (req: Request, res: Response) => { + const startedAt = Date.now(); + const client = await pool.connect(); + + try { + const query = jobSearchPlanQuerySchema.parse(req.query); + + if ((query.cursor_created_at && !query.cursor_id) || (!query.cursor_created_at && query.cursor_id)) { + return res.status(400).json({ + error: "cursor_created_at and cursor_id must be provided together", + }); + } + + if ( + query.min_budget !== undefined && + query.max_budget !== undefined && + query.min_budget > query.max_budget + ) { + return res.status(400).json({ error: "min_budget cannot be greater than max_budget" }); + } + + const builtQuery = buildJobSearchQuery(query); + await client.query("BEGIN READ ONLY ISOLATION LEVEL READ COMMITTED"); + await client.query(`SET LOCAL statement_timeout = ${positiveTimeoutMs("JOB_SEARCH_PLAN_TIMEOUT_MS", 1000)}`); + + const explain = await client.query( + `EXPLAIN (FORMAT JSON, COSTS TRUE, VERBOSE FALSE, BUFFERS FALSE) ${builtQuery.sql}`, + builtQuery.params + ); + await client.query("COMMIT"); + + const planRoot = explain.rows[0]["QUERY PLAN"][0]; + const summary = summarizePlan(planRoot.Plan); + + logger.info("Job search query plan audited", { + planKey: builtQuery.planKey, + totalCost: summary.totalCost, + jobsSequentialScan: summary.jobsSequentialScan, + durationMs: Date.now() - startedAt, + poolTotal: pool.totalCount, + poolIdle: pool.idleCount, + poolWaiting: pool.waitingCount, + }); + + return res.status(200).json({ + route: "GET /api/v1/jobs", + plan_key: builtQuery.planKey, + search_term: builtQuery.normalizedSearchTerm || null, + skills: builtQuery.normalizedSkills, + summary, + planner: planRoot, + pool: { + totalConnections: pool.totalCount, + idleConnections: pool.idleCount, + waitingRequests: pool.waitingCount, + }, + }); + } catch (error: any) { + await client.query("ROLLBACK").catch(() => undefined); + + if (error instanceof z.ZodError) { + return res.status(400).json({ error: error.issues }); + } + + logger.error("Job search plan audit failed", { + error: error.message || String(error), + durationMs: Date.now() - startedAt, + poolTotal: pool.totalCount, + poolIdle: pool.idleCount, + poolWaiting: pool.waitingCount, + }); + return res.status(500).json({ error: "Failed to audit job search query plan" }); + } finally { + client.release(); + } +}); + + export default router; diff --git a/backend/src/utils/jobSearchPlan.ts b/backend/src/utils/jobSearchPlan.ts new file mode 100644 index 00000000..2f7f307b --- /dev/null +++ b/backend/src/utils/jobSearchPlan.ts @@ -0,0 +1,166 @@ +import { PoolClient, QueryResult, QueryResultRow } from "pg"; + +export type JobSearchSort = "created_at" | "budget"; + +export interface NormalizedJobSearchQuery { + query?: string; + status?: string; + tag?: string; + sort: JobSearchSort; + limit: number; + cursor_created_at?: Date; + cursor_id?: string; + min_budget?: number; + max_budget?: number; + skills?: string; + deadline_before?: Date; +} + +export interface BuiltJobSearchQuery { + sql: string; + params: any[]; + planKey: string; + normalizedSkills: string[]; + normalizedSearchTerm?: string; +} + +const JOB_SEARCH_SELECT = ` + SELECT id, title, description, budget_usdc, milestones, client_address, + freelancer_address, status, metadata_hash, on_chain_job_id, skills, deadline_at, + created_at, updated_at + FROM jobs +`; + +function normalizeSearchTerm(query: NormalizedJobSearchQuery): string | undefined { + const candidate = query.query || (query.tag && query.tag !== "all" ? query.tag : undefined); + const trimmed = candidate?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : undefined; +} + +function normalizeSkills(skills?: string): string[] { + return (skills || "") + .split(",") + .map((skill) => skill.trim()) + .filter(Boolean); +} + +/** + * Builds the exact SQL used by both the public job listing route and the + * plan-audit route. Keeping the text in one place prevents the audit endpoint + * from drifting away from the production workload it is supposed to verify. + */ +export function buildJobSearchQuery(query: NormalizedJobSearchQuery): BuiltJobSearchQuery { + const conditions: string[] = []; + const params: any[] = []; + const planParts: string[] = []; + const addParam = (value: any): string => { + params.push(value); + return `$${params.length}`; + }; + + const searchTerm = normalizeSearchTerm(query); + const skills = normalizeSkills(query.skills); + + if (searchTerm) { + const tsQuery = addParam(searchTerm); + const trigramPattern = addParam(`%${searchTerm}%`); + // Prefer the expression GIN full-text index while retaining trigram-backed + // substring matching for partial words and legacy client behavior. + conditions.push(`( + to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(description, '')) + @@ websearch_to_tsquery('simple', ${tsQuery}) + OR title ILIKE ${trigramPattern} + OR description ILIKE ${trigramPattern} + )`); + planParts.push("text"); + } + + if (query.status) { + conditions.push(`status = ${addParam(query.status)}`); + planParts.push("status"); + } + if (query.min_budget !== undefined) { + conditions.push(`budget_usdc >= ${addParam(query.min_budget)}`); + planParts.push("min_budget"); + } + if (query.max_budget !== undefined) { + conditions.push(`budget_usdc <= ${addParam(query.max_budget)}`); + planParts.push("max_budget"); + } + if (skills.length > 0) { + conditions.push(`skills && ${addParam(skills)}::text[]`); + planParts.push("skills"); + } + if (query.deadline_before) { + conditions.push(`deadline_at <= ${addParam(query.deadline_before)}`); + planParts.push("deadline"); + } + if (query.cursor_created_at && query.cursor_id) { + conditions.push( + `(created_at, id) < (${addParam(query.cursor_created_at)}, ${addParam(query.cursor_id)}::uuid)` + ); + planParts.push("cursor"); + } + + const whereSql = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const orderSql = + query.sort === "budget" + ? "ORDER BY budget_usdc DESC, created_at DESC, id DESC" + : "ORDER BY created_at DESC, id DESC"; + const limitPlaceholder = addParam(query.limit + 1); + + return { + sql: `${JOB_SEARCH_SELECT}${whereSql}\n${orderSql}\nLIMIT ${limitPlaceholder}`, + params, + planKey: `${query.sort}:${planParts.length > 0 ? planParts.join("+") : "unfiltered"}`, + normalizedSkills: skills, + normalizedSearchTerm: searchTerm, + }; +} + +/** Executes read-only job search in a short transaction with per-query timeout. */ +export async function executeReadOnlyJobSearch( + client: PoolClient, + builtQuery: BuiltJobSearchQuery +): Promise> { + return client.query(builtQuery.sql, builtQuery.params); +} + +export function summarizePlan(planNode: any) { + const nodeTypes: string[] = []; + const relationScans: Array<{ nodeType: string; relation?: string; index?: string; totalCost?: number }> = []; + + const visit = (node: any) => { + if (!node || typeof node !== "object") return; + const nodeType = node["Node Type"]; + if (nodeType) { + nodeTypes.push(nodeType); + if (String(nodeType).includes("Scan")) { + relationScans.push({ + nodeType, + relation: node["Relation Name"], + index: node["Index Name"], + totalCost: node["Total Cost"], + }); + } + } + for (const child of node.Plans || []) { + visit(child); + } + }; + + visit(planNode); + const jobsSequentialScan = relationScans.some( + (scan) => scan.relation === "jobs" && scan.nodeType === "Seq Scan" + ); + + return { + startupCost: planNode?.["Startup Cost"] ?? null, + totalCost: planNode?.["Total Cost"] ?? null, + planRows: planNode?.["Plan Rows"] ?? null, + planWidth: planNode?.["Plan Width"] ?? null, + nodeTypes, + relationScans, + jobsSequentialScan, + }; +}