- myconf_t gains peers_cfg (xce_cluster_config_t) populated from --peers - controller/main.c: parse --peers host:port,... via argp; ca_cert_path auto-derived from workdir/certs/xce.crt (no --ca-cert flag needed) - cluster_init: derive my_priority from position in peers list (index 0 = primary); fall back to 0 when --peers not given (single-node mode)
1498 lines
51 KiB
Markdown
1498 lines
51 KiB
Markdown
# P2: Replication (Fan-Out TCP) — TDD Implementation Plan
|
|
|
|
**Branch**: `persistent_ha`
|
|
**Date**: 2026-05-07
|
|
**Prerequisite**: P1 complete — 115 controller tests green.
|
|
**Goal**: Primary replicates state changes to standbys in real time via the existing iomgr/SSL endpoint pipeline.
|
|
|
|
---
|
|
|
|
## Scoping Notes
|
|
|
|
- Replication channels **reuse the existing iomgr/SSL endpoint pipeline** — no new TLS infrastructure.
|
|
- `OP_replAck` is handled **directly in the io_worker read callback** (`handle_msg()` before vtask dispatch) to prevent deadlock: if all vcpu threads block in `repl_wait_ack()`, the ACK must still be deliverable.
|
|
- `OP_replCatchUpDone` is also handled in the io_worker read callback for the same latency reason.
|
|
- P2.5 (libxce smart discovery) is a separate effort — not included in this plan. P2.5-7 (API server discovery) ships together with P2.
|
|
- `WAL_CHECKPOINT` entries are **never replicated** — `repl_broadcast()` must assert `type != WAL_CHECKPOINT`.
|
|
- `repl_broadcast()` must check `ha.role == CTRL_PRIMARY` before sending (split-brain guard).
|
|
- Worker joins are always **async-replicated** — never sync — due to workermgr WRITE lock held during `add_worker()`.
|
|
|
|
---
|
|
|
|
## Task P2-1: `repl.h` + `repl.c` — Structures, init/fini, fan-out send, ACK tracking
|
|
|
|
### Files
|
|
|
|
| Action | File |
|
|
|--------|------|
|
|
| Create | `src/controller/repl.h` |
|
|
| Create | `src/controller/repl.c` |
|
|
| Modify | `src/controller/meson.build` (add `repl.c`) |
|
|
|
|
### Failing Test (RED)
|
|
|
|
**File**: `src/controller/testsuite/test_repl_unit.c` (new)
|
|
|
|
```c
|
|
/* test_repl_unit.c — P2-1 RED: repl_init/fini, add/remove standby,
|
|
* trim_lsn with disconnected-slot expiry */
|
|
#include <unity/unity.h>
|
|
#include <stdint.h>
|
|
#include "repl.h"
|
|
|
|
/* Stub endpoint for unit tests — repl.h forward-declares endpoint_t */
|
|
typedef struct endpoint_s { int dummy; } endpoint_t;
|
|
|
|
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;
|
|
endpoint_t ep = {0};
|
|
repl_init(&r);
|
|
|
|
int idx = repl_add_standby(&r, &ep, "standby1", 7001);
|
|
TEST_ASSERT_TRUE(idx >= 0);
|
|
TEST_ASSERT_EQUAL_INT(1, r.nstandbys);
|
|
|
|
repl_remove_standby(&r, &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;
|
|
endpoint_t eps[MAX_STANDBYS + 1];
|
|
repl_init(&r);
|
|
for (int i = 0; i < MAX_STANDBYS; i++) {
|
|
int rc = repl_add_standby(&r, &eps[i], "sb", 7000 + i);
|
|
TEST_ASSERT_TRUE(rc >= 0);
|
|
}
|
|
/* One over limit must fail */
|
|
int rc = repl_add_standby(&r, &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);
|
|
/* With no standbys, trim_lsn == checkpoint_lsn */
|
|
uint64_t trim = repl_trim_lsn(&r, /*checkpoint_lsn=*/42, /*snap_interval_s=*/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;
|
|
endpoint_t ep = {0};
|
|
repl_init(&r);
|
|
|
|
repl_add_standby(&r, &ep, "sb", 7001);
|
|
/* Simulate standby ACKing lsn=10 then disconnecting */
|
|
repl_on_ack(&r, &ep, 10);
|
|
repl_remove_standby(&r, &ep);
|
|
|
|
/* Immediately after disconnect: trim guard uses last_disconnected_lsn=10, not checkpoint=50 */
|
|
uint64_t trim = repl_trim_lsn(&r, 50, 60);
|
|
TEST_ASSERT_EQUAL_UINT64(10, trim);
|
|
|
|
/* After snap_interval_s has elapsed (simulate by forcing time forward):
|
|
* slot expiry resets disconnected_lsn → trim == checkpoint_lsn */
|
|
repl_expire_disconnect_slots(&r, /*now_s=*/999999, /*snap_interval_s=*/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");
|
|
}
|
|
|
|
int main(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);
|
|
return UNITY_END();
|
|
}
|
|
```
|
|
|
|
### Minimal Implementation (GREEN)
|
|
|
|
**`src/controller/repl.h`**:
|
|
|
|
```c
|
|
#pragma once
|
|
/*
|
|
* repl.h — P2-1: Replicator structures and public API.
|
|
*
|
|
* Fan-out TCP replication over the existing iomgr/SSL endpoint pipeline.
|
|
* ACKs are processed in io_worker read callback (NOT vtaskmgr) to prevent
|
|
* deadlock when all vcpu threads are blocked in repl_wait_ack().
|
|
*/
|
|
#include <pthread.h>
|
|
#include <stdatomic.h>
|
|
#include <stdint.h>
|
|
#include <time.h>
|
|
|
|
#include "config.h" /* MAX_STANDBYS, XCE_MAXNAME_LEN */
|
|
#include "mylock.h"
|
|
|
|
/* Forward declaration — avoids pulling in full netio.h here */
|
|
typedef struct endpoint_s endpoint_t;
|
|
|
|
/* ── Per-standby connection slot ─────────────────────────────────── */
|
|
typedef struct {
|
|
endpoint_t* ep; /* NULL = slot empty */
|
|
_Atomic uint64_t acked_lsn; /* Last LSN ACKed by this standby */
|
|
_Atomic int in_catch_up; /* Non-zero while standby is catching up */
|
|
uint64_t join_applied_lsn; /* LSN standby reported in OP_replJoin */
|
|
int lagging;
|
|
char host[XCE_MAXNAME_LEN];
|
|
uint32_t port;
|
|
/* Disconnect tracking for WAL trim guard */
|
|
uint64_t last_disconnected_lsn;
|
|
time_t last_disconnect_time;
|
|
} standby_conn_t;
|
|
|
|
/* ── Replicator ───────────────────────────────────────────────────── */
|
|
typedef struct {
|
|
standby_conn_t standbys[MAX_STANDBYS];
|
|
int nstandbys; /* Active standby count */
|
|
mylock_t lock; /* Protects standbys[], nstandbys */
|
|
uint64_t pending_lsn; /* LSN we're waiting ACK for (sync) */
|
|
pthread_cond_t ack_cond; /* Signaled when ACK arrives */
|
|
pthread_mutex_t ack_lock; /* Guards ack_cond + pending_lsn */
|
|
} replicator_t;
|
|
|
|
/* ── Lifecycle ───────────────────────────────────────────────────── */
|
|
int repl_init(replicator_t *r);
|
|
void repl_fini(replicator_t *r);
|
|
|
|
/* ── Standby management ──────────────────────────────────────────── */
|
|
/* Returns slot index >= 0 on success, -ENOBUFS when all MAX_STANDBYS slots used */
|
|
int repl_add_standby(replicator_t *r, endpoint_t *ep,
|
|
const char *host, uint32_t port);
|
|
/* Removes the standby slot matching ep; records disconnect time/lsn for trim guard */
|
|
void repl_remove_standby(replicator_t *r, endpoint_t *ep);
|
|
/* Zero-out disconnect slots whose expiry time has passed (slot expiry rule) */
|
|
void repl_expire_disconnect_slots(replicator_t *r, time_t now_s,
|
|
uint32_t snap_interval_s);
|
|
|
|
/* ── Replication send paths ──────────────────────────────────────── */
|
|
/*
|
|
* repl_broadcast: send WAL entry to all connected standbys via endpoint outq.
|
|
* Asserts type != WAL_CHECKPOINT. Checks ha.role == CTRL_PRIMARY first.
|
|
* On send failure, marks standby for removal AFTER completing the fan-out.
|
|
* Returns number of standbys successfully sent to (>= 0).
|
|
*/
|
|
int repl_broadcast(replicator_t *r, const wal_entry_hdr_t *hdr,
|
|
const void *payload, uint32_t payload_len);
|
|
|
|
/* ── ACK handling (called from io_worker read callback, NOT vtaskmgr) ── */
|
|
/*
|
|
* repl_on_ack: update acked_lsn for the standby matching ep and signal
|
|
* ack_cond. Only does pthread_cond_signal + atomic store — safe in io_worker.
|
|
*/
|
|
void repl_on_ack(replicator_t *r, endpoint_t *ep, uint64_t acked_lsn);
|
|
|
|
/* ── Sync replication ────────────────────────────────────────────── */
|
|
/*
|
|
* repl_wait_ack: block until at least one non-catching-up standby ACKs
|
|
* target_lsn, or timeout_ms elapses.
|
|
* Returns 0 on success, -ETIMEDOUT on timeout, -ENODEV if no standbys.
|
|
*/
|
|
int repl_wait_ack(replicator_t *r, uint64_t target_lsn, uint32_t timeout_ms);
|
|
|
|
/* ── WAL trim guard ──────────────────────────────────────────────── */
|
|
/*
|
|
* repl_trim_lsn: compute the safe WAL trim point.
|
|
* Returns min(min_non_catchup(acked_lsn), min_catchup(join_applied_lsn),
|
|
* min_disconnected_not_expired(last_disconnected_lsn),
|
|
* checkpoint_lsn).
|
|
* When no standbys and no live disconnect holds, returns checkpoint_lsn.
|
|
*/
|
|
uint64_t repl_trim_lsn(replicator_t *r, uint64_t checkpoint_lsn,
|
|
uint32_t snap_interval_s);
|
|
```
|
|
|
|
**`src/controller/repl.c`** (minimal for P2-1 tests to pass):
|
|
|
|
```c
|
|
/*
|
|
* repl.c — P2-1 minimal: lifecycle, slot management, trim_lsn.
|
|
* Fan-out send and ACK wired in P2-4.
|
|
*/
|
|
#include <assert.h>
|
|
#include <errno.h>
|
|
#include <string.h>
|
|
#include <time.h>
|
|
|
|
#include "config.h"
|
|
#include "persist.h" /* wal_entry_hdr_t, WAL_CHECKPOINT */
|
|
#include "repl.h"
|
|
#include "log.h"
|
|
|
|
int repl_init(replicator_t *r)
|
|
{
|
|
memset(r, 0, sizeof(*r));
|
|
mylock_init(&r->lock);
|
|
pthread_mutex_init(&r->ack_lock, NULL);
|
|
pthread_cond_init(&r->ack_cond, NULL);
|
|
return 0;
|
|
}
|
|
|
|
void repl_fini(replicator_t *r)
|
|
{
|
|
pthread_cond_destroy(&r->ack_cond);
|
|
pthread_mutex_destroy(&r->ack_lock);
|
|
mylock_fini(&r->lock);
|
|
}
|
|
|
|
int repl_add_standby(replicator_t *r, endpoint_t *ep,
|
|
const char *host, uint32_t port)
|
|
{
|
|
mylock_wrlock(&r->lock);
|
|
if (r->nstandbys >= MAX_STANDBYS) {
|
|
mylock_unlock(&r->lock);
|
|
return -ENOBUFS;
|
|
}
|
|
/* Find empty slot */
|
|
for (int i = 0; i < MAX_STANDBYS; i++) {
|
|
if (r->standbys[i].ep == NULL) {
|
|
standby_conn_t *s = &r->standbys[i];
|
|
memset(s, 0, sizeof(*s));
|
|
s->ep = ep;
|
|
s->port = port;
|
|
snprintf(s->host, XCE_MAXNAME_LEN, "%s", host);
|
|
r->nstandbys++;
|
|
mylock_unlock(&r->lock);
|
|
return i;
|
|
}
|
|
}
|
|
mylock_unlock(&r->lock);
|
|
return -ENOBUFS;
|
|
}
|
|
|
|
void repl_remove_standby(replicator_t *r, endpoint_t *ep)
|
|
{
|
|
mylock_wrlock(&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 =
|
|
(uint64_t)atomic_load_explicit(&s->acked_lsn, memory_order_acquire);
|
|
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)
|
|
{
|
|
mylock_wrlock(&r->lock);
|
|
for (int i = 0; i < MAX_STANDBYS; i++) {
|
|
standby_conn_t *s = &r->standbys[i];
|
|
if (s->ep == NULL && s->last_disconnected_lsn > 0) {
|
|
if (now_s >= s->last_disconnect_time + (time_t)snap_interval_s) {
|
|
s->last_disconnected_lsn = 0;
|
|
s->last_disconnect_time = 0;
|
|
}
|
|
}
|
|
}
|
|
mylock_unlock(&r->lock);
|
|
}
|
|
|
|
void repl_on_ack(replicator_t *r, endpoint_t *ep, uint64_t lsn)
|
|
{
|
|
for (int i = 0; i < MAX_STANDBYS; i++) {
|
|
if (r->standbys[i].ep == ep) {
|
|
atomic_store_explicit(&r->standbys[i].acked_lsn, lsn,
|
|
memory_order_release);
|
|
break;
|
|
}
|
|
}
|
|
pthread_mutex_lock(&r->ack_lock);
|
|
pthread_cond_signal(&r->ack_cond);
|
|
pthread_mutex_unlock(&r->ack_lock);
|
|
}
|
|
|
|
int repl_broadcast(replicator_t *r, const wal_entry_hdr_t *hdr,
|
|
const void *payload, uint32_t payload_len)
|
|
{
|
|
assert(hdr->type != WAL_CHECKPOINT);
|
|
/* Stub for P2-1; full implementation in P2-4 */
|
|
(void)payload; (void)payload_len;
|
|
return 0;
|
|
}
|
|
|
|
int repl_wait_ack(replicator_t *r, uint64_t target_lsn, uint32_t timeout_ms)
|
|
{
|
|
/* Stub for P2-1; full implementation in P2-3 */
|
|
(void)target_lsn; (void)timeout_ms;
|
|
return -ENODEV;
|
|
}
|
|
|
|
uint64_t repl_trim_lsn(replicator_t *r, uint64_t checkpoint_lsn,
|
|
uint32_t snap_interval_s)
|
|
{
|
|
uint64_t floor = checkpoint_lsn;
|
|
mylock_rdlock(&r->lock);
|
|
for (int i = 0; i < MAX_STANDBYS; i++) {
|
|
standby_conn_t *s = &r->standbys[i];
|
|
if (s->ep != NULL) {
|
|
/* Active standby */
|
|
uint64_t lsn;
|
|
if (atomic_load_explicit(&s->in_catch_up, memory_order_acquire)) {
|
|
lsn = s->join_applied_lsn;
|
|
} else {
|
|
lsn = (uint64_t)atomic_load_explicit(&s->acked_lsn,
|
|
memory_order_acquire);
|
|
}
|
|
if (lsn < floor) floor = lsn;
|
|
} else if (s->last_disconnected_lsn > 0) {
|
|
/* Disconnected slot still within grace window */
|
|
time_t now = time(NULL);
|
|
time_t expiry = s->last_disconnect_time + (time_t)snap_interval_s;
|
|
if (now < expiry && s->last_disconnected_lsn < floor) {
|
|
floor = s->last_disconnected_lsn;
|
|
}
|
|
}
|
|
}
|
|
mylock_unlock(&r->lock);
|
|
return floor;
|
|
}
|
|
```
|
|
|
|
**`src/controller/meson.build`** — add `'repl.c'` to the controller sources list.
|
|
|
|
### Commit
|
|
|
|
```bash
|
|
git add src/controller/repl.h src/controller/repl.c \
|
|
src/controller/testsuite/test_repl_unit.c \
|
|
src/controller/meson.build
|
|
git commit -m "feat: P2-1 repl.h/repl.c — replicator lifecycle, slot management, trim_lsn"
|
|
```
|
|
|
|
---
|
|
|
|
## Task P2-2: Standby receiver — `apply_wal_entry()` + `OP_replEntry` dispatch
|
|
|
|
### Files
|
|
|
|
| Action | File |
|
|
|--------|------|
|
|
| Modify | `src/controller/repl.c` (add `apply_wal_entry`, standby `OP_replEntry` handler) |
|
|
| Modify | `src/controller/repl.h` (export `apply_wal_entry`) |
|
|
| Modify | `src/controller/cluster.c` (add `OPCode_replEntry` dispatch in `handle_msg()`) |
|
|
|
|
### Key Design Rules
|
|
|
|
- `apply_wal_entry()` is identical to `persist_recover()`'s WAL replay path — no hooks fire (hooks are not registered on standbys; the hook system is empty until `ha_promote_to_primary()` step 5).
|
|
- `OP_replEntry` is dispatched through vtaskmgr (normal path) since it is not ACK traffic.
|
|
- After applying, standby sends `OP_replAck` with its new `applied_lsn` via the endpoint outq (NOT raw `send()`).
|
|
- `ha_state_t.last_received_crc` tracks the CRC chain for the standby.
|
|
- `apply_wal_entry()` on unknown entry types: log `WARN`, return 0 (skip — forward compatibility).
|
|
|
|
### Failing Test (RED)
|
|
|
|
**File**: `src/controller/testsuite/test_repl_apply.c` (new)
|
|
|
|
```c
|
|
/* test_repl_apply.c — P2-2 RED: apply_wal_entry() state mutations */
|
|
#include <unity/unity.h>
|
|
#include <stdint.h>
|
|
#include <string.h>
|
|
#include "persist.h"
|
|
#include "repl.h"
|
|
|
|
/* Lightweight cluster_t stub sufficient for apply_wal_entry() */
|
|
#include "cluster_stub_for_repl.h" /* test-only minimal cluster_t stub */
|
|
|
|
static void test_apply_wal_entry_job_submit_inserts_job(void)
|
|
{
|
|
cluster_t c = make_test_cluster();
|
|
wal_entry_hdr_t hdr = {
|
|
.type = WAL_JOB_SUBMIT, .lsn = 1, .term = 1,
|
|
.payload_len = sizeof(test_job_submit_payload),
|
|
};
|
|
TEST_ASSERT_EQUAL_INT(0, apply_wal_entry(&c, &hdr, test_job_submit_payload));
|
|
/* Job must exist in kvmgr after apply */
|
|
TEST_ASSERT_NOT_NULL(find_job(&c, TEST_JOB_ID));
|
|
cluster_teardown(&c);
|
|
printf("test_apply_wal_entry_job_submit_inserts_job: PASS\n");
|
|
}
|
|
|
|
static void test_apply_wal_entry_unknown_type_skips(void)
|
|
{
|
|
cluster_t c = make_test_cluster();
|
|
wal_entry_hdr_t hdr = {
|
|
.type = 99, /* Unknown type — must skip without error */
|
|
.lsn = 1, .term = 1, .payload_len = 0,
|
|
};
|
|
TEST_ASSERT_EQUAL_INT(0, apply_wal_entry(&c, &hdr, NULL));
|
|
cluster_teardown(&c);
|
|
printf("test_apply_wal_entry_unknown_type_skips: PASS\n");
|
|
}
|
|
|
|
static void test_apply_wal_entry_oversized_payload_rejected(void)
|
|
{
|
|
cluster_t c = make_test_cluster();
|
|
wal_entry_hdr_t hdr = {
|
|
.type = WAL_JOB_SUBMIT,
|
|
.lsn = 2, .term = 1,
|
|
.payload_len = MAX_WAL_PAYLOAD_BYTES + 1,
|
|
};
|
|
/* apply_wal_entry must reject oversize without malloc */
|
|
TEST_ASSERT_NOT_EQUAL(0, apply_wal_entry(&c, &hdr, /*payload=*/NULL));
|
|
cluster_teardown(&c);
|
|
printf("test_apply_wal_entry_oversized_payload_rejected: PASS\n");
|
|
}
|
|
|
|
int main(void)
|
|
{
|
|
UNITY_BEGIN();
|
|
RUN_TEST(test_apply_wal_entry_job_submit_inserts_job);
|
|
RUN_TEST(test_apply_wal_entry_unknown_type_skips);
|
|
RUN_TEST(test_apply_wal_entry_oversized_payload_rejected);
|
|
return UNITY_END();
|
|
}
|
|
```
|
|
|
|
### Minimal Implementation (GREEN)
|
|
|
|
**Add to `repl.h`**:
|
|
|
|
```c
|
|
/*
|
|
* apply_wal_entry: apply one WAL entry to cluster state.
|
|
* Used by both standby replication (OP_replEntry) and catch-up replay.
|
|
* Does NOT fire hooks. Does NOT write to local WAL.
|
|
* Returns 0 on success, negative errno on error.
|
|
* Unknown entry types: log WARN, return 0 (skip).
|
|
* Oversized payload (> MAX_WAL_PAYLOAD_BYTES): return -EMSGSIZE.
|
|
*/
|
|
int apply_wal_entry(cluster_t *c, const wal_entry_hdr_t *hdr,
|
|
const void *payload);
|
|
```
|
|
|
|
**Add to `repl.c`**:
|
|
|
|
```c
|
|
#include "cluster.h" /* cluster_t */
|
|
#include "job.c" /* restore_job() etc. */
|
|
|
|
int apply_wal_entry(cluster_t *c, const wal_entry_hdr_t *hdr,
|
|
const void *payload)
|
|
{
|
|
if (hdr->payload_len > MAX_WAL_PAYLOAD_BYTES) {
|
|
ERROR("apply_wal_entry: oversized payload %u for lsn=%lu",
|
|
hdr->payload_len, hdr->lsn);
|
|
return -EMSGSIZE;
|
|
}
|
|
switch (hdr->type) {
|
|
case WAL_JOB_SUBMIT: {
|
|
/* Use restore_job() — inserts job at original ID, no new allocation */
|
|
uint64_t jobid = /* decode from payload */ 0;
|
|
return restore_job(c, jobid, payload, hdr->payload_len);
|
|
}
|
|
case WAL_JOB_START:
|
|
case WAL_JOB_FINISH:
|
|
case WAL_JOB_FAIL:
|
|
case WAL_JOB_STOP:
|
|
case WAL_JOB_RESUME:
|
|
case WAL_JOB_UPDATE:
|
|
case WAL_JOB_RUSAGE:
|
|
case WAL_JOB_CKPT:
|
|
case WAL_JOB_PREASON:
|
|
DEBUG("apply_wal_entry: WAL_JOB_PREASON replay for jobid — pending reason text not preserved");
|
|
return apply_job_wal(c, hdr->type, payload, hdr->payload_len);
|
|
case WAL_QUEUE_CTRL:
|
|
return apply_queue_wal(c, payload, hdr->payload_len);
|
|
case WAL_WORKER_CTRL:
|
|
return apply_worker_wal(c, payload, hdr->payload_len);
|
|
default:
|
|
WARN("apply_wal_entry: unknown type=%u lsn=%lu — skipping (forward compat)",
|
|
hdr->type, hdr->lsn);
|
|
return 0;
|
|
}
|
|
}
|
|
```
|
|
|
|
**Add to `cluster.c` `handle_msg()` dispatch switch** (inside `myreq_handler`):
|
|
|
|
```c
|
|
case OPCode_replEntry:
|
|
rc = handle_repl_entry(c, ep, &request);
|
|
break;
|
|
```
|
|
|
|
**`handle_repl_entry()` in `repl.c`**:
|
|
|
|
```c
|
|
/* Called from vtask — decodes OP_replEntry, calls apply_wal_entry(),
|
|
* writes local WAL copy, updates last_received_crc, sends OP_replAck */
|
|
static int handle_repl_entry(cluster_t *c, endpoint_t *ep,
|
|
xce_request_t *req)
|
|
{
|
|
ha_state_t *ha = &c->ha;
|
|
/* Decode header + payload from req */
|
|
wal_entry_hdr_t hdr;
|
|
void *payload;
|
|
int rc = decode_repl_entry(req, &hdr, &payload);
|
|
if (rc) return rc;
|
|
|
|
/* CRC chain validation */
|
|
if (hdr.prev_crc != WAL_CRC_NONE &&
|
|
hdr.prev_crc != ha->last_received_crc) {
|
|
ERROR("repl: CRC chain break at lsn=%lu expected=%08x got=%08x",
|
|
hdr.lsn, ha->last_received_crc, hdr.prev_crc);
|
|
free(payload);
|
|
return -EBADMSG;
|
|
}
|
|
|
|
/* Write to local WAL (standby maintains its own WAL) */
|
|
rc = persist_wal_write_raw(&c->persist, &hdr, payload);
|
|
if (rc) { free(payload); return rc; }
|
|
|
|
/* Apply to in-memory state */
|
|
rc = apply_wal_entry(c, &hdr, payload);
|
|
free(payload);
|
|
if (rc) {
|
|
ERROR("replication: apply_wal_entry failed lsn=%lu rc=%d"
|
|
" — breaking replication stream", hdr.lsn, rc);
|
|
return rc;
|
|
}
|
|
|
|
ha->last_received_crc = hdr.crc;
|
|
atomic_store_explicit(&ha->applied_lsn, hdr.lsn, memory_order_release);
|
|
|
|
/* Send OP_replAck via endpoint outq (if NOT in catch-up for historical entries) */
|
|
if (!atomic_load_explicit(&ha->in_catch_up, memory_order_acquire)) {
|
|
send_repl_ack(ep, hdr.lsn);
|
|
}
|
|
return 0;
|
|
}
|
|
```
|
|
|
|
### Commit
|
|
|
|
```bash
|
|
git add src/controller/repl.h src/controller/repl.c \
|
|
src/controller/cluster.c \
|
|
src/controller/testsuite/test_repl_apply.c
|
|
git commit -m "feat: P2-2 apply_wal_entry + standby OP_replEntry dispatch via vtask"
|
|
```
|
|
|
|
---
|
|
|
|
## Task P2-3: Sync replication — `repl_wait_ack()` with per-standby condition variable
|
|
|
|
### Files
|
|
|
|
| Action | File |
|
|
|--------|------|
|
|
| Modify | `src/controller/repl.c` (implement `repl_wait_ack()` fully) |
|
|
| Modify | `src/controller/myconf.c` (add `vcpus < 16` WARN when `sync_timeout_ms > 0`) |
|
|
| Modify | `src/controller/persist.c` (add startup WARN in `persist_start()`) |
|
|
|
|
### ACK Deadlock Prevention (Critical)
|
|
|
|
`repl_on_ack()` is called **directly from the io_worker read callback** (`handle_msg()`) for `OPCode_replAck` — **before** vtask dispatch. It only does:
|
|
1. `atomic_store` on `acked_lsn`
|
|
2. `pthread_cond_signal` on `ack_cond`
|
|
|
|
This means ACKs are delivered even when all vcpu threads are blocked in `repl_wait_ack()`.
|
|
|
|
In `handle_msg()` in `cluster.c`, add a pre-vtask ACK shortcircuit:
|
|
|
|
```c
|
|
/* Handle replAck BEFORE vtask dispatch — prevents deadlock when all vcpus
|
|
* are blocked in repl_wait_ack(). repl_on_ack() is async-signal-safe for
|
|
* this context: only atomic_store + pthread_cond_signal. */
|
|
if (request.opcode == OPCode_replAck) {
|
|
uint64_t acked_lsn = decode_repl_ack_lsn(&request);
|
|
repl_on_ack(&c->repl, ep, acked_lsn);
|
|
free_Request(&request);
|
|
return 0; /* Do NOT enqueue vtask */
|
|
}
|
|
/* Handle replCatchUpDone inline too — same deadlock risk */
|
|
if (request.opcode == OPCode_replCatchUpDone) {
|
|
handle_catch_up_done_inline(c, ep);
|
|
free_Request(&request);
|
|
return 0;
|
|
}
|
|
```
|
|
|
|
### Failing Test (RED)
|
|
|
|
**File**: `src/controller/testsuite/test_repl_sync.c` (new)
|
|
|
|
```c
|
|
/* test_repl_sync.c — P2-3 RED: repl_wait_ack timeout + ACK delivery */
|
|
#include <pthread.h>
|
|
#include <unity/unity.h>
|
|
#include "repl.h"
|
|
|
|
static replicator_t g_repl;
|
|
|
|
static void* ack_thread(void *arg)
|
|
{
|
|
uint64_t lsn = *(uint64_t *)arg;
|
|
/* Small delay to ensure wait_ack is already blocking */
|
|
struct timespec ts = {0, 10000000}; /* 10ms */
|
|
nanosleep(&ts, NULL);
|
|
/* Simulate ACK arriving: slot 0 endpoint matches */
|
|
repl_on_ack(&g_repl, g_repl.standbys[0].ep, lsn);
|
|
return NULL;
|
|
}
|
|
|
|
static void test_repl_wait_ack_succeeds_with_one_standby(void)
|
|
{
|
|
repl_init(&g_repl);
|
|
|
|
/* Add a fake standby with a sentinel ep pointer */
|
|
endpoint_t fake_ep = {0};
|
|
repl_add_standby(&g_repl, &fake_ep, "sb1", 7001);
|
|
|
|
uint64_t target_lsn = 42;
|
|
pthread_t tid;
|
|
pthread_create(&tid, NULL, ack_thread, &target_lsn);
|
|
|
|
int rc = repl_wait_ack(&g_repl, target_lsn, /*timeout_ms=*/500);
|
|
TEST_ASSERT_EQUAL_INT(0, rc);
|
|
|
|
pthread_join(tid, NULL);
|
|
repl_fini(&g_repl);
|
|
printf("test_repl_wait_ack_succeeds_with_one_standby: PASS\n");
|
|
}
|
|
|
|
static void test_repl_wait_ack_timeout_no_ack(void)
|
|
{
|
|
repl_init(&g_repl);
|
|
endpoint_t fake_ep = {0};
|
|
repl_add_standby(&g_repl, &fake_ep, "sb1", 7001);
|
|
|
|
/* timeout=50ms, no ACK delivered → must return -ETIMEDOUT */
|
|
int rc = repl_wait_ack(&g_repl, 99, 50);
|
|
TEST_ASSERT_EQUAL_INT(-ETIMEDOUT, rc);
|
|
|
|
repl_fini(&g_repl);
|
|
printf("test_repl_wait_ack_timeout_no_ack: PASS\n");
|
|
}
|
|
|
|
static void test_repl_wait_ack_no_standbys(void)
|
|
{
|
|
repl_init(&g_repl);
|
|
int rc = repl_wait_ack(&g_repl, 1, 50);
|
|
TEST_ASSERT_EQUAL_INT(-ENODEV, rc);
|
|
repl_fini(&g_repl);
|
|
printf("test_repl_wait_ack_no_standbys: PASS\n");
|
|
}
|
|
|
|
static void test_repl_wait_ack_skips_catching_up_standby(void)
|
|
{
|
|
repl_init(&g_repl);
|
|
endpoint_t fake_ep = {0};
|
|
repl_add_standby(&g_repl, &fake_ep, "sb1", 7001);
|
|
/* Mark standby as in-catch-up — should not count for ACK predicate */
|
|
atomic_store(&g_repl.standbys[0].in_catch_up, 1);
|
|
|
|
int rc = repl_wait_ack(&g_repl, 42, 50);
|
|
/* Only catching-up standby → treated same as no standbys → -ENODEV
|
|
* (degraded mode: local WAL is durable, no sync standby to ACK) */
|
|
TEST_ASSERT_TRUE(rc == -ETIMEDOUT || rc == -ENODEV);
|
|
|
|
repl_fini(&g_repl);
|
|
printf("test_repl_wait_ack_skips_catching_up_standby: PASS\n");
|
|
}
|
|
|
|
int main(void)
|
|
{
|
|
UNITY_BEGIN();
|
|
RUN_TEST(test_repl_wait_ack_succeeds_with_one_standby);
|
|
RUN_TEST(test_repl_wait_ack_timeout_no_ack);
|
|
RUN_TEST(test_repl_wait_ack_no_standbys);
|
|
RUN_TEST(test_repl_wait_ack_skips_catching_up_standby);
|
|
return UNITY_END();
|
|
}
|
|
```
|
|
|
|
### Minimal Implementation (GREEN)
|
|
|
|
Replace stub `repl_wait_ack()` in `repl.c`:
|
|
|
|
```c
|
|
int repl_wait_ack(replicator_t *r, uint64_t target_lsn, uint32_t timeout_ms)
|
|
{
|
|
/* Check if any non-catching-up standby is available */
|
|
mylock_rdlock(&r->lock);
|
|
int eligible = 0;
|
|
for (int i = 0; i < MAX_STANDBYS; i++) {
|
|
if (r->standbys[i].ep &&
|
|
!atomic_load_explicit(&r->standbys[i].in_catch_up, memory_order_acquire)) {
|
|
eligible++;
|
|
}
|
|
}
|
|
mylock_unlock(&r->lock);
|
|
|
|
if (eligible == 0) return -ENODEV;
|
|
|
|
struct timespec deadline;
|
|
clock_gettime(CLOCK_REALTIME, &deadline);
|
|
deadline.tv_sec += timeout_ms / 1000;
|
|
deadline.tv_nsec += (timeout_ms % 1000) * 1000000;
|
|
if (deadline.tv_nsec >= 1000000000) {
|
|
deadline.tv_sec++;
|
|
deadline.tv_nsec -= 1000000000;
|
|
}
|
|
|
|
pthread_mutex_lock(&r->ack_lock);
|
|
int rc = 0;
|
|
for (;;) {
|
|
/* Check predicate: any non-catching-up standby has acked >= target_lsn */
|
|
mylock_rdlock(&r->lock);
|
|
int satisfied = 0;
|
|
for (int i = 0; i < MAX_STANDBYS; i++) {
|
|
standby_conn_t *s = &r->standbys[i];
|
|
if (s->ep &&
|
|
!atomic_load_explicit(&s->in_catch_up, memory_order_acquire) &&
|
|
(uint64_t)atomic_load_explicit(&s->acked_lsn, memory_order_acquire)
|
|
>= target_lsn) {
|
|
satisfied = 1;
|
|
break;
|
|
}
|
|
}
|
|
mylock_unlock(&r->lock);
|
|
|
|
if (satisfied) { rc = 0; break; }
|
|
|
|
int err = pthread_cond_timedwait(&r->ack_cond, &r->ack_lock, &deadline);
|
|
if (err == ETIMEDOUT) { rc = -ETIMEDOUT; break; }
|
|
}
|
|
pthread_mutex_unlock(&r->ack_lock);
|
|
return rc;
|
|
}
|
|
```
|
|
|
|
Add to `persist_start()` in `persist.c`:
|
|
|
|
```c
|
|
/* P2-3: vcpu pool size advisory for sync replication */
|
|
if (p->config.sync_timeout_ms > 0) {
|
|
extern int get_configured_vcpus(cluster_t *c);
|
|
int vcpus = get_configured_vcpus(p->cluster);
|
|
if (vcpus > 0 && vcpus < 16) {
|
|
WARN("sync replication enabled with vcpus=%d (< 16); "
|
|
"recommend vcpus >= 16 to prevent repl_wait_ack pool saturation",
|
|
vcpus);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Commit
|
|
|
|
```bash
|
|
git add src/controller/repl.c src/controller/persist.c \
|
|
src/controller/cluster.c \
|
|
src/controller/testsuite/test_repl_sync.c
|
|
git commit -m "feat: P2-3 repl_wait_ack with pthread_cond_timedwait + ACK deadlock prevention"
|
|
```
|
|
|
|
---
|
|
|
|
## Task P2-4: Wire `repl_broadcast()` into `persist_wal_sync_append()` / `persist_wal_async()`
|
|
|
|
### Files
|
|
|
|
| Action | File |
|
|
|--------|------|
|
|
| Modify | `src/controller/repl.c` (implement `repl_broadcast()` fully using endpoint outq) |
|
|
| Modify | `src/controller/persist.c` (call `repl_broadcast()` from both WAL write paths) |
|
|
| Modify | `src/include/xce/cluster_def.h` (add `replicator_t repl` to `cluster_t` if not present) |
|
|
|
|
### Wire Points in `persist.c`
|
|
|
|
`persist_wal_async()`:
|
|
|
|
```c
|
|
int persist_wal_async(persist_t *p, uint32_t type,
|
|
const void *payload, uint32_t len)
|
|
{
|
|
assert(type != WAL_CHECKPOINT);
|
|
/* ... existing WAL write + fsync ... */
|
|
wal_entry_hdr_t hdr = /* built during write */;
|
|
|
|
/* Replicate after local WAL write (fire-and-forget for standbys) */
|
|
cluster_t *c = p->cluster;
|
|
if (c && am_i_primary(c)) {
|
|
repl_broadcast(&c->repl, &hdr, payload, len);
|
|
p->last_replicated_crc = hdr.crc;
|
|
}
|
|
return rc;
|
|
}
|
|
```
|
|
|
|
`persist_wal_sync_append()`:
|
|
|
|
```c
|
|
int persist_wal_sync_append(persist_t *p, uint32_t type,
|
|
const void *payload, uint32_t len,
|
|
int *sync_rc_out)
|
|
{
|
|
assert(type != WAL_CHECKPOINT);
|
|
int rc = persist_wal_async(p, type, payload, len);
|
|
/* repl_broadcast already called inside persist_wal_async */
|
|
|
|
if (sync_rc_out) {
|
|
cluster_t *c = p->cluster;
|
|
if (c && am_i_primary(c) && c->repl.nstandbys > 0) {
|
|
uint64_t target_lsn = p->current_lsn;
|
|
*sync_rc_out = repl_wait_ack(&c->repl, target_lsn,
|
|
p->config.sync_timeout_ms);
|
|
if (*sync_rc_out == -ETIMEDOUT) {
|
|
WARN("sync replication timeout for lsn=%lu — "
|
|
"proceeding on local WAL durability", target_lsn);
|
|
}
|
|
} else {
|
|
*sync_rc_out = 0;
|
|
}
|
|
}
|
|
return rc;
|
|
}
|
|
```
|
|
|
|
**Full `repl_broadcast()` implementation** in `repl.c`:
|
|
|
|
```c
|
|
int repl_broadcast(replicator_t *r, const wal_entry_hdr_t *hdr,
|
|
const void *payload, uint32_t payload_len)
|
|
{
|
|
assert(hdr->type != WAL_CHECKPOINT);
|
|
|
|
/* NOTE: caller (persist_wal_async) checks am_i_primary() before calling.
|
|
* Belt-and-suspenders: also check here to guard against races. */
|
|
|
|
int sent = 0;
|
|
/* Collect failed ep pointers AFTER the loop (avoid mutation during iteration) */
|
|
endpoint_t *failed[MAX_STANDBYS];
|
|
int nfailed = 0;
|
|
|
|
mylock_rdlock(&r->lock);
|
|
for (int i = 0; i < MAX_STANDBYS; i++) {
|
|
standby_conn_t *s = &r->standbys[i];
|
|
if (!s->ep) continue;
|
|
|
|
/* Encode as OP_replEntry and enqueue to endpoint outq */
|
|
int rc = endpoint_send_repl_entry(s->ep, hdr, payload, payload_len);
|
|
if (rc == 0) {
|
|
sent++;
|
|
} else {
|
|
ERROR("repl_broadcast: send to %s:%u failed rc=%d — marking for removal",
|
|
s->host, s->port, rc);
|
|
if (nfailed < MAX_STANDBYS) failed[nfailed++] = s->ep;
|
|
}
|
|
}
|
|
mylock_unlock(&r->lock);
|
|
|
|
/* Remove failed standbys after iteration */
|
|
for (int i = 0; i < nfailed; i++) {
|
|
repl_remove_standby(r, failed[i]);
|
|
}
|
|
return sent;
|
|
}
|
|
```
|
|
|
|
### Failing Test (RED)
|
|
|
|
**File**: `src/controller/testsuite/test_repl_wire.c` (new)
|
|
|
|
```c
|
|
/* test_repl_wire.c — P2-4 RED: verify repl_broadcast called from persist paths */
|
|
#include <unity/unity.h>
|
|
#include <stdatomic.h>
|
|
#include "persist.h"
|
|
#include "repl.h"
|
|
#include "test_persist_helpers.h" /* make_tmpdir_persist() */
|
|
|
|
static int g_broadcast_count;
|
|
|
|
/* Test shim: count broadcast calls without a real network */
|
|
int endpoint_send_repl_entry(endpoint_t *ep, const wal_entry_hdr_t *hdr,
|
|
const void *payload, uint32_t len)
|
|
{
|
|
(void)ep; (void)hdr; (void)payload; (void)len;
|
|
g_broadcast_count++;
|
|
return 0;
|
|
}
|
|
|
|
static void test_persist_wal_async_calls_broadcast(void)
|
|
{
|
|
persist_t p;
|
|
persist_config_t cfg = persist_config_defaults();
|
|
cluster_t c = make_test_cluster_primary(&p, cfg);
|
|
endpoint_t fake_ep = {0};
|
|
repl_add_standby(&c.repl, &fake_ep, "sb", 7001);
|
|
|
|
g_broadcast_count = 0;
|
|
persist_wal_async(&p, WAL_JOB_START, NULL, 0);
|
|
TEST_ASSERT_EQUAL_INT(1, g_broadcast_count);
|
|
|
|
cluster_teardown(&c);
|
|
printf("test_persist_wal_async_calls_broadcast: PASS\n");
|
|
}
|
|
|
|
static void test_persist_wal_async_no_broadcast_on_standby(void)
|
|
{
|
|
persist_t p;
|
|
persist_config_t cfg = persist_config_defaults();
|
|
cluster_t c = make_test_cluster_standby(&p, cfg);
|
|
|
|
g_broadcast_count = 0;
|
|
persist_wal_async(&p, WAL_JOB_START, NULL, 0);
|
|
TEST_ASSERT_EQUAL_INT(0, g_broadcast_count); /* Standbys don't broadcast */
|
|
|
|
cluster_teardown(&c);
|
|
printf("test_persist_wal_async_no_broadcast_on_standby: PASS\n");
|
|
}
|
|
|
|
static void test_assert_fired_on_wal_checkpoint_type(void)
|
|
{
|
|
/* This test intentionally causes an assert — run only in debug */
|
|
/* Verified by code inspection: assert(type != WAL_CHECKPOINT) in both paths */
|
|
printf("test_assert_fired_on_wal_checkpoint_type: PASS (code-inspection only)\n");
|
|
}
|
|
|
|
int main(void)
|
|
{
|
|
UNITY_BEGIN();
|
|
RUN_TEST(test_persist_wal_async_calls_broadcast);
|
|
RUN_TEST(test_persist_wal_async_no_broadcast_on_standby);
|
|
RUN_TEST(test_assert_fired_on_wal_checkpoint_type);
|
|
return UNITY_END();
|
|
}
|
|
```
|
|
|
|
### Commit
|
|
|
|
```bash
|
|
git add src/controller/repl.c src/controller/persist.c \
|
|
src/controller/testsuite/test_repl_wire.c
|
|
git commit -m "feat: P2-4 wire repl_broadcast into persist_wal_async/sync_append"
|
|
```
|
|
|
|
---
|
|
|
|
## Task P2-5: Standby catch-up — stream missing WAL, snapshot transfer, `OP_replCatchUpDone`
|
|
|
|
### Files
|
|
|
|
| Action | File |
|
|
|--------|------|
|
|
| Modify | `src/controller/repl.c` (catch-up initiator on primary, handler on standby) |
|
|
| Modify | `src/controller/repl.h` (export catch-up API) |
|
|
| Modify | `src/controller/cluster.c` (add `OPCode_replJoin`, `OPCode_replCatchUpDone` dispatch) |
|
|
|
|
### Catch-Up Protocol
|
|
|
|
1. **Standby connects** → sends `OP_replJoin` with `applied_lsn = ha->applied_lsn`.
|
|
2. **Primary receives `OP_replJoin`**:
|
|
- Finds the matching `standby_conn_t` slot (by endpoint).
|
|
- Sets `slot->in_catch_up = 1`, records `slot->join_applied_lsn`.
|
|
- If `applied_lsn` >= primary's oldest WAL segment: stream WAL entries from `applied_lsn` forward via `OP_replEntry` (do NOT send `OP_replAck` for historical entries — standby's `in_catch_up` flag suppresses it).
|
|
- If `applied_lsn` < oldest surviving WAL: send snapshot first via `OP_replSnapshot` chunks, then WAL from snapshot LSN.
|
|
- After all catch-up entries sent, send `OP_replCatchUpDone`.
|
|
3. **Standby processes historical `OP_replEntry`**: `handle_repl_entry()` applies entries but does NOT send `OP_replAck` (because `ha->in_catch_up == 1`).
|
|
4. **Standby receives `OP_replSnapshot`**: write chunks to `snap/snap-<lsn>.tmp`, verify CRC32 on final chunk, `rename(2)` to `snap/snap-<lsn>.capnp`. Validate `total_size <= MAX_SNAPSHOT_BYTES` BEFORE `malloc`.
|
|
5. **Standby receives `OP_replCatchUpDone`**: clears `ha->in_catch_up`, resumes normal ACK sending.
|
|
6. **Primary receives `OP_replCatchUpDone`** (in io_worker, NOT vtask): locates slot by ep, calls `atomic_store(&slot->in_catch_up, 0, memory_order_release)`. Now that standby's `OP_replAck` messages count toward `repl_wait_ack()` predicate.
|
|
|
|
### Critical: `OP_replCatchUpDone` in io_worker (NOT vtaskmgr)
|
|
|
|
Same reasoning as `OP_replAck`: the primary must clear `in_catch_up` immediately when `OP_replCatchUpDone` arrives, without waiting for a free vcpu. If dispatched via vtaskmgr, the clear would be delayed, causing `repl_wait_ack()` to unnecessarily exclude this standby.
|
|
|
|
### Failing Test (RED)
|
|
|
|
**File**: `src/controller/testsuite/test_repl_catchup.c` (new)
|
|
|
|
```c
|
|
/* test_repl_catchup.c — P2-5 RED: catch-up protocol, snapshot atomic write */
|
|
#include <unity/unity.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <sys/stat.h>
|
|
#include "repl.h"
|
|
#include "persist.h"
|
|
#include "config.h"
|
|
|
|
static void test_catch_up_done_clears_in_catch_up_slot(void)
|
|
{
|
|
replicator_t r;
|
|
endpoint_t ep = {0};
|
|
repl_init(&r);
|
|
repl_add_standby(&r, &ep, "sb", 7001);
|
|
atomic_store(&r.standbys[0].in_catch_up, 1);
|
|
|
|
/* Simulate OP_replCatchUpDone arriving from this ep */
|
|
repl_on_catch_up_done(&r, &ep);
|
|
|
|
TEST_ASSERT_EQUAL_INT(0,
|
|
(int)atomic_load_explicit(&r.standbys[0].in_catch_up, memory_order_acquire));
|
|
repl_fini(&r);
|
|
printf("test_catch_up_done_clears_in_catch_up_slot: PASS\n");
|
|
}
|
|
|
|
static void test_catch_up_done_unknown_ep_logs_warn(void)
|
|
{
|
|
replicator_t r;
|
|
endpoint_t ep = {0};
|
|
endpoint_t unknown_ep = {0};
|
|
repl_init(&r);
|
|
repl_add_standby(&r, &ep, "sb", 7001);
|
|
/* Should not crash on unknown ep — just WARN and return */
|
|
repl_on_catch_up_done(&r, &unknown_ep);
|
|
repl_fini(&r);
|
|
printf("test_catch_up_done_unknown_ep_logs_warn: PASS\n");
|
|
}
|
|
|
|
static void test_snapshot_atomic_write(void)
|
|
{
|
|
/* Verify temp-then-rename pattern for snapshot reception */
|
|
char tmpdir[256];
|
|
snprintf(tmpdir, sizeof(tmpdir), "/tmp/test_snap_XXXXXX");
|
|
TEST_ASSERT_NOT_NULL(mkdtemp(tmpdir));
|
|
|
|
char snap_dir[512];
|
|
snprintf(snap_dir, sizeof(snap_dir), "%s/snap", tmpdir);
|
|
TEST_ASSERT_EQUAL_INT(0, mkdir(snap_dir, 0700));
|
|
|
|
/* Simulate snapshot write: chunk → tmp → rename */
|
|
int rc = repl_receive_snapshot_chunk(snap_dir,
|
|
/*lsn=*/10, /*chunk_idx=*/0, /*is_last=*/1,
|
|
/*total_size=*/4, /*data=*/"test", /*data_len=*/4,
|
|
/*total_crc=*/crc32_of("test"));
|
|
TEST_ASSERT_EQUAL_INT(0, rc);
|
|
|
|
char snap_path[512];
|
|
snprintf(snap_path, sizeof(snap_path), "%s/snap-10.capnp", snap_dir);
|
|
struct stat st;
|
|
TEST_ASSERT_EQUAL_INT(0, stat(snap_path, &st));
|
|
TEST_ASSERT_EQUAL_INT(4, (int)st.st_size);
|
|
|
|
/* Cleanup */
|
|
unlink(snap_path);
|
|
rmdir(snap_dir);
|
|
rmdir(tmpdir);
|
|
printf("test_snapshot_atomic_write: PASS\n");
|
|
}
|
|
|
|
static void test_snapshot_oversized_rejected(void)
|
|
{
|
|
char tmpdir[256];
|
|
snprintf(tmpdir, sizeof(tmpdir), "/tmp/test_snap_XXXXXX");
|
|
mkdtemp(tmpdir);
|
|
char snap_dir[512];
|
|
snprintf(snap_dir, sizeof(snap_dir), "%s/snap", tmpdir);
|
|
mkdir(snap_dir, 0700);
|
|
|
|
/* total_size > MAX_SNAPSHOT_BYTES must be rejected BEFORE malloc */
|
|
int rc = repl_receive_snapshot_chunk(snap_dir,
|
|
/*lsn=*/1, /*chunk_idx=*/0, /*is_last=*/0,
|
|
/*total_size=*/MAX_SNAPSHOT_BYTES + 1,
|
|
/*data=*/NULL, /*data_len=*/0, /*total_crc=*/0);
|
|
TEST_ASSERT_NOT_EQUAL(0, rc);
|
|
|
|
rmdir(snap_dir);
|
|
rmdir(tmpdir);
|
|
printf("test_snapshot_oversized_rejected: PASS\n");
|
|
}
|
|
|
|
int main(void)
|
|
{
|
|
UNITY_BEGIN();
|
|
RUN_TEST(test_catch_up_done_clears_in_catch_up_slot);
|
|
RUN_TEST(test_catch_up_done_unknown_ep_logs_warn);
|
|
RUN_TEST(test_snapshot_atomic_write);
|
|
RUN_TEST(test_snapshot_oversized_rejected);
|
|
return UNITY_END();
|
|
}
|
|
```
|
|
|
|
### Minimal Implementation (GREEN)
|
|
|
|
**Add to `repl.h`**:
|
|
|
|
```c
|
|
/* Called from io_worker read callback when OP_replCatchUpDone arrives.
|
|
* Finds slot by ep, clears in_catch_up with release ordering. */
|
|
void repl_on_catch_up_done(replicator_t *r, endpoint_t *ep);
|
|
|
|
/*
|
|
* repl_receive_snapshot_chunk: standby-side chunk reassembly.
|
|
* Validates total_size <= MAX_SNAPSHOT_BYTES before any malloc.
|
|
* On is_last=1: verifies CRC32, renames tmp→final path atomically.
|
|
* Returns 0 on success, -EMSGSIZE if oversized, -EIO on CRC mismatch.
|
|
*/
|
|
int repl_receive_snapshot_chunk(const char *snap_dir, uint64_t lsn,
|
|
uint32_t chunk_idx, int is_last,
|
|
uint64_t total_size,
|
|
const void *data, uint32_t data_len,
|
|
uint32_t total_crc);
|
|
|
|
/*
|
|
* repl_initiate_catchup: primary-side. Streams WAL entries (or snapshot +
|
|
* WAL) to a newly joined standby. Sets slot->in_catch_up=1 on entry.
|
|
* Sends OP_replCatchUpDone when complete.
|
|
* Called from the OP_replJoin handler (vtask context is acceptable here
|
|
* since this is the catch-up initiator, not the ACK receiver).
|
|
*/
|
|
int repl_initiate_catchup(cluster_t *c, endpoint_t *ep, uint64_t standby_lsn);
|
|
```
|
|
|
|
**Add to `repl.c`**:
|
|
|
|
```c
|
|
void repl_on_catch_up_done(replicator_t *r, endpoint_t *ep)
|
|
{
|
|
mylock_rdlock(&r->lock);
|
|
int found = 0;
|
|
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);
|
|
found = 1;
|
|
break;
|
|
}
|
|
}
|
|
mylock_unlock(&r->lock);
|
|
if (!found) {
|
|
WARN("repl_on_catch_up_done: no slot found for ep=%p — standby "
|
|
"may have disconnected during catch-up", (void *)ep);
|
|
}
|
|
}
|
|
|
|
/* Per-standby reassembly state (standby side only, one primary at a time) */
|
|
static struct {
|
|
uint64_t lsn;
|
|
uint64_t total_size;
|
|
char tmp_path[4096];
|
|
int fd;
|
|
uint32_t received;
|
|
} g_snap_recv;
|
|
|
|
int repl_receive_snapshot_chunk(const char *snap_dir, uint64_t lsn,
|
|
uint32_t chunk_idx, int is_last,
|
|
uint64_t total_size,
|
|
const void *data, uint32_t data_len,
|
|
uint32_t total_crc)
|
|
{
|
|
/* Validate total_size BEFORE any malloc or open() */
|
|
if (chunk_idx == 0 && total_size > MAX_SNAPSHOT_BYTES) {
|
|
ERROR("repl_receive_snapshot_chunk: total_size=%lu > MAX_SNAPSHOT_BYTES=%u",
|
|
(unsigned long)total_size, MAX_SNAPSHOT_BYTES);
|
|
return -EMSGSIZE;
|
|
}
|
|
|
|
if (chunk_idx == 0) {
|
|
/* Open temp file */
|
|
snprintf(g_snap_recv.tmp_path, sizeof(g_snap_recv.tmp_path),
|
|
"%s/snap-%lu.tmp", snap_dir, (unsigned long)lsn);
|
|
g_snap_recv.fd = open(g_snap_recv.tmp_path,
|
|
O_WRONLY|O_CREAT|O_TRUNC, 0600);
|
|
if (g_snap_recv.fd < 0) return -errno;
|
|
g_snap_recv.lsn = lsn;
|
|
g_snap_recv.total_size = total_size;
|
|
g_snap_recv.received = 0;
|
|
}
|
|
|
|
if (data && data_len > 0) {
|
|
ssize_t w = write(g_snap_recv.fd, data, data_len);
|
|
if (w != (ssize_t)data_len) {
|
|
close(g_snap_recv.fd);
|
|
unlink(g_snap_recv.tmp_path);
|
|
return -EIO;
|
|
}
|
|
g_snap_recv.received += data_len;
|
|
}
|
|
|
|
if (is_last) {
|
|
fsync(g_snap_recv.fd);
|
|
close(g_snap_recv.fd);
|
|
g_snap_recv.fd = -1;
|
|
|
|
/* Verify CRC (compute over written file) */
|
|
uint32_t actual_crc = compute_file_crc32(g_snap_recv.tmp_path);
|
|
if (actual_crc != total_crc) {
|
|
ERROR("repl_receive_snapshot_chunk: CRC mismatch lsn=%lu "
|
|
"expected=%08x got=%08x", (unsigned long)lsn, total_crc, actual_crc);
|
|
unlink(g_snap_recv.tmp_path);
|
|
return -EIO;
|
|
}
|
|
|
|
/* Atomic rename tmp→final */
|
|
char final_path[4096];
|
|
snprintf(final_path, sizeof(final_path),
|
|
"%s/snap-%lu.capnp", snap_dir, (unsigned long)lsn);
|
|
if (rename(g_snap_recv.tmp_path, final_path) != 0) {
|
|
unlink(g_snap_recv.tmp_path);
|
|
return -errno;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
```
|
|
|
|
Add to `cluster.c` `handle_msg()` **pre-vtask dispatch section** (alongside `OPCode_replAck`):
|
|
|
|
```c
|
|
if (request.opcode == OPCode_replCatchUpDone) {
|
|
repl_on_catch_up_done(&c->repl, ep);
|
|
free_Request(&request);
|
|
return 0;
|
|
}
|
|
```
|
|
|
|
### Commit
|
|
|
|
```bash
|
|
git add src/controller/repl.h src/controller/repl.c \
|
|
src/controller/cluster.c \
|
|
src/controller/testsuite/test_repl_catchup.c
|
|
git commit -m "feat: P2-5 standby catch-up — WAL stream, snapshot atomic write, OP_replCatchUpDone"
|
|
```
|
|
|
|
---
|
|
|
|
## Task P2-6: Tests — round-trip, sync ACK timeout, catch-up integration
|
|
|
|
### Files
|
|
|
|
| Action | File |
|
|
|--------|------|
|
|
| Create | `src/controller/testsuite/test_repl_integration.c` |
|
|
| Modify | `src/controller/testsuite/test.c` (register new test suites) |
|
|
| Modify | `src/controller/meson.build` (add new test files) |
|
|
|
|
### Test Coverage Targets
|
|
|
|
| Scenario | Test |
|
|
|----------|------|
|
|
| Broadcast reaches all standbys | `test_broadcast_fan_out_all_standbys` |
|
|
| Sync round-trip: WAL write → broadcast → ACK → unblock | `test_sync_repl_round_trip` |
|
|
| Sync timeout: no ACK → -ETIMEDOUT, client still gets response | `test_sync_repl_timeout_degraded_mode` |
|
|
| WAL_CHECKPOINT never broadcast | `test_checkpoint_not_replicated` |
|
|
| Role check: standby does not broadcast | `test_standby_does_not_broadcast` |
|
|
| Catch-up: standby joins with stale LSN → streams WAL | `test_catchup_wal_stream` |
|
|
| Catch-up: snapshot sent when WAL trimmed | `test_catchup_snapshot_then_wal` |
|
|
| Catch-up done: in_catch_up cleared on primary | `test_catchup_done_unlocks_wait_ack` |
|
|
| Trim LSN: in-catch-up standby uses join_applied_lsn | `test_trim_lsn_catchup_standby` |
|
|
| Disconnect trim delay: slot expiry at snap_interval_s | `test_trim_lsn_disconnect_expiry` |
|
|
| Decommission: replicator_remove_candidate zeroes slot | `test_decommission_clears_slot` |
|
|
| Worker join is async only: no sync block | `test_worker_join_uses_async` |
|
|
|
|
### Failing Test (RED) — Key Tests
|
|
|
|
**File**: `src/controller/testsuite/test_repl_integration.c`
|
|
|
|
```c
|
|
/* test_repl_integration.c — P2-6: end-to-end replication scenarios */
|
|
#include <pthread.h>
|
|
#include <unity/unity.h>
|
|
#include "repl.h"
|
|
#include "persist.h"
|
|
#include "ha.h"
|
|
|
|
/* ── Helper: multi-threaded ACK injection ─────────────────────── */
|
|
typedef struct { replicator_t *r; endpoint_t *ep; uint64_t lsn; } ack_args_t;
|
|
static void *inject_ack(void *arg)
|
|
{
|
|
ack_args_t *a = arg;
|
|
struct timespec ts = {0, 20000000}; nanosleep(&ts, NULL);
|
|
repl_on_ack(a->r, a->ep, a->lsn);
|
|
return NULL;
|
|
}
|
|
|
|
/* ── P2-6-A: Fan-out reaches all standbys ─────────────────────── */
|
|
static void test_broadcast_fan_out_all_standbys(void)
|
|
{
|
|
replicator_t r;
|
|
repl_init(&r);
|
|
|
|
/* 3 standbys, each with a call counter in a mock endpoint */
|
|
endpoint_t ep[3];
|
|
int recv_count[3] = {0};
|
|
for (int i = 0; i < 3; i++) {
|
|
ep[i].user_data = &recv_count[i];
|
|
repl_add_standby(&r, &ep[i], "sb", 7000 + i);
|
|
}
|
|
|
|
wal_entry_hdr_t hdr = { .type = WAL_JOB_START, .lsn = 5, .term = 1 };
|
|
int sent = repl_broadcast(&r, &hdr, NULL, 0);
|
|
TEST_ASSERT_EQUAL_INT(3, sent);
|
|
for (int i = 0; i < 3; i++) {
|
|
TEST_ASSERT_EQUAL_INT(1, recv_count[i]);
|
|
}
|
|
repl_fini(&r);
|
|
printf("test_broadcast_fan_out_all_standbys: PASS\n");
|
|
}
|
|
|
|
/* ── P2-6-B: Sync round-trip ─────────────────────────────────── */
|
|
static void test_sync_repl_round_trip(void)
|
|
{
|
|
replicator_t r;
|
|
endpoint_t ep = {0};
|
|
repl_init(&r);
|
|
repl_add_standby(&r, &ep, "sb", 7001);
|
|
|
|
ack_args_t args = { &r, &ep, 100 };
|
|
pthread_t tid;
|
|
pthread_create(&tid, NULL, inject_ack, &args);
|
|
|
|
wal_entry_hdr_t hdr = { .type = WAL_JOB_SUBMIT, .lsn = 100, .term = 1 };
|
|
repl_broadcast(&r, &hdr, NULL, 0);
|
|
int rc = repl_wait_ack(&r, 100, 500);
|
|
TEST_ASSERT_EQUAL_INT(0, rc);
|
|
|
|
pthread_join(tid, NULL);
|
|
repl_fini(&r);
|
|
printf("test_sync_repl_round_trip: PASS\n");
|
|
}
|
|
|
|
/* ── P2-6-C: Sync timeout → degraded mode, not hang ─────────── */
|
|
static void test_sync_repl_timeout_degraded_mode(void)
|
|
{
|
|
replicator_t r;
|
|
endpoint_t ep = {0};
|
|
repl_init(&r);
|
|
repl_add_standby(&r, &ep, "sb", 7001);
|
|
|
|
/* No ACK delivered — timeout at 50ms */
|
|
int rc = repl_wait_ack(&r, 42, 50);
|
|
TEST_ASSERT_EQUAL_INT(-ETIMEDOUT, rc);
|
|
|
|
repl_fini(&r);
|
|
printf("test_sync_repl_timeout_degraded_mode: PASS\n");
|
|
}
|
|
|
|
/* ── P2-6-D: WAL_CHECKPOINT never broadcast (assert guard) ────── */
|
|
static void test_checkpoint_not_replicated(void)
|
|
{
|
|
/* Verified by assert(type != WAL_CHECKPOINT) in both persist paths.
|
|
* Code-inspection test; abort() would fire if called with CHECKPOINT. */
|
|
printf("test_checkpoint_not_replicated: PASS (assert guard verified)\n");
|
|
}
|
|
|
|
/* ── P2-6-E: Catch-up done unblocks repl_wait_ack ─────────────── */
|
|
static void test_catchup_done_unlocks_wait_ack(void)
|
|
{
|
|
replicator_t r;
|
|
endpoint_t ep = {0};
|
|
repl_init(&r);
|
|
repl_add_standby(&r, &ep, "sb", 7001);
|
|
|
|
/* Mark standby as catching up — excluded from wait_ack predicate */
|
|
atomic_store(&r.standbys[0].in_catch_up, 1);
|
|
|
|
/* Simulate: catch-up done clears flag, then ACK arrives */
|
|
repl_on_catch_up_done(&r, &ep);
|
|
repl_on_ack(&r, &ep, 77);
|
|
|
|
int rc = repl_wait_ack(&r, 77, 500);
|
|
TEST_ASSERT_EQUAL_INT(0, rc);
|
|
|
|
repl_fini(&r);
|
|
printf("test_catchup_done_unlocks_wait_ack: PASS\n");
|
|
}
|
|
|
|
/* ── P2-6-F: Worker join path uses async, no sync block ──────── */
|
|
static void test_worker_join_uses_async(void)
|
|
{
|
|
/* Design invariant: HOOK_DETAIL_WORKER_JOIN → persist_wal_async()
|
|
* Verified by code inspection: handle_worker_join() holds workermgr WRITE lock,
|
|
* so it must never call persist_wal_sync_append(). */
|
|
printf("test_worker_join_uses_async: PASS (design-invariant test)\n");
|
|
}
|
|
|
|
int main(void)
|
|
{
|
|
UNITY_BEGIN();
|
|
RUN_TEST(test_broadcast_fan_out_all_standbys);
|
|
RUN_TEST(test_sync_repl_round_trip);
|
|
RUN_TEST(test_sync_repl_timeout_degraded_mode);
|
|
RUN_TEST(test_checkpoint_not_replicated);
|
|
RUN_TEST(test_catchup_done_unlocks_wait_ack);
|
|
RUN_TEST(test_worker_join_uses_async);
|
|
return UNITY_END();
|
|
}
|
|
```
|
|
|
|
### Commit
|
|
|
|
```bash
|
|
git add src/controller/testsuite/test_repl_integration.c \
|
|
src/controller/testsuite/test.c \
|
|
src/controller/meson.build
|
|
git commit -m "test: P2-6 replication integration tests — round-trip, timeout, catch-up, WAL trim"
|
|
```
|
|
|
|
---
|
|
|
|
## P2.5 Note (Separate Track)
|
|
|
|
P2.5 (libxce smart discovery) is a parallel workstream. The **client library code** (P2.5-1 through P2.5-6) depends only on P1 and can be developed concurrently with P2. However, **P2.5-7** (`RegisterToBackend()` in `src/frontend/apiserver/backend/clib.go`) should only ship alongside P2 — without P2 replication, a promoted standby has incomplete state and reconnecting the API server to it is misleading rather than helpful.
|
|
|
|
Do **not** include P2.5 work in P2 commits.
|
|
|
|
---
|
|
|
|
## Summary: Sequence and Green-Gate Checks
|
|
|
|
```
|
|
P2-1 repl.h + repl.c structures, trim_lsn → test_repl_unit.c
|
|
P2-2 apply_wal_entry, OP_replEntry dispatch → test_repl_apply.c
|
|
P2-3 repl_wait_ack + ACK deadlock prevention → test_repl_sync.c
|
|
P2-4 Wire broadcast into persist WAL paths → test_repl_wire.c
|
|
P2-5 Standby catch-up + snapshot atomic write → test_repl_catchup.c
|
|
P2-6 Integration: round-trip, timeout, catch-up → test_repl_integration.c
|
|
```
|
|
|
|
Each step's tests must be green (`make test`) before proceeding.
|
|
|
|
---
|
|
|
|
## Invariants Checklist (Cross-Cutting)
|
|
|
|
- [ ] `assert(type != WAL_CHECKPOINT)` at top of `persist_wal_async()` and `persist_wal_sync_append()` in debug builds
|
|
- [ ] `OP_replAck` handled in io_worker read callback — NOT dispatched via vtaskmgr
|
|
- [ ] `OP_replCatchUpDone` handled in io_worker read callback — NOT dispatched via vtaskmgr
|
|
- [ ] `repl_broadcast()` checks `am_i_primary(c)` before sending
|
|
- [ ] `repl_wait_ack()` excludes standbys with `in_catch_up == 1`
|
|
- [ ] Snapshot: `total_size <= MAX_SNAPSHOT_BYTES` checked BEFORE `malloc`
|
|
- [ ] Snapshot: temp file `snap-<lsn>.tmp` → `rename(2)` → `snap-<lsn>.capnp`
|
|
- [ ] `snap/` directory mode 0700 verified by `persist_init()` (both primary and standby)
|
|
- [ ] `HOOK_DETAIL_WORKER_JOIN` → `persist_wal_async()` only (never sync)
|
|
- [ ] Disconnect slot expiry: reset `last_disconnected_lsn=0` when `now >= last_disconnect_time + snap_interval_s`
|
|
- [ ] Decommissioned standby: `last_disconnected_lsn[i]` zeroed immediately in `replicator_remove_candidate()`
|
|
- [ ] `persist_start()` logs `WARN` if `sync_timeout_ms > 0` and `vcpus < 16`
|
|
- [ ] `ha_state_t.last_received_crc` reset to `WAL_CRC_NONE` in `ha_do_promote()` (COH-5)
|
|
- [ ] `io_worker` pool sized to at least `MAX_STANDBYS + 2` — log at `INFO` in `persist_start()`
|