rdev/internal/adapter/postgres/build_audit.go
jordan 4a18b1cd07 fix: Persist build audit status when worker claims task
Root cause: WorkerService.ClaimTask() was modifying the audit entry
in memory but never persisting it to the database. This caused build
tasks to remain stuck at "pending" status even after being claimed.

Changes:
- Add UpdateStatus method to port.BuildAudit interface
- Implement UpdateStatus in postgres.BuildAuditRepository
- Fix ClaimTask to call audit.UpdateStatus() to persist status
- Add test coverage for audit update during task claim
- Update all mock implementations

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 21:25:04 -07:00

233 lines
5.8 KiB
Go

// Package postgres provides PostgreSQL-based implementations of port interfaces.
package postgres
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
)
// BuildAuditRepository implements port.BuildAudit using PostgreSQL.
type BuildAuditRepository struct {
db *sql.DB
}
// NewBuildAuditRepository creates a new PostgreSQL build audit repository.
func NewBuildAuditRepository(db *sql.DB) *BuildAuditRepository {
return &BuildAuditRepository{db: db}
}
// Ensure BuildAuditRepository implements port.BuildAudit at compile time.
var _ port.BuildAudit = (*BuildAuditRepository)(nil)
// Record creates a new audit entry when a build starts.
func (r *BuildAuditRepository) Record(ctx context.Context, entry *domain.BuildAuditEntry) error {
specJSON, err := json.Marshal(entry.Spec)
if err != nil {
return fmt.Errorf("marshal build spec: %w", err)
}
_, err = r.db.ExecContext(ctx, `
INSERT INTO build_audit (task_id, project_id, worker_id, spec, status, started_at)
VALUES ($1, $2, $3, $4, $5, $6)
`, entry.TaskID, entry.ProjectID, nullString(entry.WorkerID),
specJSON, entry.Status, entry.StartedAt)
if err != nil {
return fmt.Errorf("record build audit: %w", err)
}
return nil
}
// Update modifies an existing entry when a build completes.
func (r *BuildAuditRepository) Update(ctx context.Context, taskID string, result *domain.BuildResult) error {
var resultJSON []byte
var err error
if result != nil {
resultJSON, err = json.Marshal(result)
if err != nil {
return fmt.Errorf("marshal build result: %w", err)
}
}
status := domain.BuildStatusCompleted
if result != nil && !result.Success {
status = domain.BuildStatusFailed
}
now := time.Now()
res, err := r.db.ExecContext(ctx, `
UPDATE build_audit
SET result = $2, status = $3, completed_at = $4
WHERE task_id = $1
`, taskID, resultJSON, status, now)
if err != nil {
return fmt.Errorf("update build audit: %w", err)
}
rows, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("rows affected: %w", err)
}
if rows == 0 {
return domain.ErrBuildNotFound
}
return nil
}
// UpdateStatus updates the status and worker assignment when a task is claimed.
func (r *BuildAuditRepository) UpdateStatus(ctx context.Context, taskID string, status domain.BuildStatus, workerID string) error {
res, err := r.db.ExecContext(ctx, `
UPDATE build_audit
SET status = $2, worker_id = $3
WHERE task_id = $1
`, taskID, status, nullString(workerID))
if err != nil {
return fmt.Errorf("update build audit status: %w", err)
}
rows, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("rows affected: %w", err)
}
if rows == 0 {
return domain.ErrBuildNotFound
}
return nil
}
// Get retrieves a specific audit entry by task ID.
func (r *BuildAuditRepository) Get(ctx context.Context, taskID string) (*domain.BuildAuditEntry, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT task_id, project_id, worker_id, spec, result, status,
started_at, completed_at
FROM build_audit
WHERE task_id = $1
`, taskID)
if err != nil {
return nil, fmt.Errorf("get build audit: %w", err)
}
defer func() { _ = rows.Close() }()
if !rows.Next() {
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("get build audit: %w", err)
}
return nil, domain.ErrBuildNotFound
}
return r.scanEntry(rows)
}
// List returns audit entries matching the filter.
func (r *BuildAuditRepository) List(ctx context.Context, filter port.BuildAuditFilter) ([]*domain.BuildAuditEntry, error) {
query := `
SELECT task_id, project_id, worker_id, spec, result, status,
started_at, completed_at
FROM build_audit
WHERE 1=1`
args := []any{}
argNum := 1
if filter.ProjectID != "" {
query += fmt.Sprintf(" AND project_id = $%d", argNum)
args = append(args, filter.ProjectID)
argNum++
}
if filter.WorkerID != "" {
query += fmt.Sprintf(" AND worker_id = $%d", argNum)
args = append(args, filter.WorkerID)
argNum++
}
if filter.Status != nil {
query += fmt.Sprintf(" AND status = $%d", argNum)
args = append(args, string(*filter.Status))
argNum++
}
if !filter.Since.IsZero() {
query += fmt.Sprintf(" AND started_at >= $%d", argNum)
args = append(args, filter.Since)
argNum++
}
query += " ORDER BY started_at DESC"
if filter.Limit > 0 {
query += fmt.Sprintf(" LIMIT $%d", argNum)
args = append(args, filter.Limit)
}
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("list build audit: %w", err)
}
defer func() { _ = rows.Close() }()
var entries []*domain.BuildAuditEntry
for rows.Next() {
entry, err := r.scanEntry(rows)
if err != nil {
return nil, err
}
entries = append(entries, entry)
}
return entries, rows.Err()
}
// scanEntry scans a single build audit row from a query result.
func (r *BuildAuditRepository) scanEntry(rows *sql.Rows) (*domain.BuildAuditEntry, error) {
var entry domain.BuildAuditEntry
var workerID sql.NullString
var specJSON []byte
var resultJSON []byte
var completedAt sql.NullTime
err := rows.Scan(
&entry.TaskID,
&entry.ProjectID,
&workerID,
&specJSON,
&resultJSON,
&entry.Status,
&entry.StartedAt,
&completedAt,
)
if err != nil {
return nil, fmt.Errorf("scan build audit: %w", err)
}
if workerID.Valid {
entry.WorkerID = workerID.String
}
if completedAt.Valid {
entry.CompletedAt = &completedAt.Time
}
if len(specJSON) > 0 {
if err := json.Unmarshal(specJSON, &entry.Spec); err != nil {
return nil, fmt.Errorf("unmarshal build spec: %w", err)
}
}
if len(resultJSON) > 0 {
entry.Result = &domain.BuildResult{}
if err := json.Unmarshal(resultJSON, entry.Result); err != nil {
return nil, fmt.Errorf("unmarshal build result: %w", err)
}
}
return &entry, nil
}