diff --git a/include/BackendSyncDecision.h b/include/BackendSyncDecision.h new file mode 100644 index 000000000..b0be1728a --- /dev/null +++ b/include/BackendSyncDecision.h @@ -0,0 +1,48 @@ +/** + * @file BackendSyncDecision.h + * @brief Pure decision functions for backend variable synchronization. + * + * Extracted from MySQL_Session's verify chain (handler_again___verify_*). + * Determines what sync actions are needed before a query can execute + * on a backend connection. + * + * @see Phase 3.6 (GitHub issue #5494) + */ + +#ifndef BACKEND_SYNC_DECISION_H +#define BACKEND_SYNC_DECISION_H + +/** + * @brief Actions that may be needed to synchronize backend state. + */ +enum BackendSyncAction { + SYNC_NONE = 0, ///< No synchronization needed. + SYNC_SCHEMA = 1, ///< Schema (USE db) needs to be sent. + SYNC_USER = 2, ///< Username mismatch, CHANGE USER required. + SYNC_AUTOCOMMIT = 4, ///< Autocommit state needs to be synced. +}; + +/** + * @brief Determine what sync actions are needed for the backend. + * + * Checks client vs backend state and returns a bitmask of required + * actions. Mirrors the MySQL_Session verify chain logic. + * + * @param client_user Client connection username. + * @param backend_user Backend connection username. + * @param client_schema Client connection schema. + * @param backend_schema Backend connection schema. + * @param client_autocommit Client autocommit setting. + * @param backend_autocommit Backend autocommit setting. + * @return Bitmask of BackendSyncAction values. + */ +int determine_backend_sync_actions( + const char *client_user, + const char *backend_user, + const char *client_schema, + const char *backend_schema, + bool client_autocommit, + bool backend_autocommit +); + +#endif // BACKEND_SYNC_DECISION_H diff --git a/include/MySQLErrorClassifier.h b/include/MySQLErrorClassifier.h new file mode 100644 index 000000000..6830e1fa8 --- /dev/null +++ b/include/MySQLErrorClassifier.h @@ -0,0 +1,75 @@ +/** + * @file MySQLErrorClassifier.h + * @brief Pure MySQL error classification for retry decisions. + * + * Extracted from MySQL_Session handler_ProcessingQueryError_CheckBackendConnectionStatus() + * and handler_minus1_HandleErrorCodes(). + * + * @see Phase 3.7 (GitHub issue #5495) + */ + +#ifndef MYSQL_ERROR_CLASSIFIER_H +#define MYSQL_ERROR_CLASSIFIER_H + +/** + * @brief Action to take after a MySQL backend query error. + */ +enum MySQLErrorAction { + MYSQL_ERROR_RETRY_ON_NEW_CONN, ///< Reconnect and retry on a new server. + MYSQL_ERROR_REPORT_TO_CLIENT ///< Send error to client, no retry. +}; + +/** + * @brief Classify a MySQL error code to determine retry eligibility. + * + * Mirrors the logic in handler_minus1_HandleErrorCodes(): + * - Error 1047 (WSREP not ready): retryable if conditions permit + * - Error 1053 (server shutdown): retryable if conditions permit + * - Other errors: report to client + * + * Retry is only possible when: + * - query_retries_on_failure > 0 + * - connection is reusable + * - no active transaction + * - multiplex not disabled + * + * @param error_code MySQL error number. + * @param retries_remaining Number of retries left (> 0 to allow retry). + * @param connection_reusable Whether the connection can be reused. + * @param in_active_transaction Whether a transaction is in progress. + * @param multiplex_disabled Whether multiplexing is disabled. + * @return MySQLErrorAction indicating what to do. + */ +MySQLErrorAction classify_mysql_error( + unsigned int error_code, + int retries_remaining, + bool connection_reusable, + bool in_active_transaction, + bool multiplex_disabled +); + +/** + * @brief Check if a backend query can be retried on a new connection. + * + * Mirrors handler_ProcessingQueryError_CheckBackendConnectionStatus(). + * A retry is possible when the server is offline AND all retry + * conditions are met. + * + * @param server_offline Whether the backend server is offline. + * @param retries_remaining Number of retries left (> 0 to allow retry). + * @param connection_reusable Whether the connection can be reused. + * @param in_active_transaction Whether a transaction is in progress. + * @param multiplex_disabled Whether multiplexing is disabled. + * @param transfer_started Whether result transfer has already begun. + * @return true if the query should be retried on a new connection. + */ +bool can_retry_on_new_connection( + bool server_offline, + int retries_remaining, + bool connection_reusable, + bool in_active_transaction, + bool multiplex_disabled, + bool transfer_started +); + +#endif // MYSQL_ERROR_CLASSIFIER_H diff --git a/include/ServerSelection.h b/include/ServerSelection.h new file mode 100644 index 000000000..58a835246 --- /dev/null +++ b/include/ServerSelection.h @@ -0,0 +1,84 @@ +/** + * @file ServerSelection.h + * @brief Pure server selection algorithm for unit testability. + * + * Extracted from get_random_MySrvC() in the HostGroups Manager. + * Uses a lightweight ServerCandidate struct instead of MySrvC to + * avoid connection pool dependencies. + * + * @see Phase 3.4 (GitHub issue #5492) + */ + +#ifndef SERVER_SELECTION_H +#define SERVER_SELECTION_H + +#include + +/** + * @brief Server status values (mirrors MySerStatus enum). + * + * Redefined here to avoid pulling in proxysql_structs.h and its + * entire dependency chain. + */ +enum ServerSelectionStatus { + SERVER_ONLINE = 0, + SERVER_SHUNNED = 1, + SERVER_OFFLINE_SOFT = 2, + SERVER_OFFLINE_HARD = 3, + SERVER_SHUNNED_REPLICATION_LAG = 4 +}; + +/** + * @brief Lightweight struct with decision-relevant server fields only. + * + * Avoids coupling to MySrvC which contains connection pool pointers, + * MySQL_Connection objects, and other heavy dependencies. + */ +struct ServerCandidate { + int index; ///< Caller-defined index (returned on selection). + int64_t weight; ///< Selection weight (0 = never selected). + ServerSelectionStatus status; ///< Current health status. + unsigned int current_connections; ///< Active connection count. + unsigned int max_connections; ///< Maximum allowed connections. + unsigned int current_latency_us; ///< Measured latency in microseconds. + unsigned int max_latency_us; ///< Maximum allowed latency (0 = no limit). + unsigned int current_repl_lag; ///< Measured replication lag in seconds. + unsigned int max_repl_lag; ///< Maximum allowed lag (0 = no limit). +}; + +/** + * @brief Check if a server candidate is eligible for selection. + * + * A candidate is eligible when: + * - status == SERVER_ONLINE + * - current_connections < max_connections + * - current_latency_us <= max_latency_us (or max_latency_us == 0) + * - current_repl_lag <= max_repl_lag (or max_repl_lag == 0) + * + * @note In production, max_latency_us == 0 on a per-server basis means + * "use the thread default max latency." This extraction treats 0 + * as "no limit" for simplicity. Callers should resolve defaults + * before populating the ServerCandidate. + * + * @return true if the candidate is eligible. + */ +bool is_candidate_eligible(const ServerCandidate &candidate); + +/** + * @brief Select a server from candidates using weighted random selection. + * + * Filters candidates by eligibility, then selects from eligible ones + * using weighted random with the provided seed for deterministic testing. + * + * @param candidates Array of server candidates. + * @param count Number of candidates in the array. + * @param random_seed Seed for deterministic random selection. + * @return Index field of the selected candidate, or -1 if none eligible. + */ +int select_server_from_candidates( + const ServerCandidate *candidates, + int count, + unsigned int random_seed +); + +#endif // SERVER_SELECTION_H diff --git a/lib/BackendSyncDecision.cpp b/lib/BackendSyncDecision.cpp new file mode 100644 index 000000000..b9c64e402 --- /dev/null +++ b/lib/BackendSyncDecision.cpp @@ -0,0 +1,54 @@ +/** + * @file BackendSyncDecision.cpp + * @brief Implementation of backend variable sync decisions. + * + * @see BackendSyncDecision.h + * @see Phase 3.6 (GitHub issue #5494) + */ + +#include "BackendSyncDecision.h" +#include + +int determine_backend_sync_actions( + const char *client_user, + const char *backend_user, + const char *client_schema, + const char *backend_schema, + bool client_autocommit, + bool backend_autocommit) +{ + int actions = SYNC_NONE; + + // Username mismatch → CHANGE USER required + // Asymmetric NULLs (one set, other not) count as mismatch + if (client_user == nullptr && backend_user != nullptr) { + actions |= SYNC_USER; + } else if (client_user != nullptr && backend_user == nullptr) { + actions |= SYNC_USER; + } else if (client_user && backend_user) { + if (strcmp(client_user, backend_user) != 0) { + actions |= SYNC_USER; + } + } + + // Schema mismatch → USE required + // Only check if usernames match (user change handles schema too) + if (!(actions & SYNC_USER)) { + if (client_schema == nullptr && backend_schema != nullptr) { + actions |= SYNC_SCHEMA; + } else if (client_schema != nullptr && backend_schema == nullptr) { + actions |= SYNC_SCHEMA; + } else if (client_schema && backend_schema) { + if (strcmp(client_schema, backend_schema) != 0) { + actions |= SYNC_SCHEMA; + } + } + } + + // Autocommit mismatch → SET autocommit required + if (client_autocommit != backend_autocommit) { + actions |= SYNC_AUTOCOMMIT; + } + + return actions; +} diff --git a/lib/Makefile b/lib/Makefile index b5cf85df3..2d9ecc0d6 100644 --- a/lib/Makefile +++ b/lib/Makefile @@ -106,9 +106,12 @@ _OBJ_CXX := ProxySQL_GloVars.oo network.oo debug.oo configfile.oo Query_Cache.oo PgSQL_PreparedStatement.oo PgSQL_Extended_Query_Message.oo \ pgsql_tokenizer.oo \ MonitorHealthDecision.oo \ + ServerSelection.oo \ TransactionState.oo \ HostgroupRouting.oo \ PgSQLMonitorDecision.oo \ + MySQLErrorClassifier.oo \ + BackendSyncDecision.oo \ proxy_sqlite3_symbols.oo # TSDB object files diff --git a/lib/MySQLErrorClassifier.cpp b/lib/MySQLErrorClassifier.cpp new file mode 100644 index 000000000..69ee01027 --- /dev/null +++ b/lib/MySQLErrorClassifier.cpp @@ -0,0 +1,66 @@ +/** + * @file MySQLErrorClassifier.cpp + * @brief Implementation of MySQL error classification. + * + * @see MySQLErrorClassifier.h + * @see Phase 3.7 (GitHub issue #5495) + */ + +#include "MySQLErrorClassifier.h" + +MySQLErrorAction classify_mysql_error( + unsigned int error_code, + int retries_remaining, + bool connection_reusable, + bool in_active_transaction, + bool multiplex_disabled) +{ + // Check if this error code is retryable + bool retryable_error = false; + switch (error_code) { + case 1047: // ER_UNKNOWN_COM_ERROR (WSREP not ready) + case 1053: // ER_SERVER_SHUTDOWN + retryable_error = true; + break; + default: + break; + } + + if (!retryable_error) { + return MYSQL_ERROR_REPORT_TO_CLIENT; + } + + // Check retry conditions (mirrors handler_minus1_HandleErrorCodes) + if (retries_remaining > 0 + && connection_reusable + && !in_active_transaction + && !multiplex_disabled) { + return MYSQL_ERROR_RETRY_ON_NEW_CONN; + } + + return MYSQL_ERROR_REPORT_TO_CLIENT; +} + +bool can_retry_on_new_connection( + bool server_offline, + int retries_remaining, + bool connection_reusable, + bool in_active_transaction, + bool multiplex_disabled, + bool transfer_started) +{ + if (!server_offline) { + return false; // server is fine, no retry needed + } + + // Mirror handler_ProcessingQueryError_CheckBackendConnectionStatus + if (retries_remaining > 0 + && connection_reusable + && !in_active_transaction + && !multiplex_disabled + && !transfer_started) { + return true; + } + + return false; +} diff --git a/lib/ServerSelection.cpp b/lib/ServerSelection.cpp new file mode 100644 index 000000000..4704848b9 --- /dev/null +++ b/lib/ServerSelection.cpp @@ -0,0 +1,69 @@ +/** + * @file ServerSelection.cpp + * @brief Implementation of the pure server selection algorithm. + * + * @see ServerSelection.h + * @see Phase 3.4 (GitHub issue #5492) + */ + +#include "ServerSelection.h" + +bool is_candidate_eligible(const ServerCandidate &c) { + if (c.status != SERVER_ONLINE) { + return false; + } + if (c.current_connections >= c.max_connections) { + return false; + } + if (c.max_latency_us > 0 && c.current_latency_us > c.max_latency_us) { + return false; + } + if (c.max_repl_lag > 0 && c.current_repl_lag > c.max_repl_lag) { + return false; + } + return true; +} + +int select_server_from_candidates( + const ServerCandidate *candidates, + int count, + unsigned int random_seed) +{ + if (candidates == nullptr || count <= 0) { + return -1; + } + + // First pass: compute total weight of eligible candidates + int64_t total_weight = 0; + for (int i = 0; i < count; i++) { + if (is_candidate_eligible(candidates[i]) && candidates[i].weight > 0) { + total_weight += candidates[i].weight; + } + } + + if (total_weight == 0) { + return -1; // no eligible candidates + } + + // Seeded random selection + // Use a simple LCG to avoid polluting global srand() state + // LCG: next = (a * seed + c) mod m (Numerical Recipes parameters) + unsigned int rng_state = random_seed; + rng_state = rng_state * 1664525u + 1013904223u; + // Use 64-bit modulo to avoid truncation when total_weight > UINT_MAX + int64_t target = (int64_t)(rng_state % (uint64_t)total_weight) + 1; + + // Second pass: weighted selection + int64_t cumulative = 0; + for (int i = 0; i < count; i++) { + if (is_candidate_eligible(candidates[i]) && candidates[i].weight > 0) { + cumulative += candidates[i].weight; + if (cumulative >= target) { + return candidates[i].index; + } + } + } + + // Should not reach here if total_weight > 0, but safety fallback + return -1; +} diff --git a/test/tap/tests/unit/Makefile b/test/tap/tests/unit/Makefile index 440a04822..0d9e39853 100644 --- a/test/tap/tests/unit/Makefile +++ b/test/tap/tests/unit/Makefile @@ -234,9 +234,12 @@ $(ODIR)/test_init.o: $(TEST_HELPERS_DIR)/test_init.cpp | $(ODIR) UNIT_TESTS := smoke_test-t query_cache_unit-t query_processor_unit-t \ protocol_unit-t auth_unit-t connection_pool_unit-t \ rule_matching_unit-t hostgroups_unit-t monitor_health_unit-t \ + server_selection_unit-t hostgroup_routing_unit-t \ transaction_state_unit-t \ - pgsql_monitor_unit-t + pgsql_monitor_unit-t \ + mysql_error_classifier_unit-t \ + backend_sync_unit-t .PHONY: all all: $(UNIT_TESTS) @@ -251,6 +254,8 @@ ifneq ($(UNAME_S),Darwin) endif # Pattern rule: all unit tests use the same compile + link flags. +# Each test binary is built from its .cpp source, linked against +# the test harness objects and libproxysql.a with all dependencies. %-t: %-t.cpp $(TEST_HELPERS_OBJ) $(LIBPROXYSQLAR) $(CXX) $< $(TEST_HELPERS_OBJ) $(IDIRS) $(LDIRS) $(OPT) \ $(LIBPROXYSQLAR_FULL) $(STATIC_LIBS) $(MYLIBS) \ diff --git a/test/tap/tests/unit/backend_sync_unit-t.cpp b/test/tap/tests/unit/backend_sync_unit-t.cpp new file mode 100644 index 000000000..8dd26dbe2 --- /dev/null +++ b/test/tap/tests/unit/backend_sync_unit-t.cpp @@ -0,0 +1,87 @@ +/** + * @file backend_sync_unit-t.cpp + * @brief Unit tests for backend variable sync decisions. + * + * @see Phase 3.6 (GitHub issue #5494) + */ + +#include "tap.h" +#include "test_globals.h" +#include "test_init.h" +#include "proxysql.h" +#include "BackendSyncDecision.h" + +static void test_no_sync_needed() { + int a = determine_backend_sync_actions("user", "user", "db", "db", true, true); + ok(a == SYNC_NONE, "no sync: all match"); + + a = determine_backend_sync_actions("user", "user", "db", "db", false, false); + ok(a == SYNC_NONE, "no sync: autocommit both false"); +} + +static void test_schema_mismatch() { + int a = determine_backend_sync_actions("user", "user", "app_db", "other_db", true, true); + ok((a & SYNC_SCHEMA) != 0, "schema mismatch: SYNC_SCHEMA set"); + ok((a & SYNC_USER) == 0, "schema mismatch: SYNC_USER not set"); +} + +static void test_user_mismatch() { + int a = determine_backend_sync_actions("alice", "bob", "db", "db", true, true); + ok((a & SYNC_USER) != 0, "user mismatch: SYNC_USER set"); + // Schema check skipped when user differs (CHANGE USER handles schema) + ok((a & SYNC_SCHEMA) == 0, "user mismatch: SYNC_SCHEMA not set (handled by CHANGE USER)"); +} + +static void test_user_and_schema_mismatch() { + int a = determine_backend_sync_actions("alice", "bob", "db1", "db2", true, true); + ok((a & SYNC_USER) != 0, "user+schema: SYNC_USER set"); + ok((a & SYNC_SCHEMA) == 0, "user+schema: schema handled by user change"); +} + +static void test_autocommit_mismatch() { + int a = determine_backend_sync_actions("user", "user", "db", "db", true, false); + ok((a & SYNC_AUTOCOMMIT) != 0, "autocommit mismatch: SYNC_AUTOCOMMIT set"); + ok((a & SYNC_SCHEMA) == 0, "autocommit mismatch: no other sync"); +} + +static void test_multiple_mismatches() { + int a = determine_backend_sync_actions("user", "user", "db1", "db2", true, false); + ok((a & SYNC_SCHEMA) != 0, "multi: SYNC_SCHEMA set"); + ok((a & SYNC_AUTOCOMMIT) != 0, "multi: SYNC_AUTOCOMMIT set"); +} + +static void test_null_handling() { + // null users — no crash + // Asymmetric NULL: one side null, other not → mismatch + int a = determine_backend_sync_actions(nullptr, "user", "db", "db", true, true); + ok((a & SYNC_USER) != 0, "null client_user + non-null backend → SYNC_USER"); + + a = determine_backend_sync_actions("user", nullptr, "db", "db", true, true); + ok((a & SYNC_USER) != 0, "non-null client_user + null backend → SYNC_USER"); + + // Both null → no mismatch + a = determine_backend_sync_actions(nullptr, nullptr, "db", "db", true, true); + ok(a == SYNC_NONE, "both users null → no sync"); + + // Schema asymmetric null + a = determine_backend_sync_actions("user", "user", nullptr, "db", true, true); + ok((a & SYNC_SCHEMA) != 0, "null client_schema + non-null backend → SYNC_SCHEMA"); +} + +int main() { + plan(17); + int rc = test_init_minimal(); + ok(rc == 0, "test_init_minimal() succeeds"); + + test_no_sync_needed(); // 2 + test_schema_mismatch(); // 2 + test_user_mismatch(); // 2 + test_user_and_schema_mismatch(); // 2 + test_autocommit_mismatch(); // 2 + test_multiple_mismatches(); // 2 + test_null_handling(); // 4 + // Total: 1+2+2+2+2+2+2+4 = 17 + + test_cleanup_minimal(); + return exit_status(); +} diff --git a/test/tap/tests/unit/mysql_error_classifier_unit-t.cpp b/test/tap/tests/unit/mysql_error_classifier_unit-t.cpp new file mode 100644 index 000000000..471779c6b --- /dev/null +++ b/test/tap/tests/unit/mysql_error_classifier_unit-t.cpp @@ -0,0 +1,98 @@ +/** + * @file mysql_error_classifier_unit-t.cpp + * @brief Unit tests for MySQL error classification. + * + * @see Phase 3.7 (GitHub issue #5495) + */ + +#include "tap.h" +#include "test_globals.h" +#include "test_init.h" +#include "proxysql.h" +#include "MySQLErrorClassifier.h" + +// ============================================================================ +// 1. classify_mysql_error +// ============================================================================ + +static void test_retryable_errors() { + // 1047 (WSREP not ready) with retry conditions met + ok(classify_mysql_error(1047, 3, true, false, false) == MYSQL_ERROR_RETRY_ON_NEW_CONN, + "1047: retryable when conditions met"); + // 1053 (server shutdown) with retry conditions met + ok(classify_mysql_error(1053, 1, true, false, false) == MYSQL_ERROR_RETRY_ON_NEW_CONN, + "1053: retryable when conditions met"); +} + +static void test_retryable_but_blocked() { + // 1047 but no retries left + ok(classify_mysql_error(1047, 0, true, false, false) == MYSQL_ERROR_REPORT_TO_CLIENT, + "1047: not retried when retries=0"); + // 1047 but connection not reusable + ok(classify_mysql_error(1047, 3, false, false, false) == MYSQL_ERROR_REPORT_TO_CLIENT, + "1047: not retried when connection not reusable"); + // 1047 but in active transaction + ok(classify_mysql_error(1047, 3, true, true, false) == MYSQL_ERROR_REPORT_TO_CLIENT, + "1047: not retried during active transaction"); + // 1047 but multiplex disabled + ok(classify_mysql_error(1047, 3, true, false, true) == MYSQL_ERROR_REPORT_TO_CLIENT, + "1047: not retried when multiplex disabled"); +} + +static void test_non_retryable_errors() { + // Common MySQL errors — always report to client + ok(classify_mysql_error(1045, 3, true, false, false) == MYSQL_ERROR_REPORT_TO_CLIENT, + "1045 (access denied): always report"); + ok(classify_mysql_error(1064, 3, true, false, false) == MYSQL_ERROR_REPORT_TO_CLIENT, + "1064 (syntax error): always report"); + ok(classify_mysql_error(1146, 3, true, false, false) == MYSQL_ERROR_REPORT_TO_CLIENT, + "1146 (table not found): always report"); + ok(classify_mysql_error(2006, 3, true, false, false) == MYSQL_ERROR_REPORT_TO_CLIENT, + "2006 (gone away): always report"); + ok(classify_mysql_error(0, 3, true, false, false) == MYSQL_ERROR_REPORT_TO_CLIENT, + "0 (no error): report"); +} + +// ============================================================================ +// 2. can_retry_on_new_connection +// ============================================================================ + +static void test_retry_on_offline() { + ok(can_retry_on_new_connection(true, 3, true, false, false, false) == true, + "retry: server offline, all conditions met"); +} + +static void test_no_retry_server_online() { + ok(can_retry_on_new_connection(false, 3, true, false, false, false) == false, + "no retry: server is online"); +} + +static void test_no_retry_conditions() { + ok(can_retry_on_new_connection(true, 0, true, false, false, false) == false, + "no retry: no retries left"); + ok(can_retry_on_new_connection(true, 3, false, false, false, false) == false, + "no retry: connection not reusable"); + ok(can_retry_on_new_connection(true, 3, true, true, false, false) == false, + "no retry: active transaction"); + ok(can_retry_on_new_connection(true, 3, true, false, true, false) == false, + "no retry: multiplex disabled"); + ok(can_retry_on_new_connection(true, 3, true, false, false, true) == false, + "no retry: transfer already started"); +} + +int main() { + plan(19); + int rc = test_init_minimal(); + ok(rc == 0, "test_init_minimal() succeeds"); + + test_retryable_errors(); // 2 + test_retryable_but_blocked(); // 4 + test_non_retryable_errors(); // 5 + test_retry_on_offline(); // 1 + test_no_retry_server_online(); // 1 + test_no_retry_conditions(); // 5 + // Total: 1+2+4+5+1+1+5 = 19 + + test_cleanup_minimal(); + return exit_status(); +} diff --git a/test/tap/tests/unit/server_selection_unit-t.cpp b/test/tap/tests/unit/server_selection_unit-t.cpp new file mode 100644 index 000000000..4b02d027b --- /dev/null +++ b/test/tap/tests/unit/server_selection_unit-t.cpp @@ -0,0 +1,223 @@ +/** + * @file server_selection_unit-t.cpp + * @brief Unit tests for the server selection algorithm. + * + * Tests the pure selection functions extracted from get_random_MySrvC(): + * - is_candidate_eligible() + * - select_server_from_candidates() + * + * @see Phase 3.4 (GitHub issue #5492) + */ + +#include "tap.h" +#include "test_globals.h" +#include "test_init.h" + +#include "proxysql.h" +#include "ServerSelection.h" + +// ============================================================================ +// Helper: create a default ONLINE server candidate +// ============================================================================ +static ServerCandidate make_candidate(int idx, int64_t weight = 1, + unsigned int max_conns = 1000) +{ + ServerCandidate c {}; + c.index = idx; + c.weight = weight; + c.status = SERVER_ONLINE; + c.current_connections = 0; + c.max_connections = max_conns; + c.current_latency_us = 0; + c.max_latency_us = 0; + c.current_repl_lag = 0; + c.max_repl_lag = 0; + return c; +} + +// ============================================================================ +// 1. is_candidate_eligible +// ============================================================================ + +static void test_eligibility() { + ServerCandidate online = make_candidate(0); + ok(is_candidate_eligible(online) == true, "eligible: ONLINE server"); + + ServerCandidate shunned = make_candidate(1); + shunned.status = SERVER_SHUNNED; + ok(is_candidate_eligible(shunned) == false, "ineligible: SHUNNED"); + + ServerCandidate off_soft = make_candidate(2); + off_soft.status = SERVER_OFFLINE_SOFT; + ok(is_candidate_eligible(off_soft) == false, "ineligible: OFFLINE_SOFT"); + + ServerCandidate off_hard = make_candidate(3); + off_hard.status = SERVER_OFFLINE_HARD; + ok(is_candidate_eligible(off_hard) == false, "ineligible: OFFLINE_HARD"); + + ServerCandidate lag_shunned = make_candidate(4); + lag_shunned.status = SERVER_SHUNNED_REPLICATION_LAG; + ok(is_candidate_eligible(lag_shunned) == false, "ineligible: SHUNNED_REPL_LAG"); + + ServerCandidate at_max = make_candidate(5, 1, 10); + at_max.current_connections = 10; + ok(is_candidate_eligible(at_max) == false, "ineligible: at max_connections"); + + ServerCandidate below_max = make_candidate(6, 1, 10); + below_max.current_connections = 9; + ok(is_candidate_eligible(below_max) == true, "eligible: below max_connections"); + + ServerCandidate high_latency = make_candidate(7); + high_latency.max_latency_us = 5000; + high_latency.current_latency_us = 6000; + ok(is_candidate_eligible(high_latency) == false, "ineligible: high latency"); + + ServerCandidate ok_latency = make_candidate(8); + ok_latency.max_latency_us = 5000; + ok_latency.current_latency_us = 4000; + ok(is_candidate_eligible(ok_latency) == true, "eligible: acceptable latency"); + + ServerCandidate no_limit = make_candidate(9); + no_limit.max_latency_us = 0; + no_limit.current_latency_us = 999999; + ok(is_candidate_eligible(no_limit) == true, "eligible: latency limit disabled (max=0)"); + + ServerCandidate high_lag = make_candidate(10); + high_lag.max_repl_lag = 10; + high_lag.current_repl_lag = 15; + ok(is_candidate_eligible(high_lag) == false, "ineligible: high repl lag"); + + ServerCandidate ok_lag = make_candidate(11); + ok_lag.max_repl_lag = 10; + ok_lag.current_repl_lag = 5; + ok(is_candidate_eligible(ok_lag) == true, "eligible: acceptable repl lag"); +} + +// ============================================================================ +// 2. select_server_from_candidates — basic +// ============================================================================ + +static void test_select_single() { + ServerCandidate c = make_candidate(42); + int result = select_server_from_candidates(&c, 1, 12345); + ok(result == 42, "single server: always selected (idx=42)"); +} + +static void test_select_empty() { + ok(select_server_from_candidates(nullptr, 0, 0) == -1, + "empty list: returns -1"); +} + +static void test_select_all_offline() { + ServerCandidate candidates[3]; + candidates[0] = make_candidate(0); candidates[0].status = SERVER_OFFLINE_HARD; + candidates[1] = make_candidate(1); candidates[1].status = SERVER_SHUNNED; + candidates[2] = make_candidate(2); candidates[2].status = SERVER_OFFLINE_SOFT; + + ok(select_server_from_candidates(candidates, 3, 999) == -1, + "all offline: returns -1"); +} + +static void test_select_weight_zero() { + ServerCandidate c = make_candidate(0, 0); + ok(select_server_from_candidates(&c, 1, 12345) == -1, + "weight=0: never selected"); +} + +// ============================================================================ +// 3. Weighted distribution (statistical) +// ============================================================================ + +static void test_equal_weight_distribution() { + ServerCandidate candidates[2]; + candidates[0] = make_candidate(0, 1); + candidates[1] = make_candidate(1, 1); + + int count[2] = {0, 0}; + const int N = 10000; + for (int seed = 0; seed < N; seed++) { + int result = select_server_from_candidates(candidates, 2, seed); + if (result >= 0 && result <= 1) count[result]++; + } + + double pct0 = (double)count[0] / N * 100; + ok(pct0 > 30 && pct0 < 70, + "equal weight: server 0 selected %.1f%% (expect ~50%%)", pct0); +} + +static void test_weighted_distribution() { + ServerCandidate candidates[2]; + candidates[0] = make_candidate(0, 3); // weight 3 + candidates[1] = make_candidate(1, 1); // weight 1 + + int count[2] = {0, 0}; + const int N = 10000; + for (int seed = 0; seed < N; seed++) { + int result = select_server_from_candidates(candidates, 2, seed); + if (result >= 0 && result <= 1) count[result]++; + } + + double pct0 = (double)count[0] / N * 100; + ok(pct0 > 60 && pct0 < 90, + "3:1 weight: server 0 selected %.1f%% (expect ~75%%)", pct0); +} + +// ============================================================================ +// 4. Determinism +// ============================================================================ + +static void test_determinism() { + ServerCandidate candidates[3]; + candidates[0] = make_candidate(0, 2); + candidates[1] = make_candidate(1, 3); + candidates[2] = make_candidate(2, 5); + + int r1 = select_server_from_candidates(candidates, 3, 42); + int r2 = select_server_from_candidates(candidates, 3, 42); + ok(r1 == r2, "determinism: same seed → same result"); +} + +// ============================================================================ +// 5. Mixed eligible/ineligible +// ============================================================================ + +static void test_mixed_eligibility() { + ServerCandidate candidates[4]; + candidates[0] = make_candidate(0, 1); candidates[0].status = SERVER_SHUNNED; + candidates[1] = make_candidate(1, 1); candidates[1].status = SERVER_OFFLINE_HARD; + candidates[2] = make_candidate(2, 1); // ONLINE + candidates[3] = make_candidate(3, 1); candidates[3].status = SERVER_OFFLINE_SOFT; + + // Only candidate[2] is eligible — must always be selected + int pass = 0; + for (int seed = 0; seed < 100; seed++) { + if (select_server_from_candidates(candidates, 4, seed) == 2) pass++; + } + ok(pass == 100, + "mixed: only eligible server selected 100/100 times"); +} + +// ============================================================================ +// Main +// ============================================================================ + +int main() { + plan(21); + + int rc = test_init_minimal(); + ok(rc == 0, "test_init_minimal() succeeds"); + + test_eligibility(); // 12 + test_select_single(); // 1 + test_select_empty(); // 1 + test_select_all_offline(); // 1 + test_select_weight_zero(); // 1 + test_equal_weight_distribution(); // 1 + test_weighted_distribution(); // 1 + test_determinism(); // 1 + test_mixed_eligibility(); // 1 + // Total: 1+12+1+1+1+1+1+1+1+1 = 21 + + test_cleanup_minimal(); + return exit_status(); +}