cd7c2c4383
ci/woodpecker/tag/docker Pipeline was successful
## Why The mirrorlist (PR #121) always load-balances round-robin. Round-robin is oblivious to how busy each mirror is, so a slow or saturated mirror keeps getting its fair share of new requests. This adds an opt-in **least-connections** strategy that favors the mirror currently handling the fewest in-flight requests, steering new work toward idle mirrors. Round-robin stays the default, so existing remotes are unchanged. ## How - **Model**: `models.Remote` gains `mirror_strategy` (`round_robin` default/empty, or `least_conn`). `ValidateMirrorStrategy` checks the enum and requires a non-empty mirrorlist for `least_conn`. Empty behaves as `round_robin` for back-compat. - **DB**: additive `mirror_strategy TEXT NOT NULL DEFAULT 'round_robin'` column (CREATE TABLE + `ADD COLUMN IF NOT EXISTS`), wired through remoteCols/scanRemote/CreateRemote/UpdateRemote; empty normalized to `round_robin` on write. - **Engine**: a per-remote, per-pool-URL atomic in-flight gauge is incremented around each upstream call (head/fetch/checkUpstream). For a `least_conn` remote the attempt order starts with the least-loaded pool URL (ties broken by the existing round-robin rotation). Round-robin path and failover order are unchanged; single-URL pools are a no-op. - **Tests**: unit tests for least-loaded selection, round-robin default, gauge inc/dec, single-URL no-op, and validation; DB round-trip covers the new column; docker e2e adds a `least_conn` distribution test plus a real `dnf` install through a `least_conn` remote. Back-compat: unset/empty `mirror_strategy` is `round_robin`, so all existing remotes keep their current behavior. Reviewed-on: #122 Co-authored-by: unkin-agent <unkin-agent@unkin.net> Co-committed-by: unkin-agent <unkin-agent@unkin.net>
106 lines
3.7 KiB
Go
106 lines
3.7 KiB
Go
//go:build dockere2e
|
|
|
|
package e2edocker
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// TestLeastConnMultiBaseURL configures an rpm remote with mirror_strategy =
|
|
// least_conn over a two-mirror pool and drives distinct cache-miss paths through
|
|
// it, asserting every request succeeds and both mirrors serve traffic. This
|
|
// exercises the least-connections selection path (leastConnOrder + the in-flight
|
|
// gauge inc/dec around each upstream call) end-to-end through a real HTTP client.
|
|
// A precise least-loaded assertion is timing-sensitive over HTTP and is covered
|
|
// deterministically by the proxy unit test (TestLeastConnPicksLeastLoaded);
|
|
// here, with requests issued serially, in-flight counts return to zero between
|
|
// them so equal-load mirrors are spread by the round-robin tie-break.
|
|
func TestLeastConnMultiBaseURL(t *testing.T) {
|
|
name := "e2e-leastconn"
|
|
createRepo(t, fmt.Sprintf(`{
|
|
"name": %q,
|
|
"package_type": "rpm",
|
|
"repo_type": "remote",
|
|
"base_url": %q,
|
|
"mirrorlist": [%q],
|
|
"mirror_strategy": "least_conn",
|
|
"stale_on_error": false
|
|
}`, name, mockUpstreamA(), mockUpstreamB()))
|
|
defer deleteRepo(t, name)
|
|
|
|
seenA, seenB := false, false
|
|
const n = 12
|
|
for i := 0; i < n; i++ {
|
|
url := api(fmt.Sprintf("/api/v1/remote/%s/lc/%d", name, i))
|
|
resp, body := doRequest(t, http.MethodGet, url, nil, "")
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("request %d: status %d: %s", i, resp.StatusCode, body)
|
|
}
|
|
switch strings.TrimSpace(string(body)) {
|
|
case "UPSTREAM-A":
|
|
seenA = true
|
|
case "UPSTREAM-B":
|
|
seenB = true
|
|
default:
|
|
t.Fatalf("request %d: unexpected body %q", i, body)
|
|
}
|
|
}
|
|
if !seenA || !seenB {
|
|
t.Fatalf("least_conn remote did not reach both upstreams: A=%v B=%v", seenA, seenB)
|
|
}
|
|
}
|
|
|
|
// TestLeastConnDnfInstall drives a real dnf (stock rockylinux container) at a
|
|
// two-mirror rpm remote configured with mirror_strategy = least_conn whose
|
|
// primary base_url is dead: makecache + install must succeed via the live mirror.
|
|
// This proves a real package-manager client installs correctly through a
|
|
// least_conn remote and that failover semantics are unchanged under the new
|
|
// strategy. Requires the compose network exported by scripts/docker-e2e.sh.
|
|
func TestLeastConnDnfInstall(t *testing.T) {
|
|
network := os.Getenv("COMPOSE_NETWORK")
|
|
internal := os.Getenv("ARTIFACTAPI_INTERNAL")
|
|
if network == "" || internal == "" {
|
|
t.Skip("COMPOSE_NETWORK/ARTIFACTAPI_INTERNAL not set; run via scripts/docker-e2e.sh")
|
|
}
|
|
if _, err := exec.LookPath("docker"); err != nil {
|
|
t.Skip("docker not available on the test host")
|
|
}
|
|
|
|
name := "e2e-leastconn-dnf"
|
|
createRepo(t, fmt.Sprintf(`{
|
|
"name": %q,
|
|
"package_type": "rpm",
|
|
"repo_type": "remote",
|
|
"base_url": "http://mockupstream-dead:80",
|
|
"mirrorlist": [%q],
|
|
"mirror_strategy": "least_conn",
|
|
"stale_on_error": false
|
|
}`, name, mockUpstream()))
|
|
defer deleteRepo(t, name)
|
|
|
|
repoURL := strings.TrimRight(internal, "/") + "/api/v1/remote/" + name + "/rpm-mirror"
|
|
repoConf := fmt.Sprintf("[dnflc]\nname=dnflc\nbaseurl=%s\nenabled=1\ngpgcheck=0\nsslverify=0\nmetadata_expire=0\n", repoURL)
|
|
script := "set -euo pipefail; " +
|
|
"printf '%s' \"$REPO\" > /etc/yum.repos.d/dnflc.repo; " +
|
|
"dnf -y --disablerepo='*' --enablerepo=dnflc makecache; " +
|
|
"dnf -y --disablerepo='*' --enablerepo=dnflc install e2e-testpkg; " +
|
|
"rpm -q e2e-testpkg"
|
|
|
|
cmd := exec.Command("docker", "run", "--rm",
|
|
"--network", network,
|
|
"-e", "REPO="+repoConf,
|
|
"rockylinux:9", "bash", "-c", script)
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("real dnf install through a least_conn remote failed: %v\n%s", err, out)
|
|
}
|
|
if !strings.Contains(string(out), "e2e-testpkg-1.0-1") {
|
|
t.Fatalf("dnf did not install the expected package via least_conn remote; output:\n%s", out)
|
|
}
|
|
}
|