Files
wehub-resource-sync 75f3dd141c
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CI and Release / lint-frontend (push) Has been cancelled
CI and Release / dockerfile-scan (push) Has been cancelled
CI and Release / test-frontend (push) Has been cancelled
CI and Release / lint-verification-agent (push) Has been cancelled
CI and Release / test-verification-agent (push) Has been cancelled
CI and Release / e2e-verification-agent (push) Has been cancelled
CI and Release / test-backend (push) Has been cancelled
CI and Release / build-dev-image (push) Has been cancelled
CI and Release / push-dev-image (push) Has been cancelled
CI and Release / build-image (push) Has been cancelled
CI and Release / build-verification-image (push) Has been cancelled
CI and Release / determine-version (push) Has been cancelled
CI and Release / push-image (push) Has been cancelled
CI and Release / push-verification-image (12) (push) Has been cancelled
CI and Release / lint-backend (push) Has been cancelled
CI and Release / push-verification-image (17) (push) Has been cancelled
CI and Release / push-verification-image (18) (push) Has been cancelled
CI and Release / release (push) Has been cancelled
CI and Release / publish-helm-chart (push) Has been cancelled
CI and Release / push-verification-image (13) (push) Has been cancelled
CI and Release / push-verification-image (14) (push) Has been cancelled
CI and Release / push-verification-image (15) (push) Has been cancelled
CI and Release / push-verification-image (16) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:11:07 +08:00

584 lines
16 KiB
Go

package runner
import (
"context"
"errors"
"fmt"
"log/slog"
"slices"
"strings"
"sync/atomic"
"time"
"github.com/google/uuid"
"databasus-verification-agent/internal/config"
"databasus-verification-agent/internal/features/api"
"databasus-verification-agent/internal/features/dbconn"
"databasus-verification-agent/internal/features/restore"
"databasus-verification-agent/internal/features/verifier"
)
const (
jobName = "verification_runner"
claimErrorBackoff = 5 * time.Second
idleClaimBackoff = 15 * time.Second
maxBackupDownloadAttempts = 5
streamIdleTimeout = 60 * time.Second
maxBackupDownloadBackoff = 32 * time.Second
// archive layout inside the per-job container's writable volume.
inContainerArchiveDir = "/tmp"
maxParallelRestoreJobs = 8
)
var supportedMajors = []string{"12", "13", "14", "15", "16", "17", "18"}
// errAbortNoReport signals the job was aborted (jobCtx cancelled, or the
// stream returned 410) — executeJob returns without POSTing a report.
var errAbortNoReport = errors.New("verification aborted; no report")
// backupDownloadBackoffFn is the download retry delay; tests swap it to avoid real sleeps.
var backupDownloadBackoffFn = backupDownloadBackoff
type SpawnRequest struct {
PgMajor string
CPUPerJob int
RAMMbPerJob int
VerificationID uuid.UUID
TimescaledbVersion string
}
type Runner struct {
api APIClient
capacity config.Capacity
pool *Pool
spawner Spawner
restorer Restorer
verifier StatsCollector
heartbeat Registrar
connAlive func(ctx context.Context, conn dbconn.Conn) bool
hasRun atomic.Bool
log *slog.Logger
}
func NewRunner(
apiClient APIClient,
capacity config.Capacity,
pool *Pool,
spawner Spawner,
restorer Restorer,
statsCollector StatsCollector,
heartbeat Registrar,
log *slog.Logger,
) *Runner {
return &Runner{
api: apiClient,
capacity: capacity,
pool: pool,
spawner: spawner,
restorer: restorer,
verifier: statsCollector,
heartbeat: heartbeat,
connAlive: verifier.ProbeConnAlive,
log: log,
}
}
func (r *Runner) Run(ctx context.Context) {
if r.hasRun.Swap(true) {
panic(fmt.Sprintf("%T.Run() called multiple times", r))
}
logger := r.log.With("job_id", uuid.New(), "job_name", jobName)
logger.Info("runner loop started")
for ctx.Err() == nil {
// Gate claiming on local capacity so we never hold a server-claimed
// job we cannot start immediately.
if r.pool.Saturated() {
if !sleepOrDone(ctx, 1*time.Second) {
break
}
continue
}
capacity := api.AgentCapacity{
MaxCPU: r.capacity.MaxCPU,
MaxRAMMb: r.capacity.MaxRAMMb,
MaxDiskGb: r.capacity.MaxDiskGb,
MaxConcurrentJobs: r.capacity.MaxConcurrentJobs,
}
assignment, err := r.api.ClaimVerification(ctx, capacity)
if err != nil {
logger.Warn("claim failed", "error", err)
if !sleepOrDone(ctx, claimErrorBackoff) {
break
}
continue
}
if assignment == nil {
if !sleepOrDone(ctx, idleClaimBackoff) {
break
}
continue
}
r.pool.Go(func() { r.executeJob(ctx, assignment) })
}
logger.Info("runner loop draining in-flight jobs")
r.pool.Wait()
logger.Info("runner loop stopped")
}
func (r *Runner) executeJob(ctx context.Context, job *api.JobAssignment) {
runLogger := r.log.With(
"job_id", uuid.New(),
"job_name", jobName,
"verification_id", job.VerificationID,
)
jobCtx, cancel := context.WithCancel(ctx)
defer cancel()
// Register FIRST so the ID is in the heartbeat envelope before any
// container exists: the backend must never see an artifact-bearing job
// that is absent from this agent's reported in-flight set.
r.heartbeat.TrackVerification(job.VerificationID, cancel)
defer r.heartbeat.UntrackVerification(job.VerificationID)
pgMajor, err := pgMajorFromDatabase(job.Database)
if err != nil {
r.reportFailure(ctx, job.VerificationID, nil, fmt.Sprintf("resolve pg major: %v", err), runLogger)
return
}
jobContainer, err := r.spawner.Spawn(jobCtx, SpawnRequest{
PgMajor: pgMajor,
CPUPerJob: r.capacity.CPUPerJob,
RAMMbPerJob: r.capacity.RAMMbPerJob,
VerificationID: job.VerificationID,
TimescaledbVersion: job.TimescaledbVersion,
})
if err != nil {
if jobCtx.Err() != nil {
return
}
r.reportFailure(ctx, job.VerificationID, nil, fmt.Sprintf("spawn container: %v", err), runLogger)
return
}
defer func() {
if termErr := jobContainer.Terminate(ctx); termErr != nil {
runLogger.Error("failed to terminate container", "error", termErr)
}
}()
// Server-authoritative per-job disk budget (downloaded archive + restored
// DB + safe gap). The agent enforces it by watching the container's written
// bytes and aborting the instant the budget is reached — uniform on every
// storage driver, unlike a Docker --storage-opt cap.
diskBudgetMb := int64(job.MaxContainerDiskMb)
diskBudgetFailMessage := fmt.Sprintf("restore exceeded the per-job disk budget of %d MB", diskBudgetMb)
var isDiskLimitHit atomic.Bool
// A zero/absent budget (older backend) means no enforceable ceiling — skip
// the watcher rather than trip instantly on used >= 0.
if diskBudgetMb > 0 {
watchCtx, watchStop := context.WithCancel(jobCtx)
defer watchStop()
go newDiskWatcher(
jobContainer, diskBudgetMb*1024*1024,
func() { isDiskLimitHit.Store(true); cancel() },
runLogger,
).run(watchCtx)
} else {
runLogger.Warn("no per-job disk budget from server; disk watcher disabled for this job")
}
archivePath := fmt.Sprintf("%s/%s.dump", inContainerArchiveDir, job.VerificationID)
if err := r.downloadBackupIntoContainer(
jobCtx,
jobContainer,
job.VerificationID,
archivePath,
runLogger,
); err != nil {
if isDiskLimitHit.Load() {
r.reportDiskLimitExceeded(ctx, job.VerificationID, diskBudgetFailMessage, runLogger)
return
}
if errors.Is(err, errAbortNoReport) {
return
}
r.reportFailure(ctx, job.VerificationID, nil, fmt.Sprintf("download backup: %v", err), runLogger)
return
}
parallelJobs := min(maxParallelRestoreJobs, r.capacity.CPUPerJob)
// TimescaleDB restores single-threaded and in restoring mode. Single-threaded because parallel
// pg_restore loads dependent _timescaledb_catalog rows before the rows they reference, tripping
// the catalog's own foreign keys. post_restore runs on the success path below, before stats; on
// failure the throwaway container is discarded, so nothing is left in restoring mode to repair
// (no defer, unlike the backend's user-facing restore). The hooks use the verifier (host) conn
// while pg_restore uses the in-container conn, but the database-level restoring GUC carries across.
if job.TimescaledbVersion != "" {
parallelJobs = 1
if err := r.restorer.RunTimescalePreRestore(jobCtx, jobContainer.GetVerifierConn()); err != nil {
if jobCtx.Err() != nil {
return
}
r.reportFailure(ctx, job.VerificationID, nil,
fmt.Sprintf("timescaledb_pre_restore: %v", err), runLogger)
return
}
}
restoreResult, err := r.restorer.RunPgRestore(
jobCtx, jobContainer, archivePath, jobContainer.GetInContainerConn(), parallelJobs)
if err != nil {
if isDiskLimitHit.Load() {
r.reportDiskLimitExceeded(ctx, job.VerificationID, diskBudgetFailMessage, runLogger)
return
}
if jobCtx.Err() != nil {
return
}
if !errors.Is(err, restore.ErrRestoreFailed) {
// Exec-infrastructure failure (no usable exit code).
r.reportFailure(ctx, job.VerificationID, nil,
fmt.Sprintf("pg_restore exec: %v", err), runLogger)
return
}
if restore.IsDiskExhausted(restoreResult.StderrTail) {
// Hitting the estimate-derived ceiling is agent infra, not proof the
// backup is corrupt — omit the exit code so the backend retries
// (AgentSetupFailed), never BackupRejected.
r.reportFailure(ctx, job.VerificationID, nil,
fmt.Sprintf("pg_restore hit job disk ceiling: %v", err), runLogger)
return
}
if !restore.IsMissingExtensionOnly(restoreResult.StderrTail) {
runLogger.Error("pg_restore failed",
"exit_code", restoreResult.PgRestoreExitCode,
"stderr_tail", restoreResult.StderrTail)
r.reportFailure(ctx, job.VerificationID, &restoreResult,
fmt.Sprintf("pg_restore failed: %v", err), runLogger)
return
}
// The only items pg_restore skipped are CREATE/COMMENT EXTENSION for
// extensions this verification environment lacks — the data restored.
// Treat the backup as restorable and fall through to stats; the backend's
// restored-size check is the remaining safety net.
runLogger.Warn(
"pg_restore completed with only missing-extension errors; treating backup as restorable",
"exit_code", restoreResult.PgRestoreExitCode,
"skipped_extensions",
strings.Join(restore.ExtractUnavailableExtensions(restoreResult.StderrTail), ","),
"stderr_tail", restoreResult.StderrTail)
}
if job.TimescaledbVersion != "" {
if err := r.restorer.RunTimescalePostRestore(jobCtx, jobContainer.GetVerifierConn()); err != nil {
if jobCtx.Err() != nil {
return
}
r.reportFailure(ctx, job.VerificationID, &restoreResult,
fmt.Sprintf("timescaledb_post_restore: %v", err), runLogger)
return
}
}
verifierConn := jobContainer.GetVerifierConn()
stats, err := r.verifier.CollectStats(jobCtx, verifierConn)
if err != nil {
if isDiskLimitHit.Load() {
r.reportDiskLimitExceeded(ctx, job.VerificationID, diskBudgetFailMessage, runLogger)
return
}
if jobCtx.Err() != nil {
return
}
if !r.connAlive(jobCtx, verifierConn) {
r.reportFailure(ctx, job.VerificationID, nil,
fmt.Sprintf("verify (conn dead): %v", err), runLogger)
return
}
r.reportFailure(ctx, job.VerificationID, &restoreResult,
fmt.Sprintf("verify: %v", err), runLogger)
return
}
if isDiskLimitHit.Load() {
r.reportDiskLimitExceeded(ctx, job.VerificationID, diskBudgetFailMessage, runLogger)
return
}
r.reportSuccess(ctx, job.VerificationID, restoreResult, stats, runLogger)
}
func (r *Runner) downloadBackupIntoContainer(
jobCtx context.Context,
jobContainer JobContainer,
verificationID uuid.UUID,
archivePath string,
logger *slog.Logger,
) error {
var lastErr error
for attempt := 1; attempt <= maxBackupDownloadAttempts; attempt++ {
if jobCtx.Err() != nil {
return errAbortNoReport
}
err := r.downloadBackupIntoContainerOnce(jobCtx, jobContainer, verificationID, archivePath)
if err == nil {
return nil
}
var respErr *api.ResponseError
if errors.As(err, &respErr) {
if respErr.IsGone() {
logger.Info("backup stream gone (410); dropping without report")
return errAbortNoReport
}
if !respErr.Retryable() {
return fmt.Errorf("backup stream non-retryable: %w", err)
}
}
lastErr = err
logger.Warn("backup stream attempt failed",
"attempt", attempt, "max_attempts", maxBackupDownloadAttempts, "error", err)
if !sleepOrDone(jobCtx, backupDownloadBackoffFn(attempt)) {
return errAbortNoReport
}
}
return fmt.Errorf("backup stream failed after %d attempts: %w", maxBackupDownloadAttempts, lastErr)
}
func (r *Runner) downloadBackupIntoContainerOnce(
jobCtx context.Context,
jobContainer JobContainer,
verificationID uuid.UUID,
archivePath string,
) error {
body, err := r.api.DownloadBackup(jobCtx, verificationID)
if err != nil {
return err
}
defer func() { _ = body.Close() }()
dlCtx, dlCancel := context.WithCancelCause(jobCtx)
defer dlCancel(nil)
idleReader := api.NewIdleTimeoutReader(body, streamIdleTimeout, dlCancel)
defer idleReader.Stop()
return r.restorer.StageBackupViaExec(dlCtx, jobContainer, idleReader, archivePath)
}
func (r *Runner) reportSuccess(
ctx context.Context,
verificationID uuid.UUID,
restoreResult restore.Result,
stats verifier.Stats,
logger *slog.Logger,
) {
tableStats := make([]api.ReportTableStat, 0, len(stats.TableStats))
for _, ts := range stats.TableStats {
tableStats = append(tableStats, api.ReportTableStat{
SchemaName: ts.SchemaName,
Name: ts.Name,
RowCount: ts.RowCount,
})
}
req := api.ReportRequest{
Status: api.VerificationStatusCompleted,
PgRestoreExitCode: &restoreResult.PgRestoreExitCode,
RestoreDurationMs: &restoreResult.DurationMs,
DBSizeBytesAfterRestore: &stats.DBSizeBytes,
TableCount: &stats.TableCount,
SchemaCount: &stats.SchemaCount,
TableStats: tableStats,
}
r.sendReport(ctx, verificationID, req, logger)
}
func (r *Runner) reportFailure(
ctx context.Context,
verificationID uuid.UUID,
restoreResult *restore.Result,
failMessage string,
logger *slog.Logger,
) {
if r.shouldSkipFailedReport(ctx, verificationID, logger) {
return
}
message := failMessage
req := api.ReportRequest{
Status: api.VerificationStatusFailed,
FailMessage: &message,
}
if restoreResult != nil {
req.PgRestoreExitCode = &restoreResult.PgRestoreExitCode
req.RestoreDurationMs = &restoreResult.DurationMs
}
r.sendReport(ctx, verificationID, req, logger)
}
// reportDiskLimitExceeded posts the terminal disk-budget verdict. FailureKind
// makes the backend classify it DISK_LIMIT_EXCEEDED (terminal) instead of the
// retryable nil-exit-code path: the budget is server-computed, so retrying the
// same job against the same budget would fail identically.
func (r *Runner) reportDiskLimitExceeded(
ctx context.Context,
verificationID uuid.UUID,
failMessage string,
logger *slog.Logger,
) {
if r.shouldSkipFailedReport(ctx, verificationID, logger) {
return
}
message := failMessage
kind := api.FailureKindDiskLimitExceeded
r.sendReport(ctx, verificationID, api.ReportRequest{
Status: api.VerificationStatusFailed,
FailMessage: &message,
FailureKind: &kind,
}, logger)
}
// shouldSkipFailedReport is the abort/report-race guard (the only agent-side
// lever with a frozen FAILED path): never POST FAILED for an aborted/cancelled
// ID — a spurious FAILED would flip CANCELED→FAILED or resurrect a cancelled
// row.
func (r *Runner) shouldSkipFailedReport(
ctx context.Context, verificationID uuid.UUID, logger *slog.Logger,
) bool {
if ctx.Err() != nil || r.heartbeat.IsAborted(verificationID) {
logger.Info("skipping FAILED report: verification aborted/cancelled")
return true
}
return false
}
func (r *Runner) sendReport(
ctx context.Context,
verificationID uuid.UUID,
req api.ReportRequest,
logger *slog.Logger,
) {
err := r.api.Report(ctx, verificationID, req)
if err == nil {
logger.Info(fmt.Sprintf("report accepted: status=%s", req.Status))
return
}
switch {
case errors.Is(err, api.ErrReportGone):
logger.Info("report dropped: verification no longer owned by this agent (410)")
case errors.Is(err, api.ErrReportBudgetExhausted):
logger.Warn("report abandoned: retry budget exhausted; backend will reclaim on next heartbeat")
default:
logger.Warn("report failed", "error", err)
}
}
func pgMajorFromDatabase(db api.AssignedDatabase) (string, error) {
if db.Type != "POSTGRES_LOGICAL" {
return "", fmt.Errorf("unsupported database type %q (v1 is Postgres only)", db.Type)
}
if db.PostgresqlLogical == nil || db.PostgresqlLogical.Version == "" {
return "", errors.New("assignment missing postgresql version")
}
if !slices.Contains(supportedMajors, db.PostgresqlLogical.Version) {
return "", fmt.Errorf("unsupported postgres major %q", db.PostgresqlLogical.Version)
}
return db.PostgresqlLogical.Version, nil
}
func backupDownloadBackoff(attempt int) time.Duration {
if attempt < 1 {
attempt = 1
}
d := time.Duration(1<<attempt) * time.Second
if d > maxBackupDownloadBackoff {
return maxBackupDownloadBackoff
}
return d
}
func sleepOrDone(ctx context.Context, d time.Duration) bool {
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-ctx.Done():
return false
case <-timer.C:
return true
}
}