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).
122 lines
4.0 KiB
Go
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()
|
|
}
|