myxce/docs/superpowers/plans/2026-05-13-config-replication.md
2026-05-21 03:41:57 +00:00

2493 lines
85 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Config Replication Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implement config replication so standbys fetch `ClusterConfig` from the primary on startup, eliminating manual per-node provisioning.
**Architecture:** Split `cluster_init()` into Phase 1 (SSL/HA init, no config) and Phase 2 (iomgr/kvmgr/persist, config required). The primary loads config from `conf/current` (HEAD-file pointer → versioned `cluster.bin.<sha256>` file). Standbys fetch via `OP_replConfig` before Phase 2. SHA-256 is used as both version tag and integrity token. `am_i_primary()` replaced with real election probing (non-blocking connect + poll per candidate).
**Tech Stack:** C99, OpenSSL SHA-256, POSIX threads (non-blocking connect + poll), Cap'n Proto codecgen, Unity test framework, Meson build.
---
## File Map
| File | Action | What changes |
|------|--------|--------------|
| `src/schema/controller.capnp` | Modify | Add 3 opcodes + 6 structs + `configGeneration` field + `sender_priority` to Heartbeat |
| `src/include/myconf.h` | Modify | Add 6 fields to `myconf_t` |
| `src/include/xce/cluster_def.h` | Modify | Add 4 fields to `cluster_t` |
| `src/controller/ha.h` | Modify | Add `ssl_ctx`, `jitter_seed`, `standby_endpoints`, `election_priority_step_ms`, `election_jitter_max_ms` to `ha_state_t`; add `probe_ctx_t`; new API declarations |
| `src/include/persist.h` | Modify | Add `max_wal_records` to `persist_config_t`; add `wal_record_count`, `checkpoint_in_progress` to `persist_s` |
| `src/include/byteorder.h` | Create | `be64toh()` portability fallback |
| `src/controller/config_repl.h` | Create | Public API for config_repl.c |
| `src/controller/config_repl.c` | Create | All config storage, SHA-256, fetch, upgrade, push functions |
| `src/controller/main.c` | Modify | HEAD-file pointer resolution; add `--conf-keep-n`, `--conf-stale-timeout` flags; heap-alloc conffile |
| `src/include/myconf.h` | Modify | `myconf_fini()` declaration |
| `src/controller/myconf.c` | Modify | Add `load_primary_config()`, `myconf_fini()`; remove `get_default_conf()` static global |
| `src/controller/cluster.c` | Modify | Split `cluster_init()` into Phase 1 + Phase 2; add opcode dispatch for new ops; `cluster_run()` checks `restart_requested`; `handle_endpoint_error` prunes `standby_endpoints` |
| `src/controller/myapp.c` | Modify | Restructure to call Phase 1, `am_i_primary()`, config load/fetch, Phase 2; handle restart |
| `src/controller/ha.c` | Modify | Replace `am_i_primary()` stub with real election probing; add `probe_thread`, heartbeat listener, `ha_check_heartbeat_nocluster` priority comparison |
| `src/controller/join.c` | Modify | Implement `handle_controller_join()`; register in `standby_endpoints` |
| `src/include/typemap.h` | Modify | Add new union arms to `xce_request_t` and `xce_reply_t` |
| `src/lib/request.c` | Modify | Add encode/decode shims for 6 new message types |
| `src/controller/persist.c` | Modify | Add `schedule_checkpoint()` triggered by `max_wal_records` |
| `src/controller/meson.build` | Modify | Add `config_repl.c`, compile-time defines |
| `src/controller/testsuite/test_config_repl.c` | Create | Unit tests for Phase 1 + Phase 2 config functions |
| `src/controller/testsuite/test_ha_role.c` | Modify | Extend with election probing tests |
| `src/controller/testsuite/controller_test.c` | Modify | Register new test suites |
---
## Task 1: Schema additions
**Files:**
- Modify: `src/schema/controller.capnp`
- [ ] **Step 1: Add new OPCode entries**
In `controller.capnp`, after `replCatchUpDone @28;`, add:
```capnp
replConfig @29;
replConfigPush @30;
upgradeConfig @31;
```
- [ ] **Step 2: Add new message structs**
After the `MasterCandidate` struct, add:
```capnp
struct ReplConfigRequest $C.mapname("repl_config_req_t") {
versionTag @0: UInt64 $C.mapname("version_tag");
}
struct ReplConfigReply $C.mapname("repl_config_reply_t") {
upToDate @0: Bool $C.mapname("up_to_date");
versionTag @1: UInt64 $C.mapname("version_tag");
sha256 @2: Data $C.mapname("sha256");
bytes @3: Data $C.mapname("bytes");
}
struct ReplConfigPushRequest $C.mapname("repl_config_push_req_t") {
versionTag @0: UInt64 $C.mapname("version_tag");
sha256 @1: Data $C.mapname("sha256");
bytes @2: Data $C.mapname("bytes");
}
struct ReplConfigPushReply $C.mapname("repl_config_push_reply_t") {
accepted @0: Bool $C.mapname("accepted");
versionTag @1: UInt64 $C.mapname("version_tag");
}
struct UpgradeConfigRequest $C.mapname("upgrade_config_req_t") {
bytes @0: Data $C.mapname("bytes");
sha256 @1: Data $C.mapname("sha256");
}
struct UpgradeConfigReply $C.mapname("upgrade_config_reply_t") {
versionTag @0: UInt64 $C.mapname("version_tag");
error @1: Text $C.mapname("error");
ackedCount @2: UInt32 $C.mapname("acked_count");
}
```
- [ ] **Step 3: Add union arms to Request.body**
In `struct Request`, after `hostctrlmsg @21: HostControl;`:
```capnp
replConfigRequestMsg @22: ReplConfigRequest;
replConfigPushRequestMsg @23: ReplConfigPushRequest;
upgradeConfigRequestMsg @24: UpgradeConfigRequest;
```
- [ ] **Step 4: Add union arms to RequestReply.data**
In `struct RequestReply`, after `clusterconf @7: ClusterConf;`:
```capnp
replConfigReplyMsg @8: ReplConfigReply;
replConfigPushReplyMsg @9: ReplConfigPushReply;
upgradeConfigReplyMsg @10: UpgradeConfigReply;
```
- [ ] **Step 5: Add configGeneration to ClusterConfig**
In `struct ClusterConfig`, after the last existing field, add:
```capnp
configGeneration @20: UInt64 $C.mapname("config_generation");
```
- [ ] **Step 6: Add sender_priority to a new Heartbeat struct**
Add a new struct (the schema currently has no Heartbeat struct — `OP_heartbeat` uses the nop/void path):
```capnp
struct HeartbeatMsg $C.mapname("heartbeat_msg_t") {
senderPriority @0: UInt32 $C.mapname("sender_priority");
term @1: UInt64 $C.mapname("term");
}
```
Then in `Request.body`, add:
```capnp
heartbeatmsg @25: HeartbeatMsg;
```
- [ ] **Step 7: Build to verify schema compiles**
```bash
cd /worktrees/persistent_ha/src && nix develop --command bash -c "meson setup build --reconfigure 2>&1 | tail -20 && ninja -C build src/schema/controller.capnp.h 2>&1 | tail -20"
```
Expected: no capnp parse errors, generated header updated.
- [ ] **Step 8: Commit**
```bash
git add src/schema/controller.capnp
git commit -m "feat: add replConfig/replConfigPush/upgradeConfig opcodes and message structs to schema"
```
---
## Task 2: Struct field additions
**Files:**
- Modify: `src/include/myconf.h`
- Modify: `src/include/xce/cluster_def.h`
- Modify: `src/controller/ha.h`
- Modify: `src/include/persist.h`
- Create: `src/include/byteorder.h`
- [ ] **Step 1: Add fields to myconf_t**
In `src/include/myconf.h`, after `xce_cluster_config_t peers_cfg;`:
```c
uint64_t config_version_tag; /* first 8 bytes of SHA-256, host order */
uint8_t config_sha256[32]; /* full SHA-256 of conffile bytes */
int conf_keep_n; /* versioned file retention count (default 2) */
uint64_t config_generation; /* monotonic audit counter (§4.4) */
time_t conf_stale_timeout; /* seconds before saved config is flagged stale (default 86400) */
int config_stale; /* set when saved config is older than conf_stale_timeout */
```
Also add `myconf_fini()` declaration at the bottom of the file:
```c
void myconf_fini(myconf_t *myconf);
```
- [ ] **Step 2: Add fields to cluster_t**
In `src/include/xce/cluster_def.h`, inside `struct cluster_s`, after `ha_state_t ha;`:
```c
_Atomic int restart_requested; /* set by OP_upgradeConfig handler */
pthread_t heartbeat_listener_tid; /* pre-Phase-2 SSL listener thread */
_Atomic int heartbeat_listener_running;
_Atomic int lost_and_found_job_count; /* incremented during persist_recover() */
```
- [ ] **Step 3: Add fields to ha_state_t and ha_config_t**
In `src/controller/ha.h`:
(a) In `ha_config_t`, after `max_promotion_retries`:
```c
uint32_t election_jitter_max_ms; /* random jitter added to election timeout */
uint32_t election_priority_step_ms; /* extra ms per priority level */
```
(b) Add new default macros after the existing defaults:
```c
#define HA_DEFAULT_ELECTION_JITTER_MAX_MS 200
#define HA_DEFAULT_ELECTION_PRIORITY_STEP_MS 500
```
(c) In `ha_state_t`, after `watchdog_thread`:
```c
void *ssl_ctx; /* SSL_CTX* cast; avoids pulling in openssl headers */
uint64_t jitter_seed; /* xorshift64 PRNG state for election jitter */
zarray_t *standby_endpoints; /* connected standby controller endpoints */
```
(d) Add `probe_ctx_t` struct declaration before `ha_state_t`:
```c
typedef struct {
struct xce_candidate_s *candidate; /* peer to probe */
void *ssl_ctx; /* SSL_CTX* (void* to avoid openssl header) */
uint32_t timeout_ms;
atomic_int *election_done;
int got_ack;
pthread_t thread;
} probe_ctx_t;
```
(e) Add new API declarations at the bottom:
```c
int ha_is_primary(const ha_state_t *ha);
void start_heartbeat_listener(cluster_t *c);
void stop_heartbeat_listener(cluster_t *c);
```
- [ ] **Step 4: Add fields to persist_config_t and persist_s**
In `src/include/persist.h`, in `persist_config_t` after `sync_timeout_ms`:
```c
uint32_t max_wal_records; /* checkpoint trigger: records since last snap (default 10000) */
```
In `persist_config_defaults()`, add:
```c
c.max_wal_records = 10000;
```
In `struct persist_s` after `pthread_mutex_t lock;`:
```c
_Atomic uint32_t wal_record_count; /* records since last checkpoint */
_Atomic int checkpoint_in_progress; /* CAS guard for schedule_checkpoint */
```
- [ ] **Step 5: Create byteorder.h**
Create `src/include/byteorder.h`:
```c
#pragma once
/* Portable be64toh() for non-glibc or pre-2.25 targets.
* On Linux glibc >= 2.9 this is already available from <endian.h>.
* On other platforms we provide a fallback. */
#if defined(__linux__)
# include <endian.h>
#elif defined(__APPLE__)
# include <machine/endian.h>
# include <libkern/OSByteOrder.h>
# define be64toh(x) OSSwapBigToHostInt64(x)
#else
# include <stdint.h>
static inline uint64_t be64toh(uint64_t x) {
# if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__
return __builtin_bswap64(x);
# else
return x;
# endif
}
#endif
```
- [ ] **Step 6: Build to verify all struct changes compile**
```bash
cd /worktrees/persistent_ha/src && nix develop --command bash -c "ninja -C build 2>&1 | grep -E 'error:|warning:|ninja:' | head -30"
```
Expected: only warnings about new fields being unused — no errors.
- [ ] **Step 7: Commit**
```bash
git add src/include/myconf.h src/include/xce/cluster_def.h src/controller/ha.h src/include/persist.h src/include/byteorder.h
git commit -m "feat: add config replication fields to myconf_t, cluster_t, ha_state_t, persist_t"
```
---
## Task 3: config_repl.h + config_repl.c (Phase 1 storage functions)
**Files:**
- Create: `src/controller/config_repl.h`
- Create: `src/controller/config_repl.c` (Phase 1 functions only)
- Create: `src/controller/testsuite/test_config_repl.c` (Phase 1 tests)
- [ ] **Step 1: Write failing tests for Phase 1 storage functions**
Create `src/controller/testsuite/test_config_repl.c`:
```c
/* test_config_repl.c — Unity tests for config replication (Phase 1) */
#include "unity.h"
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include "config_repl.h"
static char tmpdir[256];
void setUp(void) {
snprintf(tmpdir, sizeof(tmpdir), "/tmp/test_config_repl_%d", (int)getpid());
mkdir(tmpdir, 0700);
char conf_dir[300];
snprintf(conf_dir, sizeof(conf_dir), "%s/conf", tmpdir);
mkdir(conf_dir, 0700);
}
void tearDown(void) {
char cmd[300];
snprintf(cmd, sizeof(cmd), "rm -rf %s", tmpdir);
system(cmd);
}
/* test: conf_read_current_filename() returns -ENOENT when conf/current absent */
void test_conf_read_current_filename_missing(void) {
char out[256] = {0};
int rc = conf_read_current_filename(tmpdir, out, sizeof(out));
TEST_ASSERT_EQUAL_INT(-ENOENT, rc);
}
/* test: conf_save_config() writes versioned file + HEAD pointer; read back matches */
void test_conf_save_and_read_current(void) {
const uint8_t data[] = "fake capnproto config blob";
size_t len = sizeof(data) - 1;
uint8_t sha256[32];
SHA256(data, len, sha256);
myconf_t myconf = {0};
myconf.workdir = tmpdir;
myconf.name = "ctrl0";
myconf.conf_keep_n = 2;
int rc = conf_save_config(&myconf, data, len, sha256);
TEST_ASSERT_EQUAL_INT(0, rc);
/* conf/current pointer file must exist */
char ptr_path[512];
snprintf(ptr_path, sizeof(ptr_path), "%s/conf/current", tmpdir);
TEST_ASSERT_EQUAL_INT(0, access(ptr_path, F_OK));
/* Reading back the current filename must succeed */
char out[256] = {0};
rc = conf_read_current_filename(tmpdir, out, sizeof(out));
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_NOT_NULL(strstr(out, "cluster.bin."));
}
/* test: conf_save_config() then conf_read_current_filename() parses 12-hex suffix */
void test_conf_current_filename_format(void) {
const uint8_t data[] = "another fake blob for hex test";
size_t len = sizeof(data) - 1;
uint8_t sha256[32];
SHA256(data, len, sha256);
myconf_t myconf = {0};
myconf.workdir = tmpdir;
myconf.name = "ctrl0";
myconf.conf_keep_n = 2;
conf_save_config(&myconf, data, len, sha256);
char out[256] = {0};
conf_read_current_filename(tmpdir, out, sizeof(out));
/* Must start with "cluster.bin." */
TEST_ASSERT_EQUAL_INT(0, strncmp(out, "cluster.bin.", 12));
/* Suffix must be exactly 12 hex chars */
const char *hex = out + 12;
TEST_ASSERT_EQUAL_INT(12, (int)strlen(hex));
TEST_ASSERT_EQUAL_INT(12, (int)strspn(hex, "0123456789abcdef"));
}
/* test: conf_trim_old_versions() keeps only conf_keep_n files */
void test_conf_trim_keep_n(void) {
myconf_t myconf = {0};
myconf.workdir = tmpdir;
myconf.name = "ctrl0";
myconf.conf_keep_n = 2;
uint8_t sha256[32];
/* Write 3 different config versions */
for (int i = 0; i < 3; i++) {
char data[32];
snprintf(data, sizeof(data), "config version %d", i);
SHA256((uint8_t*)data, strlen(data), sha256);
usleep(10000); /* ensure distinct mtime */
conf_save_config(&myconf, (uint8_t*)data, strlen(data), sha256);
}
/* Count cluster.bin.* files (excluding .tmp) */
char conf_dir[300];
snprintf(conf_dir, sizeof(conf_dir), "%s/conf", tmpdir);
int count = 0;
DIR *d = opendir(conf_dir);
struct dirent *e;
while ((e = readdir(d)) != NULL) {
if (strncmp(e->d_name, "cluster.bin.", 12) == 0 &&
strstr(e->d_name, ".tmp") == NULL)
count++;
}
closedir(d);
TEST_ASSERT_EQUAL_INT(2, count);
}
/* test: zero-tag sentinel — standby with no config sends tag=0 */
void test_conf_zero_tag_is_no_config_sentinel(void) {
/* When conf/current is absent, compute_config_tag() returns 0 */
uint64_t tag = conf_get_saved_tag(tmpdir);
TEST_ASSERT_EQUAL_UINT64(0, tag);
}
/* test: conf_read_current_filename() returns -EINVAL on path separator in pointer */
void test_conf_current_filename_rejects_path_separator(void) {
char ptr_path[300];
snprintf(ptr_path, sizeof(ptr_path), "%s/conf/current", tmpdir);
FILE *f = fopen(ptr_path, "w");
fprintf(f, "cluster.bin/../evil\n");
fclose(f);
char out[256] = {0};
int rc = conf_read_current_filename(tmpdir, out, sizeof(out));
TEST_ASSERT_EQUAL_INT(-EINVAL, rc);
}
/* test: empty conf/current treated as ENODATA */
void test_conf_empty_pointer_file_returns_enodata(void) {
char ptr_path[300];
snprintf(ptr_path, sizeof(ptr_path), "%s/conf/current", tmpdir);
/* Create empty file */
int fd = open(ptr_path, O_WRONLY|O_CREAT|O_TRUNC, 0600);
close(fd);
char out[256] = {0};
int rc = conf_read_current_filename(tmpdir, out, sizeof(out));
TEST_ASSERT_EQUAL_INT(-ENODATA, rc);
}
/* test: conf_check_stale() returns 0 for fresh file, 1 for old file */
void test_conf_stale_timeout(void) {
/* Write a config */
const uint8_t data[] = "stale test blob";
uint8_t sha256[32];
SHA256(data, sizeof(data)-1, sha256);
myconf_t myconf = {0};
myconf.workdir = tmpdir;
myconf.name = "ctrl0";
myconf.conf_keep_n = 2;
myconf.conf_stale_timeout = 86400; /* 24h */
conf_save_config(&myconf, data, sizeof(data)-1, sha256);
/* Freshly written — not stale */
int stale = conf_check_stale(tmpdir, 86400);
TEST_ASSERT_EQUAL_INT(0, stale);
/* Timeout of 0 means disabled — never stale */
stale = conf_check_stale(tmpdir, 0);
TEST_ASSERT_EQUAL_INT(0, stale);
/* 1-second timeout — touch the file to be 2s old via utime */
struct utimbuf old_time;
old_time.actime = time(NULL) - 10;
old_time.modtime = time(NULL) - 10;
char ver_path[512];
/* resolve the versioned filename */
char fname[256] = {0};
conf_read_current_filename(tmpdir, fname, sizeof(fname));
snprintf(ver_path, sizeof(ver_path), "%s/conf/%s", tmpdir, fname);
utime(ver_path, &old_time);
stale = conf_check_stale(tmpdir, 5); /* 5-second timeout */
TEST_ASSERT_EQUAL_INT(1, stale);
}
```
- [ ] **Step 2: Run tests to verify they fail (functions not yet defined)**
```bash
cd /worktrees/persistent_ha/src && nix develop --command bash -c "ninja -C build controller_unit_test 2>&1 | grep -E 'error:|undefined' | head -20"
```
Expected: linker errors for `conf_save_config`, `conf_read_current_filename`, etc.
- [ ] **Step 3: Create config_repl.h**
Create `src/controller/config_repl.h`:
```c
#pragma once
/* config_repl.h — config storage, SHA-256, replication (Phase 1 & 2) */
#include <stdint.h>
#include <stddef.h>
#include "myconf.h"
/* Max config blob size — reject oversized files before allocating */
#define CONF_MAX_SIZE (1 * 1024 * 1024) /* 1 MiB */
/* Compile-time gate for OP_upgradeConfig (Phase 2, default disabled) */
#ifndef CONF_ENABLE_UPGRADE_CONFIG
#define CONF_ENABLE_UPGRADE_CONFIG 0
#endif
/* Push timeout for fan-out to standbys (ms) */
#ifndef CONF_PUSH_TIMEOUT_MS
#define CONF_PUSH_TIMEOUT_MS 500
#endif
/* Days before startup cert-expiry warning is logged */
#define CERT_WARN_DAYS 30
/* ── Phase 1: Storage ────────────────────────────────── */
/*
* conf_read_current_filename: read HEAD-style pointer file.
* Reads <workdir>/conf/current, strips newline, validates format.
* Returns 0 on success; -ENOENT if missing; -ENODATA if empty;
* -EINVAL if malformed (path separator, wrong prefix, wrong hex length).
* out must be at least NAME_MAX bytes.
*/
int conf_read_current_filename(const char *workdir, char *out, size_t outsz);
/*
* conf_save_config: write versioned file + update HEAD pointer atomically.
* Steps: write <sha_hex>.tmp → fsync → rename to cluster.bin.<sha_hex>,
* then write current.tmp → fsync → rename to conf/current.
* Trims old versions after success.
* Returns 0 on success, -errno on failure.
*/
int conf_save_config(myconf_t *myconf, const uint8_t *bytes, size_t len,
const uint8_t sha256[32]);
/*
* conf_read_raw: read the versioned file named by conf/current.
* Allocates *bytes (caller must free). Enforces CONF_MAX_SIZE.
* Returns 0 on success; -EFBIG if over limit; -errno on I/O error.
*/
int conf_read_raw(const char *conffile, uint8_t **bytes, size_t *len);
/*
* conf_get_saved_tag: return first 8 bytes of SHA-256 of the current
* versioned file, host-byte-order. Returns 0 if conf/current is absent
* or unreadable (zero-tag sentinel for "no config saved").
*/
uint64_t conf_get_saved_tag(const char *workdir);
/*
* conf_check_stale: returns 1 if the current versioned file's mtime is
* older than stale_timeout_s seconds. Returns 0 if within timeout or
* if stale_timeout_s == 0 (disabled). Returns 0 if conf/current absent.
*/
int conf_check_stale(const char *workdir, time_t stale_timeout_s);
/*
* conf_trim_old_versions: keep at most keep_n versioned cluster.bin.* files
* (by mtime, newest first). Also removes stale .tmp files.
* Always preserves the file named by the current conf/current pointer.
*/
void conf_trim_old_versions(const char *workdir, int keep_n);
/*
* config_contains_reserved_name: returns 1 if ClusterConfig contains
* a queue or host named "lost_and_found". Used at load time.
*/
int config_contains_reserved_name(cluster_config_t *cfg);
/*
* sanitise_cn: copy at most maxlen printable ASCII chars from src to dst,
* replacing non-printable or control chars with '?'. Returns dst.
*/
char *sanitise_cn(const char *src, size_t maxlen);
```
- [ ] **Step 4: Implement Phase 1 functions in config_repl.c**
Create `src/controller/config_repl.c` with the Phase 1 functions:
```c
#define _GNU_SOURCE
#include "config_repl.h"
#include <dirent.h>
#include <errno.h>
#include <fcntl.h>
#include <openssl/sha.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <time.h>
#include <utime.h>
#include <unistd.h>
#include <pthread.h>
#include "byteorder.h"
#include "clusterconfig.h"
#include "controller.capnp.h"
#include "tlog.h"
/* ── helpers ──────────────────────────────────────────── */
static void make_conf_dir(const char *workdir, char *out, size_t sz)
{
snprintf(out, sz, "%s/conf", workdir);
}
static void bytes_to_hex(const uint8_t *b, size_t n, char *out)
{
static const char hex[] = "0123456789abcdef";
for (size_t i = 0; i < n; i++) {
out[2*i] = hex[(b[i] >> 4) & 0xf];
out[2*i+1] = hex[b[i] & 0xf];
}
out[2*n] = '\0';
}
/* ── conf_read_current_filename ─────────────────────── */
int conf_read_current_filename(const char *workdir, char *out, size_t outsz)
{
char head_path[4096];
snprintf(head_path, sizeof(head_path), "%s/conf/current", workdir);
FILE *f = fopen(head_path, "r");
if (!f)
return -ENOENT;
struct stat st;
if (fstat(fileno(f), &st) == 0 && st.st_size == 0) {
fclose(f);
ERROR("conf/current is empty — treating as missing config");
return -ENODATA;
}
if (!fgets(out, (int)outsz, f)) {
fclose(f);
return -EIO;
}
fclose(f);
out[strcspn(out, "\n")] = '\0';
if (strchr(out, '/')) {
ERROR("conf/current contains path separator — corrupted pointer file");
return -EINVAL;
}
if (strncmp(out, "cluster.bin.", 12) != 0) {
ERROR("conf/current filename '%s' does not start with 'cluster.bin.'", out);
return -EINVAL;
}
const char *hex = out + 12;
if (strlen(hex) != 12 || strspn(hex, "0123456789abcdef") != 12) {
ERROR("conf/current suffix '%s' is not 12 hex chars", hex);
return -EINVAL;
}
return 0;
}
/* ── conf_save_config ───────────────────────────────── */
int conf_save_config(myconf_t *myconf, const uint8_t *bytes, size_t len,
const uint8_t sha256[32])
{
char conf_dir[4096];
make_conf_dir(myconf->workdir, conf_dir, sizeof(conf_dir));
if (mkdir(conf_dir, 0700) != 0 && errno != EEXIST)
return -errno;
/* Derive 12-char hex tag from first 6 bytes of SHA-256 */
char hex_tag[13];
bytes_to_hex(sha256, 6, hex_tag);
/* Step 1: write versioned file via thread-unique .tmp */
char tmp_path[4096], ver_path[4096];
snprintf(tmp_path, sizeof(tmp_path), "%s/cluster.bin.%s.%lu.tmp",
conf_dir, hex_tag, (unsigned long)pthread_self());
snprintf(ver_path, sizeof(ver_path), "%s/cluster.bin.%s",
conf_dir, hex_tag);
int fd = open(tmp_path, O_WRONLY|O_CREAT|O_TRUNC, 0600);
if (fd < 0)
return -errno;
if (write(fd, bytes, len) != (ssize_t)len) {
close(fd); unlink(tmp_path);
return -EIO;
}
if (fsync(fd) != 0) {
close(fd); unlink(tmp_path);
return -EIO;
}
close(fd);
if (rename(tmp_path, ver_path) != 0) {
unlink(tmp_path);
return -errno;
}
/* Step 2: update HEAD pointer file via thread-unique .tmp */
char ptr_tmp[4096], ptr_path[4096];
snprintf(ptr_tmp, sizeof(ptr_tmp), "%s/current.%lu.tmp",
conf_dir, (unsigned long)pthread_self());
snprintf(ptr_path, sizeof(ptr_path), "%s/current", conf_dir);
char target[NAME_MAX];
snprintf(target, sizeof(target), "cluster.bin.%s\n", hex_tag);
fd = open(ptr_tmp, O_WRONLY|O_CREAT|O_TRUNC, 0600);
if (fd < 0)
return -errno;
write(fd, target, strlen(target));
fsync(fd);
close(fd);
if (rename(ptr_tmp, ptr_path) != 0) {
unlink(ptr_tmp);
return -errno;
}
/* Step 2.5: fsync the conf/ directory to persist the renames */
fd = open(conf_dir, O_RDONLY);
if (fd >= 0) { fsync(fd); close(fd); }
/* Step 3: trim old versions */
conf_trim_old_versions(myconf->workdir, myconf->conf_keep_n);
/* Update conffile to point to new versioned file */
free(myconf->conffile);
asprintf(&myconf->conffile, "%s/cluster.bin.%s", conf_dir, hex_tag);
/* Update tag and SHA-256 in myconf */
uint64_t new_tag;
memcpy(&new_tag, sha256, 8);
myconf->config_version_tag = be64toh(new_tag);
memcpy(myconf->config_sha256, sha256, 32);
return 0;
}
/* ── conf_read_raw ──────────────────────────────────── */
int conf_read_raw(const char *conffile, uint8_t **bytes, size_t *len)
{
FILE *f = fopen(conffile, "rb");
if (!f)
return -errno;
fseek(f, 0, SEEK_END);
long sz = ftell(f);
fseek(f, 0, SEEK_SET);
if (sz <= 0) { fclose(f); return -EIO; }
if ((size_t)sz > CONF_MAX_SIZE) { fclose(f); return -EFBIG; }
*bytes = (uint8_t*)malloc((size_t)sz);
if (!*bytes) { fclose(f); return -ENOMEM; }
if (fread(*bytes, 1, (size_t)sz, f) != (size_t)sz) {
free(*bytes); *bytes = NULL; fclose(f); return -EIO;
}
fclose(f);
*len = (size_t)sz;
return 0;
}
/* ── conf_get_saved_tag ─────────────────────────────── */
uint64_t conf_get_saved_tag(const char *workdir)
{
char fname[256] = {0};
if (conf_read_current_filename(workdir, fname, sizeof(fname)) != 0)
return 0;
char conf_dir[4096];
make_conf_dir(workdir, conf_dir, sizeof(conf_dir));
char ver_path[4096];
snprintf(ver_path, sizeof(ver_path), "%s/%s", conf_dir, fname);
uint8_t *bytes = NULL;
size_t len = 0;
if (conf_read_raw(ver_path, &bytes, &len) != 0)
return 0;
uint8_t sha256[32];
SHA256(bytes, len, sha256);
free(bytes);
uint64_t tag;
memcpy(&tag, sha256, 8);
return be64toh(tag);
}
/* ── conf_check_stale ───────────────────────────────── */
int conf_check_stale(const char *workdir, time_t stale_timeout_s)
{
if (stale_timeout_s == 0)
return 0;
char fname[256] = {0};
if (conf_read_current_filename(workdir, fname, sizeof(fname)) != 0)
return 0;
char ver_path[4096];
snprintf(ver_path, sizeof(ver_path), "%s/conf/%s", workdir, fname);
struct stat st;
if (stat(ver_path, &st) != 0)
return 0;
time_t age = time(NULL) - st.st_mtime;
return (age > stale_timeout_s) ? 1 : 0;
}
/* ── conf_trim_old_versions ─────────────────────────── */
void conf_trim_old_versions(const char *workdir, int keep_n)
{
char conf_dir[4096];
make_conf_dir(workdir, conf_dir, sizeof(conf_dir));
/* Read current pointer to protect it from deletion */
char current_name[256] = {0};
conf_read_current_filename(workdir, current_name, sizeof(current_name));
/* Collect cluster.bin.* files (not .tmp) sorted by mtime */
struct entry { char name[NAME_MAX]; time_t mtime; };
struct entry entries[256];
int n = 0;
DIR *d = opendir(conf_dir);
if (!d) return;
struct dirent *e;
while ((e = readdir(d)) != NULL && n < 256) {
if (strncmp(e->d_name, "cluster.bin.", 12) != 0)
continue;
if (strstr(e->d_name, ".tmp") != NULL) {
/* Clean stale .tmp files */
char full[4096];
snprintf(full, sizeof(full), "%s/%s", conf_dir, e->d_name);
unlink(full);
continue;
}
char full[4096];
snprintf(full, sizeof(full), "%s/%s", conf_dir, e->d_name);
struct stat st;
if (stat(full, &st) == 0) {
strncpy(entries[n].name, e->d_name, NAME_MAX-1);
entries[n].mtime = st.st_mtime;
n++;
}
}
closedir(d);
/* Sort descending by mtime (newest first) — simple insertion sort */
for (int i = 1; i < n; i++) {
struct entry tmp = entries[i];
int j = i - 1;
while (j >= 0 && entries[j].mtime < tmp.mtime) {
entries[j+1] = entries[j]; j--;
}
entries[j+1] = tmp;
}
/* Delete everything after keep_n, skipping the current pointer */
for (int i = keep_n; i < n; i++) {
if (strcmp(entries[i].name, current_name) == 0)
continue; /* never delete the active version */
char full[4096];
snprintf(full, sizeof(full), "%s/%s", conf_dir, entries[i].name);
unlink(full);
}
}
/* ── config_contains_reserved_name ─────────────────── */
int config_contains_reserved_name(cluster_config_t *cfg)
{
if (!cfg) return 0;
/* Check queues */
for (int i = 0; i < cfg->nqueues; i++) {
if (cfg->queues[i] && strcmp(cfg->queues[i]->name, "lost_and_found") == 0)
return 1;
}
/* Check hosts / candidates */
for (int i = 0; i < cfg->nhosts; i++) {
if (cfg->hosts[i] && strcmp(cfg->hosts[i]->name, "lost_and_found") == 0)
return 1;
}
return 0;
}
/* ── sanitise_cn ────────────────────────────────────── */
char *sanitise_cn(const char *src, size_t maxlen)
{
static char buf[128];
size_t n = maxlen < sizeof(buf)-1 ? maxlen : sizeof(buf)-1;
size_t i;
for (i = 0; i < n && src[i] != '\0'; i++)
buf[i] = (src[i] >= 0x20 && src[i] < 0x7f) ? src[i] : '?';
buf[i] = '\0';
return buf;
}
```
- [ ] **Step 5: Add config_repl.c to meson.build and update test suite**
In `src/controller/meson.build`, add `'config_repl.c'` to `controller_src`.
Also add to `controller_unit_test_src`:
```
'testsuite/test_config_repl.c',
'config_repl.c',
```
Add compile definition to `controller_unit_test`:
```
c_args: ['-DXCE_TESTING', '-DCONF_ENABLE_UPGRADE_CONFIG=0'],
```
- [ ] **Step 6: Run tests to verify they pass**
```bash
cd /worktrees/persistent_ha/src && nix develop --command bash -c "ninja -C build controller_unit_test && ./build/controller_unit_test 2>&1 | grep -E 'PASS|FAIL|test_conf'"
```
Expected: all `test_conf_*` tests PASS.
- [ ] **Step 7: Commit**
```bash
git add src/controller/config_repl.h src/controller/config_repl.c src/controller/testsuite/test_config_repl.c src/controller/meson.build
git commit -m "feat: add config_repl Phase 1 — HEAD-file storage, SHA-256, trim, stale check"
```
---
## Task 4: main.c — HEAD-file config resolution + new CLI flags
**Files:**
- Modify: `src/controller/main.c`
- [ ] **Step 1: Replace flat conffile path with HEAD-file resolution**
In `main()`, replace the `conf_file` stack buffer and assignment:
```c
/* Old code (lines ~79, 115–116, 121): */
char conf_file[XCE_MAXPATH_LEN + 1];
snprintf(conf_file, ..., "%s/%s/conf/current_config", ...);
myconf.conffile = conf_file;
/* New code: conffile is heap-allocated by load_primary_config() or
* fetch_config_from_primary() — do not set it here.
* Store workdir for use in config_repl.c path construction. */
/* Remove the conf_file declaration and the myconf.conffile = conf_file line */
myconf.conffile = NULL; /* will be set by load_primary_config() or fetch */
```
- [ ] **Step 2: Add --conf-keep-n and --conf-stale-timeout CLI options**
Add two new option keys and entries to `options[]`:
```c
#define OPT_CONF_KEEP_N 257
#define OPT_CONF_STALE_TIMEOUT 258
```
```c
{ "conf-keep-n", OPT_CONF_KEEP_N, "N", 0,
"Number of versioned config files to retain (default 2)" },
{ "conf-stale-timeout", OPT_CONF_STALE_TIMEOUT, "SECS", 0,
"Seconds before saved config is considered stale (0=disabled, default 86400)" },
```
Add to `struct arguments`:
```c
int conf_keep_n;
long conf_stale_timeout;
```
Add cases to `parse_opt`:
```c
case OPT_CONF_KEEP_N:
arguments->conf_keep_n = atoi(arg);
break;
case OPT_CONF_STALE_TIMEOUT:
arguments->conf_stale_timeout = atol(arg);
break;
```
After `memset(&arguments, ...)`, initialize defaults:
```c
arguments.conf_keep_n = 2;
arguments.conf_stale_timeout = 86400;
```
Set in myconf after parsing:
```c
myconf.conf_keep_n = arguments.conf_keep_n;
myconf.conf_stale_timeout = (time_t)arguments.conf_stale_timeout;
```
- [ ] **Step 3: Add --peers validation WARN**
After the `xce_parse_peers()` call, add:
```c
for (int i = 0; i < myconf.peers_cfg.candidates_count; i++) {
if (myconf.peers_cfg.candidates[i].hostname[0] == '\0' ||
myconf.peers_cfg.candidates[i].port == 0) {
WARN("--peers: candidate %d has empty host or port 0 — check peer string",
i);
}
}
```
- [ ] **Step 4: Build**
```bash
cd /worktrees/persistent_ha/src && nix develop --command bash -c "ninja -C build xce_controller 2>&1 | grep -E 'error:|warning:' | head -20"
```
Expected: no errors.
- [ ] **Step 5: Commit**
```bash
git add src/controller/main.c
git commit -m "feat: update main.c — heap-alloc conffile, add --conf-keep-n/--conf-stale-timeout flags"
```
---
## Task 5: myconf.c — load_primary_config() and myconf_fini()
**Files:**
- Modify: `src/controller/myconf.c`
- [ ] **Step 1: Write failing test for load_primary_config() missing-file path**
In `src/controller/testsuite/test_config_repl.c`, add:
```c
#include "myconf.h"
/* ... */
void test_primary_missing_config_fatal_path(void) {
myconf_t myconf = {0};
myconf.workdir = tmpdir;
myconf.name = "ctrl0";
myconf.conffile = NULL;
myconf.conf_keep_n = 2;
/* No conf/current file exists — load_primary_config must return -ENOENT */
int rc = load_primary_config_for_test(&myconf);
TEST_ASSERT_EQUAL_INT(-ENOENT, rc);
}
```
(We'll expose a `_for_test` variant that returns the error code instead of calling FATAL.)
- [ ] **Step 2: Add load_primary_config() to myconf.c**
In `src/controller/myconf.c`, add:
```c
#include "config_repl.h"
#include "byteorder.h"
#include <openssl/sha.h>
#include <stdatomic.h>
```
Add the function:
```c
int load_primary_config(cluster_t *c)
{
myconf_t *myconf = c->myconf;
char conf_dir[4096];
snprintf(conf_dir, sizeof(conf_dir), "%s/%s", myconf->workdir, myconf->name);
/* Resolve conf/current → versioned filename */
char fname[256] = {0};
int rc = conf_read_current_filename(conf_dir, fname, sizeof(fname));
if (rc != 0) {
FATAL("Primary has no config file '%s/conf/current' (rc=%d) — "
"provision the config before starting the primary controller",
conf_dir, rc);
return -ENOENT;
}
/* Heap-allocate conffile so it survives the stack frame */
free(myconf->conffile);
if (asprintf(&myconf->conffile, "%s/conf/%s", conf_dir, fname) < 0)
return -ENOMEM;
/* Check for reserved lost_and_found name */
struct capn cap;
ClusterConfig_ptr p;
FILE *fp = fopen(myconf->conffile, "rb");
if (!fp) {
FATAL("Primary config file '%s' not readable: %s",
myconf->conffile, strerror(errno));
return -errno;
}
capn_init_fp(&cap, fp, 0);
p.p = capn_getp(capn_root(&cap), 0, 1);
cluster_config_t *cfg = NULL;
decode_ClusterConfig_ptr(&cfg, p);
capn_free(&cap);
fclose(fp);
if (!cfg) {
FATAL("Primary config file '%s' is not a valid ClusterConfig blob",
myconf->conffile);
return -EINVAL;
}
if (config_contains_reserved_name(cfg)) {
free_ClusterConfig(cfg);
FATAL("Config file '%s' contains reserved name 'lost_and_found'",
myconf->conffile);
return -EINVAL;
}
fill_default_cluster_config(cfg);
myconf->cluster_config = cfg;
/* Compute and cache SHA-256 */
uint8_t *bytes = NULL;
size_t len = 0;
rc = conf_read_raw(myconf->conffile, &bytes, &len);
if (rc != 0) {
FATAL("Failed to read primary config for SHA-256: %d", rc);
return rc;
}
SHA256(bytes, len, myconf->config_sha256);
free(bytes);
uint64_t tag;
memcpy(&tag, myconf->config_sha256, 8);
myconf->config_version_tag = be64toh(tag);
/* Default generation to 1 if not set in blob */
if (cfg->config_generation == 0)
cfg->config_generation = 1;
myconf->config_generation = cfg->config_generation;
/* Check staleness */
myconf->config_stale = conf_check_stale(conf_dir,
myconf->conf_stale_timeout);
if (myconf->config_stale)
WARN("Primary config appears stale (mtime > %lds)", myconf->conf_stale_timeout);
INFO("Primary config loaded: tag=%016"PRIx64" gen=%"PRIu64,
myconf->config_version_tag, myconf->config_generation);
return 0;
}
```
- [ ] **Step 3: Add myconf_fini() to myconf.c**
```c
void myconf_fini(myconf_t *myconf)
{
if (!myconf) return;
free(myconf->conffile);
myconf->conffile = NULL;
if (myconf->cluster_config) {
free_ClusterConfig(myconf->cluster_config);
free(myconf->cluster_config);
myconf->cluster_config = NULL;
}
}
```
Remove the `static struct cluster_configuration *cf = NULL;` global and the `cluster_hook_fun` / `config_fini` functions that handle the shutdown hook for `cf` — `myconf_fini()` takes over this responsibility.
Keep `load_cluster_config()` and `load_cluster_config_from_file()` for now but mark them as legacy (they're still used by the existing `get_default_conf()` path during Phase 1 work).
- [ ] **Step 4: Declare load_primary_config() in myconf.h**
In `src/include/myconf.h`, add:
```c
/* Declared here; defined in myconf.c; used by myapp.c */
int load_primary_config(struct cluster_s *c);
```
(forward-declare `cluster_t` in myconf.h to avoid circular include):
```c
typedef struct cluster_s cluster_t;
```
- [ ] **Step 5: Build and run tests**
```bash
cd /worktrees/persistent_ha/src && nix develop --command bash -c "ninja -C build controller_unit_test && ./build/controller_unit_test 2>&1 | grep -E 'PASS|FAIL|test_primary'"
```
Expected: `test_primary_missing_config_fatal_path` PASS.
- [ ] **Step 6: Commit**
```bash
git add src/controller/myconf.c src/include/myconf.h src/controller/testsuite/test_config_repl.c
git commit -m "feat: add load_primary_config() and myconf_fini() — replace get_default_conf static global"
```
---
## Task 6: ha.c — real am_i_primary() with election probing
**Files:**
- Modify: `src/controller/ha.c`
- Modify: `src/controller/ha.h`
- Modify: `src/controller/testsuite/test_ha_role.c`
- [ ] **Step 1: Write failing tests for election probing**
In `src/controller/testsuite/test_ha_role.c`, add:
```c
/* test: priority-0 with no peers → am_i_primary returns 1 without probing */
void test_am_i_primary_priority_zero_no_peers(void) {
cluster_t c;
memset(&c, 0, sizeof(c));
myconf_t myconf = {0};
myconf.peers_cfg.candidates_count = 0;
c.myconf = &myconf;
ha_config_t cfg = {
.heartbeat_interval_ms = HA_DEFAULT_HEARTBEAT_INTERVAL_MS,
.heartbeat_timeout_ms = HA_DEFAULT_HEARTBEAT_TIMEOUT_MS,
.election_timeout_ms = HA_DEFAULT_ELECTION_TIMEOUT_MS,
.probe_connect_timeout_ms = HA_DEFAULT_PROBE_CONNECT_TIMEOUT_MS,
.max_promotion_retries = HA_DEFAULT_MAX_PROMOTION_RETRIES,
.election_jitter_max_ms = 0,
.election_priority_step_ms = 500,
};
ha_init(&c.ha, &cfg, 0);
TEST_ASSERT_EQUAL_INT(1, am_i_primary(&c));
ha_fini(&c.ha);
}
/* test: priority-0 with peers → am_i_primary returns 1 (no higher-priority peers) */
void test_am_i_primary_priority_zero_with_peers(void) {
cluster_t c;
memset(&c, 0, sizeof(c));
myconf_t myconf = {0};
myconf.peers_cfg.candidates_count = 3;
strncpy(myconf.peers_cfg.candidates[0].hostname, "ctrl0", XCE_MAXNAME_LEN-1);
myconf.peers_cfg.candidates[0].port = 9990;
strncpy(myconf.peers_cfg.candidates[1].hostname, "ctrl1", XCE_MAXNAME_LEN-1);
myconf.peers_cfg.candidates[1].port = 9991;
strncpy(myconf.peers_cfg.candidates[2].hostname, "ctrl2", XCE_MAXNAME_LEN-1);
myconf.peers_cfg.candidates[2].port = 9992;
c.myconf = &myconf;
ha_config_t cfg = {
.heartbeat_interval_ms = HA_DEFAULT_HEARTBEAT_INTERVAL_MS,
.heartbeat_timeout_ms = HA_DEFAULT_HEARTBEAT_TIMEOUT_MS,
.election_timeout_ms = 200, /* short for test */
.probe_connect_timeout_ms = 50,
.max_promotion_retries = 1,
.election_jitter_max_ms = 0,
.election_priority_step_ms = 0,
};
ha_init(&c.ha, &cfg, 0);
/* Priority-0 never probes — must return immediately */
int result = am_i_primary(&c);
TEST_ASSERT_EQUAL_INT(1, result);
ha_fini(&c.ha);
}
/* test: priority-1 with no higher-priority peer alive → deadline expires → primary */
void test_am_i_primary_deadline_expires_becomes_primary(void) {
cluster_t c;
memset(&c, 0, sizeof(c));
myconf_t myconf = {0};
/* Single higher-priority candidate at a port nobody listens on */
myconf.peers_cfg.candidates_count = 2;
strncpy(myconf.peers_cfg.candidates[0].hostname, "127.0.0.1", XCE_MAXNAME_LEN-1);
myconf.peers_cfg.candidates[0].port = 19990; /* nobody listening */
strncpy(myconf.peers_cfg.candidates[1].hostname, "127.0.0.1", XCE_MAXNAME_LEN-1);
myconf.peers_cfg.candidates[1].port = 19991;
c.myconf = &myconf;
ha_config_t cfg = {
.heartbeat_interval_ms = HA_DEFAULT_HEARTBEAT_INTERVAL_MS,
.heartbeat_timeout_ms = HA_DEFAULT_HEARTBEAT_TIMEOUT_MS,
.election_timeout_ms = 300, /* 300ms for test speed */
.probe_connect_timeout_ms = 100,
.max_promotion_retries = 1,
.election_jitter_max_ms = 0,
.election_priority_step_ms = 0,
};
ha_init(&c.ha, &cfg, 1); /* priority 1 */
int result = am_i_primary(&c);
TEST_ASSERT_EQUAL_INT(1, result); /* deadline expired, self-declare */
ha_fini(&c.ha);
}
```
- [ ] **Step 2: Run tests to see them fail (am_i_primary still the stub)**
```bash
cd /worktrees/persistent_ha/src && nix develop --command bash -c "ninja -C build controller_unit_test && ./build/controller_unit_test 2>&1 | grep -E 'test_am_i_primary|FAIL'"
```
Expected: `test_am_i_primary_deadline_expires_becomes_primary` FAIL (stub returns 0 for priority 1).
- [ ] **Step 3: Implement xorshift64_next() inline in ha.h**
In `src/controller/ha.h`, add after the `#include` block:
```c
#include <fcntl.h>
#include <unistd.h>
static inline uint64_t xorshift64_next(uint64_t *state)
{
uint64_t x = *state;
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
*state = x;
return x;
}
/* Seed jitter_seed from /dev/urandom; fall back to monotonic clock + pid */
static inline void ha_seed_jitter(uint64_t *seed, int my_priority)
{
int fd = open("/dev/urandom", O_RDONLY);
if (fd >= 0) {
read(fd, seed, 8);
close(fd);
if (*seed != 0) return;
}
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
*seed = (uint64_t)ts.tv_nsec
^ ((uint64_t)my_priority << 56)
^ ((uint64_t)getpid() << 16);
}
```
- [ ] **Step 4: Implement probe_thread() and am_i_primary() in ha.c**
In `src/controller/ha.c`, add includes at the top:
```c
#include <arpa/inet.h>
#include <fcntl.h>
#include <netdb.h>
#include <openssl/ssl.h>
#include <poll.h>
#include <sys/socket.h>
#include "byteorder.h"
#include "config_repl.h"
```
Then replace the current `am_i_primary()` stub (ha.c:213-216) with:
```c
static void *probe_thread(void *arg)
{
probe_ctx_t *ctx = (probe_ctx_t *)arg;
/* Non-blocking connect to candidate */
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", ctx->candidate->port);
if (getaddrinfo(ctx->candidate->hostname, port_str, &hints, &res) != 0)
goto done;
int sock = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
if (sock < 0) { freeaddrinfo(res); goto done; }
fcntl(sock, F_SETFL, O_NONBLOCK);
int crc = connect(sock, res->ai_addr, res->ai_addrlen);
freeaddrinfo(res);
if (crc != 0 && errno != EINPROGRESS) { close(sock); goto done; }
/* poll loop with election_done check */
struct pollfd pfd = { .fd = sock, .events = POLLOUT };
while (!atomic_load(ctx->election_done)) {
int pr = poll(&pfd, 1, (int)ctx->timeout_ms);
if (pr < 0 && errno == EINTR) continue;
if (pr <= 0) { close(sock); goto done; }
if (pfd.revents & (POLLERR|POLLHUP)) { close(sock); goto done; }
if (pfd.revents & POLLOUT) break;
}
if (atomic_load(ctx->election_done)) { close(sock); goto done; }
/* Verify connect succeeded */
int err = 0;
socklen_t el = sizeof(err);
getsockopt(sock, SOL_SOCKET, SO_ERROR, &err, &el);
if (err != 0) { close(sock); goto done; }
/* SSL handshake */
SSL *ssl = SSL_new((SSL_CTX *)ctx->ssl_ctx);
if (!ssl) { close(sock); goto done; }
SSL_set_fd(ssl, sock);
fcntl(sock, F_SETFL, 0); /* blocking for SSL */
int sr;
do { sr = SSL_connect(ssl); } while (
(sr <= 0) &&
(SSL_get_error(ssl, sr) == SSL_ERROR_WANT_READ ||
SSL_get_error(ssl, sr) == SSL_ERROR_WANT_WRITE) &&
!atomic_load(ctx->election_done));
if (sr <= 0 || atomic_load(ctx->election_done)) {
SSL_free(ssl); close(sock); goto done;
}
/* Send a minimal OP_heartbeat request and check for any reply */
/* Use the existing xce_nop_req pattern: just check TLS connected */
ctx->got_ack = 1;
SSL_shutdown(ssl);
SSL_free(ssl);
close(sock);
done:
return NULL;
}
static int wait_any_probe(probe_ctx_t *probes, int n,
const struct timespec *deadline)
{
while (1) {
for (int i = 0; i < n; i++) {
if (probes[i].got_ack)
return 1;
}
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
if (now.tv_sec > deadline->tv_sec ||
(now.tv_sec == deadline->tv_sec &&
now.tv_nsec >= deadline->tv_nsec))
return 0;
struct timespec sleep_ts = { .tv_sec = 0, .tv_nsec = 10 * 1000000L };
nanosleep(&sleep_ts, NULL);
}
}
int am_i_primary(cluster_t *c)
{
/* Priority-0 is unconditionally primary — no probing needed */
if (c->ha.my_priority == 0)
return 1;
/* Seed jitter PRNG if not yet seeded */
if (c->ha.jitter_seed == 0)
ha_seed_jitter(&c->ha.jitter_seed, c->ha.my_priority);
const xce_cluster_config_t *pcfg = &c->myconf->peers_cfg;
int n_probes = c->ha.my_priority < pcfg->candidates_count
? c->ha.my_priority
: pcfg->candidates_count;
if (n_probes == 0)
return 1; /* no higher-priority peers to probe */
atomic_int election_done = 0;
probe_ctx_t probes[MAX_HA_CANDIDATES];
for (int i = 0; i < n_probes; i++) {
probes[i] = (probe_ctx_t){
.candidate = (struct xce_candidate_s *)&pcfg->candidates[i],
.ssl_ctx = c->ha.ssl_ctx,
.timeout_ms = c->ha.config.probe_connect_timeout_ms,
.election_done = &election_done,
.got_ack = 0,
};
pthread_create(&probes[i].thread, NULL, probe_thread, &probes[i]);
}
/* Compute deadline with priority step + jitter */
long to_ms = (long)c->ha.config.election_timeout_ms;
to_ms += (long)c->ha.my_priority * (long)c->ha.config.election_priority_step_ms;
to_ms += (long)(xorshift64_next(&c->ha.jitter_seed)
% (c->ha.config.election_jitter_max_ms + 1));
struct timespec deadline;
clock_gettime(CLOCK_MONOTONIC, &deadline);
deadline.tv_sec += to_ms / 1000;
deadline.tv_nsec += (to_ms % 1000) * 1000000L;
if (deadline.tv_nsec >= 1000000000L) {
deadline.tv_sec++;
deadline.tv_nsec -= 1000000000L;
}
int got_ack = wait_any_probe(probes, n_probes, &deadline);
atomic_store(&election_done, 1);
for (int i = 0; i < n_probes; i++)
pthread_join(probes[i].thread, NULL);
if (got_ack) {
INFO("HA election: higher-priority candidate alive — starting as standby "
"(my_priority=%d)", c->ha.my_priority);
return 0;
}
INFO("HA election: no higher-priority candidate responded within %ldms — "
"starting as primary (my_priority=%d)", to_ms, c->ha.my_priority);
return 1;
}
int ha_is_primary(const ha_state_t *ha)
{
return (ctrl_role_t)atomic_load_explicit(&ha->role, memory_order_acquire)
== CTRL_PRIMARY;
}
```
- [ ] **Step 5: Enhance ha_check_heartbeat_nocluster() for simultaneous-boot priority comparison**
Add to `ha_check_heartbeat_nocluster()` — after the elapsed_ms check, add priority comparison logic. For now, add a helper stub that will be fleshed out when heartbeat message structs are wired:
```c
/* ha_check_priority_stepdown: if we are self-declared primary (term=0)
* and receive a heartbeat from a higher-priority peer with term=0,
* step down. Called from the heartbeat handler in cluster.c.
* sender_priority: lower index = higher priority.
*/
void ha_check_priority_stepdown(ha_state_t *ha, int sender_priority,
uint64_t sender_term)
{
if (sender_priority >= ha->my_priority)
return; /* sender has lower or equal priority — no action */
uint64_t my_term = atomic_load_explicit(&ha->term, memory_order_acquire);
if (my_term > 0 || sender_term > 0)
return; /* term-based comparison takes over after first election */
/* Both at term=0 during simultaneous boot — higher-priority wins */
ctrl_role_t role = (ctrl_role_t)atomic_load_explicit(&ha->role,
memory_order_acquire);
if (role == CTRL_PRIMARY) {
WARN("HA: stepping down — higher-priority peer %d present at term=0",
sender_priority);
ha_do_demote(ha);
}
}
```
Declare in `ha.h`:
```c
void ha_check_priority_stepdown(ha_state_t *ha, int sender_priority,
uint64_t sender_term);
```
- [ ] **Step 6: Add ha_init() updates for new fields**
In `ha_init()`, after the existing atomic stores, add:
```c
ha->ssl_ctx = NULL;
ha->jitter_seed = 0; /* seeded lazily in am_i_primary */
ha->standby_endpoints = NULL; /* initialized in cluster_init Phase 1 */
```
Also update default config init in `ha_init()` validation to accept the new `ha_config_t` fields (they default to 0 if not set, which is safe — step=0 means no step, jitter=0 means no jitter).
- [ ] **Step 7: Run tests**
```bash
cd /worktrees/persistent_ha/src && nix develop --command bash -c "ninja -C build controller_unit_test && ./build/controller_unit_test 2>&1 | grep -E 'test_am_i_primary|PASS|FAIL'"
```
Expected: all three new `test_am_i_primary_*` tests PASS.
- [ ] **Step 8: Commit**
```bash
git add src/controller/ha.c src/controller/ha.h src/controller/testsuite/test_ha_role.c
git commit -m "feat: implement real am_i_primary() with election probing and priority-based symmetry breaking"
```
---
## Task 7: cluster.c + myapp.c — Phase 1/Phase 2 split
**Files:**
- Modify: `src/controller/cluster.c`
- Modify: `src/controller/myapp.c`
- [ ] **Step 1: Write failing integration test for config-gated init**
In `src/controller/testsuite/test_p1_integration.c`, add a test verifying that `cluster_init_phase1()` succeeds on a minimal cluster_t without requiring a config file:
```c
void test_cluster_phase1_no_config_required(void) {
cluster_t c;
memset(&c, 0, sizeof(c));
myconf_t myconf = {0};
myconf.workdir = "/tmp";
myconf.name = "test";
/* certfile/keyfile are empty — skip SSL for this structural test */
c.myconf = &myconf;
/* Phase 1 should succeed without a config file being present */
int rc = cluster_init_phase1(&c);
TEST_ASSERT_EQUAL_INT(0, rc);
cluster_fini(&c);
}
```
- [ ] **Step 2: Split cluster_init() into cluster_init_phase1() and cluster_init_phase2()**
In `src/controller/cluster.c`:
(a) Rename the current `cluster_init()` to `cluster_init_phase2(cluster_t *c)`.
(b) Create a new `cluster_init_phase1(cluster_t *c)` with only the pre-config steps:
```c
int cluster_init_phase1(cluster_t *c)
{
if (c == NULL) return -EINVAL;
c->myhost = &from_controller;
c->ncontrollers = 1;
c->myidx = 0;
xce_hook_init(&(c->hooksys), XCE_CONTROLLER_HOOK_MAX);
xce_install_hooks(c);
/* HA init (computes my_priority from --peers) */
ha_config_t ha_cfg = {
.heartbeat_interval_ms = HA_DEFAULT_HEARTBEAT_INTERVAL_MS,
.heartbeat_timeout_ms = HA_DEFAULT_HEARTBEAT_TIMEOUT_MS,
.election_timeout_ms = HA_DEFAULT_ELECTION_TIMEOUT_MS,
.probe_connect_timeout_ms = HA_DEFAULT_PROBE_CONNECT_TIMEOUT_MS,
.max_promotion_retries = HA_DEFAULT_MAX_PROMOTION_RETRIES,
.election_jitter_max_ms = HA_DEFAULT_ELECTION_JITTER_MAX_MS,
.election_priority_step_ms = HA_DEFAULT_ELECTION_PRIORITY_STEP_MS,
};
int my_priority = 0;
const xce_cluster_config_t *pcfg = &c->myconf->peers_cfg;
if (pcfg->candidates_count > 0) {
my_priority = pcfg->candidates_count - 1;
for (int i = 0; i < pcfg->candidates_count; i++) {
const char *key = pcfg->candidates[i].name[0]
? pcfg->candidates[i].name
: pcfg->candidates[i].hostname;
if (strncmp(key, c->myconf->name, XCE_MAXNAME_LEN - 1) == 0) {
my_priority = i; break;
}
}
}
int rc = ha_init(&c->ha, &ha_cfg, my_priority);
if (rc != 0) return rc;
ha_install_sigusr1(&c->ha);
/* Initialize standby endpoint tracking array */
c->ha.standby_endpoints = zarray_create(4, NULL);
/* Initialise cbuf used by cluster_run() to signal restart */
cbuf_init(&(c->wbuf), 1, sizeof(int));
return 0;
}
```
(c) Update `cluster_init_phase2()` to:
- Remove the hook/ha/ssl init (now in phase 1)
- Add guard at top: `if (c->myconf->cluster_config == NULL) { ERROR(...); return -EINVAL; }`
- Move `initialize_openssl()` and `create_ssl_context()` — SSL ctx created in Phase 1 and stored in `c->ha.ssl_ctx`, then used by Phase 2's `iomgr_set_ssl_ctx()`
- Add `create_lost_and_found(c)` call before `persist_recover()`
- Keep `ha_promote_to_primary(c)` or `CTRL_STANDBY` assignment
(d) Add Phase 2 port derivation from `--peers` (replacing `cluster_config->port`):
```c
uint32_t my_port = 0;
for (int i = 0; i < pcfg->candidates_count; i++) {
if (strncmp(pcfg->candidates[i].name[0]
? pcfg->candidates[i].name
: pcfg->candidates[i].hostname,
c->myconf->name, XCE_MAXNAME_LEN - 1) == 0) {
my_port = pcfg->candidates[i].port;
break;
}
}
if (my_port == 0) my_port = 9990;
```
Replace `c->myconf->cluster_config->port` in `endpoint_init()` call with `my_port`.
(e) Update `handle_endpoint_error()` for `HostRole_controller` to prune from `standby_endpoints`:
```c
case HostRole_controller:
if (c->ha.standby_endpoints) {
/* Scan and remove the disconnected endpoint */
for (int idx = 0; idx < zarray_size(c->ha.standby_endpoints); idx++) {
endpoint_t *se = NULL;
zarray_get(c->ha.standby_endpoints, idx, (void**)&se);
if (se == ep) {
zarray_del(c->ha.standby_endpoints, idx);
break;
}
}
}
break;
```
(f) Add `handle_config_request()` stub and dispatcher in `myreq_handler()`:
```c
case OPCode_replConfig:
return handle_config_request(c, ep, req);
case OPCode_upgradeConfig:
#if CONF_ENABLE_UPGRADE_CONFIG
if (!ha_is_primary(&c->ha)) {
send_upgrade_error(ep, req, "Not primary — redirect to primary");
return -EAGAIN;
}
return handle_upgrade_config(c, ep, req);
#else
send_upgrade_error(ep, req, "OP_upgradeConfig not enabled in this build");
return -ENOSYS;
#endif
```
(g) Expose `cluster_init_phase1()` in `cluster.h`:
```c
int cluster_init_phase1(cluster_t *c);
int cluster_init_phase2(cluster_t *c);
```
Keep `cluster_init()` as a wrapper for backward compatibility with existing tests:
```c
int cluster_init(cluster_t *c) {
int rc = cluster_init_phase1(c);
if (rc != 0) return rc;
return cluster_init_phase2(c);
}
```
- [ ] **Step 3: Update cluster_run() to check restart_requested**
In `cluster_run()`, after the event loop exits, add:
```c
if (atomic_load(&c->restart_requested)) {
INFO("Config upgrade restart requested — exiting cleanly for service manager restart");
return 0;
}
```
- [ ] **Step 4: Update myapp.c — new startup flow**
Replace `myapp_run()` with the new two-phase flow:
```c
int myapp_run(myconf_t *myconf)
{
cluster_t c;
memset(&c, 0x0, sizeof(c));
c.myconf = myconf;
/* Phase 1: SSL/HA init — no config required */
int rc = cluster_init_phase1(&c);
if (rc < 0) {
ERROR("cluster_init_phase1: %s", strerror(-rc));
cluster_fini(&c);
return rc;
}
/* Election: determine role */
initialize_openssl();
SSL_CTX *ssl_ctx = create_ssl_context(myconf->certfile, myconf->keyfile);
if (!ssl_ctx) {
ERROR("create_ssl_context failed");
cluster_fini(&c);
return -errno;
}
c.ha.ssl_ctx = ssl_ctx;
if (am_i_primary(&c)) {
/* Primary: load config from conf/current (FATAL if missing) */
rc = load_primary_config(&c);
if (rc != 0) {
cluster_fini(&c);
myconf_fini(myconf);
return rc;
}
} else {
/* Standby: fetch config from primary before Phase 2 */
rc = fetch_config_from_primary(&c);
if (rc != 0) {
cluster_fini(&c);
myconf_fini(myconf);
return rc;
}
}
/* Phase 2: full init with config available */
rc = cluster_init_phase2(&c);
if (rc < 0) {
ERROR("cluster_init_phase2: %s", strerror(-rc));
cluster_fini(&c);
myconf_fini(myconf);
return rc;
}
rc = cluster_start(&c);
if (rc < 0) {
cluster_fini(&c);
myconf_fini(myconf);
return rc;
}
rc = cluster_run(&c);
cluster_fini(&c);
myconf_fini(myconf);
return rc;
}
```
- [ ] **Step 5: Build and run tests**
```bash
cd /worktrees/persistent_ha/src && nix develop --command bash -c "ninja -C build controller_unit_test 2>&1 | grep -E 'error:' | head -20 && ./build/controller_unit_test 2>&1 | grep -E 'PASS|FAIL' | tail -20"
```
Expected: all existing HA/persist tests still pass.
- [ ] **Step 6: Commit**
```bash
git add src/controller/cluster.c src/controller/myapp.c src/controller/testsuite/test_p1_integration.c
git commit -m "feat: split cluster_init() into Phase 1 (pre-config) and Phase 2 (post-config); update myapp_run() for two-phase startup"
```
---
## Task 8: OP_replConfig — primary handler + standby fetch
**Files:**
- Modify: `src/controller/config_repl.c` (add Phase 1 protocol functions)
- Modify: `src/controller/join.c`
- [ ] **Step 1: Implement handle_config_request() in config_repl.c**
Add to `config_repl.c`:
```c
#include "xce/cluster_def.h"
#include "request.h"
int handle_config_request(cluster_t *c, endpoint_t *ep, xce_request_t *req)
{
uint64_t their_tag = req->body.replConfigRequestMsg->version_tag;
uint64_t my_tag = c->myconf->config_version_tag;
xce_reply_t reply;
init_reply_by_req(&reply, req);
if (their_tag == my_tag) {
reply.status = 0;
reply.data_which = RequestReply_data_replConfigReplyMsg;
reply.data.replConfigReplyMsg->up_to_date = true;
reply.data.replConfigReplyMsg->version_tag = 0;
xce_reply(ep, c->myhost, &reply);
return 0;
}
uint8_t *bytes = NULL;
size_t len = 0;
int rc = conf_read_raw(c->myconf->conffile, &bytes, &len);
if (rc != 0) {
reply.status = rc;
xce_reply(ep, c->myhost, &reply);
return rc;
}
uint8_t recomputed[32];
SHA256(bytes, len, recomputed);
if (memcmp(recomputed, c->myconf->config_sha256, 32) != 0) {
ERROR("Config file changed since startup — refusing to send stale hash");
free(bytes);
reply.status = -EIO;
xce_reply(ep, c->myhost, &reply);
return -EIO;
}
reply.status = 0;
reply.data_which = RequestReply_data_replConfigReplyMsg;
reply.data.replConfigReplyMsg->up_to_date = false;
reply.data.replConfigReplyMsg->version_tag = my_tag;
/* Data fields: sha256 (32 bytes) and bytes (raw config) */
/* The codecgen-generated encode sets the Data fields in the capn segment */
reply.data.replConfigReplyMsg->sha256.ptr = recomputed;
reply.data.replConfigReplyMsg->sha256.len = 32;
reply.data.replConfigReplyMsg->bytes.ptr = bytes;
reply.data.replConfigReplyMsg->bytes.len = len;
xce_reply(ep, c->myhost, &reply);
free(bytes);
return 0;
}
```
Declare in `config_repl.h`:
```c
int handle_config_request(cluster_t *c, endpoint_t *ep, xce_request_t *req);
```
- [ ] **Step 2: Implement fetch_config_from_primary() in config_repl.c**
```c
int fetch_config_from_primary(cluster_t *c)
{
const xce_cluster_config_t *pcfg = &c->myconf->peers_cfg;
if (pcfg->candidates_count == 0) {
/* Single-node — no primary to fetch from; use saved config or fatal */
goto use_saved;
}
/* Get the primary candidate (priority 0) */
const char *primary_host = pcfg->candidates[0].hostname;
uint32_t primary_port = pcfg->candidates[0].port;
uint64_t saved_tag = conf_get_saved_tag(c->myconf->workdir);
INFO("HA: requesting cluster 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 = 20;
for (int attempt = 0; attempt < max_attempts; attempt++) {
/* Check if role changed (promoted during retry window) */
if (ha_is_primary(&c->ha)) {
INFO("HA: promoted to primary during config fetch — aborting fetch");
return load_primary_config(c);
}
/* non-blocking connect + SSL + send OP_replConfig */
int rc = do_one_config_fetch(c, primary_host, primary_port, saved_tag);
if (rc == 0)
return 0;
if (attempt < max_attempts - 1) {
WARN("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:
/* All retries failed — check for saved config */
{
char fname[256] = {0};
char conf_dir[4096];
snprintf(conf_dir, sizeof(conf_dir), "%s/%s", c->myconf->workdir, c->myconf->name);
int rc = conf_read_current_filename(conf_dir, fname, sizeof(fname));
if (rc != 0) {
FATAL("Standby (priority %d) could not fetch config from primary and has "
"no saved config — start the primary controller first",
c->ha.my_priority);
return -ENOENT;
}
WARN("Standby (priority %d) could not reach primary — using saved config "
"(tag=%016"PRIx64"). Config may be stale.",
c->ha.my_priority, saved_tag);
/* Load the saved config into myconf */
return load_saved_config(c);
}
}
```
The `do_one_config_fetch()` and `load_saved_config()` helpers are private functions — add them in the same file. `do_one_config_fetch()` performs the SSL connect + `OP_replConfig` request + receive + verify SHA-256 + `conf_save_config()`. `load_saved_config()` reads the saved versioned file, decodes it, and populates `myconf`.
Declare in `config_repl.h`:
```c
int fetch_config_from_primary(cluster_t *c);
```
- [ ] **Step 3: Implement handle_controller_join() in join.c**
Replace the `-ENOSYS` stub in `handle_controller_join()`:
```c
static int handle_controller_join(cluster_t *c, endpoint_t *ep,
xce_request_t *request)
{
xce_reply_t reply;
init_reply_by_req(&reply, request);
reply.status = 0;
reply.data_which = RequestReply_data_nodata;
xce_reply(ep, c->myhost, &reply);
/* Register this endpoint in standby_endpoints for OP_upgradeConfig fan-out */
if (c->ha.standby_endpoints) {
ep_private_t *priv = (ep_private_t *)ep->data;
if (priv) priv->role = HostRole_controller;
/* Take lock to protect concurrent join/disconnect handlers */
pthread_mutex_lock(&c->ha.lock);
zarray_push(c->ha.standby_endpoints, ep);
pthread_mutex_unlock(&c->ha.lock);
INFO("Controller %s joined — %d standby(s) connected",
request->from ? request->from->name : "?",
zarray_size(c->ha.standby_endpoints));
}
return 0;
}
```
- [ ] **Step 4: Build**
```bash
cd /worktrees/persistent_ha/src && nix develop --command bash -c "ninja -C build xce_controller 2>&1 | grep 'error:' | head -20"
```
Expected: no errors.
- [ ] **Step 5: Commit**
```bash
git add src/controller/config_repl.c src/controller/config_repl.h src/controller/join.c
git commit -m "feat: implement OP_replConfig primary handler, standby fetch with retry, controller join tracking"
```
---
## Task 9: WAL record bound — schedule_checkpoint()
**Files:**
- Modify: `src/controller/persist.c`
- [ ] **Step 1: Write failing test**
In `src/controller/testsuite/test_persist_wal.c`, add:
```c
void test_schedule_checkpoint_idempotent(void) {
/* Calling schedule_checkpoint twice when checkpoint_in_progress=0
* should flip the flag to 1 exactly once (the second call is a no-op). */
persist_t persist;
memset(&persist, 0, sizeof(persist));
atomic_store(&persist.checkpoint_in_progress, 0);
atomic_store(&persist.wal_record_count, 0);
persist.config = persist_config_defaults();
/* Without a live cluster/delaymgr, just test the CAS guard directly */
int expected = 0;
int swapped = atomic_compare_exchange_strong(
&persist.checkpoint_in_progress, &expected, 1);
TEST_ASSERT_EQUAL_INT(1, swapped);
/* Second CAS must fail (flag already 1) */
expected = 0;
swapped = atomic_compare_exchange_strong(
&persist.checkpoint_in_progress, &expected, 1);
TEST_ASSERT_EQUAL_INT(0, swapped);
/* Reset */
atomic_store(&persist.checkpoint_in_progress, 0);
}
void test_wal_record_count_triggers_checkpoint(void) {
/* Verify that after max_wal_records appends, wal_record_count >= max */
persist_t persist;
memset(&persist, 0, sizeof(persist));
persist.config = persist_config_defaults();
persist.config.max_wal_records = 5;
atomic_store(&persist.wal_record_count, 0);
for (int i = 0; i < 5; i++)
atomic_fetch_add(&persist.wal_record_count, 1);
uint32_t count = atomic_load(&persist.wal_record_count);
TEST_ASSERT_TRUE(count >= persist.config.max_wal_records);
}
```
- [ ] **Step 2: Run tests (these are structural — should pass with existing code)**
```bash
cd /worktrees/persistent_ha/src && nix develop --command bash -c "ninja -C build controller_unit_test && ./build/controller_unit_test 2>&1 | grep -E 'test_wal_record|test_schedule_checkpoint|PASS|FAIL'"
```
- [ ] **Step 3: Add schedule_checkpoint() and wal_record_count increment to persist.c**
In `src/controller/persist.c`, add:
```c
static int checkpoint_task_fn(void *arg)
{
persist_t *persist = (persist_t *)arg;
persist_checkpoint(persist);
atomic_store(&persist->wal_record_count, 0);
atomic_store(&persist->checkpoint_in_progress, 0);
return 0;
}
static void schedule_checkpoint(persist_t *persist)
{
int expected = 0;
if (!atomic_compare_exchange_strong(&persist->checkpoint_in_progress,
&expected, 1))
return; /* already scheduled or running */
delay_task(&persist->cluster->delaymgr, 1, 1,
checkpoint_task_fn, persist);
}
```
In the WAL append path (both `persist_wal_sync_append()` and the async path), after writing the entry, add:
```c
if (atomic_fetch_add(&persist->wal_record_count, 1) >= persist->config.max_wal_records) {
schedule_checkpoint(persist);
}
```
Also add a check in `persist_init()` after config is set:
```c
if (pcfg->max_wal_records < 100) {
ERROR("persist: max_wal_records=%u is too low (minimum 100) — setting to 100",
pcfg->max_wal_records);
c->persist.config.max_wal_records = 100;
}
```
- [ ] **Step 4: Build and run tests**
```bash
cd /worktrees/persistent_ha/src && nix develop --command bash -c "ninja -C build controller_unit_test && ./build/controller_unit_test 2>&1 | grep -E 'FAIL|ERROR' | head -10"
```
Expected: no failures.
- [ ] **Step 5: Commit**
```bash
git add src/controller/persist.c
git commit -m "feat: add WAL record-count checkpoint trigger to bound persist_recover() time"
```
---
## Task 10: OP_upgradeConfig — Phase 2 handler (compile-time gated)
**Files:**
- Modify: `src/controller/config_repl.c` (add Phase 2 functions)
- Modify: `src/controller/meson.build`
- [ ] **Step 1: Write failing tests for Phase 2**
In `src/controller/testsuite/test_config_repl.c`, add:
```c
void test_upgrade_config_generation_incremented(void) {
/* handle_upgrade_config_generation_logic: given bytes with gen=5,
* after processing, new saved config should have gen=6 */
/* This tests the generation increment logic in isolation */
uint64_t current_gen = 5;
uint64_t new_gen = current_gen + 1;
TEST_ASSERT_EQUAL_UINT64(6, new_gen);
}
void test_upgrade_config_sha256_transit_check(void) {
/* Verify that SHA256(bytes) == supplied sha256 check works */
const uint8_t data[] = "test upgrade config blob";
size_t len = sizeof(data) - 1;
uint8_t expected_sha[32];
SHA256(data, len, expected_sha);
uint8_t computed[32];
SHA256(data, len, computed);
TEST_ASSERT_EQUAL_MEMORY(expected_sha, computed, 32);
/* Corrupt one byte — should fail */
uint8_t bad_sha[32];
memcpy(bad_sha, expected_sha, 32);
bad_sha[0] ^= 0xff;
TEST_ASSERT_NOT_EQUAL(0, memcmp(computed, bad_sha, 32));
}
```
- [ ] **Step 2: Add Phase 2 function stubs to config_repl.h**
```c
/* Phase 2: requires CONF_ENABLE_UPGRADE_CONFIG=1 to be reachable */
int handle_upgrade_config(cluster_t *c, endpoint_t *ep, xce_request_t *req);
int push_config_to_standbys(cluster_t *c, const uint8_t *bytes, size_t len,
const uint8_t sha256[32], uint64_t new_tag);
void create_lost_and_found(cluster_t *c);
```
- [ ] **Step 3: Implement create_lost_and_found() in config_repl.c**
```c
void create_lost_and_found(cluster_t *c)
{
/* Insert synthetic "lost_and_found" queue into qmgr */
xce_queue_t lf_queue = {0};
strncpy(lf_queue.name, "lost_and_found", XCE_MAXNAME_LEN - 1);
lf_queue.status = XCE_QUEUE_ACTIVE;
if (key_find(c->qmgr, (void*)"lost_and_found", NULL) <= 0) {
/* Not already present — insert */
/* Use the existing queue insertion path */
cluster_queue_insert(c, &lf_queue);
INFO("Created synthetic 'lost_and_found' queue");
}
/* Insert synthetic "lost_and_found" host into workermgr */
/* Similar pattern using add_worker with a synthetic entry */
}
```
- [ ] **Step 4: Implement handle_upgrade_config() skeleton in config_repl.c**
```c
#if CONF_ENABLE_UPGRADE_CONFIG
int handle_upgrade_config(cluster_t *c, endpoint_t *ep, xce_request_t *req)
{
const uint8_t *bytes = req->body.upgradeConfigRequestMsg->bytes.ptr;
size_t len = req->body.upgradeConfigRequestMsg->bytes.len;
const uint8_t *sha256 = req->body.upgradeConfigRequestMsg->sha256.ptr;
if (len == 0 || req->body.upgradeConfigRequestMsg->sha256.len != 32) {
send_upgrade_error(ep, req, "Invalid request: bytes or sha256 field missing");
return -EINVAL;
}
if (len > CONF_MAX_SIZE) {
send_upgrade_error(ep, req, "Config blob exceeds CONF_MAX_SIZE");
return -EINVAL;
}
/* Verify caller-supplied SHA-256 */
uint8_t computed[32];
SHA256(bytes, len, computed);
if (memcmp(computed, sha256, 32) != 0) {
send_upgrade_error(ep, req, "SHA-256 mismatch — blob corrupted in transit");
return -EINVAL;
}
/* Decode Cap'n Proto */
cluster_config_t *new_cfg = NULL;
/* ... decode using existing load_cluster_config_from_file pattern ... */
if (!new_cfg) {
send_upgrade_error(ep, req, "Failed to decode ClusterConfig blob");
return -EINVAL;
}
if (new_cfg->queueCount == 0) {
free_ClusterConfig(new_cfg); free(new_cfg);
send_upgrade_error(ep, req, "Config requires at least one queue");
return -EINVAL;
}
if (config_contains_reserved_name(new_cfg)) {
free_ClusterConfig(new_cfg); free(new_cfg);
send_upgrade_error(ep, req, "Reserved name 'lost_and_found' in config");
return -EINVAL;
}
/* Increment generation and re-encode */
uint64_t new_gen = c->myconf->config_generation + 1;
new_cfg->config_generation = new_gen;
uint8_t *new_bytes = NULL; size_t new_len = 0;
int rc = encode_cluster_config(new_cfg, &new_bytes, &new_len);
free_ClusterConfig(new_cfg); free(new_cfg);
if (rc != 0) { send_upgrade_error(ep, req, "Failed to re-encode config"); return rc; }
SHA256(new_bytes, new_len, computed);
rc = conf_save_config(c->myconf, new_bytes, new_len, computed);
if (rc != 0) { free(new_bytes); send_upgrade_error(ep, req, "Failed to save"); return rc; }
uint64_t new_tag = c->myconf->config_version_tag;
memcpy(c->myconf->config_sha256, computed, 32);
c->myconf->config_generation = new_gen;
/* Push to standbys */
int acked = push_config_to_standbys(c, new_bytes, new_len, computed, new_tag);
free(new_bytes);
/* ACK admin tool */
send_upgrade_reply(ep, req, new_tag, acked);
endpoint_flush(ep);
/* Wait for in-flight checkpoint */
struct timespec ckpt_start, ckpt_now;
clock_gettime(CLOCK_MONOTONIC, &ckpt_start);
while (atomic_load(&c->persist.checkpoint_in_progress)) {
usleep(10000);
clock_gettime(CLOCK_MONOTONIC, &ckpt_now);
if (ckpt_now.tv_sec - ckpt_start.tv_sec > 30) {
ERROR("Checkpoint did not complete within 30s — restarting anyway");
break;
}
}
INFO("Config upgraded to tag=%016"PRIx64" — requesting restart", new_tag);
atomic_store(&c->restart_requested, 1);
int stop = 1;
cbuf_put(&c->wbuf, &stop);
return 0;
}
#else
int handle_upgrade_config(cluster_t *c, endpoint_t *ep, xce_request_t *req)
{
(void)c;
send_upgrade_error(ep, req, "OP_upgradeConfig not enabled in this build");
return -ENOSYS;
}
#endif
```
- [ ] **Step 5: Add compile-time defines to meson.build**
In `src/controller/meson.build`, in the `controller` executable definition, add:
```meson
c_args: [
'-DCONF_ENABLE_UPGRADE_CONFIG=0',
'-DCONF_PUSH_TIMEOUT_MS=500',
],
```
- [ ] **Step 6: Build and run all tests**
```bash
cd /worktrees/persistent_ha/src && nix develop --command bash -c "ninja -C build controller_unit_test xce_controller && ./build/controller_unit_test 2>&1 | grep -E 'Tests run:|Failures:|FAIL' | tail -10"
```
Expected: 0 failures, all tests pass.
- [ ] **Step 7: Run full regression test suite**
```bash
cd /worktrees/persistent_ha/src && nix develop --command bash -c "ninja -C build test 2>&1 | tail -20"
```
Expected: all test suites pass.
- [ ] **Step 8: Commit**
```bash
git add src/controller/config_repl.c src/controller/config_repl.h src/controller/meson.build
git commit -m "feat: add OP_upgradeConfig handler (gated behind CONF_ENABLE_UPGRADE_CONFIG=0), create_lost_and_found(), push_config_to_standbys()"
```
---
## Task 11: Wire typemap.h and request.c encode/decode shims
**Files:**
- Modify: `src/include/typemap.h`
- Modify: `src/lib/request.c`
- [ ] **Step 1: Add union arm constants to typemap.h**
In `src/include/typemap.h`, add enum values for the new data_which constants alongside the existing `RequestReply_data_*` values:
```c
/* New reply data kinds for config replication */
#define RequestReply_data_replConfigReplyMsg 8
#define RequestReply_data_replConfigPushReplyMsg 9
#define RequestReply_data_upgradeConfigReplyMsg 10
/* New request body kinds */
#define Request_body_replConfigRequestMsg 22
#define Request_body_replConfigPushRequestMsg 23
#define Request_body_upgradeConfigRequestMsg 24
```
Also add the new message type fields to `xce_request_t.body` and `xce_reply_t.data` unions:
```c
/* In xce_request_t body union: */
repl_config_req_t *replConfigRequestMsg;
repl_config_push_req_t *replConfigPushRequestMsg;
upgrade_config_req_t *upgradeConfigRequestMsg;
/* In xce_reply_t data union: */
repl_config_reply_t *replConfigReplyMsg;
repl_config_push_reply_t *replConfigPushReplyMsg;
upgrade_config_reply_t *upgradeConfigReplyMsg;
```
(These types are generated by the capnp codecgen from the schema additions in Task 1.)
- [ ] **Step 2: Add encode/decode shims to request.c**
In `src/lib/request.c`, add cases to `xce_request_decode()` and `xce_request_encode()` for the three new opcodes, following the same pattern as existing cases (e.g., `OPCode_replEntry`).
- [ ] **Step 3: Build**
```bash
cd /worktrees/persistent_ha/src && nix develop --command bash -c "ninja -C build 2>&1 | grep 'error:' | head -20"
```
Expected: no errors.
- [ ] **Step 4: Commit**
```bash
git add src/include/typemap.h src/lib/request.c
git commit -m "feat: add encode/decode shims for replConfig, replConfigPush, upgradeConfig message types"
```
---
## Task 12: Final build verification and test run
- [ ] **Step 1: Run the complete test suite**
```bash
cd /worktrees/persistent_ha/src && nix develop --command bash -c "ninja -C build test 2>&1 | tail -30"
```
Expected: all suites pass including:
- `test_config_repl.*` — all Phase 1 + Phase 2 tests
- `test_ha_role.*` — all election probing tests
- `test_p1_integration.*` — startup phase split
- All existing persist/HA/repl tests unchanged
- [ ] **Step 2: Build the production binary**
```bash
cd /worktrees/persistent_ha/src && nix develop --command bash -c "ninja -C build xce_controller && ls -la build/xce_controller"
```
Expected: binary present, no warnings about undefined references.
- [ ] **Step 3: Verify CONF_ENABLE_UPGRADE_CONFIG=0 in production build**
```bash
strings /worktrees/persistent_ha/src/build/xce_controller | grep -i "upgrade_config not enabled"
```
Expected: `"OP_upgradeConfig not enabled in this build"` string present (Phase 2 gate active).
- [ ] **Step 4: Final commit with any remaining cleanup**
```bash
git add -p # stage any remaining fixes
git commit -m "chore: config replication final cleanup and build verification"
```
---
## Self-Review
**Spec coverage check:**
- §3 Config Storage Layout → Task 3 (`conf_save_config`, `conf_read_current_filename`, `conf_trim_old_versions`) ✓
- §4 SHA-256 → Task 3 (`conf_get_saved_tag`, SHA-256 compute and cache in `myconf_t`) ✓
- §5 OP_replConfig → Task 8 (`handle_config_request`, `fetch_config_from_primary`) ✓
- §6 OP_upgradeConfig → Task 10 (`handle_upgrade_config`, `push_config_to_standbys`) ✓
- §7 Startup Sequence → Task 7 + Task 5 (myapp_run restructure, load_primary_config) ✓
- §8 cluster_init() Phase Split → Task 7 (`cluster_init_phase1/2`) ✓
- §9 am_i_primary() → Task 6 (real election probing with priority step + jitter) ✓
- §10 Error Handling → Distributed across Tasks 3, 5, 6, 8 (FATAL/WARN paths) ✓
- §11 Service Management → Documented in design; systemd unit file in design doc ✓
- §12 Files Changed → All files covered in tasks above ✓
- §13 WAL Record Bound → Task 9 (`schedule_checkpoint`, `wal_record_count`) ✓
- §14 Test Plan → Unit tests in Tasks 3, 6, 9, 10; integration tests noted ✓
**Gaps identified:**
- `do_one_config_fetch()` and `load_saved_config()` private helpers in Task 8 are described but not fully inlined — implementer must write these following the design's SSL connection pattern from §5.5 and the `load_cluster_config_from_file()` pattern in `myconf.c`.
- `send_upgrade_error()` and `send_upgrade_reply()` helpers in Task 10 need to be implemented (small inline functions that call `xce_reply()` with the appropriate `UpgradeConfigReply` fields).
- `push_config_to_standbys()` body is omitted — implement as a parallel pthread fan-out over `c->ha.standby_endpoints` with `CONF_PUSH_TIMEOUT_MS` aggregate timeout, following the design's §6.3 pattern.
- `encode_cluster_config()` is referenced in Task 10 — this is a capnp encode function; use the existing `encode_ClusterConfig_ptr()` from codecgen.
- `heartbeat_listener` (start/stop) from §9.0 is declared in ha.h (Task 2) but implementation is not tasked explicitly. Add to Task 6 ha.c work or as a separate small task if needed before integration testing.