XCECLI_FF_CALL (backing xcecli_heartbeat) can silently rotate+reconnect the transport entirely inside a single call and still return success, so heartbeat.go's failure-to-success transition detector could never observe a reconnect that completes within one heartbeat tick -- the common case, since elections finish in single-digit seconds against a default 30s interval. apiserver's application-level rejoin never fired on a real idle-period failover, and job pushes silently stopped reaching it. Adds a monotonic reconnect_gen counter to endpoint_t (netio.h), bumped by xce_ctx_reconnect() (xcectx.c) on every successful reconnect regardless of trigger path. heartbeat.go now compares this generation every tick instead of tracking success/failure transitions. Re-validated live against task 5.2's exact scenario (apiserver alone, zero write traffic): rejoin fired 22s after a primary kill, 12s after election. Reopens and closes out component-failover-adoption's own NO-GO -> GO validation cycle from 2026-08-15. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
285 lines
9.9 KiB
Go
285 lines
9.9 KiB
Go
package backend
|
|
|
|
import (
|
|
"context"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestSetHeartbeatInterval_ValidValue(t *testing.T) {
|
|
atomic.StoreInt32(&heartbeatIntervalSecs, 30)
|
|
SetHeartbeatInterval(60)
|
|
if got := atomic.LoadInt32(&heartbeatIntervalSecs); got != 60 {
|
|
t.Errorf("heartbeatIntervalSecs = %d, want 60", got)
|
|
}
|
|
}
|
|
|
|
func TestSetHeartbeatInterval_ZeroIgnored(t *testing.T) {
|
|
atomic.StoreInt32(&heartbeatIntervalSecs, 30)
|
|
SetHeartbeatInterval(0)
|
|
if got := atomic.LoadInt32(&heartbeatIntervalSecs); got != 30 {
|
|
t.Errorf("heartbeatIntervalSecs = %d, want 30 (zero should be ignored)", got)
|
|
}
|
|
}
|
|
|
|
func TestSetHeartbeatInterval_NegativeIgnored(t *testing.T) {
|
|
atomic.StoreInt32(&heartbeatIntervalSecs, 30)
|
|
SetHeartbeatInterval(-1)
|
|
if got := atomic.LoadInt32(&heartbeatIntervalSecs); got != 30 {
|
|
t.Errorf("heartbeatIntervalSecs = %d, want 30 (negative should be ignored)", got)
|
|
}
|
|
}
|
|
|
|
// constantReconnectGen returns a currentReconnectGen double that never
|
|
// changes value -- used by tests that only care about runHeartbeat's
|
|
// tick/cancellation mechanics or fn's own success/failure classification,
|
|
// not its reconnect-detection behavior (see the Phase 6 tests below for
|
|
// that coverage).
|
|
func constantReconnectGen(gen uint64) func() uint64 {
|
|
return func() uint64 { return gen }
|
|
}
|
|
|
|
// noopRejoinHooks are used by tests that only care about runHeartbeat's
|
|
// tick/cancellation mechanics, not its reconnect-detected/pending-rejoin
|
|
// callback behavior (see the Phase 6 tests below for that coverage).
|
|
func noopRejoinHooks() (func(), func()) {
|
|
return func() {}, func() {}
|
|
}
|
|
|
|
func TestRunHeartbeat_CallsFunction(t *testing.T) {
|
|
atomic.StoreInt32(&heartbeatIntervalSecs, 1)
|
|
|
|
var callCount int64
|
|
fn := func(_ int) int {
|
|
atomic.AddInt64(&callCount, 1)
|
|
return 0
|
|
}
|
|
onReconnectDetected, onSuccessTick := noopRejoinHooks()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan struct{})
|
|
go func() {
|
|
runHeartbeat(ctx, fn, constantReconnectGen(0), onReconnectDetected, onSuccessTick)
|
|
close(done)
|
|
}()
|
|
|
|
time.Sleep(1500 * time.Millisecond)
|
|
cancel()
|
|
<-done // wait for goroutine to exit before the test returns
|
|
|
|
if got := atomic.LoadInt64(&callCount); got < 1 {
|
|
t.Errorf("fn was called %d times, want at least 1", got)
|
|
}
|
|
}
|
|
|
|
func TestRunHeartbeat_CancelsOnContext(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel() // already cancelled
|
|
|
|
var callCount int64
|
|
fn := func(_ int) int {
|
|
atomic.AddInt64(&callCount, 1)
|
|
return 0
|
|
}
|
|
onReconnectDetected, onSuccessTick := noopRejoinHooks()
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
runHeartbeat(ctx, fn, constantReconnectGen(0), onReconnectDetected, onSuccessTick)
|
|
close(done)
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
// returned quickly as expected
|
|
case <-time.After(100 * time.Millisecond):
|
|
t.Error("runHeartbeat did not return promptly after context cancellation")
|
|
}
|
|
|
|
if got := atomic.LoadInt64(&callCount); got != 0 {
|
|
t.Errorf("fn was called %d times, want 0 for already-cancelled context", got)
|
|
}
|
|
}
|
|
|
|
// scriptedHeartbeatFn returns a runHeartbeat fn double that yields
|
|
// results[0], results[1], ... on successive calls, clamping to the last
|
|
// entry for any calls beyond len(results) (defensive against a stray extra
|
|
// tick firing before a test's cancel() takes effect -- see the tests below,
|
|
// which all stop reading/cancel as soon as the expected callback events are
|
|
// observed rather than relying on a fixed sleep window).
|
|
func scriptedHeartbeatFn(results []int) func(int) int {
|
|
var idx int32
|
|
return func(_ int) int {
|
|
i := atomic.AddInt32(&idx, 1) - 1
|
|
if int(i) >= len(results) {
|
|
i = int32(len(results) - 1)
|
|
}
|
|
return results[i]
|
|
}
|
|
}
|
|
|
|
// scriptedReconnectGen returns a currentReconnectGen double that yields
|
|
// gens[0], gens[1], ... on successive calls (one call per tick -- runHeartbeat
|
|
// reads it exactly once per tick, after fn), clamping to the last entry for
|
|
// any calls beyond len(gens). The very first call (the pre-loop baseline
|
|
// read in runHeartbeat, before any tick) is NOT scripted from this slice --
|
|
// callers should include their intended pre-loop baseline as gens[0] and
|
|
// note the first in-loop read is gens[1] onward matching a real
|
|
// endpoint_t.reconnect_gen starting at a fixed value and changing on later
|
|
// reconnects.
|
|
func scriptedReconnectGen(gens []uint64) func() uint64 {
|
|
var idx int32
|
|
return func() uint64 {
|
|
i := atomic.AddInt32(&idx, 1) - 1
|
|
if int(i) >= len(gens) {
|
|
i = int32(len(gens) - 1)
|
|
}
|
|
return gens[i]
|
|
}
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════
|
|
// component-failover-adoption Phase 6 (bug-1548) -- reconnect-generation-
|
|
// based idle-period rejoin trigger, replacing task 4.4's original failure-
|
|
// to-success transition detector (which structurally could not observe a
|
|
// reconnect that completed silently inside a single XCECLI_FF_CALL). These
|
|
// tests exercise runHeartbeat's own tick/generation-tracking logic via
|
|
// generic injected callbacks, entirely without CGO (the production wiring
|
|
// -- reconnect.go's real setNeedsRejoin/checkAndPerformRejoin plus clib.go's
|
|
// real currentReconnectGen reading C.xcecli_get_my_endpoint -- is exercised
|
|
// end-to-end in heartbeat_rejoin_test.go, //go:build cgo).
|
|
// ════════════════════════════════════════════════════════════════════════
|
|
|
|
// This is the literal shape of bug-1548: every heartbeat tick the Go loop
|
|
// observes reports SUCCESS (fn never returns a failure -- XCECLI_FF_CALL's
|
|
// silent inline reconnect+recover means the caller's own return code never
|
|
// surfaces one), yet a reconnect genuinely happened (the generation counter
|
|
// changed). The old failure-to-success transition detector could never fire
|
|
// here since it requires an observed failing tick first. This test is the
|
|
// actual proof the fix closes the gap the old detector's own green test
|
|
// suite could not catch -- it is still, deliberately, only proof of the
|
|
// DETECTOR's logic: whether the generation counter is reachable/reliable
|
|
// against the real XCECLI_FF_CALL path is proven only by a live E2E re-run
|
|
// (component-failover-adoption Phase 6, task 6.2), not by this test.
|
|
func TestRunHeartbeat_ReconnectDetected_FiresOnGenChangeEvenWhenAllTicksSucceed(t *testing.T) {
|
|
atomic.StoreInt32(&heartbeatIntervalSecs, 1)
|
|
|
|
fn := scriptedHeartbeatFn([]int{0, 0, 0}) // every tick succeeds
|
|
gens := scriptedReconnectGen([]uint64{5, 5, 6, 6})
|
|
|
|
events := make(chan string, 8)
|
|
onReconnectDetected := func() { events <- "reconnect_detected" }
|
|
onSuccessTick := func() { events <- "tick" }
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan struct{})
|
|
go func() {
|
|
runHeartbeat(ctx, fn, gens, onReconnectDetected, onSuccessTick)
|
|
close(done)
|
|
}()
|
|
|
|
// tick 1: gen 5->5 (no change), tick succeeds -> "tick"
|
|
// tick 2: gen 5->6 (changed) -> "reconnect_detected", then tick succeeds -> "tick"
|
|
want := []string{"tick", "reconnect_detected", "tick"}
|
|
got := make([]string, 0, len(want))
|
|
for range want {
|
|
select {
|
|
case e := <-events:
|
|
got = append(got, e)
|
|
case <-time.After(6 * time.Second):
|
|
t.Fatalf("timed out waiting for heartbeat callback events, got %v so far", got)
|
|
}
|
|
}
|
|
cancel()
|
|
<-done
|
|
|
|
for i := range want {
|
|
if got[i] != want[i] {
|
|
t.Fatalf("callback event %d = %q, want %q (full sequence got=%v want=%v) -- "+
|
|
"onReconnectDetected must fire on a reconnect_gen change even though "+
|
|
"every tick's own fn() call reported success",
|
|
i, got[i], want[i], got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A reconnect can complete on a tick whose own send then fails for an
|
|
// unrelated reason -- the generation check must not be gated on tick
|
|
// success, unlike onSuccessTick.
|
|
func TestRunHeartbeat_GenChangeOnFailedTick_StillFiresOnReconnectDetected(t *testing.T) {
|
|
atomic.StoreInt32(&heartbeatIntervalSecs, 1)
|
|
|
|
fn := scriptedHeartbeatFn([]int{-1}) // the one tick this test observes fails
|
|
gens := scriptedReconnectGen([]uint64{1, 2})
|
|
|
|
events := make(chan string, 8)
|
|
onReconnectDetected := func() { events <- "reconnect_detected" }
|
|
onSuccessTick := func() { events <- "tick" }
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan struct{})
|
|
go func() {
|
|
runHeartbeat(ctx, fn, gens, onReconnectDetected, onSuccessTick)
|
|
close(done)
|
|
}()
|
|
|
|
select {
|
|
case e := <-events:
|
|
if e != "reconnect_detected" {
|
|
t.Fatalf("first event = %q, want %q -- a gen change must fire "+
|
|
"onReconnectDetected even on a tick whose own fn() call failed", e, "reconnect_detected")
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("timed out waiting for onReconnectDetected")
|
|
}
|
|
cancel()
|
|
<-done
|
|
|
|
select {
|
|
case e := <-events:
|
|
t.Fatalf("unexpected extra event %q -- onSuccessTick must not fire on a failed tick", e)
|
|
default:
|
|
}
|
|
}
|
|
|
|
func TestRunHeartbeat_NoGenChange_NeverFiresOnReconnectDetected(t *testing.T) {
|
|
atomic.StoreInt32(&heartbeatIntervalSecs, 1)
|
|
|
|
fn := scriptedHeartbeatFn([]int{0, 0, 0})
|
|
|
|
var reconnectCalls int32
|
|
onReconnectDetected := func() { atomic.AddInt32(&reconnectCalls, 1) }
|
|
|
|
var tickCalls int32
|
|
tickEvents := make(chan struct{}, 8)
|
|
onSuccessTick := func() {
|
|
atomic.AddInt32(&tickCalls, 1)
|
|
tickEvents <- struct{}{}
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan struct{})
|
|
go func() {
|
|
runHeartbeat(ctx, fn, constantReconnectGen(42), onReconnectDetected, onSuccessTick)
|
|
close(done)
|
|
}()
|
|
|
|
for i := 0; i < 3; i++ {
|
|
select {
|
|
case <-tickEvents:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatalf("timed out waiting for tick %d", i+1)
|
|
}
|
|
}
|
|
cancel()
|
|
<-done
|
|
|
|
if got := atomic.LoadInt32(&reconnectCalls); got != 0 {
|
|
t.Errorf("onReconnectDetected called %d times, want 0 -- the generation "+
|
|
"never changed, so no reconnect ever happened", got)
|
|
}
|
|
if got := atomic.LoadInt32(&tickCalls); got < 3 {
|
|
t.Errorf("onSuccessTick called %d times, want at least 3", got)
|
|
}
|
|
}
|