Files
cloud-ip-validator/internal/db/queries_selfcheck_test.go
T
ayurishchevandClaude Sonnet 5.5 e95b5eb7d5 Retry a failed self-check on another validator; add the self-check failure ceiling
A validator that failed the self-check of an address no longer gets that address
again in the current round (ClaimNextQueued skips it); the validator itself stays
in service and takes all other addresses. The verdict fail is set when the number
of failed self-checks of an address reaches settings.self_check_max_attempts
(1..50, default 5, independent of the number of validators); max_retries and
retry_count are no longer used for self-check. If every working validator has
already failed the address, a new round starts and the exclusions lapse.

Migration 0012: ip_self_check_failures (permanent history per registry address),
ip_queue.sc_failures and sc_round_start_cycle (cycle_id is used instead of
attempt_number, which restarts when a queue row is recreated), the setting.
db.FailSelfCheck does it in one transaction; re-submission starts a new series.
API: self_check_max_attempts in GET/PUT /admin/config/orchestrator,
self_check_failed_on in /admin/ips/{ip} and /admin/registry/{ip}. Dashboard: the
field on /settings and the line "Self-check не прошёл на: ..." on the address
pages. Docs, plan and summary in docs/changes/.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 09:50:12 +03:00

191 lines
6.8 KiB
Go

package db
import (
"context"
"errors"
"testing"
"time"
"cloudipvalidator/internal/config"
)
// claimAwaitingSelfCheck claims the next address for the validator and brings
// it to awaiting_self_check, as the orchestrator does after the association.
func claimAwaitingSelfCheck(t *testing.T, ctx context.Context, d *DB, validatorID string) *IPQueueItem {
t.Helper()
item, err := d.ClaimNextQueued(ctx, validatorID, time.Minute)
if err != nil || item == nil {
t.Fatalf("claim for %s: item=%+v err=%v", validatorID, item, err)
}
if err := d.SetFIPAssociated(ctx, item.ID, "fip-"+item.IPAddress, time.Minute); err != nil {
t.Fatalf("set fip associated: %v", err)
}
return item
}
// A validator that failed the self-check of an address is not given that
// address again, takes the next one instead, and another validator takes the
// excluded one; the exclusion covers that address only.
func TestClaimSkipsAddressExcludedForValidator(t *testing.T) {
d, ctx := newTestDB(t)
if err := d.SeedQueue(ctx, []string{"1.1.1.1", "2.2.2.2"}); err != nil {
t.Fatal(err)
}
for _, v := range []string{"v1", "v2"} {
if err := d.AdminCreateValidator(ctx, v, "port-"+v); err != nil {
t.Fatal(err)
}
}
first := claimAwaitingSelfCheck(t, ctx, d, "v1")
if first.IPAddress != "1.1.1.1" {
t.Fatalf("expected 1.1.1.1 first, got %s", first.IPAddress)
}
res, err := d.FailSelfCheck(ctx, first.ID, "v1", "ip echo timeout", 5)
if err != nil {
t.Fatalf("fail self-check: %v", err)
}
if res.Failed || res.NewRound || res.Failures != 1 {
t.Fatalf("expected a plain retry after the first failure, got %+v", res)
}
back, _ := d.GetIP(ctx, first.ID)
if back.State != IPQueued || back.RetryCount != 0 {
t.Fatalf("expected queued with retry_count untouched, got state=%s retry_count=%d", back.State, back.RetryCount)
}
if v, _ := d.GetValidator(ctx, "v1"); v.State != ValidatorIdle {
t.Fatalf("expected v1 freed to idle, got %s", v.State)
}
// v1 skips 1.1.1.1 (still ahead in the queue) and takes 2.2.2.2.
second, err := d.ClaimNextQueued(ctx, "v1", time.Minute)
if err != nil || second == nil || second.IPAddress != "2.2.2.2" {
t.Fatalf("expected v1 to take 2.2.2.2, got %+v err=%v", second, err)
}
// v2 takes the excluded address.
other, err := d.ClaimNextQueued(ctx, "v2", time.Minute)
if err != nil || other == nil || other.IPAddress != "1.1.1.1" {
t.Fatalf("expected v2 to take 1.1.1.1, got %+v err=%v", other, err)
}
}
// When every working validator has failed the address in the round, a new
// round starts and the exclusions lapse; an unreachable validator does not
// count as working.
func TestSelfCheckNewRoundLiftsExclusions(t *testing.T) {
d, ctx := newTestDB(t)
if err := d.SeedQueue(ctx, []string{"1.1.1.1"}); err != nil {
t.Fatal(err)
}
for _, v := range []string{"v1", "v2"} {
if err := d.AdminCreateValidator(ctx, v, "port-"+v); err != nil {
t.Fatal(err)
}
}
if _, err := d.Exec(`UPDATE validators SET state=? WHERE validator_id='v2'`, ValidatorUnreachable); err != nil {
t.Fatal(err)
}
item := claimAwaitingSelfCheck(t, ctx, d, "v1")
res, err := d.FailSelfCheck(ctx, item.ID, "v1", "x", 5)
if err != nil {
t.Fatal(err)
}
if !res.NewRound {
t.Fatalf("expected a new round (the only working validator failed it), got %+v", res)
}
again, err := d.ClaimNextQueued(ctx, "v1", time.Minute)
if err != nil || again == nil || again.IPAddress != "1.1.1.1" {
t.Fatalf("expected v1 to get the address again in the new round, got %+v err=%v", again, err)
}
}
// Reaching the ceiling gives the verdict fail; a re-submission starts a new
// series (counter and exclusions) but keeps the failure history.
func TestSelfCheckCeilingAndResubmit(t *testing.T) {
d, ctx := newTestDB(t)
if err := d.SeedQueue(ctx, []string{"1.1.1.1"}); err != nil {
t.Fatal(err)
}
for _, v := range []string{"v1", "v2"} {
if err := d.AdminCreateValidator(ctx, v, "port-"+v); err != nil {
t.Fatal(err)
}
}
item := claimAwaitingSelfCheck(t, ctx, d, "v1")
if res, err := d.FailSelfCheck(ctx, item.ID, "v1", "x", 2); err != nil || res.Failed {
t.Fatalf("below the ceiling: res=%+v err=%v", res, err)
}
item = claimAwaitingSelfCheck(t, ctx, d, "v2")
res, err := d.FailSelfCheck(ctx, item.ID, "v2", "y", 2)
if err != nil {
t.Fatal(err)
}
if !res.Failed || res.Failures != 2 || len(res.Validators) != 2 || res.Validators[0] != "v1" || res.Validators[1] != "v2" {
t.Fatalf("expected fail at the ceiling on v1, v2, got %+v", res)
}
ip, _ := d.GetIP(ctx, item.ID)
if ip.State != IPFailed || ip.OverallResult != ResultFail {
t.Fatalf("expected failed/fail, got %s/%s", ip.State, ip.OverallResult)
}
if _, err := d.SubmitIPs(ctx, []string{"1.1.1.1"}); err != nil {
t.Fatal(err)
}
var failures int
if err := d.QueryRow(`SELECT sc_failures FROM ip_queue WHERE id=?`, item.ID).Scan(&failures); err != nil || failures != 0 {
t.Fatalf("expected the series reset to 0, got %d err=%v", failures, err)
}
if got, err := d.ListSelfCheckFailedOn(ctx, ip.RegistryID, 0); err != nil || len(got) != 2 || got[0] != "v1" || got[1] != "v2" {
t.Fatalf("expected the history kept (v1, v2), got %v err=%v", got, err)
}
// v1 failed it before the re-submission, but is no longer excluded.
if again, err := d.ClaimNextQueued(ctx, "v1", time.Minute); err != nil || again == nil {
t.Fatalf("expected v1 to claim the re-submitted address, got %+v err=%v", again, err)
}
}
// A late report for an address the validator does not hold is refused.
func TestFailSelfCheckRequiresAwaitingSelfCheckOnValidator(t *testing.T) {
d, ctx := newTestDB(t)
if err := d.SeedQueue(ctx, []string{"1.1.1.1"}); err != nil {
t.Fatal(err)
}
for _, v := range []string{"v1", "v2"} {
if err := d.AdminCreateValidator(ctx, v, "port-"+v); err != nil {
t.Fatal(err)
}
}
item := claimAwaitingSelfCheck(t, ctx, d, "v1")
if _, err := d.FailSelfCheck(ctx, item.ID, "v2", "late", 5); !errors.Is(err, ErrInvalidState) {
t.Fatalf("expected ErrInvalidState for another validator, got %v", err)
}
cur, _ := d.GetIP(ctx, item.ID)
if got, _ := d.ListSelfCheckFailedOn(ctx, cur.RegistryID, 0); len(got) != 0 {
t.Fatalf("a refused report must leave no history, got %v", got)
}
}
func TestSelfCheckMaxAttemptsSetting(t *testing.T) {
d, ctx := newTestDB(t)
if err := d.BootstrapFromConfig(ctx, &config.ControlAPI{}); err != nil {
t.Fatal(err)
}
if s, err := d.GetSettings(ctx); err != nil || s.SelfCheckMaxAttempts != 5 {
t.Fatalf("expected default 5, got %+v err=%v", s, err)
}
for _, bad := range []int{0, -1, 51} {
if err := d.SetSelfCheckMaxAttempts(ctx, bad); !errors.Is(err, ErrValidation) {
t.Fatalf("expected ErrValidation for %d, got %v", bad, err)
}
}
for _, ok := range []int{1, 50} {
if err := d.SetSelfCheckMaxAttempts(ctx, ok); err != nil {
t.Fatalf("set %d: %v", ok, err)
}
if s, _ := d.GetSettings(ctx); s.SelfCheckMaxAttempts != ok {
t.Fatalf("expected %d, got %d", ok, s.SelfCheckMaxAttempts)
}
}
}