diff --git a/.kiro/specs/libxce-failover/tasks.md b/.kiro/specs/libxce-failover/tasks.md index 43f7e82..840c304 100644 --- a/.kiro/specs/libxce-failover/tasks.md +++ b/.kiro/specs/libxce-failover/tasks.md @@ -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 diff --git a/src/include/xce/client_failover.h b/src/include/xce/client_failover.h new file mode 100644 index 0000000..db24a39 --- /dev/null +++ b/src/include/xce/client_failover.h @@ -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 + +/* 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 */ diff --git a/src/lib/client_failover.c b/src/lib/client_failover.c new file mode 100644 index 0000000..0e23477 --- /dev/null +++ b/src/lib/client_failover.c @@ -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 + +#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; +} diff --git a/src/lib/meson.build b/src/lib/meson.build index 3e98b8a..c0c4815 100644 --- a/src/lib/meson.build +++ b/src/lib/meson.build @@ -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', diff --git a/src/lib/testsuite/test_libxce_retry.c b/src/lib/testsuite/test_libxce_retry.c index 02cc43d..3885225 100644 --- a/src/lib/testsuite/test_libxce_retry.c +++ b/src/lib/testsuite/test_libxce_retry.c @@ -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(); }