2
0
Files
gitcaddy-server/models/actions/runner_routing.go
logikonline 12f4ea03a8
Some checks failed
Build and Release / Create Release (push) Successful in 0s
Trigger Vault Plugin Rebuild / Trigger Vault Rebuild (push) Successful in 0s
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 2m48s
Build and Release / Lint (push) Failing after 5m2s
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Has been skipped
Build and Release / Build Binaries (amd64, darwin, linux-latest) (push) Has been skipped
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Has been skipped
Build and Release / Build Binaries (arm64, darwin, linux-latest) (push) Has been skipped
Build and Release / Build Binaries (arm64, linux, linux-latest) (push) Has been skipped
Build and Release / Unit Tests (push) Successful in 5m37s
refactor: add /v3 suffix to module path for proper Go semver
Go's semantic import versioning requires v2+ modules to include the
major version in the module path. This enables using proper version
tags (v3.x.x) instead of pseudo-versions.

Updated module path: code.gitcaddy.com/server/v3
2026-01-17 17:53:59 -05:00

169 lines
4.8 KiB
Go

// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"context"
"math"
"sort"
"code.gitcaddy.com/server/v3/models/db"
"code.gitcaddy.com/server/v3/modules/log"
"code.gitcaddy.com/server/v3/modules/optional"
"code.gitcaddy.com/server/v3/modules/setting"
)
// RunnerBandwidthScore calculates a routing score for a runner
// Higher score = better for job assignment
// Score considers: bandwidth (primary), latency (secondary)
func RunnerBandwidthScore(runner *ActionRunner) float64 {
caps := runner.GetCapabilities()
if caps == nil {
return 50.0 // Default middling score if no capabilities
}
bw := caps.Bandwidth
if bw == nil || bw.DownloadMbps <= 0 {
// No bandwidth data - give a default middling score
return 50.0
}
// Base score from bandwidth (log scale to prevent huge gaps)
// 1 Mbps = 0, 10 Mbps = 33, 100 Mbps = 66, 1000 Mbps = 100
bandwidthScore := 0.0
if bw.DownloadMbps > 0 {
// Log10 scale: log10(1)=0, log10(10)=1, log10(100)=2, log10(1000)=3
logVal := math.Log10(bw.DownloadMbps)
if logVal < 0 {
logVal = 0
}
bandwidthScore = logVal * 33.3 // Scale to 0-100
}
// Latency penalty (subtract up to 20 points for high latency)
latencyPenalty := 0.0
if bw.LatencyMs > 10 {
// 0-10ms = no penalty, 10-50ms = small penalty, 50-200ms = bigger penalty
latencyPenalty = (bw.LatencyMs - 10) / 10.0
if latencyPenalty > 20 {
latencyPenalty = 20
}
}
return bandwidthScore - latencyPenalty
}
// ShouldAssignJobToRunner determines if a job should be assigned to this runner
// considering bandwidth-aware routing
// Returns: (shouldAssign bool, reason string)
func ShouldAssignJobToRunner(ctx context.Context, runner *ActionRunner, job *ActionRunJob) (bool, string) {
if !setting.Actions.BandwidthAwareRouting {
return true, "bandwidth routing disabled"
}
// Always assign if this runner is the only option
// (e.g., macos-only jobs when only mac runner available)
competingRunners := findCompetingRunners(ctx, runner, job)
if len(competingRunners) == 0 {
return true, "only matching runner"
}
// Calculate scores
myScore := RunnerBandwidthScore(runner)
// Find the best competing score
bestCompetingScore := 0.0
var bestCompetitor *ActionRunner
for _, r := range competingRunners {
score := RunnerBandwidthScore(r)
if score > bestCompetingScore {
bestCompetingScore = score
bestCompetitor = r
}
}
// If requesting runner is within threshold of best, allow assignment
// This prevents slow runners from being completely starved
threshold := setting.Actions.BandwidthScoreThreshold // default 20
if myScore >= bestCompetingScore-threshold {
return true, "within threshold of best runner"
}
// If the better runner is busy, allow this runner to take it
if bestCompetitor != nil && !isRunnerIdle(ctx, bestCompetitor) {
return true, "better runner is busy"
}
log.Debug("Runner %s (score: %.1f) deferred job to faster runner %s (score: %.1f)",
runner.Name, myScore, bestCompetitor.Name, bestCompetingScore)
return false, "faster runner available"
}
// findCompetingRunners finds other online runners that could handle this job
func findCompetingRunners(ctx context.Context, excludeRunner *ActionRunner, job *ActionRunJob) []*ActionRunner {
runners, err := db.Find[ActionRunner](ctx, FindRunnerOptions{
IsOnline: optional.Some(true),
})
if err != nil {
log.Error("Failed to find competing runners: %v", err)
return nil
}
var competing []*ActionRunner
for _, r := range runners {
// Skip the requesting runner
if r.ID == excludeRunner.ID {
continue
}
// Skip offline runners
if !r.IsOnline() {
continue
}
// Check if this runner can handle the job
if r.CanMatchLabels(job.RunsOn) {
competing = append(competing, r)
}
}
return competing
}
// isRunnerIdle checks if a runner currently has no active tasks
func isRunnerIdle(ctx context.Context, runner *ActionRunner) bool {
count, err := db.GetEngine(ctx).
Where("runner_id = ? AND status = ?", runner.ID, StatusRunning).
Count(&ActionTask{})
if err != nil {
log.Error("Failed to check if runner %s is idle: %v", runner.Name, err)
return false
}
return count == 0
}
// GetRunnersForJobByBandwidth returns runners sorted by bandwidth score (best first)
func GetRunnersForJobByBandwidth(ctx context.Context, job *ActionRunJob) []*ActionRunner {
runners, err := db.Find[ActionRunner](ctx, FindRunnerOptions{
IsOnline: optional.Some(true),
})
if err != nil {
log.Error("Failed to find runners for job: %v", err)
return nil
}
var matching []*ActionRunner
for _, r := range runners {
if r.CanMatchLabels(job.RunsOn) {
matching = append(matching, r)
}
}
// Sort by bandwidth score (highest first)
sort.Slice(matching, func(i, j int) bool {
return RunnerBandwidthScore(matching[i]) > RunnerBandwidthScore(matching[j])
})
return matching
}