Files
silo/internal/db/solver_results.go
Forbes 5f144878d6 feat(api): solver service Phase 3b — server endpoints, job definitions, and result cache
Add server-side solver service module with REST API endpoints, database
schema, job definitions, and runner result caching.

New files:
- migrations/021_solver_results.sql: solver_results table with upsert constraint
- internal/db/solver_results.go: SolverResultRepository (Upsert, GetByItem, GetByItemRevision)
- internal/api/solver_handlers.go: solver API handlers and maybeCacheSolverResult hook
- jobdefs/assembly-solve.yaml: manual solve job definition
- jobdefs/assembly-validate.yaml: auto-validate on revision creation
- jobdefs/assembly-kinematic.yaml: manual kinematic simulation job

Modified:
- internal/config/config.go: SolverConfig struct with max_context_size_mb, default_timeout
- internal/modules/modules.go, loader.go: register solver module (depends on jobs)
- internal/db/jobs.go: ListSolverJobs helper with definition_name prefix filter
- internal/api/handlers.go: wire SolverResultRepository into Server
- internal/api/routes.go: /api/solver/* routes + /api/items/{partNumber}/solver/results
- internal/api/runner_handlers.go: async result cache hook on job completion

API endpoints:
- POST   /api/solver/jobs          — submit solver job (editor)
- GET    /api/solver/jobs          — list solver jobs with filters
- GET    /api/solver/jobs/{id}     — get solver job status
- POST   /api/solver/jobs/{id}/cancel — cancel solver job (editor)
- GET    /api/solver/solvers       — registry of available solvers
- GET    /api/items/{pn}/solver/results — cached results for item

Also fixes pre-existing test compilation errors (missing workflows param
in NewServer calls across 6 test files).
2026-02-20 12:08:34 -06:00

122 lines
4.0 KiB
Go

package db
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// SolverResult represents a row in the solver_results table.
type SolverResult struct {
ID string
ItemID string
RevisionNumber int
JobID *string
Operation string // solve, diagnose, kinematic
SolverName string
Status string // SolveStatus string (Success, Failed, etc.)
DOF *int
Diagnostics []byte // raw JSONB
Placements []byte // raw JSONB
NumFrames int
SolveTimeMS *float64
CreatedAt time.Time
}
// SolverResultRepository provides solver_results database operations.
type SolverResultRepository struct {
db *DB
}
// NewSolverResultRepository creates a new solver result repository.
func NewSolverResultRepository(db *DB) *SolverResultRepository {
return &SolverResultRepository{db: db}
}
// Upsert inserts or updates a solver result. The UNIQUE(item_id, revision_number, operation)
// constraint means each revision has at most one result per operation type.
func (r *SolverResultRepository) Upsert(ctx context.Context, s *SolverResult) error {
err := r.db.pool.QueryRow(ctx, `
INSERT INTO solver_results (item_id, revision_number, job_id, operation,
solver_name, status, dof, diagnostics, placements,
num_frames, solve_time_ms)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT (item_id, revision_number, operation) DO UPDATE SET
job_id = EXCLUDED.job_id,
solver_name = EXCLUDED.solver_name,
status = EXCLUDED.status,
dof = EXCLUDED.dof,
diagnostics = EXCLUDED.diagnostics,
placements = EXCLUDED.placements,
num_frames = EXCLUDED.num_frames,
solve_time_ms = EXCLUDED.solve_time_ms,
created_at = now()
RETURNING id, created_at
`, s.ItemID, s.RevisionNumber, s.JobID, s.Operation,
s.SolverName, s.Status, s.DOF, s.Diagnostics, s.Placements,
s.NumFrames, s.SolveTimeMS,
).Scan(&s.ID, &s.CreatedAt)
if err != nil {
return fmt.Errorf("upserting solver result: %w", err)
}
return nil
}
// GetByItem returns all solver results for an item, ordered by revision descending.
func (r *SolverResultRepository) GetByItem(ctx context.Context, itemID string) ([]*SolverResult, error) {
rows, err := r.db.pool.Query(ctx, `
SELECT id, item_id, revision_number, job_id, operation,
solver_name, status, dof, diagnostics, placements,
num_frames, solve_time_ms, created_at
FROM solver_results
WHERE item_id = $1
ORDER BY revision_number DESC, operation
`, itemID)
if err != nil {
return nil, fmt.Errorf("listing solver results: %w", err)
}
defer rows.Close()
return scanSolverResults(rows)
}
// GetByItemRevision returns a single solver result for an item/revision/operation.
func (r *SolverResultRepository) GetByItemRevision(ctx context.Context, itemID string, revision int, operation string) (*SolverResult, error) {
s := &SolverResult{}
err := r.db.pool.QueryRow(ctx, `
SELECT id, item_id, revision_number, job_id, operation,
solver_name, status, dof, diagnostics, placements,
num_frames, solve_time_ms, created_at
FROM solver_results
WHERE item_id = $1 AND revision_number = $2 AND operation = $3
`, itemID, revision, operation).Scan(
&s.ID, &s.ItemID, &s.RevisionNumber, &s.JobID, &s.Operation,
&s.SolverName, &s.Status, &s.DOF, &s.Diagnostics, &s.Placements,
&s.NumFrames, &s.SolveTimeMS, &s.CreatedAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("getting solver result: %w", err)
}
return s, nil
}
func scanSolverResults(rows pgx.Rows) ([]*SolverResult, error) {
var results []*SolverResult
for rows.Next() {
s := &SolverResult{}
if err := rows.Scan(
&s.ID, &s.ItemID, &s.RevisionNumber, &s.JobID, &s.Operation,
&s.SolverName, &s.Status, &s.DOF, &s.Diagnostics, &s.Placements,
&s.NumFrames, &s.SolveTimeMS, &s.CreatedAt,
); err != nil {
return nil, fmt.Errorf("scanning solver result: %w", err)
}
results = append(results, s)
}
return results, rows.Err()
}