# 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.` 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 . * On other platforms we provide a fallback. */ #if defined(__linux__) # include #elif defined(__APPLE__) # include # include # define be64toh(x) OSSwapBigToHostInt64(x) #else # include 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 #include #include #include #include #include #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 #include #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 /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 .tmp → fsync → rename to cluster.bin., * 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 #include #include #include #include #include #include #include #include #include #include #include #include #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 #include ``` 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 #include 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 #include #include #include #include #include #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.