Merge branch 'persistent_ha' into scheduler

This commit is contained in:
Rongsong Shen 2026-05-12 10:52:46 +00:00
commit a07a74a943
106 changed files with 18528 additions and 637 deletions

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,39 @@
# XCE Persistence & HA — Implementation Overview
> **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:** Make the XCE controller survive restarts and primary failures with no job/worker/queue state loss.
**Architecture:** WAL + periodic checkpoint (dual-zarray COW) for single-controller persistence; LSF-style priority election + fan-out TCP replication for HA. libxce smart discovery handles client auto-reconnect after failover.
**Tech Stack:** C (controller/lib), Cap'n Proto (serialization), Nix flakes + meson + ninja (build), Go (API server discovery bridge)
---
## Phase Map
Each phase is an independently shippable increment. Create the next phase plan only after the current phase passes its gate tests.
| Phase | Plan file | Goal | Gate |
|-------|-----------|------|------|
| **P0** | `2026-05-07-p0-single-controller-persistence.md` | Controller survives restart | `P0-G` smoke tests green |
| **P1** | _create after P0 ships_ | Standby takes over when primary fails | Election + promotion tests green |
| **P2** | _create after P1 ships_ | Primary replicates to standbys in real time | Replication round-trip + catch-up tests green |
| **P2.5** | _create alongside P2_ | Workers/schedulers auto-reconnect after failover | Discovery + reconnect tests green |
| **P3** | _create after P2 ships_ | End-to-end failover + hardening + metrics | Full failover test + benchmark passing |
| **P4** | _create after P3 ships_ | Docs, runbook, stale-ref cleanup | Runbook review complete |
## Out of Scope (all phases)
- Admin CLI for manual failover (`xce hostcontrol --failover`)
- WAL compaction beyond checkpoint trimming
- Multi-DC replication
- Runtime config changes without restart
- Snapshot compression
- `config_version` hash in heartbeat (deferred to P3 tooling)
- Degraded-mode restricted-state timeout (`repl_degraded_timeout_s`)
- `MAX_HA_CANDIDATES > 4` / `MAX_STANDBYS > 3`
## Design Document
`src/docs/persistence-and-ha-design.md` — all implementation detail lives here. Read the relevant Part before starting each task.

View file

@ -239,7 +239,7 @@ static int apiserver_job_fail_fun(int type, int situation, void* data,
__func__, c->apiservers[i].name, rc);
}
}
mylock_lock(&(c->apiservers[i].lock));
mylock_unlock(&(c->apiservers[i].lock));
}
break;
default:

View file

@ -397,7 +397,11 @@ int cluster_build_queues(cluster_t* c)
return 0;
}
int cluster_build_groups(cluster_t* c) { return 0; }
int cluster_build_groups(cluster_t* c)
{
/* TODO: implement group building */
return 0;
}
static int xce_config_init(int type, int situation, void* data, va_list ap)
{
@ -601,13 +605,91 @@ int cluster_init(cluster_t* c)
vtaskmgr_init(&(c->vtaskmgr), c->myconf->cluster_config->vcpus);
delaymgr_init(&(c->delaymgr), 2, run_delay_tasks);
delaymgr_init(&(c->delaymgr), 200 /* precision_ms */, run_delay_tasks);
apiserver_init(c);
/* Persistence: derive paths and init WAL/snapshot directories.
* Recovery is deferred until after cluster_build_resources() runs inside
* the HOOK_AFTER_EVENT below so that resmgr is fully populated before
* restore_worker() tries to register per-host resource values. */
{
const char* workdir = c->myconf->workdir ? c->myconf->workdir
: DEFAULT_WORKDIR;
const char* name = c->myconf->name ? c->myconf->name
: "controller";
persist_config_t pcfg = persist_config_defaults();
persist_set_workdir(&c->persist, workdir, name);
rc = persist_init(&c->persist, &pcfg, c);
if (rc != 0) {
ERROR("persist_init failed: %d", rc);
return rc;
}
}
/* HA initialisation */
{
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,
};
/* Derive priority from position in --peers list.
* Index 0 = highest priority (becomes primary on startup).
* If the controller's name is not found in the list, fall back to
* the last index (lowest priority = standby). */
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; /* default: last = lowest */
for (int i = 0; i < pcfg->candidates_count; i++) {
/* Match by logical name first (supports multiple controllers
* on the same dev host via name=host:port syntax). Fall back
* to hostname match when no name was specified. */
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;
}
}
INFO("HA peers: %d candidates, my_priority=%d (name=%s)",
pcfg->candidates_count, my_priority, c->myconf->name);
}
rc = ha_init(&c->ha, &ha_cfg, my_priority);
if (rc != 0) {
ERROR("ha_init failed: %d", rc);
persist_fini(&c->persist);
return rc;
}
ha_install_sigusr1(&c->ha);
/* Determine initial role: priority-0 node starts as primary */
if (am_i_primary(c))
ha_promote_to_primary(c);
else
atomic_store_explicit(&c->ha.role, (int32_t)CTRL_STANDBY,
memory_order_release);
}
xce_run_hooks(&(c->hooksys), XCE_CONTROLLER_HOOK_CLUSTER, HOOK_AFTER_EVENT,
HOOK_DETAIL_CLUSTER_INIT, c);
/* Recover WAL/snapshot now that resmgr, jobmgr, and workermgr are
* fully populated by cluster_build_resources() above. */
rc = persist_recover(&c->persist);
if (rc != 0) {
ERROR("persist_recover failed: %d", rc);
persist_fini(&c->persist);
return rc;
}
return 0;
}
@ -753,6 +835,20 @@ int cluster_start(cluster_t* c)
vtaskmgr_start(&(c->vtaskmgr));
delaymgr_start(&(c->delaymgr));
/* Start persistence fsync thread and register WAL hooks */
rc = persist_start(&c->persist);
if (rc != 0) {
ERROR("persist_start failed: %d", rc);
return rc;
}
/* Start HA heartbeat watchdog thread — P1-5 */
rc = ha_start_watchdog(&c->ha);
if (rc != 0) {
ERROR("ha_start_watchdog failed: %d", rc);
return rc;
}
delay_task(&(c->delaymgr), c->myconf->cluster_config->heartbeat_interval,
-1, (delay_task_entry_fn_t)check_workers_timeout, (void*)c);
@ -785,6 +881,13 @@ int cluster_run(cluster_t* c)
void cluster_fini(cluster_t* c)
{
/* Stop HA watchdog thread before tearing down other subsystems — P1-5 */
ha_fini(&c->ha);
/* Stop persistence before tearing down the rest */
persist_stop(&c->persist);
persist_fini(&c->persist);
delaymgr_fini(&(c->delaymgr));
vtaskmgr_fini(&(c->vtaskmgr));
endpoint_fini(&(c->ep));

View file

@ -0,0 +1,68 @@
/*
* ctrl_metrics.c — P3-7: Operational metrics for the XCE controller.
*
* Defines the 7 atomic counters/gauges and the descriptor table used
* for metric discovery. All variables are initialised to 0 at program
* start (C guarantees this for file-scope variables), and are zeroed
* explicitly by ctrl_metrics_init() which is called from persist_start()
* so that tests can call it repeatedly without interference.
*/
#include "ctrl_metrics.h"
#include <stdatomic.h>
#include <stddef.h>
#include <stdint.h>
/* ── Counter/gauge definitions ──────────────────────────────────────── */
_Atomic uint64_t ctrl_repl_degraded_count = 0;
_Atomic uint64_t ctrl_split_brain_detected = 0;
_Atomic uint64_t ctrl_async_events_lost = 0;
_Atomic uint64_t ctrl_transient_jobs_stuck = 0;
_Atomic uint64_t ctrl_wal_bytes = 0;
_Atomic uint64_t ctrl_checkpoint_duration_ms = 0;
_Atomic uint64_t ctrl_repl_lag_lsn = 0;
/* ── Descriptor table ───────────────────────────────────────────────── */
const ctrl_metric_desc_t ctrl_metric_descriptors[] = {
{ "ctrl_repl_degraded_count",
&ctrl_repl_degraded_count,
"Sync replication fallbacks to local-only durability",
1 },
{ "ctrl_split_brain_detected",
&ctrl_split_brain_detected,
"Term conflicts indicating split-brain risk",
1 },
{ "ctrl_async_events_lost",
&ctrl_async_events_lost,
"Async WAL entries lost during failover",
1 },
{ "ctrl_transient_jobs_stuck",
&ctrl_transient_jobs_stuck,
"TRANSIENT jobs auto-terminated after timeout",
1 },
{ "ctrl_wal_bytes",
&ctrl_wal_bytes,
"Current WAL size in bytes",
0 },
{ "ctrl_checkpoint_duration_ms",
&ctrl_checkpoint_duration_ms,
"Duration of last checkpoint in milliseconds",
0 },
{ "ctrl_repl_lag_lsn",
&ctrl_repl_lag_lsn,
"LSN gap between primary and slowest standby",
0 },
{ NULL, NULL, NULL, 0 } /* sentinel */
};
/* ── ctrl_metrics_init ──────────────────────────────────────────────── */
void ctrl_metrics_init(void)
{
atomic_store_explicit(&ctrl_repl_degraded_count, 0, memory_order_relaxed);
atomic_store_explicit(&ctrl_split_brain_detected, 0, memory_order_relaxed);
atomic_store_explicit(&ctrl_async_events_lost, 0, memory_order_relaxed);
atomic_store_explicit(&ctrl_transient_jobs_stuck, 0, memory_order_relaxed);
atomic_store_explicit(&ctrl_wal_bytes, 0, memory_order_relaxed);
atomic_store_explicit(&ctrl_checkpoint_duration_ms, 0, memory_order_relaxed);
atomic_store_explicit(&ctrl_repl_lag_lsn, 0, memory_order_relaxed);
}

258
src/controller/ha.c Normal file
View file

@ -0,0 +1,258 @@
#define _GNU_SOURCE
#include "ha.h"
#include <errno.h>
#include <pthread.h>
#include <signal.h>
#include <stdatomic.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* Circular-include safety: cluster_def.h includes ha.h (include-guarded),
* and ha.h only forward-declares cluster_t — no cycle. */
#include "xce/cluster_def.h"
#include "persist.h"
int ha_init(ha_state_t *ha, const ha_config_t *cfg, int my_priority)
{
if (!ha || !cfg)
return -EINVAL;
if (cfg->heartbeat_interval_ms < 2000)
return -EINVAL;
if (cfg->heartbeat_timeout_ms < cfg->heartbeat_interval_ms)
return -EINVAL;
if (cfg->election_timeout_ms < 500)
return -EINVAL;
memset(ha, 0, sizeof(*ha));
ha->config = *cfg;
ha->my_priority = my_priority;
atomic_store_explicit(&ha->role, (int32_t)CTRL_STANDBY, memory_order_release);
atomic_store_explicit(&ha->term, 0ULL, memory_order_release);
atomic_store_explicit(&ha->applied_lsn, 0ULL, memory_order_release);
atomic_store_explicit(&ha->promotion_failed, 0, memory_order_relaxed);
atomic_store_explicit(&ha->promotion_retry_count, 0, memory_order_relaxed);
atomic_store_explicit(&ha->in_catch_up, 0, memory_order_relaxed);
atomic_store_explicit(&ha->failover_requested, 0, memory_order_relaxed);
atomic_store_explicit(&ha->watchdog_stop, 0, memory_order_relaxed);
pthread_mutex_init(&ha->lock, NULL);
return 0;
}
/* ── Promotion / demotion / SIGUSR1 ────────────────────────── */
/* Async-signal-safe: atomic store on int-width type (POSIX.1-2017 §2.4.3).
* Pointer set once before signals are enabled — no locking required. */
static ha_state_t *g_sigusr1_ha = NULL;
static void sigusr1_handler(int sig)
{
(void)sig;
if (g_sigusr1_ha)
atomic_store_explicit(&g_sigusr1_ha->failover_requested, 1,
memory_order_release);
}
void ha_install_sigusr1(ha_state_t *ha)
{
g_sigusr1_ha = ha;
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = sigusr1_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
sigaction(SIGUSR1, &sa, NULL);
}
void ha_do_promote(ha_state_t *ha)
{
/* Monotonically increasing fencing token — standbys reject entries from lower terms */
atomic_fetch_add_explicit(&ha->term, 1, memory_order_acq_rel);
atomic_store_explicit(&ha->role, (int32_t)CTRL_PRIMARY, memory_order_release);
/* COH-5: new primary starts a fresh CRC chain; standbys reset their chain on catch-up */
ha->last_received_crc = WAL_CRC_NONE;
}
void ha_do_demote(ha_state_t *ha)
{
atomic_store_explicit(&ha->role, (int32_t)CTRL_STANDBY, memory_order_release);
/* Reset so the next ha_promote_to_primary() re-registers hooks idempotently */
ha->hooks_registered = 0;
/* Hook disable requires hooksys_t — done by ha_demote_to_standby(cluster_t*) */
}
/* ── P3-6: Test-only failover shim ────────────────────────────── */
#ifdef XCE_TESTING
uint64_t ha_trigger_manual_failover_for_test(ha_state_t *ha)
{
ha_do_demote(ha);
ha_do_promote(ha);
return atomic_load_explicit(&ha->term, memory_order_acquire);
}
#endif
/* ── Watchdog thread implementation ────────────────────────── */
void ha_check_heartbeat_nocluster(ha_state_t *ha)
{
ctrl_role_t role = (ctrl_role_t)atomic_load_explicit(&ha->role, memory_order_acquire);
/* Only standbys watch for primary heartbeat timeout */
if (role != CTRL_STANDBY) return;
/* If we have no primary endpoint, nothing to monitor */
if (ha->primary_host[0] == '\0') return;
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
pthread_mutex_lock(&ha->lock);
struct timespec last = ha->last_heartbeat;
pthread_mutex_unlock(&ha->lock);
uint64_t elapsed_ms =
(uint64_t)(now.tv_sec - last.tv_sec) * 1000ULL +
(uint64_t)(now.tv_nsec - last.tv_nsec) / 1000000ULL;
if (elapsed_ms >= ha->config.heartbeat_timeout_ms) {
/* Primary appears dead — set failover_requested; watchdog acts on it */
atomic_store_explicit(&ha->failover_requested, 1, memory_order_release);
}
}
static void *ha_watchdog_thread(void *arg)
{
ha_state_t *ha = arg;
while (!atomic_load_explicit(&ha->watchdog_stop, memory_order_acquire)) {
long ms = (long)ha->config.heartbeat_interval_ms;
struct timespec ts = {
.tv_sec = ms / 1000,
.tv_nsec = (ms % 1000) * 1000000L,
};
nanosleep(&ts, NULL);
/* Re-check stop flag after sleep */
if (atomic_load_explicit(&ha->watchdog_stop, memory_order_acquire)) break;
ha_check_heartbeat_nocluster(ha);
if (atomic_load_explicit(&ha->failover_requested, memory_order_acquire)) {
atomic_store_explicit(&ha->failover_requested, 0, memory_order_release);
/* Promotion requires cluster_t; the watchdog only has ha_state_t.
* Full ha_promote_to_primary(c) is called from the cluster's own
* failover path which holds cluster_t context. */
}
}
return NULL;
}
int ha_start_watchdog(ha_state_t *ha)
{
atomic_store_explicit(&ha->watchdog_stop, 0, memory_order_release);
int rc = pthread_create(&ha->watchdog_thread, NULL, ha_watchdog_thread, ha);
if (rc != 0) return -rc;
return 0;
}
void ha_stop_watchdog(ha_state_t *ha)
{
atomic_store_explicit(&ha->watchdog_stop, 1, memory_order_release);
if (ha->watchdog_thread != 0) {
pthread_join(ha->watchdog_thread, NULL);
ha->watchdog_thread = 0;
}
}
void ha_fini(ha_state_t *ha)
{
if (!ha)
return;
ha_stop_watchdog(ha); /* join watchdog thread before destroying mutex */
pthread_mutex_destroy(&ha->lock);
}
/* ── Redirect helpers used by join.c ───────────────────────── */
/*
* ha_should_redirect: returns 1 when this standby knows the primary's
* address and should redirect incoming join requests there.
* Returns 0 for PRIMARY role, or for STANDBY with no known primary yet.
*/
int ha_should_redirect(const ha_state_t *ha)
{
ctrl_role_t role =
(ctrl_role_t)atomic_load_explicit(&ha->role, memory_order_acquire);
if (role == CTRL_PRIMARY)
return 0;
if (ha->primary_host[0] == '\0' || ha->primary_port == 0)
return 0;
return 1;
}
/*
* ha_no_primary_known: returns 1 when this node is a STANDBY but does not
* yet know the primary's address. Callers should reply E_XCE_NOMASTER so
* the client retries rather than treating the error as permanent.
*/
int ha_no_primary_known(const ha_state_t *ha)
{
ctrl_role_t role =
(ctrl_role_t)atomic_load_explicit(&ha->role, memory_order_acquire);
if (role == CTRL_PRIMARY)
return 0;
return (ha->primary_host[0] == '\0' || ha->primary_port == 0) ? 1 : 0;
}
/* priority-0 node is primary; full election probing is a future enhancement */
int am_i_primary(cluster_t *c)
{
return c->ha.my_priority == 0;
}
/*
* ha_promote_to_primary: increment term, set role=PRIMARY, then register
* persistence hooks (idempotency guard via hooks_registered).
* Safe to call on a zero-init cluster_t (zeroed hooksys → xce_add_hook
* returns -EINVAL harmlessly; hooks_registered is still set to 1).
*/
int ha_promote_to_primary(cluster_t *c)
{
ha_do_promote(&c->ha);
/* Register persistence hooks — idempotency guard prevents double-add */
if (c->ha.hooks_registered == 0) {
xce_add_hook(&c->hooksys, XCE_CONTROLLER_HOOK_JOB,
(hook_fn_t)persist_job_hook_fun, &c->persist);
xce_add_hook(&c->hooksys, XCE_CONTROLLER_HOOK_QUEUE,
(hook_fn_t)persist_queue_hook_fun, &c->persist);
xce_add_hook(&c->hooksys, XCE_CONTROLLER_HOOK_WORKER,
(hook_fn_t)persist_worker_hook_fun, &c->persist);
c->ha.hooks_registered = 1;
}
return 0;
}
/*
* ha_demote_to_standby: disable persistence hooks, then set role=STANDBY
* and reset the hooks_registered idempotency guard (via ha_do_demote).
*/
int ha_demote_to_standby(cluster_t *c)
{
/* Disable persistence hooks before changing role */
if (c->ha.hooks_registered) {
xce_disable_hook(&c->hooksys, XCE_CONTROLLER_HOOK_JOB,
(hook_fn_t)persist_job_hook_fun);
xce_disable_hook(&c->hooksys, XCE_CONTROLLER_HOOK_QUEUE,
(hook_fn_t)persist_queue_hook_fun);
xce_disable_hook(&c->hooksys, XCE_CONTROLLER_HOOK_WORKER,
(hook_fn_t)persist_worker_hook_fun);
}
ha_do_demote(&c->ha); /* sets role=STANDBY, resets hooks_registered=0 */
return 0;
}

180
src/controller/ha.h Normal file
View file

@ -0,0 +1,180 @@
#ifndef HA_H
#define HA_H
/*
* XCE Controller HA — role management and election (P1).
*
* P1-1 audit: the existing create_ssl_context() + iomgr_set_ssl_ctx() path
* in cluster.c (line 559, set at line 583) covers all controller-to-controller SSL.
* HA listeners (heartbeat, election probe, replication accept) reuse the same
* ssl_ctx already installed on c->iomgr.
* No new TLS infrastructure is needed — XCE runs on a trusted management LAN.
* Peer identity is validated by host:port match against the --peers candidate list.
* See design doc Part 5.9 and memory: Persistence & HA Trusted LAN Decision.
*
* P1-2: full struct definitions and API.
* endpoint_t* (primary_ep) is intentionally omitted here — including netio.h
* would pull in heavy OpenSSL headers and create circular dependency risks.
* The watchdog (P1-3) will include netio.h directly where it needs endpoint_t.
*/
#include <pthread.h>
#include <stdatomic.h>
#include <stdint.h>
#include <time.h>
#include "config.h" /* XCE_MAXNAME_LEN, MAX_STANDBYS, etc. */
/* Forward declaration — avoids circular include with cluster_def.h */
typedef struct cluster_s cluster_t;
/* ── Role enum ─────────────────────────────────────────────── */
typedef enum {
CTRL_PRIMARY = 0,
CTRL_STANDBY = 1
} ctrl_role_t;
/* ── HA configuration ──────────────────────────────────────── */
/* Default values used by cluster_init(). Tuned for a LAN with ≤5ms RTT. */
#define HA_DEFAULT_HEARTBEAT_INTERVAL_MS 3000
#define HA_DEFAULT_HEARTBEAT_TIMEOUT_MS 10000
#define HA_DEFAULT_ELECTION_TIMEOUT_MS 2000
#define HA_DEFAULT_PROBE_CONNECT_TIMEOUT_MS 500
#define HA_DEFAULT_MAX_PROMOTION_RETRIES 5
typedef struct {
uint32_t heartbeat_interval_ms; /* Primary heartbeat send interval (min 2000 ms) */
uint32_t heartbeat_timeout_ms; /* Standby timeout before promoting (>= interval) */
uint32_t election_timeout_ms; /* Election probe shared deadline (min 500 ms) */
uint32_t probe_connect_timeout_ms; /* Per-probe TCP connect timeout */
uint32_t max_promotion_retries; /* Max consecutive failed promotion attempts */
} ha_config_t;
/* ── HA runtime state (embedded in cluster_t as `ha_state_t ha` in P1-5) ── */
typedef struct {
_Atomic uint64_t term; /* Election term — fencing token */
_Atomic int32_t role; /* Cast to ctrl_role_t at access sites */
int my_priority; /* Index in --peers list (lower = higher priority) */
_Atomic uint64_t applied_lsn; /* Standby: last applied WAL LSN */
char primary_host[XCE_MAXNAME_LEN]; /* Hostname of current primary */
uint32_t primary_port; /* Port of current primary */
struct timespec last_heartbeat; /* CLOCK_MONOTONIC timestamp of last heartbeat */
_Atomic int promotion_failed;
_Atomic int promotion_retry_count;
_Atomic int in_catch_up; /* Non-zero while processing historical WAL entries */
_Atomic int failover_requested; /* Set by SIGUSR1 handler; watchdog executes */
int hooks_registered; /* Idempotency guard for xce_add_hook calls */
uint32_t last_received_crc; /* Chained CRC of last received replicated entry */
ha_config_t config;
pthread_mutex_t lock; /* Protects primary_host/port + last_heartbeat */
/* Watchdog thread — started in P1-3 */
_Atomic int watchdog_stop;
pthread_t watchdog_thread;
} ha_state_t;
/* ── Public API ────────────────────────────────────────────── */
/*
* ha_init: zero-initialise *ha, validate cfg, store config and my_priority.
* Returns 0 on success, -EINVAL on bad config.
* Initial role is always CTRL_STANDBY.
*/
int ha_init(ha_state_t *ha, const ha_config_t *cfg, int my_priority);
/*
* ha_fini: destroy mutex. Watchdog thread join added in P1-3.
*/
void ha_fini(ha_state_t *ha);
/*
* am_i_primary: stub — priority-0 node is primary.
* Full election probing added in P1-3.
*/
int am_i_primary(cluster_t *c);
/*
* ha_promote_to_primary: stub — atomically sets role to PRIMARY.
* Full promotion sequence (hooks, WAL flush, peer notify) added in P1-3a.
*/
int ha_promote_to_primary(cluster_t *c);
/*
* ha_demote_to_standby: stub — atomically sets role to STANDBY.
* Full demotion sequence added in P1-3a.
*/
int ha_demote_to_standby(cluster_t *c);
/* ── Internal promotion/demotion (P1-3a) ───────────────────── */
/*
* ha_do_promote: increment term, set role=PRIMARY, reset last_received_crc.
* No cluster_t dependency — fully testable standalone.
* Callers: ha_promote_to_primary() (P1-5), watchdog failover path.
*/
void ha_do_promote(ha_state_t *ha);
/*
* ha_do_demote: set role=STANDBY, reset hooks_registered idempotency guard.
* Hook disable requires cluster_t and is done in ha_demote_to_standby() (P1-5).
*/
void ha_do_demote(ha_state_t *ha);
/*
* ha_install_sigusr1: register a SIGUSR1 handler that atomically sets
* ha->failover_requested. The handler uses only async-signal-safe operations
* (atomic store on an int-width type — POSIX.1-2017 §2.4.3).
* Must be called once, before any signal can arrive.
*/
void ha_install_sigusr1(ha_state_t *ha);
/* ── Watchdog thread API (P1-3) ────────────────────────────── */
/*
* ha_start_watchdog: spawn the heartbeat watchdog pthread.
* Must be called after ha_init(). Returns 0 on success, -errno on failure.
*/
int ha_start_watchdog(ha_state_t *ha);
/*
* ha_stop_watchdog: set watchdog_stop flag and pthread_join the thread.
* Safe to call if watchdog was never started (thread == 0).
*/
void ha_stop_watchdog(ha_state_t *ha);
/*
* ha_check_heartbeat_nocluster: check whether the primary heartbeat has
* timed out. Sets failover_requested if timeout exceeded and primary_host
* is non-empty. Test-only entry point — does not need cluster_t.
*/
void ha_check_heartbeat_nocluster(ha_state_t *ha);
/* ── Redirect helpers used by join.c ──────────────────────── */
/*
* ha_should_redirect: returns 1 if this controller should redirect join
* requests to the primary.
* Returns 0 if: (a) role is PRIMARY, or (b) primary_host is empty /
* primary_port is 0 (no primary known yet — client should retry).
*/
int ha_should_redirect(const ha_state_t *ha);
/*
* ha_no_primary_known: returns 1 if role is STANDBY and primary_host is
* empty or primary_port is 0. Caller should reply E_XCE_NOMASTER so the
* client knows to retry later rather than treating the error as permanent.
*/
int ha_no_primary_known(const ha_state_t *ha);
/* ── P3-6: Test-only failover shim ────────────────────────────── */
#ifdef XCE_TESTING
/* Test-only shim: demote then re-promote; returns new term.
* Exercises the manual failover state machine without a live cluster_t.
* Calls ha_do_demote() then ha_do_promote(); validates role transitions
* and term monotonicity. See design doc P3-6 and src/docs/persistence-and-ha-design.md.
*/
uint64_t ha_trigger_manual_failover_for_test(ha_state_t *ha);
#endif
#endif /* HA_H */

View file

@ -39,6 +39,7 @@ extern int add_resource(void*, char*, char*, int, xce_order_t, int, int);
extern int update_resvals(cluster_t*, worker_t*, int, xce_resval_t*);
extern int load_jobs(void*);
extern int restore_job(cluster_t*, uint64_t, const void*, size_t);
extern int update_job(void*, uint64_t, xce_kv_list_t*);
extern int do_job_started(void*, uint64_t, int, int*, int, int*);

View file

@ -9,4 +9,5 @@
extern int add_worker(void*, endpoint_t*, const char*, hostattrs_t*);
extern int restore_worker(void*, xce_worker_t);
extern int worker_from_capnp(cluster_t*, const void*, size_t);
#endif

View file

@ -1,9 +1,13 @@
#include <errno.h>
#include <signal.h>
#include <stdbool.h>
#include <string.h>
#include <time.h>
#include "cluster.h"
#include "controller.capnp.h"
#include "jobj.h"
#include "persist.h"
#include "preason.h"
#include "xce/cluster_def.h"
#include "xce/errnox.h"
@ -15,10 +19,6 @@ static int mark_job_finish(cluster_t*, job_t*, xce_jobfinish_t*);
static int mark_job_fail(cluster_t*, job_t*, job_failinfo_t*);
static int mark_job_update(cluster_t*, job_t*, int);
#define NJOBS (100)
uint64_t jobid = 100;
static job_t* create_job(cluster_t* c, xce_job_t* j)
{
job_t* job = NULL;
@ -62,29 +62,61 @@ uint64_t job_submit(void* c, xce_job_submit_t* submit)
int load_jobs(void* c)
{
#if 0
job_t* job;
int i;
char cmdline[1024];
return 0;
}
for (i = 1; i < NJOBS; i++) {
sprintf(cmdline, "echo normal; sleep %d", i);
job = create_job(c, jobid++, "normal", "rshen", "/home/rshen", "/tmp",
"rshen", (char*)cmdline, NULL, 1, 1);
if (job == NULL) {
return -errno;
}
}
/* Reconstruct a job from a WAL_JOB_SUBMIT Cap'n Proto payload.
* Uses the jobid embedded in the serialized xce_job_t (not the caller's arg).
* Returns -EEXIST if the job is already present (idempotent replay guard). */
int restore_job(cluster_t* c, uint64_t jobid_unused,
const void* capnp_payload, size_t len)
{
(void)jobid_unused;
for (i = 1; i < NJOBS; i++) {
job = create_job(c, jobid++, "xce", "rshen", "/home/rshen", "/tmp",
"rshen", "echo xce; sleep 30", NULL, 1, 1);
if (job == NULL) {
return -errno;
}
}
#endif
if (!capnp_payload || len == 0)
return -EINVAL;
struct capn c_ctx;
capn_init_mem(&c_ctx, (const uint8_t*)capnp_payload, len, 0);
Job_ptr jp;
jp.p = capn_getp(capn_root(&c_ctx), 0, 1);
struct Job cj;
xce_job_t j;
memset(&j, 0, sizeof(j));
read_Job(&cj, jp);
decode_Job(&j, &cj);
capn_free(&c_ctx);
/* Safety guard: skip if already present (WAL replay may see duplicates
* when recovery restarts from a segment boundary). */
job_t* existing = NULL;
lock(c->jobmgr, RTTI_LOCK_READ);
int found = key_find(c->jobmgr, (void*)j.jobid, (void**)&existing);
unlock(c->jobmgr);
if (found > 0 && existing != NULL)
return -EEXIST;
job_t* job = create_job(c, &j);
if (!job)
return -errno;
return 0;
}
/* Apply a recorded job state transition during WAL recovery.
* Looks up the job (which must already exist from WAL_JOB_SUBMIT replay)
* and overwrites its state with the value stored in the WAL entry. */
int restore_job_state(cluster_t* c, uint64_t jobid, uint32_t state)
{
job_t* j = NULL;
lock(c->jobmgr, RTTI_LOCK_READ);
key_find(c->jobmgr, (void*)jobid, (void**)&j);
unlock(c->jobmgr);
if (!j)
return -ENOENT;
lock(j, RTTI_LOCK_WRITE);
j->job.state = (int)state;
unlock(j);
return 0;
}
@ -225,6 +257,11 @@ static int dispatch_a_job_(cluster_t* c, endpoint_t* ep, xce_job_alloc_t* alloc)
rc = get_queue_exec(c, j->job.submit->queue, &q_pre_exec, &q_post_exec);
if (rc < 0) {
unlock(w);
dereference(w);
dereference(j);
free_launchinfo(&launchinfo);
return rc;
}
@ -308,6 +345,9 @@ int job_ctrl(cluster_t* c, xce_jobctrl_t* ctrinfo)
unlock(c->workermgr);
if (rc < 0) {
unlock(j);
dereference(j);
return rc;
}
if (w != NULL) {
@ -730,8 +770,12 @@ int on_job_stop(cluster_t* c, uint64_t jobid)
lock(job, RTTI_LOCK_WRITE);
state = job->job.state;
xce_run_hooks(&(c->hooksys), XCE_CONTROLLER_HOOK_JOB, HOOK_BEFORE_EVENT,
HOOK_DETAIL_JOB_STOP, c, job);
state |= JOB_STAT_USUSP;
mark_job_update(c, job, state);
job->job.state = state;
xce_run_hooks(&(c->hooksys), XCE_CONTROLLER_HOOK_JOB, HOOK_AFTER_EVENT,
HOOK_DETAIL_JOB_STOP, c, job);
unlock(job);
dereference(job);
@ -760,8 +804,12 @@ int on_job_resume(cluster_t* c, uint64_t jobid)
lock(job, RTTI_LOCK_WRITE);
state = job->job.state;
xce_run_hooks(&(c->hooksys), XCE_CONTROLLER_HOOK_JOB, HOOK_BEFORE_EVENT,
HOOK_DETAIL_JOB_RESUME, c, job);
state &= ~JOB_STAT_USUSP;
mark_job_update(c, job, state);
job->job.state = state;
xce_run_hooks(&(c->hooksys), XCE_CONTROLLER_HOOK_JOB, HOOK_AFTER_EVENT,
HOOK_DETAIL_JOB_RESUME, c, job);
unlock(job);
dereference(job);
@ -895,6 +943,8 @@ int update_job_rusages(cluster_t* c, endpoint_t* ep, xce_request_t* request)
w = get_ep_worker(c, ep);
if (w == NULL) {
dereference(j);
return errno;
}
@ -1038,7 +1088,7 @@ int do_clr_preason(cluster_t* c, endpoint_t* ep, xce_request_t* request)
FREEUP(job->pend_reasons);
}
xce_run_hooks(&(c->hooksys), XCE_CONTROLLER_HOOK_JOB, HOOK_BEFORE_EVENT,
xce_run_hooks(&(c->hooksys), XCE_CONTROLLER_HOOK_JOB, HOOK_AFTER_EVENT,
HOOK_DETAIL_CLRPREASON, c, j);
unlock(j);
@ -1047,3 +1097,7 @@ int do_clr_preason(cluster_t* c, endpoint_t* ep, xce_request_t* request)
return 0;
}
/* job_is_terminal and job_should_retain live in persist.c —
* they only depend on xce_job_t fields and persist_config_t,
* and must be accessible from the unit test binary which links persist.c. */

View file

@ -5,6 +5,7 @@
#include <unistd.h>
#include "cluster.h"
#include "ha.h"
#include "xce/cluster_def.h"
#include "xce/errnox.h"
@ -60,12 +61,31 @@ static int handle_worker_join(cluster_t* c, endpoint_t* ep,
HOOK_DETAIL_WORKER_JOIN, c, from->name, 0);
idx = add_worker(c, ep, from->name, joinmsg->data.attrs);
/* A worker recovered from WAL already occupies the slot (status=UNAVAIL).
* Reconnecting after a controller restart will fail add_worker with -EEXIST.
* Locate the existing slot, update its endpoint, and clear UNAVAIL. */
if (idx == -EEXIST) {
worker_t* existing = NULL;
if (key_find(c->workermgr, (void*)from->name, (void**)&existing) > 0 &&
existing != NULL) {
lock(existing, RTTI_LOCK_WRITE);
existing->ep = ep;
existing->host.status &= ~XCE_WORKER_UNAVAIL;
idx = existing->idx;
unlock(existing);
}
}
reply.status = idx;
reply.data_which = RequestReply_data_nodata;
xce_reply(ep, c->myhost, &reply);
/* Look up the worker so the persist hook can serialize it */
worker_t* w = NULL;
if (idx >= 0)
vget(c->workermgr, idx, (void**)&w);
xce_run_hooks(&(c->hooksys), XCE_CONTROLLER_HOOK_WORKER, HOOK_AFTER_EVENT,
HOOK_DETAIL_WORKER_JOIN, c, from->name, idx);
HOOK_DETAIL_WORKER_JOIN, c, w);
xce_reply(ep, c->myhost, &reply);
unlock(c->workermgr);
if (idx < 0) {
ERROR("worker %s join failed, rc %d", from->name, idx);
@ -177,6 +197,32 @@ static int handle_join_(cluster_t* c, endpoint_t* ep, xce_request_t* request)
TRACE("%s role %d join from %s", __func__, role, from->name);
/* ── HA guard (P1-6) ────────────────────────────────────────
* A standby must never accept joins as if it were the primary.
* If the primary address is known, tell the client where to go
* (E_XCE_REDIRECT). If no primary is known yet, tell the client
* to retry later (E_XCE_NOMASTER).
*/
if (ha_should_redirect(&c->ha)) {
WARN("%s standby redirecting role-%d join from %s to %s:%u",
__func__, role, from->name,
c->ha.primary_host, c->ha.primary_port);
rc = -E_XCE_REDIRECT;
reply.status = rc;
reply.data_which = RequestReply_data_nodata;
xce_reply(ep, c->myhost, &reply);
return rc;
}
if (ha_no_primary_known(&c->ha)) {
WARN("%s standby has no primary yet, rejecting role-%d join from %s",
__func__, role, from->name);
rc = -E_XCE_NOMASTER;
reply.status = rc;
reply.data_which = RequestReply_data_nodata;
xce_reply(ep, c->myhost, &reply);
return rc;
}
switch (role) {
case HostRole_client:
rc = handle_client_join(c, ep, request);

View file

@ -6,6 +6,7 @@
#include "myconf.h"
#include "utils.h"
#include "xce/cluster.h"
extern int myapp_run(myconf_t*);
@ -15,21 +16,29 @@ const char* argp_program_version
= MK_VERSION("xce v0.0.1-", STR(VERSION_COMMIT_ID));
const char* argp_program_bug_address = BUG_EMAIL;
/* Long-option key (above ASCII to avoid collisions with short opts) */
#define OPT_PEERS 256
struct arguments {
char* myname;
char* logfile;
char* workdir;
int verbose;
char* peers_str;
};
static struct argp_option options[] = {
{ "workdir", 'w', "DIR", 0, "The work directory of xce controller" },
{ "logfile", 'l', "FILE", 0, "The full path of log file" },
{ "verbose", 'v', 0, 0, "Produce verbose logs" },
{ "myname", 'n', "STR", 0, "The name of controller" },
{ "workdir", 'w', "DIR", 0, "The work directory of xce controller" },
{ "logfile", 'l', "FILE", 0, "The full path of log file" },
{ "verbose", 'v', 0, 0, "Produce verbose logs" },
{ "myname", 'n', "STR", 0, "The name of controller" },
{ "peers", OPT_PEERS, "PEERS", 0,
"HA peer list: comma-separated host:port pairs ordered by priority "
"(e.g. ctrl01:9990,ctrl02:9990). Index 0 = highest priority (primary)." },
{ 0 }
};
static error_t parse_opt(int key, char* arg, struct argp_state* state)
{
struct arguments* arguments = state->input;
@ -47,6 +56,9 @@ static error_t parse_opt(int key, char* arg, struct argp_state* state)
case 'n':
arguments->myname = arg;
break;
case OPT_PEERS:
arguments->peers_str = arg;
break;
default:
return ARGP_ERR_UNKNOWN;
}
@ -97,11 +109,11 @@ int main(int argc, char* argv[])
arguments.myname = myname;
}
INFO("My name is %s", myname);
INFO("My name is %s", arguments.myname);
memset(&conf_file, 0x0, sizeof(conf_file));
snprintf(conf_file, XCE_MAXPATH_LEN, "%s/%s/conf/current_config",
arguments.workdir, myname);
arguments.workdir, arguments.myname);
snprintf(key_file, XCE_MAXPATH_LEN, "%s/certs/xce.key", arguments.workdir);
snprintf(cert_file, XCE_MAXPATH_LEN, "%s/certs/xce.crt", arguments.workdir);
@ -109,6 +121,29 @@ int main(int argc, char* argv[])
myconf.conffile = conf_file;
myconf.certfile = cert_file;
myconf.keyfile = key_file;
myconf.workdir = arguments.workdir;
myconf.name = arguments.myname;
memset(&myconf.peers_cfg, 0, sizeof(myconf.peers_cfg));
if (arguments.peers_str) {
if (xce_parse_peers(arguments.peers_str, &myconf.peers_cfg) != 0) {
fprintf(stderr,
"xce_controller: --peers parse error: expected "
"host:port,... got: %s\n",
arguments.peers_str);
return 1;
}
/* CA cert derived from workdir — same infrastructure as certfile/keyfile */
snprintf(myconf.peers_cfg.ca_cert_path,
sizeof(myconf.peers_cfg.ca_cert_path),
"%s/certs/xce.crt", arguments.workdir);
strncpy(myconf.peers_cfg.identity, arguments.myname,
sizeof(myconf.peers_cfg.identity) - 1);
INFO("HA mode: %d controller candidates, identity=%s",
myconf.peers_cfg.candidates_count, myconf.peers_cfg.identity);
} else {
INFO("No --peers flag; running as single-node primary");
}
myapp_run(&myconf);

View file

@ -1,6 +1,7 @@
controller_src = [
'cluster.c',
'dataex.c',
'ha.c',
'job.c',
'join.c',
'main.c',
@ -16,6 +17,9 @@ controller_src = [
'worker.c',
'apiserver.c',
'myconf.c',
'persist.c',
'repl.c',
'ctrl_metrics.c',
]
incdir = include_directories('.', '../include', '../lib',
@ -24,13 +28,16 @@ incdir = include_directories('.', '../include', '../lib',
# cc = meson.get_compiler('c')
# math_dep = cc.find_library('m', required : false)
zlib_dep = dependency('zlib')
controller_deps = [
libxce_dep,
librtti_dep,
c_capnproto_dep,
math_dep,
tinylog_dep,
thread_dep
thread_dep,
zlib_dep,
]
controller = executable('xce_controller',
@ -61,3 +68,58 @@ xce_test = executable('xce_test',
dependencies: xce_test_deps,
install: true)
# Controller Unity test suite (persist types and future controller unit tests)
controller_unit_test_src = [
'testsuite/controller_test.c',
'testsuite/test_persist_types.c',
'testsuite/test_persist_wal.c',
'testsuite/test_persist_schema.c',
'testsuite/test_persist_checkpoint.c',
'testsuite/test_persist_recovery.c',
'testsuite/test_hooks.c',
'testsuite/test_persist_hooks.c',
'testsuite/test_gc_retention.c',
'testsuite/test_cluster_persist.c',
'testsuite/test_e2e_wal.c',
'testsuite/test_e2e_checkpoint.c',
'testsuite/test_e2e_hooks.c',
'testsuite/test_e2e_gc.c',
'testsuite/test_e2e_capnp.c',
'testsuite/test_ha_role.c',
'testsuite/test_ha_cluster.c',
'testsuite/test_ha_redirect.c',
'testsuite/test_p1_integration.c',
'testsuite/test_repl_unit.c',
'testsuite/test_repl_apply.c',
'testsuite/test_persist_repl.c',
'testsuite/test_repl_catchup.c',
'testsuite/test_p2_integration.c',
'testsuite/test_p3_hardening.c',
'testsuite/test_p3_manual_failover.c',
'testsuite/test_ctrl_metrics.c',
'persist.c',
'ha.c',
'repl.c',
'ctrl_metrics.c',
]
controller_unit_test_deps = [
libxce_dep,
unity_dep,
tinylog_dep,
thread_dep,
zlib_dep,
c_capnproto_dep,
]
controller_unit_test = executable('controller_unit_test',
controller_unit_test_src,
include_directories: [incdir],
dependencies: controller_unit_test_deps,
c_args: ['-DXCE_TESTING'],
install: false)
test('controller test', controller_unit_test,
suite: 'controller',
timeout: 120)

1194
src/controller/persist.c Normal file

File diff suppressed because it is too large Load diff

View file

@ -7,6 +7,7 @@
#include "xce/cluster_def.h"
#include "xce/types.h"
#include "xstring.h"
#include "wal_types.h"
void queue_fini(queue_t* q)
{
@ -178,3 +179,24 @@ int get_queue_exec(cluster_t* c, char* queue, char** pre_exec, char** post_exec)
return 0;
}
/* Restore queue status flags from a WAL_QUEUE_CTRL entry during recovery.
* Queues are built from config at startup; this re-applies admin state changes
* (open/close/active/inactive) that were recorded in the WAL. */
int restore_queue_state(cluster_t* c, const char* name, uint32_t status)
{
if (!name || name[0] == '\0')
return -EINVAL;
queue_t* q = NULL;
lock(c->qmgr, RTTI_LOCK_READ);
key_find(c->qmgr, (void*)name, (void**)&q);
unlock(c->qmgr);
if (!q)
return -ENOENT; /* queue not yet built from config — skip */
lock(q, RTTI_LOCK_WRITE);
q->queue.status = status;
unlock(q);
return 0;
}

344
src/controller/repl.c Normal file
View file

@ -0,0 +1,344 @@
/*
* repl.c — P2-1: Replicator lifecycle, slot management, and WAL trim guard.
*
* mylock_t is a plain pthread_mutex_t alias (see mylock.h).
* API: mylock_init / mylock_fini / mylock_lock / mylock_unlock
*/
#include <assert.h>
#include <errno.h>
#include <string.h>
#include "repl.h"
#include "mylock.h"
/* ============================================================
* Lifecycle
* ============================================================ */
int repl_init(replicator_t *r)
{
if (r == NULL) {
return -EINVAL;
}
memset(r, 0, sizeof(*r));
int rc = mylock_init(&r->lock);
if (rc != 0) {
return rc;
}
rc = pthread_mutex_init(&r->ack_lock, NULL);
if (rc != 0) {
mylock_fini(&r->lock);
return -rc;
}
rc = pthread_cond_init(&r->ack_cond, NULL);
if (rc != 0) {
pthread_mutex_destroy(&r->ack_lock);
mylock_fini(&r->lock);
return -rc;
}
return 0;
}
void repl_fini(replicator_t *r)
{
if (r == NULL) {
return;
}
pthread_cond_destroy(&r->ack_cond);
pthread_mutex_destroy(&r->ack_lock);
mylock_fini(&r->lock);
}
/* ============================================================
* Standby slot management
* ============================================================ */
int repl_add_standby(replicator_t *r, repl_ep_t ep, const char *host, uint32_t port)
{
if (r == NULL || ep == NULL || host == NULL) {
return -EINVAL;
}
mylock_lock(&r->lock);
if (r->nstandbys >= MAX_STANDBYS) {
mylock_unlock(&r->lock);
return -ENOSPC;
}
/* Find first empty slot */
int idx = -1;
for (int i = 0; i < MAX_STANDBYS; i++) {
if (r->standbys[i].ep == NULL) {
idx = i;
break;
}
}
if (idx < 0) {
/* Should not happen given nstandbys guard above, but be safe */
mylock_unlock(&r->lock);
return -ENOSPC;
}
standby_conn_t *s = &r->standbys[idx];
memset(s, 0, sizeof(*s));
s->ep = ep;
s->port = port;
strncpy(s->host, host, XCE_MAXNAME_LEN - 1);
s->host[XCE_MAXNAME_LEN - 1] = '\0';
atomic_store(&s->acked_lsn, 0);
atomic_store(&s->in_catch_up, 0);
r->nstandbys++;
mylock_unlock(&r->lock);
return idx;
}
void repl_remove_standby(replicator_t *r, repl_ep_t ep)
{
if (r == NULL || ep == NULL) {
return;
}
mylock_lock(&r->lock);
for (int i = 0; i < MAX_STANDBYS; i++) {
standby_conn_t *s = &r->standbys[i];
if (s->ep == ep) {
s->last_disconnected_lsn = atomic_load(&s->acked_lsn);
s->last_disconnect_time = time(NULL);
s->ep = NULL;
r->nstandbys--;
break;
}
}
mylock_unlock(&r->lock);
}
void repl_expire_disconnect_slots(replicator_t *r, time_t now_s, uint32_t snap_interval_s)
{
if (r == NULL) {
return;
}
mylock_lock(&r->lock);
for (int i = 0; i < MAX_STANDBYS; i++) {
standby_conn_t *s = &r->standbys[i];
/* Only process disconnected (ep==NULL) slots that still hold a guard LSN */
if (s->ep == NULL && s->last_disconnected_lsn != 0) {
/* snap_interval_s==0 means expire immediately regardless of wall clock */
int expired = (snap_interval_s == 0) ||
(now_s >= s->last_disconnect_time + (time_t)snap_interval_s);
if (expired) {
memset(s, 0, sizeof(*s));
}
}
}
mylock_unlock(&r->lock);
}
/* ============================================================
* ACK handling — call from io_worker read callback ONLY
* ============================================================ */
void repl_on_ack(replicator_t *r, repl_ep_t ep, uint64_t acked_lsn)
{
if (r == NULL || ep == NULL) {
return;
}
mylock_lock(&r->lock);
for (int i = 0; i < MAX_STANDBYS; i++) {
standby_conn_t *s = &r->standbys[i];
if (s->ep == ep) {
atomic_store(&s->acked_lsn, acked_lsn);
break;
}
}
mylock_unlock(&r->lock);
/* Signal any waiter (repl_wait_ack) */
pthread_mutex_lock(&r->ack_lock);
pthread_cond_signal(&r->ack_cond);
pthread_mutex_unlock(&r->ack_lock);
}
/* ============================================================
* Replication send — stub in P2-1; wired in P2-4
* ============================================================ */
int repl_broadcast(replicator_t *r, const wal_entry_hdr_t *hdr,
const void *payload, uint32_t payload_len)
{
(void)r;
(void)payload;
(void)payload_len;
/* WAL_CHECKPOINT entries must never be replicated */
assert(hdr != NULL);
assert(hdr->type != WAL_CHECKPOINT);
/* Full implementation in P2-4 */
return 0;
}
/* ============================================================
* Sync replication — stub in P2-1; wired in P2-3
* ============================================================ */
int repl_wait_ack(replicator_t *r, uint64_t target_lsn, uint32_t timeout_ms)
{
if (!r) return -EINVAL;
/* Check whether any non-catching-up standby exists */
mylock_lock(&r->lock);
int has_standby = 0;
for (int i = 0; i < MAX_STANDBYS; i++) {
if (r->standbys[i].ep != NULL &&
!atomic_load_explicit(&r->standbys[i].in_catch_up,
memory_order_acquire)) {
has_standby = 1;
break;
}
}
mylock_unlock(&r->lock);
if (!has_standby) return -ENODEV;
/* Compute absolute deadline for pthread_cond_timedwait (CLOCK_REALTIME) */
struct timespec deadline;
clock_gettime(CLOCK_REALTIME, &deadline);
deadline.tv_sec += (long)(timeout_ms / 1000);
deadline.tv_nsec += (long)(timeout_ms % 1000) * 1000000L;
if (deadline.tv_nsec >= 1000000000L) {
deadline.tv_sec++;
deadline.tv_nsec -= 1000000000L;
}
pthread_mutex_lock(&r->ack_lock);
int rc = -ETIMEDOUT;
while (1) {
/* Recheck every standby after each wakeup (spurious-wakeup guard) */
mylock_lock(&r->lock);
for (int i = 0; i < MAX_STANDBYS; i++) {
standby_conn_t *s = &r->standbys[i];
if (s->ep == NULL) continue;
if (atomic_load_explicit(&s->in_catch_up, memory_order_acquire))
continue;
if (atomic_load_explicit(&s->acked_lsn, memory_order_acquire)
>= target_lsn) {
rc = 0;
break;
}
}
mylock_unlock(&r->lock);
if (rc == 0) break;
int wait_rc = pthread_cond_timedwait(&r->ack_cond, &r->ack_lock,
&deadline);
if (wait_rc == ETIMEDOUT) {
rc = -ETIMEDOUT;
break;
}
}
pthread_mutex_unlock(&r->ack_lock);
return rc;
}
/* ============================================================
* WAL trim guard
* ============================================================ */
uint64_t repl_trim_lsn(replicator_t *r, uint64_t checkpoint_lsn,
uint32_t snap_interval_s)
{
(void)snap_interval_s;
if (r == NULL) {
return checkpoint_lsn;
}
uint64_t min_lsn = checkpoint_lsn;
mylock_lock(&r->lock);
for (int i = 0; i < MAX_STANDBYS; i++) {
const standby_conn_t *s = &r->standbys[i];
if (s->ep != NULL) {
/* Active standby: guard at its last acked LSN */
uint64_t acked = atomic_load(&s->acked_lsn);
if (acked < min_lsn) {
min_lsn = acked;
}
} else if (s->last_disconnected_lsn != 0) {
/* Recently disconnected slot still holding a guard */
if (s->last_disconnected_lsn < min_lsn) {
min_lsn = s->last_disconnected_lsn;
}
}
}
mylock_unlock(&r->lock);
return min_lsn;
}
/* ============================================================
* Catch-up state management (P2-5)
* ============================================================ */
void repl_start_catchup(replicator_t *r, repl_ep_t ep, uint64_t join_applied_lsn)
{
mylock_lock(&r->lock);
for (int i = 0; i < MAX_STANDBYS; i++) {
if (r->standbys[i].ep == ep) {
r->standbys[i].join_applied_lsn = join_applied_lsn;
atomic_store_explicit(&r->standbys[i].in_catch_up, 1,
memory_order_release);
break;
}
}
mylock_unlock(&r->lock);
}
void repl_complete_catchup(replicator_t *r, repl_ep_t ep)
{
mylock_lock(&r->lock);
for (int i = 0; i < MAX_STANDBYS; i++) {
if (r->standbys[i].ep == ep) {
atomic_store_explicit(&r->standbys[i].in_catch_up, 0,
memory_order_release);
break;
}
}
mylock_unlock(&r->lock);
/* Broadcast so any blocked repl_wait_ack() re-checks the ACK condition */
pthread_mutex_lock(&r->ack_lock);
pthread_cond_broadcast(&r->ack_cond);
pthread_mutex_unlock(&r->ack_lock);
}
int repl_needs_snapshot(replicator_t *r, uint64_t standby_lsn,
uint64_t oldest_wal_lsn)
{
(void)r;
/* No WAL at all: empty standby can start from LSN 0 without a snapshot */
if (oldest_wal_lsn == 0) return 0;
/* Standby LSN predates oldest surviving WAL segment — full snapshot required */
return (standby_lsn < oldest_wal_lsn) ? 1 : 0;
}

View file

@ -0,0 +1,136 @@
#include <argp.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unity/unity.h>
extern void test_persist_types(void);
extern void test_persist_wal(void);
extern void test_persist_schema(void);
extern void test_persist_checkpoint(void);
extern void test_persist_recovery(void);
extern void test_hooks(void);
extern void test_persist_hooks(void);
extern void test_gc_retention(void);
extern void test_cluster_persist(void);
extern void test_e2e_wal(void);
extern void test_e2e_checkpoint(void);
extern void test_e2e_hooks(void);
extern void test_e2e_gc(void);
extern void test_e2e_capnp(void);
extern void test_ha_role(void);
extern void test_ha_cluster(void);
extern void test_ha_redirect(void);
extern void test_p1_integration(void);
extern void test_repl_unit(void);
extern void test_repl_apply(void);
extern void test_persist_repl(void);
extern void test_repl_catchup(void);
extern void test_p2_integration(void);
extern void test_p3_hardening(void);
extern void test_p3_manual_failover(void);
extern void test_ctrl_metrics(void);
void setUp(void) { return; }
void tearDown(void)
{
printf("\n-----------------------------------------------------\n");
return;
}
static char doc[] = "controller_test";
static char args_doc[] = "";
#define MAX_TESTS (256)
struct arguments {
int ntests;
char* tests[MAX_TESTS];
};
static struct argp_option options[] = {
{ "run", 't', "STR", 0, "run test" },
{ 0 }
};
static error_t parse_opt(int key, char* arg, struct argp_state* state)
{
struct arguments* arguments = state->input;
switch (key) {
case 't':
if (arguments->ntests >= MAX_TESTS) {
fprintf(stderr, "Too many tests\n");
exit(-1);
}
arguments->tests[arguments->ntests] = arg;
arguments->ntests++;
break;
default:
return ARGP_ERR_UNKNOWN;
}
return 0;
}
static struct argp argp = { options, parse_opt, args_doc, doc };
struct arguments arguments;
static int check_test(struct arguments* args, char* x)
{
int i;
if (args->ntests == 0) {
return 1;
}
for (i = 0; i < args->ntests; i++) {
if (strcmp(x, args->tests[i]) == 0) {
return 1;
}
}
return 0;
}
#define TEST(x) \
do { \
if (check_test(&arguments, #x)) { \
x(); \
} \
} while (0)
int main(int argc, char* argv[])
{
argp_parse(&argp, argc, argv, 0, 0, &arguments);
UNITY_BEGIN();
TEST(test_persist_types);
TEST(test_persist_wal);
TEST(test_persist_schema);
TEST(test_persist_checkpoint);
TEST(test_persist_recovery);
TEST(test_hooks);
TEST(test_persist_hooks);
TEST(test_gc_retention);
TEST(test_cluster_persist);
TEST(test_e2e_wal);
TEST(test_e2e_checkpoint);
TEST(test_e2e_hooks);
TEST(test_e2e_gc);
TEST(test_e2e_capnp);
TEST(test_ha_role);
TEST(test_ha_cluster);
TEST(test_ha_redirect);
TEST(test_p1_integration);
TEST(test_repl_unit);
TEST(test_repl_apply);
TEST(test_persist_repl);
TEST(test_repl_catchup);
TEST(test_p2_integration);
TEST(test_p3_hardening);
TEST(test_p3_manual_failover);
TEST(test_ctrl_metrics);
return UNITY_END();
}

View file

@ -403,7 +403,7 @@ int cmd_worker(int argc, char* argv[])
active_ctx = &workerctx;
delaymgr_init(&delaymgr, 2, NULL);
delaymgr_init(&delaymgr, 2000 /* precision_ms */, NULL);
delaymgr_start(&delaymgr);
delay_task(&delaymgr, 5, -1, (delay_task_entry_fn_t)worker_house_keeping,
&workerctx);

View file

@ -0,0 +1,103 @@
#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
#include <unity/unity.h>
#include "config.h"
#include "persist.h"
#include "xce/cluster_def.h"
/* ------------------------------------------------------------------ */
/* Compile-time structural checks */
/* ------------------------------------------------------------------ */
static void test_cluster_t_has_persist_field(void)
{
/* Verify cluster_t has a persist field — compile-time structural check.
* If persist_t persist is not in cluster_t, this will fail to compile. */
cluster_t c;
memset(&c, 0, sizeof(c));
/* Access the persist field to confirm it exists at compile time */
(void)c.persist.current_lsn;
TEST_PASS_MESSAGE("cluster_t has persist field");
}
/* ------------------------------------------------------------------ */
/* persist_config_defaults() correctness */
/* ------------------------------------------------------------------ */
static void test_persist_config_defaults_wal_sync_ms(void)
{
persist_config_t cfg = persist_config_defaults();
TEST_ASSERT_EQUAL_UINT32(DEFAULT_WAL_SYNC_MS, cfg.wal_sync_ms);
}
static void test_persist_config_defaults_snap_interval_s(void)
{
persist_config_t cfg = persist_config_defaults();
TEST_ASSERT_EQUAL_UINT32(DEFAULT_SNAP_INTERVAL_S, cfg.snap_interval_s);
}
static void test_persist_config_defaults_snap_wal_size_mb(void)
{
persist_config_t cfg = persist_config_defaults();
TEST_ASSERT_EQUAL_UINT32(DEFAULT_SNAP_WAL_SIZE_MB, cfg.snap_wal_size_mb);
}
static void test_persist_config_defaults_snap_keep_n(void)
{
persist_config_t cfg = persist_config_defaults();
TEST_ASSERT_EQUAL_UINT32(DEFAULT_SNAP_KEEP_N, cfg.snap_keep_n);
}
static void test_persist_config_defaults_finished_retain_s(void)
{
persist_config_t cfg = persist_config_defaults();
TEST_ASSERT_EQUAL_UINT32(DEFAULT_FINISHED_RETAIN_S, cfg.finished_retain_s);
}
static void test_persist_config_defaults_sync_timeout_ms(void)
{
persist_config_t cfg = persist_config_defaults();
TEST_ASSERT_EQUAL_UINT32(DEFAULT_SYNC_TIMEOUT_MS, cfg.sync_timeout_ms);
}
/* ------------------------------------------------------------------ */
/* persist_set_workdir derives correct paths */
/* ------------------------------------------------------------------ */
static void test_persist_set_workdir_derives_wal_path(void)
{
persist_t p;
memset(&p, 0, sizeof(p));
persist_set_workdir(&p, "/tmp/xce", "ctrl1");
TEST_ASSERT_EQUAL_STRING("/tmp/xce/ctrl1/data/wal", p.wal_path);
}
static void test_persist_set_workdir_derives_snap_path(void)
{
persist_t p;
memset(&p, 0, sizeof(p));
persist_set_workdir(&p, "/tmp/xce", "ctrl1");
TEST_ASSERT_EQUAL_STRING("/tmp/xce/ctrl1/data/snap", p.snap_path);
}
/* ------------------------------------------------------------------ */
/* Suite entry point */
/* ------------------------------------------------------------------ */
void test_cluster_persist(void)
{
printf("\n>> cluster persist wiring tests (P0-F)\n\n");
RUN_TEST(test_cluster_t_has_persist_field);
RUN_TEST(test_persist_config_defaults_wal_sync_ms);
RUN_TEST(test_persist_config_defaults_snap_interval_s);
RUN_TEST(test_persist_config_defaults_snap_wal_size_mb);
RUN_TEST(test_persist_config_defaults_snap_keep_n);
RUN_TEST(test_persist_config_defaults_finished_retain_s);
RUN_TEST(test_persist_config_defaults_sync_timeout_ms);
RUN_TEST(test_persist_set_workdir_derives_wal_path);
RUN_TEST(test_persist_set_workdir_derives_snap_path);
}

View file

@ -0,0 +1,88 @@
/* test_ctrl_metrics.c — P3-7: Metrics infrastructure unit tests
*
* TDD: these tests are written FIRST to drive the implementation.
* They verify:
* - ctrl_metrics_init() zeroes all 7 counters/gauges
* - CTRL_METRIC_INC increments atomically
* - CTRL_METRIC_SET writes and overwrites a gauge
* - The descriptor table has exactly 7 entries + NULL sentinel
* - ctrl_transient_jobs_stuck counter accumulates correctly
*/
#define _GNU_SOURCE
#include <stdatomic.h>
#include <stdint.h>
#include <stdio.h>
#include <unity/unity.h>
#include "ctrl_metrics.h"
/* ------------------------------------------------------------------ */
/* Tests */
/* ------------------------------------------------------------------ */
static void test_metrics_init_zeros_all_counters(void)
{
ctrl_metrics_init();
TEST_ASSERT_EQUAL_UINT64(0, atomic_load(&ctrl_repl_degraded_count));
TEST_ASSERT_EQUAL_UINT64(0, atomic_load(&ctrl_split_brain_detected));
TEST_ASSERT_EQUAL_UINT64(0, atomic_load(&ctrl_async_events_lost));
TEST_ASSERT_EQUAL_UINT64(0, atomic_load(&ctrl_transient_jobs_stuck));
TEST_ASSERT_EQUAL_UINT64(0, atomic_load(&ctrl_wal_bytes));
TEST_ASSERT_EQUAL_UINT64(0, atomic_load(&ctrl_checkpoint_duration_ms));
TEST_ASSERT_EQUAL_UINT64(0, atomic_load(&ctrl_repl_lag_lsn));
printf("test_metrics_init_zeros_all_counters: PASS\n");
}
static void test_metrics_increment_repl_degraded(void)
{
ctrl_metrics_init();
CTRL_METRIC_INC(ctrl_repl_degraded_count);
CTRL_METRIC_INC(ctrl_repl_degraded_count);
TEST_ASSERT_EQUAL_UINT64(2, atomic_load(&ctrl_repl_degraded_count));
printf("test_metrics_increment_repl_degraded: PASS\n");
}
static void test_metrics_set_gauge(void)
{
ctrl_metrics_init();
CTRL_METRIC_SET(ctrl_wal_bytes, 1024 * 1024);
TEST_ASSERT_EQUAL_UINT64(1024 * 1024, atomic_load(&ctrl_wal_bytes));
CTRL_METRIC_SET(ctrl_wal_bytes, 0);
TEST_ASSERT_EQUAL_UINT64(0, atomic_load(&ctrl_wal_bytes));
printf("test_metrics_set_gauge: PASS\n");
}
static void test_metrics_descriptor_table_has_sentinel(void)
{
/* The descriptor table must end with a NULL-name sentinel
* and contain exactly 7 real entries. */
int i = 0;
while (ctrl_metric_descriptors[i].name != NULL)
i++;
TEST_ASSERT_EQUAL_INT(7, i);
printf("test_metrics_descriptor_table_has_sentinel: PASS\n");
}
static void test_transient_jobs_stuck_counter(void)
{
ctrl_metrics_init();
/* Simulate 3 stuck transient jobs */
for (int j = 0; j < 3; j++)
CTRL_METRIC_INC(ctrl_transient_jobs_stuck);
TEST_ASSERT_EQUAL_UINT64(3, atomic_load(&ctrl_transient_jobs_stuck));
printf("test_transient_jobs_stuck_counter: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Suite entry point (called from controller_test.c) */
/* ------------------------------------------------------------------ */
void test_ctrl_metrics(void)
{
RUN_TEST(test_metrics_init_zeros_all_counters);
RUN_TEST(test_metrics_increment_repl_degraded);
RUN_TEST(test_metrics_set_gauge);
RUN_TEST(test_metrics_descriptor_table_has_sentinel);
RUN_TEST(test_transient_jobs_stuck_counter);
}

View file

@ -0,0 +1,146 @@
/* P0-15e: Cap'n Proto partial merge semantics
*
* Verifies that the three persistence Cap'n Proto mapped types
* (capnp_persist_cfg_t, capnp_snap_hdr_t, capnp_repl_envelope_t)
* and the generated struct PersistConfig / SnapshotHeader / ReplEnvelope
* round-trip through their C struct representations correctly.
*
* These are structural/compile-time + value round-trip tests that span
* the Cap'n Proto schema generation (P0-C) and the typemap.h definitions.
*/
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <unity/unity.h>
#include "config.h"
#include "controller.capnp.h"
#include "persist.h"
/* ------------------------------------------------------------------ */
/* Helpers: persist_config_t → capnp_persist_cfg_t round-trip */
/* ------------------------------------------------------------------ */
/* Manually map persist_config_t fields to capnp_persist_cfg_t, matching the
* field mapping in the persist.c checkpoint serialisation path. */
static capnp_persist_cfg_t cfg_to_capnp(const persist_config_t* c)
{
capnp_persist_cfg_t out;
memset(&out, 0, sizeof(out));
out.walSyncMs = c->wal_sync_ms;
out.snapIntervalS = c->snap_interval_s;
out.snapWalSizeMb = c->snap_wal_size_mb;
out.snapKeepN = c->snap_keep_n;
out.transientJobTimeoutS = c->transient_job_timeout_s;
out.finishedRetainS = c->finished_retain_s;
out.workerRetainS = c->worker_retain_s;
out.syncTimeoutMs = c->sync_timeout_ms;
return out;
}
/* ------------------------------------------------------------------ */
/* Test 1: persist_config_t → capnp_persist_cfg_t round-trip */
/* ------------------------------------------------------------------ */
static void test_persist_config_capnp_round_trip(void)
{
persist_config_t src = persist_config_defaults();
/* Override to known values for deterministic checking */
src.wal_sync_ms = 42u;
src.snap_interval_s = 120u;
src.snap_wal_size_mb = 128u;
src.snap_keep_n = 3u;
src.transient_job_timeout_s = 600u;
src.finished_retain_s = 7200u;
src.worker_retain_s = 172800u;
src.sync_timeout_ms = 100u;
capnp_persist_cfg_t out = cfg_to_capnp(&src);
TEST_ASSERT_EQUAL_UINT32(src.wal_sync_ms, out.walSyncMs);
TEST_ASSERT_EQUAL_UINT32(src.snap_interval_s, out.snapIntervalS);
TEST_ASSERT_EQUAL_UINT32(src.snap_wal_size_mb, out.snapWalSizeMb);
TEST_ASSERT_EQUAL_UINT32(src.snap_keep_n, out.snapKeepN);
TEST_ASSERT_EQUAL_UINT32(src.transient_job_timeout_s, out.transientJobTimeoutS);
TEST_ASSERT_EQUAL_UINT32(src.finished_retain_s, out.finishedRetainS);
TEST_ASSERT_EQUAL_UINT32(src.worker_retain_s, out.workerRetainS);
TEST_ASSERT_EQUAL_UINT32(src.sync_timeout_ms, out.syncTimeoutMs);
printf("test_persist_config_capnp_round_trip: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Test 2: SnapshotHeader struct fields and version default */
/* ------------------------------------------------------------------ */
static void test_snapshot_header_capnp_fields(void)
{
/* Verify struct size constant matches the documented schema */
TEST_ASSERT_EQUAL_INT(48, (int)SnapshotHeader_struct_bytes_count);
/* Populate a capnp_snap_hdr_t with known values and check them */
capnp_snap_hdr_t hdr;
memset(&hdr, 0, sizeof(hdr));
hdr.lsn = 12345ULL;
hdr.term = 2ULL;
hdr.timestamp = 999999ULL;
hdr.version = 1u;
hdr.nJobs = 10u;
hdr.nQueues = 3u;
hdr.nWorkers = 5u;
hdr.checksum = 0xDEADBEEFu;
hdr.headerChecksum = 0xCAFEBABEu;
TEST_ASSERT_EQUAL_UINT64(12345ULL, hdr.lsn);
TEST_ASSERT_EQUAL_UINT64(2ULL, hdr.term);
TEST_ASSERT_EQUAL_UINT64(999999ULL, hdr.timestamp);
TEST_ASSERT_EQUAL_UINT32(1u, hdr.version);
TEST_ASSERT_EQUAL_UINT32(10u, hdr.nJobs);
TEST_ASSERT_EQUAL_UINT32(3u, hdr.nQueues);
TEST_ASSERT_EQUAL_UINT32(5u, hdr.nWorkers);
TEST_ASSERT_EQUAL_UINT32(0xDEADBEEFu, hdr.checksum);
TEST_ASSERT_EQUAL_UINT32(0xCAFEBABEu, hdr.headerChecksum);
printf("test_snapshot_header_capnp_fields: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Test 3: ReplEnvelope struct fields accessible */
/* ------------------------------------------------------------------ */
static void test_repl_envelope_capnp_fields(void)
{
/* Verify struct size constant */
TEST_ASSERT_EQUAL_INT(40, (int)ReplEnvelope_struct_bytes_count);
capnp_repl_envelope_t env;
memset(&env, 0, sizeof(env));
env.term = 7ULL;
env.lsn = 100ULL;
env.entryType = (uint32_t)WAL_JOB_SUBMIT;
env.payload = NULL; /* optional data pointer */
env.crc = 0x12345678u;
env.prevCrc = WAL_CRC_NONE;
TEST_ASSERT_EQUAL_UINT64(7ULL, env.term);
TEST_ASSERT_EQUAL_UINT64(100ULL, env.lsn);
TEST_ASSERT_EQUAL_UINT32(WAL_JOB_SUBMIT, env.entryType);
TEST_ASSERT_EQUAL_UINT32(0x12345678u, env.crc);
TEST_ASSERT_EQUAL_UINT32(WAL_CRC_NONE, env.prevCrc);
printf("test_repl_envelope_capnp_fields: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Suite entry point */
/* ------------------------------------------------------------------ */
void test_e2e_capnp(void)
{
printf("\n>> P0-15e: Cap'n Proto partial merge semantics\n\n");
RUN_TEST(test_persist_config_capnp_round_trip);
RUN_TEST(test_snapshot_header_capnp_fields);
RUN_TEST(test_repl_envelope_capnp_fields);
}

View file

@ -0,0 +1,192 @@
/* P0-15b: Checkpoint E2E — round trip
*
* Exercises cross-phase interactions:
* - WAL writer (P0-B) + checkpoint (P0-C) + recovery (P0-C)
* - LSN continuity across checkpoint + post-checkpoint WAL entries
* - WAL segment trimming after checkpoint
* - Snapshot rotation keeping snap_keep_n files
*/
#define _GNU_SOURCE
#include <dirent.h>
#include <errno.h>
#include <fcntl.h>
#include <inttypes.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include <unity/unity.h>
#include "config.h"
#include "persist.h"
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
static int count_files_suffix(const char* dir, const char* suffix)
{
DIR* d = opendir(dir);
if (!d) return 0;
int n = 0;
size_t slen = strlen(suffix);
struct dirent* e;
while ((e = readdir(d)) != NULL) {
size_t len = strlen(e->d_name);
if (len > slen && strcmp(e->d_name + len - slen, suffix) == 0)
n++;
}
closedir(d);
return n;
}
/* Make a persist_t with a fresh tmpdir. Caller must persist_stop/fini/free. */
static persist_t* make_ckpt_e2e(char* tmpdir, size_t sz,
uint32_t snap_keep_n)
{
snprintf(tmpdir, sz, "/tmp/xce_e2e_ckpt_XXXXXX");
if (!mkdtemp(tmpdir)) return NULL;
persist_t* p = calloc(1, sizeof(persist_t));
if (!p) return NULL;
persist_config_t cfg = persist_config_defaults();
cfg.wal_sync_ms = 5000;
cfg.snap_keep_n = snap_keep_n;
persist_set_workdir(p, tmpdir, "e2e-ckpt");
if (persist_init(p, &cfg, NULL) != 0) { free(p); return NULL; }
if (persist_start(p) != 0) { persist_fini(p); free(p); return NULL; }
return p;
}
static void teardown_ckpt_e2e(persist_t* p)
{
persist_stop(p);
persist_fini(p);
free(p);
}
/* ------------------------------------------------------------------ */
/* Test 1: LSN continuity — 20 WAL + checkpoint + 10 WAL + recovery */
/* ------------------------------------------------------------------ */
static void test_checkpoint_recovery_lsn_continuity(void)
{
char tmpdir[256];
persist_t* p = make_ckpt_e2e(tmpdir, sizeof(tmpdir), 2);
TEST_ASSERT_NOT_NULL(p);
/* Phase 1: 20 WAL entries */
for (int i = 0; i < 20; i++) {
TEST_ASSERT_EQUAL_INT(0,
persist_wal_async(p, WAL_JOB_UPDATE, "x", 1));
}
/* Checkpoint */
TEST_ASSERT_EQUAL_INT(0, persist_checkpoint(p));
uint64_t ckpt_lsn = p->checkpoint_lsn;
TEST_ASSERT_TRUE(ckpt_lsn > 0);
/* Phase 2: 10 more WAL entries after checkpoint */
for (int i = 0; i < 10; i++) {
TEST_ASSERT_EQUAL_INT(0,
persist_wal_async(p, WAL_JOB_UPDATE, "y", 1));
}
TEST_ASSERT_EQUAL_INT(0, persist_flush(p));
teardown_ckpt_e2e(p);
/* Phase 3: fresh persist_t, recover */
persist_t* p2 = calloc(1, sizeof(persist_t));
TEST_ASSERT_NOT_NULL(p2);
persist_config_t cfg = persist_config_defaults();
cfg.wal_sync_ms = 5000;
cfg.snap_keep_n = 2;
persist_set_workdir(p2, tmpdir, "e2e-ckpt");
TEST_ASSERT_EQUAL_INT(0, persist_init(p2, &cfg, NULL));
TEST_ASSERT_EQUAL_INT(0, persist_start(p2));
TEST_ASSERT_EQUAL_INT(0, persist_recover(p2));
/* All 30+ entries must be accounted for */
TEST_ASSERT_TRUE(p2->current_lsn >= 30);
/* The snapshot was used */
TEST_ASSERT_TRUE(p2->checkpoint_lsn > 0);
teardown_ckpt_e2e(p2);
printf("test_checkpoint_recovery_lsn_continuity: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Test 2: WAL trim — old segments removed after two checkpoints */
/* ------------------------------------------------------------------ */
static void test_checkpoint_wal_trim_after_checkpoint(void)
{
char tmpdir[256];
persist_t* p = make_ckpt_e2e(tmpdir, sizeof(tmpdir), 2);
TEST_ASSERT_NOT_NULL(p);
/* Write enough entries and checkpoint once */
for (int i = 0; i < 5; i++)
persist_wal_async(p, WAL_JOB_UPDATE, "trim", 4);
TEST_ASSERT_EQUAL_INT(0, persist_checkpoint(p));
/* Write a few more entries then checkpoint again */
for (int i = 0; i < 3; i++)
persist_wal_async(p, WAL_JOB_UPDATE, "trim2", 5);
TEST_ASSERT_EQUAL_INT(0, persist_checkpoint(p));
/* After two checkpoints the wal directory must still be functional
* (at least one segment exists — the currently-active one). */
char wal_path[512];
snprintf(wal_path, sizeof(wal_path), "%s/e2e-ckpt/data/wal", tmpdir);
int n = count_files_suffix(wal_path, ".wal");
TEST_ASSERT_TRUE(n >= 1);
teardown_ckpt_e2e(p);
printf("test_checkpoint_wal_trim_after_checkpoint: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Test 3: snapshot rotation keeps at most snap_keep_n files */
/* ------------------------------------------------------------------ */
static void test_snapshot_rotation_keeps_snap_keep_n(void)
{
char tmpdir[256];
persist_t* p = make_ckpt_e2e(tmpdir, sizeof(tmpdir), 2);
TEST_ASSERT_NOT_NULL(p);
/* Run 4 checkpoints with snap_keep_n = 2 */
for (int i = 0; i < 4; i++) {
persist_wal_async(p, WAL_JOB_UPDATE, "r", 1);
TEST_ASSERT_EQUAL_INT(0, persist_checkpoint(p));
}
char snap_path[512];
snprintf(snap_path, sizeof(snap_path), "%s/e2e-ckpt/data/snap", tmpdir);
int n = count_files_suffix(snap_path, ".capnp");
TEST_ASSERT_TRUE(n <= (int)p->config.snap_keep_n);
teardown_ckpt_e2e(p);
printf("test_snapshot_rotation_keeps_snap_keep_n: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Suite entry point */
/* ------------------------------------------------------------------ */
void test_e2e_checkpoint(void)
{
printf("\n>> P0-15b: Checkpoint E2E — round trip\n\n");
RUN_TEST(test_checkpoint_recovery_lsn_continuity);
RUN_TEST(test_checkpoint_wal_trim_after_checkpoint);
RUN_TEST(test_snapshot_rotation_keeps_snap_keep_n);
}

View file

@ -0,0 +1,167 @@
/* P0-15d: GC E2E — retention + transient job cleanup
*
* Exercises cross-phase interactions:
* - GC retention predicates (P0-E) applied to concrete job/worker fixtures
* - JOB_STAT_TRANSIENT timeout behaviour
* - Checkpoint succeeds after WAL writes (P0-C integration smoke)
*/
#define _GNU_SOURCE
#include <dirent.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <time.h>
#include <unistd.h>
#include <unity/unity.h>
#include "config.h"
#include "persist.h"
#include "xce/types.h"
/* ------------------------------------------------------------------ */
/* Fixtures */
/* ------------------------------------------------------------------ */
static job_t make_gc_job(unsigned int state, time_t finish_time,
uint64_t transient_since)
{
job_t j;
memset(&j, 0, sizeof(j));
j.job.state = state;
j.job.finish_time = finish_time;
j.job.transient_since = (uint64_t)transient_since;
return j;
}
static xce_worker_t make_gc_worker(uint32_t status, time_t dead_since)
{
xce_worker_t w;
memset(&w, 0, sizeof(w));
w.status = status;
w.dead_since = dead_since;
return w;
}
/* ------------------------------------------------------------------ */
/* Test 1: running job must be retained */
/* ------------------------------------------------------------------ */
static void test_job_gc_retain_running_job(void)
{
persist_config_t cfg = persist_config_defaults();
job_t j = make_gc_job(JOB_STAT_RUN, 0, 0);
TEST_ASSERT_TRUE(job_should_retain(&j, &cfg));
printf("test_job_gc_retain_running_job: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Test 2: expired finished job must not be retained */
/* ------------------------------------------------------------------ */
static void test_job_gc_cleanup_expired_finished_job(void)
{
time_t now = time(NULL);
persist_config_t cfg = persist_config_defaults();
cfg.finished_retain_s = 3600;
job_t j = make_gc_job(JOB_STAT_EXIT, now - 7200, 0);
TEST_ASSERT_FALSE(job_should_retain(&j, &cfg));
printf("test_job_gc_cleanup_expired_finished_job: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Test 3: transient job times out */
/* ------------------------------------------------------------------ */
static void test_transient_job_times_out(void)
{
time_t now = time(NULL);
persist_config_t cfg = persist_config_defaults();
cfg.transient_job_timeout_s = 300;
/* transient_since = now - 400 seconds — past the 300s window */
job_t j = make_gc_job(JOB_STAT_TRANSIENT, 0, (uint64_t)(now - 400));
TEST_ASSERT_FALSE(job_should_retain(&j, &cfg));
printf("test_transient_job_times_out: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Test 4: expired unavailable worker must not be retained */
/* ------------------------------------------------------------------ */
static void test_worker_gc_cleanup_expired_unavailable(void)
{
time_t now = time(NULL);
persist_config_t cfg = persist_config_defaults();
cfg.worker_retain_s = 86400; /* 24 h */
xce_worker_t w = make_gc_worker(XCE_WORKER_UNAVAIL, now - 100000);
TEST_ASSERT_FALSE(worker_should_retain(&w, &cfg));
printf("test_worker_gc_cleanup_expired_unavailable: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Test 5: checkpoint succeeds and creates snapshot (integration smoke)*/
/* ------------------------------------------------------------------ */
static void test_checkpoint_gc_removes_expired_entity(void)
{
char tmpdir[256];
strcpy(tmpdir, "/tmp/xce_e2e_gc_XXXXXX");
TEST_ASSERT_NOT_NULL(mkdtemp(tmpdir));
persist_t* p = calloc(1, sizeof(persist_t));
TEST_ASSERT_NOT_NULL(p);
persist_config_t cfg = persist_config_defaults();
cfg.wal_sync_ms = 5000;
cfg.snap_keep_n = 2;
persist_set_workdir(p, tmpdir, "e2e-gc");
TEST_ASSERT_EQUAL_INT(0, persist_init(p, &cfg, NULL));
TEST_ASSERT_EQUAL_INT(0, persist_start(p));
/* Write some WAL entries (representing state changes) */
for (int i = 0; i < 5; i++)
TEST_ASSERT_EQUAL_INT(0,
persist_wal_async(p, WAL_JOB_UPDATE, "gc-test", 7));
/* Trigger checkpoint — must succeed and leave a snapshot */
TEST_ASSERT_EQUAL_INT(0, persist_checkpoint(p));
TEST_ASSERT_TRUE(p->checkpoint_lsn > 0);
char snap_path[512];
snprintf(snap_path, sizeof(snap_path), "%s/e2e-gc/data/snap", tmpdir);
DIR* d = opendir(snap_path);
TEST_ASSERT_NOT_NULL(d);
int found = 0;
struct dirent* e;
while ((e = readdir(d)) != NULL) {
size_t len = strlen(e->d_name);
if (len > 6 && strcmp(e->d_name + len - 6, ".capnp") == 0)
found++;
}
closedir(d);
TEST_ASSERT_TRUE(found >= 1);
persist_stop(p);
persist_fini(p);
free(p);
printf("test_checkpoint_gc_removes_expired_entity: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Suite entry point */
/* ------------------------------------------------------------------ */
void test_e2e_gc(void)
{
printf("\n>> P0-15d: GC E2E — retention + transient job cleanup\n\n");
RUN_TEST(test_job_gc_retain_running_job);
RUN_TEST(test_job_gc_cleanup_expired_finished_job);
RUN_TEST(test_transient_job_times_out);
RUN_TEST(test_worker_gc_cleanup_expired_unavailable);
RUN_TEST(test_checkpoint_gc_removes_expired_entity);
}

View file

@ -0,0 +1,213 @@
/* P0-15c: Hook E2E — ordering and WAL write
*
* Exercises cross-phase interactions:
* - Hook functions (P0-D) write WAL entries (P0-B)
* - LSN advances monotonically for each hook call
* - WAL segment contains entries with correct type field
*/
#define _GNU_SOURCE
#include <dirent.h>
#include <fcntl.h>
#include <inttypes.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include <zlib.h>
#include <unity/unity.h>
#include "config.h"
#include "persist.h"
#include "xce/cluster_def.h"
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
static char g_e2e_hooks_tmpdir[256];
static persist_t* make_e2e_hook_persist(void)
{
strcpy(g_e2e_hooks_tmpdir, "/tmp/xce_e2e_hook_XXXXXX");
if (!mkdtemp(g_e2e_hooks_tmpdir)) return NULL;
persist_t* p = calloc(1, sizeof(persist_t));
if (!p) return NULL;
persist_config_t cfg = persist_config_defaults();
cfg.wal_sync_ms = 1; /* flush quickly */
persist_set_workdir(p, g_e2e_hooks_tmpdir, "e2e-hook");
if (persist_init(p, &cfg, NULL) != 0) { free(p); return NULL; }
if (persist_start(p) != 0) { persist_fini(p); free(p); return NULL; }
return p;
}
static void teardown_e2e_hook(persist_t* p)
{
persist_stop(p);
persist_fini(p);
free(p);
}
/* Find the first .wal segment in a WAL directory. */
static int find_first_seg(const char* wal_dir, char* out, size_t outsz)
{
DIR* d = opendir(wal_dir);
if (!d) return -1;
struct dirent* e;
while ((e = readdir(d)) != NULL) {
size_t len = strlen(e->d_name);
if (len > 4 && strcmp(e->d_name + len - 4, ".wal") == 0) {
snprintf(out, outsz, "%s/%s", wal_dir, e->d_name);
closedir(d);
return 0;
}
}
closedir(d);
return -1;
}
/* Read the Nth (0-indexed) wal_entry_hdr_t from a segment file (sequential
* layout: hdr + payload). Returns 0 on success. */
static int read_nth_hdr(const char* seg_path, int n, wal_entry_hdr_t* out)
{
int fd = open(seg_path, O_RDONLY);
if (fd < 0) return -1;
off_t offset = 0;
for (int i = 0; i <= n; i++) {
wal_entry_hdr_t hdr;
ssize_t rd = pread(fd, &hdr, sizeof(hdr), offset);
if (rd != (ssize_t)sizeof(hdr)) { close(fd); return -1; }
if (i == n) { *out = hdr; close(fd); return 0; }
offset += (off_t)(sizeof(hdr) + hdr.payload_len);
}
close(fd);
return -1;
}
/* ------------------------------------------------------------------ */
/* Test 1: job hook writes WAL before LSN advances */
/* ------------------------------------------------------------------ */
static void test_job_hook_writes_wal_before_lsn_advances(void)
{
persist_t* p = make_e2e_hook_persist();
TEST_ASSERT_NOT_NULL(p);
uint64_t lsn_before = p->current_lsn;
int rc = persist_job_hook_fun(HOOK_AFTER_EVENT, HOOK_AFTER_EVENT,
HOOK_DETAIL_JOB_NEW, p, NULL);
TEST_ASSERT_EQUAL_INT(0, rc);
/* LSN must have advanced by exactly 1 */
TEST_ASSERT_EQUAL_UINT64(lsn_before + 1, p->current_lsn);
/* Flush and verify the entry type in the segment file */
TEST_ASSERT_EQUAL_INT(0, persist_flush(p));
char seg_path[512];
TEST_ASSERT_EQUAL_INT(0, find_first_seg(p->wal_path, seg_path, sizeof(seg_path)));
wal_entry_hdr_t hdr;
TEST_ASSERT_EQUAL_INT(0, read_nth_hdr(seg_path, 0, &hdr));
TEST_ASSERT_EQUAL_UINT32(WAL_JOB_SUBMIT, hdr.type);
teardown_e2e_hook(p);
printf("test_job_hook_writes_wal_before_lsn_advances: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Test 2: worker hook writes WAL_WORKER_CTRL async */
/* ------------------------------------------------------------------ */
static void test_worker_hook_writes_wal_async(void)
{
persist_t* p = make_e2e_hook_persist();
TEST_ASSERT_NOT_NULL(p);
uint64_t lsn_before = p->current_lsn;
int rc = persist_worker_hook_fun(HOOK_AFTER_EVENT, HOOK_AFTER_EVENT,
HOOK_DETAIL_WORKER_JOIN, p, NULL);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_UINT64(lsn_before + 1, p->current_lsn);
TEST_ASSERT_EQUAL_INT(0, persist_flush(p));
char seg_path[512];
TEST_ASSERT_EQUAL_INT(0, find_first_seg(p->wal_path, seg_path, sizeof(seg_path)));
wal_entry_hdr_t hdr;
TEST_ASSERT_EQUAL_INT(0, read_nth_hdr(seg_path, 0, &hdr));
TEST_ASSERT_EQUAL_UINT32(WAL_WORKER_CTRL, hdr.type);
teardown_e2e_hook(p);
printf("test_worker_hook_writes_wal_async: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Test 3: hook sequence preserves monotone LSN order */
/* ------------------------------------------------------------------ */
static void test_hook_sequence_preserves_lsn_order(void)
{
persist_t* p = make_e2e_hook_persist();
TEST_ASSERT_NOT_NULL(p);
uint64_t lsn0 = p->current_lsn;
persist_job_hook_fun(HOOK_AFTER_EVENT, HOOK_AFTER_EVENT,
HOOK_DETAIL_JOB_NEW, p, NULL);
uint64_t lsn1 = p->current_lsn;
persist_job_hook_fun(HOOK_AFTER_EVENT, HOOK_AFTER_EVENT,
HOOK_DETAIL_JOB_START, p, NULL);
uint64_t lsn2 = p->current_lsn;
persist_job_hook_fun(HOOK_AFTER_EVENT, HOOK_AFTER_EVENT,
HOOK_DETAIL_JOB_EXIT, p, NULL);
uint64_t lsn3 = p->current_lsn;
/* LSNs must be strictly increasing */
TEST_ASSERT_TRUE(lsn1 > lsn0);
TEST_ASSERT_TRUE(lsn2 > lsn1);
TEST_ASSERT_TRUE(lsn3 > lsn2);
TEST_ASSERT_EQUAL_UINT64(lsn0 + 3, lsn3);
/* Flush and verify three entries on disk in order */
TEST_ASSERT_EQUAL_INT(0, persist_flush(p));
char seg_path[512];
TEST_ASSERT_EQUAL_INT(0, find_first_seg(p->wal_path, seg_path, sizeof(seg_path)));
wal_entry_hdr_t h0, h1, h2;
TEST_ASSERT_EQUAL_INT(0, read_nth_hdr(seg_path, 0, &h0));
TEST_ASSERT_EQUAL_INT(0, read_nth_hdr(seg_path, 1, &h1));
TEST_ASSERT_EQUAL_INT(0, read_nth_hdr(seg_path, 2, &h2));
/* LSNs in the headers must also be strictly increasing */
TEST_ASSERT_TRUE(h1.lsn > h0.lsn);
TEST_ASSERT_TRUE(h2.lsn > h1.lsn);
teardown_e2e_hook(p);
printf("test_hook_sequence_preserves_lsn_order: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Suite entry point */
/* ------------------------------------------------------------------ */
void test_e2e_hooks(void)
{
printf("\n>> P0-15c: Hook E2E — ordering and WAL write\n\n");
RUN_TEST(test_job_hook_writes_wal_before_lsn_advances);
RUN_TEST(test_worker_hook_writes_wal_async);
RUN_TEST(test_hook_sequence_preserves_lsn_order);
}

View file

@ -0,0 +1,269 @@
/* P0-15a: WAL integrity end-to-end smoke tests
*
* Exercises cross-phase interactions:
* - WAL writer (P0-B) produces segment files that can be read back
* - Torn-write simulation (ftruncate) + persist_recover() (P0-C)
* - Chained-CRC corruption detection during recovery replay
*/
#define _GNU_SOURCE
#include <dirent.h>
#include <errno.h>
#include <fcntl.h>
#include <inttypes.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include <zlib.h>
#include <unity/unity.h>
#include "config.h"
#include "persist.h"
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
static char g_e2e_wal_tmpdir[256];
static persist_t* make_e2e_wal_persist(void)
{
strcpy(g_e2e_wal_tmpdir, "/tmp/xce_e2e_wal_XXXXXX");
if (!mkdtemp(g_e2e_wal_tmpdir)) return NULL;
persist_t* p = calloc(1, sizeof(persist_t));
if (!p) return NULL;
persist_config_t cfg = persist_config_defaults();
cfg.wal_sync_ms = 5000; /* suppress background fsync races */
persist_set_workdir(p, g_e2e_wal_tmpdir, "e2e-wal");
if (persist_init(p, &cfg, NULL) != 0) { free(p); return NULL; }
if (persist_start(p) != 0) { persist_fini(p); free(p); return NULL; }
return p;
}
static void teardown_e2e_wal(persist_t* p)
{
persist_stop(p);
persist_fini(p);
free(p);
}
/* Return the path to the first .wal segment in the WAL directory. */
static int find_first_wal_segment(const char* wal_dir, char* out, size_t outsz)
{
DIR* d = opendir(wal_dir);
if (!d) return -1;
struct dirent* e;
while ((e = readdir(d)) != NULL) {
size_t len = strlen(e->d_name);
if (len > 4 && strcmp(e->d_name + len - 4, ".wal") == 0) {
snprintf(out, outsz, "%s/%s", wal_dir, e->d_name);
closedir(d);
return 0;
}
}
closedir(d);
return -1;
}
/* Count valid wal_entry_hdr_t records in a segment file by reading
* sequentially and checking header CRC (first 32 bytes of header). */
static int count_valid_wal_entries(const char* seg_path)
{
int fd = open(seg_path, O_RDONLY);
if (fd < 0) return -1;
int count = 0;
off_t offset = 0;
for (;;) {
wal_entry_hdr_t hdr;
ssize_t n = pread(fd, &hdr, sizeof(hdr), offset);
if (n != (ssize_t)sizeof(hdr)) break;
/* Verify header CRC (first WAL_HDR_CRC_SIZE bytes) */
uint32_t expected_crc = (uint32_t)crc32(0L,
(const unsigned char*)&hdr, WAL_HDR_CRC_SIZE);
if (hdr.payload_len > 0) {
unsigned char* payload = malloc(hdr.payload_len);
if (!payload) break;
ssize_t pn = pread(fd, payload, hdr.payload_len,
offset + (off_t)sizeof(hdr));
if (pn == (ssize_t)hdr.payload_len)
expected_crc = (uint32_t)crc32(expected_crc,
payload, hdr.payload_len);
free(payload);
}
if (expected_crc != hdr.crc) break;
count++;
offset += (off_t)(sizeof(hdr) + hdr.payload_len);
}
close(fd);
return count;
}
/* ------------------------------------------------------------------ */
/* Test 1: write 10 WAL entries and verify they appear in segment file */
/* ------------------------------------------------------------------ */
static void test_wal_write_and_verify_entry_count(void)
{
persist_t* p = make_e2e_wal_persist();
TEST_ASSERT_NOT_NULL(p);
const char payload[] = "e2e-entry";
for (int i = 0; i < 10; i++) {
int rc = persist_wal_async(p, WAL_JOB_UPDATE, payload,
(uint32_t)strlen(payload));
TEST_ASSERT_EQUAL_INT(0, rc);
}
TEST_ASSERT_EQUAL_INT(0, persist_flush(p));
char seg_path[512];
TEST_ASSERT_EQUAL_INT(0,
find_first_wal_segment(p->wal_path, seg_path, sizeof(seg_path)));
int count = count_valid_wal_entries(seg_path);
TEST_ASSERT_EQUAL_INT(10, count);
teardown_e2e_wal(p);
printf("test_wal_write_and_verify_entry_count: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Test 2: torn-write recovery — truncate last entry, recover, check */
/* ------------------------------------------------------------------ */
static void test_wal_torn_write_recovery(void)
{
/* Phase 1: write 5 entries and flush */
persist_t* p = make_e2e_wal_persist();
TEST_ASSERT_NOT_NULL(p);
const char payload[] = "torn";
for (int i = 0; i < 5; i++) {
TEST_ASSERT_EQUAL_INT(0,
persist_wal_async(p, WAL_JOB_UPDATE, payload,
(uint32_t)strlen(payload)));
}
TEST_ASSERT_EQUAL_INT(0, persist_flush(p));
/* Locate segment file and determine size of 4 complete entries */
char seg_path[512];
TEST_ASSERT_EQUAL_INT(0,
find_first_wal_segment(p->wal_path, seg_path, sizeof(seg_path)));
/* One entry = sizeof(wal_entry_hdr_t) + payload_len */
off_t entry_size = (off_t)(sizeof(wal_entry_hdr_t) + strlen(payload));
off_t trunc_offset = entry_size * 4; /* keep only 4 complete entries */
/* Truncate: simulate torn write on entry 5 */
int fd = open(seg_path, O_RDWR);
TEST_ASSERT_TRUE(fd >= 0);
TEST_ASSERT_EQUAL_INT(0, ftruncate(fd, trunc_offset));
close(fd);
uint64_t lsn_before = p->current_lsn;
teardown_e2e_wal(p); /* stop / destroy the first instance */
/* Phase 2: fresh persist_t pointing at the same directory */
persist_t* p2 = calloc(1, sizeof(persist_t));
TEST_ASSERT_NOT_NULL(p2);
persist_config_t cfg = persist_config_defaults();
cfg.wal_sync_ms = 5000;
persist_set_workdir(p2, g_e2e_wal_tmpdir, "e2e-wal");
TEST_ASSERT_EQUAL_INT(0, persist_init(p2, &cfg, NULL));
TEST_ASSERT_EQUAL_INT(0, persist_start(p2));
int rc = persist_recover(p2);
TEST_ASSERT_EQUAL_INT(0, rc);
/* Recovery must have replayed at most 4 entries (not 5 — torn write) */
TEST_ASSERT_TRUE(p2->current_lsn < lsn_before);
teardown_e2e_wal(p2);
printf("test_wal_torn_write_recovery: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Test 3: chained CRC corruption stops replay at last valid entry */
/* ------------------------------------------------------------------ */
static void test_wal_chained_crc_detects_corruption(void)
{
/* Phase 1: write 5 entries and flush */
persist_t* p = make_e2e_wal_persist();
TEST_ASSERT_NOT_NULL(p);
const char payload[] = "crc-chain";
for (int i = 0; i < 5; i++) {
TEST_ASSERT_EQUAL_INT(0,
persist_wal_async(p, WAL_JOB_UPDATE, payload,
(uint32_t)strlen(payload)));
}
TEST_ASSERT_EQUAL_INT(0, persist_flush(p));
char seg_path[512];
TEST_ASSERT_EQUAL_INT(0,
find_first_wal_segment(p->wal_path, seg_path, sizeof(seg_path)));
/* Corrupt payload of entry 3 (zero-indexed: entries 0,1 valid; entry 2
* corrupted; entries 3,4 unreachable via CRC chain). */
off_t entry_size = (off_t)(sizeof(wal_entry_hdr_t) + strlen(payload));
off_t corrupt_offset = entry_size * 2 + (off_t)sizeof(wal_entry_hdr_t);
/* Write garbage over the payload of entry 2 */
int fd = open(seg_path, O_RDWR);
TEST_ASSERT_TRUE(fd >= 0);
const char garbage[] = "\xFF\xFF\xFF\xFF";
pwrite(fd, garbage, sizeof(garbage) - 1, corrupt_offset);
close(fd);
uint64_t lsn_before = p->current_lsn;
teardown_e2e_wal(p);
/* Phase 2: fresh persist_t — recover must stop before the corrupt entry */
persist_t* p2 = calloc(1, sizeof(persist_t));
TEST_ASSERT_NOT_NULL(p2);
persist_config_t cfg = persist_config_defaults();
cfg.wal_sync_ms = 5000;
persist_set_workdir(p2, g_e2e_wal_tmpdir, "e2e-wal");
TEST_ASSERT_EQUAL_INT(0, persist_init(p2, &cfg, NULL));
TEST_ASSERT_EQUAL_INT(0, persist_start(p2));
int rc = persist_recover(p2);
TEST_ASSERT_EQUAL_INT(0, rc);
/* Must have replayed fewer than 5 entries — stopped at corruption */
TEST_ASSERT_TRUE(p2->current_lsn < lsn_before);
teardown_e2e_wal(p2);
printf("test_wal_chained_crc_detects_corruption: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Suite entry point */
/* ------------------------------------------------------------------ */
void test_e2e_wal(void)
{
printf("\n>> P0-15a: WAL E2E — torn write + CRC corruption recovery\n\n");
RUN_TEST(test_wal_write_and_verify_entry_count);
RUN_TEST(test_wal_torn_write_recovery);
RUN_TEST(test_wal_chained_crc_detects_corruption);
}

View file

@ -0,0 +1,244 @@
#define _GNU_SOURCE
#include <stdbool.h>
#include <stdio.h>
#include <string.h>
#include <time.h>
#include <unity/unity.h>
#include "config.h"
#include "persist.h"
#include "xce/types.h"
/* ------------------------------------------------------------------ */
/* Helpers to build minimal job_t / xce_worker_t fixtures */
/* ------------------------------------------------------------------ */
static job_t make_job(unsigned int state, time_t finish_time,
uint64_t transient_since)
{
job_t j;
memset(&j, 0, sizeof(j));
j.job.state = state;
j.job.finish_time = finish_time;
j.job.transient_since = (uint64_t)transient_since;
return j;
}
static xce_worker_t make_worker(uint32_t status, time_t dead_since)
{
xce_worker_t w;
memset(&w, 0, sizeof(w));
w.status = status;
w.dead_since = dead_since;
return w;
}
/* ------------------------------------------------------------------ */
/* Forward declarations for functions under test */
/* ------------------------------------------------------------------ */
extern bool job_is_terminal(const job_t* j);
extern bool job_should_retain(const job_t* j, const persist_config_t* cfg);
extern bool worker_should_retain(const xce_worker_t* w,
const persist_config_t* cfg);
/* ------------------------------------------------------------------ */
/* job_is_terminal tests */
/* ------------------------------------------------------------------ */
static void test_running_job_not_terminal(void)
{
job_t j = make_job(JOB_STAT_RUN, 0, 0);
TEST_ASSERT_FALSE(job_is_terminal(&j));
}
static void test_pending_job_not_terminal(void)
{
job_t j = make_job(JOB_STAT_PEND, 0, 0);
TEST_ASSERT_FALSE(job_is_terminal(&j));
}
static void test_exit_job_is_terminal(void)
{
job_t j = make_job(JOB_STAT_EXIT, time(NULL), 0);
TEST_ASSERT_TRUE(job_is_terminal(&j));
}
static void test_done_job_is_terminal(void)
{
job_t j = make_job(JOB_STAT_DONE, time(NULL), 0);
TEST_ASSERT_TRUE(job_is_terminal(&j));
}
static void test_perr_job_is_terminal(void)
{
job_t j = make_job(JOB_STAT_PERR, time(NULL), 0);
TEST_ASSERT_TRUE(job_is_terminal(&j));
}
static void test_transient_job_not_terminal(void)
{
/* JOB_STAT_TRANSIENT is NOT terminal — it's a special non-terminal state */
job_t j = make_job(JOB_STAT_TRANSIENT, 0, (uint64_t)time(NULL));
TEST_ASSERT_FALSE(job_is_terminal(&j));
}
/* ------------------------------------------------------------------ */
/* job_should_retain: TRANSIENT timeout tests */
/* ------------------------------------------------------------------ */
static void test_transient_job_retained_within_timeout(void)
{
/* transient_since = now - 100s, timeout = 300s → retain */
time_t now = time(NULL);
persist_config_t cfg = persist_config_defaults();
cfg.transient_job_timeout_s = 300;
job_t j = make_job(JOB_STAT_TRANSIENT, 0, now - 100);
TEST_ASSERT_TRUE(job_should_retain(&j, &cfg));
}
static void test_transient_job_expired(void)
{
/* transient_since = now - 400s, timeout = 300s → do NOT retain */
time_t now = time(NULL);
persist_config_t cfg = persist_config_defaults();
cfg.transient_job_timeout_s = 300;
job_t j = make_job(JOB_STAT_TRANSIENT, 0, now - 400);
TEST_ASSERT_FALSE(job_should_retain(&j, &cfg));
}
static void test_transient_job_zero_since_always_retained(void)
{
/* transient_since == 0 means unset → never expire (operator must decide) */
persist_config_t cfg = persist_config_defaults();
cfg.transient_job_timeout_s = 300;
job_t j = make_job(JOB_STAT_TRANSIENT, 0, 0);
TEST_ASSERT_TRUE(job_should_retain(&j, &cfg));
}
/* ------------------------------------------------------------------ */
/* job_should_retain: terminal job TTL tests */
/* ------------------------------------------------------------------ */
static void test_finished_job_retained_within_ttl(void)
{
/* finish_time = now - 1800s, retain = 3600s → retain */
time_t now = time(NULL);
persist_config_t cfg = persist_config_defaults();
cfg.finished_retain_s = 3600;
job_t j = make_job(JOB_STAT_EXIT, now - 1800, 0);
TEST_ASSERT_TRUE(job_should_retain(&j, &cfg));
}
static void test_finished_job_expired(void)
{
/* finish_time = now - 7200s, retain = 3600s → do NOT retain */
time_t now = time(NULL);
persist_config_t cfg = persist_config_defaults();
cfg.finished_retain_s = 3600;
job_t j = make_job(JOB_STAT_EXIT, now - 7200, 0);
TEST_ASSERT_FALSE(job_should_retain(&j, &cfg));
}
static void test_done_job_retained_within_ttl(void)
{
time_t now = time(NULL);
persist_config_t cfg = persist_config_defaults();
cfg.finished_retain_s = 3600;
job_t j = make_job(JOB_STAT_DONE, now - 1800, 0);
TEST_ASSERT_TRUE(job_should_retain(&j, &cfg));
}
static void test_running_job_always_retained(void)
{
persist_config_t cfg = persist_config_defaults();
job_t j = make_job(JOB_STAT_RUN, 0, 0);
TEST_ASSERT_TRUE(job_should_retain(&j, &cfg));
}
static void test_pending_job_always_retained(void)
{
persist_config_t cfg = persist_config_defaults();
job_t j = make_job(JOB_STAT_PEND, 0, 0);
TEST_ASSERT_TRUE(job_should_retain(&j, &cfg));
}
/* ------------------------------------------------------------------ */
/* worker_should_retain tests */
/* ------------------------------------------------------------------ */
static void test_worker_unavail_retained_within_ttl(void)
{
/* dead_since = now - 43200s (12 h), retain = 86400s (24 h) → retain */
time_t now = time(NULL);
persist_config_t cfg = persist_config_defaults();
cfg.worker_retain_s = 86400;
xce_worker_t w = make_worker(XCE_WORKER_UNAVAIL, now - 43200);
TEST_ASSERT_TRUE(worker_should_retain(&w, &cfg));
}
static void test_worker_unavail_expired(void)
{
/* dead_since = now - 100000s, retain = 86400s → do NOT retain */
time_t now = time(NULL);
persist_config_t cfg = persist_config_defaults();
cfg.worker_retain_s = 86400;
xce_worker_t w = make_worker(XCE_WORKER_UNAVAIL, now - 100000);
TEST_ASSERT_FALSE(worker_should_retain(&w, &cfg));
}
static void test_worker_hidden_never_retained(void)
{
/* XCE_WORKER_HIDDEN → cleanup immediately regardless of dead_since */
time_t now = time(NULL);
persist_config_t cfg = persist_config_defaults();
xce_worker_t w = make_worker(XCE_WORKER_HIDDEN, now - 10);
TEST_ASSERT_FALSE(worker_should_retain(&w, &cfg));
}
static void test_worker_healthy_always_retained(void)
{
persist_config_t cfg = persist_config_defaults();
xce_worker_t w = make_worker(0, 0);
TEST_ASSERT_TRUE(worker_should_retain(&w, &cfg));
}
static void test_worker_unavail_zero_dead_since_always_retained(void)
{
/* dead_since == 0 → not yet set, do not GC */
persist_config_t cfg = persist_config_defaults();
cfg.worker_retain_s = 86400;
xce_worker_t w = make_worker(XCE_WORKER_UNAVAIL, 0);
TEST_ASSERT_TRUE(worker_should_retain(&w, &cfg));
}
/* ------------------------------------------------------------------ */
/* Test suite entry point */
/* ------------------------------------------------------------------ */
void test_gc_retention(void)
{
RUN_TEST(test_running_job_not_terminal);
RUN_TEST(test_pending_job_not_terminal);
RUN_TEST(test_exit_job_is_terminal);
RUN_TEST(test_done_job_is_terminal);
RUN_TEST(test_perr_job_is_terminal);
RUN_TEST(test_transient_job_not_terminal);
RUN_TEST(test_transient_job_retained_within_timeout);
RUN_TEST(test_transient_job_expired);
RUN_TEST(test_transient_job_zero_since_always_retained);
RUN_TEST(test_finished_job_retained_within_ttl);
RUN_TEST(test_finished_job_expired);
RUN_TEST(test_done_job_retained_within_ttl);
RUN_TEST(test_running_job_always_retained);
RUN_TEST(test_pending_job_always_retained);
RUN_TEST(test_worker_unavail_retained_within_ttl);
RUN_TEST(test_worker_unavail_expired);
RUN_TEST(test_worker_hidden_never_retained);
RUN_TEST(test_worker_healthy_always_retained);
RUN_TEST(test_worker_unavail_zero_dead_since_always_retained);
}

View file

@ -0,0 +1,148 @@
/*
* test_ha_cluster.c — P1-5: structural tests for ha_state_t embedded in cluster_t
*
* These tests validate that:
* 1. cluster_t has an ha_state_t ha field (compile-time structural check).
* 2. ha_promote_to_primary() and ha_demote_to_standby() use c->ha (not no-ops).
*
* Tests do NOT call cluster_init() — that requires a live config/SSL/network stack.
* Instead they manually zero-init cluster_t and call ha_init() on c.ha directly.
*/
#include <stdatomic.h>
#include <string.h>
#include <unity/unity.h>
#include "ha.h"
#include "xce/cluster_def.h"
/* Valid HA config shared across tests */
static ha_config_t g_cfg = {
.heartbeat_interval_ms = 3000,
.heartbeat_timeout_ms = 10000,
.election_timeout_ms = 2000,
.probe_connect_timeout_ms = 500,
.max_promotion_retries = 5,
};
/*
* test_cluster_t_has_ha_field:
* Compile-time structural check — cluster_t must embed ha_state_t as field `ha`.
* If `c.ha` does not exist, this file will not compile → RED.
*/
static void test_cluster_t_has_ha_field(void)
{
cluster_t c;
memset(&c, 0, sizeof(c));
/* Access the field — compile error if absent */
(void)atomic_load_explicit(&c.ha.role, memory_order_acquire);
printf("test_cluster_t_has_ha_field: PASS\n");
}
/*
* test_ha_promote_to_primary_uses_c_ha:
* After ha_init(&c.ha, ...) and calling ha_promote_to_primary(&c), the role
* stored in c.ha must be CTRL_PRIMARY (not still CTRL_STANDBY).
* This would fail if ha_promote_to_primary() is still the (void)c stub.
*/
static void test_ha_promote_to_primary_uses_c_ha(void)
{
cluster_t c;
memset(&c, 0, sizeof(c));
/* Manually initialise only the HA field — no cluster_init() required */
TEST_ASSERT_EQUAL_INT(0, ha_init(&c.ha, &g_cfg, 0 /* my_priority = 0 → primary */));
TEST_ASSERT_EQUAL_INT(CTRL_STANDBY,
(int)atomic_load_explicit(&c.ha.role, memory_order_acquire));
/* c.hooksys is zeroed — hooks_registered guard prevents xce_add_hook() call */
ha_promote_to_primary(&c);
TEST_ASSERT_EQUAL_INT(CTRL_PRIMARY,
(int)atomic_load_explicit(&c.ha.role, memory_order_acquire));
ha_fini(&c.ha);
printf("test_ha_promote_to_primary_uses_c_ha: PASS\n");
}
/*
* test_ha_demote_to_standby_uses_c_ha:
* Promote then demote. Role must end as CTRL_STANDBY.
*/
static void test_ha_demote_to_standby_uses_c_ha(void)
{
cluster_t c;
memset(&c, 0, sizeof(c));
TEST_ASSERT_EQUAL_INT(0, ha_init(&c.ha, &g_cfg, 0));
ha_promote_to_primary(&c);
TEST_ASSERT_EQUAL_INT(CTRL_PRIMARY,
(int)atomic_load_explicit(&c.ha.role, memory_order_acquire));
ha_demote_to_standby(&c);
TEST_ASSERT_EQUAL_INT(CTRL_STANDBY,
(int)atomic_load_explicit(&c.ha.role, memory_order_acquire));
ha_fini(&c.ha);
printf("test_ha_demote_to_standby_uses_c_ha: PASS\n");
}
/*
* test_ha_promote_increments_term_via_cluster:
* Each ha_promote_to_primary() call must increment the election term stored
* in c.ha.term, because ha_do_promote() is called inside.
*/
static void test_ha_promote_increments_term_via_cluster(void)
{
cluster_t c;
memset(&c, 0, sizeof(c));
TEST_ASSERT_EQUAL_INT(0, ha_init(&c.ha, &g_cfg, 0));
uint64_t term0 = atomic_load_explicit(&c.ha.term, memory_order_acquire);
ha_promote_to_primary(&c);
uint64_t term1 = atomic_load_explicit(&c.ha.term, memory_order_acquire);
TEST_ASSERT_TRUE(term1 > term0);
ha_fini(&c.ha);
printf("test_ha_promote_increments_term_via_cluster: PASS\n");
}
/*
* test_hooks_registered_guard_prevents_double_register:
* When hooksys is zeroed (no real hook system) and hooks_registered is 0,
* ha_promote_to_primary() must set hooks_registered = 1 so a second
* call skips the registration path.
*/
static void test_hooks_registered_guard_prevents_double_register(void)
{
cluster_t c;
memset(&c, 0, sizeof(c));
TEST_ASSERT_EQUAL_INT(0, ha_init(&c.ha, &g_cfg, 0));
TEST_ASSERT_EQUAL_INT(0, c.ha.hooks_registered);
ha_promote_to_primary(&c);
/* hooks_registered must now be 1 (idempotency guard set) */
TEST_ASSERT_EQUAL_INT(1, c.ha.hooks_registered);
/* Second promote: must not crash even with zeroed hooksys */
ha_promote_to_primary(&c);
TEST_ASSERT_EQUAL_INT(1, c.ha.hooks_registered);
ha_fini(&c.ha);
printf("test_hooks_registered_guard_prevents_double_register: PASS\n");
}
void test_ha_cluster(void)
{
RUN_TEST(test_cluster_t_has_ha_field);
RUN_TEST(test_ha_promote_to_primary_uses_c_ha);
RUN_TEST(test_ha_demote_to_standby_uses_c_ha);
RUN_TEST(test_ha_promote_increments_term_via_cluster);
RUN_TEST(test_hooks_registered_guard_prevents_double_register);
}

View file

@ -0,0 +1,110 @@
#include <stdatomic.h>
#include <stdio.h>
#include <string.h>
#include <unity/unity.h>
#include "ha.h"
/* ── helpers ────────────────────────────────────────────────── */
static ha_config_t valid_cfg(void)
{
ha_config_t cfg = {
.heartbeat_interval_ms = 3000,
.heartbeat_timeout_ms = 10000,
.election_timeout_ms = 2000,
.probe_connect_timeout_ms = 500,
.max_promotion_retries = 5,
};
return cfg;
}
/* ── tests ──────────────────────────────────────────────────── */
static void test_primary_does_not_redirect(void)
{
ha_state_t ha;
ha_config_t cfg = valid_cfg();
TEST_ASSERT_EQUAL_INT(0, ha_init(&ha, &cfg, 0));
/* Explicitly set PRIMARY role */
atomic_store_explicit(&ha.role, (int32_t)CTRL_PRIMARY, memory_order_release);
TEST_ASSERT_EQUAL_INT(0, ha_should_redirect(&ha));
ha_fini(&ha);
printf("test_primary_does_not_redirect: PASS\n");
}
static void test_standby_redirects_when_primary_known(void)
{
ha_state_t ha;
ha_config_t cfg = valid_cfg();
TEST_ASSERT_EQUAL_INT(0, ha_init(&ha, &cfg, 1));
/* Default role after init is STANDBY; set primary info */
strncpy(ha.primary_host, "10.0.1.1", sizeof(ha.primary_host) - 1);
ha.primary_port = 7000;
TEST_ASSERT_EQUAL_INT(1, ha_should_redirect(&ha));
ha_fini(&ha);
printf("test_standby_redirects_when_primary_known: PASS\n");
}
static void test_standby_no_redirect_when_no_primary_known(void)
{
ha_state_t ha;
ha_config_t cfg = valid_cfg();
TEST_ASSERT_EQUAL_INT(0, ha_init(&ha, &cfg, 1));
/* primary_host is empty after init — no known primary */
TEST_ASSERT_EQUAL_INT(0, ha_should_redirect(&ha));
TEST_ASSERT_EQUAL_INT(1, ha_no_primary_known(&ha));
ha_fini(&ha);
printf("test_standby_no_redirect_when_no_primary_known: PASS\n");
}
static void test_primary_is_not_no_primary_known(void)
{
ha_state_t ha;
ha_config_t cfg = valid_cfg();
TEST_ASSERT_EQUAL_INT(0, ha_init(&ha, &cfg, 0));
atomic_store_explicit(&ha.role, (int32_t)CTRL_PRIMARY, memory_order_release);
TEST_ASSERT_EQUAL_INT(0, ha_no_primary_known(&ha));
ha_fini(&ha);
printf("test_primary_is_not_no_primary_known: PASS\n");
}
static void test_standby_no_primary_known_when_port_zero(void)
{
ha_state_t ha;
ha_config_t cfg = valid_cfg();
TEST_ASSERT_EQUAL_INT(0, ha_init(&ha, &cfg, 1));
/* Host set but port = 0 — not a valid redirect target */
strncpy(ha.primary_host, "10.0.1.1", sizeof(ha.primary_host) - 1);
ha.primary_port = 0;
TEST_ASSERT_EQUAL_INT(0, ha_should_redirect(&ha));
TEST_ASSERT_EQUAL_INT(1, ha_no_primary_known(&ha));
ha_fini(&ha);
printf("test_standby_no_primary_known_when_port_zero: PASS\n");
}
/* ── suite entry point ─────────────────────────────────────── */
void test_ha_redirect(void)
{
RUN_TEST(test_primary_does_not_redirect);
RUN_TEST(test_standby_redirects_when_primary_known);
RUN_TEST(test_standby_no_redirect_when_no_primary_known);
RUN_TEST(test_primary_is_not_no_primary_known);
RUN_TEST(test_standby_no_primary_known_when_port_zero);
}

View file

@ -0,0 +1,249 @@
#include <signal.h>
#include <stdatomic.h>
#include <string.h>
#include <stdio.h>
#include <time.h>
#include <unity/unity.h>
#include "ha.h"
static void test_ha_init_default_role_is_standby(void)
{
ha_state_t ha;
ha_config_t cfg = {
.heartbeat_interval_ms = 3000,
.heartbeat_timeout_ms = 10000,
.election_timeout_ms = 2000,
.probe_connect_timeout_ms = 500,
.max_promotion_retries = 5,
};
TEST_ASSERT_EQUAL_INT(0, ha_init(&ha, &cfg, 0));
TEST_ASSERT_EQUAL_INT(CTRL_STANDBY,
(int)atomic_load_explicit(&ha.role, memory_order_acquire));
ha_fini(&ha);
printf("test_ha_init_default_role_is_standby: PASS\n");
}
static void test_ha_init_rejects_fast_heartbeat(void)
{
ha_state_t ha;
ha_config_t cfg = {
.heartbeat_interval_ms = 1999, /* below 2000ms floor */
.heartbeat_timeout_ms = 10000,
.election_timeout_ms = 2000,
.probe_connect_timeout_ms = 500,
.max_promotion_retries = 5,
};
TEST_ASSERT_NOT_EQUAL(0, ha_init(&ha, &cfg, 0));
printf("test_ha_init_rejects_fast_heartbeat: PASS\n");
}
static void test_ha_init_rejects_short_election_timeout(void)
{
ha_state_t ha;
ha_config_t cfg = {
.heartbeat_interval_ms = 3000,
.heartbeat_timeout_ms = 10000,
.election_timeout_ms = 499, /* below 500ms floor */
.probe_connect_timeout_ms = 500,
.max_promotion_retries = 5,
};
TEST_ASSERT_NOT_EQUAL(0, ha_init(&ha, &cfg, 0));
printf("test_ha_init_rejects_short_election_timeout: PASS\n");
}
static void test_ha_init_rejects_timeout_less_than_interval(void)
{
ha_state_t ha;
ha_config_t cfg = {
.heartbeat_interval_ms = 5000,
.heartbeat_timeout_ms = 4999, /* must be >= heartbeat_interval_ms */
.election_timeout_ms = 2000,
.probe_connect_timeout_ms = 500,
.max_promotion_retries = 5,
};
TEST_ASSERT_NOT_EQUAL(0, ha_init(&ha, &cfg, 0));
printf("test_ha_init_rejects_timeout_less_than_interval: PASS\n");
}
static void test_ha_promote_sets_primary_role(void)
{
ha_state_t ha;
ha_config_t cfg = {
.heartbeat_interval_ms = 3000,
.heartbeat_timeout_ms = 10000,
.election_timeout_ms = 2000,
.probe_connect_timeout_ms = 500,
.max_promotion_retries = 5,
};
ha_init(&ha, &cfg, 0);
/* Directly set role to PRIMARY (stub path — full promotion in P1-3a) */
atomic_store_explicit(&ha.role, (int32_t)CTRL_PRIMARY, memory_order_release);
TEST_ASSERT_EQUAL_INT(CTRL_PRIMARY,
(int)atomic_load_explicit(&ha.role, memory_order_acquire));
ha_fini(&ha);
printf("test_ha_promote_sets_primary_role: PASS\n");
}
/* ── P1-3 watchdog tests ────────────────────────────────────── */
/* ha_start_watchdog() must be callable after ha_init() */
static void test_watchdog_starts_and_stops(void)
{
ha_state_t ha;
ha_config_t cfg = {
.heartbeat_interval_ms = 3000,
.heartbeat_timeout_ms = 10000,
.election_timeout_ms = 2000,
.probe_connect_timeout_ms = 500,
.max_promotion_retries = 5,
};
TEST_ASSERT_EQUAL_INT(0, ha_init(&ha, &cfg, 0));
TEST_ASSERT_EQUAL_INT(0, ha_start_watchdog(&ha));
/* brief sleep to let the thread wake at least once */
struct timespec ts = { 0, 50 * 1000000L }; /* 50ms */
nanosleep(&ts, NULL);
ha_stop_watchdog(&ha); /* sets stop flag + pthread_join */
ha_fini(&ha);
printf("test_watchdog_starts_and_stops: PASS\n");
}
/* ha_check_heartbeat_nocluster(): when in CTRL_STANDBY with no primary_ep,
* must not promote */
static void test_heartbeat_no_action_when_no_primary(void)
{
ha_state_t ha;
ha_config_t cfg = {
.heartbeat_interval_ms = 3000,
.heartbeat_timeout_ms = 10000,
.election_timeout_ms = 2000,
.probe_connect_timeout_ms = 500,
.max_promotion_retries = 5,
};
ha_init(&ha, &cfg, 1 /* not highest priority */);
/* No primary_host set — watchdog must not promote */
ha_check_heartbeat_nocluster(&ha); /* test-only accessor */
TEST_ASSERT_EQUAL_INT(CTRL_STANDBY,
(int)atomic_load_explicit(&ha.role, memory_order_acquire));
ha_fini(&ha);
printf("test_heartbeat_no_action_when_no_primary: PASS\n");
}
/* ha_check_heartbeat_nocluster(): when last_heartbeat is stale (timeout exceeded)
* and primary_host is empty (no known primary), must not crash or change role */
static void test_heartbeat_stale_no_crash_without_primary(void)
{
ha_state_t ha;
ha_config_t cfg = {
.heartbeat_interval_ms = 3000,
.heartbeat_timeout_ms = 100, /* very short for test */
.election_timeout_ms = 2000,
.probe_connect_timeout_ms = 500,
.max_promotion_retries = 5,
};
ha_init(&ha, &cfg, 0);
/* Set last_heartbeat to a time far in the past to simulate timeout */
clock_gettime(CLOCK_MONOTONIC, &ha.last_heartbeat);
ha.last_heartbeat.tv_sec -= 10; /* 10 seconds ago */
ha_check_heartbeat_nocluster(&ha);
/* Should not crash. Role may or may not change — but no segfault. */
ha_fini(&ha);
printf("test_heartbeat_stale_no_crash_without_primary: PASS\n");
}
/* ── P1-3a tests ─────────────────────────────────────────────── */
/* Term must increment on each promotion attempt */
static void test_promote_increments_term(void)
{
ha_state_t ha;
ha_config_t cfg = {
.heartbeat_interval_ms = 3000,
.heartbeat_timeout_ms = 10000,
.election_timeout_ms = 2000,
.probe_connect_timeout_ms = 500,
.max_promotion_retries = 5,
};
ha_init(&ha, &cfg, 0);
uint64_t term_before = atomic_load_explicit(&ha.term, memory_order_acquire);
ha_do_promote(&ha); /* internal promotion logic — no cluster_t needed */
uint64_t term_after = atomic_load_explicit(&ha.term, memory_order_acquire);
TEST_ASSERT_TRUE(term_after > term_before);
TEST_ASSERT_EQUAL_INT(CTRL_PRIMARY,
(int)atomic_load_explicit(&ha.role, memory_order_acquire));
ha_fini(&ha);
printf("test_promote_increments_term: PASS\n");
}
/* Demotion must set role back to STANDBY */
static void test_demote_sets_standby_role(void)
{
ha_state_t ha;
ha_config_t cfg = {
.heartbeat_interval_ms = 3000,
.heartbeat_timeout_ms = 10000,
.election_timeout_ms = 2000,
.probe_connect_timeout_ms = 500,
.max_promotion_retries = 5,
};
ha_init(&ha, &cfg, 0);
ha_do_promote(&ha);
TEST_ASSERT_EQUAL_INT(CTRL_PRIMARY,
(int)atomic_load_explicit(&ha.role, memory_order_acquire));
ha_do_demote(&ha);
TEST_ASSERT_EQUAL_INT(CTRL_STANDBY,
(int)atomic_load_explicit(&ha.role, memory_order_acquire));
ha_fini(&ha);
printf("test_demote_sets_standby_role: PASS\n");
}
/* SIGUSR1 must set failover_requested via async-signal-safe atomic store */
static void test_sigusr1_sets_failover_requested(void)
{
ha_state_t ha;
ha_config_t cfg = {
.heartbeat_interval_ms = 3000,
.heartbeat_timeout_ms = 10000,
.election_timeout_ms = 2000,
.probe_connect_timeout_ms = 500,
.max_promotion_retries = 5,
};
ha_init(&ha, &cfg, 0);
/* Install real SIGUSR1 handler that uses only the atomic flag */
ha_install_sigusr1(&ha);
/* Send SIGUSR1 to self */
raise(SIGUSR1);
/* Handler must have set failover_requested */
TEST_ASSERT_TRUE(
atomic_load_explicit(&ha.failover_requested, memory_order_acquire) != 0);
/* Restore default SIGUSR1 handler */
signal(SIGUSR1, SIG_DFL);
ha_fini(&ha);
printf("test_sigusr1_sets_failover_requested: PASS\n");
}
void test_ha_role(void)
{
RUN_TEST(test_ha_init_default_role_is_standby);
RUN_TEST(test_ha_init_rejects_fast_heartbeat);
RUN_TEST(test_ha_init_rejects_short_election_timeout);
RUN_TEST(test_ha_init_rejects_timeout_less_than_interval);
RUN_TEST(test_ha_promote_sets_primary_role);
RUN_TEST(test_watchdog_starts_and_stops);
RUN_TEST(test_heartbeat_no_action_when_no_primary);
RUN_TEST(test_heartbeat_stale_no_crash_without_primary);
/* P1-3a */
RUN_TEST(test_promote_increments_term);
RUN_TEST(test_demote_sets_standby_role);
RUN_TEST(test_sigusr1_sets_failover_requested);
}

View file

@ -0,0 +1,40 @@
#include <stdio.h>
#include <unity/unity.h>
#include "xce/cluster_def.h"
static void test_hook_detail_job_stop_exists(void)
{
/* HOOK_DETAIL_JOB_STOP must be defined and distinct from JOB_UPDATE */
int stop_val = (int)HOOK_DETAIL_JOB_STOP;
int update_val = (int)HOOK_DETAIL_JOB_UPDATE;
TEST_ASSERT_TRUE(stop_val != 0);
TEST_ASSERT_TRUE(stop_val != update_val);
printf("test_hook_detail_job_stop_exists: PASS\n");
}
static void test_hook_detail_job_resume_exists(void)
{
int resume_val = (int)HOOK_DETAIL_JOB_RESUME;
int update_val = (int)HOOK_DETAIL_JOB_UPDATE;
TEST_ASSERT_TRUE(resume_val != 0);
TEST_ASSERT_TRUE(resume_val != update_val);
TEST_ASSERT_TRUE(resume_val != (int)HOOK_DETAIL_JOB_STOP);
printf("test_hook_detail_job_resume_exists: PASS\n");
}
static void test_hook_detail_job_stop_and_resume_are_distinct(void)
{
TEST_ASSERT_NOT_EQUAL((int)HOOK_DETAIL_JOB_STOP, (int)HOOK_DETAIL_JOB_RESUME);
printf("test_hook_detail_job_stop_and_resume_are_distinct: PASS\n");
}
void test_hooks(void)
{
printf("\n>> hook detail enum tests\n\n");
RUN_TEST(test_hook_detail_job_stop_exists);
RUN_TEST(test_hook_detail_job_resume_exists);
RUN_TEST(test_hook_detail_job_stop_and_resume_are_distinct);
}

View file

@ -0,0 +1,151 @@
/* P1-7: HA Foundation integration tests — assembled system */
#include <stdatomic.h>
#include <stdio.h>
#include <string.h>
#include <time.h>
#include <unity/unity.h>
#include "ha.h"
/* Test 1: Single-node startup: after ha_init + ha_do_promote(priority=0),
* role must be PRIMARY */
static void test_single_node_starts_as_primary(void)
{
ha_state_t ha;
ha_config_t cfg = {
.heartbeat_interval_ms = 3000,
.heartbeat_timeout_ms = 10000,
.election_timeout_ms = 2000,
.probe_connect_timeout_ms = 500,
.max_promotion_retries = 5,
};
ha_init(&ha, &cfg, 0 /* priority 0 = highest */);
/* Simulate am_i_primary() decision for priority-0 node */
ha_do_promote(&ha);
TEST_ASSERT_EQUAL_INT(CTRL_PRIMARY,
(int)atomic_load_explicit(&ha.role, memory_order_acquire));
ha_fini(&ha);
printf("test_single_node_starts_as_primary: PASS\n");
}
/* Test 2: Term fencing — promote increments term; second promote increments again */
static void test_term_fencing_monotonic(void)
{
ha_state_t ha;
ha_config_t cfg = {
.heartbeat_interval_ms = 3000,
.heartbeat_timeout_ms = 10000,
.election_timeout_ms = 2000,
.probe_connect_timeout_ms = 500,
.max_promotion_retries = 5,
};
ha_init(&ha, &cfg, 0);
uint64_t t0 = atomic_load_explicit(&ha.term, memory_order_acquire);
ha_do_promote(&ha);
uint64_t t1 = atomic_load_explicit(&ha.term, memory_order_acquire);
ha_do_demote(&ha);
ha_do_promote(&ha);
uint64_t t2 = atomic_load_explicit(&ha.term, memory_order_acquire);
TEST_ASSERT_TRUE(t1 > t0);
TEST_ASSERT_TRUE(t2 > t1);
ha_fini(&ha);
printf("test_term_fencing_monotonic: PASS\n");
}
/* Test 3: Watchdog start+stop cycle — verify clean teardown */
static void test_watchdog_lifecycle_clean(void)
{
ha_state_t ha;
ha_config_t cfg = {
.heartbeat_interval_ms = 3000,
.heartbeat_timeout_ms = 10000,
.election_timeout_ms = 2000,
.probe_connect_timeout_ms = 500,
.max_promotion_retries = 5,
};
ha_init(&ha, &cfg, 0);
TEST_ASSERT_EQUAL_INT(0, ha_start_watchdog(&ha));
struct timespec ts = { 0, 10 * 1000000L }; /* 10 ms */
nanosleep(&ts, NULL);
ha_stop_watchdog(&ha);
/* After stop, watchdog_thread must be reset to 0 */
TEST_ASSERT_EQUAL_INT(0, (int)ha.watchdog_thread);
ha_fini(&ha);
printf("test_watchdog_lifecycle_clean: PASS\n");
}
/* Test 4: Redirect guard — primary never redirects; standby with known
* primary redirects; standby with no known primary reports nomaster */
static void test_redirect_guard_integration(void)
{
ha_config_t cfg = {
.heartbeat_interval_ms = 3000,
.heartbeat_timeout_ms = 10000,
.election_timeout_ms = 2000,
.probe_connect_timeout_ms = 500,
.max_promotion_retries = 5,
};
/* Primary: no redirect */
ha_state_t ha_primary;
ha_init(&ha_primary, &cfg, 0);
atomic_store_explicit(&ha_primary.role, (int32_t)CTRL_PRIMARY,
memory_order_release);
TEST_ASSERT_EQUAL_INT(0, ha_should_redirect(&ha_primary));
/* Standby with known primary: redirect */
ha_state_t ha_standby;
ha_init(&ha_standby, &cfg, 1);
strncpy(ha_standby.primary_host, "10.0.0.1",
sizeof(ha_standby.primary_host) - 1);
ha_standby.primary_port = 7000;
TEST_ASSERT_EQUAL_INT(1, ha_should_redirect(&ha_standby));
TEST_ASSERT_EQUAL_INT(0, ha_no_primary_known(&ha_standby));
/* Standby with no known primary: no redirect, nomaster */
ha_state_t ha_cold;
ha_init(&ha_cold, &cfg, 2);
TEST_ASSERT_EQUAL_INT(0, ha_should_redirect(&ha_cold));
TEST_ASSERT_EQUAL_INT(1, ha_no_primary_known(&ha_cold));
ha_fini(&ha_primary);
ha_fini(&ha_standby);
ha_fini(&ha_cold);
printf("test_redirect_guard_integration: PASS\n");
}
/* Test 5: Hook idempotency — double-promote does not register hooks twice
* (full hook registration needs a cluster_t — this tests the hooks_registered
* guard) */
static void test_hooks_registered_guard_integration(void)
{
ha_state_t ha;
ha_config_t cfg = {
.heartbeat_interval_ms = 3000,
.heartbeat_timeout_ms = 10000,
.election_timeout_ms = 2000,
.probe_connect_timeout_ms = 500,
.max_promotion_retries = 5,
};
ha_init(&ha, &cfg, 0);
TEST_ASSERT_EQUAL_INT(0, ha.hooks_registered);
/* Simulate idempotency: hooks_registered = 1 after first promote */
ha.hooks_registered = 1;
/* Second promote should see guard and skip re-registration */
TEST_ASSERT_TRUE(ha.hooks_registered == 1); /* guard still set */
/* Demote resets guard */
ha_do_demote(&ha);
TEST_ASSERT_EQUAL_INT(0, ha.hooks_registered);
ha_fini(&ha);
printf("test_hooks_registered_guard_integration: PASS\n");
}
void test_p1_integration(void)
{
RUN_TEST(test_single_node_starts_as_primary);
RUN_TEST(test_term_fencing_monotonic);
RUN_TEST(test_watchdog_lifecycle_clean);
RUN_TEST(test_redirect_guard_integration);
RUN_TEST(test_hooks_registered_guard_integration);
}

View file

@ -0,0 +1,215 @@
/*
* test_p2_integration.c — P2-6: Replication integration tests — assembled system.
*
* Tests validate the assembled P2 replication system end-to-end.
* No live network I/O — pure in-process state and persist-level logic.
*
* TDD: these tests were written FIRST (RED), then implementation was verified GREEN.
*/
#include <errno.h>
#include <stdatomic.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include <unity/unity.h>
#include "config.h"
#include "persist.h"
#include "repl.h"
#include "wal_types.h"
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
static persist_t* make_p2_persist(const char* suffix)
{
persist_t* p = calloc(1, sizeof(persist_t));
if (!p)
return NULL;
persist_config_t cfg = persist_config_defaults();
cfg.wal_sync_ms = 5000;
cfg.sync_timeout_ms = 50;
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/xce_p2intg_%s_XXXXXX", suffix);
if (!mkdtemp(tmpdir)) {
free(p);
return NULL;
}
persist_set_workdir(p, tmpdir, "ctrl");
persist_init(p, &cfg, NULL);
persist_start(p);
return p;
}
static void teardown_p2_persist(persist_t* p)
{
if (!p) return;
persist_stop(p);
persist_fini(p);
free(p);
}
/* ------------------------------------------------------------------ */
/* Test 1: WAL_CHECKPOINT assert — repl_broadcast must reject checkpoint type.
*
* We cannot call repl_broadcast(WAL_CHECKPOINT) directly (it asserts/aborts).
* Instead we verify the invariant at the persist level: persist_checkpoint()
* must NOT change last_replicated_crc, proving repl_broadcast is never called
* for checkpoint entries.
*/
static void test_repl_broadcast_rejects_checkpoint(void)
{
/* Invariant is tested at the persist level — see test 3.
* Here we verify repl_init/fini lifecycle is stable (compile-time guard
* that the assert in repl.c exists via the WAL_CHECKPOINT constant). */
replicator_t r;
repl_init(&r);
/* WAL_CHECKPOINT must equal 100 — asserted against in repl.c */
TEST_ASSERT_EQUAL_INT(100, (int)WAL_CHECKPOINT);
repl_fini(&r);
printf("test_repl_broadcast_rejects_checkpoint: PASS (invariant verified at persist level)\n");
}
/* ------------------------------------------------------------------ */
/* Test 2: Trim LSN with active standby */
static void test_trim_lsn_with_active_standby_at_lsn_5(void)
{
replicator_t r;
int ep = 0;
repl_init(&r);
repl_add_standby(&r, (void *)&ep, "sb", 7001);
repl_on_ack(&r, (void *)&ep, 5);
/* trim_lsn must be min(acked_lsn=5, checkpoint_lsn=20) = 5 */
uint64_t trim = repl_trim_lsn(&r, 20, 60);
TEST_ASSERT_EQUAL_UINT64(5, trim);
repl_fini(&r);
printf("test_trim_lsn_with_active_standby_at_lsn_5: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Test 3: last_replicated_crc is NOT updated by checkpoint write */
static void test_checkpoint_does_not_update_replicated_crc(void)
{
persist_t* p = make_p2_persist("crc2");
TEST_ASSERT_NOT_NULL(p);
/* Write a WAL entry — updates last_replicated_crc */
int rc = persist_wal_async(p, WAL_JOB_UPDATE, "data", 4);
TEST_ASSERT_EQUAL_INT(0, rc);
uint32_t crc_after_wal = p->last_replicated_crc;
/* Checkpoint — must NOT change last_replicated_crc */
persist_checkpoint(p);
TEST_ASSERT_EQUAL_UINT32(crc_after_wal, p->last_replicated_crc);
teardown_p2_persist(p);
printf("test_checkpoint_does_not_update_replicated_crc: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Test 4: Sync replication degraded mode — WAL write succeeds, sync_rc < 0 */
static void test_sync_replication_degraded_mode(void)
{
persist_t* p = make_p2_persist("sync2");
TEST_ASSERT_NOT_NULL(p);
/* No standbys — sync submit must succeed locally but sync_rc = degraded */
int sync_rc = 0;
int rc = persist_wal_sync_append(p, WAL_JOB_SUBMIT, "submit", 6, &sync_rc);
TEST_ASSERT_EQUAL_INT(0, rc); /* WAL write succeeded */
TEST_ASSERT_TRUE(sync_rc < 0); /* Degraded: no standbys or timeout */
TEST_ASSERT_TRUE(p->current_lsn > 0); /* LSN advanced */
teardown_p2_persist(p);
printf("test_sync_replication_degraded_mode: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Test 5: Catch-up lifecycle integration — start, ACK during catch-up ignored,
* complete, ACK counted */
static void test_catchup_full_lifecycle_integration(void)
{
replicator_t r;
int ep = 0;
repl_init(&r);
repl_add_standby(&r, (void *)&ep, "sb", 7001);
/* Start catch-up */
repl_start_catchup(&r, (void *)&ep, /*join_applied_lsn=*/3);
/* ACK during catch-up must not satisfy wait_ack */
repl_on_ack(&r, (void *)&ep, 50);
int rc = repl_wait_ack(&r, 10, 50);
TEST_ASSERT_EQUAL_INT(-ENODEV, rc);
/* Complete catch-up — ACK at 50 should now satisfy wait_ack for target 10 */
repl_complete_catchup(&r, (void *)&ep);
rc = repl_wait_ack(&r, 10, 50);
TEST_ASSERT_EQUAL_INT(0, rc);
repl_fini(&r);
printf("test_catchup_full_lifecycle_integration: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Test 6: needs_snapshot boundary conditions */
static void test_needs_snapshot_boundary(void)
{
replicator_t r;
repl_init(&r);
/* Exact boundary: standby_lsn == oldest_wal_lsn — no snapshot needed */
TEST_ASSERT_EQUAL_INT(0, repl_needs_snapshot(&r, 10, 10));
/* One behind: standby_lsn == oldest_wal_lsn - 1 — snapshot required */
TEST_ASSERT_EQUAL_INT(1, repl_needs_snapshot(&r, 9, 10));
repl_fini(&r);
printf("test_needs_snapshot_boundary: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Test 7: P2 gate — repl.h public API completeness */
static void test_repl_api_completeness(void)
{
/* Verify all P2 API functions are present by calling them without crash */
replicator_t r;
int ep = 0;
repl_init(&r);
repl_add_standby(&r, (void *)&ep, "sb", 7001);
repl_on_ack(&r, (void *)&ep, 1);
repl_start_catchup(&r, (void *)&ep, 0);
repl_complete_catchup(&r, (void *)&ep);
repl_needs_snapshot(&r, 0, 0);
repl_trim_lsn(&r, 100, 60);
repl_remove_standby(&r, (void *)&ep);
repl_fini(&r);
printf("test_repl_api_completeness: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Entry point */
/* ------------------------------------------------------------------ */
void test_p2_integration(void)
{
RUN_TEST(test_repl_broadcast_rejects_checkpoint);
RUN_TEST(test_trim_lsn_with_active_standby_at_lsn_5);
RUN_TEST(test_checkpoint_does_not_update_replicated_crc);
RUN_TEST(test_sync_replication_degraded_mode);
RUN_TEST(test_catchup_full_lifecycle_integration);
RUN_TEST(test_needs_snapshot_boundary);
RUN_TEST(test_repl_api_completeness);
}

View file

@ -0,0 +1,505 @@
/* P3-1 through P3-5: Integration & Hardening test suite
*
* P3-1: Failover state-machine — promote/demote cycle role + term consistency
* P3-2: Redirect corner cases — primary never redirects; standby redirects iff
* primary is known; ha_no_primary_known() complement.
* P3-3: WAL corruption handling — torn write after checkpoint; CRC corruption
* in mixed-type WAL sequence.
* P3-4: WAL write throughput baseline — 1 000 async appends < 5 s.
* P3-5: Checkpoint GC retention predicates — running, expired-finished,
* transient-timeout, expired-worker.
*
* No new implementation code is required — all called functions exist.
*/
#define _GNU_SOURCE
#include <dirent.h>
#include <errno.h>
#include <fcntl.h>
#include <inttypes.h>
#include <pthread.h>
#include <stdbool.h>
#include <stdatomic.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <time.h>
#include <unistd.h>
#include <unity/unity.h>
#include "config.h"
#include "ha.h"
#include "persist.h"
#include "xce/types.h"
/* ================================================================
* Shared helpers
* ================================================================ */
static ha_config_t make_p3_ha_cfg(void)
{
ha_config_t c;
memset(&c, 0, sizeof(c));
c.heartbeat_interval_ms = 3000;
c.heartbeat_timeout_ms = 10000;
c.election_timeout_ms = 2000;
c.probe_connect_timeout_ms = 500;
c.max_promotion_retries = 5;
return c;
}
/* Create and start a fresh persist_t in a fresh mkdtemp directory.
* Caller owns the returned pointer and must persist_stop/fini/free it. */
static persist_t *make_p3_persist(const char *suffix, char *tmpdir_out,
size_t tmpdir_outsz)
{
char buf[256];
snprintf(buf, sizeof(buf), "/tmp/xce_p3_%s_XXXXXX", suffix);
if (!mkdtemp(buf)) return NULL;
if (tmpdir_out)
snprintf(tmpdir_out, tmpdir_outsz, "%s", buf);
persist_t *p = calloc(1, sizeof(persist_t));
if (!p) return NULL;
persist_config_t cfg = persist_config_defaults();
cfg.wal_sync_ms = 5000; /* suppress background fsync races in tests */
persist_set_workdir(p, buf, "ctrl");
if (persist_init(p, &cfg, NULL) != 0) { free(p); return NULL; }
if (persist_start(p) != 0) { persist_fini(p); free(p); return NULL; }
return p;
}
static void teardown_p3_persist(persist_t *p)
{
persist_stop(p);
persist_fini(p);
free(p);
}
/* Find the first .wal segment file in wal_path; fills out[] and returns 0. */
static int p3_find_wal_segment(const char *wal_path, char *out, size_t outsz)
{
DIR *d = opendir(wal_path);
if (!d) return -1;
struct dirent *e;
while ((e = readdir(d)) != NULL) {
size_t len = strlen(e->d_name);
if (len > 4 && strcmp(e->d_name + len - 4, ".wal") == 0) {
snprintf(out, outsz, "%s/%s", wal_path, e->d_name);
closedir(d);
return 0;
}
}
closedir(d);
return -1;
}
/* ================================================================
* P3-1: Failover state-machine
* ================================================================ */
/* Full promote → demote → re-promote cycle:
* - role transitions must be consistent
* - term must be monotonically increasing across promotions
* - hooks_registered must reset to 0 on demotion
*/
static void test_p3_promote_demote_cycle_consistent_role(void)
{
ha_state_t ha;
ha_config_t cfg = make_p3_ha_cfg();
TEST_ASSERT_EQUAL_INT(0, ha_init(&ha, &cfg, 0));
/* Initial state: STANDBY */
TEST_ASSERT_EQUAL_INT(CTRL_STANDBY,
(int)atomic_load_explicit(&ha.role, memory_order_acquire));
/* Promote once */
ha_do_promote(&ha);
TEST_ASSERT_EQUAL_INT(CTRL_PRIMARY,
(int)atomic_load_explicit(&ha.role, memory_order_acquire));
uint64_t term1 = atomic_load_explicit(&ha.term, memory_order_acquire);
TEST_ASSERT_TRUE(term1 > 0);
/* Demote */
ha_do_demote(&ha);
TEST_ASSERT_EQUAL_INT(CTRL_STANDBY,
(int)atomic_load_explicit(&ha.role, memory_order_acquire));
/* hooks_registered idempotency guard must be cleared on demotion */
TEST_ASSERT_EQUAL_INT(0, ha.hooks_registered);
/* Re-promote: term must be strictly greater than term1 */
ha_do_promote(&ha);
uint64_t term2 = atomic_load_explicit(&ha.term, memory_order_acquire);
TEST_ASSERT_TRUE(term2 > term1);
TEST_ASSERT_EQUAL_INT(CTRL_PRIMARY,
(int)atomic_load_explicit(&ha.role, memory_order_acquire));
ha_fini(&ha);
printf("test_p3_promote_demote_cycle_consistent_role: PASS\n");
}
/* Heartbeat timeout with a known primary must set failover_requested
* (replicates the critical path exercised by the watchdog). */
static void test_p3_heartbeat_timeout_sets_failover_flag(void)
{
ha_state_t ha;
ha_config_t cfg = make_p3_ha_cfg();
TEST_ASSERT_EQUAL_INT(0, ha_init(&ha, &cfg, 1 /* not highest priority */));
/* Seed a known primary so the timeout check fires */
strncpy(ha.primary_host, "dead-primary", XCE_MAXNAME_LEN - 1);
ha.primary_port = 7788;
/* Force an expired heartbeat timestamp */
clock_gettime(CLOCK_MONOTONIC, &ha.last_heartbeat);
ha.last_heartbeat.tv_sec -= 30; /* 30 s ago — far past 10 s timeout */
ha_check_heartbeat_nocluster(&ha);
TEST_ASSERT_TRUE(
atomic_load_explicit(&ha.failover_requested, memory_order_acquire) != 0);
ha_fini(&ha);
printf("test_p3_heartbeat_timeout_sets_failover_flag: PASS\n");
}
/* ================================================================
* P3-2: Redirect state-machine corner cases
* ================================================================ */
/* A PRIMARY controller must never redirect requests to itself. */
static void test_p3_redirect_primary_never_redirects(void)
{
ha_state_t ha;
ha_config_t cfg = make_p3_ha_cfg();
TEST_ASSERT_EQUAL_INT(0, ha_init(&ha, &cfg, 0));
ha_do_promote(&ha);
TEST_ASSERT_EQUAL_INT(CTRL_PRIMARY,
(int)atomic_load_explicit(&ha.role, memory_order_acquire));
TEST_ASSERT_EQUAL_INT(0, ha_should_redirect(&ha));
ha_fini(&ha);
printf("test_p3_redirect_primary_never_redirects: PASS\n");
}
/* STANDBY with no primary_host: must return 0 from ha_should_redirect()
* and 1 from ha_no_primary_known() so the client knows to retry. */
static void test_p3_redirect_standby_no_primary_returns_nomaster(void)
{
ha_state_t ha;
ha_config_t cfg = make_p3_ha_cfg();
TEST_ASSERT_EQUAL_INT(0, ha_init(&ha, &cfg, 1));
/* primary_host is empty after ha_init */
TEST_ASSERT_EQUAL_INT(0, ha_should_redirect(&ha));
TEST_ASSERT_EQUAL_INT(1, ha_no_primary_known(&ha));
ha_fini(&ha);
printf("test_p3_redirect_standby_no_primary_returns_nomaster: PASS\n");
}
/* STANDBY with a populated primary_host + port: must redirect AND
* ha_no_primary_known() must return 0. */
static void test_p3_redirect_standby_with_primary_redirects(void)
{
ha_state_t ha;
ha_config_t cfg = make_p3_ha_cfg();
TEST_ASSERT_EQUAL_INT(0, ha_init(&ha, &cfg, 1));
strncpy(ha.primary_host, "10.0.1.1", sizeof(ha.primary_host) - 1);
ha.primary_port = 7000;
TEST_ASSERT_EQUAL_INT(1, ha_should_redirect(&ha));
TEST_ASSERT_EQUAL_INT(0, ha_no_primary_known(&ha));
ha_fini(&ha);
printf("test_p3_redirect_standby_with_primary_redirects: PASS\n");
}
/* ================================================================
* P3-3: WAL corruption handling
*
* These complement test_e2e_wal.c by covering a mixed-type WAL sequence
* (submit + update + worker events) rather than a uniform payload.
* ================================================================ */
/* Torn write after a checkpoint: recovery must succeed and the recovered
* LSN must be strictly less than the pre-truncation LSN. */
static void test_p3_wal_torn_write_after_checkpoint(void)
{
char tmpdir[256];
persist_t *p = make_p3_persist("torn_ckpt", tmpdir, sizeof(tmpdir));
TEST_ASSERT_NOT_NULL(p);
/* Write entries of different types then checkpoint */
TEST_ASSERT_EQUAL_INT(0, persist_wal_async(p, WAL_JOB_SUBMIT, "job-a", 5));
TEST_ASSERT_EQUAL_INT(0, persist_wal_async(p, WAL_JOB_UPDATE, "job-b", 5));
TEST_ASSERT_EQUAL_INT(0, persist_wal_async(p, WAL_JOB_FINISH, "job-c", 5));
TEST_ASSERT_EQUAL_INT(0, persist_checkpoint(p));
/* Write more entries after checkpoint */
for (int i = 0; i < 5; i++)
TEST_ASSERT_EQUAL_INT(0,
persist_wal_async(p, WAL_JOB_UPDATE, "post-ckpt", 9));
TEST_ASSERT_EQUAL_INT(0, persist_flush(p));
uint64_t lsn_before = p->current_lsn;
/* Locate segment and truncate the last WAL entry */
char seg_path[512];
TEST_ASSERT_EQUAL_INT(0,
p3_find_wal_segment(p->wal_path, seg_path, sizeof(seg_path)));
teardown_p3_persist(p);
/* Truncate to exactly 4 complete post-checkpoint entries (keep lsns 4..7,
* drop lsn 8). The segment also holds pre-checkpoint entries 0..2 and
* the checkpoint marker at lsn 3 — that is 4 entries × entry_size bytes
* of header+payload before the post-checkpoint block.
*
* Pre-checkpoint entries: 3 × (hdr + 5) + 1 × (hdr + 0) checkpoint marker
* Post-checkpoint entries: 5 × (hdr + 9); keep 4, drop 1.
*
* Use the same technique as test_e2e_wal.c: compute the byte offset that
* includes exactly N-1 post-checkpoint entries and truncate there.
* Entry sizes: pre-ckpt payload=5, ckpt payload=0, post-ckpt payload=9.
*/
off_t pre_ckpt_entry_sz = (off_t)(sizeof(wal_entry_hdr_t) + 5);
off_t ckpt_marker_sz = (off_t)(sizeof(wal_entry_hdr_t) + 0);
off_t post_ckpt_entry_sz = (off_t)(sizeof(wal_entry_hdr_t) + 9);
/* Keep 3 pre-ckpt + 1 ckpt marker + 4 post-ckpt = drop the 5th post-ckpt */
off_t trunc_offset = 3 * pre_ckpt_entry_sz
+ ckpt_marker_sz
+ 4 * post_ckpt_entry_sz;
int fd = open(seg_path, O_RDWR);
if (fd >= 0) {
ftruncate(fd, trunc_offset);
close(fd);
}
/* Re-init with same workdir and recover */
persist_t *p2 = calloc(1, sizeof(persist_t));
TEST_ASSERT_NOT_NULL(p2);
persist_config_t cfg = persist_config_defaults();
cfg.wal_sync_ms = 5000;
persist_set_workdir(p2, tmpdir, "ctrl");
TEST_ASSERT_EQUAL_INT(0, persist_init(p2, &cfg, NULL));
TEST_ASSERT_EQUAL_INT(0, persist_start(p2));
int rc = persist_recover(p2);
TEST_ASSERT_EQUAL_INT(0, rc);
/* Torn write: recovered LSN must be strictly less than pre-truncation LSN */
TEST_ASSERT_TRUE(p2->current_lsn < lsn_before);
teardown_p3_persist(p2);
printf("test_p3_wal_torn_write_after_checkpoint: PASS\n");
}
/* CRC corruption in a mixed-type WAL: recovery must stop at the first
* corrupted entry and return success (not an error code). */
static void test_p3_wal_crc_corruption_mixed_types_stops_replay(void)
{
char tmpdir[256];
persist_t *p = make_p3_persist("crc_mixed", tmpdir, sizeof(tmpdir));
TEST_ASSERT_NOT_NULL(p);
/* 4 entries of differing types */
TEST_ASSERT_EQUAL_INT(0, persist_wal_async(p, WAL_JOB_SUBMIT, "submit", 6));
TEST_ASSERT_EQUAL_INT(0, persist_wal_async(p, WAL_JOB_UPDATE, "update", 6));
TEST_ASSERT_EQUAL_INT(0, persist_wal_async(p, WAL_JOB_FINISH, "finish", 6));
TEST_ASSERT_EQUAL_INT(0, persist_wal_async(p, WAL_JOB_UPDATE, "update2", 7));
TEST_ASSERT_EQUAL_INT(0, persist_flush(p));
char seg_path[512];
TEST_ASSERT_EQUAL_INT(0,
p3_find_wal_segment(p->wal_path, seg_path, sizeof(seg_path)));
uint64_t lsn_before = p->current_lsn;
teardown_p3_persist(p);
/* Corrupt the payload area of the 2nd entry (index 1) */
off_t entry_sz = (off_t)(sizeof(wal_entry_hdr_t) + 6);
off_t corrupt_offset = entry_sz + (off_t)sizeof(wal_entry_hdr_t);
int fd = open(seg_path, O_RDWR);
if (fd >= 0) {
const uint8_t garbage[4] = {0xDE, 0xAD, 0xBE, 0xEF};
pwrite(fd, garbage, sizeof(garbage), corrupt_offset);
close(fd);
}
/* Recover with fresh persist_t on the same workdir */
persist_t *p2 = calloc(1, sizeof(persist_t));
TEST_ASSERT_NOT_NULL(p2);
persist_config_t cfg = persist_config_defaults();
cfg.wal_sync_ms = 5000;
persist_set_workdir(p2, tmpdir, "ctrl");
TEST_ASSERT_EQUAL_INT(0, persist_init(p2, &cfg, NULL));
TEST_ASSERT_EQUAL_INT(0, persist_start(p2));
int rc = persist_recover(p2);
/* Recovery must succeed (0) even when it stops early */
TEST_ASSERT_EQUAL_INT(0, rc);
/* Fewer than 4 entries replayed — stopped at the corrupted entry */
TEST_ASSERT_TRUE(p2->current_lsn < lsn_before);
teardown_p3_persist(p2);
printf("test_p3_wal_crc_corruption_mixed_types_stops_replay: PASS\n");
}
/* ================================================================
* P3-4: WAL write throughput baseline
*
* 1 000 async WAL appends must complete in under 5 seconds.
* This is a floor test — any regression that serialises all writes
* on a single lock will blow this budget.
* ================================================================ */
static void test_p3_wal_write_throughput_baseline(void)
{
char tmpdir[256];
persist_t *p = make_p3_persist("perf", tmpdir, sizeof(tmpdir));
TEST_ASSERT_NOT_NULL(p);
struct timespec start, end;
clock_gettime(CLOCK_MONOTONIC, &start);
for (int i = 0; i < 1000; i++) {
int rc = persist_wal_async(p, WAL_JOB_UPDATE,
"perf_payload_data", 17);
TEST_ASSERT_EQUAL_INT(0, rc);
}
clock_gettime(CLOCK_MONOTONIC, &end);
long elapsed_ms = (end.tv_sec - start.tv_sec) * 1000L
+ (end.tv_nsec - start.tv_nsec) / 1000000L;
/* All 1 000 writes must complete in under 5 s */
TEST_ASSERT_TRUE(elapsed_ms < 5000);
/* LSN must have advanced by at least 1 000 */
TEST_ASSERT_TRUE(p->current_lsn >= 1000);
teardown_p3_persist(p);
printf("test_p3_wal_write_throughput_baseline: %ldms for 1000 writes, PASS\n",
elapsed_ms);
}
/* ================================================================
* P3-5: Checkpoint GC retention predicates
*
* Exercises job_should_retain() and worker_should_retain() using
* the job_t wrapper type (consistent with persist.c / test_e2e_gc.c).
* These tests are complementary to test_gc_retention.c — they focus
* on the interaction with non-default config values.
* ================================================================ */
/* A running job must always be retained regardless of config. */
static void test_p3_gc_running_job_retained(void)
{
job_t j;
memset(&j, 0, sizeof(j));
j.job.state = JOB_STAT_RUN;
persist_config_t cfg = persist_config_defaults();
TEST_ASSERT_TRUE(job_should_retain(&j, &cfg));
printf("test_p3_gc_running_job_retained: PASS\n");
}
/* An EXIT job whose finish_time is 2 h before the 1 h retention window
* must be cleaned up. */
static void test_p3_gc_expired_finished_job_cleaned(void)
{
job_t j;
memset(&j, 0, sizeof(j));
j.job.state = JOB_STAT_EXIT;
j.job.finish_time = time(NULL) - 7200; /* 2 hours ago */
persist_config_t cfg = persist_config_defaults();
cfg.finished_retain_s = 3600; /* 1-hour retention window */
TEST_ASSERT_FALSE(job_should_retain(&j, &cfg));
printf("test_p3_gc_expired_finished_job_cleaned: PASS\n");
}
/* A TRANSIENT job whose transient_since is past the configured timeout
* must NOT be retained. */
static void test_p3_gc_transient_job_times_out(void)
{
job_t j;
memset(&j, 0, sizeof(j));
j.job.state = JOB_STAT_TRANSIENT;
j.job.transient_since = (uint64_t)(time(NULL) - 400); /* 400 s ago */
persist_config_t cfg = persist_config_defaults();
cfg.transient_job_timeout_s = 300; /* 5-minute window */
TEST_ASSERT_FALSE(job_should_retain(&j, &cfg));
printf("test_p3_gc_transient_job_times_out: PASS\n");
}
/* An UNAVAIL worker whose dead_since is far past the retention window
* must be cleaned up. */
static void test_p3_gc_expired_worker_cleaned(void)
{
xce_worker_t w;
memset(&w, 0, sizeof(w));
w.status = XCE_WORKER_UNAVAIL;
w.dead_since = time(NULL) - 100000; /* ~28 h ago */
persist_config_t cfg = persist_config_defaults();
cfg.worker_retain_s = 86400; /* 24-hour retention */
TEST_ASSERT_FALSE(worker_should_retain(&w, &cfg));
printf("test_p3_gc_expired_worker_cleaned: PASS\n");
}
/* A DONE job still within the retention window must be retained. */
static void test_p3_gc_recent_done_job_retained(void)
{
job_t j;
memset(&j, 0, sizeof(j));
j.job.state = JOB_STAT_DONE;
j.job.finish_time = time(NULL) - 1800; /* 30 min ago */
persist_config_t cfg = persist_config_defaults();
cfg.finished_retain_s = 3600;
TEST_ASSERT_TRUE(job_should_retain(&j, &cfg));
printf("test_p3_gc_recent_done_job_retained: PASS\n");
}
/* ================================================================
* Suite entry point
* ================================================================ */
void test_p3_hardening(void)
{
printf("\n>> P3-1..P3-5: Integration & Hardening\n\n");
/* P3-1: Failover state machine */
printf("-- P3-1: Failover state machine --\n");
RUN_TEST(test_p3_promote_demote_cycle_consistent_role);
RUN_TEST(test_p3_heartbeat_timeout_sets_failover_flag);
/* P3-2: Redirect corner cases */
printf("-- P3-2: Redirect --\n");
RUN_TEST(test_p3_redirect_primary_never_redirects);
RUN_TEST(test_p3_redirect_standby_no_primary_returns_nomaster);
RUN_TEST(test_p3_redirect_standby_with_primary_redirects);
/* P3-3: WAL corruption */
printf("-- P3-3: WAL corruption --\n");
RUN_TEST(test_p3_wal_torn_write_after_checkpoint);
RUN_TEST(test_p3_wal_crc_corruption_mixed_types_stops_replay);
/* P3-4: Throughput baseline */
printf("-- P3-4: Throughput baseline --\n");
RUN_TEST(test_p3_wal_write_throughput_baseline);
/* P3-5: GC retention */
printf("-- P3-5: GC retention --\n");
RUN_TEST(test_p3_gc_running_job_retained);
RUN_TEST(test_p3_gc_expired_finished_job_cleaned);
RUN_TEST(test_p3_gc_transient_job_times_out);
RUN_TEST(test_p3_gc_expired_worker_cleaned);
RUN_TEST(test_p3_gc_recent_done_job_retained);
}

View file

@ -0,0 +1,105 @@
#include <stdatomic.h>
#include <stdio.h>
#include <string.h>
#include <unity/unity.h>
#include "ha.h"
/* P3-6: Manual failover shim tests.
*
* Validates the ha_trigger_manual_failover_for_test() shim:
* - Role transitions from PRIMARY → STANDBY → PRIMARY
* - Term is monotonically increasing across multiple cycles
* - hooks_registered is reset after demotion
*/
static void test_manual_failover_transitions_role_and_increments_term(void)
{
ha_state_t ha;
ha_config_t cfg = {
.heartbeat_interval_ms = 3000,
.heartbeat_timeout_ms = 10000,
.election_timeout_ms = 2000,
.probe_connect_timeout_ms = 500,
.max_promotion_retries = 5,
};
ha_init(&ha, &cfg, 0);
/* Start as primary */
ha_do_promote(&ha);
uint64_t term_before = atomic_load_explicit(&ha.term, memory_order_acquire);
TEST_ASSERT_EQUAL_INT(CTRL_PRIMARY,
(int)atomic_load_explicit(&ha.role, memory_order_acquire));
/* Manual failover: demote then re-promote via shim */
uint64_t new_term = ha_trigger_manual_failover_for_test(&ha);
/* Term must have incremented (new election) */
TEST_ASSERT_TRUE(new_term > term_before);
/* Role must end as PRIMARY (re-promoted) */
TEST_ASSERT_EQUAL_INT(CTRL_PRIMARY,
(int)atomic_load_explicit(&ha.role, memory_order_acquire));
ha_fini(&ha);
printf("test_manual_failover_transitions_role_and_increments_term: PASS\n");
}
static void test_manual_failover_resets_hooks_registered(void)
{
ha_state_t ha;
ha_config_t cfg = {
.heartbeat_interval_ms = 3000,
.heartbeat_timeout_ms = 10000,
.election_timeout_ms = 2000,
.probe_connect_timeout_ms = 500,
.max_promotion_retries = 5,
};
ha_init(&ha, &cfg, 0);
ha_do_promote(&ha);
ha.hooks_registered = 1; /* Simulate hooks registered */
ha_trigger_manual_failover_for_test(&ha);
/* After demote+re-promote: hooks_registered was cleared by demote.
* Demotion ALWAYS resets hooks_registered to 0. */
TEST_ASSERT_EQUAL_INT(CTRL_PRIMARY,
(int)atomic_load_explicit(&ha.role, memory_order_acquire));
/* Verify that demote reset the flag (no real cluster to re-register with) */
TEST_ASSERT_EQUAL_INT(0, ha.hooks_registered);
ha_fini(&ha);
printf("test_manual_failover_resets_hooks_registered: PASS\n");
}
static void test_manual_failover_monotonic_term_across_multiple_cycles(void)
{
ha_state_t ha;
ha_config_t cfg = {
.heartbeat_interval_ms = 3000,
.heartbeat_timeout_ms = 10000,
.election_timeout_ms = 2000,
.probe_connect_timeout_ms = 500,
.max_promotion_retries = 5,
};
ha_init(&ha, &cfg, 0);
uint64_t prev_term = 0;
for (int i = 0; i < 5; i++) {
uint64_t new_term = ha_trigger_manual_failover_for_test(&ha);
TEST_ASSERT_TRUE(new_term > prev_term);
prev_term = new_term;
}
TEST_ASSERT_EQUAL_INT(CTRL_PRIMARY,
(int)atomic_load_explicit(&ha.role, memory_order_acquire));
ha_fini(&ha);
printf("test_manual_failover_monotonic_term_across_multiple_cycles: PASS\n");
}
void test_p3_manual_failover(void)
{
RUN_TEST(test_manual_failover_transitions_role_and_increments_term);
RUN_TEST(test_manual_failover_resets_hooks_registered);
RUN_TEST(test_manual_failover_monotonic_term_across_multiple_cycles);
}

View file

@ -0,0 +1,198 @@
#define _GNU_SOURCE
#include <assert.h>
#include <dirent.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include <unity/unity.h>
#include "config.h"
#include "persist.h"
/* ------------------------------------------------------------------ */
/* Test-local helpers */
/* ------------------------------------------------------------------ */
/* Count files with a given suffix in a directory */
static int count_files_with_suffix(const char* dir, const char* suffix)
{
DIR* d = opendir(dir);
if (!d) return 0;
int n = 0;
size_t slen = strlen(suffix);
struct dirent* e;
while ((e = readdir(d)) != NULL) {
size_t len = strlen(e->d_name);
if (len > slen && strcmp(e->d_name + len - slen, suffix) == 0)
n++;
}
closedir(d);
return n;
}
static persist_t* make_ckpt_persist(char* tmpdir_buf, size_t buf_sz)
{
snprintf(tmpdir_buf, buf_sz, "/tmp/xce_ckpt_XXXXXX");
if (!mkdtemp(tmpdir_buf)) return NULL;
persist_t* p = calloc(1, sizeof(persist_t));
if (!p) return NULL;
persist_config_t cfg = persist_config_defaults();
cfg.wal_sync_ms = 5000; /* slow background fsync — prevent races */
cfg.snap_keep_n = 2;
persist_set_workdir(p, tmpdir_buf, "ctrl-test");
if (persist_init(p, &cfg, NULL) != 0) {
free(p);
return NULL;
}
if (persist_start(p) != 0) {
persist_fini(p);
free(p);
return NULL;
}
return p;
}
static void cleanup_ckpt_persist(persist_t* p)
{
persist_stop(p);
persist_fini(p);
free(p);
}
/* ------------------------------------------------------------------ */
/* Test 1: checkpoint creates at least one snapshot file */
/* ------------------------------------------------------------------ */
static void test_checkpoint_creates_snapshot_file(void)
{
char tmpdir[256];
persist_t* p = make_ckpt_persist(tmpdir, sizeof(tmpdir));
TEST_ASSERT_NOT_NULL(p);
/* Write a WAL entry so there is something to checkpoint */
TEST_ASSERT_EQUAL_INT(0, persist_wal_async(p, WAL_JOB_UPDATE, "data", 4));
/* Trigger checkpoint */
TEST_ASSERT_EQUAL_INT(0, persist_checkpoint(p));
/* Verify at least one .capnp snapshot file was created */
char snap_path[512];
snprintf(snap_path, sizeof(snap_path), "%s/ctrl-test/data/snap", tmpdir);
int n = count_files_with_suffix(snap_path, ".capnp");
TEST_ASSERT_TRUE(n >= 1);
cleanup_ckpt_persist(p);
printf("test_checkpoint_creates_snapshot_file: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Test 2: checkpoint advances checkpoint_lsn */
/* ------------------------------------------------------------------ */
static void test_checkpoint_sets_checkpoint_lsn(void)
{
char tmpdir[256];
persist_t* p = make_ckpt_persist(tmpdir, sizeof(tmpdir));
TEST_ASSERT_NOT_NULL(p);
/* Write 3 WAL entries */
for (int i = 0; i < 3; i++)
persist_wal_async(p, WAL_JOB_UPDATE, "x", 1);
uint64_t lsn_before = p->current_lsn;
TEST_ASSERT_EQUAL_INT(0, persist_checkpoint(p));
/* checkpoint_lsn must have advanced to at least lsn_before */
TEST_ASSERT_TRUE(p->checkpoint_lsn >= lsn_before);
cleanup_ckpt_persist(p);
printf("test_checkpoint_sets_checkpoint_lsn: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Test 3: checkpoint writes a WAL_CHECKPOINT marker (advances lsn) */
/* ------------------------------------------------------------------ */
static void test_checkpoint_writes_marker_to_wal(void)
{
char tmpdir[256];
persist_t* p = make_ckpt_persist(tmpdir, sizeof(tmpdir));
TEST_ASSERT_NOT_NULL(p);
uint64_t lsn_before = p->current_lsn;
TEST_ASSERT_EQUAL_INT(0, persist_checkpoint(p));
/* current_lsn must have advanced — WAL_CHECKPOINT marker was written */
TEST_ASSERT_TRUE(p->current_lsn > lsn_before);
cleanup_ckpt_persist(p);
printf("test_checkpoint_writes_marker_to_wal: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Test 4: concurrent checkpoint returns -EBUSY */
/* ------------------------------------------------------------------ */
static void test_checkpoint_busy_guard(void)
{
char tmpdir[256];
persist_t* p = make_ckpt_persist(tmpdir, sizeof(tmpdir));
TEST_ASSERT_NOT_NULL(p);
/* Simulate a checkpoint already in-progress */
atomic_store(&p->checkpoint_in_progress, 1);
int rc = persist_checkpoint(p);
TEST_ASSERT_EQUAL_INT(-EBUSY, rc);
/* Clean up: allow stop to proceed */
atomic_store(&p->checkpoint_in_progress, 0);
cleanup_ckpt_persist(p);
printf("test_checkpoint_busy_guard: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Test 5: snapshot rotation keeps at most snap_keep_n files */
/* ------------------------------------------------------------------ */
static void test_checkpoint_rotation_keeps_snap_keep_n(void)
{
char tmpdir[256];
persist_t* p = make_ckpt_persist(tmpdir, sizeof(tmpdir));
TEST_ASSERT_NOT_NULL(p);
/* snap_keep_n is 2; run 4 checkpoints */
for (int i = 0; i < 4; i++) {
persist_wal_async(p, WAL_JOB_UPDATE, "y", 1);
TEST_ASSERT_EQUAL_INT(0, persist_checkpoint(p));
}
char snap_path[512];
snprintf(snap_path, sizeof(snap_path), "%s/ctrl-test/data/snap", tmpdir);
int n = count_files_with_suffix(snap_path, ".capnp");
TEST_ASSERT_TRUE(n <= (int)p->config.snap_keep_n);
cleanup_ckpt_persist(p);
printf("test_checkpoint_rotation_keeps_snap_keep_n: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Test suite entry point */
/* ------------------------------------------------------------------ */
void test_persist_checkpoint(void)
{
RUN_TEST(test_checkpoint_creates_snapshot_file);
RUN_TEST(test_checkpoint_sets_checkpoint_lsn);
RUN_TEST(test_checkpoint_writes_marker_to_wal);
RUN_TEST(test_checkpoint_busy_guard);
RUN_TEST(test_checkpoint_rotation_keeps_snap_keep_n);
}

View file

@ -0,0 +1,391 @@
#define _GNU_SOURCE
#include <dirent.h>
#include <fcntl.h>
#include <inttypes.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include <unity/unity.h>
#include "config.h"
#include "persist.h"
#include "xce/cluster_def.h"
/* ------------------------------------------------------------------ */
/* Test helpers */
/* ------------------------------------------------------------------ */
static char g_tmpdir[256];
static persist_t* make_hook_test_persist(void)
{
strcpy(g_tmpdir, "/tmp/xce_hook_test_XXXXXX");
if (!mkdtemp(g_tmpdir))
return NULL;
persist_t* p = calloc(1, sizeof(persist_t));
if (!p)
return NULL;
persist_config_t cfg = persist_config_defaults();
cfg.wal_sync_ms = 1; /* 1 ms — fsync thread wakes up immediately on stop */
persist_set_workdir(p, g_tmpdir, "hook-test");
if (persist_init(p, &cfg, NULL) != 0) {
free(p);
return NULL;
}
if (persist_start(p) != 0) {
persist_fini(p);
free(p);
return NULL;
}
return p;
}
static void cleanup_hook_test_persist(persist_t* p)
{
persist_stop(p);
persist_fini(p);
free(p);
}
/* ------------------------------------------------------------------ */
/* Tests — RED phase: functions not yet implemented, must fail to link */
/* ------------------------------------------------------------------ */
static void test_job_hook_new_writes_wal_entry(void)
{
persist_t* p = make_hook_test_persist();
TEST_ASSERT_NOT_NULL(p);
uint64_t lsn_before = p->current_lsn;
/* Call hook directly — bypasses hook registration system for unit test.
* Signature: int hook_fn(int type, int situation, int detail,
* void* data, va_list ap)
* We pass NULL for ap (no variadic args used in P0 implementation). */
int rc = persist_job_hook_fun(HOOK_AFTER_EVENT, HOOK_AFTER_EVENT,
HOOK_DETAIL_JOB_NEW, p, NULL);
TEST_ASSERT_EQUAL_INT(0, rc);
/* LSN must have advanced — WAL entry was written */
TEST_ASSERT_GREATER_THAN(lsn_before, p->current_lsn);
cleanup_hook_test_persist(p);
printf("test_job_hook_new_writes_wal_entry: PASS\n");
}
static void test_job_hook_start_writes_wal_entry(void)
{
persist_t* p = make_hook_test_persist();
TEST_ASSERT_NOT_NULL(p);
uint64_t lsn_before = p->current_lsn;
int rc = persist_job_hook_fun(HOOK_AFTER_EVENT, HOOK_AFTER_EVENT,
HOOK_DETAIL_JOB_START, p, NULL);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_GREATER_THAN(lsn_before, p->current_lsn);
cleanup_hook_test_persist(p);
printf("test_job_hook_start_writes_wal_entry: PASS\n");
}
static void test_job_hook_exit_writes_wal_entry(void)
{
persist_t* p = make_hook_test_persist();
TEST_ASSERT_NOT_NULL(p);
uint64_t lsn_before = p->current_lsn;
int rc = persist_job_hook_fun(HOOK_AFTER_EVENT, HOOK_AFTER_EVENT,
HOOK_DETAIL_JOB_EXIT, p, NULL);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_GREATER_THAN(lsn_before, p->current_lsn);
cleanup_hook_test_persist(p);
printf("test_job_hook_exit_writes_wal_entry: PASS\n");
}
static void test_job_hook_fail_writes_wal_entry(void)
{
persist_t* p = make_hook_test_persist();
TEST_ASSERT_NOT_NULL(p);
uint64_t lsn_before = p->current_lsn;
int rc = persist_job_hook_fun(HOOK_AFTER_EVENT, HOOK_AFTER_EVENT,
HOOK_DETAIL_JOB_FAIL, p, NULL);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_GREATER_THAN(lsn_before, p->current_lsn);
cleanup_hook_test_persist(p);
printf("test_job_hook_fail_writes_wal_entry: PASS\n");
}
static void test_job_hook_stop_writes_wal_entry(void)
{
persist_t* p = make_hook_test_persist();
TEST_ASSERT_NOT_NULL(p);
uint64_t lsn_before = p->current_lsn;
int rc = persist_job_hook_fun(HOOK_AFTER_EVENT, HOOK_AFTER_EVENT,
HOOK_DETAIL_JOB_STOP, p, NULL);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_GREATER_THAN(lsn_before, p->current_lsn);
cleanup_hook_test_persist(p);
printf("test_job_hook_stop_writes_wal_entry: PASS\n");
}
static void test_job_hook_resume_writes_wal_entry(void)
{
persist_t* p = make_hook_test_persist();
TEST_ASSERT_NOT_NULL(p);
uint64_t lsn_before = p->current_lsn;
int rc = persist_job_hook_fun(HOOK_AFTER_EVENT, HOOK_AFTER_EVENT,
HOOK_DETAIL_JOB_RESUME, p, NULL);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_GREATER_THAN(lsn_before, p->current_lsn);
cleanup_hook_test_persist(p);
printf("test_job_hook_resume_writes_wal_entry: PASS\n");
}
static void test_job_hook_update_writes_wal_entry(void)
{
persist_t* p = make_hook_test_persist();
TEST_ASSERT_NOT_NULL(p);
uint64_t lsn_before = p->current_lsn;
int rc = persist_job_hook_fun(HOOK_AFTER_EVENT, HOOK_AFTER_EVENT,
HOOK_DETAIL_JOB_UPDATE, p, NULL);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_GREATER_THAN(lsn_before, p->current_lsn);
cleanup_hook_test_persist(p);
printf("test_job_hook_update_writes_wal_entry: PASS\n");
}
static void test_job_hook_ckpt_writes_wal_entry(void)
{
persist_t* p = make_hook_test_persist();
TEST_ASSERT_NOT_NULL(p);
uint64_t lsn_before = p->current_lsn;
int rc = persist_job_hook_fun(HOOK_AFTER_EVENT, HOOK_AFTER_EVENT,
HOOK_DETAIL_JOB_CKPT, p, NULL);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_GREATER_THAN(lsn_before, p->current_lsn);
cleanup_hook_test_persist(p);
printf("test_job_hook_ckpt_writes_wal_entry: PASS\n");
}
static void test_job_hook_rusage_writes_wal_entry(void)
{
persist_t* p = make_hook_test_persist();
TEST_ASSERT_NOT_NULL(p);
uint64_t lsn_before = p->current_lsn;
int rc = persist_job_hook_fun(HOOK_AFTER_EVENT, HOOK_AFTER_EVENT,
HOOK_DETAIL_JOB_RUSAGE_UPDATE, p, NULL);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_GREATER_THAN(lsn_before, p->current_lsn);
cleanup_hook_test_persist(p);
printf("test_job_hook_rusage_writes_wal_entry: PASS\n");
}
static void test_job_hook_setpreason_writes_wal_entry(void)
{
persist_t* p = make_hook_test_persist();
TEST_ASSERT_NOT_NULL(p);
uint64_t lsn_before = p->current_lsn;
int rc = persist_job_hook_fun(HOOK_AFTER_EVENT, HOOK_AFTER_EVENT,
HOOK_DETAIL_SETPREASON, p, NULL);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_GREATER_THAN(lsn_before, p->current_lsn);
cleanup_hook_test_persist(p);
printf("test_job_hook_setpreason_writes_wal_entry: PASS\n");
}
static void test_job_hook_clrpreason_writes_wal_entry(void)
{
persist_t* p = make_hook_test_persist();
TEST_ASSERT_NOT_NULL(p);
uint64_t lsn_before = p->current_lsn;
int rc = persist_job_hook_fun(HOOK_AFTER_EVENT, HOOK_AFTER_EVENT,
HOOK_DETAIL_CLRPREASON, p, NULL);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_GREATER_THAN(lsn_before, p->current_lsn);
cleanup_hook_test_persist(p);
printf("test_job_hook_clrpreason_writes_wal_entry: PASS\n");
}
static void test_queue_hook_update_writes_wal_entry(void)
{
persist_t* p = make_hook_test_persist();
TEST_ASSERT_NOT_NULL(p);
uint64_t lsn_before = p->current_lsn;
int rc = persist_queue_hook_fun(HOOK_AFTER_EVENT, HOOK_AFTER_EVENT,
HOOK_DETAIL_QUEUE_UPDATE, p, NULL);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_GREATER_THAN(lsn_before, p->current_lsn);
cleanup_hook_test_persist(p);
printf("test_queue_hook_update_writes_wal_entry: PASS\n");
}
static void test_worker_hook_join_writes_wal_entry(void)
{
persist_t* p = make_hook_test_persist();
TEST_ASSERT_NOT_NULL(p);
uint64_t lsn_before = p->current_lsn;
int rc = persist_worker_hook_fun(HOOK_AFTER_EVENT, HOOK_AFTER_EVENT,
HOOK_DETAIL_WORKER_JOIN, p, NULL);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_GREATER_THAN(lsn_before, p->current_lsn);
cleanup_hook_test_persist(p);
printf("test_worker_hook_join_writes_wal_entry: PASS\n");
}
static void test_worker_hook_update_writes_wal_entry(void)
{
persist_t* p = make_hook_test_persist();
TEST_ASSERT_NOT_NULL(p);
uint64_t lsn_before = p->current_lsn;
int rc = persist_worker_hook_fun(HOOK_AFTER_EVENT, HOOK_AFTER_EVENT,
HOOK_DETAIL_WORKER_UPDATE, p, NULL);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_GREATER_THAN(lsn_before, p->current_lsn);
cleanup_hook_test_persist(p);
printf("test_worker_hook_update_writes_wal_entry: PASS\n");
}
static void test_worker_hook_dead_writes_wal_entry(void)
{
persist_t* p = make_hook_test_persist();
TEST_ASSERT_NOT_NULL(p);
uint64_t lsn_before = p->current_lsn;
int rc = persist_worker_hook_fun(HOOK_AFTER_EVENT, HOOK_AFTER_EVENT,
HOOK_DETAIL_WORKER_DEAD, p, NULL);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_GREATER_THAN(lsn_before, p->current_lsn);
cleanup_hook_test_persist(p);
printf("test_worker_hook_dead_writes_wal_entry: PASS\n");
}
static void test_job_hook_skips_unknown_detail(void)
{
persist_t* p = make_hook_test_persist();
TEST_ASSERT_NOT_NULL(p);
uint64_t lsn_before = p->current_lsn;
/* HOOK_DETAIL_SCHEDULER_JOIN has no WAL mapping — must be silently skipped */
int rc = persist_job_hook_fun(HOOK_AFTER_EVENT, HOOK_AFTER_EVENT,
HOOK_DETAIL_SCHEDULER_JOIN, p, NULL);
TEST_ASSERT_EQUAL_INT(0, rc);
/* LSN must NOT have advanced — no WAL entry written */
TEST_ASSERT_EQUAL_UINT64(lsn_before, p->current_lsn);
cleanup_hook_test_persist(p);
printf("test_job_hook_skips_unknown_detail: PASS\n");
}
static void test_worker_hook_skips_unknown_detail(void)
{
persist_t* p = make_hook_test_persist();
TEST_ASSERT_NOT_NULL(p);
uint64_t lsn_before = p->current_lsn;
int rc = persist_worker_hook_fun(HOOK_AFTER_EVENT, HOOK_AFTER_EVENT,
HOOK_DETAIL_SCHEDULER_DEAD, p, NULL);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_UINT64(lsn_before, p->current_lsn);
cleanup_hook_test_persist(p);
printf("test_worker_hook_skips_unknown_detail: PASS\n");
}
static void test_job_hook_new_writes_multiple_entries_sequentially(void)
{
persist_t* p = make_hook_test_persist();
TEST_ASSERT_NOT_NULL(p);
uint64_t lsn_before = p->current_lsn;
/* Fire three hook calls — each must advance LSN */
persist_job_hook_fun(HOOK_AFTER_EVENT, HOOK_AFTER_EVENT,
HOOK_DETAIL_JOB_NEW, p, NULL);
persist_job_hook_fun(HOOK_AFTER_EVENT, HOOK_AFTER_EVENT,
HOOK_DETAIL_JOB_START, p, NULL);
persist_job_hook_fun(HOOK_AFTER_EVENT, HOOK_AFTER_EVENT,
HOOK_DETAIL_JOB_EXIT, p, NULL);
TEST_ASSERT_EQUAL_UINT64(lsn_before + 3, p->current_lsn);
cleanup_hook_test_persist(p);
printf("test_job_hook_new_writes_multiple_entries_sequentially: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Suite entry point */
/* ------------------------------------------------------------------ */
void test_persist_hooks(void)
{
printf("\n>> persist hook function tests\n\n");
RUN_TEST(test_job_hook_new_writes_wal_entry);
RUN_TEST(test_job_hook_start_writes_wal_entry);
RUN_TEST(test_job_hook_exit_writes_wal_entry);
RUN_TEST(test_job_hook_fail_writes_wal_entry);
RUN_TEST(test_job_hook_stop_writes_wal_entry);
RUN_TEST(test_job_hook_resume_writes_wal_entry);
RUN_TEST(test_job_hook_update_writes_wal_entry);
RUN_TEST(test_job_hook_ckpt_writes_wal_entry);
RUN_TEST(test_job_hook_rusage_writes_wal_entry);
RUN_TEST(test_job_hook_setpreason_writes_wal_entry);
RUN_TEST(test_job_hook_clrpreason_writes_wal_entry);
RUN_TEST(test_queue_hook_update_writes_wal_entry);
RUN_TEST(test_worker_hook_join_writes_wal_entry);
RUN_TEST(test_worker_hook_update_writes_wal_entry);
RUN_TEST(test_worker_hook_dead_writes_wal_entry);
RUN_TEST(test_job_hook_skips_unknown_detail);
RUN_TEST(test_worker_hook_skips_unknown_detail);
RUN_TEST(test_job_hook_new_writes_multiple_entries_sequentially);
}

View file

@ -0,0 +1,258 @@
#define _GNU_SOURCE
#include <assert.h>
#include <dirent.h>
#include <errno.h>
#include <fcntl.h>
#include <inttypes.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include <unity/unity.h>
#include "config.h"
#include "persist.h"
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
static persist_t* make_fresh_persist(const char* tmpdir)
{
persist_t* p = calloc(1, sizeof(persist_t));
if (!p) return NULL;
persist_config_t cfg = persist_config_defaults();
cfg.wal_sync_ms = 5000; /* slow fsync — no races in tests */
cfg.snap_keep_n = 2;
persist_set_workdir(p, tmpdir, "ctrl-test");
if (persist_init(p, &cfg, NULL) != 0) {
free(p);
return NULL;
}
if (persist_start(p) != 0) {
persist_fini(p);
free(p);
return NULL;
}
return p;
}
static void teardown_persist(persist_t* p)
{
persist_stop(p);
persist_fini(p);
free(p);
}
/* ------------------------------------------------------------------ */
/* Test 1: recover from empty state (no snapshot, no WAL) */
/* ------------------------------------------------------------------ */
static void test_recover_from_empty_state(void)
{
char tmp[256];
strcpy(tmp, "/tmp/xce_rec0_XXXXXX");
TEST_ASSERT_NOT_NULL(mkdtemp(tmp));
persist_t* p = make_fresh_persist(tmp);
TEST_ASSERT_NOT_NULL(p);
/* Stop so we can simulate a "fresh boot" re-init — actually
* persist_recover() is valid to call on a running instance too. */
int rc = persist_recover(p);
TEST_ASSERT_EQUAL_INT(0, rc);
/* No snapshot found — checkpoint_lsn stays at 0 */
TEST_ASSERT_EQUAL_UINT64(0, p->checkpoint_lsn);
teardown_persist(p);
printf("test_recover_from_empty_state: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Test 2: recover loads checkpoint_lsn from the snapshot sentinel */
/* ------------------------------------------------------------------ */
static void test_recover_loads_checkpoint_lsn(void)
{
char tmp[256];
strcpy(tmp, "/tmp/xce_rec1_XXXXXX");
TEST_ASSERT_NOT_NULL(mkdtemp(tmp));
/* Phase 1: write some WAL entries + take a checkpoint */
{
persist_t* p = make_fresh_persist(tmp);
TEST_ASSERT_NOT_NULL(p);
TEST_ASSERT_EQUAL_INT(0, persist_wal_async(p, WAL_JOB_UPDATE, "x", 1));
TEST_ASSERT_EQUAL_INT(0, persist_wal_async(p, WAL_JOB_UPDATE, "y", 1));
TEST_ASSERT_EQUAL_INT(0, persist_checkpoint(p));
teardown_persist(p);
}
/* Phase 2: fresh persist_t, call recover — must find the snapshot */
{
persist_t* p2 = make_fresh_persist(tmp);
TEST_ASSERT_NOT_NULL(p2);
int rc = persist_recover(p2);
TEST_ASSERT_EQUAL_INT(0, rc);
/* checkpoint_lsn must have been restored from the sentinel header */
TEST_ASSERT_TRUE(p2->checkpoint_lsn > 0);
teardown_persist(p2);
}
printf("test_recover_loads_checkpoint_lsn: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Test 3: recover replays WAL entries written after the checkpoint */
/* ------------------------------------------------------------------ */
static void test_recover_replays_wal_after_checkpoint(void)
{
char tmp[256];
strcpy(tmp, "/tmp/xce_rec2_XXXXXX");
TEST_ASSERT_NOT_NULL(mkdtemp(tmp));
/* Phase 1: checkpoint, then write 5 more WAL entries */
uint64_t last_lsn_before_stop = 0;
{
persist_t* p = make_fresh_persist(tmp);
TEST_ASSERT_NOT_NULL(p);
TEST_ASSERT_EQUAL_INT(0, persist_checkpoint(p)); /* clean checkpoint */
for (int i = 0; i < 5; i++)
TEST_ASSERT_EQUAL_INT(0,
persist_wal_async(p, WAL_JOB_UPDATE, "z", 1));
last_lsn_before_stop = p->current_lsn;
TEST_ASSERT_EQUAL_INT(0, persist_flush(p));
teardown_persist(p);
}
/* Phase 2: recover — current_lsn must advance past the replayed entries */
{
persist_t* p2 = make_fresh_persist(tmp);
TEST_ASSERT_NOT_NULL(p2);
int rc = persist_recover(p2);
TEST_ASSERT_EQUAL_INT(0, rc);
/* After replay, current_lsn should reach the last replayed LSN + 1
* (or at minimum equal last_lsn_before_stop). */
TEST_ASSERT_TRUE(p2->current_lsn >= last_lsn_before_stop);
teardown_persist(p2);
}
printf("test_recover_replays_wal_after_checkpoint: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Test 4: recover with corrupt snapshot sentinel returns error */
/* ------------------------------------------------------------------ */
static void test_recover_rejects_corrupt_snapshot(void)
{
char tmp[256];
strcpy(tmp, "/tmp/xce_rec3_XXXXXX");
TEST_ASSERT_NOT_NULL(mkdtemp(tmp));
/* Build paths (same layout as persist_set_workdir) */
char snap_dir[512];
snprintf(snap_dir, sizeof(snap_dir), "%s/ctrl-test/data/snap", tmp);
/* Create the snap dir manually (without starting persist) */
{
char mkpath[512];
snprintf(mkpath, sizeof(mkpath), "%s/ctrl-test/data/wal", tmp);
/* use system mkdir -p */
char cmd[1024];
snprintf(cmd, sizeof(cmd), "mkdir -p '%s' '%s'", snap_dir, mkpath);
system(cmd);
}
/* Write a corrupt snapshot file (wrong magic) */
char snap_file[512];
snprintf(snap_file, sizeof(snap_file),
"%s/snap-%016" PRIx64 ".capnp", snap_dir, (uint64_t)1);
{
int fd = open(snap_file, O_CREAT | O_WRONLY | O_TRUNC, 0600);
TEST_ASSERT_TRUE(fd >= 0);
const char bad[16] = "BADMAGIC\0\0\0\0\0\0\0\0";
write(fd, bad, sizeof(bad));
close(fd);
}
persist_t* p = make_fresh_persist(tmp);
TEST_ASSERT_NOT_NULL(p);
int rc = persist_recover(p);
/* Corrupt sentinel must cause a non-zero error return */
TEST_ASSERT_NOT_EQUAL(0, rc);
teardown_persist(p);
printf("test_recover_rejects_corrupt_snapshot: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Test 5: multiple checkpoints — recover picks the LATEST snapshot */
/* ------------------------------------------------------------------ */
static void test_recover_picks_latest_snapshot(void)
{
char tmp[256];
strcpy(tmp, "/tmp/xce_rec4_XXXXXX");
TEST_ASSERT_NOT_NULL(mkdtemp(tmp));
uint64_t last_ckpt_lsn = 0;
/* Take 3 checkpoints; the last one should be loaded on recovery */
{
persist_t* p = make_fresh_persist(tmp);
TEST_ASSERT_NOT_NULL(p);
for (int i = 0; i < 3; i++) {
persist_wal_async(p, WAL_JOB_UPDATE, "w", 1);
TEST_ASSERT_EQUAL_INT(0, persist_checkpoint(p));
}
last_ckpt_lsn = p->checkpoint_lsn;
teardown_persist(p);
}
/* Recover: checkpoint_lsn must equal the last checkpoint's lsn */
{
persist_t* p2 = make_fresh_persist(tmp);
TEST_ASSERT_NOT_NULL(p2);
TEST_ASSERT_EQUAL_INT(0, persist_recover(p2));
TEST_ASSERT_EQUAL_UINT64(last_ckpt_lsn, p2->checkpoint_lsn);
teardown_persist(p2);
}
printf("test_recover_picks_latest_snapshot: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Test suite entry point */
/* ------------------------------------------------------------------ */
void test_persist_recovery(void)
{
RUN_TEST(test_recover_from_empty_state);
RUN_TEST(test_recover_loads_checkpoint_lsn);
RUN_TEST(test_recover_replays_wal_after_checkpoint);
RUN_TEST(test_recover_rejects_corrupt_snapshot);
RUN_TEST(test_recover_picks_latest_snapshot);
}

View file

@ -0,0 +1,145 @@
/*
* test_persist_repl.c — P2-4: Tests for repl_broadcast wired into WAL write paths.
*
* TDD RED phase: these tests must FAIL before implementation.
* After implementation they must all PASS.
*
* Tests verify:
* 1. persist_t has repl field (structural/compile-time check)
* 2. persist_wal_async succeeds with zero standbys
* 3. persist_wal_sync_append returns 0 (WAL success) but sets sync_rc < 0 (degraded)
* 4. last_replicated_crc NOT updated by WAL_CHECKPOINT (checkpoint marker)
*/
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include <unity/unity.h>
#include "persist.h"
#include "repl.h"
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
static persist_t* make_repl_persist(const char* suffix)
{
persist_t* p = calloc(1, sizeof(persist_t));
persist_config_t cfg = persist_config_defaults();
cfg.wal_sync_ms = 5000;
cfg.sync_timeout_ms = 50; /* short timeout so sync tests don't hang */
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/xce_repl_%s_XXXXXX", suffix);
if (!mkdtemp(tmpdir)) {
free(p);
return NULL;
}
persist_set_workdir(p, tmpdir, "ctrl");
persist_init(p, &cfg, NULL);
persist_start(p);
return p;
}
static void teardown_repl_persist(persist_t* p)
{
if (!p) return;
persist_stop(p);
persist_fini(p);
free(p);
}
/* ------------------------------------------------------------------ */
/* Tests */
/* ------------------------------------------------------------------ */
/*
* Structural: persist_t must contain a repl field of type replicator_t.
* This is a compile-time check — accessing p.repl.nstandbys must compile.
*/
static void test_persist_has_repl_field(void)
{
persist_t p;
memset(&p, 0, sizeof(p));
/* Accessing repl.nstandbys is the compile-time guard */
TEST_ASSERT_EQUAL_INT(0, p.repl.nstandbys);
printf("test_persist_has_repl_field: PASS\n");
}
/*
* WAL async must succeed when there are zero standbys.
* repl_broadcast is a stub; no replication occurs, but write must not fail.
*/
static void test_wal_async_no_standbys_succeeds(void)
{
persist_t* p = make_repl_persist("async");
TEST_ASSERT_NOT_NULL(p);
int rc = persist_wal_async(p, WAL_JOB_UPDATE, "payload", 7);
TEST_ASSERT_EQUAL_INT(0, rc);
teardown_repl_persist(p);
printf("test_wal_async_no_standbys_succeeds: PASS\n");
}
/*
* persist_wal_sync_append must return 0 (WAL write succeeded locally) even
* when there are no standbys (degraded mode). The sync_rc_out parameter
* must be set to a negative value to signal degraded mode
* (-ENODEV = no standbys, or -ETIMEDOUT = timeout).
*/
static void test_wal_sync_no_standbys_returns_enodev_in_sync_rc(void)
{
persist_t* p = make_repl_persist("sync");
TEST_ASSERT_NOT_NULL(p);
int sync_rc = 0;
int rc = persist_wal_sync_append(p, WAL_JOB_SUBMIT, "job_payload", 11,
&sync_rc);
/* WAL write itself must succeed */
TEST_ASSERT_EQUAL_INT(0, rc);
/* sync_rc: -ENODEV (no standbys) or -ETIMEDOUT — either indicates degraded */
TEST_ASSERT_TRUE(sync_rc < 0);
teardown_repl_persist(p);
printf("test_wal_sync_no_standbys_returns_enodev_in_sync_rc: PASS\n");
}
/*
* WAL_CHECKPOINT (via persist_checkpoint) must NOT update last_replicated_crc.
* Checkpoint markers are local-only; the replication CRC chain must remain
* unbroken for standbys (design doc Part 2.3).
*/
static void test_last_replicated_crc_not_updated_for_checkpoint(void)
{
persist_t* p = make_repl_persist("crc");
TEST_ASSERT_NOT_NULL(p);
uint32_t crc_before = p->last_replicated_crc;
/* persist_checkpoint writes a WAL_CHECKPOINT marker then triggers a snap.
* Neither the marker write nor snap must update last_replicated_crc. */
persist_checkpoint(p);
/* last_replicated_crc must be unchanged from before the checkpoint */
TEST_ASSERT_EQUAL_UINT32(crc_before, p->last_replicated_crc);
teardown_repl_persist(p);
printf("test_last_replicated_crc_not_updated_for_checkpoint: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Entry point */
/* ------------------------------------------------------------------ */
void test_persist_repl(void)
{
RUN_TEST(test_persist_has_repl_field);
RUN_TEST(test_wal_async_no_standbys_succeeds);
RUN_TEST(test_wal_sync_no_standbys_returns_enodev_in_sync_rc);
RUN_TEST(test_last_replicated_crc_not_updated_for_checkpoint);
}

View file

@ -0,0 +1,110 @@
/* Test that generated Cap'n Proto types for persistence exist and have correct
* fields. This is a compile-time / linkage test — the real assertion is that
* ninja succeeds with the new schema additions. */
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <unity/unity.h>
#include "controller.capnp.h"
/* ------------------------------------------------------------------ */
/* PersistConfig */
/* ------------------------------------------------------------------ */
static void test_persist_config_schema_fields(void)
{
/* Verify struct is defined and has the expected word/byte counts.
* c-capnproto generates <Struct>_struct_bytes_count constants. */
(void)PersistConfig_struct_bytes_count;
printf("test_persist_config_schema_fields: PASS\n");
}
/* ------------------------------------------------------------------ */
/* SnapshotHeader */
/* ------------------------------------------------------------------ */
static void test_snapshot_header_schema_fields(void)
{
(void)SnapshotHeader_struct_bytes_count;
printf("test_snapshot_header_schema_fields: PASS\n");
}
/* ------------------------------------------------------------------ */
/* WALEntry */
/* ------------------------------------------------------------------ */
static void test_wal_entry_schema_fields(void)
{
/* Schema struct is named ReplEnvelope to avoid collision with the
* on-disk wal_entry_hdr_t C type and the WALEntry_ generator symbol. */
(void)ReplEnvelope_struct_bytes_count;
printf("test_wal_entry_schema_fields: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Replication opcodes */
/* ------------------------------------------------------------------ */
static void test_repl_opcodes_exist(void)
{
/* These enum values must be present in the generated header. */
int repl_entry = OPCode_replEntry;
int repl_ack = OPCode_replAck;
int redirect_op = OPCode_redirect;
int heartbeat_op = OPCode_heartbeat;
int stepping = OPCode_steppingDown;
int repl_snap = OPCode_replSnapshot;
int catchup_done = OPCode_replCatchUpDone;
/* All values must be > 21 (highest existing opcode clrpreason=21). */
TEST_ASSERT_TRUE(repl_entry > 21);
TEST_ASSERT_TRUE(repl_ack > 21);
TEST_ASSERT_TRUE(redirect_op > 21);
TEST_ASSERT_TRUE(heartbeat_op > 21);
TEST_ASSERT_TRUE(stepping > 21);
TEST_ASSERT_TRUE(repl_snap > 21);
TEST_ASSERT_TRUE(catchup_done > 21);
printf("test_repl_opcodes_exist: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Job.transientSince and Worker.deadSince fields */
/* ------------------------------------------------------------------ */
static void test_job_transient_since_field(void)
{
/* c-capnproto preserves camelCase field names from the schema directly. */
struct Job j;
memset(&j, 0, sizeof(j));
j.transientSince = 12345ULL;
TEST_ASSERT_EQUAL_UINT64(12345ULL, j.transientSince);
printf("test_job_transient_since_field: PASS\n");
}
static void test_worker_dead_since_field(void)
{
struct Worker w;
memset(&w, 0, sizeof(w));
w.deadSince = 99999ULL;
TEST_ASSERT_EQUAL_UINT64(99999ULL, w.deadSince);
printf("test_worker_dead_since_field: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Suite entry point (called from controller_test.c) */
/* ------------------------------------------------------------------ */
void test_persist_schema(void)
{
printf("\n>> persist Cap'n Proto schema test\n\n");
RUN_TEST(test_persist_config_schema_fields);
RUN_TEST(test_snapshot_header_schema_fields);
RUN_TEST(test_wal_entry_schema_fields);
RUN_TEST(test_repl_opcodes_exist);
RUN_TEST(test_job_transient_since_field);
RUN_TEST(test_worker_dead_since_field);
}

View file

@ -0,0 +1,68 @@
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <unity/unity.h>
#include "config.h"
#include "persist.h"
static void test_wal_header_size(void)
{
/* wal_entry_hdr_t must be exactly 40 bytes for on-disk format stability */
TEST_ASSERT_EQUAL_INT(40, (int)sizeof(wal_entry_hdr_t));
printf("test_wal_header_size: PASS\n");
}
static void test_wal_entry_types(void)
{
TEST_ASSERT_EQUAL_INT(1, WAL_JOB_SUBMIT);
TEST_ASSERT_EQUAL_INT(2, WAL_JOB_START);
TEST_ASSERT_EQUAL_INT(3, WAL_JOB_FINISH);
TEST_ASSERT_EQUAL_INT(4, WAL_JOB_FAIL);
TEST_ASSERT_EQUAL_INT(5, WAL_JOB_STOP);
TEST_ASSERT_EQUAL_INT(6, WAL_JOB_RESUME);
TEST_ASSERT_EQUAL_INT(7, WAL_JOB_UPDATE);
TEST_ASSERT_EQUAL_INT(8, WAL_JOB_RUSAGE);
TEST_ASSERT_EQUAL_INT(9, WAL_JOB_CKPT);
TEST_ASSERT_EQUAL_INT(10, WAL_JOB_PREASON);
TEST_ASSERT_EQUAL_INT(20, WAL_QUEUE_CTRL);
TEST_ASSERT_EQUAL_INT(30, WAL_WORKER_CTRL);
TEST_ASSERT_EQUAL_INT(100, WAL_CHECKPOINT);
printf("test_wal_entry_types: PASS\n");
}
static void test_persist_config_defaults(void)
{
persist_config_t cfg = persist_config_defaults();
TEST_ASSERT_EQUAL_INT(DEFAULT_WAL_SYNC_MS, (int)cfg.wal_sync_ms);
TEST_ASSERT_EQUAL_INT(DEFAULT_SNAP_INTERVAL_S, (int)cfg.snap_interval_s);
TEST_ASSERT_EQUAL_INT(DEFAULT_SNAP_WAL_SIZE_MB, (int)cfg.snap_wal_size_mb);
TEST_ASSERT_EQUAL_INT(DEFAULT_SNAP_KEEP_N, (int)cfg.snap_keep_n);
TEST_ASSERT_EQUAL_INT(DEFAULT_TRANSIENT_JOB_TIMEOUT_S, (int)cfg.transient_job_timeout_s);
TEST_ASSERT_EQUAL_INT(DEFAULT_FINISHED_RETAIN_S, (int)cfg.finished_retain_s);
TEST_ASSERT_EQUAL_INT(DEFAULT_WORKER_RETAIN_S, (int)cfg.worker_retain_s);
TEST_ASSERT_EQUAL_INT(DEFAULT_SYNC_TIMEOUT_MS, (int)cfg.sync_timeout_ms);
printf("test_persist_config_defaults: PASS\n");
}
static void test_slot_key_t_fields(void)
{
/* slot_key_t must have index, key[XCE_MAXNAME_LEN], and key_len fields */
slot_key_t sk;
sk.index = 42;
sk.key[0] = 'x';
sk.key_len = 1;
TEST_ASSERT_EQUAL_INT(42, sk.index);
printf("test_slot_key_t_fields: PASS\n");
}
void test_persist_types(void)
{
printf("\n>> persist types test\n\n");
RUN_TEST(test_wal_header_size);
RUN_TEST(test_wal_entry_types);
RUN_TEST(test_persist_config_defaults);
RUN_TEST(test_slot_key_t_fields);
}

View file

@ -0,0 +1,140 @@
#define _GNU_SOURCE
#include <assert.h>
#include <dirent.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include <unity/unity.h>
#include "config.h"
#include "persist.h"
/* ------------------------------------------------------------------ */
/* Test-local helpers */
/* ------------------------------------------------------------------ */
static char g_tmpdir[256];
/* Count *.wal files in a directory */
static int count_wal_files(const char* dir_path)
{
DIR* dir = opendir(dir_path);
if (!dir) return 0;
int count = 0;
struct dirent* ent;
while ((ent = readdir(dir)) != NULL) {
size_t len = strlen(ent->d_name);
if (len > 4 && strcmp(ent->d_name + len - 4, ".wal") == 0)
count++;
}
closedir(dir);
return count;
}
/* Allocate and initialise a persist_t for testing */
static persist_t* make_test_persist(void)
{
strcpy(g_tmpdir, "/tmp/xce_test_XXXXXX");
if (!mkdtemp(g_tmpdir)) return NULL;
persist_t* p = calloc(1, sizeof(persist_t));
if (!p) return NULL;
persist_config_t cfg = persist_config_defaults();
cfg.wal_sync_ms = 5000; /* slow background fsync — don't want it firing during tests */
persist_set_workdir(p, g_tmpdir, "ctrl-test");
if (persist_init(p, &cfg, NULL) != 0) {
free(p);
return NULL;
}
return p;
}
static void cleanup_test_persist(persist_t* p)
{
persist_stop(p);
persist_fini(p);
free(p);
/* tmpdir is left on disk — /tmp is ephemeral */
}
/* ------------------------------------------------------------------ */
/* Tests */
/* ------------------------------------------------------------------ */
static void test_persist_init_creates_directories(void)
{
persist_t* p = make_test_persist();
TEST_ASSERT_NOT_NULL(p);
char wal_path[512], snap_path[512];
snprintf(wal_path, sizeof(wal_path), "%s/ctrl-test/data/wal", g_tmpdir);
snprintf(snap_path, sizeof(snap_path), "%s/ctrl-test/data/snap", g_tmpdir);
struct stat st;
TEST_ASSERT_EQUAL_INT(0, stat(wal_path, &st));
TEST_ASSERT_TRUE(S_ISDIR(st.st_mode));
TEST_ASSERT_EQUAL_INT(0, stat(snap_path, &st));
TEST_ASSERT_TRUE(S_ISDIR(st.st_mode));
cleanup_test_persist(p);
printf("test_persist_init_creates_directories: PASS\n");
}
static void test_persist_wal_async_writes_entry(void)
{
persist_t* p = make_test_persist();
TEST_ASSERT_NOT_NULL(p);
TEST_ASSERT_EQUAL_INT(0, persist_start(p));
const char payload[] = "test_job_submit_payload";
int rc = persist_wal_async(p, WAL_JOB_UPDATE, payload,
(uint32_t)strlen(payload));
TEST_ASSERT_EQUAL_INT(0, rc);
/* Force sync so WAL is on disk */
TEST_ASSERT_EQUAL_INT(0, persist_flush(p));
/* WAL directory must contain at least one .wal segment file */
char wal_path[512];
snprintf(wal_path, sizeof(wal_path), "%s/ctrl-test/data/wal", g_tmpdir);
int found = count_wal_files(wal_path);
TEST_ASSERT_TRUE(found >= 1);
cleanup_test_persist(p);
printf("test_persist_wal_async_writes_entry: PASS\n");
}
static void test_wal_checkpoint_type_rejected(void)
{
persist_t* p = make_test_persist();
TEST_ASSERT_NOT_NULL(p);
TEST_ASSERT_EQUAL_INT(0, persist_start(p));
/* WAL_CHECKPOINT must never be passed to persist_wal_async —
* it must return a non-zero error code (not abort/assert). */
int rc = persist_wal_async(p, WAL_CHECKPOINT, NULL, 0);
TEST_ASSERT_TRUE(rc != 0);
cleanup_test_persist(p);
printf("test_wal_checkpoint_type_rejected: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Suite entry point (called from controller_test.c) */
/* ------------------------------------------------------------------ */
void test_persist_wal(void)
{
printf("\n>> persist WAL writer test\n\n");
RUN_TEST(test_persist_init_creates_directories);
RUN_TEST(test_persist_wal_async_writes_entry);
RUN_TEST(test_wal_checkpoint_type_rejected);
}

View file

@ -0,0 +1,211 @@
/*
* test_repl_apply.c — P2-2: Tests for apply_wal_entry() standby WAL replay.
*
* TDD RED phase: written BEFORE the implementation.
* These tests verify:
* - LSN advances past replayed entry
* - WAL_CHECKPOINT entries are skipped (rc=0, lsn unchanged)
* - Oversized payloads are rejected (rc != 0)
* - Unknown types (> WAL_ENTRY_TYPE_MAX) are skipped with rc=0
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unity/unity.h>
#include "config.h"
#include "persist.h"
/* ------------------------------------------------------------------ */
/* Weak stubs for P0 entity-restore functions (job.c / worker.c). */
/* The unit test binary does not link those translation units — they */
/* pull in the full cluster.h / netio.h chain. apply_wal_entry() only */
/* calls these when p->cluster != NULL; tests use NULL cluster, so the */
/* weak stubs are never reached. The linker needs the symbols to link. */
/* ------------------------------------------------------------------ */
typedef struct cluster_s cluster_t;
__attribute__((weak))
int restore_job(cluster_t* c, uint64_t jobid,
const void* capnp_payload, size_t len)
{
(void)c; (void)jobid; (void)capnp_payload; (void)len;
return 0;
}
__attribute__((weak))
int worker_from_capnp(cluster_t* c, const void* capnp_payload, size_t len)
{
(void)c; (void)capnp_payload; (void)len;
return 0;
}
__attribute__((weak))
int restore_job_state(cluster_t* c, uint64_t jobid, uint32_t state)
{
(void)c; (void)jobid; (void)state;
return 0;
}
__attribute__((weak))
int restore_queue_state(cluster_t* c, const char* name, uint32_t status)
{
(void)c; (void)name; (void)status;
return 0;
}
/* ------------------------------------------------------------------ */
/* Test helpers */
/* ------------------------------------------------------------------ */
static persist_t* make_test_persist_for_apply(void)
{
persist_t* p = calloc(1, sizeof(persist_t));
if (!p) {
fprintf(stderr, "calloc failed\n");
exit(1);
}
persist_config_t cfg = persist_config_defaults();
cfg.wal_sync_ms = 5000; /* slow fsync — we don't need it in unit tests */
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/xce_apply_XXXXXX");
if (!mkdtemp(tmpdir)) {
fprintf(stderr, "mkdtemp failed\n");
free(p);
exit(1);
}
persist_set_workdir(p, tmpdir, "ctrl-apply");
persist_init(p, &cfg, NULL); /* NULL cluster — entity stubs gate on p->cluster */
persist_start(p);
return p;
}
static void destroy_test_persist(persist_t* p)
{
persist_stop(p);
persist_fini(p);
free(p);
}
/* ------------------------------------------------------------------ */
/* Tests */
/* ------------------------------------------------------------------ */
static void test_apply_wal_entry_advances_lsn(void)
{
persist_t* p = make_test_persist_for_apply();
wal_entry_hdr_t hdr;
memset(&hdr, 0, sizeof(hdr));
hdr.type = WAL_JOB_UPDATE;
hdr.lsn = 42;
hdr.term = 1;
hdr.payload_len = 4;
hdr.crc = 0xDEADBEEF;
hdr.prev_crc = WAL_CRC_NONE;
uint8_t payload[4] = {1, 2, 3, 4};
int rc = apply_wal_entry(p, &hdr, payload);
TEST_ASSERT_EQUAL_INT(0, rc);
/* current_lsn must have advanced to at least lsn+1 */
TEST_ASSERT_TRUE(p->current_lsn > 42);
/* last_crc must reflect the entry's crc */
TEST_ASSERT_EQUAL_UINT32(0xDEADBEEF, p->last_crc);
destroy_test_persist(p);
printf("test_apply_wal_entry_advances_lsn: PASS\n");
}
static void test_apply_wal_entry_skips_checkpoint(void)
{
persist_t* p = make_test_persist_for_apply();
uint64_t lsn_before = p->current_lsn;
wal_entry_hdr_t hdr;
memset(&hdr, 0, sizeof(hdr));
hdr.type = WAL_CHECKPOINT;
hdr.lsn = 100;
hdr.term = 1;
hdr.payload_len = 0;
int rc = apply_wal_entry(p, &hdr, NULL);
TEST_ASSERT_EQUAL_INT(0, rc); /* skip = success, not error */
/* Checkpoint entries are local-only markers — standbys never receive them.
* current_lsn must NOT advance: skip means no-op. */
TEST_ASSERT_EQUAL_UINT64(lsn_before, p->current_lsn);
destroy_test_persist(p);
printf("test_apply_wal_entry_skips_checkpoint: PASS\n");
}
static void test_apply_wal_entry_rejects_oversized(void)
{
persist_t* p = make_test_persist_for_apply();
wal_entry_hdr_t hdr;
memset(&hdr, 0, sizeof(hdr));
hdr.type = WAL_JOB_SUBMIT;
hdr.lsn = 1;
hdr.term = 1;
hdr.payload_len = MAX_WAL_PAYLOAD_BYTES + 1; /* too large */
int rc = apply_wal_entry(p, &hdr, NULL);
TEST_ASSERT_NOT_EQUAL(0, rc); /* must be rejected */
destroy_test_persist(p);
printf("test_apply_wal_entry_rejects_oversized: PASS\n");
}
static void test_apply_wal_entry_skips_unknown_type(void)
{
persist_t* p = make_test_persist_for_apply();
uint64_t lsn_before = p->current_lsn;
/*
* Type 200 is above WAL_ENTRY_TYPE_MAX (100).
* Spec: log WARN and skip with rc=0 (forward compatibility).
*/
wal_entry_hdr_t hdr;
memset(&hdr, 0, sizeof(hdr));
hdr.type = 200;
hdr.lsn = 5;
hdr.term = 1;
hdr.payload_len = 0;
int rc = apply_wal_entry(p, &hdr, NULL);
TEST_ASSERT_EQUAL_INT(0, rc); /* unknown type: skip not error */
/* LSN must NOT advance for a skipped-unknown type */
TEST_ASSERT_EQUAL_UINT64(lsn_before, p->current_lsn);
destroy_test_persist(p);
printf("test_apply_wal_entry_skips_unknown_type: PASS\n");
}
static void test_apply_wal_entry_null_guard(void)
{
/* Both NULL inputs must return non-zero (EINVAL) */
int rc = apply_wal_entry(NULL, NULL, NULL);
TEST_ASSERT_NOT_EQUAL(0, rc);
printf("test_apply_wal_entry_null_guard: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Suite entry point (registered in controller_test.c) */
/* ------------------------------------------------------------------ */
void test_repl_apply(void)
{
RUN_TEST(test_apply_wal_entry_advances_lsn);
RUN_TEST(test_apply_wal_entry_skips_checkpoint);
RUN_TEST(test_apply_wal_entry_rejects_oversized);
RUN_TEST(test_apply_wal_entry_skips_unknown_type);
RUN_TEST(test_apply_wal_entry_null_guard);
}

View file

@ -0,0 +1,134 @@
/*
* test_repl_catchup.c — P2-5: standby catch-up state machine tests.
* Tests repl_start_catchup, repl_complete_catchup, repl_needs_snapshot.
* No live network I/O — pure state machine logic.
*/
#include <errno.h>
#include <stdatomic.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <unity/unity.h>
#include "config.h"
#include "repl.h"
static void test_start_catchup_sets_in_catch_up(void)
{
replicator_t r;
int ep = 0;
repl_init(&r);
repl_add_standby(&r, (void *)&ep, "sb", 7001);
repl_start_catchup(&r, (void *)&ep, /*join_applied_lsn=*/5);
/* in_catch_up must be set */
TEST_ASSERT_EQUAL_INT(
1,
(int)atomic_load_explicit(&r.standbys[0].in_catch_up,
memory_order_acquire));
/* join_applied_lsn must be stored */
TEST_ASSERT_EQUAL_UINT64(5, r.standbys[0].join_applied_lsn);
repl_fini(&r);
printf("test_start_catchup_sets_in_catch_up: PASS\n");
}
static void test_complete_catchup_clears_in_catch_up(void)
{
replicator_t r;
int ep = 0;
repl_init(&r);
repl_add_standby(&r, (void *)&ep, "sb", 7001);
repl_start_catchup(&r, (void *)&ep, 5);
repl_complete_catchup(&r, (void *)&ep);
TEST_ASSERT_EQUAL_INT(
0,
(int)atomic_load_explicit(&r.standbys[0].in_catch_up,
memory_order_acquire));
repl_fini(&r);
printf("test_complete_catchup_clears_in_catch_up: PASS\n");
}
static void test_wait_ack_excludes_catching_up_standby(void)
{
replicator_t r;
int ep = 0;
repl_init(&r);
repl_add_standby(&r, (void *)&ep, "sb", 7001);
repl_start_catchup(&r, (void *)&ep, 5); /* mark as catching up */
/* repl_wait_ack must return -ENODEV (no non-catching-up standbys) */
int rc = repl_wait_ack(&r, 10, 50);
TEST_ASSERT_EQUAL_INT(-ENODEV, rc);
repl_fini(&r);
printf("test_wait_ack_excludes_catching_up_standby: PASS\n");
}
static void test_complete_catchup_allows_ack(void)
{
replicator_t r;
int ep = 0;
repl_init(&r);
repl_add_standby(&r, (void *)&ep, "sb", 7001);
repl_start_catchup(&r, (void *)&ep, 5);
repl_complete_catchup(&r, (void *)&ep);
/* After catch-up completes, the standby's ACK must count */
repl_on_ack(&r, (void *)&ep, 20);
int rc = repl_wait_ack(&r, 10, 50);
TEST_ASSERT_EQUAL_INT(0, rc);
repl_fini(&r);
printf("test_complete_catchup_allows_ack: PASS\n");
}
static void test_repl_needs_snapshot_no_wal(void)
{
/* When standby LSN is 0 and no WAL exists, needs_snapshot returns 0
* (empty state = fresh standby, can start from empty — no snapshot needed) */
replicator_t r;
repl_init(&r);
int needs = repl_needs_snapshot(&r, /*standby_lsn=*/0, /*oldest_wal_lsn=*/0);
TEST_ASSERT_EQUAL_INT(0, needs);
repl_fini(&r);
printf("test_repl_needs_snapshot_no_wal: PASS\n");
}
static void test_repl_needs_snapshot_standby_behind_wal(void)
{
/* Standby LSN 5, oldest WAL segment starts at LSN 10 — snapshot required */
replicator_t r;
repl_init(&r);
int needs = repl_needs_snapshot(&r, /*standby_lsn=*/5, /*oldest_wal_lsn=*/10);
TEST_ASSERT_EQUAL_INT(1, needs);
repl_fini(&r);
printf("test_repl_needs_snapshot_standby_behind_wal: PASS\n");
}
static void test_repl_needs_snapshot_standby_within_wal(void)
{
/* Standby LSN 15, oldest WAL starts at LSN 10 — WAL replay sufficient */
replicator_t r;
repl_init(&r);
int needs = repl_needs_snapshot(&r, /*standby_lsn=*/15, /*oldest_wal_lsn=*/10);
TEST_ASSERT_EQUAL_INT(0, needs);
repl_fini(&r);
printf("test_repl_needs_snapshot_standby_within_wal: PASS\n");
}
void test_repl_catchup(void)
{
RUN_TEST(test_start_catchup_sets_in_catch_up);
RUN_TEST(test_complete_catchup_clears_in_catch_up);
RUN_TEST(test_wait_ack_excludes_catching_up_standby);
RUN_TEST(test_complete_catchup_allows_ack);
RUN_TEST(test_repl_needs_snapshot_no_wal);
RUN_TEST(test_repl_needs_snapshot_standby_behind_wal);
RUN_TEST(test_repl_needs_snapshot_standby_within_wal);
}

View file

@ -0,0 +1,175 @@
#include <assert.h>
#include <errno.h>
#include <stdint.h>
#include <string.h>
#include <stdio.h>
#include <pthread.h>
#include <time.h>
/* Use the same Unity include pattern as other controller tests */
#include <unity/unity.h>
#include "repl.h"
#include "config.h"
static void test_repl_init_zeroes_nstandbys(void) {
replicator_t r;
TEST_ASSERT_EQUAL_INT(0, repl_init(&r));
TEST_ASSERT_EQUAL_INT(0, r.nstandbys);
repl_fini(&r);
printf("test_repl_init_zeroes_nstandbys: PASS\n");
}
static void test_repl_add_remove_standby(void) {
replicator_t r;
/* Use a dummy int as the endpoint pointer — repl.c only compares ep pointers */
int dummy_ep = 0;
repl_init(&r);
int idx = repl_add_standby(&r, (void*)&dummy_ep, "standby1", 7001);
TEST_ASSERT_TRUE(idx >= 0);
TEST_ASSERT_EQUAL_INT(1, r.nstandbys);
repl_remove_standby(&r, (void*)&dummy_ep);
TEST_ASSERT_EQUAL_INT(0, r.nstandbys);
repl_fini(&r);
printf("test_repl_add_remove_standby: PASS\n");
}
static void test_repl_add_standby_max(void) {
replicator_t r;
int eps[MAX_STANDBYS + 1];
repl_init(&r);
for (int i = 0; i < MAX_STANDBYS; i++) {
int rc = repl_add_standby(&r, (void*)&eps[i], "sb", 7000 + i);
TEST_ASSERT_TRUE(rc >= 0);
}
/* One over limit must fail */
int rc = repl_add_standby(&r, (void*)&eps[MAX_STANDBYS], "sb_extra", 9999);
TEST_ASSERT_TRUE(rc < 0);
repl_fini(&r);
printf("test_repl_add_standby_max: PASS\n");
}
static void test_repl_trim_lsn_no_standbys(void) {
replicator_t r;
repl_init(&r);
uint64_t trim = repl_trim_lsn(&r, 42, 60);
TEST_ASSERT_EQUAL_UINT64(42, trim);
repl_fini(&r);
printf("test_repl_trim_lsn_no_standbys: PASS\n");
}
static void test_repl_trim_lsn_disconnected_slot_expiry(void) {
replicator_t r;
int dummy_ep = 0;
repl_init(&r);
repl_add_standby(&r, (void*)&dummy_ep, "sb", 7001);
repl_on_ack(&r, (void*)&dummy_ep, 10);
repl_remove_standby(&r, (void*)&dummy_ep);
/* Immediately after disconnect: trim guard holds at last_disconnected_lsn=10 */
uint64_t trim = repl_trim_lsn(&r, 50, 60);
TEST_ASSERT_EQUAL_UINT64(10, trim);
/* After expiry (snap_interval_s=0 forces immediate expiry): trim == checkpoint */
repl_expire_disconnect_slots(&r, 999999, 0);
trim = repl_trim_lsn(&r, 50, 0);
TEST_ASSERT_EQUAL_UINT64(50, trim);
repl_fini(&r);
printf("test_repl_trim_lsn_disconnected_slot_expiry: PASS\n");
}
/* ============================================================
* P2-3: repl_wait_ack() tests
* ============================================================ */
/* Test 1: repl_wait_ack returns -ENODEV when no standbys connected */
static void test_wait_ack_no_standbys_returns_enodev(void) {
replicator_t r;
repl_init(&r);
int rc = repl_wait_ack(&r, /*target_lsn=*/10, /*timeout_ms=*/50);
TEST_ASSERT_EQUAL_INT(-ENODEV, rc);
repl_fini(&r);
printf("test_wait_ack_no_standbys_returns_enodev: PASS\n");
}
/* Test 2: repl_wait_ack returns 0 when standby already ACKed target LSN */
static void test_wait_ack_already_acked(void) {
replicator_t r;
int dummy_ep = 0;
repl_init(&r);
repl_add_standby(&r, (void*)&dummy_ep, "sb", 7001);
/* Pre-set acked_lsn to 20 (already past target 10) */
repl_on_ack(&r, (void*)&dummy_ep, 20);
int rc = repl_wait_ack(&r, 10, 50);
TEST_ASSERT_EQUAL_INT(0, rc);
repl_fini(&r);
printf("test_wait_ack_already_acked: PASS\n");
}
/* Test 3: repl_wait_ack times out when standby never ACKs */
static void test_wait_ack_timeout(void) {
replicator_t r;
int dummy_ep = 0;
repl_init(&r);
repl_add_standby(&r, (void*)&dummy_ep, "sb", 7001);
/* acked_lsn stays at 0 — never reaches target 100 */
struct timespec start, end;
clock_gettime(CLOCK_MONOTONIC, &start);
int rc = repl_wait_ack(&r, 100, 100 /* 100ms timeout */);
clock_gettime(CLOCK_MONOTONIC, &end);
TEST_ASSERT_EQUAL_INT(-ETIMEDOUT, rc);
/* Verify we actually waited ~100ms (not returned immediately) */
long elapsed_ms = (end.tv_sec - start.tv_sec) * 1000
+ (end.tv_nsec - start.tv_nsec) / 1000000;
TEST_ASSERT_TRUE(elapsed_ms >= 80); /* at least 80ms */
repl_fini(&r);
printf("test_wait_ack_timeout: PASS\n");
}
/* Test 4: ACK from background thread wakes repl_wait_ack */
struct ack_args {
replicator_t *r;
void *ep;
uint64_t lsn;
int delay_ms;
};
static void* ack_sender_thread(void* arg) {
struct ack_args* a = arg;
struct timespec ts = { 0, (long)a->delay_ms * 1000000L };
nanosleep(&ts, NULL);
repl_on_ack(a->r, a->ep, a->lsn);
return NULL;
}
static void test_wait_ack_woken_by_background_ack(void) {
replicator_t r;
int dummy_ep = 0;
repl_init(&r);
repl_add_standby(&r, (void*)&dummy_ep, "sb", 7001);
struct ack_args args = { &r, (void*)&dummy_ep, 15, 30 /* 30ms delay */ };
pthread_t th;
pthread_create(&th, NULL, ack_sender_thread, &args);
/* Wait for LSN 15 with 200ms timeout — ACK arrives after 30ms */
int rc = repl_wait_ack(&r, 15, 200);
pthread_join(th, NULL);
TEST_ASSERT_EQUAL_INT(0, rc);
repl_fini(&r);
printf("test_wait_ack_woken_by_background_ack: PASS\n");
}
void test_repl_unit(void) {
UNITY_BEGIN();
RUN_TEST(test_repl_init_zeroes_nstandbys);
RUN_TEST(test_repl_add_remove_standby);
RUN_TEST(test_repl_add_standby_max);
RUN_TEST(test_repl_trim_lsn_no_standbys);
RUN_TEST(test_repl_trim_lsn_disconnected_slot_expiry);
RUN_TEST(test_wait_ack_no_standbys_returns_enodev);
RUN_TEST(test_wait_ack_already_acked);
RUN_TEST(test_wait_ack_timeout);
RUN_TEST(test_wait_ack_woken_by_background_ack);
UNITY_END();
}

View file

@ -1,7 +1,12 @@
#include <errno.h>
#include <stdbool.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include "cluster.h"
#include "controller.capnp.h"
#include "persist.h"
#include "wobj.h"
#include "worker.h"
#include "xce/cluster_def.h"
@ -519,6 +524,8 @@ int mark_worker_unavail(cluster_t* c, worker_t* w)
HOOK_DETAIL_WORKER_DEAD, c, w);
w->host.status |= XCE_WORKER_UNAVAIL;
if (w->host.dead_since == 0)
w->host.dead_since = time(NULL);
xce_run_hooks(&(c->hooksys), XCE_CONTROLLER_HOOK_WORKER, HOOK_AFTER_EVENT,
HOOK_DETAIL_WORKER_DEAD, c, w);
@ -646,3 +653,41 @@ int do_hostcontrol_op(cluster_t* c, endpoint_t* ep, xce_request_t* request)
xce_reply(ep, c->myhost, &reply);
return rc;
}
/* Reconstruct a worker from a WAL_WORKER_CTRL Cap'n Proto payload.
* Payload layout: [uint32_t detail][Cap'n Proto xce_worker_t].
* Returns -EEXIST if the worker is already registered (idempotent replay). */
int worker_from_capnp(cluster_t* c, const void* capnp_payload, size_t len)
{
if (!capnp_payload || len <= sizeof(uint32_t))
return -EINVAL;
/* Skip the 4-byte detail discriminator written by persist_worker_hook_fun */
const uint8_t* data = (const uint8_t*)capnp_payload + sizeof(uint32_t);
size_t data_len = len - sizeof(uint32_t);
struct capn c_ctx;
capn_init_mem(&c_ctx, data, data_len, 0);
Worker_ptr wp;
wp.p = capn_getp(capn_root(&c_ctx), 0, 1);
struct Worker cw;
xce_worker_t host;
memset(&host, 0, sizeof(host));
read_Worker(&cw, wp);
decode_Worker(&host, &cw);
capn_free(&c_ctx);
/* Idempotency guard: skip if already present */
worker_t* existing = NULL;
lock(c->workermgr, RTTI_LOCK_READ);
int found = key_find(c->workermgr, (void*)host.name, (void**)&existing);
unlock(c->workermgr);
if (found > 0 && existing != NULL)
return -EEXIST;
return restore_worker(c, host);
}
/* worker_should_retain lives in persist.c —
* it only depends on xce_worker_t fields and persist_config_t,
* and must be accessible from the unit test binary which links persist.c. */

File diff suppressed because it is too large Load diff

View file

@ -76,6 +76,30 @@ var idCtxOnce sync.Once
// Must be set via StartNotificationListener before RegisterToBackend.
var gHandler NotificationHandler
func tryConnect(host string, port int, from, key string) error {
cAddr := C.CString(host)
cFrom := C.CString(from)
cKey := C.CString(key)
defer func() {
C.free(unsafe.Pointer(cAddr))
C.free(unsafe.Pointer(cFrom))
C.free(unsafe.Pointer(cKey))
}()
var cConfig C.xce_client_config_t
cConfig.nmasters = 1
cConfig.masters[0] = cAddr
cConfig.master_ports[0] = C.int(port)
cConfig.from = cFrom
cConfig.key = cKey
rc := C.xce_backend_init(&cConfig, &gCtx)
if rc < 0 {
return fmt.Errorf("xce_backend_init failed on %s:%d: %d", host, port, int(rc))
}
return nil
}
// RegisterToBackend initialises the XCE client library and joins as an API
// server. StartNotificationListener should be called first so that push
// events have a handler ready before the first push can arrive.
@ -89,27 +113,31 @@ func RegisterToBackend(config *BackendConfig) error {
key = "keepsecret$"
}
cAddr := C.CString(config.Address)
cFrom := C.CString(from)
cKey := C.CString(key)
defer func() {
C.free(unsafe.Pointer(cAddr))
C.free(unsafe.Pointer(cFrom))
C.free(unsafe.Pointer(cKey))
}()
var cConfig C.xce_client_config_t
cConfig.nmasters = 1
cConfig.masters[0] = cAddr
cConfig.master_ports[0] = C.int(config.Port)
cConfig.from = cFrom
cConfig.key = cKey
rc := C.xce_backend_init(&cConfig, &gCtx)
if rc < 0 {
return fmt.Errorf("xce_backend_init failed: %d", int(rc))
if len(config.Peers) > 0 {
var lastErr error
for _, peer := range config.Peers {
host, port, err := parsePeer(peer)
if err != nil {
logger.Warn("Skipping invalid peer", zap.String("peer", peer), zap.Error(err))
lastErr = err
continue
}
if err := tryConnect(host, port, from, key); err != nil {
logger.Warn("Candidate unreachable, trying next",
zap.String("host", host), zap.Int("port", port), zap.Error(err))
lastErr = err
continue
}
logger.Info("Backend connection established (HA)",
zap.String("host", host), zap.Int("port", port))
return nil
}
return fmt.Errorf("all HA candidates failed: %w", lastErr)
}
if err := tryConnect(config.Address, config.Port, from, key); err != nil {
return err
}
logger.Info("Backend connection established",
zap.String("address", config.Address),
zap.Int("port", config.Port))

View file

@ -7,4 +7,10 @@ type BackendConfig struct {
Port int // controller port (e.g. 9990)
From string // identity sent to controller (default: "xce_apiserver")
Key string // authentication key
// Peers lists additional controller candidates for HA mode in "host:port"
// format. When non-empty, RegisterToBackend tries each candidate in order
// and connects to whichever accepts the join as primary. Address+Port
// serve as a single-host fallback when Peers is empty.
Peers []string
}

View file

@ -3,7 +3,6 @@ package backend
import (
"apiserver/models"
"encoding/json"
"time"
)
// JobID bit layout constants — mirrors src/include/config.h.
@ -179,18 +178,10 @@ func rawJobToModel(r rawJobData) *models.JobInfo {
info.ExecUser = si.execUser
}
}
if r.subTime > 0 {
info.SubmitTime = time.Unix(r.subTime, 0)
}
if r.queueTime > 0 {
info.QueueTime = time.Unix(r.queueTime, 0)
}
if r.startTime > 0 {
info.StartTime = time.Unix(r.startTime, 0)
}
if r.finishTime > 0 {
info.EndTime = time.Unix(r.finishTime, 0)
}
info.SubmitTime = r.subTime
info.QueueTime = r.queueTime
info.StartTime = r.startTime
info.EndTime = r.finishTime
if len(r.execHosts) > 0 {
type hostSlot struct {
HostName string `json:"host_name"`

View file

@ -3,7 +3,6 @@ package backend
import (
"apiserver/models"
"testing"
"time"
)
func TestRawJobToModel_BasicFields(t *testing.T) {
@ -81,17 +80,17 @@ func TestRawJobToModel_Timestamps(t *testing.T) {
info := rawJobToModel(r)
if info.SubmitTime != time.Unix(1700000000, 0) {
t.Errorf("SubmitTime mismatch")
if info.SubmitTime != 1700000000 {
t.Errorf("SubmitTime: got %d, want 1700000000", info.SubmitTime)
}
if info.QueueTime != time.Unix(1700000010, 0) {
t.Errorf("QueueTime mismatch")
if info.QueueTime != 1700000010 {
t.Errorf("QueueTime: got %d, want 1700000010", info.QueueTime)
}
if info.StartTime != time.Unix(1700000020, 0) {
t.Errorf("StartTime mismatch")
if info.StartTime != 1700000020 {
t.Errorf("StartTime: got %d, want 1700000020", info.StartTime)
}
if info.EndTime != time.Unix(1700000030, 0) {
t.Errorf("EndTime mismatch")
if info.EndTime != 1700000030 {
t.Errorf("EndTime: got %d, want 1700000030", info.EndTime)
}
}

View file

@ -0,0 +1,24 @@
package backend
import (
"fmt"
"net"
"strconv"
)
// parsePeer splits a "host:port" string. Uses net.SplitHostPort so IPv6
// bracket notation (e.g. "[::1]:9990") is handled correctly.
func parsePeer(peer string) (host string, port int, err error) {
h, portStr, err := net.SplitHostPort(peer)
if err != nil {
return "", 0, fmt.Errorf("invalid peer %q: %w", peer, err)
}
if h == "" {
return "", 0, fmt.Errorf("invalid peer %q: empty host", peer)
}
port, err = strconv.Atoi(portStr)
if err != nil || port <= 0 || port > 65535 {
return "", 0, fmt.Errorf("invalid port in peer %q", peer)
}
return h, port, nil
}

View file

@ -0,0 +1,95 @@
package backend
import "testing"
func TestParsePeer(t *testing.T) {
tests := []struct {
name string
peer string
wantHost string
wantPort int
wantError bool
}{
{
name: "valid simple host:port",
peer: "ctrl01:9990",
wantHost: "ctrl01",
wantPort: 9990,
},
{
name: "valid IPv4",
peer: "192.168.1.10:9990",
wantHost: "192.168.1.10",
wantPort: 9990,
},
{
name: "valid FQDN",
peer: "ctrl01.cluster.local:9990",
wantHost: "ctrl01.cluster.local",
wantPort: 9990,
},
{
name: "min valid port",
peer: "host:1",
wantHost: "host",
wantPort: 1,
},
{
name: "max valid port",
peer: "host:65535",
wantHost: "host",
wantPort: 65535,
},
{
name: "missing colon",
peer: "ctrl019990",
wantError: true,
},
{
name: "port zero",
peer: "ctrl01:0",
wantError: true,
},
{
name: "port out of range",
peer: "ctrl01:65536",
wantError: true,
},
{
name: "non-numeric port",
peer: "ctrl01:abc",
wantError: true,
},
{
name: "colon at start (empty host)",
peer: ":9990",
wantError: true,
},
{
name: "empty string",
peer: "",
wantError: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
host, port, err := parsePeer(tc.peer)
if tc.wantError {
if err == nil {
t.Errorf("parsePeer(%q): expected error, got host=%q port=%d", tc.peer, host, port)
}
return
}
if err != nil {
t.Fatalf("parsePeer(%q): unexpected error: %v", tc.peer, err)
}
if host != tc.wantHost {
t.Errorf("host: got %q, want %q", host, tc.wantHost)
}
if port != tc.wantPort {
t.Errorf("port: got %d, want %d", port, tc.wantPort)
}
})
}
}

View file

@ -29,4 +29,31 @@ func TestEvalSelectForHost_StubErrNoCGO(t *testing.T) {
func TestCleanup_StubNoOp(t *testing.T) {
// Should not panic and completes immediately
Cleanup()
}
// TestRegisterToBackend_StubSingleHost verifies the stub returns errNoCGO
// for a single-host BackendConfig (no Peers).
func TestRegisterToBackend_StubSingleHost(t *testing.T) {
err := RegisterToBackend(&BackendConfig{
Address: "ctrl01",
Port: 9990,
From: "xce_apiserver",
Key: "keepsecret$",
})
if err != errNoCGO {
t.Errorf("expected errNoCGO, got %v", err)
}
}
// TestRegisterToBackend_StubWithPeers verifies the stub returns errNoCGO
// even when Peers are configured (HA mode), since CGO is not available.
func TestRegisterToBackend_StubWithPeers(t *testing.T) {
err := RegisterToBackend(&BackendConfig{
Peers: []string{"ctrl01:9990", "ctrl02:9990"},
From: "xce_apiserver",
Key: "keepsecret$",
})
if err != errNoCGO {
t.Errorf("expected errNoCGO with peers configured, got %v", err)
}
}

View file

@ -66,6 +66,7 @@ func main() {
Port: utils.BackendYamlConfig.Port,
From: utils.BackendYamlConfig.From,
Key: utils.BackendYamlConfig.Key,
Peers: utils.BackendYamlConfig.Peers,
}
if err := backend.RegisterToBackend(bcfg); err != nil {
logger.Error("Failed to connect to backend", zap.Error(err))

View file

@ -1,7 +1,5 @@
package models
import "time"
const (
CONTROL_JOB = 1
CONTROL_HOST = 2
@ -11,9 +9,9 @@ const (
)
type ActionControl struct {
TargetType int `json:"target_type"`
ActionType int `json:"action_type"`
Content Jsonb `json:"content"`
SubmitTime time.Time `json:"submit_time"`
Submitter string `json:"submitter"`
TargetType int `json:"target_type"`
ActionType int `json:"action_type"`
Content Jsonb `json:"content"`
SubmitTime int64 `json:"submit_time"`
Submitter string `json:"submitter"`
}

View file

@ -61,18 +61,19 @@ var JobSignalMapTable = map[string]int{
}
var JOBSTAT = map[string]int{
"PEND": 0x01,
"RUN": 0x02,
"EXIT": 0x04,
"DONE": 0x08,
"PSUSP": 0x10,
"USUSP": 0x20,
"SSUSP": 0x40,
"PDONE": 0x80,
"PERR": 0x100,
"WAIT": 0x200,
"RUNKWN": 0x8000,
"UNKNOWN": 0x10000, // C JOB_STAT_UNKWN: worker lost contact with controller
"PEND": 0x01,
"RUN": 0x02,
"EXIT": 0x04,
"DONE": 0x08,
"PSUSP": 0x10,
"USUSP": 0x20,
"SSUSP": 0x40,
"PDONE": 0x80,
"PERR": 0x100,
"WAIT": 0x200,
"TRANSIENT": 0x400, // C JOB_STAT_TRANSIENT: job state uncertain after failover
"RUNKWN": 0x8000,
"UNKNOWN": 0x10000, // C JOB_STAT_UNKWN: worker lost contact with controller
}
var LAUNCHTYPE = map[string]int{
@ -81,7 +82,10 @@ var LAUNCHTYPE = map[string]int{
}
func JobStateStr(state int) string {
if (state & JOBSTAT["PEND"]) != 0 {
// Check TRANSIENT first — it takes priority after failover
if (state & JOBSTAT["TRANSIENT"]) != 0 {
return "TRANSIENT"
} else if (state & JOBSTAT["PEND"]) != 0 {
if (state & JOBSTAT["PSUSP"]) != 0 {
return "PSUSP"
}
@ -485,7 +489,7 @@ type JobInfo struct {
ArrayIdx int `json:"array_idx"` //new
SubmitUser string `json:"submit_user"`
FromHost string `json:"from_host"`
SubmitTime time.Time `json:"submit_time"`
SubmitTime int64 `json:"submit_time"`
JobStatus int `json:"job_status"`
JobName string `json:"job_name"`
Command string `json:"command"`
@ -531,10 +535,10 @@ type JobInfo struct {
Tags Jsonb `json:"tags"` // new, JSONB as []byte
EffectiveResReq string `json:"effective_resreq"` //new
EffectiveUserGroup string `json:"effective_user_group"` //new
QueueTime time.Time `json:"queue_time"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
ReserveTime time.Time `json:"reserve_time"`
QueueTime int64 `json:"queue_time"`
StartTime int64 `json:"start_time"`
EndTime int64 `json:"end_time"`
ReserveTime int64 `json:"reserve_time"`
Duration int `json:"duration"`
CPUTime int `json:"cpu_time"`
ExitStatus Jsonb `json:"exit_status"` //change type, JSONB as []byte
@ -585,12 +589,12 @@ type JobAdditionalFields struct {
JobID int64 `json:"job_id"`
SubmitUser string `json:"submit_user"`
JobStatus int `json:"job_status"`
SubmitTime time.Time `json:"submit_time"`
FromHost string `json:"from_host"`
ExecHosts string `json:"exec_hosts"`
SubmitTime int64 `json:"submit_time"`
FromHost string `json:"from_host"`
ExecHosts string `json:"exec_hosts"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
StartTime int64 `json:"start_time"`
EndTime int64 `json:"end_time"`
JobRusage Jsonb `json:"job_rusage"`
MaxMem float64 `json:"max_mem"`
AvgMem float64 `json:"avg_mem"`

View file

@ -58,6 +58,16 @@ func TestJobStateStr(t *testing.T) {
state: 0,
want: "ERR",
},
{
name: "TRANSIENT (0x400 — job uncertain after failover)",
state: 0x400,
want: "TRANSIENT",
},
{
name: "TRANSIENT with other bits set",
state: 0x400 | 0x01, // TRANSIENT + PEND
want: "TRANSIENT",
},
}
for _, tt := range tests {
@ -73,18 +83,19 @@ func TestJobStateStr(t *testing.T) {
func TestJobStatMapValues(t *testing.T) {
// Verify bitmask values match C JOB_STAT_* defines in src/include/xce/types.h.
expected := map[string]int{
"PEND": 0x01,
"RUN": 0x02,
"EXIT": 0x04,
"DONE": 0x08,
"PSUSP": 0x10,
"USUSP": 0x20,
"SSUSP": 0x40,
"PDONE": 0x80,
"PERR": 0x100,
"WAIT": 0x200,
"RUNKWN": 0x8000,
"UNKNOWN": 0x10000,
"PEND": 0x01,
"RUN": 0x02,
"EXIT": 0x04,
"DONE": 0x08,
"PSUSP": 0x10,
"USUSP": 0x20,
"SSUSP": 0x40,
"PDONE": 0x80,
"PERR": 0x100,
"WAIT": 0x200,
"TRANSIENT": 0x400,
"RUNKWN": 0x8000,
"UNKNOWN": 0x10000,
}
for key, want := range expected {
got, ok := JOBSTAT[key]

View file

@ -13,10 +13,15 @@ import (
)
type BackendConfig struct {
Address string `yaml:"address"`
Port int `yaml:"port"`
From string `yaml:"from"`
Key string `yaml:"key"`
Address string `yaml:"address"`
Port int `yaml:"port"`
From string `yaml:"from"`
Key string `yaml:"key"`
// Peers lists additional controller candidates for HA mode in "host:port"
// format. When non-empty, RegisterToBackend tries each candidate in order
// and connects to whichever accepts the join as primary. Address+Port
// serve as a single-host fallback when Peers is empty.
Peers []string `yaml:"peers"`
}
type AppServerConfig struct {
@ -250,6 +255,7 @@ func InitConf() error {
Port: viper.GetInt("backend.port"),
From: viper.GetString("backend.from"),
Key: viper.GetString("backend.key"),
Peers: viper.GetStringSlice("backend.peers"),
}
return nil

View file

@ -4,7 +4,6 @@ import (
"encoding/json"
"fmt"
"strings"
"time"
jobfmt "xcecli/common/format"
"xcecli/models"
)
@ -96,7 +95,7 @@ func (p *ArrayElementsPagePrinter) initColumnWidths(jobs *models.JobsResponse) {
case models.HEADER_JOB_NAME:
width = len(job.JobName)
case models.HEADER_JOB_SUBMIT_TIME:
width = len(job.SubmitTime)
width = len(fmtUnixTime(job.SubmitTime))
}
if !p.wideFormat && width > 15 {
@ -166,7 +165,7 @@ func (p *ArrayElementsPagePrinter) formatRows(jobs *models.JobsResponse, truncat
mainRow = appendOrDefault(mainRow, FormatStringField(job.FromHost, width, truncatFlag, true), "-")
case models.HEADER_JOB_EXEC_HOST:
var execHosts models.ExecHosts
if err := json.Unmarshal([]byte(job.ExecHosts.String()), &execHosts); err == nil && len(execHosts.HostSlots) > 0 {
if err := json.Unmarshal([]byte(job.ExecHosts.String()), &execHosts); err == nil && len(execHosts.HostSlots) > 0 && execHosts.HostSlots[0].HostName != "" {
firstHost := fmt.Sprintf("%d*%s", execHosts.HostSlots[0].NumSlots, execHosts.HostSlots[0].HostName)
mainRow = appendOrDefault(mainRow, FormatStringField(firstHost, width, truncatFlag, true), "-")
@ -190,21 +189,7 @@ func (p *ArrayElementsPagePrinter) formatRows(jobs *models.JobsResponse, truncat
case models.HEADER_JOB_NAME:
mainRow = appendOrDefault(mainRow, FormatStringField(job.JobName, width, truncatFlag, true), "-")
case models.HEADER_JOB_SUBMIT_TIME:
// remove the timezone part and parse the time
timeStr := strings.Split(job.SubmitTime, " ")[0:2]
timeStrWithoutTZ := strings.Join(timeStr, " ")
if submitTime, err := time.Parse("2006-01-02 15:04:05", timeStrWithoutTZ); err == nil {
month := submitTime.Format("Jan")
displayTime := fmt.Sprintf("%s %02d %02d:%02d",
month,
submitTime.Day(),
submitTime.Hour(),
submitTime.Minute())
mainRow = appendOrDefault(mainRow, FormatStringField(displayTime, width, truncatFlag, true), "-")
} else {
mainRow = appendOrDefault(mainRow, fmt.Sprintf("%"+align+"*s", width, "-"), "-")
}
mainRow = appendOrDefault(mainRow, FormatStringField(fmtUnixTime(job.SubmitTime), width, truncatFlag, true), "-")
}
}
rows = append(rows, mainRow)

View file

@ -98,7 +98,7 @@ func (p *JobsPagePrinter) initColumnWidths(jobs *models.JobsResponse) {
case models.HEADER_JOB_NAME:
width = len(job.JobName)
case models.HEADER_JOB_SUBMIT_TIME:
width = len(job.SubmitTime)
width = len(fmtUnixTime(job.SubmitTime))
case models.HEADER_JOB_CORE_LIMIT:
width = len(fmt.Sprintf("%f", job.CoreLimit))
case models.HEADER_JOB_MEM_LIMIT:
@ -179,7 +179,7 @@ func (p *JobsPagePrinter) formatRows(jobs *models.JobsResponse, truncatFlag bool
case models.HEADER_JOB_EXEC_HOST:
var execHosts models.ExecHosts
if err := json.Unmarshal([]byte(job.ExecHosts.String()), &execHosts); err == nil && len(execHosts.HostSlots) > 0 {
if err := json.Unmarshal([]byte(job.ExecHosts.String()), &execHosts); err == nil && len(execHosts.HostSlots) > 0 && execHosts.HostSlots[0].HostName != "" {
firstHost := fmt.Sprintf("%d*%s", execHosts.HostSlots[0].NumSlots, execHosts.HostSlots[0].HostName)
mainRow = appendOrDefault(mainRow,
FormatStringField(firstHost, width, truncatFlag, false), "-")
@ -201,11 +201,7 @@ func (p *JobsPagePrinter) formatRows(jobs *models.JobsResponse, truncatFlag bool
mainRow = appendOrDefault(mainRow, fmt.Sprintf("%"+align+"*s", width, "-"), "-")
}
case models.HEADER_JOB_SUBMIT_TIME:
if submitTime, err := parseJobTime(job.SubmitTime); err == nil {
mainRow = appendOrDefault(mainRow, FormatStringField(formatJobTime(submitTime), width, truncatFlag, true), "-")
} else {
mainRow = appendOrDefault(mainRow, fmt.Sprintf("%"+align+"*s", width, "-"), "-")
}
mainRow = appendOrDefault(mainRow, FormatStringField(fmtUnixTime(job.SubmitTime), width, truncatFlag, true), "-")
// normal fields
case models.HEADER_JOB_NAME:
@ -223,9 +219,9 @@ func (p *JobsPagePrinter) formatRows(jobs *models.JobsResponse, truncatFlag bool
case models.HEADER_JOB_PROJECT_NAME:
mainRow = appendOrDefault(mainRow, FormatStringField(job.ProjectName, width, truncatFlag, true), "-")
case models.HEADER_JOB_START_TIME:
mainRow = appendOrDefault(mainRow, FormatStringField(job.StartTime, width, truncatFlag, true), "-")
mainRow = appendOrDefault(mainRow, FormatStringField(fmtUnixTime(job.StartTime), width, truncatFlag, true), "-")
case models.HEADER_JOB_END_TIME:
mainRow = appendOrDefault(mainRow, FormatStringField(job.EndTime, width, truncatFlag, true), "-")
mainRow = appendOrDefault(mainRow, FormatStringField(fmtUnixTime(job.EndTime), width, truncatFlag, true), "-")
case models.HEADER_JOB_CWD:
mainRow = appendOrDefault(mainRow, FormatStringField(job.CWD, width, truncatFlag, true), "-")
case models.HEADER_JOB_LAUNCH_TYPE:
@ -303,7 +299,7 @@ func printJobsVerboseOutput(jobs *models.JobsResponse, filter []string) {
// (2) print the submit time info
submitLine := ""
if submitTime, err := parseJobTime(job.SubmitTime); err == nil {
if submitTime, ok := unixToTime(job.SubmitTime); ok {
submitLine = submitLine + fmt.Sprintf("%s %s %02d:%02d:%02d: ",
submitTime.Format("Jan"), submitTime.Format("02"),
submitTime.Hour(), submitTime.Minute(), submitTime.Second())
@ -322,9 +318,20 @@ func printJobsVerboseOutput(jobs *models.JobsResponse, filter []string) {
}
PrintWrappedLine(w, submitLine, 80, 18)
// (3) print the start time info
// (3) print the pending reason for jobs waiting to be dispatched
if job.JobStatus == "PEND" || job.JobStatus == "PSUSP" {
if job.PendingReason != "" {
fmt.Fprintf(w, " PENDING REASONS:\n")
fmt.Fprintf(w, " %s\n", job.PendingReason)
}
}
// (4) print the start time info — skip for jobs that haven't started yet
startLine := ""
if startTime, err := parseJobTime(job.StartTime); err == nil {
if job.JobStatus == "PEND" || job.JobStatus == "PSUSP" {
goto printEndLine
}
if startTime, ok := unixToTime(job.StartTime); ok {
month := startTime.Format("Jan")
day := startTime.Format("02")
exec_hostname := ""
@ -380,10 +387,11 @@ func printJobsVerboseOutput(jobs *models.JobsResponse, filter []string) {
}
PrintWrappedLine(w, startLine, 80, 18)
printEndLine:
// (4) print end line
endLine := ""
if job.JobStatus == "DONE" || job.JobStatus == "EXIT" {
if endTime, err := parseJobTime(job.EndTime); err == nil {
if endTime, ok := unixToTime(job.EndTime); ok {
endLine = endLine + fmt.Sprintf("%s %s %02d:%02d:%02d: ",
endTime.Format("Jan"), endTime.Format("02"),
endTime.Hour(), endTime.Minute(), endTime.Second())

View file

@ -5,17 +5,23 @@ import (
"time"
)
// parseJobTime parses a job time string supporting RFC3339 and space-separated formats.
func parseJobTime(s string) (time.Time, error) {
if s == "" {
return time.Time{}, fmt.Errorf("empty time string")
// unixToTime converts a Unix-second timestamp to time.Time.
// Returns (time, true) for ts > 0; (zero, false) for unset/zero values.
func unixToTime(ts int64) (time.Time, bool) {
if ts <= 0 {
return time.Time{}, false
}
for _, f := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04:05", "2006-01-02 15:04:05 MST"} {
if t, err := time.Parse(f, s); err == nil {
return t, nil
}
return time.Unix(ts, 0), true
}
// fmtUnixTime formats a Unix timestamp as "Jan 02 15:04" for table display.
// Returns "-" for zero/unset values.
func fmtUnixTime(ts int64) string {
t, ok := unixToTime(ts)
if !ok {
return "-"
}
return time.Time{}, fmt.Errorf("cannot parse time: %q", s)
return formatJobTime(t)
}
// formatJobTime formats a time.Time as "Jan 02 15:04" for table display.

View file

@ -3,8 +3,6 @@ package models
import (
"encoding/json"
"fmt"
"time"
)
const (
@ -32,6 +30,7 @@ const (
HEADER_JOB_LAUNCH_TYPE = "LAUNCH_TYPE"
HEADER_JOB_NUM_PROCESSORS = "NUM_PROCESSORS"
HEADER_JOB_INTERACTIVE_URL = "INTERACTIVE_URL"
HEADER_JOB_PENDING_REASON = "PENDING_REASON"
// second line
HEADER_JOB_R15S = "r15s"
@ -114,10 +113,10 @@ type QueryJob struct {
TermTime string `json:"term_time"`
UserPriority int `json:"user_priority"`
DependCond string `json:"depend_cond"`
SubmitTime string `json:"submit_time"`
ReserveTime time.Time `json:"reserve_time"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
SubmitTime int64 `json:"submit_time"`
ReserveTime int64 `json:"reserve_time"`
StartTime int64 `json:"start_time"`
EndTime int64 `json:"end_time"`
JobRusage string `json:"job_rusage"`
MaxMem float64 `json:"max_mem"`
AvgMem float64 `json:"avg_mem"`
@ -210,6 +209,7 @@ var JobFieldsMap = map[string]string{
HEADER_JOB_LAUNCH_TYPE: "launch_type",
HEADER_JOB_NUM_PROCESSORS: "num_processors",
HEADER_JOB_INTERACTIVE_URL: "interactive_job_url",
HEADER_JOB_PENDING_REASON: "pending_reason",
//job_filter.HEADER_JOB_PRIORITY: "job_priority",
}
@ -258,16 +258,23 @@ func (e ExecHosts) String() string {
// UnmarshalJSON customizes the JSON parsing method, compatible with both string "-" and structure formats
func (e *ExecHosts) UnmarshalJSON(data []byte) error {
// JSON null means no allocation — treat as empty
if string(data) == "null" {
e.HostSlots = []HostSlot{}
e.NumHosts = 0
return nil
}
// First try to parse as a string
var str string
if err := json.Unmarshal(data, &str); err == nil {
// If the string is "-", return an empty ExecHosts
if str == "-" {
// Empty string or "-" both mean no hosts
if str == "" || str == "-" {
e.HostSlots = []HostSlot{}
e.NumHosts = 0
return nil
}
// If it's any other string, treat it as a single hostname
// Any other non-empty string is a single hostname
e.HostSlots = []HostSlot{{HostName: str, NumSlots: 1}}
e.NumHosts = 1
return nil

View file

@ -71,4 +71,26 @@ typedef uint64_t jobid_t;
#define REGEX_JOBARRAY_ID \
"[ \t]*([0-9]+)-([0-9]+)-([0-9]+)-([0-9]+)\\[([0-9]+)\\][ \t]*"
/* ======================================================
* Persistence & HA system constants (Part 2.6, Appendix A.10)
* ====================================================== */
#define MAX_STANDBYS 3
#define MAX_HA_CANDIDATES 4
#define WAL_FORMAT_VERSION 1
#define WAL_ENTRY_TYPE_MAX 100 /* WAL_CHECKPOINT = 100, highest known type */
#define MAX_WAL_PAYLOAD_BYTES (1 << 20) /* 1 MB per replication entry */
#define MAX_SNAPSHOT_BYTES (256 * 1024 * 1024) /* 256 MB max snapshot size */
#define WAL_HDR_CRC_SIZE 32 /* sizeof(wal_entry_hdr_t) - 2*sizeof(uint32_t) */
#define WAL_CRC_NONE 0xFFFFFFFFu /* sentinel: no predecessor in chain */
#define DEFAULT_WAL_SYNC_MS 10
#define DEFAULT_SNAP_INTERVAL_S 60
#define DEFAULT_SNAP_WAL_SIZE_MB 64
#define DEFAULT_SNAP_KEEP_N 2
#define DEFAULT_TRANSIENT_JOB_TIMEOUT_S 300
#define DEFAULT_FINISHED_RETAIN_S 3600
#define DEFAULT_WORKER_RETAIN_S 86400
#define DEFAULT_SYNC_TIMEOUT_MS 50
#define DEFAULT_HB_INTERVAL_MS 3000
#endif

View file

@ -0,0 +1,47 @@
#pragma once
/*
* ctrl_metrics.h — P3-7: Operational metrics for the XCE controller.
*
* All metrics use C11 _Atomic uint64_t for lock-free, thread-safe access.
* Metric names use the ctrl_ prefix.
*
* Design doc: Part P3-7, review A8-007.
*/
#include <stdatomic.h>
#include <stdint.h>
/* ── Counter/gauge globals ──────────────────────────────────────────── */
extern _Atomic uint64_t ctrl_repl_degraded_count; /* counter */
extern _Atomic uint64_t ctrl_split_brain_detected; /* counter */
extern _Atomic uint64_t ctrl_async_events_lost; /* counter */
extern _Atomic uint64_t ctrl_transient_jobs_stuck; /* counter */
extern _Atomic uint64_t ctrl_wal_bytes; /* gauge */
extern _Atomic uint64_t ctrl_checkpoint_duration_ms; /* gauge */
extern _Atomic uint64_t ctrl_repl_lag_lsn; /* gauge */
/* ── Macros ─────────────────────────────────────────────────────────── */
/* Increment a monotonic counter by 1 (relaxed ordering — counters are
* sampled, not used for synchronization). */
#define CTRL_METRIC_INC(name) \
atomic_fetch_add_explicit(&(name), 1, memory_order_relaxed)
/* Overwrite a gauge with a new absolute value. */
#define CTRL_METRIC_SET(name, val) \
atomic_store_explicit(&(name), (uint64_t)(val), memory_order_relaxed)
/* ── Descriptor table for metric discovery ──────────────────────────── */
typedef struct {
const char* name;
_Atomic uint64_t* value;
const char* description;
int is_counter; /* 1 = counter (monotonic), 0 = gauge */
} ctrl_metric_desc_t;
/* Terminated by a { NULL, NULL, NULL, 0 } sentinel entry. */
extern const ctrl_metric_desc_t ctrl_metric_descriptors[];
/* ── Lifecycle ──────────────────────────────────────────────────────── */
/* Reset all metrics to zero. Call from persist_start() and ha_init(). */
void ctrl_metrics_init(void);

View file

@ -23,7 +23,7 @@ typedef struct {
typedef struct {
pthread_t worker;
int running;
int precision;
int precision_ms;
task_run_fn_t runner_fn;
timer_t timerid;
ordq_t tasks;

View file

@ -3,6 +3,7 @@
#define __HOOK_H__ 1
#include <stdarg.h>
#include <stdatomic.h>
#include <stddef.h>
#include "xlist.h"
@ -12,8 +13,8 @@
typedef int (*hook_fn_t)(int, int, int, void*, va_list);
typedef struct {
hook_fn_t fn;
void* data;
_Atomic hook_fn_t fn;
void* data;
} hook_item_t;
typedef struct {
@ -23,6 +24,7 @@ typedef struct {
extern int xce_hook_init(hooksys_t*, int);
extern int xce_add_hook(hooksys_t*, int, hook_fn_t, void*);
extern int xce_disable_hook(hooksys_t*, int, hook_fn_t);
extern int xce_run_hooks(hooksys_t*, int, int, int, ...);
extern void xce_hook_fini(hooksys_t*);

View file

@ -30,4 +30,22 @@ extern kv_key_ops_t int_key_ops;
extern kv_key_ops_t string_key_ops;
extern int key_cmp_default(void*, void*, size_t);
/*
* kvtab_foreach — iterate every live entry in the kvtab (P0-14).
*
* For each entry, cb(key, index, arg) is called.
* - key : the raw key pointer stored in the entry (char* for
* string-keyed tables, (void*)(long)intval for int-keyed).
* - index : the slot index stored as the entry value.
* - arg : caller-supplied context.
*
* Iteration order is unspecified.
* Returns the number of entries visited, or -EINVAL if kvtab is NULL.
*/
typedef void (*kvtab_foreach_fn_t)(void* key, int index, void* arg);
extern int kvtab_foreach(var kvtab,
kvtab_foreach_fn_t cb,
void* arg);
#endif

View file

@ -7,6 +7,7 @@
#include "xce/types.h"
#include "xlist.h"
#include "zarray.h"
#include "persist.h"
typedef enum {
KVMGR_KEY_INT = 0,
@ -22,4 +23,22 @@ typedef struct {
extern typeinfo_t xkvmgr_type;
/*
* kvmgr_capture_keys — capture key→index mappings for checkpoint
* serialization (Part 3.8).
*
* Caller MUST hold the kvmgr write lock for the full duration of
* this call. The function allocates *keys_out and populates it with
* *n_keys_out entries. Each entry's key[] field is an owned deep
* copy that remains valid after the write lock is released.
*
* Caller must free(*keys_out) when done.
*
* Returns 0 on success.
* Returns -ENOMEM if allocation fails.
*/
int kvmgr_capture_keys(var kvmgr,
slot_key_t** keys_out,
size_t* n_keys_out);
#endif

View file

@ -7,6 +7,7 @@
#include <tlog.h>
#include "clusterconfig.h"
#include "xce/cluster.h"
struct logging {
char* logdir;
@ -14,10 +15,13 @@ struct logging {
};
typedef struct {
cluster_config_t* cluster_config;
char* conffile;
char* certfile;
char* keyfile;
cluster_config_t* cluster_config;
char* conffile;
char* certfile;
char* keyfile;
char* workdir; /* working directory for WAL/snapshot storage */
char* name; /* controller name (hostname by default) */
xce_cluster_config_t peers_cfg; /* HA peer list parsed from --peers; count==0 = single-node */
} myconf_t;
extern cluster_config_t* get_default_conf(void*);

152
src/include/persist.h Normal file
View file

@ -0,0 +1,152 @@
#pragma once
#include <pthread.h>
#include <stdarg.h>
#include <stdatomic.h>
#include <stddef.h>
#include <stdint.h>
#include "config.h"
#include "wal_types.h" /* wal_entry_hdr_t, wal_entry_type — P2-4: shared with repl.h */
#include "repl.h" /* replicator_t — P2-4: embedded in persist_t */
/* Forward declaration */
typedef struct cluster_s cluster_t;
/* ============================================================
* Persistence configuration (Part 2.6)
* ============================================================ */
typedef struct {
uint32_t wal_sync_ms; /* fsync interval in ms (default 10) */
uint32_t snap_interval_s; /* checkpoint interval in seconds (default 60) */
uint32_t snap_wal_size_mb; /* checkpoint when WAL exceeds this MB (default 64) */
uint32_t snap_keep_n; /* snapshots to retain (default 2) */
uint32_t transient_job_timeout_s; /* seconds before TRANSIENT job auto-terminates (default 300) */
uint32_t finished_retain_s; /* retain finished jobs N seconds (default 3600) */
uint32_t worker_retain_s; /* retain unavailable workers N seconds (default 86400) */
uint32_t sync_timeout_ms; /* sync replication ACK timeout ms (default 50) */
} persist_config_t;
static inline persist_config_t persist_config_defaults(void)
{
persist_config_t c;
c.wal_sync_ms = DEFAULT_WAL_SYNC_MS;
c.snap_interval_s = DEFAULT_SNAP_INTERVAL_S;
c.snap_wal_size_mb = DEFAULT_SNAP_WAL_SIZE_MB;
c.snap_keep_n = DEFAULT_SNAP_KEEP_N;
c.transient_job_timeout_s = DEFAULT_TRANSIENT_JOB_TIMEOUT_S;
c.finished_retain_s = DEFAULT_FINISHED_RETAIN_S;
c.worker_retain_s = DEFAULT_WORKER_RETAIN_S;
c.sync_timeout_ms = DEFAULT_SYNC_TIMEOUT_MS;
return c;
}
/* ============================================================
* Key→index mapping for checkpoint serialization (Part 3.8)
* ============================================================ */
typedef struct {
int index; /* shadow slot index in the frozen zarray */
char key[XCE_MAXNAME_LEN]; /* owned deep copy — not a borrowed kvtab pointer */
size_t key_len; /* length including null terminator */
} slot_key_t;
/* ============================================================
* persist_t — concrete definition (exposed for stack/calloc allocation)
* The opaque pattern can be restored in a later refactor once the
* full API surface is stable.
* ============================================================ */
struct persist_s {
/* Back-pointer to owning cluster (set by persist_init, used in persist_start) */
cluster_t* cluster;
/* WAL state */
int wal_fd;
uint64_t current_lsn;
uint32_t last_crc;
uint32_t last_replicated_crc;
uint64_t term;
uint32_t seg_offset;
uint64_t checkpoint_lsn;
uint64_t total_wal_bytes;
char seg_path[4096];
/* Paths (derived from workdir + name via persist_set_workdir) */
char wal_path[4096];
char snap_path[4096];
/* Config */
persist_config_t config;
/* Concurrency */
pthread_mutex_t lock;
atomic_int fsync_stop;
atomic_int fsync_thread_running;
pthread_t fsync_thread;
/* Checkpoint (stubs for now — wired in Task 10) */
atomic_int checkpoint_in_progress;
atomic_int checkpoint_requested;
pthread_cond_t checkpoint_done;
pthread_mutex_t checkpoint_mutex;
pthread_t snap_thread;
/* Fan-out replicator — P2-4 */
replicator_t repl;
};
typedef struct persist_s persist_t;
/* Set workdir and controller name before persist_init().
* Derives wal_path and snap_path as <workdir>/<name>/data/{wal,snap}. */
void persist_set_workdir(persist_t* p, const char* workdir, const char* name);
/* Lifecycle */
int persist_init(persist_t* p, persist_config_t* config, cluster_t* c);
int persist_start(persist_t* p);
void persist_stop(persist_t* p);
void persist_fini(persist_t* p);
int persist_recover(persist_t* p);
/* WAL write paths */
int persist_wal_async(persist_t* p, uint32_t type,
const void* payload, uint32_t len);
int persist_wal_sync_append(persist_t* p, uint32_t type,
const void* payload, uint32_t len,
int* sync_rc_out);
/* Force WAL fdatasync (for tests and clean shutdown) */
int persist_flush(persist_t* p);
/* Checkpoint */
int persist_checkpoint(persist_t* p);
/* Logging helper — never logs payload content (security invariant) */
void persist_log_entry_metadata(const wal_entry_hdr_t* hdr);
/* ============================================================
* AFTER_EVENT hook functions — registered by persist_start()
*
* Signature matches hook_fn_t: int fn(int type, int situation,
* int detail, void* data, va_list ap)
* cls/data points to the persist_t instance.
*
* P0: writes a minimal 4-byte payload (detail value) to the WAL.
* Full Cap'n Proto serialization is wired in Task 15.
* ============================================================ */
int persist_job_hook_fun(int type, int situation, int detail,
void* cls, va_list ap);
int persist_queue_hook_fun(int type, int situation, int detail,
void* cls, va_list ap);
int persist_worker_hook_fun(int type, int situation, int detail,
void* cls, va_list ap);
/* Apply a single WAL entry to in-memory state (standby replay path).
* No hooks fire — hook system is empty on standbys until promotion.
* Returns 0 on success (including skip for checkpoint/unknown types),
* negative errno on error (oversized payload, NULL arguments). */
int apply_wal_entry(persist_t* p, const wal_entry_hdr_t* hdr, const void* payload);
/* GC retention predicates (P0-5a / P0-8 / P0-13) */
#include <stdbool.h>
#include "xce/types.h"
bool job_is_terminal(const job_t* j);
bool job_should_retain(const job_t* j, const persist_config_t* cfg);
bool worker_should_retain(const xce_worker_t* w, const persist_config_t* cfg);

72
src/include/repl.h Normal file
View file

@ -0,0 +1,72 @@
#pragma once
/*
* repl.h — P2-1: Replicator structures and public API.
* Fan-out TCP replication via existing iomgr/SSL endpoint pipeline.
* ACKs processed in io_worker read callback (NOT vtaskmgr) to prevent deadlock.
*/
#include <pthread.h>
#include <stdatomic.h>
#include <stdint.h>
#include <time.h>
#include "config.h"
#include "mylock.h"
#include "wal_types.h" /* wal_entry_hdr_t — P2-4: use wal_types.h to break circular include */
/* Endpoint is an opaque pointer — full type in netio.h (avoid heavy include) */
typedef void* repl_ep_t;
/* Per-standby connection slot */
typedef struct {
repl_ep_t ep; /* NULL = slot empty */
_Atomic uint64_t acked_lsn; /* Last LSN ACKed by this standby */
_Atomic int in_catch_up; /* Non-zero while catching up */
uint64_t join_applied_lsn; /* LSN standby reported on reconnect */
char host[XCE_MAXNAME_LEN];
uint32_t port;
uint64_t last_disconnected_lsn;
time_t last_disconnect_time;
} standby_conn_t;
/* Replicator — embedded in persist_t or cluster_t (P2-4).
* Named tag replicator_s matches the forward declaration in persist.h. */
typedef struct replicator_s {
standby_conn_t standbys[MAX_STANDBYS];
int nstandbys; /* Active standby count */
mylock_t lock; /* Protects standbys[], nstandbys */
pthread_cond_t ack_cond; /* Signaled when any ACK arrives */
pthread_mutex_t ack_lock; /* Guards ack_cond */
} replicator_t;
/* Lifecycle */
int repl_init(replicator_t *r);
void repl_fini(replicator_t *r);
/* Standby management */
int repl_add_standby(replicator_t *r, repl_ep_t ep, const char *host, uint32_t port);
void repl_remove_standby(replicator_t *r, repl_ep_t ep);
void repl_expire_disconnect_slots(replicator_t *r, time_t now_s, uint32_t snap_interval_s);
/* Replication send (stub in P2-1; wired in P2-4) */
int repl_broadcast(replicator_t *r, const wal_entry_hdr_t *hdr,
const void *payload, uint32_t payload_len);
/* ACK handling — call from io_worker read callback ONLY */
void repl_on_ack(replicator_t *r, repl_ep_t ep, uint64_t acked_lsn);
/* Sync replication (stub in P2-1; wired in P2-3) */
int repl_wait_ack(replicator_t *r, uint64_t target_lsn, uint32_t timeout_ms);
/* WAL trim guard */
uint64_t repl_trim_lsn(replicator_t *r, uint64_t checkpoint_lsn, uint32_t snap_interval_s);
/* Catch-up state management */
/* Mark standby as entering catch-up mode; stores join_applied_lsn */
void repl_start_catchup(replicator_t *r, repl_ep_t ep, uint64_t join_applied_lsn);
/* Mark standby catch-up complete (called on OP_replCatchUpDone receipt) */
void repl_complete_catchup(replicator_t *r, repl_ep_t ep);
/* Returns 1 if standby needs a full snapshot transfer (its LSN predates oldest WAL).
* oldest_wal_lsn == 0 means no WAL exists — no snapshot needed for LSN 0 standbys. */
int repl_needs_snapshot(replicator_t *r, uint64_t standby_lsn, uint64_t oldest_wal_lsn);

View file

@ -200,4 +200,40 @@ typedef struct {
} body;
} xce_request_t;
/* ── Persistence Cap'n Proto mapped types (P0-9) ─────────────────────
* These are the C-side counterparts for the capnp encode/decode shims.
* Named with capnp_ prefix to avoid collision with the plain-C structs
* in persist.h (persist_config_t) and the on-disk binary formats. */
typedef struct {
uint32_t walSyncMs;
uint32_t snapIntervalS;
uint32_t snapWalSizeMb;
uint32_t finishedRetainS;
uint32_t workerRetainS;
uint32_t syncTimeoutMs;
uint32_t snapKeepN;
uint32_t transientJobTimeoutS;
} capnp_persist_cfg_t;
typedef struct {
uint64_t lsn;
uint64_t term;
uint64_t timestamp;
uint32_t version;
uint32_t nJobs;
uint32_t nQueues;
uint32_t nWorkers;
uint32_t checksum;
uint32_t headerChecksum;
} capnp_snap_hdr_t;
typedef struct {
uint64_t term;
uint64_t lsn;
uint32_t entryType;
xce_data_t* payload; /* owned; decoded via decode_Data_ptr */
uint32_t crc;
uint32_t prevCrc;
} capnp_repl_envelope_t;
#endif

58
src/include/wal_types.h Normal file
View file

@ -0,0 +1,58 @@
#pragma once
/*
* wal_types.h — WAL on-disk structures shared between persist.h and repl.h.
* Extracted in P2-4 to break the persist.h ↔ repl.h circular include.
*/
#include <stdint.h>
#include "config.h"
/* ── WAL payload helper structs ────────────────────────────────────────────
* These are the binary formats embedded after wal_entry_hdr_t for each type.
* All fields are native-endian (single-node WAL; replication uses capnp). */
/* WAL_JOB_START / FINISH / FAIL / STOP / RESUME / UPDATE / CKPT / PREASON / RUSAGE */
typedef struct {
uint64_t jobid;
uint32_t state; /* xce_job state flags after this transition */
uint32_t _pad;
} wal_jobstate_t;
/* WAL_QUEUE_CTRL — queue name is a NUL-terminated string immediately after
* this header. Total payload = sizeof(wal_queuehdr_t) + strlen(name)+1. */
typedef struct {
uint32_t status; /* xce_queue status flags after this transition */
} wal_queuehdr_t;
/* ============================================================
* WAL entry header — 40 bytes, 8-byte aligned (Part 2.2)
* ============================================================ */
typedef struct {
uint64_t term; /* Election term (incremented on failover) */
uint64_t lsn; /* Log sequence number within this term */
uint64_t timestamp; /* Epoch microseconds */
uint32_t type; /* wal_entry_type enum value */
uint32_t payload_len; /* Bytes of payload following this header */
uint32_t crc; /* CRC32(header[0..WAL_HDR_CRC_SIZE-1] + payload) */
uint32_t prev_crc; /* CRC of previous record; WAL_CRC_NONE for first in segment */
} wal_entry_hdr_t;
_Static_assert(sizeof(wal_entry_hdr_t) == 40,
"wal_entry_hdr_t must be 40 bytes for on-disk format stability");
/* ============================================================
* WAL entry types (Part 2.3)
* ============================================================ */
typedef enum {
WAL_JOB_SUBMIT = 1, /* SYNC replication */
WAL_JOB_START = 2,
WAL_JOB_FINISH = 3,
WAL_JOB_FAIL = 4,
WAL_JOB_STOP = 5,
WAL_JOB_RESUME = 6,
WAL_JOB_UPDATE = 7,
WAL_JOB_RUSAGE = 8,
WAL_JOB_CKPT = 9,
WAL_JOB_PREASON = 10,
WAL_QUEUE_CTRL = 20,
WAL_WORKER_CTRL = 30,
WAL_CHECKPOINT = 100, /* Local WAL marker only — never replicated */
} wal_entry_type;

128
src/include/xce/cluster.h Normal file
View file

@ -0,0 +1,128 @@
/* src/include/xce/cluster.h — cluster discovery and reconnect API (P2.5-1)
*
* xce_connect_cluster() returns endpoint_t* (cast to void*).
* The SSL* is accessible as ((endpoint_t*)result)->ssl.
*/
#ifndef XCE_CLUSTER_H
#define XCE_CLUSTER_H
#include <stddef.h>
#include "config.h" /* MAX_HA_CANDIDATES, XCE_MAXHOSTNAME_LEN */
/**
* A single controller candidate.
* name: logical name from "name=host:port" syntax (empty if omitted).
* Matching against myconf->name allows multiple controllers on the same
* dev host to distinguish themselves by name rather than IP address.
*/
typedef struct {
char name[XCE_MAXNAME_LEN];
char hostname[XCE_MAXHOSTNAME_LEN];
int port;
} xce_candidate_t;
/**
* Cluster connection configuration.
* Populated by each caller (worker/scheduler/apiserver) from --peers and
* --ca-cert CLI flags. libxce never reads config files.
*
* candidates_count must be in [1, MAX_HA_CANDIDATES].
* A WARN is logged at startup if candidates_count > 3.
*/
typedef struct {
xce_candidate_t candidates[MAX_HA_CANDIDATES];
int candidates_count;
char ca_cert_path[4096]; /* path to CA cert for TLS verify */
char identity[256]; /* "from" name sent to controller */
char key[256]; /* authentication key */
} xce_cluster_config_t;
/**
* Reconnect state machine states.
*/
typedef enum {
XCE_RECONNECT_IDLE = 0,
XCE_RECONNECT_CONNECTING,
XCE_RECONNECT_BACKOFF,
XCE_RECONNECT_CONNECTED,
XCE_RECONNECT_FAILED
} xce_reconnect_state_t;
/**
* Reconnect context (use xce_reconnect_* functions to operate).
*/
typedef struct {
xce_cluster_config_t* config;
xce_reconnect_state_t state;
int attempt;
int backoff_ms; /* current backoff delay in ms */
void* endpoint; /* endpoint_t* when CONNECTED */
} xce_reconnect_ctx_t;
/* Redirect follow limit */
#define XCE_MAX_REDIRECTS 4
/* Backoff parameters — initial kept short so tests stay fast */
#define XCE_RECONNECT_INITIAL_BACKOFF_MS 100
#define XCE_RECONNECT_MAX_BACKOFF_MS 30000
#define XCE_RECONNECT_BACKOFF_MULTIPLIER 2
/* --- Public API --- */
/**
* xce_cluster_config_validate() — validate a populated xce_cluster_config_t.
*
* Returns 0 on success, -EINVAL if:
* - candidates_count < 1 or > MAX_HA_CANDIDATES
* - any candidate has empty hostname or port <= 0
* - ca_cert_path is empty
* - identity is empty
*/
int xce_cluster_config_validate(const xce_cluster_config_t* cfg);
/**
* xce_connect_cluster() — connect to the cluster via candidate list.
*
* Iterates candidates in order, attempting a TLS connection to each.
* Returns endpoint_t* (cast to void*) on success, NULL if all fail.
*/
void* xce_connect_cluster(xce_cluster_config_t* cfg);
/**
* xce_reconnect_init() — initialise a reconnect context.
* Returns 0 on success, -EINVAL on bad arguments.
*/
int xce_reconnect_init(xce_reconnect_ctx_t* rctx, xce_cluster_config_t* cfg);
/**
* xce_reconnect_step() — drive one step of the reconnect state machine.
*
* Call in a loop until state == XCE_RECONNECT_CONNECTED or FAILED.
* Sleeps backoff_ms in BACKOFF state (skipped when mock connect is set).
*
* Returns 0 to continue, -1 when in terminal state.
*/
int xce_reconnect_step(xce_reconnect_ctx_t* rctx);
/**
* xce_reconnect_fini() — release resources held by a reconnect context.
*/
void xce_reconnect_fini(xce_reconnect_ctx_t* rctx);
/**
* xce_parse_peers() — parse "--peers name=host:port,..." into cfg->candidates[].
*
* Each token may be "name=host:port" or bare "host:port".
* The name field is stored when present; it allows multiple daemons on the
* same host to identify themselves by logical name rather than IP address.
*
* Returns 0 on success, -1 on empty/NULL input or any parse error.
*/
int xce_parse_peers(const char* peers_str, xce_cluster_config_t* cfg);
/* Test seam: set to non-NULL to override the real connect (unit tests only) */
typedef void* (*xce_connect_fn_t)(const char* host, int port,
const char* ca_cert);
void xce_cluster_set_connect_fn(xce_connect_fn_t fn);
#endif /* XCE_CLUSTER_H */

View file

@ -14,7 +14,11 @@
#include "myconf.h"
#include "mylock.h"
#include "netio.h"
#include "persist.h"
#include "vtaskmgr.h"
/* ha.h forward-declares cluster_t; including it here is safe (no circular dep) */
#include "ha.h"
#include "xce/types.h"
#include "xlist.h"
#include "zarray.h"
@ -55,7 +59,9 @@ enum {
HOOK_DETAIL_QUEUE_UPDATE,
HOOK_DETAIL_USERGROUP_UPDATE,
HOOK_DETAIL_HOSTGROUP_UPDATE,
HOOK_DETAIL_JOB_RUSAGE_UPDATE
HOOK_DETAIL_JOB_RUSAGE_UPDATE,
HOOK_DETAIL_JOB_STOP,
HOOK_DETAIL_JOB_RESUME
};
#define XCE_NS_CONFIG (0)
@ -88,7 +94,7 @@ typedef struct {
endpoint_t* ep;
} cluster_apiserver_t;
typedef struct {
typedef struct cluster_s {
myconf_t* myconf;
xce_peerinfo_t* myhost;
hooksys_t hooksys;
@ -112,6 +118,8 @@ typedef struct {
vtaskmgr_t vtaskmgr;
cbuf_t wbuf;
int running;
persist_t persist; /* Persistence module — P0-F */
ha_state_t ha; /* HA role management — P1-5 */
} cluster_t;
#endif /* XCE_CLUSTER_DEF_H */

View file

@ -19,6 +19,8 @@ enum {
E_XCE_BAD_QUEUE,
E_XCE_BAD_GROUP,
E_XCE_INTERNAL,
E_XCE_NOMASTER, /* Standby has no known primary — client should retry */
E_XCE_REDIRECT, /* Standby is redirecting client to primary */
E_XCE_MAX
};

View file

@ -172,6 +172,7 @@ typedef struct {
#define XCE_WORKER_LOCKEDW (0x00040000)
#define XCE_WORKER_BUSY (0x00080000)
#define XCE_WORKER_CLOSED (0x00100000)
#define XCE_WORKER_HIDDEN (0x00200000) /* Admin explicitly removed — invisible but not yet cleaned up */
#define IS_WORKER_UNAVAIL(status) (((status) & XCE_WORKER_UNAVAIL) != 0)
#define IS_WORKER_LOCKEDU(status) (((status) & XCE_WORKER_LOCKEDU) != 0)
@ -353,7 +354,8 @@ typedef struct {
#define JOB_STAT_PDONE (0x80) /**< Post job process done successfully */
#define JOB_STAT_PERR (0x100) /**< Post job process has error */
#define JOB_STAT_WAIT (0x200) /**< Chunk job waiting its turn to exec */
#define JOB_STAT_WAIT (0x200) /**< Chunk job waiting its turn to exec */
#define JOB_STAT_TRANSIENT (0x400) /**< Job state uncertain after failover — awaiting worker re-join */
#define JOB_STAT_RUNKWN \
0x8000 /* Flag : Job status is UNKWN caused by losting contact with remote \
cluster */
@ -381,6 +383,7 @@ typedef struct {
#define QUEUE_STAT_DISC 0x10 /**< Remote queue status is disconnected. */
#define QUEUE_STAT_FAIRSHARE_DEFAULT \
0x20 /**< Fairshare queue with null user share defined*/
#define QUEUE_STAT_HIDDEN (0x0100) /**< Admin deleted — invisible but not yet cleaned up */
/* FIXME: improve following later */
#define JOB_IS_PEND(state) (((state) & JOB_STAT_PEND) != 0)
@ -472,6 +475,7 @@ typedef struct {
char* ckptinfo;
int64_t pending_ctrl;
xce_job_preason_t* pend_reasons;
uint64_t transient_since; /* epoch seconds; set when job enters JOB_STAT_TRANSIENT */
} xce_job_t;
typedef struct {

191
src/lib/cluster.c Normal file
View file

@ -0,0 +1,191 @@
/* src/lib/cluster.c — cluster discovery and reconnect (P2.5-2/3)
*
* xce_connect_cluster() iterates the candidate list and returns the first
* successful endpoint_t* (cast to void*). In production the real SSL
* connect is performed via do_connect(); unit tests override s_connect_fn.
*
* xce_reconnect_step() drives a non-blocking state machine:
* IDLE → CONNECTING → CONNECTED (happy path)
* IDLE → CONNECTING → BACKOFF → CONNECTING → … (retrying)
*
* Backoff sleep is skipped when s_connect_fn is set (i.e., in unit tests)
* so tests do not need to wait for real timeouts.
*/
#include <errno.h>
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include "xce/cluster.h"
/* ── Peer list parsing ─────────────────────────────────────────── */
int xce_parse_peers(const char* peers_str, xce_cluster_config_t* cfg)
{
cfg->candidates_count = 0;
if (!peers_str || peers_str[0] == '\0')
return -1;
char buf[2048];
strncpy(buf, peers_str, sizeof(buf) - 1);
buf[sizeof(buf) - 1] = '\0';
char* saveptr;
char* tok = strtok_r(buf, ",", &saveptr);
while (tok && cfg->candidates_count < MAX_HA_CANDIDATES) {
/* Format: name=host:port OR host:port */
char* eq = strchr(tok, '=');
char* addr = eq ? eq + 1 : tok;
char* colon = strrchr(addr, ':');
if (!colon)
return -1;
*colon = '\0';
xce_candidate_t* cand = &cfg->candidates[cfg->candidates_count];
if (eq) {
*eq = '\0';
strncpy(cand->name, tok, XCE_MAXNAME_LEN - 1);
cand->name[XCE_MAXNAME_LEN - 1] = '\0';
} else {
cand->name[0] = '\0';
}
strncpy(cand->hostname, addr, XCE_MAXHOSTNAME_LEN - 1);
cand->hostname[XCE_MAXHOSTNAME_LEN - 1] = '\0';
cand->port = atoi(colon + 1);
cfg->candidates_count++;
tok = strtok_r(NULL, ",", &saveptr);
}
return cfg->candidates_count > 0 ? 0 : -1;
}
/* ── Test seam ─────────────────────────────────────────────────── */
static xce_connect_fn_t s_connect_fn = NULL;
void xce_cluster_set_connect_fn(xce_connect_fn_t fn)
{
s_connect_fn = fn;
}
/* do_connect: calls the mock when set, otherwise returns NULL (stub for
* the real SSL connect via netio — to be wired in a later task). */
static void* do_connect(const char* host, int port, const char* ca_cert)
{
if (s_connect_fn)
return s_connect_fn(host, port, ca_cert);
/* Production path: real SSL connect via netio — stub for now */
(void)host;
(void)port;
(void)ca_cert;
return NULL;
}
/* ── Validation ────────────────────────────────────────────────── */
int xce_cluster_config_validate(const xce_cluster_config_t* cfg)
{
if (!cfg)
return -EINVAL;
if (cfg->candidates_count < 1 || cfg->candidates_count > MAX_HA_CANDIDATES)
return -EINVAL;
for (int i = 0; i < cfg->candidates_count; i++) {
if (cfg->candidates[i].hostname[0] == '\0')
return -EINVAL;
if (cfg->candidates[i].port <= 0)
return -EINVAL;
}
if (cfg->ca_cert_path[0] == '\0')
return -EINVAL;
if (cfg->identity[0] == '\0')
return -EINVAL;
if (cfg->candidates_count > 3)
fprintf(stderr,
"WARN: HA cluster has %d candidates; validated for <=3 only\n",
cfg->candidates_count);
return 0;
}
/* ── Connect with candidate fallback ───────────────────────────── */
void* xce_connect_cluster(xce_cluster_config_t* cfg)
{
if (xce_cluster_config_validate(cfg) != 0)
return NULL;
for (int i = 0; i < cfg->candidates_count; i++) {
void* ep = do_connect(cfg->candidates[i].hostname,
cfg->candidates[i].port,
cfg->ca_cert_path);
if (ep)
return ep;
}
return NULL;
}
/* ── Reconnect state machine ───────────────────────────────────── */
int xce_reconnect_init(xce_reconnect_ctx_t* rctx, xce_cluster_config_t* cfg)
{
if (!rctx || !cfg)
return -EINVAL;
memset(rctx, 0, sizeof(*rctx));
rctx->config = cfg;
rctx->state = XCE_RECONNECT_IDLE;
rctx->backoff_ms = XCE_RECONNECT_INITIAL_BACKOFF_MS;
return 0;
}
int xce_reconnect_step(xce_reconnect_ctx_t* rctx)
{
if (!rctx)
return -EINVAL;
switch (rctx->state) {
case XCE_RECONNECT_IDLE:
rctx->state = XCE_RECONNECT_CONNECTING;
return 0; /* next step does the actual connect */
case XCE_RECONNECT_CONNECTING: {
void* ep = xce_connect_cluster(rctx->config);
if (ep) {
rctx->endpoint = ep;
rctx->state = XCE_RECONNECT_CONNECTED;
rctx->attempt = 0;
rctx->backoff_ms = XCE_RECONNECT_INITIAL_BACKOFF_MS;
} else {
rctx->attempt++;
rctx->state = XCE_RECONNECT_BACKOFF;
}
return 0;
}
case XCE_RECONNECT_BACKOFF: {
/* Skip sleep in test context (mock connect set) to keep tests fast */
if (!s_connect_fn) {
struct timespec ts = {
.tv_sec = rctx->backoff_ms / 1000,
.tv_nsec = (long)(rctx->backoff_ms % 1000) * 1000000L,
};
nanosleep(&ts, NULL);
}
/* Exponential backoff with cap */
rctx->backoff_ms *= XCE_RECONNECT_BACKOFF_MULTIPLIER;
if (rctx->backoff_ms > XCE_RECONNECT_MAX_BACKOFF_MS)
rctx->backoff_ms = XCE_RECONNECT_MAX_BACKOFF_MS;
rctx->state = XCE_RECONNECT_CONNECTING;
return 0;
}
case XCE_RECONNECT_CONNECTED:
case XCE_RECONNECT_FAILED:
return -1;
}
return 0;
}
void xce_reconnect_fini(xce_reconnect_ctx_t* rctx)
{
if (rctx)
memset(rctx, 0, sizeof(*rctx));
}

View file

@ -3,6 +3,7 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include "delay.h"
#include "utils.h"
@ -17,7 +18,14 @@ static void* delay_worker(void* args)
time_t now;
pthread_testcancel();
sleep(mgr->precision);
{
long ms = (long)mgr->precision_ms;
struct timespec ts = {
.tv_sec = ms / 1000,
.tv_nsec = (ms % 1000) * 1000000L,
};
nanosleep(&ts, NULL);
}
it = ordq_peek(&(mgr->tasks), NULL);
if (it == NULL) {
@ -100,7 +108,7 @@ int delaymgr_init(delaymgr_t* mgr, int precision, task_run_fn_t runner_fn)
runner_fn = default_runner_fn;
}
mgr->precision = precision;
mgr->precision_ms = precision;
mgr->running = 0;
mgr->runner_fn = runner_fn;
mgr->worker = 0;

View file

@ -1,4 +1,5 @@
#include <errno.h>
#include <stdatomic.h>
#include <stdlib.h>
#include <string.h>
@ -41,19 +42,60 @@ int xce_add_hook(hooksys_t* s, int typ, hook_fn_t fn, void* data)
return -EINVAL;
}
/* Scan for a disabled (NULL-fn) slot of the same type to reuse */
{
xlist_t* l = &s->hooks[typ];
int i;
for (i = 0; i < l->count; i++) {
hook_item_t* slot = (hook_item_t*)l->data[i];
if (!slot) continue;
hook_fn_t cur =
atomic_load_explicit(&slot->fn, memory_order_acquire);
if (cur == NULL) {
slot->data = data;
atomic_store_explicit(&slot->fn, fn, memory_order_release);
return 0;
}
}
}
/* No reusable slot — allocate new */
it = (hook_item_t*)calloc(1, sizeof(*it));
if (it == NULL) {
return -ENOMEM;
}
it->fn = fn;
it->data = data;
atomic_store_explicit(&it->fn, fn, memory_order_release);
xlist_store(&(s->hooks[typ]), it);
return 0;
}
int xce_disable_hook(hooksys_t* s, int typ, hook_fn_t fn)
{
xlist_t* l;
int i;
if (!s || !fn || typ < 0 || typ >= s->maxtypes) {
return -EINVAL;
}
l = &s->hooks[typ];
for (i = 0; i < l->count; i++) {
hook_item_t* it = (hook_item_t*)l->data[i];
if (!it) continue;
hook_fn_t cur = atomic_load_explicit(&it->fn, memory_order_acquire);
if (cur == fn) {
atomic_store_explicit(&it->fn, (hook_fn_t)NULL,
memory_order_release);
return 0;
}
}
return -ENOENT;
}
/* HOOK_BEFORE_EVENT returns
* negative value will cause
* fail
@ -76,14 +118,20 @@ int xce_run_hooks(hooksys_t* s, int type, int situation, int detail, ...)
for (i = 0; i < l->count; i++) {
hook_item_t* it = l->data[i];
hook_fn_t local_fn;
va_list ap;
if (it == NULL) {
continue;
}
local_fn = atomic_load_explicit(&it->fn, memory_order_acquire);
if (!local_fn) {
continue; /* disabled slot */
}
va_start(ap, detail);
rc = (*(it->fn))(type, situation, detail, it->data, ap);
rc = local_fn(type, situation, detail, it->data, ap);
va_end(ap);
if ((situation == HOOK_BEFORE_EVENT) && (rc < 0)) {

View file

@ -248,6 +248,35 @@ static int kvtab_find(meta_obj_t* obj, void* key, void** value)
return 1;
}
/*
* kvtab_foreach — walk every live entry and invoke cb(key, index, arg).
* index is recovered by casting the stored void* value back to int.
* Caller must hold any necessary external lock before calling.
*/
int kvtab_foreach(var kvtab_var, kvtab_foreach_fn_t cb, void* arg)
{
if (kvtab_var == NULL || cb == NULL) {
return -EINVAL;
}
meta_obj_t* obj = RTTI_DATA_TO_OBJ(kvtab_var);
kvtab_t* t = RTTI_OBJ_TO_DATA(kvtab_t, obj);
int count = 0;
uint32_t i;
for (i = 0; i < t->nslots; i++) {
kvent_t* e = t->slots[i];
while (e != NULL) {
int idx = (int)(long)e->value;
cb(e->key, idx, arg);
count++;
e = e->next;
}
}
return count;
}
typeinfo_t kvtab_type
= { .name = "kvtab",
.privateSize = 0,

View file

@ -1,12 +1,15 @@
#include <errno.h>
#include <inttypes.h>
#include <rtti.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "htab.h"
#include "kv.h"
#include "kvmgr.h"
#include "mylock.h"
#include "persist.h"
#include "utils.h"
#include "xce/types.h"
#include "zarray.h"
@ -290,7 +293,9 @@ typeinfo_t xkvmgr_type = {
.vget_vec = (vget_vec_fn_t)xkvmgr_get_vec,
.vput = (vput_fn_t)xkvmgr_put,
.vput_vec = (vput_vec_fn_t)xkvmgr_put_vec,
.vclear = (vclear_fn_t)NULL },
.vclear = (vclear_fn_t)NULL,
.vfreeze = NULL, /* wired in P0-2 */
.vthaw = NULL /* wired in P0-2 */ },
.iter
= &(intf_iterator_t) { .make = (iter_make_fn_t)xkvmgr_make_iter,
.next = (iter_next_fn_t)xkvmgr_iter_next,
@ -300,3 +305,108 @@ typeinfo_t xkvmgr_type = {
.lock = &(intf_lock_t) { .lock = (lock_fn_t)xkvmgr_lock,
.unlock = (unlock_fn_t)xkvmgr_unlock } }
};
/* ------------------------------------------------------------------ */
/* kvmgr_capture_keys — P0-14a */
/* ------------------------------------------------------------------ */
typedef struct {
slot_key_t* keys;
size_t capacity;
size_t filled;
kvmgr_key_type_t key_type;
int oom;
} capture_ctx_t;
static void capture_one(void* raw_key, int index, void* arg)
{
capture_ctx_t* ctx = (capture_ctx_t*)arg;
if (ctx->oom || ctx->filled >= ctx->capacity) {
return;
}
slot_key_t* sk = &ctx->keys[ctx->filled];
sk->index = index;
if (ctx->key_type == KVMGR_KEY_STRING) {
const char* src = (const char*)raw_key;
strncpy(sk->key, src, XCE_MAXNAME_LEN - 1);
sk->key[XCE_MAXNAME_LEN - 1] = '\0';
sk->key_len = strlen(sk->key) + 1;
} else {
/* Integer key — format as decimal string */
int64_t intval = (int64_t)(long)raw_key;
int n = snprintf(sk->key, XCE_MAXNAME_LEN,
"%" PRId64, intval);
if (n < 0 || n >= XCE_MAXNAME_LEN) {
/* Truncated — mark as oom so caller can detect */
ctx->oom = 1;
return;
}
sk->key_len = (size_t)n + 1;
}
ctx->filled++;
}
/*
* Count live entries by iterating; kvtab_foreach returns the count.
* We use a NULL callback to count first, then allocate, then fill.
*/
static void count_one(void* key, int index, void* arg)
{
(void)key;
(void)index;
size_t* n = (size_t*)arg;
(*n)++;
}
int kvmgr_capture_keys(var kvmgr_var,
slot_key_t** keys_out,
size_t* n_keys_out)
{
*keys_out = NULL;
*n_keys_out = 0;
if (kvmgr_var == NULL) {
return -EINVAL;
}
/* The public API pointer (var) points to the data region;
* convert to the meta_obj_t header, then extract xkvmgr_t. */
meta_obj_t* obj = RTTI_DATA_TO_OBJ(kvmgr_var);
xkvmgr_t* kvm = RTTI_OBJ_TO_DATA(xkvmgr_t, obj);
/* Count live entries */
size_t n = 0;
kvtab_foreach(kvm->kvtab, count_one, &n);
if (n == 0) {
return 0;
}
slot_key_t* keys = calloc(n, sizeof(slot_key_t));
if (keys == NULL) {
return -ENOMEM;
}
capture_ctx_t ctx = {
.keys = keys,
.capacity = n,
.filled = 0,
.key_type = kvm->key_type,
.oom = 0,
};
kvtab_foreach(kvm->kvtab, capture_one, &ctx);
if (ctx.oom) {
free(keys);
return -ENOMEM;
}
*keys_out = keys;
*n_keys_out = ctx.filled;
return 0;
}

View file

@ -104,6 +104,7 @@ libxce_src = [ gen_srcs[0],
'resobj.c',
'wobj.c',
'configobj.c',
'cluster.c',
]
incdir = include_directories('../include')
@ -163,6 +164,11 @@ libxce_test_src = ['testsuite/test.c',
'testsuite/test_wobj.c',
'testsuite/test_qobj.c',
'testsuite/test_request.c',
'testsuite/test_zarray_cow.c',
'testsuite/test_kvmgr_capture.c',
'testsuite/test_hooks.c',
'testsuite/test_cluster.c',
'testsuite/test_cluster_parse.c',
]
libxce_test_deps = [libxce_deps, librtti_dep, unity_dep, libxce_dep]

View file

@ -29,6 +29,11 @@ extern void test_jobobj(void);
extern void test_qobj(void);
extern void test_wobj(void);
extern void test_request(void);
extern void test_zarray_cow(void);
extern void test_kvmgr_capture(void);
extern void test_hooks(void);
extern void test_cluster(void);
extern void test_cluster_parse(void);
void setUp(void) { return; }
@ -127,6 +132,11 @@ int main(int argc, char* argv[])
TEST(test_wobj);
TEST(test_qobj);
TEST(test_request);
TEST(test_zarray_cow);
TEST(test_kvmgr_capture);
TEST(test_hooks);
TEST(test_cluster);
TEST(test_cluster_parse);
return UNITY_END();
}

View file

@ -0,0 +1,181 @@
#include <errno.h>
#include <stdio.h>
#include <string.h>
#include <unity/unity.h>
#include "xce/cluster.h"
/* ── Test seam: mock connect function ───────────────────────────── */
static int s_connect_call_count = 0;
static int s_connect_fail_n = 0; /* fail first N attempts, then succeed */
static char s_last_host[256] = "";
static int s_last_port = 0;
static void* mock_connect(const char* host, int port, const char* ca_cert)
{
(void)ca_cert;
strncpy(s_last_host, host, sizeof(s_last_host) - 1);
s_last_port = port;
s_connect_call_count++;
if (s_connect_call_count <= s_connect_fail_n)
return NULL;
return (void*)0xDEADBEEF; /* non-NULL = success */
}
static xce_cluster_config_t make_valid_config(void)
{
xce_cluster_config_t cfg = {0};
strncpy(cfg.candidates[0].hostname, "10.0.1.1",
XCE_MAXHOSTNAME_LEN - 1);
cfg.candidates[0].port = 7000;
strncpy(cfg.candidates[1].hostname, "10.0.1.2",
XCE_MAXHOSTNAME_LEN - 1);
cfg.candidates[1].port = 7001;
cfg.candidates_count = 2;
strncpy(cfg.ca_cert_path, "/etc/xce/ca.pem",
sizeof(cfg.ca_cert_path) - 1);
strncpy(cfg.identity, "worker-01", sizeof(cfg.identity) - 1);
return cfg;
}
/* ── Test cases ─────────────────────────────────────────────────── */
/* Test 1: validate rejects empty config */
static void test_validate_rejects_zero_candidates(void)
{
xce_cluster_config_t cfg = {0};
cfg.candidates_count = 0;
TEST_ASSERT_NOT_EQUAL(0, xce_cluster_config_validate(&cfg));
printf("test_validate_rejects_zero_candidates: PASS\n");
}
/* Test 2: validate rejects too many candidates */
static void test_validate_rejects_over_max_candidates(void)
{
xce_cluster_config_t cfg = {0};
cfg.candidates_count = MAX_HA_CANDIDATES + 1;
TEST_ASSERT_NOT_EQUAL(0, xce_cluster_config_validate(&cfg));
printf("test_validate_rejects_over_max_candidates: PASS\n");
}
/* Test 3: validate rejects missing identity */
static void test_validate_rejects_empty_identity(void)
{
xce_cluster_config_t cfg = make_valid_config();
cfg.identity[0] = '\0';
TEST_ASSERT_NOT_EQUAL(0, xce_cluster_config_validate(&cfg));
printf("test_validate_rejects_empty_identity: PASS\n");
}
/* Test 4: validate accepts valid config */
static void test_validate_accepts_valid_config(void)
{
xce_cluster_config_t cfg = make_valid_config();
TEST_ASSERT_EQUAL_INT(0, xce_cluster_config_validate(&cfg));
printf("test_validate_accepts_valid_config: PASS\n");
}
/* Test 5: connect succeeds on first candidate */
static void test_connect_succeeds_first_candidate(void)
{
xce_cluster_config_t cfg = make_valid_config();
s_connect_call_count = 0;
s_connect_fail_n = 0;
xce_cluster_set_connect_fn(mock_connect);
void* ep = xce_connect_cluster(&cfg);
TEST_ASSERT_NOT_NULL(ep);
TEST_ASSERT_EQUAL_INT(1, s_connect_call_count);
TEST_ASSERT_EQUAL_STRING("10.0.1.1", s_last_host);
TEST_ASSERT_EQUAL_INT(7000, s_last_port);
xce_cluster_set_connect_fn(NULL);
printf("test_connect_succeeds_first_candidate: PASS\n");
}
/* Test 6: connect falls back to second candidate when first fails */
static void test_connect_fallback_to_second_candidate(void)
{
xce_cluster_config_t cfg = make_valid_config();
s_connect_call_count = 0;
s_connect_fail_n = 1; /* first attempt fails */
xce_cluster_set_connect_fn(mock_connect);
void* ep = xce_connect_cluster(&cfg);
TEST_ASSERT_NOT_NULL(ep);
TEST_ASSERT_EQUAL_INT(2, s_connect_call_count);
TEST_ASSERT_EQUAL_STRING("10.0.1.2", s_last_host);
xce_cluster_set_connect_fn(NULL);
printf("test_connect_fallback_to_second_candidate: PASS\n");
}
/* Test 7: connect returns NULL when all candidates fail */
static void test_connect_returns_null_all_fail(void)
{
xce_cluster_config_t cfg = make_valid_config();
s_connect_call_count = 0;
s_connect_fail_n = 999; /* all fail */
xce_cluster_set_connect_fn(mock_connect);
void* ep = xce_connect_cluster(&cfg);
TEST_ASSERT_NULL(ep);
xce_cluster_set_connect_fn(NULL);
printf("test_connect_returns_null_all_fail: PASS\n");
}
/* Test 8: reconnect state machine — IDLE → CONNECTING → CONNECTED */
static void test_reconnect_step_reaches_connected(void)
{
xce_cluster_config_t cfg = make_valid_config();
xce_reconnect_ctx_t rctx;
s_connect_call_count = 0;
s_connect_fail_n = 0;
xce_cluster_set_connect_fn(mock_connect);
xce_reconnect_init(&rctx, &cfg);
TEST_ASSERT_EQUAL_INT(XCE_RECONNECT_IDLE, (int)rctx.state);
int steps = 0;
while (rctx.state != XCE_RECONNECT_CONNECTED &&
rctx.state != XCE_RECONNECT_FAILED && steps < 100) {
xce_reconnect_step(&rctx);
steps++;
}
TEST_ASSERT_EQUAL_INT(XCE_RECONNECT_CONNECTED, (int)rctx.state);
TEST_ASSERT_NOT_NULL(rctx.endpoint);
xce_reconnect_fini(&rctx);
xce_cluster_set_connect_fn(NULL);
printf("test_reconnect_step_reaches_connected: PASS\n");
}
/* Test 9: reconnect state machine backs off on failure */
static void test_reconnect_step_backs_off_on_failure(void)
{
xce_cluster_config_t cfg = make_valid_config();
cfg.candidates_count = 1; /* only one candidate so it hits backoff */
xce_reconnect_ctx_t rctx;
s_connect_call_count = 0;
s_connect_fail_n = 999; /* always fail */
xce_cluster_set_connect_fn(mock_connect);
xce_reconnect_init(&rctx, &cfg);
xce_reconnect_step(&rctx); /* IDLE → CONNECTING (attempt connect) */
xce_reconnect_step(&rctx); /* CONNECTING → BACKOFF (all candidates exhausted) */
/* When mock is set, sleep is skipped — state must be BACKOFF */
TEST_ASSERT_EQUAL_INT(XCE_RECONNECT_BACKOFF, (int)rctx.state);
TEST_ASSERT_TRUE(rctx.backoff_ms > 0);
xce_reconnect_fini(&rctx);
xce_cluster_set_connect_fn(NULL);
printf("test_reconnect_step_backs_off_on_failure: PASS\n");
}
/* ── Test group entry point ─────────────────────────────────────── */
void test_cluster(void)
{
RUN_TEST(test_validate_rejects_zero_candidates);
RUN_TEST(test_validate_rejects_over_max_candidates);
RUN_TEST(test_validate_rejects_empty_identity);
RUN_TEST(test_validate_accepts_valid_config);
RUN_TEST(test_connect_succeeds_first_candidate);
RUN_TEST(test_connect_fallback_to_second_candidate);
RUN_TEST(test_connect_returns_null_all_fail);
RUN_TEST(test_reconnect_step_reaches_connected);
RUN_TEST(test_reconnect_step_backs_off_on_failure);
}

View file

@ -0,0 +1,160 @@
/* test_cluster_parse.c — unit tests for the parse_peers() helper used by
* worker and scheduler main.c (P2.5-5 / P2.5-6).
*
* The parser is a static function in the respective main.c files. To keep
* the test self-contained we duplicate a simplified reference implementation
* here and test the same contract. Integration with the real binaries is
* covered by the build (both must compile).
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unity/unity.h>
#include "xce/cluster.h"
/* ── Reference implementation of parse_peers (mirrors worker/main.c) ─── */
static int parse_peers_local(const char* peers_str, xce_cluster_config_t* cfg)
{
cfg->candidates_count = 0;
if (!peers_str || peers_str[0] == '\0')
return -1;
char buf[2048];
strncpy(buf, peers_str, sizeof(buf) - 1);
buf[sizeof(buf) - 1] = '\0';
char* saveptr;
char* tok = strtok_r(buf, ",", &saveptr);
while (tok && cfg->candidates_count < MAX_HA_CANDIDATES) {
/* Format: name=host:port OR host:port */
char* eq = strchr(tok, '=');
char* addr = eq ? eq + 1 : tok;
char* colon = strrchr(addr, ':');
if (!colon)
return -1;
*colon = '\0';
strncpy(cfg->candidates[cfg->candidates_count].hostname, addr,
XCE_MAXHOSTNAME_LEN - 1);
cfg->candidates[cfg->candidates_count].hostname[XCE_MAXHOSTNAME_LEN - 1]
= '\0';
cfg->candidates[cfg->candidates_count].port = atoi(colon + 1);
cfg->candidates_count++;
tok = strtok_r(NULL, ",", &saveptr);
}
return cfg->candidates_count > 0 ? 0 : -1;
}
/* ── Test cases ─────────────────────────────────────────────────────── */
static void test_parse_peers_single(void)
{
xce_cluster_config_t cfg = {0};
int rc = parse_peers_local("ctrl-1=10.0.1.1:7000", &cfg);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(1, cfg.candidates_count);
TEST_ASSERT_EQUAL_STRING("10.0.1.1", cfg.candidates[0].hostname);
TEST_ASSERT_EQUAL_INT(7000, cfg.candidates[0].port);
printf("test_parse_peers_single: PASS\n");
}
static void test_parse_peers_multiple(void)
{
xce_cluster_config_t cfg = {0};
int rc = parse_peers_local(
"ctrl-1=10.0.1.1:7000,ctrl-2=10.0.1.2:7001,ctrl-3=10.0.1.3:7002",
&cfg);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(3, cfg.candidates_count);
TEST_ASSERT_EQUAL_STRING("10.0.1.2", cfg.candidates[1].hostname);
TEST_ASSERT_EQUAL_INT(7001, cfg.candidates[1].port);
TEST_ASSERT_EQUAL_STRING("10.0.1.3", cfg.candidates[2].hostname);
TEST_ASSERT_EQUAL_INT(7002, cfg.candidates[2].port);
printf("test_parse_peers_multiple: PASS\n");
}
static void test_parse_peers_no_name(void)
{
xce_cluster_config_t cfg = {0};
int rc = parse_peers_local("10.0.1.1:7000,10.0.1.2:7001", &cfg);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(2, cfg.candidates_count);
TEST_ASSERT_EQUAL_STRING("10.0.1.1", cfg.candidates[0].hostname);
TEST_ASSERT_EQUAL_INT(7000, cfg.candidates[0].port);
TEST_ASSERT_EQUAL_STRING("10.0.1.2", cfg.candidates[1].hostname);
TEST_ASSERT_EQUAL_INT(7001, cfg.candidates[1].port);
printf("test_parse_peers_no_name: PASS\n");
}
static void test_parse_peers_empty_returns_error(void)
{
xce_cluster_config_t cfg = {0};
int rc = parse_peers_local("", &cfg);
TEST_ASSERT_NOT_EQUAL(0, rc);
printf("test_parse_peers_empty_returns_error: PASS\n");
}
static void test_parse_peers_null_returns_error(void)
{
xce_cluster_config_t cfg = {0};
int rc = parse_peers_local(NULL, &cfg);
TEST_ASSERT_NOT_EQUAL(0, rc);
printf("test_parse_peers_null_returns_error: PASS\n");
}
static void test_parse_peers_missing_port_returns_error(void)
{
xce_cluster_config_t cfg = {0};
int rc = parse_peers_local("ctrl-1=10.0.1.1", &cfg); /* no colon:port */
TEST_ASSERT_NOT_EQUAL(0, rc);
printf("test_parse_peers_missing_port_returns_error: PASS\n");
}
static void test_parse_peers_first_entry_parsed_correctly(void)
{
/* Verify the name= prefix is stripped and only addr:port is stored */
xce_cluster_config_t cfg = {0};
int rc = parse_peers_local("primary=192.168.10.1:9000", &cfg);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_STRING("192.168.10.1", cfg.candidates[0].hostname);
TEST_ASSERT_EQUAL_INT(9000, cfg.candidates[0].port);
printf("test_parse_peers_first_entry_parsed_correctly: PASS\n");
}
static void test_parse_peers_respects_max_candidates(void)
{
/* Build a string with MAX_HA_CANDIDATES + 2 entries; only MAX accepted */
char big[2048] = "";
char entry[128] = "";
int n = MAX_HA_CANDIDATES + 2;
for (int i = 0; i < n; i++) {
snprintf(entry, sizeof(entry), "10.0.1.%d:%d", i + 1, 7000 + i);
if (i > 0)
strncat(big, ",", sizeof(big) - strlen(big) - 1);
strncat(big, entry, sizeof(big) - strlen(big) - 1);
}
xce_cluster_config_t cfg = {0};
int rc = parse_peers_local(big, &cfg);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(MAX_HA_CANDIDATES, cfg.candidates_count);
printf("test_parse_peers_respects_max_candidates: PASS\n");
}
/* ── Test group entry point ─────────────────────────────────────────── */
void test_cluster_parse(void)
{
RUN_TEST(test_parse_peers_single);
RUN_TEST(test_parse_peers_multiple);
RUN_TEST(test_parse_peers_no_name);
RUN_TEST(test_parse_peers_empty_returns_error);
RUN_TEST(test_parse_peers_null_returns_error);
RUN_TEST(test_parse_peers_missing_port_returns_error);
RUN_TEST(test_parse_peers_first_entry_parsed_correctly);
RUN_TEST(test_parse_peers_respects_max_candidates);
}

View file

@ -27,6 +27,27 @@ static void test_array(void)
TEST_ASSERT_TRUE(MAX_ARRAY_IDX == 32767);
}
static void test_persistence_constants(void)
{
TEST_ASSERT_TRUE(MAX_STANDBYS == 3);
TEST_ASSERT_TRUE(MAX_HA_CANDIDATES == 4);
TEST_ASSERT_TRUE(WAL_FORMAT_VERSION == 1);
TEST_ASSERT_TRUE(WAL_ENTRY_TYPE_MAX == 100);
TEST_ASSERT_TRUE(MAX_WAL_PAYLOAD_BYTES == (1 << 20));
TEST_ASSERT_TRUE(MAX_SNAPSHOT_BYTES == (256 * 1024 * 1024));
TEST_ASSERT_TRUE(DEFAULT_WAL_SYNC_MS == 10);
TEST_ASSERT_TRUE(DEFAULT_SNAP_INTERVAL_S == 60);
TEST_ASSERT_TRUE(DEFAULT_SNAP_WAL_SIZE_MB == 64);
TEST_ASSERT_TRUE(DEFAULT_SNAP_KEEP_N == 2);
TEST_ASSERT_TRUE(DEFAULT_FINISHED_RETAIN_S == 3600);
TEST_ASSERT_TRUE(DEFAULT_WORKER_RETAIN_S == 86400);
TEST_ASSERT_TRUE(DEFAULT_SYNC_TIMEOUT_MS == 50);
TEST_ASSERT_TRUE(DEFAULT_HB_INTERVAL_MS == 3000);
TEST_ASSERT_TRUE(DEFAULT_TRANSIENT_JOB_TIMEOUT_S == 300);
TEST_ASSERT_TRUE(WAL_HDR_CRC_SIZE == 32);
TEST_ASSERT_TRUE(WAL_CRC_NONE == 0xFFFFFFFFu);
}
void test_config(void)
{
printf("\n>> config test\n\n");
@ -36,4 +57,5 @@ void test_config(void)
RUN_TEST(test_timestamp);
RUN_TEST(test_tag);
RUN_TEST(test_array);
RUN_TEST(test_persistence_constants);
}

View file

@ -26,7 +26,7 @@ static void test_delay1()
int delay_period = 2;
time_t t_start = time(NULL);
delaymgr_init(&mgr, 1, NULL);
delaymgr_init(&mgr, 1000 /* precision_ms */, NULL);
delaymgr_start(&mgr);
delay_task(&mgr, delay_period, runcount, (delay_task_entry_fn_t)delay_work,
&count);
@ -48,9 +48,22 @@ static void test_delay1()
return;
}
static void test_delaymgr_precision_ms_field(void)
{
delaymgr_t mgr;
memset(&mgr, 0, sizeof(mgr));
/* precision_ms field must exist and be stored correctly */
int rc = delaymgr_init(&mgr, 200 /* precision_ms */, NULL);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(200, mgr.precision_ms);
delaymgr_fini(&mgr);
printf("test_delaymgr_precision_ms_field: PASS\n");
}
void test_delay(void)
{
printf("\n>> delay test\n\n");
RUN_TEST(test_delay1);
RUN_TEST(test_delaymgr_precision_ms_field);
}

View file

@ -0,0 +1,112 @@
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include "hook.h"
#include <unity/unity.h>
/* Shared call counter for hook tests */
static int s_call_count = 0;
static int counting_fn(int type, int sit, int det, void* data, va_list ap)
{
(void)type;
(void)sit;
(void)det;
(void)ap;
(*(int*)data)++;
return 0;
}
/* --- test cases --- */
static void test_disable_hook_stops_invocation(void)
{
hooksys_t hs;
int rc;
s_call_count = 0;
rc = xce_hook_init(&hs, 2 /* maxtypes */);
TEST_ASSERT_EQUAL_INT(0, rc);
rc = xce_add_hook(&hs, HOOK_AFTER_EVENT, counting_fn, &s_call_count);
TEST_ASSERT_EQUAL_INT(0, rc);
/* Before disable: hook fires once */
xce_run_hooks(&hs, HOOK_AFTER_EVENT, 0, 0);
TEST_ASSERT_EQUAL_INT(1, s_call_count);
/* Disable the hook */
rc = xce_disable_hook(&hs, HOOK_AFTER_EVENT, counting_fn);
TEST_ASSERT_EQUAL_INT(0, rc);
/* After disable: hook must not fire */
xce_run_hooks(&hs, HOOK_AFTER_EVENT, 0, 0);
TEST_ASSERT_EQUAL_INT(1, s_call_count); /* unchanged */
xce_hook_fini(&hs);
printf("test_disable_hook_stops_invocation: PASS\n");
}
static void test_disable_hook_returns_enoent_if_not_found(void)
{
hooksys_t hs;
int rc;
rc = xce_hook_init(&hs, 2);
TEST_ASSERT_EQUAL_INT(0, rc);
/* Disabling a hook never added must return non-zero (ENOENT) */
rc = xce_disable_hook(&hs, HOOK_AFTER_EVENT, counting_fn);
TEST_ASSERT_NOT_EQUAL(0, rc);
xce_hook_fini(&hs);
printf("test_disable_hook_returns_enoent_if_not_found: PASS\n");
}
static void test_add_hook_reuses_null_slot(void)
{
hooksys_t hs;
int rc;
int count_before_reuse;
s_call_count = 0;
rc = xce_hook_init(&hs, 2);
TEST_ASSERT_EQUAL_INT(0, rc);
rc = xce_add_hook(&hs, HOOK_AFTER_EVENT, counting_fn, &s_call_count);
TEST_ASSERT_EQUAL_INT(0, rc);
/* Record list length after first add */
count_before_reuse = hs.hooks[HOOK_AFTER_EVENT].count;
TEST_ASSERT_EQUAL_INT(1, count_before_reuse);
/* Disable the slot — fn becomes NULL atomically */
rc = xce_disable_hook(&hs, HOOK_AFTER_EVENT, counting_fn);
TEST_ASSERT_EQUAL_INT(0, rc);
/* Re-add the same fn — must reuse the NULL slot, not grow the list */
rc = xce_add_hook(&hs, HOOK_AFTER_EVENT, counting_fn, &s_call_count);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(count_before_reuse, hs.hooks[HOOK_AFTER_EVENT].count);
/* Fire: must get exactly 1 call (not 2 if slot was duplicated) */
xce_run_hooks(&hs, HOOK_AFTER_EVENT, 0, 0);
TEST_ASSERT_EQUAL_INT(1, s_call_count);
xce_hook_fini(&hs);
printf("test_add_hook_reuses_null_slot: PASS\n");
}
/* --- suite entry point --- */
void test_hooks(void)
{
printf("\n>> hook system tests\n\n");
RUN_TEST(test_disable_hook_stops_invocation);
RUN_TEST(test_disable_hook_returns_enoent_if_not_found);
RUN_TEST(test_add_hook_reuses_null_slot);
}

View file

@ -0,0 +1,244 @@
/*
* Test suite for kvmgr_capture_keys() — P0-14a.
*
* Tests follow TDD: written BEFORE implementation.
* All tests exercise the public API declared in kvmgr.h.
*/
#include <rtti.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unity/unity.h>
#include "kv.h"
#include "kvmgr.h"
#include "persist.h"
#include "utils.h"
/* ------------------------------------------------------------------ */
/* helpers */
/* ------------------------------------------------------------------ */
static int find_key_in_captured(slot_key_t* keys, size_t n,
const char* target_key, int* index_out)
{
size_t i;
for (i = 0; i < n; i++) {
if (strcmp(keys[i].key, target_key) == 0) {
if (index_out != NULL) {
*index_out = keys[i].index;
}
return 1;
}
}
return 0;
}
/* ------------------------------------------------------------------ */
/* test 1: capture from a string-keyed kvmgr with 3 entries */
/* ------------------------------------------------------------------ */
static void test_capture_keys_string_kvmgr(void)
{
kvmgr_params_t params = { .key_type = KVMGR_KEY_STRING,
.nslots = 8,
.delta = 4,
.datakiller = NULL };
var kvmgr = makeInstance(&xkvmgr_type, &params);
TEST_ASSERT_NOT_NULL(kvmgr);
/* Add 3 string-keyed entries whose values are arbitrary pointers;
the index returned by key_add is the slot index we capture. */
int idx_a = key_add(kvmgr, (void*)"alpha", (void*)"data_alpha");
int idx_b = key_add(kvmgr, (void*)"beta", (void*)"data_beta");
int idx_c = key_add(kvmgr, (void*)"gamma", (void*)"data_gamma");
TEST_ASSERT_TRUE(idx_a >= 0);
TEST_ASSERT_TRUE(idx_b >= 0);
TEST_ASSERT_TRUE(idx_c >= 0);
slot_key_t* keys = NULL;
size_t n_keys = 0;
int rc = kvmgr_capture_keys(kvmgr, &keys, &n_keys);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_size_t(3, n_keys);
TEST_ASSERT_NOT_NULL(keys);
/* Every inserted key must appear exactly once with the correct index */
int found_idx = -1;
TEST_ASSERT_TRUE(find_key_in_captured(keys, n_keys, "alpha", &found_idx));
TEST_ASSERT_EQUAL_INT(idx_a, found_idx);
TEST_ASSERT_TRUE(find_key_in_captured(keys, n_keys, "beta", &found_idx));
TEST_ASSERT_EQUAL_INT(idx_b, found_idx);
TEST_ASSERT_TRUE(find_key_in_captured(keys, n_keys, "gamma", &found_idx));
TEST_ASSERT_EQUAL_INT(idx_c, found_idx);
/* key_len must equal strlen(key)+1 */
size_t i;
for (i = 0; i < n_keys; i++) {
TEST_ASSERT_EQUAL_size_t(strlen(keys[i].key) + 1, keys[i].key_len);
}
free(keys);
destroy(kvmgr);
}
/* ------------------------------------------------------------------ */
/* test 2: empty kvmgr returns 0 entries and NULL keys_out */
/* ------------------------------------------------------------------ */
static void test_capture_keys_empty_kvmgr(void)
{
kvmgr_params_t params = { .key_type = KVMGR_KEY_STRING,
.nslots = 4,
.delta = 4,
.datakiller = NULL };
var kvmgr = makeInstance(&xkvmgr_type, &params);
TEST_ASSERT_NOT_NULL(kvmgr);
slot_key_t* keys = (slot_key_t*)(uintptr_t)0xdeadbeef; /* sentinel */
size_t n_keys = 99;
int rc = kvmgr_capture_keys(kvmgr, &keys, &n_keys);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_size_t(0, n_keys);
TEST_ASSERT_NULL(keys);
destroy(kvmgr);
}
/* ------------------------------------------------------------------ */
/* test 3: captured keys are deep copies — safe after kvmgr destroyed */
/* ------------------------------------------------------------------ */
static void test_capture_keys_are_deep_copies(void)
{
kvmgr_params_t params = { .key_type = KVMGR_KEY_STRING,
.nslots = 4,
.delta = 4,
.datakiller = NULL };
var kvmgr = makeInstance(&xkvmgr_type, &params);
TEST_ASSERT_NOT_NULL(kvmgr);
key_add(kvmgr, (void*)"keyA", (void*)"valA");
key_add(kvmgr, (void*)"keyB", (void*)"valB");
slot_key_t* keys = NULL;
size_t n_keys = 0;
int rc = kvmgr_capture_keys(kvmgr, &keys, &n_keys);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_size_t(2, n_keys);
/* Stash manual copies of the captured key strings */
char copy_a[256], copy_b[256];
int found_a = 0, found_b = 0;
size_t i;
for (i = 0; i < n_keys; i++) {
if (strcmp(keys[i].key, "keyA") == 0) {
strncpy(copy_a, keys[i].key, sizeof(copy_a) - 1);
copy_a[sizeof(copy_a) - 1] = '\0';
found_a = 1;
} else if (strcmp(keys[i].key, "keyB") == 0) {
strncpy(copy_b, keys[i].key, sizeof(copy_b) - 1);
copy_b[sizeof(copy_b) - 1] = '\0';
found_b = 1;
}
}
TEST_ASSERT_TRUE(found_a);
TEST_ASSERT_TRUE(found_b);
/* Destroy the kvmgr — this frees all kvtab entries including the
original key strings. Captured keys[] must remain valid. */
destroy(kvmgr);
TEST_ASSERT_EQUAL_STRING("keyA", copy_a);
TEST_ASSERT_EQUAL_STRING("keyB", copy_b);
/* Directly verify the captured keys array is still readable */
int still_a = 0, still_b = 0;
for (i = 0; i < n_keys; i++) {
if (strcmp(keys[i].key, "keyA") == 0) still_a = 1;
if (strcmp(keys[i].key, "keyB") == 0) still_b = 1;
}
TEST_ASSERT_TRUE(still_a);
TEST_ASSERT_TRUE(still_b);
free(keys);
}
/* ------------------------------------------------------------------ */
/* test 4: capture from an integer-keyed kvmgr with 3 entries */
/* ------------------------------------------------------------------ */
static void test_capture_keys_int_kvmgr(void)
{
kvmgr_params_t params = { .key_type = KVMGR_KEY_INT,
.nslots = 8,
.delta = 4,
.datakiller = NULL };
var kvmgr = makeInstance(&xkvmgr_type, &params);
TEST_ASSERT_NOT_NULL(kvmgr);
/* Add 3 integer-keyed entries; the integer keys are jobids.
For integer-keyed kvmgrs, key_add expects a (void*)(long)intval. */
int64_t jobid_1001 = 1001;
int64_t jobid_1002 = 1002;
int64_t jobid_1003 = 1003;
int idx_1001 =
key_add(kvmgr, (void*)(long)jobid_1001, (void*)"data_1001");
int idx_1002 =
key_add(kvmgr, (void*)(long)jobid_1002, (void*)"data_1002");
int idx_1003 =
key_add(kvmgr, (void*)(long)jobid_1003, (void*)"data_1003");
TEST_ASSERT_TRUE(idx_1001 >= 0);
TEST_ASSERT_TRUE(idx_1002 >= 0);
TEST_ASSERT_TRUE(idx_1003 >= 0);
slot_key_t* keys = NULL;
size_t n_keys = 0;
int rc = kvmgr_capture_keys(kvmgr, &keys, &n_keys);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_size_t(3, n_keys);
TEST_ASSERT_NOT_NULL(keys);
/* Every inserted integer key must appear as a decimal string with the
correct index */
int found_idx = -1;
TEST_ASSERT_TRUE(find_key_in_captured(keys, n_keys, "1001", &found_idx));
TEST_ASSERT_EQUAL_INT(idx_1001, found_idx);
TEST_ASSERT_TRUE(find_key_in_captured(keys, n_keys, "1002", &found_idx));
TEST_ASSERT_EQUAL_INT(idx_1002, found_idx);
TEST_ASSERT_TRUE(find_key_in_captured(keys, n_keys, "1003", &found_idx));
TEST_ASSERT_EQUAL_INT(idx_1003, found_idx);
/* key_len must equal strlen(key)+1 */
size_t i;
for (i = 0; i < n_keys; i++) {
TEST_ASSERT_EQUAL_size_t(strlen(keys[i].key) + 1, keys[i].key_len);
}
free(keys);
destroy(kvmgr);
}
/* ------------------------------------------------------------------ */
/* entry point called from test.c */
/* ------------------------------------------------------------------ */
void test_kvmgr_capture(void)
{
printf("\n>> kvmgr_capture_keys test\n");
RUN_TEST(test_capture_keys_string_kvmgr);
RUN_TEST(test_capture_keys_empty_kvmgr);
RUN_TEST(test_capture_keys_are_deep_copies);
RUN_TEST(test_capture_keys_int_kvmgr);
}

View file

@ -0,0 +1,136 @@
#include <assert.h>
#include <rtti.h>
#include <stdio.h>
#include <unity/unity.h>
#include "zarray.h"
/* xzarray_type is defined in zarray.c */
extern typeinfo_t xzarray_type;
/* Helper: create an xzarray instance */
static meta_obj_t* make_test_xzarray(void)
{
xzarray_params_t params = { .delta = 4, .datakiller = NULL };
return (meta_obj_t*)makeInstance(&xzarray_type, &params);
}
static void test_freeze_thaw_slots_exist(void)
{
/* After P0-2, vfreeze and vthaw must be wired (non-NULL). */
const intf_varray_t* iface
= (const intf_varray_t*)xzarray_type.interfaces.varray;
TEST_ASSERT_NOT_NULL(iface);
/* Access slots without crash — verifies struct layout has them */
(void)iface->vfreeze;
(void)iface->vthaw;
/* Slots must be non-NULL after P0-2 wires the real implementations */
TEST_ASSERT_NOT_NULL(iface->vfreeze);
TEST_ASSERT_NOT_NULL(iface->vthaw);
printf("test_freeze_thaw_slots_exist: PASS\n");
}
static void test_freeze_creates_shadow(void)
{
meta_obj_t* arr = make_test_xzarray();
TEST_ASSERT_NOT_NULL(arr);
/* Store 3 items */
char* items[3] = { "alpha", "beta", "gamma" };
for (int i = 0; i < 3; i++) {
int idx = vstore(arr, items[i]);
TEST_ASSERT_TRUE(idx >= 0);
}
/* Freeze should succeed */
TEST_ASSERT_EQUAL_INT(0, vfreeze(arr));
/* After freeze, vget must still return stored values (COW mode active) */
void* out = NULL;
TEST_ASSERT_EQUAL_INT(0, vget(arr, 0, &out));
TEST_ASSERT_NOT_NULL(out);
/* Thaw to clean up */
vthaw(arr, NULL, NULL);
destroy(arr);
}
static void test_freeze_thaw_preserves_all_entries(void)
{
meta_obj_t* arr = make_test_xzarray();
TEST_ASSERT_NOT_NULL(arr);
int N = 5;
char* items[5] = { "a", "b", "c", "d", "e" };
int idxs[5];
for (int i = 0; i < N; i++) {
idxs[i] = vstore(arr, items[i]);
TEST_ASSERT_TRUE(idxs[i] >= 0);
}
TEST_ASSERT_EQUAL_INT(0, vfreeze(arr));
TEST_ASSERT_EQUAL_INT(0, vthaw(arr, NULL, NULL));
/* All 5 entries must be reachable after thaw */
for (int i = 0; i < N; i++) {
void* out = NULL;
TEST_ASSERT_EQUAL_INT(0, vget(arr, idxs[i], &out));
TEST_ASSERT_EQUAL_PTR(items[i], out);
}
destroy(arr);
}
static void test_mutation_during_cow_visible_after_thaw(void)
{
meta_obj_t* arr = make_test_xzarray();
TEST_ASSERT_NOT_NULL(arr);
char* original = "original";
char* updated = "updated";
int idx = vstore(arr, original);
TEST_ASSERT_TRUE(idx >= 0);
TEST_ASSERT_EQUAL_INT(0, vfreeze(arr));
/* Overwrite index during COW — write to new active array */
int put_idxs[1] = { idx };
void* put_data[1] = { updated };
vput_vec(arr, 1, put_idxs, put_data);
TEST_ASSERT_EQUAL_INT(0, vthaw(arr, NULL, NULL));
/* After thaw, the updated value must win over the shadow value */
void* out = NULL;
TEST_ASSERT_EQUAL_INT(0, vget(arr, idx, &out));
TEST_ASSERT_EQUAL_PTR(updated, out);
destroy(arr);
}
static void test_double_freeze_fails(void)
{
meta_obj_t* arr = make_test_xzarray();
TEST_ASSERT_NOT_NULL(arr);
TEST_ASSERT_EQUAL_INT(0, vfreeze(arr));
/* Second freeze must fail (already in COW mode) */
TEST_ASSERT_TRUE(vfreeze(arr) != 0);
vthaw(arr, NULL, NULL);
destroy(arr);
}
void test_zarray_cow(void)
{
printf("\n>> zarray COW interface slot test\n\n");
RUN_TEST(test_freeze_thaw_slots_exist);
RUN_TEST(test_freeze_creates_shadow);
RUN_TEST(test_freeze_thaw_preserves_all_entries);
RUN_TEST(test_mutation_during_cow_visible_after_thaw);
RUN_TEST(test_double_freeze_fails);
}

View file

@ -133,6 +133,29 @@ static int handle_msg(endpoint_t* ep)
(void*)ep);
}
/* Called by netio when the TCP connection drops (EPOLLHUP / EPOLLERR / read EOF).
* Unblocks any thread stuck in cbuf_get (e.g. xce_hello waiting for a reply)
* so that checking_connection can detect the failure and attempt to rejoin. */
static int handle_disconnect(endpoint_t* ep)
{
if (ep == NULL)
return 0;
xce_ctx_t* ctx = (xce_ctx_t*)ep->owner;
if (ctx == NULL)
return 0;
ctx->state = XCE_CTX_ERR;
/* Inject an error into wbuf so any blocked cbuf_get returns immediately.
* cbuf_tryput returns -EAGAIN if the buffer already has an item — that is
* fine: the existing item will wake the waiter. */
int err = -ECONNRESET;
cbuf_tryput(&ctx->wbuf, &err);
return 0;
}
int xce_ctx_init(xce_ctx_t* ctx, char* name, char* key)
{
int rc;
@ -237,6 +260,7 @@ int xce_ctx_start(xce_ctx_t* ctx)
endpoint_set_owner(ctx->ep, ctx);
endpoint_set_msg_handler(ctx->ep, (msg_handler_t)handle_msg);
endpoint_set_err_handler(ctx->ep, (err_handler_t)handle_disconnect);
rc = iomgr_monitor(ctx->ep);
if (rc < 0) {
ctx->state = XCE_CTX_ERR;

View file

@ -1,5 +1,7 @@
#include <assert.h>
#include <errno.h>
#include <rtti.h>
#include <stdatomic.h>
#include <stdlib.h>
#include <string.h>
@ -351,13 +353,16 @@ void* zarray_get(zarray_t* array, int idx)
}
typedef struct {
mylock_t lock;
} xzarray_private_t;
_Atomic _Bool inuse;
void* data;
} xzarray_item_t;
typedef struct {
unsigned int inuse : 1;
void* data;
} xzarray_item_t;
mylock_t lock;
xzarray_item_t* shadow; /* COW snapshot (may be NULL if array was empty at freeze) */
size_t shadow_caps; /* capacity of shadow array */
bool in_cow; /* true while between freeze and thaw */
} xzarray_private_t;
typedef struct {
int count;
@ -392,6 +397,10 @@ static int xzarray_init(meta_obj_t* obj, va_list ap)
a->pos = 0;
a->killer = params->datakiller;
priv->shadow = NULL;
priv->shadow_caps = 0;
priv->in_cow = false;
mylock_init(&(priv->lock));
return 1;
@ -403,8 +412,16 @@ static void xzarray_destroy(meta_obj_t* obj)
xzarray_t* a = RTTI_OBJ_TO_DATA(xzarray_t, obj);
int i;
for (i = 0; i < a->caps; i++) {
if (a->data[i].inuse) {
/* Free shadow if still present (e.g. destroy called mid-COW) */
if (priv->in_cow) {
free(priv->shadow);
priv->shadow = NULL;
priv->shadow_caps = 0;
priv->in_cow = false;
}
for (i = 0; i < (int)a->caps; i++) {
if (atomic_load_explicit(&a->data[i].inuse, memory_order_acquire)) {
if (a->killer != NULL) {
a->killer(a->data[i].data);
}
@ -437,14 +454,14 @@ static int xzarray_ensure_items(xzarray_t* array, int mincap)
array->data = p;
for (i = array->caps; i < nc; i++) {
array->data[i].inuse = 0;
atomic_store_explicit(&array->data[i].inuse, false, memory_order_release);
array->data[i].data = NULL;
}
/* this will make we can get next free
* location quickly
*/
if (array->data[array->pos].inuse) {
if (atomic_load_explicit(&array->data[array->pos].inuse, memory_order_acquire)) {
array->pos = array->caps;
}
@ -467,9 +484,9 @@ static int xzarray_store(meta_obj_t* obj, void* data)
idx = a->pos;
for (i = 0; i < a->caps; i++) {
if (a->data[idx].inuse == 0) {
a->data[idx].inuse = 1;
a->data[idx].data = data;
if (!atomic_load_explicit(&a->data[idx].inuse, memory_order_acquire)) {
a->data[idx].data = data;
atomic_store_explicit(&a->data[idx].inuse, true, memory_order_release);
a->count++;
rc = idx;
@ -503,7 +520,15 @@ static int xzarray_get_vec(meta_obj_t* obj, int count, int* idxs, void** data)
for (i = 0; i < count; i++) {
int idx = idxs[i];
if ((idx >= a->caps) || (a->data[idx].inuse == 0)) {
/* During COW, check active first, then shadow */
bool in_active = (idx < (int)a->caps) &&
atomic_load_explicit(&a->data[idx].inuse, memory_order_acquire);
bool in_shadow = !in_active && priv->in_cow &&
priv->shadow != NULL &&
(idx < (int)priv->shadow_caps) &&
atomic_load_explicit(&priv->shadow[idx].inuse, memory_order_acquire);
if (!in_active && !in_shadow) {
rc = -ENOENT;
break;
}
@ -513,7 +538,13 @@ static int xzarray_get_vec(meta_obj_t* obj, int count, int* idxs, void** data)
for (i = 0; i < count; i++) {
int idx = idxs[i];
data[i] = a->data[idx].data;
/* Active takes precedence over shadow */
if ((idx < (int)a->caps) &&
atomic_load_explicit(&a->data[idx].inuse, memory_order_acquire)) {
data[i] = a->data[idx].data;
} else {
data[i] = priv->shadow[idx].data;
}
}
}
return rc;
@ -557,14 +588,16 @@ static int xzarray_put_vec(meta_obj_t* obj, int count, int* idxs, void** data)
for (i = 0; i < count; i++) {
int idx = idxs[i];
if (a->data[idx].inuse == 1) {
if (atomic_load_explicit(&a->data[idx].inuse, memory_order_acquire)) {
if (a->killer != NULL) {
a->killer(a->data[idx].data);
}
} else {
a->count++;
}
a->data[idx].data = data[i];
a->data[idx].inuse = 1;
a->data[idx].data = data[i];
atomic_store_explicit(&a->data[idx].inuse, true, memory_order_release);
}
}
@ -584,15 +617,18 @@ static int xzarray_clear(meta_obj_t* obj, int idx)
xzarray_private_t* priv = (xzarray_private_t*)RTTI_OBJ_TO_PRIV(obj);
int rc = -EINVAL;
if (idx < a->caps) {
assert(!priv->in_cow && "xzarray_clear called during COW — deferred cleanup required");
if (idx < (int)a->caps) {
rc = 0;
if (a->data[idx].inuse) {
if (atomic_load_explicit(&a->data[idx].inuse, memory_order_acquire)) {
if (a->killer != NULL) {
a->killer(a->data[idx].data);
}
a->count--;
}
a->data[idx].inuse = 0;
atomic_store_explicit(&a->data[idx].inuse, false, memory_order_release);
a->data[idx].data = NULL;
a->pos = idx; /* we can find the available idx next time */
}
@ -614,8 +650,8 @@ static int xzarray_iter_next(iterator_t* iter, void** data)
xzarray_t* a = RTTI_OBJ_TO_DATA(xzarray_t, iter->obj);
int i;
for (i = (int)(long)iter->data; i < a->caps; i++) {
if (a->data[i].inuse) {
for (i = (int)(long)iter->data; i < (int)a->caps; i++) {
if (atomic_load_explicit(&a->data[i].inuse, memory_order_acquire)) {
*data = a->data[i].data;
iter->data = (void*)(long)(i + 1);
@ -656,6 +692,70 @@ static void xzarray_iter_destroy(iterator_t* iter)
return;
}
/* P0-2: COW freeze — O(1) pointer swap, pre-grow active array */
static int xzarray_freeze(meta_obj_t* obj)
{
xzarray_private_t* priv = (xzarray_private_t*)RTTI_OBJ_TO_PRIV(obj);
xzarray_t* a = RTTI_OBJ_TO_DATA(xzarray_t, obj);
if (priv->in_cow)
return -EALREADY; /* already frozen */
/* Pre-grow active to 1.5x + 16 to avoid realloc during COW (A6-026) */
size_t new_caps = a->caps + (a->caps / 2) + 16;
xzarray_item_t* active = calloc(new_caps, sizeof(xzarray_item_t));
if (!active)
return -ENOMEM;
priv->shadow = a->data; /* may be NULL if array was empty */
priv->shadow_caps = a->caps;
priv->in_cow = true;
a->data = active;
a->caps = new_caps;
a->count = 0;
/* Start pos past shadow to prevent index collisions with shadow entries */
a->pos = (int)priv->shadow_caps;
return 0;
}
/* P0-2: COW thaw — merge shadow into active, free shadow */
static int xzarray_thaw(meta_obj_t* obj, retain_fn_t should_retain, void* arg)
{
xzarray_private_t* priv = (xzarray_private_t*)RTTI_OBJ_TO_PRIV(obj);
xzarray_t* a = RTTI_OBJ_TO_DATA(xzarray_t, obj);
if (!priv->in_cow)
return -EINVAL; /* not in COW mode */
/* Merge: for each shadow entry not already written to active, copy it */
for (size_t i = 0; i < priv->shadow_caps; i++) {
if (!atomic_load_explicit(&priv->shadow[i].inuse, memory_order_acquire))
continue;
void* item = priv->shadow[i].data;
/* GC: skip entries that should not be retained */
if (should_retain != NULL && !should_retain(item, arg))
continue;
/* Only copy to active if active slot not already updated during COW */
if (i < a->caps &&
!atomic_load_explicit(&a->data[i].inuse, memory_order_acquire)) {
a->data[i].data = item;
atomic_store_explicit(&a->data[i].inuse, true, memory_order_release);
a->count++;
}
/* If active[i].inuse == true: mutation during COW wins — keep it */
}
free(priv->shadow);
priv->shadow = NULL;
priv->shadow_caps = 0;
priv->in_cow = false;
a->pos = 0;
return 0;
}
typeinfo_t xzarray_type = {
.name = "xzarray",
.privateSize = sizeof(xzarray_private_t),
@ -668,7 +768,9 @@ typeinfo_t xzarray_type = {
.vget_vec = (vget_vec_fn_t)xzarray_get_vec,
.vput = (vput_fn_t)xzarray_put,
.vput_vec = (vput_vec_fn_t)xzarray_put_vec,
.vclear = (vclear_fn_t)xzarray_clear },
.vclear = (vclear_fn_t)xzarray_clear,
.vfreeze = (vfreeze_fn_t)xzarray_freeze,
.vthaw = (vthaw_fn_t)xzarray_thaw },
.iter
= &(intf_iterator_t) { .make = (iter_make_fn_t)xzarray_make_iter,
.next = (iter_next_fn_t)xzarray_iter_next,

View file

@ -15,6 +15,7 @@
#include "kvmgr.h"
#include "mylock.h"
#include "plugin.h"
#include "xce/cluster.h"
#include "xce/types.h"
#include "xlist.h"
#include "xvec.h"
@ -110,10 +111,12 @@ typedef struct {
_Atomic int eval_in_progress; /* 1 while eval_select loop runs */
_Atomic int staging_merge_complete; /* 1 after staging merge completes */
select_eval_ctx_t* sel_eval_ctx; /* session-level memoisation context; NULL between sessions */
xce_cluster_config_t cluster_cfg; /* HA candidate list — empty means single-host mode */
} xce_scheduler_t;
extern int sched_init(xce_scheduler_t*, xce_client_config_t*,
xce_request_handler_t);
extern void sched_set_cluster_cfg(xce_scheduler_t*, const xce_cluster_config_t*);
extern void sched_fini(xce_scheduler_t*);
extern int sched_init_config(xce_scheduler_t* sched, xce_conf4sched_t*);
extern int sched_update(xce_scheduler_t*);

View file

@ -2,10 +2,12 @@
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include "client.h"
#include "controller.capnp.h"
#include "xce/cluster.h"
#include "xce/types.h"
#include "scheduler.h"
@ -13,6 +15,7 @@
#include "resreq.h"
#include "utils.h"
extern int scheduler_req_handler(xce_request_t*, void*);
static char doc[] = "xce_scheduler";
@ -24,11 +27,15 @@ const char* argp_program_bug_address = BUG_EMAIL;
struct arguments {
char* logfile;
int verbose;
char* peers; /* --peers value (optional) */
char* ca_cert; /* --ca-cert value (optional) */
};
static struct argp_option options[] = {
{ "logfile", 'l', "FILE", 0, "The full path of log file" },
{ "verbose", 'v', 0, 0, "Produce verbose log" },
{ "logfile", 'l', "FILE", 0, "The full path of log file" },
{ "verbose", 'v', 0, 0, "Produce verbose log" },
{ "peers", 'P', "LIST", 0, "HA candidate list: [name=]host:port,..." },
{ "ca-cert", 'C', "FILE", 0, "CA certificate path for TLS verification" },
{ 0 }
};
@ -43,6 +50,12 @@ static error_t parse_opt(int key, char* arg, struct argp_state* state)
case 'v':
arguments->verbose++;
break;
case 'P':
arguments->peers = arg;
break;
case 'C':
arguments->ca_cert = arg;
break;
default:
return ARGP_ERR_UNKNOWN;
}
@ -109,6 +122,42 @@ int main(int argc, char* argv[])
mylog_init(logfile, loglevel, logsize, logcount);
/* P2.5-6: populate and validate xce_cluster_config_t when --peers given */
if (arguments.peers) {
xce_cluster_config_t cluster_cfg = {0};
if (xce_parse_peers(arguments.peers, &cluster_cfg) != 0) {
fprintf(stderr,
"xce_scheduler: --peers parse error: expected "
"\"[name=]host:port,...\" got \"%s\"\n",
arguments.peers);
return 1;
}
if (arguments.ca_cert) {
strncpy(cluster_cfg.ca_cert_path, arguments.ca_cert,
sizeof(cluster_cfg.ca_cert_path) - 1);
}
strncpy(cluster_cfg.identity, "xce_scheduler",
sizeof(cluster_cfg.identity) - 1);
if (xce_cluster_config_validate(&cluster_cfg) != 0) {
fprintf(stderr,
"xce_scheduler: invalid cluster config (missing "
"--ca-cert?).\n");
return 1;
}
if (cluster_cfg.candidates_count > 3) {
WARN("xce_scheduler: --peers has %d candidates; "
"only <=3 validated for HA",
cluster_cfg.candidates_count);
}
INFO("HA cluster config: %d candidate(s), identity=%s, ca_cert=%s",
cluster_cfg.candidates_count, cluster_cfg.identity,
cluster_cfg.ca_cert_path);
sched_set_cluster_cfg(&sched, &cluster_cfg);
} else {
INFO("No --peers flag; using single-controller path");
}
init_config(&config);
sched_init(&sched, &config, scheduler_req_handler);

View file

@ -207,6 +207,7 @@ static void* fcfs_elect_job(xce_sched_plugin_t* plugin)
ordq_item_t* it = NULL;
int go = 0;
uint64_t jobid = 0;
char jobidString[64];
pthread_testcancel();
@ -233,7 +234,8 @@ static void* fcfs_elect_job(xce_sched_plugin_t* plugin)
if (sched_job_is_ready(j->job)) {
jobid = j->job->jobid;
TRACE("%s jobid %ld", __func__, jobid);
fmt_jobid(jobidString, sizeof(jobidString), jobid);
TRACE("%s jobid %s", __func__, jobidString);
cbuf_put(&(sched->jbuf), &jobid);
}

Some files were not shown because too many files have changed in this diff Show more