feat(libxce-failover): best-effort single-shot reconnect for fire-and-forget ops (notify/heartbeat/nop) (task 5.2)

This commit is contained in:
Rongsong Shen 2026-06-17 04:08:58 +00:00
parent 4345974b54
commit fdb8938059
5 changed files with 210 additions and 4 deletions

View file

@ -1395,6 +1395,125 @@ static void test_failover_call_success_runs_once(void)
printf("test_failover_call_success_runs_once: PASS\n");
}
/* ════════════════════════════════════════════════════════════════════════
* Task 5.2 — Best-effort reconnect for fire-and-forget ops.
*
* Fire-and-forget ops (notify_*, heartbeat, nop) send a *_req only — there is
* NO reply to classify, so they do NOT use the full failover loop (no status
* classification, no no-primary backoff, no retry budget). Putting a heartbeat
* through the retry loop would let it block on the no-primary backoff budget,
* which is explicitly forbidden. Instead the FF path makes AT MOST ONE
* best-effort transport reconnect when the connection is KNOWN-DEAD, then does
* a single-shot send (which may be lost — acceptable, redelivery is deferred to
* worker-rejoin-resync).
*
* The "known-dead" signal is ctx->state == XCE_CTX_ERR (set by the disconnect
* path after a detected transport failure). xcecli_ff_needs_reconnect is the
* pure predicate factored out so this decision is unit-testable socket-free:
* ERR → true (reconnect once, best-effort)
* RUN → false (connection live, send directly)
* DOWN → false (deliberately stopped — do not resurrect)
* INIT → false (not yet started)
*
* Requirements: 1.1 (all ops go through failover machinery — FF via the
* best-effort transport-reconnect variant), 5.3 (no silent drop on a path that
* CAN reconnect; a known-dead connection is repaired before the single shot).
* Full live reconnect-then-send is E2E task 6.2.
* ════════════════════════════════════════════════════════════════════════ */
/* Req 1.1/5.3: a known-dead (ERR) connection needs a best-effort reconnect. */
static void test_ff_needs_reconnect_on_err(void)
{
api_ctx_t ac;
memset(&ac, 0, sizeof(ac));
ac.ctx.state = XCE_CTX_ERR;
TEST_ASSERT_TRUE(xcecli_ff_needs_reconnect(&ac));
printf("test_ff_needs_reconnect_on_err: PASS\n");
}
/* Req 1.1: a live (RUN) connection needs NO reconnect — send directly. */
static void test_ff_no_reconnect_on_run(void)
{
api_ctx_t ac;
memset(&ac, 0, sizeof(ac));
ac.ctx.state = XCE_CTX_RUN;
TEST_ASSERT_FALSE(xcecli_ff_needs_reconnect(&ac));
printf("test_ff_no_reconnect_on_run: PASS\n");
}
/* Req 1.1: a DOWN (deliberately stopped) or INIT (not yet started) ctx is NOT
* resurrected by a fire-and-forget op — only a transport-ERR connection is. */
static void test_ff_no_reconnect_on_down_or_init(void)
{
api_ctx_t ac;
memset(&ac, 0, sizeof(ac));
ac.ctx.state = XCE_CTX_DOWN;
TEST_ASSERT_FALSE(xcecli_ff_needs_reconnect(&ac));
memset(&ac, 0, sizeof(ac));
ac.ctx.state = XCE_CTX_INIT;
TEST_ASSERT_FALSE(xcecli_ff_needs_reconnect(&ac));
printf("test_ff_no_reconnect_on_down_or_init: PASS\n");
}
/* Guard: a NULL api_ctx is rejected as "no reconnect" without crashing. */
static void test_ff_needs_reconnect_null_safe(void)
{
TEST_ASSERT_FALSE(xcecli_ff_needs_reconnect(NULL));
printf("test_ff_needs_reconnect_null_safe: PASS\n");
}
/* Req 5.3 (structural): the fire-and-forget path is single-shot — it makes AT
* MOST ONE reconnect attempt and NEVER consumes the no-primary retry budget /
* sleeps. We prove this structurally: the FF macro drives the body exactly once
* regardless of the rc it returns (a "lost" send rc is surfaced verbatim, NOT
* retried), and on a known-dead ctx the single reconnect attempt fires exactly
* once before that single body invocation. */
typedef struct {
api_ctx_t *ac;
int body_calls; /* number of times the FF body ran */
int body_rc; /* rc the body returns each call */
} fo_ff_probe_t;
static int fo_ff_body(fo_ff_probe_t *p)
{
p->body_calls++;
return p->body_rc;
}
static void test_ff_call_is_single_shot_no_budget(void)
{
xce_client_config_t conf;
api_ctx_t ac;
fo_make_client_masters(&conf);
fo_make_api_ctx(&ac, &conf);
TEST_ASSERT_EQUAL_INT(0, mylock_init(&ac.failover.lock));
ac.failover.retry = XCE_RETRY_DEFAULTS;
/* a live RUN ctx: no reconnect, just the single-shot send */
ac.ctx.state = XCE_CTX_RUN;
fo_ff_probe_t p;
memset(&p, 0, sizeof(p));
p.ac = ∾
/* a "lost" fire-and-forget send (e.g. -ECONNRESET) is surfaced verbatim and
* is NOT retried — the body must run exactly once. */
p.body_rc = -ECONNRESET;
int rc = XCECLI_FF_CALL(&ac, fo_ff_body(&p));
mylock_fini(&ac.failover.lock);
TEST_ASSERT_EQUAL_INT(-ECONNRESET, rc); /* best-effort: surfaced as-is */
TEST_ASSERT_EQUAL_INT(1, p.body_calls); /* single shot, no retry loop */
printf("test_ff_call_is_single_shot_no_budget: PASS\n");
}
/* ── Suite entry point ──────────────────────────────────────────────── */
void test_libxce_retry(void)
@ -1440,5 +1559,10 @@ void test_libxce_retry(void)
RUN_TEST(test_invoke_rejoin_null_ctx_safe);
RUN_TEST(test_failover_call_serialises_shared_ctx);
RUN_TEST(test_failover_call_success_runs_once);
RUN_TEST(test_ff_needs_reconnect_on_err);
RUN_TEST(test_ff_no_reconnect_on_run);
RUN_TEST(test_ff_no_reconnect_on_down_or_init);
RUN_TEST(test_ff_needs_reconnect_null_safe);
RUN_TEST(test_ff_call_is_single_shot_no_budget);
UNITY_END();
}