feat(libxce-failover): 4-action status classifier with attempt-aware DUP-replay rule (task 2.1)

This commit is contained in:
Rongsong Shen 2026-06-17 02:42:54 +00:00
parent d11c6e1d89
commit fa2ac05036
5 changed files with 222 additions and 1 deletions

View file

@ -15,7 +15,7 @@
- _Requirements: 2.5, 4.1, 4.2, 4.3, 4.4, 4.5, 5.2, 8.3, 8.4_
- [ ] 2. Core: status classification, endpoint rotation, reply-path capture
- [ ] 2.1 Implement status classification
- [x] 2.1 Implement status classification
- Map each controller status and transport result to one of four actions: success, retry-now (redirect/standby/transport — rotate without backoff), retry-after-backoff (no-primary/not-ready), terminal
- Never classify redirect, standby, no-primary, or not-ready as success
- Treat a duplicate-job/exists status on a re-sent write as success (idempotent replay); on a first attempt treat it as a genuine duplicate surfaced to the caller

View file

@ -0,0 +1,49 @@
/*
* client_failover.h — public StatusClassifier surface for the libxce-failover
* spec.
*
* Task 2.1 (StatusClassifier): pure mapping from a reply status / transport rc
* to a failover action. No I/O, no globals — see design.md
* "Components and Interfaces → StatusClassifier".
*/
#ifndef XCE_CLIENT_FAILOVER_H
#define XCE_CLIENT_FAILOVER_H
#include <stdbool.h>
/* Failover action a round-trip outcome maps to. Order mirrors the harness'
* fo_expected_action_t (failover_harness.h) and design.md. */
typedef enum {
XCE_FO_SUCCESS, /* status == 0, or idempotent-replay success
(DUP on a re-sent write) */
XCE_FO_RETRY_NOW, /* E_XCE_REDIRECT / E_XCE_STANDBY / transport error
— rotate, no backoff */
XCE_FO_RETRY_BACKOFF, /* E_XCE_NOMASTER / E_XCE_NOT_READY — rotate after
jittered backoff */
XCE_FO_TERMINAL /* AUTH/PERM/INVALID/NOENT and other non-retriable
— surface to caller */
} xce_fo_action_t;
/*
* Map a reply status / transport rc to a failover action.
*
* status : the controller reply status or negative -errno transport rc, as
* the failover loop observes it. Controller codes (E_XCE_*) are
* accepted in either sign.
* is_write: true for mutating ops (e.g. job submit); false for reads.
* attempt : 0 for the first send; >0 for a re-send after failover.
*
* Invariant (2.5): E_XCE_REDIRECT/STANDBY/NOMASTER/NOT_READY never map to
* XCE_FO_SUCCESS.
*
* Idempotent-replay rule (5.2): E_XCE_DUP_JOB / EEXIST on a re-sent write
* (is_write && attempt > 0) → XCE_FO_SUCCESS; otherwise → XCE_FO_TERMINAL.
*
* Unknown / unrecognized statuses are treated conservatively as
* XCE_FO_TERMINAL (surfaced, not retried).
*
* Pure function: no I/O, no globals.
*/
xce_fo_action_t xce_failover_classify(int status, bool is_write, int attempt);
#endif /* XCE_CLIENT_FAILOVER_H */

60
src/lib/client_failover.c Normal file
View file

@ -0,0 +1,60 @@
/*
* client_failover.c — StatusClassifier for the libxce-failover spec (task 2.1).
*
* Pure mapping from a reply status / transport rc to a failover action. No I/O,
* no globals. See design.md "Components and Interfaces → StatusClassifier" and
* requirements 2.5, 4.1, 4.2, 4.3, 4.5, 5.2.
*/
#define _GNU_SOURCE
#include "xce/client_failover.h"
#include <errno.h>
#include "xce/errnox.h"
/*
* Controller status codes (E_XCE_*) are positive enum values, but the failover
* loop may observe them as negative -errno-style rc. Accept either sign by
* comparing against the magnitude.
*/
static int fo_abs(int v)
{
return v < 0 ? -v : v;
}
xce_fo_action_t xce_failover_classify(int status, bool is_write, int attempt)
{
/* Success: served by the primary. */
if (status == 0)
return XCE_FO_SUCCESS;
int code = fo_abs(status);
/* Idempotent-replay rule (5.2): a duplicate on a re-sent write means the
* controller's client-jobid replay guard already applied our write — treat
* it as success. On the first send (or a non-write), it is a genuine
* duplicate and is surfaced unchanged. */
if (code == E_XCE_DUP_JOB || code == EEXIST) {
if (is_write && attempt > 0)
return XCE_FO_SUCCESS;
return XCE_FO_TERMINAL;
}
/* Redirect / standby: a primary exists elsewhere, or this op is
* primary-only — rotate promptly, no backoff (4.2, 4.3). */
if (code == E_XCE_REDIRECT || code == E_XCE_STANDBY)
return XCE_FO_RETRY_NOW;
/* No primary known yet / controller initializing or electing — rotate after
* jittered backoff (4.1). */
if (code == E_XCE_NOMASTER || code == E_XCE_NOT_READY)
return XCE_FO_RETRY_BACKOFF;
/* Transport-level failures — reconnect/rotate and retry now (4.4). */
if (code == ECONNREFUSED || code == ETIMEDOUT || code == ECONNRESET)
return XCE_FO_RETRY_NOW;
/* Everything else — auth/permission/invalid/not-found and any unrecognized
* status — is non-retriable and surfaced to the caller (4.5). */
return XCE_FO_TERMINAL;
}

View file

@ -68,6 +68,7 @@ libxce_src = [ gen_srcs[0],
xce_resreq_parser[0],
xce_select_parser[0],
'client.c',
'client_failover.c',
'dataq.c',
'hook.c',
'htab.c',

View file

@ -30,6 +30,8 @@
#include "failover_harness.h"
#include "request.h"
#include "xce/client_failover.h"
#include "xce/errnox.h"
/* ════════════════════════════════════════════════════════════════════════
* Harness implementation
@ -344,6 +346,110 @@ static void test_thread_harness_rejects_bad_args(void)
printf("test_thread_harness_rejects_bad_args: PASS\n");
}
/* ════════════════════════════════════════════════════════════════════════
* Task 2.1 — StatusClassifier (xce_failover_classify) RED→GREEN tests
* ════════════════════════════════════════════════════════════════════════ */
/* Map the harness' expected-action category to the real classifier enum. The
* two enums share order by design, but we map explicitly so a future reorder of
* either enum is caught here rather than silently passing. */
static xce_fo_action_t fo_expected_to_action(fo_expected_action_t e)
{
switch (e) {
case FO_ACT_SUCCESS: return XCE_FO_SUCCESS;
case FO_ACT_RETRY_NOW: return XCE_FO_RETRY_NOW;
case FO_ACT_RETRY_BACKOFF: return XCE_FO_RETRY_BACKOFF;
case FO_ACT_TERMINAL: return XCE_FO_TERMINAL;
}
return XCE_FO_TERMINAL;
}
/* Req 2.5, 4.1, 4.2, 4.3, 4.5: every taxonomy row's first-send classification
* (is_write=false, attempt=0) matches the design's StatusClassifier mapping. */
static void test_classify_matches_taxonomy_table(void)
{
for (size_t i = 0; i < FO_STATUS_TABLE_LEN; i++) {
const fo_status_row_t *row = &FO_STATUS_TABLE[i];
xce_fo_action_t want = fo_expected_to_action(row->expect);
xce_fo_action_t got =
xce_failover_classify(row->status, /*is_write=*/false,
/*attempt=*/0);
char msg[128];
snprintf(msg, sizeof(msg), "row '%s' (status=%d): want %d got %d",
row->name, row->status, (int)want, (int)got);
TEST_ASSERT_EQUAL_INT_MESSAGE(want, got, msg);
}
printf("test_classify_matches_taxonomy_table: PASS\n");
}
/* Req 2.5: REDIRECT/STANDBY/NOMASTER/NOT_READY are never reported as success,
* regardless of is_write/attempt. */
static void test_classify_control_codes_never_success(void)
{
const int codes[] = { E_XCE_REDIRECT, E_XCE_STANDBY, E_XCE_NOMASTER,
E_XCE_NOT_READY };
for (size_t i = 0; i < sizeof(codes) / sizeof(codes[0]); i++) {
TEST_ASSERT_NOT_EQUAL(XCE_FO_SUCCESS,
xce_failover_classify(codes[i], false, 0));
TEST_ASSERT_NOT_EQUAL(XCE_FO_SUCCESS,
xce_failover_classify(codes[i], true, 1));
/* accepted in either sign */
TEST_ASSERT_NOT_EQUAL(XCE_FO_SUCCESS,
xce_failover_classify(-codes[i], true, 1));
}
printf("test_classify_control_codes_never_success: PASS\n");
}
/* Req 5.2: idempotent-replay rule — DUP_JOB/EEXIST on a RE-SENT WRITE is a
* success; the first send (or a read) is a genuine duplicate → terminal. */
static void test_classify_dup_replay_rule(void)
{
/* re-sent write (is_write && attempt>0) → SUCCESS */
TEST_ASSERT_EQUAL_INT(XCE_FO_SUCCESS,
xce_failover_classify(-E_XCE_DUP_JOB, true, 1));
TEST_ASSERT_EQUAL_INT(XCE_FO_SUCCESS,
xce_failover_classify(E_XCE_DUP_JOB, true, 1));
TEST_ASSERT_EQUAL_INT(XCE_FO_SUCCESS,
xce_failover_classify(-EEXIST, true, 1));
/* first send → genuine duplicate → TERMINAL */
TEST_ASSERT_EQUAL_INT(XCE_FO_TERMINAL,
xce_failover_classify(-E_XCE_DUP_JOB, true, 0));
/* non-write replay → TERMINAL */
TEST_ASSERT_EQUAL_INT(XCE_FO_TERMINAL,
xce_failover_classify(-E_XCE_DUP_JOB, false, 1));
TEST_ASSERT_EQUAL_INT(XCE_FO_TERMINAL,
xce_failover_classify(-EEXIST, false, 1));
printf("test_classify_dup_replay_rule: PASS\n");
}
/* Req 3.2 / general: status 0 is always success. */
static void test_classify_zero_is_success(void)
{
TEST_ASSERT_EQUAL_INT(XCE_FO_SUCCESS,
xce_failover_classify(0, false, 0));
TEST_ASSERT_EQUAL_INT(XCE_FO_SUCCESS,
xce_failover_classify(0, true, 3));
printf("test_classify_zero_is_success: PASS\n");
}
/* Req 4.5: unknown / unrecognized statuses are conservatively terminal. */
static void test_classify_unknown_is_terminal(void)
{
TEST_ASSERT_EQUAL_INT(XCE_FO_TERMINAL,
xce_failover_classify(-EACCES, false, 0));
TEST_ASSERT_EQUAL_INT(XCE_FO_TERMINAL,
xce_failover_classify(-ENOENT, true, 1));
TEST_ASSERT_EQUAL_INT(XCE_FO_TERMINAL,
xce_failover_classify(-12345, false, 0));
printf("test_classify_unknown_is_terminal: PASS\n");
}
/* ── Suite entry point ──────────────────────────────────────────────── */
void test_libxce_retry(void)
@ -356,5 +462,10 @@ void test_libxce_retry(void)
RUN_TEST(test_status_table_redirect_hint_flag);
RUN_TEST(test_thread_harness_runs_all_threads);
RUN_TEST(test_thread_harness_rejects_bad_args);
RUN_TEST(test_classify_matches_taxonomy_table);
RUN_TEST(test_classify_control_codes_never_success);
RUN_TEST(test_classify_dup_replay_rule);
RUN_TEST(test_classify_zero_is_success);
RUN_TEST(test_classify_unknown_is_terminal);
UNITY_END();
}