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

@ -37,9 +37,6 @@
#include "xce/cluster_def.h"
#include "wal_types.h" /* wal_entry_hdr_t, WAL_* constants */
/* xce_request: defined in request.c, not yet in any public header */
extern int xce_request(endpoint_t *ep, xce_request_t *req);
/* SSL context for xce_probe_config_tag.
* Stored as void* to avoid SSL_CTX type at this point in the translation unit. */
static void *s_probe_ssl_ctx = NULL;
@ -678,320 +675,6 @@ int handle_config_request(cluster_t *c, endpoint_t *ep, xce_request_t *req)
return rc2;
}
/* ── fetch_config_from_primary helpers ─────────────────────── */
/*
* connect_ssl_raw: open a non-blocking TCP connection to host:port, then
* perform a TLS handshake using ssl_ctx.
*
* The poll loop checks ha_is_primary(ha) between iterations: if this node
* is promoted to primary while waiting, the function returns -ECANCELED so
* the caller can switch to the primary path immediately.
*
* On success: *out_ssl and *out_sock are set; caller owns them and must call
* SSL_shutdown(ssl) + SSL_free(ssl) + close(sock).
* On failure: nothing is left open; returns negative errno.
*/
static int connect_ssl_raw(SSL_CTX *ssl_ctx, const char *host, uint32_t port,
int poll_timeout_ms, const ha_state_t *ha,
SSL **out_ssl, int *out_sock)
{
struct addrinfo hints, *res = NULL;
memset(&hints, 0, sizeof(hints));
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
char port_str[16];
snprintf(port_str, sizeof(port_str), "%u", (unsigned)port);
if (getaddrinfo(host, port_str, &hints, &res) != 0)
return -ENONET;
int sock = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
if (sock < 0) {
int e = errno;
freeaddrinfo(res);
return -e;
}
/* Non-blocking connect */
int flags = fcntl(sock, F_GETFL, 0);
fcntl(sock, F_SETFL, flags | O_NONBLOCK);
connect(sock, res->ai_addr, res->ai_addrlen);
freeaddrinfo(res);
res = NULL;
/* Poll loop: check ha_is_primary between iterations */
while (!ha_is_primary(ha)) {
struct pollfd pfd = { .fd = sock, .events = POLLOUT | POLLERR | POLLHUP };
int pr = poll(&pfd, 1, poll_timeout_ms);
if (pr < 0 && errno == EINTR)
continue;
if (pr == 0) {
close(sock);
return -ETIMEDOUT;
}
if (pfd.revents & (POLLERR | POLLHUP)) {
close(sock);
return -ECONNREFUSED;
}
if (pfd.revents & POLLOUT)
break;
}
if (ha_is_primary(ha)) {
close(sock);
return -ECANCELED;
}
/* Verify connect succeeded */
{
int err = 0;
socklen_t el = sizeof(err);
getsockopt(sock, SOL_SOCKET, SO_ERROR, &err, &el);
if (err != 0) {
close(sock);
return -err;
}
}
/* Restore blocking for SSL handshake */
fcntl(sock, F_SETFL, flags & ~O_NONBLOCK);
SSL *ssl = SSL_new(ssl_ctx);
if (!ssl) {
close(sock);
return -ENOMEM;
}
SSL_set_fd(ssl, sock);
int sr;
do {
sr = SSL_connect(ssl);
if (sr <= 0) {
int e = SSL_get_error(ssl, sr);
if (e != SSL_ERROR_WANT_READ && e != SSL_ERROR_WANT_WRITE)
break;
}
} while (sr <= 0 && !ha_is_primary(ha));
if (sr <= 0 || ha_is_primary(ha)) {
SSL_free(ssl);
close(sock);
return -ECONNRESET;
}
*out_ssl = ssl;
*out_sock = sock;
return 0;
}
/*
* do_one_config_fetch: attempt one network fetch of the ClusterConfig from
* the primary controller over a standalone SSL socket.
*
* Steps:
* 1. Open non-blocking TCP + SSL handshake via connect_ssl_raw()
* 2. Send OP_replConfig request with our current version tag
* 3. Decode the reply
* 4. If up_to_date: nothing to save — return 0
* 5. Verify SHA-256 integrity of received bytes
* 6. Save to disk via conf_save_config()
* 7. Reload cluster_config via load_primary_config()
* 8. Log success and clean up
*
* Returns 0 on success (config up-to-date or freshly saved+loaded).
* Returns -ECANCELED if promoted to primary during the attempt (caller should
* switch to primary path). Returns other negative errno on transient failure
* (caller will retry with backoff).
*/
static int do_one_config_fetch(cluster_t *c, const char *host, uint32_t port,
uint64_t our_tag)
{
SSL *ssl = NULL;
int sock = -1;
/* 1. Connect + SSL handshake — use client_ssl_ctx (TLS_client_method) for
* outgoing connections; ssl_ctx is the server context (TLS_server_method). */
SSL_CTX *out_ctx = c->ha.client_ssl_ctx
? (SSL_CTX *)c->ha.client_ssl_ctx
: (SSL_CTX *)c->ha.ssl_ctx;
int rc = connect_ssl_raw(
out_ctx, host, port,
(int)c->ha.config.probe_connect_timeout_ms,
&c->ha, &ssl, &sock);
if (rc != 0)
return rc;
/* 2. Send OP_replConfig request */
rc = xce_replconfig_req_ssl(ssl, our_tag);
if (rc != 0) {
SSL_shutdown(ssl);
SSL_free(ssl);
close(sock);
return rc;
}
/* 3. Decode reply */
int up_to_date = 0;
uint64_t new_tag = 0;
uint8_t *sha256 = NULL;
uint8_t *config_bytes = NULL;
size_t config_len = 0;
rc = xce_replconfig_reply_ssl_decode(ssl, &up_to_date, &new_tag,
&sha256, &config_bytes, &config_len);
if (rc != 0) {
SSL_shutdown(ssl);
SSL_free(ssl);
close(sock);
return rc;
}
/* 4. Up-to-date fast path: our tag matches primary — nothing to save */
if (up_to_date) {
SSL_shutdown(ssl);
SSL_free(ssl);
close(sock);
return 0;
}
/* 5. Verify SHA-256 integrity */
uint8_t computed[32];
SHA256(config_bytes, config_len, computed);
if (memcmp(computed, sha256, 32) != 0) {
ERROR("HA: config fetch SHA-256 mismatch from primary — retrying");
free(sha256);
free(config_bytes);
SSL_shutdown(ssl);
SSL_free(ssl);
close(sock);
return -EBADMSG;
}
/* 6. Save to disk atomically */
rc = conf_save_config(c->myconf, config_bytes, config_len, sha256);
free(sha256);
free(config_bytes);
sha256 = NULL;
config_bytes = NULL;
if (rc != 0) {
SSL_shutdown(ssl);
SSL_free(ssl);
close(sock);
return rc;
}
/* 7. Reload cluster_config from the newly saved file */
rc = load_primary_config(c);
if (rc != 0) {
SSL_shutdown(ssl);
SSL_free(ssl);
close(sock);
return rc;
}
/* 8. Log success */
INFO("HA: config received from primary (tag=%016" PRIx64 ")",
c->myconf->config_version_tag);
/* Clean up SSL and socket */
SSL_shutdown(ssl);
SSL_free(ssl);
close(sock);
return 0;
}
/* ── fetch_config_from_primary ──────────────────── */
int fetch_config_from_primary(cluster_t *c)
{
const xce_cluster_config_t *pcfg = &c->myconf->peers_cfg;
/* Build conf dir path: <workdir>/<name> */
char conf_dir[4096];
snprintf(conf_dir, sizeof(conf_dir), "%s/%s",
c->myconf->workdir, c->myconf->name);
uint64_t saved_tag = conf_get_saved_tag(conf_dir);
if (pcfg->candidates_count == 0) {
/* Single-node mode — no primary to contact; load from saved file */
goto use_saved;
}
{
/* Primary is candidates[0] (highest priority = lowest index) */
const char *primary_host = pcfg->candidates[0].hostname;
uint32_t primary_port = pcfg->candidates[0].port;
INFO("HA: requesting config from primary %s:%u (my_tag=%016" PRIx64 ")",
primary_host, primary_port, saved_tag);
uint32_t delay_ms = 500;
const uint32_t max_delay_ms = 5000;
const int max_attempts = 60; /* up to 5min: primary may still be initializing */
for (int attempt = 0; attempt < max_attempts; attempt++) {
/* If promoted during retry, switch to primary path */
if (ha_is_primary(&c->ha)) {
INFO("HA: promoted to primary during config fetch"
" — switching to primary path");
return load_primary_config(c);
}
int rc = do_one_config_fetch(c, primary_host, primary_port,
saved_tag);
if (rc == 0) {
INFO("HA: config received from primary"
" (tag=%016" PRIx64 ")",
c->myconf->config_version_tag);
/* Config loaded — complete standby bootstrap:
* 1. cluster_init_phase2 to set up data structures with config
* 2. ha_find_and_connect_to_primary to establish the persistent
* control channel needed for RDHUP failover detection */
#ifndef XCE_TESTING
{
extern int cluster_init_phase2(cluster_t *c);
int p2rc = cluster_init_phase2(c);
if (p2rc == 0) {
ha_find_and_connect_to_primary(&c->ha, &c->ha.config,
&c->myconf->peers_cfg, c);
}
}
#endif
return 0;
}
if (attempt < max_attempts - 1) {
WARN("HA: config fetch attempt %d/%d failed (rc=%d)"
" — retrying in %ums",
attempt + 1, max_attempts, rc, delay_ms);
usleep((useconds_t)delay_ms * 1000);
delay_ms = delay_ms * 2 < max_delay_ms
? delay_ms * 2 : max_delay_ms;
}
}
}
use_saved:
{
char fname[256] = {0};
int rc = conf_read_current_filename(conf_dir, fname, sizeof(fname));
if (rc != 0) {
ERROR("Standby (priority=%d) could not fetch config from primary"
" and has no saved config at '%s/conf/current'"
" — start the primary first or provision config manually",
c->ha.my_priority, conf_dir);
return -ENOENT;
}
WARN("HA: using saved config (tag=%016" PRIx64 ")"
" — config may be stale", saved_tag);
/* load_primary_config resolves conf/current → versioned file,
* decodes Cap'n Proto, computes SHA-256, and checks staleness.
* It is reused here since the saved-config load path is identical. */
return load_primary_config(c);
}
}
/* ── send_upgrade_error ─────────────────────────── */
static void send_upgrade_error(endpoint_t *ep, xce_request_t *req,
@ -1399,43 +1082,58 @@ int xce_probe_config_tag(const char *host, uint16_t port, uint64_t *tag_out)
int sock = -1;
int flags_saved = 0;
/* ── Step 1: SSL connect via connect_ssl_raw (same path as do_one_config_fetch) ── */
/* ── Step 1: TCP connect (+ optional SSL handshake) ── */
{
/* s_ssl_ctx may be set externally; if NULL (unit test), fall back to plain TCP. */
SSL_CTX *ssl_ctx = s_probe_ssl_ctx;
struct addrinfo hints, *res = NULL;
memset(&hints, 0, sizeof(hints));
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
char port_str[16];
snprintf(port_str, sizeof(port_str), "%u", (unsigned)port);
if (getaddrinfo(host, port_str, &hints, &res) != 0)
return -EHOSTUNREACH;
sock = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
if (sock < 0) { int e = errno; freeaddrinfo(res); return -e; }
flags_saved = fcntl(sock, F_GETFL, 0);
fcntl(sock, F_SETFL, flags_saved | O_NONBLOCK);
connect(sock, res->ai_addr, res->ai_addrlen);
freeaddrinfo(res);
struct pollfd pfd = { .fd = sock, .events = POLLOUT | POLLERR | POLLHUP };
int pr;
do { pr = poll(&pfd, 1, 500); } while (pr < 0 && errno == EINTR);
if (pr == 0) { close(sock); return -ETIMEDOUT; }
if (pr < 0 || (pfd.revents & (POLLERR | POLLHUP))) { close(sock); return -ECONNREFUSED; }
int err = 0; socklen_t el = sizeof(err);
getsockopt(sock, SOL_SOCKET, SO_ERROR, &err, &el);
if (err) { close(sock); return -err; }
fcntl(sock, F_SETFL, flags_saved & ~O_NONBLOCK);
/* Optional SSL handshake — only when s_probe_ssl_ctx is set */
SSL_CTX *ssl_ctx = (SSL_CTX *)s_probe_ssl_ctx;
if (ssl_ctx) {
int rc = connect_ssl_raw(ssl_ctx, host, port, 500, NULL, &ssl, &sock);
if (rc != 0)
return rc;
} else {
struct addrinfo hints, *res = NULL;
memset(&hints, 0, sizeof(hints));
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
char port_str[16];
snprintf(port_str, sizeof(port_str), "%u", (unsigned)port);
if (getaddrinfo(host, port_str, &hints, &res) != 0)
return -EHOSTUNREACH;
sock = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
if (sock < 0) { int e = errno; freeaddrinfo(res); return -e; }
flags_saved = fcntl(sock, F_GETFL, 0);
fcntl(sock, F_SETFL, flags_saved | O_NONBLOCK);
connect(sock, res->ai_addr, res->ai_addrlen);
freeaddrinfo(res);
struct pollfd pfd = { .fd = sock, .events = POLLOUT | POLLERR | POLLHUP };
int pr;
do { pr = poll(&pfd, 1, 500); } while (pr < 0 && errno == EINTR);
if (pr == 0) { close(sock); return -ETIMEDOUT; }
if (pr < 0 || (pfd.revents & (POLLERR | POLLHUP))) { close(sock); return -ECONNREFUSED; }
int err = 0; socklen_t el = sizeof(err);
getsockopt(sock, SOL_SOCKET, SO_ERROR, &err, &el);
if (err) { close(sock); return -err; }
fcntl(sock, F_SETFL, flags_saved & ~O_NONBLOCK);
ssl = SSL_new(ssl_ctx);
if (!ssl) { close(sock); return -ENOMEM; }
SSL_set_fd(ssl, sock);
int sr;
do {
sr = SSL_connect(ssl);
if (sr <= 0) {
int ssl_err = SSL_get_error(ssl, sr);
if (ssl_err != SSL_ERROR_WANT_READ &&
ssl_err != SSL_ERROR_WANT_WRITE)
break;
}
} while (sr <= 0);
if (sr <= 0) {
SSL_free(ssl);
ssl = NULL;
close(sock);
return -ECONNREFUSED;
}
}
}
@ -1472,11 +1170,18 @@ int xce_probe_config_tag(const char *host, uint16_t port, uint64_t *tag_out)
/* Wire framing: 8-byte size prefix then capnp payload (matches ha.c pattern) */
uint64_t hdr = (uint64_t)blen;
ssize_t nsent;
nsent = (ssize_t)write(sock, &hdr, sizeof(hdr));
if (nsent == (ssize_t)sizeof(hdr))
nsent = (ssize_t)write(sock, buf, (size_t)blen);
if (ssl) {
nsent = (ssize_t)SSL_write(ssl, &hdr, (int)sizeof(hdr));
if (nsent == (ssize_t)sizeof(hdr))
nsent = (ssize_t)SSL_write(ssl, buf, (int)blen);
} else {
nsent = (ssize_t)write(sock, &hdr, sizeof(hdr));
if (nsent == (ssize_t)sizeof(hdr))
nsent = (ssize_t)write(sock, buf, (size_t)blen);
}
free(buf);
if (nsent != (ssize_t)blen) {
if (ssl) { SSL_shutdown(ssl); SSL_free(ssl); }
close(sock);
return -EIO;
}
@ -1489,8 +1194,10 @@ int xce_probe_config_tag(const char *host, uint16_t port, uint64_t *tag_out)
/* Read 8-byte size header */
while (off < sizeof(hdr)) {
int n = (int)read(sock, (char *)&hdr + off, sizeof(hdr) - off);
int n = ssl ? SSL_read(ssl, (char *)&hdr + off, (int)(sizeof(hdr) - off))
: (int)read(sock, (char *)&hdr + off, sizeof(hdr) - off);
if (n <= 0) {
if (ssl) { SSL_shutdown(ssl); SSL_free(ssl); }
close(sock);
return -EIO;
}
@ -1498,21 +1205,25 @@ int xce_probe_config_tag(const char *host, uint16_t port, uint64_t *tag_out)
}
if (hdr == 0 || hdr > 65536) { /* sanity cap — reply is tiny */
if (ssl) { SSL_shutdown(ssl); SSL_free(ssl); }
close(sock);
return -EMSGSIZE;
}
uint8_t *buf = (uint8_t *)malloc((size_t)hdr);
if (!buf) {
if (ssl) { SSL_shutdown(ssl); SSL_free(ssl); }
close(sock);
return -ENOMEM;
}
off = 0;
while (off < (size_t)hdr) {
int n = (int)read(sock, buf + off, (size_t)hdr - off);
int n = ssl ? SSL_read(ssl, buf + off, (int)((size_t)hdr - off))
: (int)read(sock, buf + off, (size_t)hdr - off);
if (n <= 0) {
free(buf);
if (ssl) { SSL_shutdown(ssl); SSL_free(ssl); }
close(sock);
return -EIO;
}
@ -1536,6 +1247,7 @@ int xce_probe_config_tag(const char *host, uint16_t port, uint64_t *tag_out)
*tag_out = rep_s.configTag;
}
if (ssl) { SSL_shutdown(ssl); SSL_free(ssl); }
close(sock);
INFO("HA: probe config tag from %s:%u tag=%016" PRIx64,
host, (unsigned)port, *tag_out);
@ -1590,6 +1302,18 @@ int handle_ctrlhello(cluster_t *c, endpoint_t *ep, xce_request_t *req)
uint64_t standby_applied_lsn = hello->applied_lsn;
uint64_t standby_config_tag = hello->config_tag;
/* Req 3.1: on the standby side, the first ctrlhello from the winner
* designates this inbound connection as the primary control channel.
* Guard with primary_ctrl_fd == -1 for idempotency. */
if (c && ep &&
(ctrl_role_t)atomic_load_explicit(&c->ha.role,
memory_order_acquire) == CTRL_STANDBY &&
c->ha.primary_ctrl_fd == -1) {
c->ha.primary_ctrl_fd = ep->fd;
ha_on_heartbeat_received(&c->ha);
INFO("HA: primary control channel established (fd=%d)", ep->fd);
}
/* 2. Register standby in replication set and mark as catching up */
int rc = repl_add_standby(&c->persist.repl, ep,
hello->name ? hello->name : "unknown", 0);