refactor(ha): passive standby model — primary connects outbound to all peers

Remove fetch_config_from_primary, ha_find_and_connect_to_primary, and the
tier-1/2/3 discovery path. Standbys never initiate outbound connections to
the primary; the primary owns all inter-controller channels.

Key changes:
- ha.c: add primary heartbeat loop (ha_start_primary_heartbeat) that sends
  OPCode_nop to connected standbys every heartbeat_interval_ms; loop
  self-cancels on demotion via role check before re-arm.
- ha.c: add outbound reconnect in primary_heartbeat_fn — for peers absent
  from standby_endpoints, open fresh outbound TLS connection (on_standby_connected
  sends ctrlhello; standby_ep_ctx_t mirrors vote_ep_ctx_t layout).
- ha.c: remove ha_find_and_connect_to_primary + helpers (ha_raw_tcp_connect,
  ha_tcp_connect_hook); remove election_timeout_fn (superseded by
  raft_election_timeout_fn); remove fetch_config_from_primary call from
  ha_elect_evaluate_and_signal STANDBY branch.
- cluster.c: OPCode_requestVote denial no longer reuses inbound connection;
  late joiners are bootstrapped via primary heartbeat outbound connect.
- config_repl.c: remove connect_ssl_raw, do_one_config_fetch,
  fetch_config_from_primary (~250 LOC).
- myapp.c: remove my_tag > 0 fetch branch; all standbys wait passively.
- ha.h: remove ha_find_and_connect_to_primary declaration + macros +
  ha_tcp_connect_hook seam; add ha_start_primary_heartbeat declaration.
- meson.build: remove test_ha_connect.c (tests deleted function).
- controller_test.c: remove test_ha_connect registration.
- CLAUDE.md, src/controller/CLAUDE.md: document passive standby model,
  inbound-vs-outbound connection ownership, async I/O constraint,
  heartbeat model.
- request.h: add xce_request declaration (removes scattered local externs).
This commit is contained in:
Rongsong Shen 2026-06-01 04:17:05 +00:00
parent 842b9a838e
commit 7b39650229
11 changed files with 716 additions and 919 deletions

View file

@ -40,7 +40,6 @@ extern void test_ha_bootstrap(void);
extern void test_ha_durability(void);
extern void test_repl_majority(void);
extern void test_persist_truncate(void);
extern void test_ha_connect(void);
extern void test_ha_ctrlhello(void);
extern void test_ha_failover(void);
extern void test_ha_vote_send(void);
@ -49,6 +48,7 @@ extern void test_ha_vote_gate(void);
extern void test_ha_evaluate_committing(void);
extern void test_ha_repl_push(void);
extern void test_ha_repl_ack(void);
extern void test_ha_connection_reuse(void);
void setUp(void) { return; }
@ -158,7 +158,6 @@ int main(int argc, char* argv[])
TEST(test_ha_durability);
TEST(test_repl_majority);
TEST(test_persist_truncate);
TEST(test_ha_connect);
TEST(test_ha_ctrlhello);
TEST(test_ha_failover);
TEST(test_ha_vote_send);
@ -167,6 +166,7 @@ int main(int argc, char* argv[])
TEST(test_ha_evaluate_committing);
TEST(test_ha_repl_push);
TEST(test_ha_repl_ack);
TEST(test_ha_connection_reuse);
return UNITY_END();
}

View file

@ -1,230 +0,0 @@
/*
* test_ha_connect.c — Unity tests for ha_find_and_connect_to_primary() (task 9.2).
*
* Uses ha_tcp_connect_hook to inject mock fds; no real network connections.
* Requirements: 6.1
*/
#define _GNU_SOURCE
#include <errno.h>
#include <stdatomic.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <unity/unity.h>
#include "ha.h"
/* ── Minimal ha_config_t for tests ─────────────────────────────── */
static ha_config_t g_cfg = {
.heartbeat_interval_ms = 3000,
.heartbeat_timeout_ms = 10000,
.election_timeout_ms = 500,
.probe_connect_timeout_ms = 50,
.max_promotion_retries = 3,
.election_jitter_max_ms = 0,
.election_priority_step_ms = 500,
};
/* ── Mock connect hook state ────────────────────────────────────── */
static const char *g_last_host = NULL;
static int g_last_port = 0;
static int g_mock_fd = -1;
static int g_fail_err = 0;
static int g_call_count = 0;
static int mock_connect(const char *host, int port)
{
g_call_count++;
g_last_host = host;
g_last_port = port;
if (g_fail_err) return -g_fail_err;
return g_mock_fd;
}
static void reset_mock(int mock_fd, int fail_err)
{
g_last_host = NULL;
g_last_port = 0;
g_mock_fd = mock_fd;
g_fail_err = fail_err;
g_call_count = 0;
ha_tcp_connect_hook = mock_connect;
}
/* ── Helper: build a small peers config ────────────────────────── */
static void build_peers(xce_cluster_config_t *peers, int n,
const char *names[], const char *hosts[], int ports[])
{
memset(peers, 0, sizeof(*peers));
peers->candidates_count = n;
for (int i = 0; i < n; i++) {
strncpy(peers->candidates[i].name, names[i],
sizeof(peers->candidates[i].name) - 1);
strncpy(peers->candidates[i].hostname, hosts[i],
sizeof(peers->candidates[i].hostname) - 1);
peers->candidates[i].port = ports[i];
}
}
/* ── Test: primary_ctrl_fd initialised to -1 ───────────────────── */
static void test_primary_ctrl_fd_init_minus_one(void)
{
ha_state_t ha;
TEST_ASSERT_EQUAL_INT(0, ha_init(&ha, &g_cfg, 1));
TEST_ASSERT_EQUAL_INT(-1, ha.primary_ctrl_fd);
ha_fini(&ha);
printf("test_primary_ctrl_fd_init_minus_one: PASS\n");
}
/* ── Test: NULL peers → -EINVAL ─────────────────────────────────── */
static void test_null_peers_returns_einval(void)
{
ha_state_t ha;
ha_init(&ha, &g_cfg, 1);
int rc = ha_find_and_connect_to_primary(&ha, &g_cfg, NULL, NULL);
TEST_ASSERT_LESS_THAN_INT(0, rc);
ha_fini(&ha);
printf("test_null_peers_returns_einval: PASS\n");
}
/* ── Test: empty peers list → -EINVAL ────────────────────────────── */
static void test_empty_peers_returns_einval(void)
{
ha_state_t ha;
ha_init(&ha, &g_cfg, 1);
xce_cluster_config_t peers;
memset(&peers, 0, sizeof(peers));
peers.candidates_count = 0;
int rc = ha_find_and_connect_to_primary(&ha, &g_cfg, &peers, NULL);
TEST_ASSERT_LESS_THAN_INT(0, rc);
ha_fini(&ha);
printf("test_empty_peers_returns_einval: PASS\n");
}
/* ── Test: voted_for_name → Tier 1 connect attempted first ──────── */
static void test_tier1_voted_for_name_tried_first(void)
{
int sv[2];
TEST_ASSERT_EQUAL_INT(0, socketpair(AF_UNIX, SOCK_STREAM, 0, sv));
reset_mock(sv[0], 0);
ha_state_t ha;
ha_init(&ha, &g_cfg, 1);
strncpy(ha.voted_for_name, "ctrl-primary", sizeof(ha.voted_for_name) - 1);
const char *names[] = { "ctrl-primary", "ctrl-b" };
const char *hosts[] = { "10.0.0.1", "10.0.0.2" };
int ports[] = { 9990, 9990 };
xce_cluster_config_t peers;
build_peers(&peers, 2, names, hosts, ports);
int fd = ha_find_and_connect_to_primary(&ha, &g_cfg, &peers, NULL);
TEST_ASSERT_EQUAL_INT(sv[0], fd);
TEST_ASSERT_EQUAL_INT(sv[0], ha.primary_ctrl_fd);
TEST_ASSERT_EQUAL_STRING("10.0.0.1", g_last_host);
TEST_ASSERT_EQUAL_INT(9990, g_last_port);
TEST_ASSERT_GREATER_THAN_INT(0, g_call_count);
close(sv[1]);
ha.primary_ctrl_fd = -1;
close(sv[0]);
ha_fini(&ha);
printf("test_tier1_voted_for_name_tried_first: PASS\n");
}
/* ── Test: voted_for_name absent → Tier 2 scan ───────────────────── */
static void test_tier2_scan_when_voted_for_missing(void)
{
int sv[2];
TEST_ASSERT_EQUAL_INT(0, socketpair(AF_UNIX, SOCK_STREAM, 0, sv));
reset_mock(sv[0], 0);
ha_state_t ha;
ha_init(&ha, &g_cfg, 1);
strncpy(ha.voted_for_name, "ctrl-missing", sizeof(ha.voted_for_name) - 1);
const char *names[] = { "ctrl-a", "ctrl-b" };
const char *hosts[] = { "10.0.0.1", "10.0.0.2" };
int ports[] = { 9990, 9990 };
xce_cluster_config_t peers;
build_peers(&peers, 2, names, hosts, ports);
int fd = ha_find_and_connect_to_primary(&ha, &g_cfg, &peers, NULL);
TEST_ASSERT_EQUAL_INT(sv[0], fd);
TEST_ASSERT_EQUAL_INT(sv[0], ha.primary_ctrl_fd);
close(sv[1]);
ha.primary_ctrl_fd = -1;
close(sv[0]);
ha_fini(&ha);
printf("test_tier2_scan_when_voted_for_missing: PASS\n");
}
/* ── Test: successful connect sets primary_ctrl_fd ──────────────── */
static void test_successful_connect_sets_primary_ctrl_fd(void)
{
int sv[2];
TEST_ASSERT_EQUAL_INT(0, socketpair(AF_UNIX, SOCK_STREAM, 0, sv));
reset_mock(sv[0], 0);
ha_state_t ha;
ha_init(&ha, &g_cfg, 0);
strncpy(ha.voted_for_name, "ctrl-a", sizeof(ha.voted_for_name) - 1);
const char *names[] = { "ctrl-a" };
const char *hosts[] = { "127.0.0.1" };
int ports[] = { 9990 };
xce_cluster_config_t peers;
build_peers(&peers, 1, names, hosts, ports);
int fd = ha_find_and_connect_to_primary(&ha, &g_cfg, &peers, NULL);
TEST_ASSERT_EQUAL_INT(sv[0], fd);
TEST_ASSERT_EQUAL_INT(sv[0], ha.primary_ctrl_fd);
close(sv[1]);
ha.primary_ctrl_fd = -1;
close(sv[0]);
ha_fini(&ha);
printf("test_successful_connect_sets_primary_ctrl_fd: PASS\n");
}
/* ── Test: function symbol is resolvable ─────────────────────────── */
static void test_ha_find_and_connect_symbol_exists(void)
{
typedef int (*fn_t)(ha_state_t *, const ha_config_t *,
const xce_cluster_config_t *, cluster_t *);
fn_t fn = ha_find_and_connect_to_primary;
TEST_ASSERT_NOT_NULL((void *)fn);
printf("test_ha_find_and_connect_symbol_exists: PASS\n");
}
/* ── Suite entry point ───────────────────────────────────────────── */
void test_ha_connect(void)
{
ha_tcp_connect_hook = mock_connect;
UNITY_BEGIN();
RUN_TEST(test_primary_ctrl_fd_init_minus_one);
RUN_TEST(test_null_peers_returns_einval);
RUN_TEST(test_empty_peers_returns_einval);
RUN_TEST(test_tier1_voted_for_name_tried_first);
RUN_TEST(test_tier2_scan_when_voted_for_missing);
RUN_TEST(test_successful_connect_sets_primary_ctrl_fd);
RUN_TEST(test_ha_find_and_connect_symbol_exists);
UNITY_END();
}