myxce/src/frontend/apiserver/backend/heartbeat_rejoin_test.go
Rongsong Shen d479b70764
fix(component-failover-adoption): Phase 6 -- close apiserver's idle-period rejoin gap (bug-1548/bug-1549)
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>
2026-08-16 03:42:03 +00:00

196 lines
7 KiB
Go

//go:build cgo
package backend
import (
"context"
"sync/atomic"
"testing"
"time"
)
// heartbeat_rejoin_test.go exercises runHeartbeat wired to reconnect.go's
// REAL setNeedsRejoin/checkAndPerformRejoin (component-failover-adoption
// task 4.4/Phase 6, boundary: ApiserverBackendBridge, Req 2 AC4/AC5) --
// proving the idle-period rejoin trigger end-to-end at real tick
// granularity, the same way clib.go's StartHeartbeat wires them in
// production (currentReconnectGen itself -- the real
// C.xcecli_get_my_endpoint(gCtx.ctx) read -- is NOT exercised here; these
// tests inject a scripted currentReconnectGen double, same as
// heartbeat_test.go, since gCtx has no live connection in a unit test).
// heartbeat_test.go covers runHeartbeat's own tick/generation-tracking
// logic via generic injected callbacks, without CGO; this file proves
// those callbacks are wired to the right production functions and that the
// composition actually clears/retains needsRejoin correctly across real
// ticks.
//
// Kept //go:build cgo since needsRejoin/setNeedsRejoin/checkAndPerformRejoin/
// performRejoinFn only exist in the CGO build (reconnect.go, task 4.3) --
// this file itself does not `import "C"` (a _test.go file can never do that
// directly, golang/go#13938), matching the reconnect_test.go/candidates_test.go
// precedent already established in this package.
//
// performRejoinFn (reconnect.go's existing test seam, task 4.3) is
// overridden here to script the actual xcecli_join outcome without a live
// controller -- same technique reconnect_test.go's own retry-on-failure
// tests use. scriptedHeartbeatFn/scriptedReconnectGen (heartbeat_test.go)
// script the heartbeat send outcomes and the reconnect-generation reads.
// This is the CGO-level counterpart to
// TestRunHeartbeat_ReconnectDetected_FiresOnGenChangeEvenWhenAllTicksSucceed
// in heartbeat_test.go, and is the actual proof of closure for bug-1548 at
// the level production code runs at: every heartbeat tick's own fn() call
// reports SUCCESS (the literal shape XCECLI_FF_CALL's silent inline
// reconnect+recover produces -- no failing tick is ever observed), yet a
// reconnect genuinely happened (the generation counter changed). The old
// failure-to-success transition detector could never set needsRejoin in
// this scenario; this test proves the replacement generation-based
// detector does. Still only proof of the DETECTOR's logic wired to the
// real production callbacks -- whether currentReconnectGen's real
// C.xcecli_get_my_endpoint read is itself reachable/reliable against a real
// XCECLI_FF_CALL reconnect is proven only by the required live E2E re-run
// of task 5.2's scenario (Phase 6, task 6.2), not by any unit test.
func TestRunHeartbeat_IdlePeriodRecovery_GenChangeAloneSetsNeedsRejoinEvenWhenAllTicksSucceed(t *testing.T) {
captureLog(t) // checkAndPerformRejoin logs via utils.Log; nil otherwise
resetNeedsRejoinForTest()
atomic.StoreInt32(&heartbeatIntervalSecs, 1)
origPerformRejoinFn := performRejoinFn
defer func() { performRejoinFn = origPerformRejoinFn }()
var joinCalls int32
joinDone := make(chan struct{}, 8)
performRejoinFn = func() int {
atomic.AddInt32(&joinCalls, 1)
joinDone <- struct{}{}
return 0 // succeeds on the first attempt
}
// Every heartbeat tick this test observes reports success -- no failure
// ever surfaces to Go, matching XCECLI_FF_CALL's silent self-heal.
fn := scriptedHeartbeatFn([]int{0, 0, 0})
// Generation changes on the second tick (pre-loop baseline=5, tick1=5
// unchanged, tick2=6 changed) -- a reconnect happened even though the
// caller never saw a failing tick.
gens := scriptedReconnectGen([]uint64{5, 5, 6, 6})
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
runHeartbeat(ctx, fn, gens, setNeedsRejoin, checkAndPerformRejoin)
close(done)
}()
select {
case <-joinDone:
case <-time.After(6 * time.Second):
t.Fatal("timed out waiting for the rejoin attempt triggered by the generation change")
}
cancel()
<-done
if got := atomic.LoadInt32(&joinCalls); got != 1 {
t.Errorf("performRejoinFn called %d times, want exactly 1", got)
}
if got := atomic.LoadInt32(&needsRejoin); got != 0 {
t.Error("expected needsRejoin to clear once the rejoin succeeds")
}
}
func TestRunHeartbeat_RejoinRetriesOnFailureUntilItSucceeds(t *testing.T) {
captureLog(t)
resetNeedsRejoinForTest()
atomic.StoreInt32(&heartbeatIntervalSecs, 1)
origPerformRejoinFn := performRejoinFn
defer func() { performRejoinFn = origPerformRejoinFn }()
var joinCalls int32
joinDone := make(chan struct{}, 8)
performRejoinFn = func() int {
n := atomic.AddInt32(&joinCalls, 1)
joinDone <- struct{}{}
if n == 1 {
return -1 // simulates -E_XCE_NOT_READY: this attempt's own retry budget exhausted
}
return 0 // succeeds on the second, separate attempt
}
fn := scriptedHeartbeatFn([]int{0, 0, 0})
// Generation changes once, on tick 1 (baseline=1, tick1=2 changed,
// stays 2 thereafter) -- one reconnect, but the resulting rejoin must
// be retried across multiple successful ticks until it succeeds (Req 2
// AC5), not abandoned after its first failed attempt.
gens := scriptedReconnectGen([]uint64{1, 2, 2, 2})
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
runHeartbeat(ctx, fn, gens, setNeedsRejoin, checkAndPerformRejoin)
close(done)
}()
for i := 0; i < 2; i++ {
select {
case <-joinDone:
case <-time.After(6 * time.Second):
t.Fatalf("timed out waiting for rejoin attempt %d", i+1)
}
}
cancel()
<-done
if got := atomic.LoadInt32(&joinCalls); got != 2 {
t.Errorf("performRejoinFn called %d times, want exactly 2 (first attempt "+
"fails, retried on the next successful heartbeat tick)", got)
}
if got := atomic.LoadInt32(&needsRejoin); got != 0 {
t.Error("expected needsRejoin to clear once the retried rejoin succeeds")
}
}
func TestRunHeartbeat_NoGenChange_NeverSetsNeedsRejoin(t *testing.T) {
resetNeedsRejoinForTest()
atomic.StoreInt32(&heartbeatIntervalSecs, 1)
origPerformRejoinFn := performRejoinFn
defer func() { performRejoinFn = origPerformRejoinFn }()
var joinCalls int32
performRejoinFn = func() int {
atomic.AddInt32(&joinCalls, 1)
return 0
}
tickEvents := make(chan struct{}, 8)
fn := func(_ int) int {
tickEvents <- struct{}{}
return 0 // always succeeds
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
runHeartbeat(ctx, fn, constantReconnectGen(9), setNeedsRejoin, checkAndPerformRejoin)
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(&needsRejoin); got != 0 {
t.Error("needsRejoin must stay clear when the reconnect generation never changes -- " +
"no reconnect ever occurred")
}
if got := atomic.LoadInt32(&joinCalls); got != 0 {
t.Errorf("performRejoinFn called %d times, want 0 (checkAndPerformRejoin "+
"must no-op when no rejoin is pending)", got)
}
}