feat(component-failover-adoption): trigger apiserver's rejoin from the idle-period heartbeat path

xcecli_heartbeat() is XCECLI_FF_CALL-wrapped -- a deliberately
non-retrying fire-and-forget path that does its own best-effort
one-shot reconnect but never calls xcecli_invoke_rejoin() (that only
fires from the write-call retry path). Left as-is, a controller
failover with no in-flight write request would silently repair the
transport connection without ever re-joining, permanently starving
push notifications.

runHeartbeat() gains two callback hooks fired at the right points --
onFailureToSuccess on a genuine transition, onSuccessTick on every
successful tick thereafter -- wired directly to task 4.3's existing
setNeedsRejoin()/checkAndPerformRejoin() from StartHeartbeat(). No
second rejoin mechanism: checkAndPerformRejoin() is a cheap no-op when
nothing is pending, so calling it unconditionally on every successful
tick is safe and matches the requirement that retry isn't gated on the
transition edge alone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NNB4zGPCsHyDUCDJyMbiQN
This commit is contained in:
Rongsong Shen 2026-08-12 09:58:27 +00:00
parent 2a205291c0
commit ecf5eddc61
Signed by: rshen
SSH key fingerprint: SHA256:8Vf2pZ3h6rJa7EhGK+WaAQ9Mta/apZvteW6g9U/WKKk
5 changed files with 363 additions and 12 deletions

View file

@ -31,16 +31,28 @@ func TestSetHeartbeatInterval_NegativeIgnored(t *testing.T) {
}
}
// noopRejoinHooks are used by tests that only care about runHeartbeat's
// tick/cancellation mechanics, not its failure-to-success/pending-rejoin
// callback behavior (see TestRunHeartbeat_FailureToSuccessTransition_* etc.
// 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) { atomic.AddInt64(&callCount, 1) }
fn := func(_ int) int {
atomic.AddInt64(&callCount, 1)
return 0
}
onFailureToSuccess, onSuccessTick := noopRejoinHooks()
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
runHeartbeat(ctx, fn)
runHeartbeat(ctx, fn, onFailureToSuccess, onSuccessTick)
close(done)
}()
@ -58,13 +70,15 @@ func TestRunHeartbeat_CancelsOnContext(t *testing.T) {
cancel() // already cancelled
var callCount int64
fn := func(_ int) {
fn := func(_ int) int {
atomic.AddInt64(&callCount, 1)
return 0
}
onFailureToSuccess, onSuccessTick := noopRejoinHooks()
done := make(chan struct{})
go func() {
runHeartbeat(ctx, fn)
runHeartbeat(ctx, fn, onFailureToSuccess, onSuccessTick)
close(done)
}()
@ -79,3 +93,155 @@ func TestRunHeartbeat_CancelsOnContext(t *testing.T) {
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]
}
}
// ════════════════════════════════════════════════════════════════════════
// component-failover-adoption task 4.4 (Req 2 AC4/AC5) -- idle-period
// rejoin trigger. These tests exercise runHeartbeat's own tick/transition-
// tracking logic via generic injected callbacks, entirely without CGO (the
// production wiring -- reconnect.go's real setNeedsRejoin/
// checkAndPerformRejoin -- is exercised end-to-end in
// heartbeat_rejoin_test.go, //go:build cgo).
// ════════════════════════════════════════════════════════════════════════
func TestRunHeartbeat_FailureToSuccessTransition_FiresOnFailureToSuccessThenOnSuccessTick(t *testing.T) {
atomic.StoreInt32(&heartbeatIntervalSecs, 1)
// fail, fail, succeed -- the transition happens on tick 3.
fn := scriptedHeartbeatFn([]int{-1, -1, 0})
events := make(chan string, 8)
onFailureToSuccess := func() { events <- "failure_to_success" }
onSuccessTick := func() { events <- "success_tick" }
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
runHeartbeat(ctx, fn, onFailureToSuccess, onSuccessTick)
close(done)
}()
want := []string{"failure_to_success", "success_tick"}
got := make([]string, 0, len(want))
for range want {
select {
case e := <-events:
got = append(got, e)
case <-time.After(5 * time.Second):
t.Fatalf("timed out waiting for heartbeat callback events, got %v so far", got)
}
}
// Cancel immediately once the expected events are observed, before any
// further tick can fire -- keeps the assertion below exact regardless
// of scheduling slop.
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) -- "+
"onFailureToSuccess must fire exactly once, before onSuccessTick, "+
"only on the fail-then-succeed transition tick",
i, got[i], want[i], got, want)
}
}
}
func TestRunHeartbeat_EverySuccessfulTick_RetriesWhilePending(t *testing.T) {
atomic.StoreInt32(&heartbeatIntervalSecs, 1)
// fail, then succeed 3 times in a row: onFailureToSuccess must fire
// only on the first success (the transition), but onSuccessTick must
// fire on ALL THREE successes -- proving the pending-rejoin check is
// retried on every successful tick, not abandoned after the transition
// tick (Req 2 AC5: "checked every tick, not only the transition edge").
fn := scriptedHeartbeatFn([]int{-1, 0, 0, 0})
events := make(chan string, 8)
onFailureToSuccess := func() { events <- "f2s" }
onSuccessTick := func() { events <- "tick" }
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
runHeartbeat(ctx, fn, onFailureToSuccess, onSuccessTick)
close(done)
}()
want := []string{"f2s", "tick", "tick", "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)",
i, got[i], want[i], got, want)
}
}
}
func TestRunHeartbeat_AllTicksSucceed_NeverFiresOnFailureToSuccess(t *testing.T) {
atomic.StoreInt32(&heartbeatIntervalSecs, 1)
fn := scriptedHeartbeatFn([]int{0, 0, 0})
var f2sCalls int32
onFailureToSuccess := func() { atomic.AddInt32(&f2sCalls, 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, onFailureToSuccess, 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(&f2sCalls); got != 0 {
t.Errorf("onFailureToSuccess called %d times, want 0 for an all-successful "+
"run -- no failure was ever observed, so there is no transition to fire on", got)
}
if got := atomic.LoadInt32(&tickCalls); got < 3 {
t.Errorf("onSuccessTick called %d times, want at least 3", got)
}
}