diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..47a84e868c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,156 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +ProxySQL is a high-performance, protocol-aware proxy for MySQL (and forks like MariaDB, Percona Server) and PostgreSQL. Written in C++17, it provides connection pooling, query routing, caching, and monitoring. Licensed under GPL. + +## Build Commands + +The build system is GNU Make-based with a three-stage pipeline: `deps` → `lib` → `src`. + +```bash +# Full release build (auto-detects -j based on nproc/hw.ncpu) +make + +# Debug build (-O0, -ggdb, -DDEBUG) +make debug + +# Build with ASAN (requires no jemalloc) +NOJEMALLOC=1 WITHASAN=1 make build_deps_debug && make debug && make build_tap_test_debug + +# Build TAP tests (requires proxysql binary built first) +make build_tap_tests # release +make build_tap_test_debug # debug + +# Clean +make clean # clean src/lib +make cleanall # clean everything including deps + +# Build packages +make packages +``` + +### Feature Tiers + +The same codebase produces three product tiers via feature flags: + +| Tier | Flag | Version | Adds | +|------|------|---------|------| +| Stable | (default) | v3.0.x | Core proxy | +| Innovative | `PROXYSQL31=1` | v3.1.x | FFTO, TSDB | +| AI/MCP | `PROXYSQLGENAI=1` | v4.0.x | GenAI, MCP, Anomaly Detection (requires Rust toolchain) | + +`PROXYSQLGENAI=1` implies `PROXYSQL31=1`, which implies `PROXYSQLFFTO=1` and `PROXYSQLTSDB=1`. + +### Build Flags + +- `NOJEMALLOC=1` — disable jemalloc +- `WITHASAN=1` — enable AddressSanitizer (requires `NOJEMALLOC=1`) +- `WITHGCOV=1` — enable code coverage +- `PROXYSQLCLICKHOUSE=1` — enabled by default in current builds + +## Testing + +Tests use TAP (Test Anything Protocol) with Docker-based backend infrastructure. + +```bash +# Build and run all TAP tests +make build_tap_tests +cd test/tap && make + +# Run specific test groups +cd test/tap/tests && make +cd test/tap/tests_with_deps && make + +# Test infrastructure (Docker environments) +# Located in test/infra/ with docker-compose configs for: +# mysql57, mysql84, mariadb10, pgsql16, pgsql17, clickhouse23, etc. +``` + +Test files follow the naming pattern `test_*.cpp` or `*-t.cpp` in `test/tap/tests/`. + +## Architecture + +### Build Pipeline + +``` +deps/ → builds 25+ vendored dependencies as static libraries +lib/ → compiles ~121 .cpp files into libproxysql.a +src/main.cpp → links against libproxysql.a to produce the proxysql binary +``` + +### Dual-Protocol Design + +MySQL and PostgreSQL share parallel class hierarchies with the same architecture but protocol-specific implementations: + +| Layer | MySQL | PostgreSQL | +|-------|-------|------------| +| Protocol | `MySQL_Protocol` | `PgSQL_Protocol` | +| Session | `MySQL_Session` | `PgSQL_Session` | +| Thread | `MySQL_Thread` | `PgSQL_Thread` | +| HostGroups | `MySQL_HostGroups_Manager` | `PgSQL_HostGroups_Manager` | +| Monitor | `MySQL_Monitor` | `PgSQL_Monitor` | +| Query Processor | `MySQL_Query_Processor` | `PgSQL_Query_Processor` | +| Logger | `MySQL_Logger` | `PgSQL_Logger` | + +### Core Components + +- **Admin Interface** (`ProxySQL_Admin.cpp`, `Admin_Handler.cpp`) — SQL-based configuration via SQLite3 backend. Supports runtime config changes without restart. Schema versions tracked in `ProxySQL_Admin_Tables_Definitions.h`. +- **HostGroups Manager** — Routes connections based on hostgroup assignments. Supports master-slave, Galera, Group Replication, and Aurora topologies. +- **Query Processor** — Parses queries, matches against routing rules, handles query caching via `Query_Cache`. +- **Monitor** — Health-checks backends for replication lag, read-only status, and connectivity. +- **Threading** — Event-based I/O using libev. `Base_Thread` base class with protocol-specific thread managers. +- **HTTP/REST** (`ProxySQL_HTTP_Server`, `ProxySQL_RESTAPI_Server`) — Metrics and management endpoints. + +### Key Dependencies (in deps/) + +- `jemalloc` — memory allocator +- `sqlite3` — admin config storage +- `mariadb-client-library` — MySQL protocol +- `postgresql` — PostgreSQL protocol +- `re2`, `pcre` — regex engines +- `libev` — event loop +- `libinjection` — SQL injection detection +- `lz4`, `zstd` — compression +- `curl`, `libmicrohttpd`, `libhttpserver` — HTTP +- `prometheus-cpp` — metrics +- `libscram` — SCRAM authentication + +### Conditional Components + +- **FFTO** (Fast Forward Traffic Observer) — `MySQLFFTO.cpp`, `PgSQLFFTO.cpp` +- **TSDB** — Time-series metrics with embedded dashboard +- **GenAI/MCP** — `GenAI_Thread`, `MCP_Thread`, `LLM_Bridge`, `Anomaly_Detector`, tool handlers +- **ClickHouse** — Native ClickHouse protocol support + +## Code Layout + +- `include/` — All headers (.h/.hpp). Include guards use `#ifndef __CLASS_*_H`. +- `lib/` — Core library sources (~121 files). One class per file typically. +- `src/main.cpp` — Entry point, daemon init, thread spawning (~95K lines). +- `test/tap/` — TAP test framework and tests. +- `test/infra/` — Docker-based test environments. +- `.github/workflows/` — CI/CD pipelines (selftests, TAP tests, package builds, CodeQL). + +## Agent Guidelines + +See `doc/agents/` for detailed guidance on working with AI coding agents: +- `doc/agents/project-conventions.md` — ProxySQL-specific rules (directories, build, test harness, git workflow) +- `doc/agents/task-assignment-template.md` — Template for writing issues assignable to AI agents +- `doc/agents/common-mistakes.md` — Known agent failure patterns with prevention and detection + +### Unit Test Harness + +Unit tests live in `test/tap/tests/unit/` and link against `libproxysql.a` via a custom test harness. Tests must use `test_globals.h` and `test_init.h` — see `doc/agents/project-conventions.md` for the full pattern. + +## Coding Conventions + +- Class names: `PascalCase` with protocol prefixes (`MySQL_`, `PgSQL_`, `ProxySQL_`) +- Member variables: `snake_case` +- Constants/macros: `UPPER_SNAKE_CASE` +- C++17 required; conditional compilation via `#ifdef PROXYSQLGENAI`, `#ifdef PROXYSQL31`, etc. +- Performance-critical code — consider implications of changes to hot paths +- RAII for resource management; jemalloc for allocation +- Pthread mutexes for synchronization; `std::atomic<>` for counters diff --git a/doc/agents/README.md b/doc/agents/README.md new file mode 100644 index 0000000000..1a18febd44 --- /dev/null +++ b/doc/agents/README.md @@ -0,0 +1,9 @@ +# Agent Guidelines for ProxySQL + +This directory contains guidance for AI coding agents (Claude Code, GitHub Copilot, etc.) working on the ProxySQL codebase. + +| Document | Purpose | +|----------|---------| +| [project-conventions.md](project-conventions.md) | ProxySQL-specific rules: where files go, how to build, branch model | +| [task-assignment-template.md](task-assignment-template.md) | Template for writing issues that agents can execute correctly | +| [common-mistakes.md](common-mistakes.md) | Known failure patterns and how to prevent them | diff --git a/doc/agents/common-mistakes.md b/doc/agents/common-mistakes.md new file mode 100644 index 0000000000..143b87a992 --- /dev/null +++ b/doc/agents/common-mistakes.md @@ -0,0 +1,148 @@ +# Common Mistakes by AI Coding Agents + +Patterns observed across multiple AI agent interactions on the ProxySQL codebase, with root cause analysis and prevention strategies. + +## 1. Wrong Branch Target + +**Symptom:** PR targets `v3.0` (main) instead of the feature branch. + +**Root cause:** Agents prioritize technical content over administrative instructions. Even when branch info is present in the issue, agents often skim past it while focusing on code requirements, then use heuristics (e.g., most recent branch, default branch) to fill the gap they don't realize they have. + +**Prevention:** Place git workflow instructions **at the very top** of the issue, before the technical description. Agents read top-down with decreasing attention — administrative details buried after exciting code specs will be skipped. + +``` +### FIRST: Git workflow (do this before reading anything else) +- Create branch `v3.0-XXXX` from `v3.0-5473` +- PR target: `v3.0-5473` +``` + +**Detection:** Check `gh pr view --json baseRefName` after PR creation. + +--- + +## 2. Reimplemented Functions in Test Files + +**Symptom:** Test file contains copy-pasted reimplementations of the functions under test. Tests validate the copy, not the real production code. + +**Root cause:** Agent doesn't know the build system links tests against `libproxysql.a`, so it creates standalone tests that don't depend on the library. + +**Prevention:** +- Explain that tests link against `libproxysql.a` (the real functions are available at link time) +- Add to DO NOT list: "Do NOT reimplement extracted functions in the test file" +- Provide the Makefile rule that shows the linking + +**Detection:** `grep -c "static.*calculate_eviction\|static.*evaluate_pool" test_file.cpp` — if > 0, functions were reimplemented. + +--- + +## 3. Test Files in Wrong Directory + +**Symptom:** Test placed in `test/tap/tests/` (E2E test directory) instead of `test/tap/tests/unit/` (unit test directory). + +**Root cause:** Agent sees existing test files in `test/tap/tests/` and follows that pattern. Doesn't know about the `unit/` subdirectory. + +**Prevention:** Specify the exact file path including directory in the issue deliverables. + +**Detection:** `ls test/tap/tests/*unit*` should return nothing — unit tests belong in `test/tap/tests/unit/`. + +--- + +## 4. Manual TAP Symbol Stubs + +**Symptom:** Test file manually defines `noise_failures`, `noise_failure_mutex`, `stop_noise_tools()`, `get_noise_tools_count()`. + +**Root cause:** Agent compiles `tap.cpp` which references these symbols. Without the harness, the agent must define them. This is a signal the agent isn't using the harness. + +**Prevention:** +- Explain that `test_globals.cpp` already provides all TAP stubs +- Add to DO NOT list: "Do NOT define noise_failures or stop_noise_tools" + +**Detection:** `grep -c "noise_failures\|stop_noise_tools" test_file.cpp` — if > 0, harness not used. + +--- + +## 5. Merged Instead of Rebased + +**Symptom:** PR diff includes dozens of unrelated files because the agent ran `git merge ` into its branch. + +**Root cause:** Agent's default strategy for incorporating upstream changes is merge. This creates a merge commit that brings all upstream changes into the PR diff. + +**Prevention:** Explicit instruction: "Use `git rebase`, NOT `git merge`." + +**Detection:** `git log --merges --not ` — any merge commits indicate merging. + +--- + +## 6. Circular Include Dependencies + +**Symptom:** Production code compiles on the agent's machine (or doesn't get tested) but fails in CI or on other platforms with "unknown type name" errors. + +**Root cause:** ProxySQL has circular include chains (`proxysql.h` → `cpp.h` → `MySQL_HostGroups_Manager.h` → `Base_HostGroups_Manager.h` → `proxysql.h`). Placing new declarations in these headers can result in the declarations being invisible depending on include order. + +**Prevention:** +- Create standalone headers with their own include guards (e.g., `ConnectionPoolDecision.h`) +- Explicitly warn about the circular chain in the issue +- Require `make build_lib -j4` as a verification step + +**Detection:** Compilation failure with "unknown type name" for a type that clearly exists in a header. + +--- + +## 7. Modified Existing Test Files Instead of Creating New Ones + +**Symptom:** Agent adds tests to an existing test file instead of creating a new one for the new feature. + +**Root cause:** Agent sees a test file for a related component and assumes new tests belong there. + +**Prevention:** Specify the exact test file name in the issue: "Create `test/tap/tests/unit/my_feature_unit-t.cpp`." + +--- + +## 8. Didn't Verify Compilation + +**Symptom:** PR contains code that doesn't compile. Agent submitted without building. + +**Root cause:** Some agents don't have access to the build environment, or don't run the build as part of their workflow. + +**Prevention:** +- Add explicit verification step: "`make build_lib -j4` must exit with code 0" +- Add to acceptance criteria as a hard requirement + +--- + +## 9. Overly Broad Changes + +**Symptom:** Agent refactors callers, updates documentation, fixes unrelated bugs, or "improves" code outside the task scope. + +**Root cause:** Agent optimizes for perceived quality/completeness and makes changes it considers beneficial. + +**Prevention:** +- Explicitly scope: "Only modify ``" +- Add: "Do not refactor code outside the scope of this task" +- Add: "Do not fix pre-existing issues you notice — file separate issues for those" + +--- + +## Summary: Red Flags in Agent PRs + +Quick checks to run on any agent-generated PR: + +```bash +# Wrong base branch? +gh pr view --json baseRefName -q '.baseRefName' + +# Test in wrong directory? +gh pr diff | grep "^+++ b/test/tap/tests/[^u]" + +# Reimplemented functions? +gh pr diff | grep "^+static.*calculate_\|^+static.*evaluate_\|^+static.*should_" + +# Manual TAP stubs? +gh pr diff | grep "^+.*noise_failures\|^+.*stop_noise_tools" + +# Merge commits? +gh pr view --json commits --jq '.commits[].messageHeadline' | grep -i merge + +# Unrelated files changed? +gh pr diff | grep "^+++ b/" | grep -v "" +``` diff --git a/doc/agents/project-conventions.md b/doc/agents/project-conventions.md new file mode 100644 index 0000000000..50c5e8d881 --- /dev/null +++ b/doc/agents/project-conventions.md @@ -0,0 +1,149 @@ +# ProxySQL Project Conventions for Agents + +## Build System + +```bash +make build_deps -j4 # Build dependencies (first time only) +make build_lib -j4 # Build libproxysql.a +make build_tap_tests # Build all tests (includes unit tests) +make -j4 # Full build (deps + lib + binary) +``` + +Always verify `make build_lib -j4` compiles successfully before submitting changes to `lib/` or `include/`. + +## Directory Layout + +| Directory | Purpose | When to modify | +|-----------|---------|---------------| +| `include/` | All headers (.h/.hpp) | When adding new declarations | +| `lib/` | Core library sources → compiled into `libproxysql.a` | When adding/modifying implementations | +| `src/` | Entry point (`main.cpp`) and daemon code | Rarely — avoid unless necessary | +| `test/tap/tests/unit/` | Unit tests (no infrastructure needed) | When adding unit tests | +| `test/tap/tests/` | E2E TAP tests (need running ProxySQL + backends) | When adding integration tests | +| `test/tap/test_helpers/` | Unit test harness (`test_globals`, `test_init`) | When extending test infrastructure | +| `doc/` | Documentation | When documenting features | + +## Unit Test Conventions + +### File placement +- Unit tests go in `test/tap/tests/unit/`, NOT in `test/tap/tests/` +- File naming: `_unit-t.cpp` or `-t.cpp` + +### Test harness (MUST use) +Every unit test MUST use the existing harness: + +```cpp +#include "tap.h" +#include "test_globals.h" +#include "test_init.h" + +#include "proxysql.h" +// ... other headers for types you need + +int main() { + plan(); + test_init_minimal(); // Always call first + + // ... test functions with ok(), is(), etc. + + test_cleanup_minimal(); // Always call last + return exit_status(); +} +``` + +The harness provides: +- All `Glo*` global stubs (defined in `test_globals.cpp`) +- TAP noise symbols (`noise_failures`, `stop_noise_tools`, etc.) +- Component init helpers: `test_init_auth()`, `test_init_query_cache()`, `test_init_query_processor()` +- Cleanup functions for each + +### Makefile registration +Edit `test/tap/tests/unit/Makefile`: +1. Append test name to `UNIT_TESTS :=` line +2. Add build rule: +```makefile +my_test-t: my_test-t.cpp $(TEST_HELPERS_OBJ) $(LIBPROXYSQLAR) + $(CXX) $< $(TEST_HELPERS_OBJ) $(IDIRS) $(LDIRS) $(OPT) \ + $(LIBPROXYSQLAR_FULL) $(STATIC_LIBS) $(MYLIBS) \ + $(ALLOW_MULTI_DEF) -o $@ +``` + +### What the unit test Makefile does +- Compiles `tap.o` directly from source (no `cpp-dotenv` dependency) +- Links test binary against `libproxysql.a` + `test_globals.o` + `test_init.o` +- This means tests call the REAL production functions, not copies + +### Reference files +- `test/tap/tests/unit/connection_pool_unit-t.cpp` — good example of a unit test +- `test/tap/tests/unit/rule_matching_unit-t.cpp` — good example with regex testing +- `test/tap/test_helpers/test_globals.cpp` — what symbols are already stubbed +- `include/ConnectionPoolDecision.h` — good example of a standalone extracted header + +## Header Conventions + +### Include guards +Use `#ifndef HEADER_NAME_H` / `#define HEADER_NAME_H` / `#endif`. + +### Avoiding circular dependencies +The ProxySQL include chain has circular dependencies: +``` +proxysql.h → proxysql_structs.h → proxysql_glovars.hpp +cpp.h → MySQL_Thread.h → MySQL_Session.h → proxysql.h (circular) +cpp.h → MySQL_HostGroups_Manager.h → Base_HostGroups_Manager.h → proxysql.h (circular) +``` + +When extracting functions from tightly-coupled classes, create a **standalone header** in `include/` with its own include guard and no ProxySQL dependencies. Example: `include/ConnectionPoolDecision.h`. + +### Naming +- Class headers: `PascalCase` matching the class name +- Standalone function headers: `PascalCase` describing the feature + +## Git Workflow + +### Branch model +- `v3.0` — main stable branch (DO NOT target PRs here for unit test work) +- `v3.0-5473` — unit test feature branch (target PRs here for test-related changes) +- Feature branches: `v3.0-` (e.g., `v3.0-5491`) + +### Creating a feature branch +```bash +git checkout -b v3.0- v3.0-5473 +``` + +### Incorporating upstream changes +Use `git rebase`, NOT `git merge`: +```bash +git fetch origin v3.0-5473 +git rebase origin/v3.0-5473 +# Resolve conflicts if any +git push --force-with-lease +``` + +### Commit messages +- Descriptive subject line (what changed and why) +- Reference issue numbers: `(#5491)` +- Separate production code changes from test changes in different commits + +## Coding Conventions + +- C++17 required +- Class names: `PascalCase` with protocol prefixes (`MySQL_`, `PgSQL_`, `ProxySQL_`) +- Member variables: `snake_case` +- Constants/macros: `UPPER_SNAKE_CASE` +- Doxygen documentation on all public functions: `@brief`, `@param`, `@return` +- `(char *)` casts on string literals are acceptable (codebase convention) + +## Dual Protocol + +ProxySQL supports both MySQL and PostgreSQL. When modifying one protocol's code, check if the same change is needed for the other: + +| MySQL | PostgreSQL | +|-------|-----------| +| `MySQL_Session` | `PgSQL_Session` | +| `MySQL_Thread` | `PgSQL_Thread` | +| `MySQL_HostGroups_Manager` | `PgSQL_HostGroups_Manager` | +| `MySQL_Monitor` | `PgSQL_Monitor` | +| `MySQL_Query_Processor` | `PgSQL_Query_Processor` | +| `MySrvConnList` | `PgSQL_SrvConnList` | + +Some components share a template base (`Base_Session`, `Base_HostGroups_Manager`, `Query_Processor`). Changes to the template cover both protocols. diff --git a/doc/agents/task-assignment-template.md b/doc/agents/task-assignment-template.md new file mode 100644 index 0000000000..f9838b26f7 --- /dev/null +++ b/doc/agents/task-assignment-template.md @@ -0,0 +1,186 @@ +# Task Assignment Template for AI Agents + +Use this template when writing GitHub issues that will be assigned to AI coding agents. The goal is to eliminate ambiguity — agents interpret every gap in the most expedient way possible. + +## Core Principle + +**Describe the HOW as precisely as the WHAT.** Intent is what you write for a human who can ask questions. Unambiguous instructions are what you write for an agent that cannot. + +--- + +## Template + +```markdown +## Task: + +### FIRST: Git workflow (do this before reading anything else) +- Create branch `` from `` +- PR target: `` +- If upstream changes needed: `git rebase`, NOT `git merge` + +### Context + + +### Deliverables +- [ ] New file: `` — +- [ ] New file: `` — +- [ ] Modified: `` — + +### Implementation details + + +### Build & verification +```bash + # Must exit 0 + # Must show all tests passing +``` + +### DO NOT +- +- +- + +### Reference files +Study these before starting: +- `` — for +- `` — for + +### Acceptance criteria +- [ ] +- [ ] +- [ ] +``` + +--- + +## Checklist for the Orchestrator + +Before publishing the issue, verify each of these: + +### 1. Did I specify WHERE? +- [ ] Exact file paths for every new file +- [ ] Exact file paths for every modified file +- [ ] Directory that files go in (not just the repo root) +- [ ] Files that should NOT be modified + +### 2. Did I specify HOW? +- [ ] Code template or skeleton (includes, boilerplate, structure) +- [ ] Build system integration (Makefile rules, CMake, etc.) +- [ ] How the new code connects to existing code (linking, imports) +- [ ] Pattern to follow (reference file) + +### 3. Did I specify the environment? +- [ ] Base branch to create from +- [ ] Target branch for PR +- [ ] Branch naming convention +- [ ] Git workflow (rebase vs merge) +- [ ] Build command to verify compilation +- [ ] Test command to verify functionality + +### 4. Did I provide reference examples? +- [ ] At least one existing file that follows the desired pattern +- [ ] Pointed to it explicitly ("follow the pattern in ``") + +### 5. Did I write a DO NOT list? +- [ ] Listed known anti-patterns for this specific task +- [ ] Explained WHY each is wrong (agents ignore rules they don't understand) + +### 6. Are acceptance criteria binary? +- [ ] Each criterion is answerable with pass/fail +- [ ] Each criterion can be verified with a specific command or file check +- [ ] No subjective criteria ("well-structured", "clean", "appropriate") + +### 7. Did I anticipate the agent's likely mistakes? +- [ ] Asked: "If I had no context beyond this issue and the repo, what would I get wrong?" +- [ ] Added explicit instructions to prevent each predicted mistake + +### 8. Did I scope the blast radius? +- [ ] Defined what's in scope +- [ ] Defined what's out of scope +- [ ] Separated production code from test code expectations +- [ ] Limited the task to one clear deliverable (or ordered multiple steps explicitly) + +### 9. Did I write ready-made prompts for the executing agent? +For each phase or step, include a fenced prompt block written in imperative voice, sequential order, with no ambiguity. The issue description is for human readers (context, rationale). The prompt block is for agent readers (do this, then this, verify that). + +### 10. Did I document design decisions with rationale? +If you made choices during planning, document them: +- [ ] Options considered +- [ ] Option chosen and why +- [ ] Constraints or precedents that drove the decision + +This prevents the executing agent from re-litigating settled questions or making a different choice that conflicts with the architecture. + +### 11. Did I show the research? +If you analyzed the codebase to write the issue, include a summary: +- [ ] File locations and line numbers of the code being modified +- [ ] Relevant functions and their current behavior +- [ ] Existing patterns in the codebase + +This serves as verification (is the analysis correct?) and context transfer (the executor doesn't need to re-explore). + +### 12. For refactoring tasks: did I specify what to replace? +When extracting logic from existing code (not just adding new code): +- [ ] Identified exact line ranges or code blocks to replace +- [ ] Specified what each block should be replaced with (function call, delegation) +- [ ] Listed every file that needs `#include` of the new header +- [ ] Verified the include dependency chain won't cause circular issues + +--- + +## Common Mistakes Agents Make (and how to prevent them) + +| Agent behavior | Root cause | Prevention | +|---|---|---| +| Uses wrong branch | No branch specified | Explicit branch instructions | +| Places files in wrong directory | No directory specified | Exact file paths | +| Reimplements code instead of linking | Doesn't know the build system | Explain linking model, provide Makefile snippet | +| Creates workarounds for missing infra | Doesn't know infra exists | Reference existing infrastructure files | +| Merges instead of rebasing | No git workflow specified | Explicit "rebase, NOT merge" | +| Modifies unrelated files | Scope too broad | "DO NOT modify files outside ``" | +| Satisfies letter but not spirit | Ambiguous requirements | Make the spirit explicit in DO NOT list | +| Doesn't verify compilation | No verification step | Explicit build command in acceptance criteria | +| Recreates existing infrastructure | Research contradicts plan | Verify infrastructure exists before writing plan | +| Uses wrong infrastructure | Multiple similar patterns exist | Name the exact files/includes to use, not just "follow existing pattern" | + +--- + +## Example: Good vs Bad Issue + +### Bad issue +> Extract the server selection algorithm from HostGroups Manager and write unit tests for it. + +### Good issue +> **Task:** Extract server selection into `select_server_from_candidates()` +> +> **Deliverables:** +> - New file: `include/ServerSelection.h` — struct + function declaration +> - New file: `lib/ServerSelection.cpp` — implementation +> - Modified: `lib/Base_HostGroups_Manager.cpp` — replace lines ~2283-2310 with call to extracted function +> - Modified: `lib/Makefile` — add `ServerSelection.oo` to `_OBJ_CXX` list +> - New file: `test/tap/tests/unit/server_selection_unit-t.cpp` — 15+ test cases +> - Modified: `test/tap/tests/unit/Makefile` — register test +> +> **Git:** Branch `v3.0-5492` from `v3.0-5473`. PR targets `v3.0-5473`. +> +> **DO NOT:** Reimplement functions in test. Place test in `test/tap/tests/`. Use `unit_test.h` (use `test_globals.h` and `test_init.h` instead). +> +> **Design decision:** Use standalone header `ServerSelection.h` (not `Base_HostGroups_Manager.h`) to avoid circular include chain. See `ConnectionPoolDecision.h` for the pattern. +> +> **Reference:** `include/ConnectionPoolDecision.h`, `test/tap/tests/unit/connection_pool_unit-t.cpp` +> +> **Verify:** `make build_lib -j4` exits 0. `./server_selection_unit-t` shows "Test took" with no failures. +> +>
Prompt for AI agents +> +> ``` +> Create `include/ServerSelection.h` with include guard. Define ServerCandidate +> struct with fields: index, weight, status, current_connections, ... +> Then create `lib/ServerSelection.cpp` implementing select_server_from_candidates(). +> Then create `test/tap/tests/unit/server_selection_unit-t.cpp` including +> tap.h, test_globals.h, test_init.h, ServerSelection.h. Call test_init_minimal() +> first. Do NOT reimplement functions. Register in unit Makefile. +> ``` +> +>
diff --git a/include/BackendSyncDecision.h b/include/BackendSyncDecision.h new file mode 100644 index 0000000000..b0be1728a0 --- /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/Base_HostGroups_Manager.h b/include/Base_HostGroups_Manager.h index 2fcbac4a77..a03a4c4e55 100644 --- a/include/Base_HostGroups_Manager.h +++ b/include/Base_HostGroups_Manager.h @@ -143,6 +143,8 @@ class MetricsCollector; typedef std::unordered_map umap_mysql_errors; +#include "ConnectionPoolDecision.h" + class MySrvConnList; class MySrvC; class MySrvList; diff --git a/include/ConnectionPoolDecision.h b/include/ConnectionPoolDecision.h new file mode 100644 index 0000000000..09837939b5 --- /dev/null +++ b/include/ConnectionPoolDecision.h @@ -0,0 +1,53 @@ +/** + * @file ConnectionPoolDecision.h + * @brief Pure decision functions for connection pool create/reuse/evict logic. + * + * Extracted from get_random_MyConn() for unit testability. These functions + * have no global state dependencies. + * + * @see Phase 3.1 (GitHub issue #5489) + */ + +#ifndef CONNECTION_POOL_DECISION_H +#define CONNECTION_POOL_DECISION_H + +/** + * @brief Encodes the outcome of a connection-pool evaluation. + */ +struct ConnectionPoolDecision { + bool create_new_connection; ///< True if a new backend connection must be created. + bool evict_connections; ///< True if free connections should be evicted. + unsigned int num_to_evict; ///< Number of free connections to evict. + bool needs_warming; ///< True if warming threshold not reached. +}; + +/** + * @brief Calculate how many free connections to evict to stay within 75% of max. + * + * Eviction is triggered when (conns_free + conns_used) >= (3 * max_connections / 4). + * At least one connection is evicted when the threshold is crossed and conns_free > 0. + * When max_connections is 0, any free connections are subject to eviction since the + * 75% threshold is 0. + * + * @return Number of free connections to evict; 0 if eviction is not needed. + */ +unsigned int calculate_eviction_count(unsigned int conns_free, unsigned int conns_used, unsigned int max_connections); + +/** + * @brief Decide whether new-connection creation should be throttled. + */ +bool should_throttle_connection_creation(unsigned int new_connections_now, unsigned int throttle_connections_per_sec); + +/** + * @brief Pure decision function for connection-pool create-vs-reuse logic. + */ +ConnectionPoolDecision evaluate_pool_state( + unsigned int conns_free, + unsigned int conns_used, + unsigned int max_connections, + unsigned int connection_quality_level, + bool connection_warming, + int free_connections_pct +); + +#endif // CONNECTION_POOL_DECISION_H diff --git a/include/HostgroupRouting.h b/include/HostgroupRouting.h new file mode 100644 index 0000000000..9ba9e386f2 --- /dev/null +++ b/include/HostgroupRouting.h @@ -0,0 +1,52 @@ +/** + * @file HostgroupRouting.h + * @brief Pure hostgroup routing decision logic for unit testability. + * + * Extracted from MySQL_Session::get_pkts_from_client() and + * PgSQL_Session::get_pkts_from_client(). The logic is identical + * for both protocols. + * + * @see Phase 3.5 (GitHub issue #5493) + */ + +#ifndef HOSTGROUP_ROUTING_H +#define HOSTGROUP_ROUTING_H + +/** + * @brief Result of a hostgroup routing decision. + */ +struct HostgroupRoutingDecision { + int target_hostgroup; ///< Resolved hostgroup to route to. + int new_locked_on_hostgroup; ///< Updated lock state (-1 = unlocked). + bool error; ///< True if an illegal HG switch was attempted. +}; + +/** + * @brief Resolve the target hostgroup given session state and QP output. + * + * Decision logic (mirrors get_pkts_from_client()): + * 1. Start with default_hostgroup as the target + * 2. If QP provides a destination (>= 0) and no transaction lock, + * use the QP destination + * 3. If transaction_persistent_hostgroup >= 0, override with transaction HG + * 4. If locking is enabled and lock_hostgroup flag is set, acquire lock + * 5. If already locked, verify target matches lock (error if mismatch) + * + * @param default_hostgroup Session's default hostgroup. + * @param qpo_destination_hostgroup Query Processor output destination (-1 = no override). + * @param transaction_persistent_hostgroup Current transaction HG (-1 = none). + * @param locked_on_hostgroup Current lock state (-1 = unlocked). + * @param lock_hostgroup_flag Whether the QP wants to acquire a lock. + * @param lock_enabled Whether set_query_lock_on_hostgroup is enabled. + * @return HostgroupRoutingDecision with resolved target and updated lock. + */ +HostgroupRoutingDecision resolve_hostgroup_routing( + int default_hostgroup, + int qpo_destination_hostgroup, + int transaction_persistent_hostgroup, + int locked_on_hostgroup, + bool lock_hostgroup_flag, + bool lock_enabled +); + +#endif // HOSTGROUP_ROUTING_H diff --git a/include/MonitorHealthDecision.h b/include/MonitorHealthDecision.h new file mode 100644 index 0000000000..edd4f7fe09 --- /dev/null +++ b/include/MonitorHealthDecision.h @@ -0,0 +1,99 @@ +/** + * @file MonitorHealthDecision.h + * @brief Pure decision functions for monitor health state transitions. + * + * Extracted from MySQL_Monitor, MySrvC, and MyHGC for unit testability. + * These functions have no global state dependencies — all inputs are + * passed as parameters. + * + * @see Phase 3.3 (GitHub issue #5491) + */ + +#ifndef MONITOR_HEALTH_DECISION_H +#define MONITOR_HEALTH_DECISION_H + +#include + +/** + * @brief Determine if a server should be shunned based on connect errors. + * + * Mirrors the logic in MySrvC::connect_error() — a server is shunned + * when errors in the current second reach min(shun_on_failures, + * connect_retries_on_failure + 1). + * + * @param errors_this_second Number of connect errors in the current second. + * @param shun_on_failures Config: mysql-shun_on_failures. + * @param connect_retries Config: mysql-connect_retries_on_failure. + * @return true if the error count meets or exceeds the shunning threshold. + */ +bool should_shun_on_connect_errors( + unsigned int errors_this_second, + int shun_on_failures, + int connect_retries +); + +/** + * @brief Determine if a shunned server can be brought back online. + * + * Mirrors the recovery logic in MyHGC's server scan loop. A server + * can be unshunned when: + * 1. Enough time has elapsed since the last detected error. + * 2. If shunned_and_kill_all_connections is true, all connections + * (both used and free) must be fully drained first. + * + * @param time_last_error Timestamp of the last detected error. + * @param current_time Current time. + * @param shun_recovery_time_sec Config: mysql-shun_recovery_time_sec. + * @param connect_timeout_max_ms Config: mysql-connect_timeout_server_max (milliseconds). + * @param kill_all_conns Whether shunned_and_kill_all_connections is set. + * @param connections_used Number of in-use connections. + * @param connections_free Number of idle connections. + * @return true if the server can be unshunned. + */ +bool can_unshun_server( + time_t time_last_error, + time_t current_time, + int shun_recovery_time_sec, + int connect_timeout_max_ms, + bool kill_all_conns, + unsigned int connections_used, + unsigned int connections_free +); + +/** + * @brief Determine if a server should be shunned for replication lag. + * + * Mirrors the replication lag check in MySQL_HostGroups_Manager. + * A server is shunned when its replication lag exceeds max_replication_lag + * for N consecutive checks (where N = monitor_replication_lag_count). + * + * @param current_lag Measured replication lag in seconds (-1 = unknown). + * @param max_replication_lag Configured max lag threshold (0 = disabled). + * @param consecutive_count Number of consecutive checks exceeding threshold. + * @param count_threshold Config: mysql-monitor_replication_lag_count. + * @return true if the server should be shunned for replication lag. + */ +bool should_shun_on_replication_lag( + int current_lag, + unsigned int max_replication_lag, + unsigned int consecutive_count, + int count_threshold +); + +/** + * @brief Determine if a server shunned for replication lag can be recovered. + * + * @param current_lag Measured replication lag in seconds. + * @param max_replication_lag Configured max lag threshold. + * @return true if the server's lag is now within acceptable bounds. + * + * @note Production code also has a special override path for + * current_lag == -2 with an override flag (see issue #959). + * That case is not covered by this simplified extraction. + */ +bool can_recover_from_replication_lag( + int current_lag, + unsigned int max_replication_lag +); + +#endif // MONITOR_HEALTH_DECISION_H diff --git a/include/MySQLErrorClassifier.h b/include/MySQLErrorClassifier.h new file mode 100644 index 0000000000..6830e1fa83 --- /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/MySQLProtocolUtils.h b/include/MySQLProtocolUtils.h new file mode 100644 index 0000000000..83c5ccf4ff --- /dev/null +++ b/include/MySQLProtocolUtils.h @@ -0,0 +1,51 @@ +/** + * @file MySQLProtocolUtils.h + * @brief Pure MySQL protocol utility functions for unit testability. + * + * Extracted from MySQLFFTO for testing. These are low-level protocol + * parsing helpers that operate on raw byte buffers. + * + * @see FFTO unit testing (GitHub issue #5499) + */ + +#ifndef MYSQL_PROTOCOL_UTILS_H +#define MYSQL_PROTOCOL_UTILS_H + +#include +#include + +/** + * @brief Read a MySQL length-encoded integer from a buffer. + * + * MySQL length-encoded integers use 1-9 bytes: + * - 0x00-0xFA: 1 byte (value itself) + * - 0xFC: 2 bytes follow (uint16) + * - 0xFD: 3 bytes follow (uint24) + * - 0xFE: 8 bytes follow (uint64) + * + * @param buf [in/out] Pointer to current position; advanced past the integer. + * @param len [in/out] Remaining buffer length; decremented. + * @return Decoded 64-bit integer value (0 on error/truncation). + */ +uint64_t mysql_read_lenenc_int(const unsigned char* &buf, size_t &len); + +/** + * @brief Build a MySQL protocol packet header + payload. + * + * Constructs a complete MySQL wire-format packet: 3-byte length + + * 1-byte sequence number + payload. + * + * @param payload Payload data. + * @param payload_len Length of payload. + * @param seq_id Packet sequence number. + * @param out_buf Output buffer (must be at least payload_len + 4). + * @return Total packet size (payload_len + 4). + */ +size_t mysql_build_packet( + const unsigned char *payload, + uint32_t payload_len, + uint8_t seq_id, + unsigned char *out_buf +); + +#endif // MYSQL_PROTOCOL_UTILS_H diff --git a/include/PgSQLCommandComplete.h b/include/PgSQLCommandComplete.h new file mode 100644 index 0000000000..4dfe6b8b16 --- /dev/null +++ b/include/PgSQLCommandComplete.h @@ -0,0 +1,48 @@ +/** + * @file PgSQLCommandComplete.h + * @brief Pure parser for PostgreSQL CommandComplete message tags. + * + * Extracted from PgSQLFFTO for unit testability. Parses command tags + * like "INSERT 0 10", "SELECT 5", "UPDATE 3" to extract row counts. + * + * @see FFTO unit testing (GitHub issue #5499) + */ + +#ifndef PGSQL_COMMAND_COMPLETE_H +#define PGSQL_COMMAND_COMPLETE_H + +#include +#include + +/** + * @brief Result of parsing a PostgreSQL CommandComplete tag. + */ +struct PgSQLCommandResult { + uint64_t rows; ///< Number of rows affected/returned. + bool is_select; ///< True if the command is a result-set operation (SELECT, FETCH, MOVE). +}; + +/** + * @brief Parse a PostgreSQL CommandComplete message tag to extract row count. + * + * PostgreSQL encodes row counts in the tag string: + * - "INSERT 0 10" → rows=10, is_select=false + * - "SELECT 5" → rows=5, is_select=true + * - "UPDATE 3" → rows=3, is_select=false + * - "FETCH 10" → rows=10, is_select=true + * - "MOVE 7" → rows=7, is_select=true + * - "DELETE 0" → rows=0, is_select=false + * - "COPY 100" → rows=100, is_select=false + * - "MERGE 5" → rows=5, is_select=false + * - "CREATE TABLE" → rows=0, is_select=false (no row count) + * + * @param payload Pointer to the CommandComplete tag string. + * @param len Length of the payload. + * @return PgSQLCommandResult with parsed rows and is_select flag. + */ +PgSQLCommandResult parse_pgsql_command_complete( + const unsigned char *payload, + size_t len +); + +#endif // PGSQL_COMMAND_COMPLETE_H diff --git a/include/PgSQLErrorClassifier.h b/include/PgSQLErrorClassifier.h new file mode 100644 index 0000000000..471ddce405 --- /dev/null +++ b/include/PgSQLErrorClassifier.h @@ -0,0 +1,58 @@ +/** + * @file PgSQLErrorClassifier.h + * @brief Pure PgSQL error classification for retry decisions. + * + * Classifies PostgreSQL SQLSTATE error codes by class to determine + * if a query error is retryable or fatal. + * + * @see Phase 3.10 (GitHub issue #5498) + */ + +#ifndef PGSQL_ERROR_CLASSIFIER_H +#define PGSQL_ERROR_CLASSIFIER_H + +/** + * @brief Action to take after a PgSQL backend error. + */ +enum PgSQLErrorAction { + PGSQL_ERROR_REPORT_TO_CLIENT, ///< Send error to client, no retry. + PGSQL_ERROR_RETRY, ///< Retryable error (connection/server). + PGSQL_ERROR_FATAL ///< Fatal server state (shutdown/crash). +}; + +/** + * @brief Classify a PgSQL SQLSTATE error code for retry eligibility. + * + * SQLSTATE classes (first 2 chars): + * - "08" (connection exception): retryable + * - "40" (transaction rollback, including serialization failure): retryable + * - "53" (insufficient resources, e.g. too_many_connections): retryable + * - "57" (operator intervention, e.g. admin_shutdown, crash_shutdown): fatal + * Exception: "57014" (query_canceled) is non-fatal + * - "58" (system error, e.g. I/O error): fatal + * - All others (syntax, constraint, etc.): report to client + * + * @param sqlstate 5-character SQLSTATE string (e.g., "08006", "42P01"). + * @return PgSQLErrorAction indicating what to do. + */ +PgSQLErrorAction classify_pgsql_error(const char *sqlstate); + +/** + * @brief Check if a PgSQL error is retryable given session conditions. + * + * Even if the error class is retryable, retry is blocked when: + * - In an active transaction (PgSQL transactions are atomic) + * - No retries remaining + * + * @param action Result of classify_pgsql_error(). + * @param retries_remaining Number of retries left. + * @param in_transaction Whether a transaction is in progress. + * @return true if the error can be retried. + */ +bool pgsql_can_retry_error( + PgSQLErrorAction action, + int retries_remaining, + bool in_transaction +); + +#endif // PGSQL_ERROR_CLASSIFIER_H diff --git a/include/PgSQLMonitorDecision.h b/include/PgSQLMonitorDecision.h new file mode 100644 index 0000000000..ef309cb6c0 --- /dev/null +++ b/include/PgSQLMonitorDecision.h @@ -0,0 +1,44 @@ +/** + * @file PgSQLMonitorDecision.h + * @brief Pure decision functions for PgSQL monitor health state. + * + * PgSQL monitor is simpler than MySQL — it uses ping failure + * threshold directly with shun_and_killall() (always aggressive). + * Unshunning follows the same time-based recovery as MySQL + * (already covered by MonitorHealthDecision.h can_unshun_server). + * + * @see Phase 3.9 (GitHub issue #5497) + */ + +#ifndef PGSQL_MONITOR_DECISION_H +#define PGSQL_MONITOR_DECISION_H + +/** + * @brief Determine if a PgSQL server should be shunned based on ping failures. + * + * PgSQL monitor shuns servers when they miss N consecutive heartbeats. + * Unlike MySQL, PgSQL always uses aggressive shunning (kill all connections). + * + * @param missed_heartbeats Number of consecutive missed pings. + * @param max_failures_threshold Config: pgsql-monitor_ping_max_failures. + * @return true if the server should be shunned. + */ +bool pgsql_should_shun_on_ping_failure( + unsigned int missed_heartbeats, + unsigned int max_failures_threshold +); + +/** + * @brief Determine if a PgSQL server's read-only status indicates it should + * be taken offline for a writer hostgroup. + * + * @param is_read_only Whether the server reports read_only=true. + * @param is_writer_hg Whether this is a writer hostgroup. + * @return true if the server should be set to OFFLINE_SOFT. + */ +bool pgsql_should_offline_for_readonly( + bool is_read_only, + bool is_writer_hg +); + +#endif // PGSQL_MONITOR_DECISION_H diff --git a/include/ServerSelection.h b/include/ServerSelection.h new file mode 100644 index 0000000000..58a8352461 --- /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/include/TransactionState.h b/include/TransactionState.h new file mode 100644 index 0000000000..00eed2735e --- /dev/null +++ b/include/TransactionState.h @@ -0,0 +1,49 @@ +/** + * @file TransactionState.h + * @brief Pure transaction state tracking logic for unit testability. + * + * Extracted from MySQL_Session/PgSQL_Session transaction persistence + * logic. The decision is identical for both protocols. + * + * @see Phase 3.8 (GitHub issue #5496) + */ + +#ifndef TRANSACTION_STATE_H +#define TRANSACTION_STATE_H + +/** + * @brief Update transaction_persistent_hostgroup based on backend state. + * + * Mirrors the transaction persistence logic in MySQL_Session and + * PgSQL_Session (near handler_rc0_Process_Resultset): + * - When a transaction starts on a backend, lock to the current HG + * - When a transaction ends, unlock (-1) + * + * @param transaction_persistent Whether transaction persistence is enabled. + * @param transaction_persistent_hostgroup Current persistent HG (-1 = none). + * @param current_hostgroup HG where the query executed. + * @param backend_in_transaction Whether the backend has an active transaction. + * @return Updated transaction_persistent_hostgroup value. + */ +int update_transaction_persistent_hostgroup( + bool transaction_persistent, + int transaction_persistent_hostgroup, + int current_hostgroup, + bool backend_in_transaction +); + +/** + * @brief Check if a transaction has exceeded the maximum allowed time. + * + * @param transaction_started_at Timestamp when transaction started, in microseconds (0 = none). + * @param current_time Current timestamp, in microseconds. + * @param max_transaction_time_ms Maximum transaction time in milliseconds (0 or negative = no limit). + * @return true if the transaction has exceeded the time limit. + */ +bool is_transaction_timed_out( + unsigned long long transaction_started_at, + unsigned long long current_time, + int max_transaction_time_ms +); + +#endif // TRANSACTION_STATE_H diff --git a/include/query_processor.h b/include/query_processor.h index a4a61db038..71b3ee8f4a 100644 --- a/include/query_processor.h +++ b/include/query_processor.h @@ -240,6 +240,41 @@ class Query_Processor_Output { */ void __reset_rules(std::vector* qrs); +/** + * @brief Evaluates whether a query rule matches the supplied query and session attributes. + * @details The predicate only depends on the provided inputs. If the rule references regex + * patterns and does not already hold compiled regex engines, temporary regex objects are + * compiled on demand using the requested regex engine. + * + * @param rule Rule to evaluate. + * @param current_flagIN Current query flag. + * @param username Session username. + * @param schemaname Session schema name. + * @param client_addr Client address. + * @param proxy_addr Proxy listener address. + * @param proxy_port Proxy listener port. + * @param digest Parsed query digest. + * @param digest_text Parsed digest text. + * @param query_text Original query text. + * @param rewritten_query Rewritten query text produced by a previous rule, if any. + * @param query_processor_regex Regex engine selector: PCRE (1) or RE2 (2). + * @return true when all rule criteria match, otherwise false. + */ +bool rule_matches_query( + const QP_rule_t* rule, + int current_flagIN, + const char* username, + const char* schemaname, + const char* client_addr, + const char* proxy_addr, + int proxy_port, + uint64_t digest, + const char* digest_text, + const char* query_text, + const char* rewritten_query, + int query_processor_regex +); + /** * @brief Helper type for performing the 'mysql_rules_fast_routing' hashmaps creation. * @details Holds all the info 'Query_Processor' requires about the hashmap. diff --git a/lib/BackendSyncDecision.cpp b/lib/BackendSyncDecision.cpp new file mode 100644 index 0000000000..b9c64e402f --- /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/HostgroupRouting.cpp b/lib/HostgroupRouting.cpp new file mode 100644 index 0000000000..0c2d0d8d57 --- /dev/null +++ b/lib/HostgroupRouting.cpp @@ -0,0 +1,60 @@ +/** + * @file HostgroupRouting.cpp + * @brief Implementation of pure hostgroup routing decision logic. + * + * Mirrors the routing block in get_pkts_from_client() (MySQL_Session.cpp + * ~lines 5340-5377 and PgSQL_Session.cpp ~lines 2154-2189). + * + * @see HostgroupRouting.h + * @see Phase 3.5 (GitHub issue #5493) + */ + +#include "HostgroupRouting.h" + +HostgroupRoutingDecision resolve_hostgroup_routing( + int default_hostgroup, + int qpo_destination_hostgroup, + int transaction_persistent_hostgroup, + int locked_on_hostgroup, + bool lock_hostgroup_flag, + bool lock_enabled) +{ + HostgroupRoutingDecision d; + d.error = false; + d.new_locked_on_hostgroup = locked_on_hostgroup; + + // Start with default hostgroup + int current_hostgroup = default_hostgroup; + + // If QP provides a valid destination and no transaction lock, use it + if (qpo_destination_hostgroup >= 0 + && transaction_persistent_hostgroup == -1) { + current_hostgroup = qpo_destination_hostgroup; + } + + // Transaction affinity overrides everything + if (transaction_persistent_hostgroup >= 0) { + current_hostgroup = transaction_persistent_hostgroup; + } + + // Hostgroup locking logic (algorithm introduced in 2.0.6) + if (lock_enabled) { + if (locked_on_hostgroup < 0) { + // Not yet locked + if (lock_hostgroup_flag) { + // Acquire lock on the current (already resolved) hostgroup + d.new_locked_on_hostgroup = current_hostgroup; + } + } + if (d.new_locked_on_hostgroup >= 0) { + // Already locked (or just acquired) — enforce + if (current_hostgroup != d.new_locked_on_hostgroup) { + d.error = true; + } + current_hostgroup = d.new_locked_on_hostgroup; + } + } + + d.target_hostgroup = current_hostgroup; + return d; +} diff --git a/lib/Makefile b/lib/Makefile index 932cc386ec..cc7f3bda74 100644 --- a/lib/Makefile +++ b/lib/Makefile @@ -105,6 +105,16 @@ _OBJ_CXX := ProxySQL_GloVars.oo network.oo debug.oo configfile.oo Query_Cache.oo PgSQL_Variables_Validator.oo PgSQL_ExplicitTxnStateMgr.oo \ PgSQL_PreparedStatement.oo PgSQL_Extended_Query_Message.oo \ pgsql_tokenizer.oo \ + MonitorHealthDecision.oo \ + ServerSelection.oo \ + TransactionState.oo \ + HostgroupRouting.oo \ + PgSQLCommandComplete.oo \ + MySQLProtocolUtils.oo \ + PgSQLErrorClassifier.oo \ + PgSQLMonitorDecision.oo \ + MySQLErrorClassifier.oo \ + BackendSyncDecision.oo \ proxy_sqlite3_symbols.oo # TSDB object files diff --git a/lib/MonitorHealthDecision.cpp b/lib/MonitorHealthDecision.cpp new file mode 100644 index 0000000000..5b37cb03be --- /dev/null +++ b/lib/MonitorHealthDecision.cpp @@ -0,0 +1,105 @@ +/** + * @file MonitorHealthDecision.cpp + * @brief Implementation of pure monitor health decision functions. + * + * These functions extract the decision logic from MySrvC::connect_error(), + * MyHGC's unshun recovery loop, and MySQL_HostGroups_Manager's replication + * lag check. They are intentionally free of global state and I/O. + * + * @see MonitorHealthDecision.h + * @see Phase 3.3 (GitHub issue #5491) + */ + +#include "MonitorHealthDecision.h" + +bool should_shun_on_connect_errors( + unsigned int errors_this_second, + int shun_on_failures, + int connect_retries) +{ + // Mirror MySrvC::connect_error() threshold logic: + // max_failures = min(shun_on_failures, connect_retries + 1) + int connect_retries_plus_1 = connect_retries + 1; + int max_failures = (shun_on_failures > connect_retries_plus_1) + ? connect_retries_plus_1 + : shun_on_failures; + + return (errors_this_second >= (unsigned int)max_failures); +} + +bool can_unshun_server( + time_t time_last_error, + time_t current_time, + int shun_recovery_time_sec, + int connect_timeout_max_ms, + bool kill_all_conns, + unsigned int connections_used, + unsigned int connections_free) +{ + if (shun_recovery_time_sec == 0) { + return false; // recovery disabled + } + + // Mirror MyHGC recovery: compute max_wait_sec with timeout cap + int max_wait_sec; + if ((int64_t)shun_recovery_time_sec * 1000 >= connect_timeout_max_ms) { + max_wait_sec = connect_timeout_max_ms / 1000 - 1; + } else { + max_wait_sec = shun_recovery_time_sec; + } + if (max_wait_sec < 1) { + max_wait_sec = 1; + } + + // Time check + if (current_time <= time_last_error) { + return false; + } + if ((current_time - time_last_error) <= max_wait_sec) { + return false; + } + + // Connection drain check for kill-all mode + if (kill_all_conns) { + if (connections_used != 0 || connections_free != 0) { + return false; // connections still draining + } + } + + return true; +} + +bool should_shun_on_replication_lag( + int current_lag, + unsigned int max_replication_lag, + unsigned int consecutive_count, + int count_threshold) +{ + // Mirror MySQL_HostGroups_Manager replication lag logic + if (current_lag < 0) { + return false; // lag unknown, don't shun + } + if (max_replication_lag == 0) { + return false; // lag check disabled + } + if (current_lag <= (int)max_replication_lag) { + return false; // within threshold + } + + // Lag exceeds threshold — check consecutive count + // The caller is expected to have incremented consecutive_count + // before calling this function + return (consecutive_count >= (unsigned int)count_threshold); +} + +bool can_recover_from_replication_lag( + int current_lag, + unsigned int max_replication_lag) +{ + // Mirror MySQL_HostGroups_Manager unshun for replication lag: + // recover when lag drops to <= max_replication_lag + if (current_lag < 0) { + return false; // unknown lag, don't recover + } + return (current_lag <= (int)max_replication_lag); +} diff --git a/lib/MySQLErrorClassifier.cpp b/lib/MySQLErrorClassifier.cpp new file mode 100644 index 0000000000..69ee010277 --- /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/MySQLProtocolUtils.cpp b/lib/MySQLProtocolUtils.cpp new file mode 100644 index 0000000000..562ac16191 --- /dev/null +++ b/lib/MySQLProtocolUtils.cpp @@ -0,0 +1,59 @@ +/** + * @file MySQLProtocolUtils.cpp + * @brief Implementation of MySQL protocol utility functions. + * + * @see MySQLProtocolUtils.h + */ + +#include "MySQLProtocolUtils.h" +#include + +uint64_t mysql_read_lenenc_int(const unsigned char* &buf, size_t &len) { + if (len == 0) return 0; + uint8_t first_byte = buf[0]; + buf++; len--; + if (first_byte < 0xFB) return first_byte; + if (first_byte == 0xFC) { + if (len < 2) return 0; + uint64_t value = buf[0] | (static_cast(buf[1]) << 8); + buf += 2; len -= 2; + return value; + } + if (first_byte == 0xFD) { + if (len < 3) return 0; + uint64_t value = buf[0] | (static_cast(buf[1]) << 8) + | (static_cast(buf[2]) << 16); + buf += 3; len -= 3; + return value; + } + if (first_byte == 0xFE) { + if (len < 8) return 0; + uint64_t value = buf[0] | (static_cast(buf[1]) << 8) + | (static_cast(buf[2]) << 16) + | (static_cast(buf[3]) << 24) + | (static_cast(buf[4]) << 32) + | (static_cast(buf[5]) << 40) + | (static_cast(buf[6]) << 48) + | (static_cast(buf[7]) << 56); + buf += 8; len -= 8; + return value; + } + return 0; +} + +size_t mysql_build_packet( + const unsigned char *payload, + uint32_t payload_len, + uint8_t seq_id, + unsigned char *out_buf) +{ + // 3-byte length (little-endian) + 1-byte sequence + out_buf[0] = payload_len & 0xFF; + out_buf[1] = (payload_len >> 8) & 0xFF; + out_buf[2] = (payload_len >> 16) & 0xFF; + out_buf[3] = seq_id; + if (payload && payload_len > 0) { + memcpy(out_buf + 4, payload, payload_len); + } + return payload_len + 4; +} diff --git a/lib/MySrvConnList.cpp b/lib/MySrvConnList.cpp index e2175c830b..bdf96f6a9f 100644 --- a/lib/MySrvConnList.cpp +++ b/lib/MySrvConnList.cpp @@ -1,4 +1,5 @@ #include "MySQL_HostGroups_Manager.h" +#include "ConnectionPoolDecision.h" #include "MySQL_Data_Stream.h" @@ -47,6 +48,65 @@ void MySrvConnList::drop_all_connections() { } } +unsigned int calculate_eviction_count(unsigned int conns_free, unsigned int conns_used, unsigned int max_connections) { + if (conns_free < 1) return 0; + unsigned int pct_max_connections = (3 * max_connections) / 4; + unsigned int total = conns_free + conns_used; + if (pct_max_connections <= total) { + unsigned int count = total - pct_max_connections; + return (count == 0) ? 1 : count; + } + return 0; +} + +bool should_throttle_connection_creation(unsigned int new_connections_now, unsigned int throttle_connections_per_sec) { + return new_connections_now > throttle_connections_per_sec; +} + +ConnectionPoolDecision evaluate_pool_state( + unsigned int conns_free, + unsigned int conns_used, + unsigned int max_connections, + unsigned int connection_quality_level, + bool connection_warming, + int free_connections_pct +) { + ConnectionPoolDecision decision = { false, false, 0, false }; + + // Check connection warming threshold first + if (connection_warming) { + unsigned int total = conns_free + conns_used; + unsigned int expected_warm = (unsigned int)(free_connections_pct) * max_connections / 100; + if (total < expected_warm) { + decision.needs_warming = true; + decision.create_new_connection = true; + return decision; + } + } + + switch (connection_quality_level) { + case 0: // no good match — must create new, possibly after evicting stale free connections + decision.create_new_connection = true; + decision.num_to_evict = calculate_eviction_count(conns_free, conns_used, max_connections); + decision.evict_connections = (decision.num_to_evict > 0); + break; + case 1: // tracked options OK but CHANGE_USER / session reset required — may create new + if ((conns_used > conns_free) && (max_connections > (conns_free / 2 + conns_used / 2))) { + decision.create_new_connection = true; + } + break; + case 2: // partial match — reuse + case 3: // perfect match — reuse + decision.create_new_connection = false; + break; + default: + decision.create_new_connection = true; + break; + } + + return decision; +} + void MySrvConnList::get_random_MyConn_inner_search(unsigned int start, unsigned int end, unsigned int& conn_found_idx, unsigned int& connection_quality_level, unsigned int& number_of_matching_session_variables, const MySQL_Connection * client_conn) { char *schema = client_conn->userinfo->schemaname; MySQL_Connection * conn=NULL; @@ -115,10 +175,9 @@ void MySrvConnList::get_random_MyConn_inner_search(unsigned int start, unsigned MySQL_Connection * MySrvConnList::get_random_MyConn(MySQL_Session *sess, bool ff) { MySQL_Connection * conn=NULL; unsigned int i; - unsigned int conn_found_idx; + unsigned int conn_found_idx = 0; unsigned int l=conns_length(); unsigned int connection_quality_level = 0; - bool needs_warming = false; // connection_quality_level: // 0 : not found any good connection, tracked options are not OK // 1 : tracked options are OK , but CHANGE USER is required @@ -132,9 +191,12 @@ MySQL_Connection * MySrvConnList::get_random_MyConn(MySQL_Session *sess, bool ff connection_warming = mysrvc->myhgc->attributes.connection_warming; free_connections_pct = mysrvc->myhgc->attributes.free_connections_pct; } + unsigned int conns_free = mysrvc->ConnectionsFree->conns_length(); + unsigned int conns_used = mysrvc->ConnectionsUsed->conns_length(); + bool needs_warming = false; if (connection_warming == true) { - unsigned int total_connections = mysrvc->ConnectionsFree->conns_length()+mysrvc->ConnectionsUsed->conns_length(); - unsigned int expected_warm_connections = free_connections_pct*mysrvc->max_connections/100; + unsigned int total_connections = conns_free + conns_used; + unsigned int expected_warm_connections = (unsigned int)free_connections_pct * mysrvc->max_connections / 100; if (total_connections < expected_warm_connections) { needs_warming = true; } @@ -151,6 +213,11 @@ MySQL_Connection * MySrvConnList::get_random_MyConn(MySQL_Session *sess, bool ff if (connection_quality_level !=3 ) { // we didn't find the perfect connection get_random_MyConn_inner_search(0, i, conn_found_idx, connection_quality_level, number_of_matching_session_variables, client_conn); } + // Evaluate pool state to determine create-vs-reuse and eviction (warming already handled above) + ConnectionPoolDecision decision = evaluate_pool_state( + conns_free, conns_used, (unsigned int)mysrvc->max_connections, + connection_quality_level, false, 0 + ); // connection_quality_level: // 1 : tracked options are OK , but CHANGE USER is required // 2 : tracked options are OK , CHANGE USER is not required, but some SET statement or INIT_DB needs to be executed @@ -159,25 +226,14 @@ MySQL_Connection * MySrvConnList::get_random_MyConn(MySQL_Session *sess, bool ff // we must check if connections need to be freed before // creating a new connection { - unsigned int conns_free = mysrvc->ConnectionsFree->conns_length(); - unsigned int conns_used = mysrvc->ConnectionsUsed->conns_length(); - unsigned int pct_max_connections = (3 * mysrvc->max_connections) / 4; - unsigned int connections_to_free = 0; - - if (conns_free >= 1) { - // connection cleanup is triggered when connections exceed 3/4 of the total - // allowed max connections, this cleanup ensures that at least *one connection* - // will be freed. - if (pct_max_connections <= (conns_free + conns_used)) { - connections_to_free = (conns_free + conns_used) - pct_max_connections; - if (connections_to_free == 0) connections_to_free = 1; - } - - while (conns_free && connections_to_free) { - MySQL_Connection* conn = mysrvc->ConnectionsFree->remove(0); - delete conn; + if (decision.evict_connections) { + unsigned int cur_free = conns_free; + unsigned int connections_to_free = decision.num_to_evict; + while (cur_free && connections_to_free) { + MySQL_Connection* c = mysrvc->ConnectionsFree->remove(0); + delete c; - conns_free = mysrvc->ConnectionsFree->conns_length(); + cur_free = mysrvc->ConnectionsFree->conns_length(); connections_to_free -= 1; } } @@ -194,9 +250,7 @@ MySQL_Connection * MySrvConnList::get_random_MyConn(MySQL_Session *sess, bool ff case 1: //tracked options are OK , but CHANGE USER is required // we may consider creating a new connection { - unsigned int conns_free = mysrvc->ConnectionsFree->conns_length(); - unsigned int conns_used = mysrvc->ConnectionsUsed->conns_length(); - if ((conns_used > conns_free) && (mysrvc->max_connections > (conns_free/2 + conns_used/2)) ) { + if (decision.create_new_connection) { conn = new MySQL_Connection(); conn->parent=mysrvc; // if attributes.multiplex == true , STATUS_MYSQL_CONNECTION_NO_MULTIPLEX_HG is set to false. And vice-versa @@ -238,7 +292,7 @@ MySQL_Connection * MySrvConnList::get_random_MyConn(MySQL_Session *sess, bool ff // mysql_hostgroup_attributes takes priority throttle_connections_per_sec_to_hostgroup = _myhgc->attributes.throttle_connections_per_sec; } - if (_myhgc->new_connections_now > (unsigned int) throttle_connections_per_sec_to_hostgroup) { + if (should_throttle_connection_creation(_myhgc->new_connections_now, throttle_connections_per_sec_to_hostgroup)) { __sync_fetch_and_add(&MyHGM->status.server_connections_delayed, 1); return NULL; } else { diff --git a/lib/PgSQLCommandComplete.cpp b/lib/PgSQLCommandComplete.cpp new file mode 100644 index 0000000000..308a751ce5 --- /dev/null +++ b/lib/PgSQLCommandComplete.cpp @@ -0,0 +1,57 @@ +/** + * @file PgSQLCommandComplete.cpp + * @brief Implementation of PostgreSQL CommandComplete tag parser. + * + * Logic mirrors extract_pg_rows_affected() in PgSQLFFTO.cpp. + * + * @see PgSQLCommandComplete.h + * @see FFTO unit testing (GitHub issue #5499) + */ + +#include "PgSQLCommandComplete.h" +#include +#include +#include + +PgSQLCommandResult parse_pgsql_command_complete( + const unsigned char *payload, + size_t len) +{ + PgSQLCommandResult result = {0, false}; + + if (payload == nullptr || len == 0) return result; + + // Trim whitespace and null terminators + size_t begin = 0; + while (begin < len && std::isspace(payload[begin])) begin++; + while (len > begin && (payload[len - 1] == '\0' || std::isspace(payload[len - 1]))) len--; + if (begin >= len) return result; + + std::string tag(reinterpret_cast(payload + begin), len - begin); + + // Extract command type (first token) + size_t first_space = tag.find(' '); + if (first_space == std::string::npos) return result; + + std::string command = tag.substr(0, first_space); + + if (command == "SELECT" || command == "FETCH" || command == "MOVE") { + result.is_select = true; + } else if (command != "INSERT" && command != "UPDATE" && + command != "DELETE" && command != "COPY" && + command != "MERGE") { + return result; // Unknown command, no row count + } + + // Extract row count (last token) + size_t last_space = tag.rfind(' '); + if (last_space == std::string::npos || last_space + 1 >= tag.size()) return result; + + const char *rows_str = tag.c_str() + last_space + 1; + char *endptr = nullptr; + unsigned long long rows = std::strtoull(rows_str, &endptr, 10); + if (endptr == rows_str || *endptr != '\0') return result; + + result.rows = rows; + return result; +} diff --git a/lib/PgSQLErrorClassifier.cpp b/lib/PgSQLErrorClassifier.cpp new file mode 100644 index 0000000000..cd48afcce0 --- /dev/null +++ b/lib/PgSQLErrorClassifier.cpp @@ -0,0 +1,60 @@ +/** + * @file PgSQLErrorClassifier.cpp + * @brief Implementation of PgSQL error classification. + * + * @see PgSQLErrorClassifier.h + * @see Phase 3.10 (GitHub issue #5498) + */ + +#include "PgSQLErrorClassifier.h" +#include + +PgSQLErrorAction classify_pgsql_error(const char *sqlstate) { + if (sqlstate == nullptr || strlen(sqlstate) < 2) { + return PGSQL_ERROR_REPORT_TO_CLIENT; + } + + // Classify by SQLSTATE class (first 2 characters) + char cls[3] = {sqlstate[0], sqlstate[1], '\0'}; + + // Connection exceptions — retryable + if (strcmp(cls, "08") == 0) return PGSQL_ERROR_RETRY; + + // Transaction rollback (serialization failure, deadlock) — retryable + if (strcmp(cls, "40") == 0) return PGSQL_ERROR_RETRY; + + // Insufficient resources (too many connections) — retryable + if (strcmp(cls, "53") == 0) return PGSQL_ERROR_RETRY; + + // Operator intervention — mostly fatal, except query_canceled + if (strcmp(cls, "57") == 0) { + // 57014 = query_canceled — not fatal, report to client + if (strlen(sqlstate) >= 5 && strncmp(sqlstate, "57014", 5) == 0) { + return PGSQL_ERROR_REPORT_TO_CLIENT; + } + return PGSQL_ERROR_FATAL; // admin_shutdown, crash_shutdown, etc. + } + + // System error (I/O error) — fatal + if (strcmp(cls, "58") == 0) return PGSQL_ERROR_FATAL; + + // Everything else (syntax, constraints, data, etc.) — report to client + return PGSQL_ERROR_REPORT_TO_CLIENT; +} + +bool pgsql_can_retry_error( + PgSQLErrorAction action, + int retries_remaining, + bool in_transaction) +{ + if (action != PGSQL_ERROR_RETRY) { + return false; + } + if (retries_remaining <= 0) { + return false; + } + if (in_transaction) { + return false; // PgSQL transactions are atomic, can't retry mid-txn + } + return true; +} diff --git a/lib/PgSQLMonitorDecision.cpp b/lib/PgSQLMonitorDecision.cpp new file mode 100644 index 0000000000..3effd2419d --- /dev/null +++ b/lib/PgSQLMonitorDecision.cpp @@ -0,0 +1,27 @@ +/** + * @file PgSQLMonitorDecision.cpp + * @brief Implementation of PgSQL monitor health decisions. + * + * @see PgSQLMonitorDecision.h + * @see Phase 3.9 (GitHub issue #5497) + */ + +#include "PgSQLMonitorDecision.h" + +bool pgsql_should_shun_on_ping_failure( + unsigned int missed_heartbeats, + unsigned int max_failures_threshold) +{ + if (max_failures_threshold == 0) { + return false; // shunning disabled + } + return (missed_heartbeats >= max_failures_threshold); +} + +bool pgsql_should_offline_for_readonly( + bool is_read_only, + bool is_writer_hg) +{ + // A read-only server in a writer hostgroup should go OFFLINE_SOFT + return (is_read_only && is_writer_hg); +} diff --git a/lib/PgSQL_HostGroups_Manager.cpp b/lib/PgSQL_HostGroups_Manager.cpp index 8dfc89c8e5..2c5d2ad13f 100644 --- a/lib/PgSQL_HostGroups_Manager.cpp +++ b/lib/PgSQL_HostGroups_Manager.cpp @@ -3,6 +3,7 @@ using json = nlohmann::json; #define PROXYJSON #include "PgSQL_HostGroups_Manager.h" +#include "ConnectionPoolDecision.h" #include "proxysql.h" #include "cpp.h" @@ -2330,7 +2331,7 @@ void PgSQL_SrvConnList::get_random_MyConn_inner_search(unsigned int start, unsig PgSQL_Connection * PgSQL_SrvConnList::get_random_MyConn(PgSQL_Session *sess, bool ff) { PgSQL_Connection * conn=NULL; unsigned int i; - unsigned int conn_found_idx; + unsigned int conn_found_idx = 0; unsigned int l=conns_length(); unsigned int connection_quality_level = 0; bool needs_warming = false; @@ -2347,19 +2348,16 @@ PgSQL_Connection * PgSQL_SrvConnList::get_random_MyConn(PgSQL_Session *sess, boo connection_warming = mysrvc->myhgc->attributes.connection_warming; free_connections_pct = mysrvc->myhgc->attributes.free_connections_pct; } + unsigned int conns_free = mysrvc->ConnectionsFree->conns_length(); + unsigned int conns_used = mysrvc->ConnectionsUsed->conns_length(); if (connection_warming == true) { - unsigned int total_connections = mysrvc->ConnectionsFree->conns_length()+mysrvc->ConnectionsUsed->conns_length(); - unsigned int expected_warm_connections = free_connections_pct*mysrvc->max_connections/100; + unsigned int total_connections = conns_free + conns_used; + unsigned int expected_warm_connections = (unsigned int)free_connections_pct * mysrvc->max_connections / 100; if (total_connections < expected_warm_connections) { needs_warming = true; } } if (l && ff==false && needs_warming==false) { - //if (l>32768) { - // i=rand()%l; - //} else { - // i=fastrand()%l; - //} i = rand_fast() % l; if (sess && sess->client_myds && sess->client_myds->myconn && sess->client_myds->myconn->userinfo) { PgSQL_Connection * client_conn = sess->client_myds->myconn; @@ -2367,6 +2365,11 @@ PgSQL_Connection * PgSQL_SrvConnList::get_random_MyConn(PgSQL_Session *sess, boo if (connection_quality_level !=3 ) { // we didn't find the perfect connection get_random_MyConn_inner_search(0, i, conn_found_idx, connection_quality_level, number_of_matching_session_variables, client_conn); } + // Evaluate pool state to determine create-vs-reuse and eviction (warming already handled above) + ConnectionPoolDecision decision = evaluate_pool_state( + conns_free, conns_used, (unsigned int)mysrvc->max_connections, + connection_quality_level, false, 0 + ); // connection_quality_level: // 1 : tracked options are OK , but RESETTING SESSION is required // 2 : tracked options are OK , RESETTING SESSION is not required, but some SET statement or INIT_DB needs to be executed @@ -2375,25 +2378,14 @@ PgSQL_Connection * PgSQL_SrvConnList::get_random_MyConn(PgSQL_Session *sess, boo // we must check if connections need to be freed before // creating a new connection { - unsigned int conns_free = mysrvc->ConnectionsFree->conns_length(); - unsigned int conns_used = mysrvc->ConnectionsUsed->conns_length(); - unsigned int pct_max_connections = (3 * mysrvc->max_connections) / 4; - unsigned int connections_to_free = 0; - - if (conns_free >= 1) { - // connection cleanup is triggered when connections exceed 3/4 of the total - // allowed max connections, this cleanup ensures that at least *one connection* - // will be freed. - if (pct_max_connections <= (conns_free + conns_used)) { - connections_to_free = (conns_free + conns_used) - pct_max_connections; - if (connections_to_free == 0) connections_to_free = 1; - } - - while (conns_free && connections_to_free) { - PgSQL_Connection* conn = mysrvc->ConnectionsFree->remove(0); - delete conn; - - conns_free = mysrvc->ConnectionsFree->conns_length(); + if (decision.evict_connections) { + unsigned int cur_free = conns_free; + unsigned int connections_to_free = decision.num_to_evict; + while (cur_free && connections_to_free) { + PgSQL_Connection* c = mysrvc->ConnectionsFree->remove(0); + delete c; + + cur_free = mysrvc->ConnectionsFree->conns_length(); connections_to_free -= 1; } } @@ -2410,9 +2402,7 @@ PgSQL_Connection * PgSQL_SrvConnList::get_random_MyConn(PgSQL_Session *sess, boo case 1: //tracked options are OK , but RESETTING SESSION is required // we may consider creating a new connection { - unsigned int conns_free = mysrvc->ConnectionsFree->conns_length(); - unsigned int conns_used = mysrvc->ConnectionsUsed->conns_length(); - if ((conns_used > conns_free) && (mysrvc->max_connections > (conns_free/2 + conns_used/2)) ) { + if (decision.create_new_connection) { conn = new PgSQL_Connection(false); conn->parent=mysrvc; // if attributes.multiplex == true , STATUS_PGSQL_CONNECTION_NO_MULTIPLEX_HG is set to false. And vice-versa @@ -2454,7 +2444,7 @@ PgSQL_Connection * PgSQL_SrvConnList::get_random_MyConn(PgSQL_Session *sess, boo // pgsql_hostgroup_attributes takes priority throttle_connections_per_sec_to_hostgroup = _myhgc->attributes.throttle_connections_per_sec; } - if (_myhgc->new_connections_now > (unsigned int) throttle_connections_per_sec_to_hostgroup) { + if (should_throttle_connection_creation(_myhgc->new_connections_now, throttle_connections_per_sec_to_hostgroup)) { __sync_fetch_and_add(&PgHGM->status.server_connections_delayed, 1); return NULL; } else { diff --git a/lib/Query_Processor.cpp b/lib/Query_Processor.cpp index d11970ed87..5d7908253d 100644 --- a/lib/Query_Processor.cpp +++ b/lib/Query_Processor.cpp @@ -19,6 +19,7 @@ using json = nlohmann::json; #include "PgSQL_Data_Stream.h" #include "MySQL_Data_Stream.h" +#include "gen_utils.h" #include "query_processor.h" #include "QP_rule_text.h" #include "MySQL_Query_Processor.h" @@ -220,7 +221,7 @@ static bool query_digest_text_matches( return it != digest_end; } -static re2_t * compile_query_rule(QP_rule_t *qr, int i, int query_processor_regex) { +static re2_t * compile_query_rule(const QP_rule_t *qr, int i, int query_processor_regex) { re2_t *r=(re2_t *)malloc(sizeof(re2_t)); r->opt1=NULL; r->re1=NULL; @@ -250,6 +251,121 @@ static re2_t * compile_query_rule(QP_rule_t *qr, int i, int query_processor_rege return r; }; +static void free_compiled_query_rule(re2_t *r) { + if (r == NULL) return; + if (r->opt1) { delete r->opt1; r->opt1=NULL; } + if (r->re1) { delete r->re1; r->re1=NULL; } + if (r->opt2) { delete r->opt2; r->opt2=NULL; } + if (r->re2) { delete r->re2; r->re2=NULL; } + free(r); +} + +static bool rule_matches_regex( + const QP_rule_t* qr, + void* regex_engine, + int regex_index, + const char* subject, + int query_processor_regex +) { + if (subject == NULL) return false; + + re2_t *compiled_regex = static_cast(regex_engine); + re2_t *temporary_regex = NULL; + + if (compiled_regex == NULL) { + temporary_regex = compile_query_rule(qr, regex_index, query_processor_regex); + compiled_regex = temporary_regex; + } + + bool rc = false; + if (compiled_regex) { + if (compiled_regex->re2) { + rc = RE2::PartialMatch(subject, *compiled_regex->re2); + } else if (compiled_regex->re1) { + rc = compiled_regex->re1->PartialMatch(subject); + } + } + + free_compiled_query_rule(temporary_regex); + return (qr->negate_match_pattern ? (rc == false) : (rc == true)); +} + +bool rule_matches_query( + const QP_rule_t* qr, + int current_flagIN, + const char* username, + const char* schemaname, + const char* client_addr, + const char* proxy_addr, + int proxy_port, + uint64_t digest, + const char* digest_text, + const char* query_text, + const char* rewritten_query, + int query_processor_regex +) { + if (qr == NULL) return false; + + if (qr->flagIN != current_flagIN) { + return false; + } + + if (qr->username && strlen(qr->username)) { + if (username == NULL || strcmp(qr->username, username) != 0) { + return false; + } + } + + if (qr->schemaname && strlen(qr->schemaname)) { + if (schemaname == NULL || strcmp(qr->schemaname, schemaname) != 0) { + return false; + } + } + + if (qr->client_addr && strlen(qr->client_addr)) { + if (client_addr) { + if (qr->client_addr_wildcard_position == -1) { + if (strcmp(qr->client_addr, client_addr) != 0) { + return false; + } + } else if (mywildcmp(qr->client_addr, client_addr) == false) { + return false; + } + } + } + + if (qr->proxy_addr && strlen(qr->proxy_addr)) { + if (proxy_addr) { + if (strcmp(qr->proxy_addr, proxy_addr) != 0) { + return false; + } + } + } + + if (qr->proxy_port >= 0 && qr->proxy_port != proxy_port) { + return false; + } + + if (qr->digest && digest && qr->digest != digest) { + return false; + } + + if (qr->match_digest && digest_text) { + if (rule_matches_regex(qr, qr->regex_engine1, 1, digest_text, query_processor_regex) == false) { + return false; + } + } + + if (qr->match_pattern) { + const char* match_query = (rewritten_query ? rewritten_query : query_text); + if (rule_matches_regex(qr, qr->regex_engine2, 2, match_query, query_processor_regex) == false) { + return false; + } + } + + return true; +} + static void __delete_query_rule(QP_rule_t *qr) { proxy_debug(PROXY_DEBUG_MYSQL_QUERY_PROCESSOR, 5, "Deleting rule in %p : rule_id:%d, active:%d, username=%s, schemaname=%s, flagIN:%d, %smatch_pattern=\"%s\", flagOUT:%d replace_pattern=\"%s\", destination_hostgroup:%d, apply:%d\n", qr, qr->rule_id, qr->active, qr->username, qr->schemaname, qr->flagIN, (qr->negate_match_pattern ? "(!)" : "") , qr->match_pattern, qr->flagOUT, qr->replace_pattern, qr->destination_hostgroup, qr->apply); if (qr->username) @@ -275,20 +391,10 @@ static void __delete_query_rule(QP_rule_t *qr) { if (qr->comment) free(qr->comment); if (qr->regex_engine1) { - re2_t *r=(re2_t *)qr->regex_engine1; - if (r->opt1) { delete r->opt1; r->opt1=NULL; } - if (r->re1) { delete r->re1; r->re1=NULL; } - if (r->opt2) { delete r->opt2; r->opt2=NULL; } - if (r->re2) { delete r->re2; r->re2=NULL; } - free(qr->regex_engine1); + free_compiled_query_rule((re2_t *)qr->regex_engine1); } if (qr->regex_engine2) { - re2_t *r=(re2_t *)qr->regex_engine2; - if (r->opt1) { delete r->opt1; r->opt1=NULL; } - if (r->re1) { delete r->re1; r->re1=NULL; } - if (r->opt2) { delete r->opt2; r->opt2=NULL; } - if (r->re2) { delete r->re2; r->re2=NULL; } - free(qr->regex_engine2); + free_compiled_query_rule((re2_t *)qr->regex_engine2); } if (qr->flagOUT_ids != NULL) { qr->flagOUT_ids->clear(); @@ -1635,7 +1741,6 @@ Query_Processor_Output* Query_Processor::process_query(TypeSession* wrunlock(); } QP_rule_t *qr = NULL; - re2_t *re2p; int flagIN=0; ret->next_query_flagIN=-1; // reset if (sess->next_query_flagIN >= 0) { @@ -1669,113 +1774,22 @@ Query_Processor_Output* Query_Processor::process_query(TypeSession* __internal_loop: for (std::vector::iterator it=_thr_SQP_rules->begin(); it!=_thr_SQP_rules->end(); ++it) { qr=*it; - if (qr->flagIN != flagIN) { - proxy_debug(PROXY_DEBUG_MYSQL_QUERY_PROCESSOR, 6, "query rule %d has no matching flagIN\n", qr->rule_id); + if (rule_matches_query( + qr, + flagIN, + sess->client_myds->myconn->userinfo->username, + sess->client_myds->myconn->userinfo->schemaname, + sess->client_myds->addr.addr, + sess->client_myds->proxy_addr.addr, + sess->client_myds->proxy_addr.port, + (qp ? qp->digest : 0), + (qp ? qp->digest_text : NULL), + query, + ((ret && ret->new_query) ? ret->new_query->c_str() : NULL), + GET_THREAD_VARIABLE(query_processor_regex) + ) == false) { continue; } - if (qr->username && strlen(qr->username)) { - if (strcmp(qr->username,sess->client_myds->myconn->userinfo->username)!=0) { - proxy_debug(PROXY_DEBUG_MYSQL_QUERY_PROCESSOR, 5, "query rule %d has no matching username\n", qr->rule_id); - continue; - } - } - if (qr->schemaname && strlen(qr->schemaname)) { - if (strcmp(qr->schemaname,sess->client_myds->myconn->userinfo->schemaname)!=0) { - proxy_debug(PROXY_DEBUG_MYSQL_QUERY_PROCESSOR, 5, "query rule %d has no matching schemaname\n", qr->rule_id); - continue; - } - } - - // match on client address - if (qr->client_addr && strlen(qr->client_addr)) { - if (sess->client_myds->addr.addr) { - if (qr->client_addr_wildcard_position == -1) { // no wildcard , old algorithm - if (strcmp(qr->client_addr,sess->client_myds->addr.addr)!=0) { - proxy_debug(PROXY_DEBUG_MYSQL_QUERY_PROCESSOR, 5, "query rule %d has no matching client_addr\n", qr->rule_id); - continue; - } - } else if (qr->client_addr_wildcard_position==0) { - // catch all! - // therefore we have a match - } else { // client_addr_wildcard_position > 0 - if (strncmp(qr->client_addr,sess->client_myds->addr.addr,qr->client_addr_wildcard_position)!=0) { - proxy_debug(PROXY_DEBUG_MYSQL_QUERY_PROCESSOR, 5, "query rule %d has no matching client_addr\n", qr->rule_id); - continue; - } - } - } - } - - // match on proxy_addr - if (qr->proxy_addr && strlen(qr->proxy_addr)) { - if (sess->client_myds->proxy_addr.addr) { - if (strcmp(qr->proxy_addr,sess->client_myds->proxy_addr.addr)!=0) { - proxy_debug(PROXY_DEBUG_MYSQL_QUERY_PROCESSOR, 5, "query rule %d has no matching proxy_addr\n", qr->rule_id); - continue; - } - } - } - - // match on proxy_port - if (qr->proxy_port>=0) { - if (qr->proxy_port!=sess->client_myds->proxy_addr.port) { - proxy_debug(PROXY_DEBUG_MYSQL_QUERY_PROCESSOR, 5, "query rule %d has no matching proxy_port\n", qr->rule_id); - continue; - } - } - - // match on digest - if (qp && qp->digest) { - if (qr->digest) { - if (qr->digest != qp->digest) { - proxy_debug(PROXY_DEBUG_MYSQL_QUERY_PROCESSOR, 5, "query rule %d has no matching digest\n", qr->rule_id); - continue; - } - } - } - - // match on query digest - if (qp && qp->digest_text ) { // we call this only if we have a query digest - re2p=(re2_t *)qr->regex_engine1; - if (qr->match_digest) { - bool rc; - // we always match on original query - if (re2p->re2) { - rc=RE2::PartialMatch(qp->digest_text,*re2p->re2); - } else { - rc=re2p->re1->PartialMatch(qp->digest_text); - } - if ((rc==true && qr->negate_match_pattern==true) || ( rc==false && qr->negate_match_pattern==false )) { - proxy_debug(PROXY_DEBUG_MYSQL_QUERY_PROCESSOR, 5, "query rule %d has no matching pattern\n", qr->rule_id); - continue; - } - } - } - // match on query - re2p=(re2_t *)qr->regex_engine2; - if (qr->match_pattern) { - bool rc; - if (ret && ret->new_query) { - // if we already rewrote the query, process the new query - //std::string *s=ret->new_query; - if (re2p->re2) { - rc=RE2::PartialMatch(ret->new_query->c_str(),*re2p->re2); - } else { - rc=re2p->re1->PartialMatch(ret->new_query->c_str()); - } - } else { - // we never rewrote the query - if (re2p->re2) { - rc=RE2::PartialMatch(query,*re2p->re2); - } else { - rc=re2p->re1->PartialMatch(query); - } - } - if ((rc==true && qr->negate_match_pattern==true) || ( rc==false && qr->negate_match_pattern==false )) { - proxy_debug(PROXY_DEBUG_MYSQL_QUERY_PROCESSOR, 5, "query rule %d has no matching pattern\n", qr->rule_id); - continue; - } - } // if we arrived here, we have a match qr->hits++; // this is done without atomic function because it updates only the local variables diff --git a/lib/ServerSelection.cpp b/lib/ServerSelection.cpp new file mode 100644 index 0000000000..4704848b99 --- /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/lib/TransactionState.cpp b/lib/TransactionState.cpp new file mode 100644 index 0000000000..e68bf509c6 --- /dev/null +++ b/lib/TransactionState.cpp @@ -0,0 +1,55 @@ +/** + * @file TransactionState.cpp + * @brief Implementation of pure transaction state tracking. + * + * @see TransactionState.h + * @see Phase 3.8 (GitHub issue #5496) + */ + +#include "TransactionState.h" + +int update_transaction_persistent_hostgroup( + bool transaction_persistent, + int transaction_persistent_hostgroup, + int current_hostgroup, + bool backend_in_transaction) +{ + if (!transaction_persistent) { + return -1; // persistence disabled + } + + if (transaction_persistent_hostgroup == -1) { + // Not currently locked — lock if transaction just started + if (backend_in_transaction) { + return current_hostgroup; + } + } else { + // Currently locked — unlock if transaction just ended + if (!backend_in_transaction) { + return -1; + } + } + + return transaction_persistent_hostgroup; // no change +} + +bool is_transaction_timed_out( + unsigned long long transaction_started_at, + unsigned long long current_time, + int max_transaction_time_ms) +{ + if (transaction_started_at == 0) { + return false; // no active transaction + } + if (max_transaction_time_ms <= 0) { + return false; // no time limit + } + + unsigned long long elapsed_ms = 0; + if (current_time > transaction_started_at) { + elapsed_ms = (current_time - transaction_started_at) / 1000; + // transaction_started_at and current_time are in microseconds + } + + return (elapsed_ms > (unsigned long long)max_transaction_time_ms); +} diff --git a/test/tap/Makefile b/test/tap/Makefile index f35d1e76d9..2c50397a30 100644 --- a/test/tap/Makefile +++ b/test/tap/Makefile @@ -3,10 +3,10 @@ .DEFAULT: all .PHONY: all -all: tests tests_with_deps +all: tests tests_with_deps unit_tests .PHONY: debug -debug: tests tests_with_deps +debug: tests tests_with_deps unit_tests .PHONY: test_deps test_deps: @@ -29,6 +29,11 @@ tests_with_deps: tap test_deps cd tests_with_deps && CC=${CC} CXX=${CXX} ${MAKE} $(MAKECMDGOALS) +.PHONY: unit_tests +unit_tests: + cd tests/unit && CC=${CC} CXX=${CXX} ${MAKE} + + .PHONY: clean_utils .SILENT: clean_utils clean_utils: @@ -40,6 +45,7 @@ clean: cd tap && ${MAKE} -s clean cd tests && ${MAKE} -s clean cd tests_with_deps && ${MAKE} -s clean + cd tests/unit && ${MAKE} -s clean .PHONY: cleanall .SILENT: cleanall @@ -48,3 +54,4 @@ cleanall: cd tap && ${MAKE} -s cleanall cd tests && ${MAKE} -s clean cd tests_with_deps && ${MAKE} -s clean + cd tests/unit && ${MAKE} -s clean diff --git a/test/tap/tap/tap.cpp b/test/tap/tap/tap.cpp index cbf1a22f24..5298ced21c 100644 --- a/test/tap/tap/tap.cpp +++ b/test/tap/tap/tap.cpp @@ -38,6 +38,10 @@ typedef char my_bool; using std::size_t; +#ifdef __APPLE__ +typedef unsigned long ulong; +#endif + extern std::vector noise_failures; extern std::mutex noise_failure_mutex; diff --git a/test/tap/test_helpers/test_globals.cpp b/test/tap/test_helpers/test_globals.cpp new file mode 100644 index 0000000000..147f5fa10c --- /dev/null +++ b/test/tap/test_helpers/test_globals.cpp @@ -0,0 +1,278 @@ +/** + * @file test_globals.cpp + * @brief Stub definitions of ProxySQL global symbols for unit testing. + * + * This file serves as a replacement for both src/proxysql_global.cpp and + * the global variable definitions in src/main.cpp. It allows unit test + * binaries to link against libproxysql.a without pulling in main() or + * the full daemon initialization sequence. + * + * The PROXYSQL_EXTERN mechanism (defined in include/proxysql_structs.h) + * controls whether global variables are declared as 'extern' or defined. + * By defining PROXYSQL_EXTERN here, we cause the headers to emit actual + * definitions for: + * - GloVars (ProxySQL_GlobalVariables) + * - MyHGM, PgHGM (HostGroups Manager pointers) + * - glovars (global_variables struct) + * - All __thread per-thread variables (mysql_thread_*, pgsql_thread_*) + * - mysql_tracked_variables[], pgsql_tracked_variables[] + * + * Additionally, this file defines the Glo* pointers that are normally + * declared in main.cpp (GloMyQC, GloMyAuth, GloAdmin, GloMTH, etc.), + * all initialized to nullptr. + * + * @see test_globals.h for the public interface. + * @see Phase 2.1 of the Unit Testing Framework (GitHub issue #5473) + */ + +// Define PROXYSQL_EXTERN before including headers to emit global +// variable definitions (same mechanism as src/proxysql_global.cpp). +#define PROXYSQL_EXTERN + +#include "../deps/json/json.hpp" + +using json = nlohmann::json; +#define PROXYJSON + +#include +#include +#include +#include "btree_map.h" +#include "proxysql.h" +#include "cpp.h" + +#include "ProxySQL_Statistics.hpp" +#include "MySQL_PreparedStatement.h" +#include "PgSQL_PreparedStatement.h" +#include "ProxySQL_Cluster.hpp" +#include "MySQL_Logger.hpp" +#include "PgSQL_Logger.hpp" + +#ifdef PROXYSQLGENAI +#include "MCP_Thread.h" +#include "GenAI_Thread.h" +#include "AI_Features_Manager.h" +#endif /* PROXYSQLGENAI */ + +#include "SQLite3_Server.h" +#include "MySQL_Query_Processor.h" +#include "PgSQL_Query_Processor.h" +#include "MySQL_Authentication.hpp" +#include "PgSQL_Authentication.h" +#include "MySQL_LDAP_Authentication.hpp" +#include "MySQL_Query_Cache.h" +#include "PgSQL_Query_Cache.h" +#include "proxysql_restapi.h" +#include "Web_Interface.hpp" +#include "proxysql_utils.h" + +#include "test_globals.h" + +// ============================================================================ +// Glo* pointer stubs — normally defined in src/main.cpp +// +// These are the global singleton pointers that most ProxySQL classes +// reference directly. For unit tests, they start as nullptr and are +// selectively initialized by the test_init_*() helpers. +// ============================================================================ + +MySQL_Query_Cache *GloMyQC = nullptr; +PgSQL_Query_Cache *GloPgQC = nullptr; +MySQL_Authentication *GloMyAuth = nullptr; +PgSQL_Authentication *GloPgAuth = nullptr; +MySQL_LDAP_Authentication *GloMyLdapAuth = nullptr; + +#ifdef PROXYSQLCLICKHOUSE +ClickHouse_Authentication *GloClickHouseAuth = nullptr; +#endif /* PROXYSQLCLICKHOUSE */ + +MySQL_Query_Processor *GloMyQPro = nullptr; +PgSQL_Query_Processor *GloPgQPro = nullptr; +ProxySQL_Admin *GloAdmin = nullptr; +MySQL_Threads_Handler *GloMTH = nullptr; +PgSQL_Threads_Handler *GloPTH = nullptr; + +#ifdef PROXYSQLGENAI +MCP_Threads_Handler *GloMCPH = nullptr; +GenAI_Threads_Handler *GloGATH = nullptr; +AI_Features_Manager *GloAI = nullptr; +#endif /* PROXYSQLGENAI */ + +Web_Interface *GloWebInterface = nullptr; +MySQL_STMT_Manager_v14 *GloMyStmt = nullptr; +PgSQL_STMT_Manager *GloPgStmt = nullptr; + +MySQL_Monitor *GloMyMon = nullptr; +PgSQL_Monitor *GloPgMon = nullptr; +std::thread *MyMon_thread = nullptr; + +MySQL_Logger *GloMyLogger = nullptr; +PgSQL_Logger *GloPgSQL_Logger = nullptr; + +MySQL_Variables mysql_variables; +PgSQL_Variables pgsql_variables; + +SQLite3_Server *GloSQLite3Server = nullptr; + +#ifdef PROXYSQLCLICKHOUSE +ClickHouse_Server *GloClickHouseServer = nullptr; +#endif /* PROXYSQLCLICKHOUSE */ + +ProxySQL_Cluster *GloProxyCluster = nullptr; +ProxySQL_Statistics *GloProxyStats = nullptr; + +// ============================================================================ +// TAP noise testing stubs — normally defined in noise_utils.cpp. +// Unit tests do not use noise testing, so these are empty stubs to +// satisfy the extern references in tap.cpp. +// ============================================================================ + +std::vector noise_failures; +std::mutex noise_failure_mutex; + +// ============================================================================ +// Other symbols from main.cpp +// ============================================================================ + +/// Atomic load counter used during daemon startup. Unused in tests. +std::atomic load_{0}; + +/// File descriptors for the proxy listener sockets. Unused in tests. +int listen_fd = -1; +int socket_fd = -1; + +/// SHA1 checksum of the binary. Unused in tests. +char *binary_sha1 = nullptr; + +// ============================================================================ +// Stub functions for symbols referenced by libproxysql.a that are +// normally defined in src/ object files (proxy_tls.o, SQLite3_Server.o). +// These are no-ops since unit tests don't exercise TLS bootstrap or +// the SQLite3 server module. +// ============================================================================ + +#include "SQLite3_Server.h" + +int ProxySQL_create_or_load_TLS(bool, std::string &) { return 0; } + +char *SQLite3_Server::get_variable(char *) { return nullptr; } +bool SQLite3_Server::has_variable(const char *) { return false; } +bool SQLite3_Server::set_variable(char *, char *) { return false; } +char **SQLite3_Server::get_variables_list() { return nullptr; } +void SQLite3_Server::wrlock() {} +void SQLite3_Server::wrunlock() {} + +// ============================================================================ +// TAP noise testing stubs — exit_status() in tap.cpp calls these. +// Normally defined in noise_utils.cpp (test/tap/tap/utils.cpp). +// ============================================================================ + +extern "C" void stop_noise_tools() {} +extern "C" int get_noise_tools_count() { return 0; } + +/// jemalloc configuration string. Required at link time on non-FreeBSD. +#ifndef __FreeBSD__ +const char *malloc_conf = + "xmalloc:true,lg_tcache_max:16,prof:false"; +#endif + +// ============================================================================ +// ProxySQL_GlobalVariables SSL helpers (from src/proxysql_global.cpp) +// +// These methods access GloVars.global.ssl_ctx which will be nullptr +// in tests. The stubs return nullptr/noop to avoid crashes. +// ============================================================================ + +SSL_CTX *ProxySQL_GlobalVariables::get_SSL_ctx() { + std::lock_guard lock(global.ssl_mutex); + return global.ssl_ctx; +} + +SSL *ProxySQL_GlobalVariables::get_SSL_new() { + std::lock_guard lock(global.ssl_mutex); + if (global.ssl_ctx == nullptr) return nullptr; + return SSL_new(global.ssl_ctx); +} + +void ProxySQL_GlobalVariables::get_SSL_pem_mem(char **key, char **cert) { + std::lock_guard lock(global.ssl_mutex); + if (global.ssl_key_pem_mem != nullptr) + *key = strdup(global.ssl_key_pem_mem); + else + *key = nullptr; + if (global.ssl_cert_pem_mem != nullptr) + *cert = strdup(global.ssl_cert_pem_mem); + else + *cert = nullptr; +} + +// ============================================================================ +// test_globals_init / test_globals_cleanup +// ============================================================================ + +/** + * @brief Sets up GloVars with minimal safe defaults for unit testing. + * + * Configures GloVars to use a temporary directory as datadir, disables + * SSL, monitoring, and other features that require infrastructure. + * This function is idempotent. + * + * @return 0 on success, non-zero on failure. + */ +int test_globals_init() { + // Ensure the global debug flag matches the build type so that + // components which validate debug compatibility in their + // constructors do not abort. +#ifdef DEBUG + glovars.has_debug = true; +#else + glovars.has_debug = false; +#endif + + // Set safe defaults for the global configuration + GloVars.global.nostart = true; + GloVars.global.foreground = true; + GloVars.global.gdbg = false; + GloVars.global.my_monitor = false; + GloVars.global.pg_monitor = false; + GloVars.global.version_check = false; + GloVars.global.sqlite3_server = false; +#ifdef PROXYSQLCLICKHOUSE + GloVars.global.clickhouse_server = false; +#endif + GloVars.global.ssl_keylog_enabled = false; + GloVars.global.gr_bootstrap_mode = 0; + GloVars.global.gr_bootstrap_uri = nullptr; + GloVars.global.data_packets_history_size = 0; + + // SSL pointers — nullptr means no SSL + GloVars.global.ssl_ctx = nullptr; + GloVars.global.tmp_ssl_ctx = nullptr; + GloVars.global.ssl_key_pem_mem = nullptr; + GloVars.global.ssl_cert_pem_mem = nullptr; + + // File paths — use a temp directory so tests don't touch real data. + // These are strdup'd so cleanup can safely free() them. + const char *tmpdir = getenv("TMPDIR"); + if (tmpdir == nullptr) tmpdir = "/tmp"; + + if (GloVars.datadir == nullptr) { + std::string datadir_path = std::string(tmpdir) + + "/proxysql_unit_test_" + std::to_string(getpid()); + GloVars.datadir = strdup(datadir_path.c_str()); + } + + return 0; +} + +/** + * @brief Frees resources allocated by test_globals_init(). + * + * Safe to call multiple times or even if test_globals_init() was never called. + */ +void test_globals_cleanup() { + if (GloVars.datadir != nullptr) { + free(GloVars.datadir); + GloVars.datadir = nullptr; + } +} diff --git a/test/tap/test_helpers/test_globals.h b/test/tap/test_helpers/test_globals.h new file mode 100644 index 0000000000..898f57131f --- /dev/null +++ b/test/tap/test_helpers/test_globals.h @@ -0,0 +1,46 @@ +/** + * @file test_globals.h + * @brief Stub global definitions for ProxySQL unit tests. + * + * This header is the entry point for unit tests that need to link against + * libproxysql.a without the real main.cpp. It provides: + * + * 1. All Glo* pointer stubs (initialized to nullptr) + * 2. GloVars (ProxySQL_GlobalVariables) with safe defaults + * 3. All __thread variable definitions via the PROXYSQL_EXTERN mechanism + * 4. Other extern symbols normally provided by main.cpp + * + * Usage in test files: + * @code + * #include "test_globals.h" + * #include "test_init.h" + * // ... test code ... + * @endcode + * + * @note This file must NOT be included by production code. + * @see test_globals.cpp for the corresponding definitions. + * @see Phase 2.1 of the Unit Testing Framework (GitHub issue #5473) + */ + +#ifndef TEST_GLOBALS_H +#define TEST_GLOBALS_H + +/** + * @brief Initialize minimal global state required for unit tests. + * + * Sets up GloVars with safe defaults (tmpdir-based datadir, no SSL, + * no pidfile). Must be called before any component initialization. + * + * @return 0 on success, non-zero on failure. + */ +int test_globals_init(); + +/** + * @brief Clean up global state allocated by test_globals_init(). + * + * Frees any memory allocated during initialization. Safe to call + * multiple times. + */ +void test_globals_cleanup(); + +#endif /* TEST_GLOBALS_H */ diff --git a/test/tap/test_helpers/test_init.cpp b/test/tap/test_helpers/test_init.cpp new file mode 100644 index 0000000000..08261b2efd --- /dev/null +++ b/test/tap/test_helpers/test_init.cpp @@ -0,0 +1,207 @@ +/** + * @file test_init.cpp + * @brief Implementation of component initialization helpers for unit tests. + * + * Each test_init_*() function creates real instances of ProxySQL components, + * bypassing the full daemon startup sequence. Components are assigned to + * their respective Glo* global pointers so that internal cross-references + * work correctly. + * + * @see test_init.h for the public interface and usage examples. + * @see Phase 2.1 of the Unit Testing Framework (GitHub issue #5473) + */ + +#include "proxysql.h" +#include "cpp.h" + +#include "MySQL_Authentication.hpp" +#include "PgSQL_Authentication.h" +#include "MySQL_Query_Cache.h" +#include "PgSQL_Query_Cache.h" +#include "MySQL_Query_Processor.h" +#include "PgSQL_Query_Processor.h" + +#include "test_globals.h" +#include "test_init.h" + +// Extern declarations for Glo* pointers defined in test_globals.cpp. +// These are normally defined in main.cpp and have no header declarations. +extern MySQL_Authentication *GloMyAuth; +extern PgSQL_Authentication *GloPgAuth; +extern MySQL_Query_Cache *GloMyQC; +extern PgSQL_Query_Cache *GloPgQC; +extern MySQL_Query_Processor *GloMyQPro; +extern PgSQL_Query_Processor *GloPgQPro; + +// GloMTH is declared extern in proxysql_utils.h. +// GloPTH has no extern declaration in any header, so we add one here. +extern PgSQL_Threads_Handler *GloPTH; + +// ============================================================================ +// Minimal initialization +// ============================================================================ + +int test_init_minimal() { + return test_globals_init(); +} + +void test_cleanup_minimal() { + test_globals_cleanup(); +} + +// ============================================================================ +// Authentication +// ============================================================================ + +int test_init_auth() { + if (GloMyAuth != nullptr || GloPgAuth != nullptr) { + // Already initialized — idempotent + return 0; + } + + GloMyAuth = new MySQL_Authentication(); + GloPgAuth = new PgSQL_Authentication(); + + return 0; +} + +void test_cleanup_auth() { + if (GloMyAuth != nullptr) { + delete GloMyAuth; + GloMyAuth = nullptr; + } + if (GloPgAuth != nullptr) { + delete GloPgAuth; + GloPgAuth = nullptr; + } +} + +// ============================================================================ +// Query Cache +// ============================================================================ + +int test_init_query_cache() { + if (GloMyQC != nullptr || GloPgQC != nullptr) { + return 0; + } + + // The Query_Cache constructor registers Prometheus metrics via + // GloVars.prometheus_registry. Provide a real registry so the + // constructor doesn't crash on nullptr dereference. + if (GloVars.prometheus_registry == nullptr) { + GloVars.prometheus_registry = std::make_shared(); + } + + GloMyQC = new MySQL_Query_Cache(); + GloPgQC = new PgSQL_Query_Cache(); + + // NOTE: We intentionally do NOT start the purge thread here. + // Unit tests should call purgeHash() explicitly for deterministic + // behavior. + + return 0; +} + +void test_cleanup_query_cache() { + if (GloMyQC != nullptr) { + delete GloMyQC; + GloMyQC = nullptr; + } + if (GloPgQC != nullptr) { + delete GloPgQC; + GloPgQC = nullptr; + } +} + +// ============================================================================ +// Query Processor +// ============================================================================ + +int test_init_query_processor() { + if (GloMyQPro != nullptr || GloPgQPro != nullptr) { + return 0; + } + + // Query Processor constructors register Prometheus metrics and + // read variables from GloMTH/GloPTH. Ensure both are available. + if (GloVars.prometheus_registry == nullptr) { + GloVars.prometheus_registry = std::make_shared(); + } + if (GloMTH == nullptr) { + GloMTH = new MySQL_Threads_Handler(); + } + if (GloPTH == nullptr) { + GloPTH = new PgSQL_Threads_Handler(); + } + + // Trigger lazy initialization of VariablesPointers maps. + // The QP constructor calls get_variable_int() which requires + // these maps to be populated. + char **vl = GloMTH->get_variables_list(); + if (vl) { + for (char **p = vl; *p != nullptr; ++p) free(*p); + free(vl); + } + vl = GloPTH->get_variables_list(); + if (vl) { + for (char **p = vl; *p != nullptr; ++p) free(*p); + free(vl); + } + + GloMyQPro = new MySQL_Query_Processor(); + GloPgQPro = new PgSQL_Query_Processor(); + + return 0; +} + +void test_cleanup_query_processor() { + if (GloMyQPro != nullptr) { + delete GloMyQPro; + GloMyQPro = nullptr; + } + if (GloPgQPro != nullptr) { + delete GloPgQPro; + GloPgQPro = nullptr; + } + // NOTE: We do NOT delete GloMTH/GloPTH here because other + // components may still reference them. Their cleanup relies + // on process exit. +} + +// ============================================================================ +// HostGroups Manager +// ============================================================================ + +int test_init_hostgroups() { + // HostGroups Manager constructors register Prometheus metrics. + if (GloVars.prometheus_registry == nullptr) { + GloVars.prometheus_registry = std::make_shared(); + } + + if (MyHGM == nullptr) { + MyHGM = new MySQL_HostGroups_Manager(); + // NOTE: We intentionally do NOT call MyHGM->init() here. + // init() starts background threads (HGCU_thread, GTID_syncer) + // that run forever and would cause the test process to hang on + // exit. The constructor alone sets up the internal SQLite3 + // database and all data structures needed for unit testing. + } + + if (PgHGM == nullptr) { + PgHGM = new PgSQL_HostGroups_Manager(); + // PgHGM->init() is a no-op, but we skip it for consistency. + } + + return 0; +} + +void test_cleanup_hostgroups() { + if (MyHGM != nullptr) { + delete MyHGM; + MyHGM = nullptr; + } + if (PgHGM != nullptr) { + delete PgHGM; + PgHGM = nullptr; + } +} diff --git a/test/tap/test_helpers/test_init.h b/test/tap/test_helpers/test_init.h new file mode 100644 index 0000000000..5818bde0ba --- /dev/null +++ b/test/tap/test_helpers/test_init.h @@ -0,0 +1,131 @@ +/** + * @file test_init.h + * @brief Component initialization helpers for ProxySQL unit tests. + * + * Provides functions to selectively initialize individual ProxySQL + * components for isolated testing, without requiring the full daemon + * startup sequence. Each init function has a matching cleanup function + * that frees all resources. + * + * Typical usage: + * @code + * #include "test_globals.h" + * #include "test_init.h" + * #include "tap.h" + * + * int main() { + * plan(3); + * test_init_minimal(); + * test_init_auth(); + * + * // ... run tests against GloMyAuth ... + * + * test_cleanup_auth(); + * test_cleanup_minimal(); + * return exit_status(); + * } + * @endcode + * + * @note All init functions are idempotent — calling them multiple + * times is safe (subsequent calls are no-ops). + * @note Always call cleanup functions in reverse init order. + * + * @see test_globals.h for the global stub definitions. + * @see Phase 2.1 of the Unit Testing Framework (GitHub issue #5473) + */ + +#ifndef TEST_INIT_H +#define TEST_INIT_H + +/** + * @brief Initialize minimal global state required by all unit tests. + * + * Sets up GloVars with safe defaults. This is the foundation that all + * other test_init_* functions build upon. Must be called first. + * + * @return 0 on success, non-zero on failure. + */ +int test_init_minimal(); + +/** + * @brief Clean up resources allocated by test_init_minimal(). + */ +void test_cleanup_minimal(); + +/** + * @brief Initialize MySQL and PostgreSQL Authentication components. + * + * Creates real MySQL_Authentication and PgSQL_Authentication objects + * (assigned to GloMyAuth and GloPgAuth). The auth stores are empty + * and ready for test data via add()/lookup()/del(). + * + * @pre test_init_minimal() must have been called. + * @return 0 on success, non-zero on failure. + */ +int test_init_auth(); + +/** + * @brief Clean up resources allocated by test_init_auth(). + * + * Destroys GloMyAuth and GloPgAuth, setting them back to nullptr. + */ +void test_cleanup_auth(); + +/** + * @brief Initialize MySQL and PostgreSQL Query Cache components. + * + * Creates real MySQL_Query_Cache and PgSQL_Query_Cache objects + * (assigned to GloMyQC and GloPgQC). The purge thread is NOT started; + * callers can invoke purgeHash() manually for deterministic testing. + * + * @pre test_init_minimal() must have been called. + * @return 0 on success, non-zero on failure. + */ +int test_init_query_cache(); + +/** + * @brief Clean up resources allocated by test_init_query_cache(). + * + * Destroys GloMyQC and GloPgQC, setting them back to nullptr. + */ +void test_cleanup_query_cache(); + +/** + * @brief Initialize MySQL and PostgreSQL Query Processor components. + * + * Creates real MySQL_Query_Processor and PgSQL_Query_Processor objects + * (assigned to GloMyQPro and GloPgQPro) with empty rulesets. Rules + * can be added via new_query_rule() for testing. + * + * @pre test_init_minimal() must have been called. + * @return 0 on success, non-zero on failure. + */ +int test_init_query_processor(); + +/** + * @brief Clean up resources allocated by test_init_query_processor(). + * + * Destroys GloMyQPro and GloPgQPro, setting them back to nullptr. + */ +void test_cleanup_query_processor(); + +/** + * @brief Initialize MySQL and PostgreSQL HostGroups Managers. + * + * Creates real MySQL_HostGroups_Manager and PgSQL_HostGroups_Manager + * objects (assigned to MyHGM and PgHGM) with internal SQLite3 databases. + * Servers can be added via create_new_server_in_hg() for testing. + * + * @pre test_init_minimal() must have been called. + * @return 0 on success, non-zero on failure. + */ +int test_init_hostgroups(); + +/** + * @brief Clean up resources allocated by test_init_hostgroups(). + * + * Destroys MyHGM and PgHGM, setting them back to nullptr. + */ +void test_cleanup_hostgroups(); + +#endif /* TEST_INIT_H */ diff --git a/test/tap/tests/unit/Makefile b/test/tap/tests/unit/Makefile new file mode 100644 index 0000000000..04a5e77778 --- /dev/null +++ b/test/tap/tests/unit/Makefile @@ -0,0 +1,275 @@ +#!/bin/make -f +# +# Makefile for ProxySQL unit tests. +# +# Unit tests link against libproxysql.a with stub globals (test_globals.o) +# instead of main.o, allowing individual components to be tested in +# isolation without a running ProxySQL daemon or backend servers. +# +# See: GitHub issue #5473 (Phase 2.1: Test Infrastructure Foundation) + + +PROXYSQL_PATH := $(shell while [ ! -f ./src/proxysql_global.cpp ]; do cd ..; done; pwd) + +include $(PROXYSQL_PATH)/include/makefiles_vars.mk +include $(PROXYSQL_PATH)/include/makefiles_paths.mk + + +# =========================================================================== +# Include directories — mirrors test/tap/tests/Makefile +# =========================================================================== + +IDIRS := -I$(TAP_IDIR) \ + -I$(RE2_IDIR) \ + -I$(PROXYSQL_IDIR) \ + -I$(JEMALLOC_IDIR) \ + -I$(LIBCONFIG_IDIR) \ + -I$(MARIADB_IDIR) \ + -I$(LIBDAEMON_IDIR) \ + -I$(MICROHTTPD_IDIR) \ + -I$(LIBHTTPSERVER_IDIR) \ + -I$(CURL_IDIR) -I$(EV_IDIR) \ + -I$(PROMETHEUS_IDIR) \ + -I$(DOTENV_DYN_IDIR) \ + -I$(SQLITE3_IDIR) \ + -I$(JSON_IDIR) \ + -I$(POSTGRESQL_IDIR) \ + -I$(LIBSCRAM_IDIR) \ + -I$(LIBUSUAL_IDIR) \ + -I$(SSL_IDIR) \ + -I$(ZSTD_IDIR) \ + -I$(PROXYSQL_PATH)/include \ + -I$(PROXYSQL_PATH)/test/tap/test_helpers + + +# =========================================================================== +# Library directories +# =========================================================================== + +LDIRS := -L$(TAP_LDIR) \ + -L$(RE2_LDIR) \ + -L$(PROXYSQL_LDIR) \ + -L$(JEMALLOC_LDIR) \ + -L$(LIBCONFIG_LDIR) \ + -L$(MARIADB_LDIR) \ + -L$(LIBDAEMON_LDIR) \ + -L$(MICROHTTPD_LDIR) \ + -L$(LIBHTTPSERVER_LDIR) \ + -L$(CURL_LDIR) -L$(EV_LDIR) \ + -L$(PROMETHEUS_LDIR) \ + -L$(DOTENV_DYN_LDIR) \ + -L$(PCRE_LDIR) \ + -L$(LIBINJECTION_LDIR) \ + -L$(POSTGRESQL_LDIR) \ + -L$(LIBSCRAM_LDIR) \ + -L$(LIBUSUAL_LDIR) \ + -L$(SSL_LDIR) + +ifeq ($(UNAME_S),Linux) + LDIRS += -L$(COREDUMPER_LDIR) +endif +ifeq ($(UNAME_S),Darwin) + IDIRS += -I/usr/local/include -I/opt/homebrew/include + LDIRS += -L/usr/local/lib -L/opt/homebrew/lib +endif + + +# =========================================================================== +# ClickHouse include/link paths (enabled by default) +# =========================================================================== + +CLICKHOUSE_CPP_PATH := $(DEPS_PATH)/clickhouse-cpp/clickhouse-cpp +CLICKHOUSE_CPP_IDIR := $(CLICKHOUSE_CPP_PATH) -I$(CLICKHOUSE_CPP_PATH)/contrib/absl +CLICKHOUSE_CPP_LDIR := $(CLICKHOUSE_CPP_PATH)/clickhouse +LZ4_LDIR := $(DEPS_PATH)/lz4/lz4/lib + +IDIRS += -I$(CLICKHOUSE_CPP_IDIR) + + +# =========================================================================== +# libproxysql.a — the core library under test +# =========================================================================== + +LIBPROXYSQLAR := $(PROXYSQL_LDIR)/libproxysql.a + + +# =========================================================================== +# Static libraries required at link time +# =========================================================================== + +STATIC_LIBS := $(CITYHASH_LDIR)/libcityhash.a \ + $(LZ4_LDIR)/liblz4.a \ + $(ZSTD_LDIR)/libzstd.a + +ifeq ($(PROXYSQLCLICKHOUSE),1) + STATIC_LIBS += $(CLICKHOUSE_CPP_LDIR)/libclickhouse-cpp-lib.a +endif + +ifeq ($(UNAME_S),Linux) + STATIC_LIBS += $(COREDUMPER_LDIR)/libcoredumper.a +endif + +ifeq ($(PROXYSQLGENAI),1) + STATIC_LIBS += $(SQLITE3_LDIR)/../libsqlite_rembed.a $(SQLITE3_LDIR)/vec.o +endif + + +# =========================================================================== +# Linker flags — platform-specific +# =========================================================================== + +ifeq ($(UNAME_S),Darwin) +# macOS: No -Bstatic/-Bdynamic; use explicit .a paths for static linking. +# libproxysql.a already bundles most deps on Darwin (see src/Makefile). +LIBPROXYSQLAR_FULL := $(LIBPROXYSQLAR) \ + $(JEMALLOC_LDIR)/libjemalloc.a \ + $(MICROHTTPD_LDIR)/libmicrohttpd.a \ + $(LIBHTTPSERVER_LDIR)/libhttpserver.a \ + $(PCRE_LDIR)/libpcre.a \ + $(PCRE_LDIR)/libpcrecpp.a \ + $(LIBDAEMON_LDIR)/libdaemon.a \ + $(LIBCONFIG_LDIR)/libconfig++.a \ + $(LIBCONFIG_LDIR)/libconfig.a \ + $(CURL_LDIR)/libcurl.a \ + $(SQLITE3_LDIR)/sqlite3.o \ + $(LIBINJECTION_LDIR)/libinjection.a \ + $(EV_LDIR)/libev.a \ + $(LIBSCRAM_LDIR)/libscram.a \ + $(LIBUSUAL_LDIR)/libusual.a \ + $(MARIADB_LDIR)/libmariadbclient.a \ + $(RE2_LDIR)/libre2.a \ + $(POSTGRESQL_PATH)/interfaces/libpq/libpq.a \ + $(POSTGRESQL_PATH)/common/libpgcommon.a \ + $(POSTGRESQL_PATH)/port/libpgport.a + +MYLIBS := -lssl -lcrypto -lpthread -lm -lz \ + -liconv -lgnutls -lprometheus-cpp-pull -lprometheus-cpp-core -luuid \ + -lzstd $(LWGCOV) +else +# Linux/FreeBSD: Use -Bstatic/-Bdynamic for controlled linking. +LIBPROXYSQLAR_FULL := $(LIBPROXYSQLAR) + +MYLIBS := -Wl,--export-dynamic -Wl,-Bdynamic -lgnutls -lcurl -lssl -lcrypto -luuid \ + -Wl,-Bstatic -lconfig -lproxysql -ldaemon -lconfig++ -lre2 -lpcrecpp -lpcre \ + -lmariadbclient -lhttpserver -lmicrohttpd -linjection -lev \ + -lprometheus-cpp-pull -lprometheus-cpp-core \ + -Wl,-Bstatic -lpq -lpgcommon -lpgport \ + -Wl,-Bdynamic -lpthread -lm -lz -lzstd -lrt -ldl \ + -lscram -lusual -Wl,--allow-multiple-definition \ + $(LWGCOV) +endif + +ifneq ($(NOJEMALLOC),1) +ifeq ($(UNAME_S),Linux) + MYLIBS += -Wl,-Bstatic -ljemalloc -Wl,-Bdynamic +endif +endif + + +# =========================================================================== +# Compiler flags +# =========================================================================== + +PSQLCH := +ifeq ($(PROXYSQLCLICKHOUSE),1) + PSQLCH := -DPROXYSQLCLICKHOUSE +endif +PSQLGA := +ifeq ($(PROXYSQLGENAI),1) + PSQLGA := -DPROXYSQLGENAI +endif +PSQL31 := +ifeq ($(PROXYSQL31),1) + PSQL31 := -DPROXYSQL31 +endif +PSQLFFTO := +ifeq ($(PROXYSQLFFTO),1) + PSQLFFTO := -DPROXYSQLFFTO +endif +PSQLTSDB := +ifeq ($(PROXYSQLTSDB),1) + PSQLTSDB := -DPROXYSQLTSDB +endif + +OPT := $(STDCPP) -O0 -ggdb $(PSQLCH) $(PSQLGA) $(PSQL31) $(PSQLFFTO) $(PSQLTSDB) \ + -DGITVERSION=\"$(GIT_VERSION)\" $(NOJEM) $(WGCOV) $(WASAN) \ + -Wl,--no-as-needed -Wl,-rpath,$(TAP_LDIR) + +ifeq ($(UNAME_S),Darwin) + OPT := $(STDCPP) -O0 -ggdb $(PSQLCH) $(PSQLGA) $(PSQL31) $(PSQLFFTO) $(PSQLTSDB) \ + -DGITVERSION=\"$(GIT_VERSION)\" $(NOJEM) $(WGCOV) $(WASAN) +endif + + +# =========================================================================== +# Test helper objects +# =========================================================================== + +TEST_HELPERS_DIR := $(PROXYSQL_PATH)/test/tap/test_helpers +ODIR := obj + +TEST_HELPERS_OBJ := $(ODIR)/test_globals.o $(ODIR)/test_init.o $(ODIR)/tap.o + +$(ODIR): + mkdir -p $(ODIR) + +# Compile tap.o directly from tap.cpp to avoid the full TAP build chain +# and its cpp-dotenv dependency (which doesn't build on macOS). +# Unit tests only need the core TAP functions: plan(), ok(), is(), etc. +TAP_SRC := $(TAP_PATH)/tap.cpp +$(ODIR)/tap.o: $(TAP_SRC) | $(ODIR) + $(CXX) -c -o $@ $< $(OPT) $(IDIRS) -w + +$(ODIR)/test_globals.o: $(TEST_HELPERS_DIR)/test_globals.cpp | $(ODIR) + $(CXX) -c -o $@ $< $(OPT) $(IDIRS) -Wall + +$(ODIR)/test_init.o: $(TEST_HELPERS_DIR)/test_init.cpp | $(ODIR) + $(CXX) -c -o $@ $< $(OPT) $(IDIRS) -Wall + + +# =========================================================================== +# Unit test targets +# =========================================================================== + +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 \ + pgsql_command_complete_unit-t \ + ffto_protocol_unit-t \ + server_selection_unit-t \ + hostgroup_routing_unit-t \ + transaction_state_unit-t \ + pgsql_error_classifier_unit-t \ + pgsql_monitor_unit-t \ + mysql_error_classifier_unit-t \ + backend_sync_unit-t + +.PHONY: all +all: $(UNIT_TESTS) + +.PHONY: debug +debug: OPT += -DDEBUG +debug: $(UNIT_TESTS) + +ALLOW_MULTI_DEF := +ifneq ($(UNAME_S),Darwin) + ALLOW_MULTI_DEF := -Wl,--allow-multiple-definition +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) \ + $(ALLOW_MULTI_DEF) -o $@ + + +# =========================================================================== +# Clean +# =========================================================================== + +.PHONY: clean +.SILENT: clean +clean: + rm -rf $(ODIR) $(UNIT_TESTS) diff --git a/test/tap/tests/unit/auth_unit-t.cpp b/test/tap/tests/unit/auth_unit-t.cpp new file mode 100644 index 0000000000..89ceef1ee3 --- /dev/null +++ b/test/tap/tests/unit/auth_unit-t.cpp @@ -0,0 +1,593 @@ +/** + * @file auth_unit-t.cpp + * @brief Unit tests for MySQL_Authentication and PgSQL_Authentication. + * + * Tests the authentication subsystem in isolation without a running + * ProxySQL instance. Covers: + * - Core CRUD: add, lookup, del, exists + * - Credential management: SHA1, clear-text passwords + * - Connection counting and max_connections enforcement + * - Bulk operations: set_all_inactive, remove_inactives, reset + * - Runtime checksums + * - Memory tracking + * - Frontend vs backend separation + * - PgSQL Authentication parity + * + * @see Phase 2.2 of the Unit Testing Framework (GitHub issue #5474) + */ + +#include "tap.h" +#include "test_globals.h" +#include "test_init.h" + +#include "proxysql.h" +#include "MySQL_Authentication.hpp" +#include "PgSQL_Authentication.h" + +#include +#include + +// Extern declarations for Glo* pointers (defined in test_globals.cpp) +extern MySQL_Authentication *GloMyAuth; +extern PgSQL_Authentication *GloPgAuth; + +// ============================================================================ +// Helper: add a MySQL frontend user with common defaults +// ============================================================================ +static bool mysql_add_frontend(MySQL_Authentication *auth, + const char *user, const char *pass, + int default_hg = 0, int max_conn = 100) +{ + return auth->add( + (char *)user, (char *)pass, USERNAME_FRONTEND, + false, // use_ssl + default_hg, // default_hostgroup + (char *)"", // default_schema + false, // schema_locked + false, // transaction_persistent + false, // fast_forward + max_conn, // max_connections + (char *)"", // attributes + (char *)"" // comment + ); +} + +// ============================================================================ +// Helper: add a MySQL backend user +// ============================================================================ +static bool mysql_add_backend(MySQL_Authentication *auth, + const char *user, const char *pass, + int default_hg = 0, int max_conn = 100) +{ + return auth->add( + (char *)user, (char *)pass, USERNAME_BACKEND, + false, default_hg, (char *)"", false, false, false, + max_conn, (char *)"", (char *)"" + ); +} + +// ============================================================================ +// Helper: add a PgSQL frontend user +// ============================================================================ +static bool pgsql_add_frontend(PgSQL_Authentication *auth, + const char *user, const char *pass, + int default_hg = 0, int max_conn = 100) +{ + return auth->add( + (char *)user, (char *)pass, USERNAME_FRONTEND, + false, // use_ssl + default_hg, // default_hostgroup + false, // transaction_persistent + false, // fast_forward + max_conn, // max_connections + (char *)"", // attributes + (char *)"" // comment + ); +} + +// ============================================================================ +// 1. MySQL_Authentication: Core CRUD +// ============================================================================ + +/** + * @brief Test basic add + exists + lookup cycle. + */ +static void test_mysql_add_exists_lookup() { + mysql_add_frontend(GloMyAuth, "alice", "pass123", 1, 50); + + ok(GloMyAuth->exists((char *)"alice") == true, + "MySQL: exists() returns true for added frontend user"); + + ok(GloMyAuth->exists((char *)"unknown") == false, + "MySQL: exists() returns false for nonexistent user"); + + // Lookup with dup options + dup_account_details_t dup = {true, true, true}; + account_details_t ad = GloMyAuth->lookup( + (char *)"alice", USERNAME_FRONTEND, dup); + + ok(ad.password != nullptr && strcmp(ad.password, "pass123") == 0, + "MySQL: lookup() returns correct password"); + ok(ad.default_hostgroup == 1, + "MySQL: lookup() returns correct default_hostgroup"); + ok(ad.max_connections == 50, + "MySQL: lookup() returns correct max_connections"); + + free_account_details(ad); +} + +/** + * @brief Test that exists() only checks frontends, not backends. + */ +static void test_mysql_exists_frontend_only() { + mysql_add_backend(GloMyAuth, "backend_only", "secret"); + + ok(GloMyAuth->exists((char *)"backend_only") == false, + "MySQL: exists() returns false for backend-only user"); + + // But lookup with USERNAME_BACKEND should find it + dup_account_details_t dup = {false, false, false}; + account_details_t ad = GloMyAuth->lookup( + (char *)"backend_only", USERNAME_BACKEND, dup); + + ok(ad.password != nullptr && strcmp(ad.password, "secret") == 0, + "MySQL: lookup(BACKEND) finds backend user"); + + free_account_details(ad); +} + +/** + * @brief Test that add() overwrites on duplicate username. + */ +static void test_mysql_add_overwrites() { + mysql_add_frontend(GloMyAuth, "bob", "old_pass", 1, 10); + mysql_add_frontend(GloMyAuth, "bob", "new_pass", 2, 20); + + dup_account_details_t dup = {true, false, false}; + account_details_t ad = GloMyAuth->lookup( + (char *)"bob", USERNAME_FRONTEND, dup); + + ok(ad.password != nullptr && strcmp(ad.password, "new_pass") == 0, + "MySQL: add() overwrites password on duplicate"); + ok(ad.default_hostgroup == 2, + "MySQL: add() overwrites default_hostgroup on duplicate"); + ok(ad.max_connections == 20, + "MySQL: add() overwrites max_connections on duplicate"); + + free_account_details(ad); +} + +/** + * @brief Test del() removes a user. + */ +static void test_mysql_del() { + mysql_add_frontend(GloMyAuth, "charlie", "pass"); + + ok(GloMyAuth->exists((char *)"charlie") == true, + "MySQL: user exists before del()"); + + bool deleted = GloMyAuth->del((char *)"charlie", USERNAME_FRONTEND); + ok(deleted == true, "MySQL: del() returns true for existing user"); + + ok(GloMyAuth->exists((char *)"charlie") == false, + "MySQL: user gone after del()"); + + bool deleted_again = GloMyAuth->del((char *)"charlie", USERNAME_FRONTEND); + ok(deleted_again == false, + "MySQL: del() returns false for already-deleted user"); +} + +/** + * @brief Test lookup() returns empty struct for nonexistent user. + */ +static void test_mysql_lookup_not_found() { + dup_account_details_t dup = {true, true, true}; + account_details_t ad = GloMyAuth->lookup( + (char *)"nonexistent", USERNAME_FRONTEND, dup); + + ok(ad.password == nullptr, + "MySQL: lookup() returns null password for nonexistent user"); + ok(ad.username == nullptr, + "MySQL: lookup() returns null username for nonexistent user"); +} + +// ============================================================================ +// 2. MySQL_Authentication: Credential Management +// ============================================================================ + +/** + * @brief Test set_SHA1() stores and retrieves SHA1 hash. + */ +static void test_mysql_sha1() { + mysql_add_frontend(GloMyAuth, "sha1user", "pass"); + + unsigned char sha1_hash[SHA_DIGEST_LENGTH]; + SHA1((unsigned char *)"pass", 4, sha1_hash); + + bool set_ok = GloMyAuth->set_SHA1( + (char *)"sha1user", USERNAME_FRONTEND, sha1_hash); + ok(set_ok == true, "MySQL: set_SHA1() returns true"); + + // Lookup with sha1 duplication + dup_account_details_t dup = {false, true, false}; + account_details_t ad = GloMyAuth->lookup( + (char *)"sha1user", USERNAME_FRONTEND, dup); + + ok(ad.sha1_pass != nullptr, "MySQL: SHA1 pass retrieved via lookup()"); + if (ad.sha1_pass != nullptr) { + ok(memcmp(ad.sha1_pass, sha1_hash, SHA_DIGEST_LENGTH) == 0, + "MySQL: SHA1 hash matches what was set"); + } else { + ok(0, "MySQL: SHA1 hash was unexpectedly null"); + } + + // set_SHA1 on nonexistent user + bool set_fail = GloMyAuth->set_SHA1( + (char *)"nobody", USERNAME_FRONTEND, sha1_hash); + ok(set_fail == false, + "MySQL: set_SHA1() returns false for nonexistent user"); + + free_account_details(ad); +} + +/** + * @brief Test set_clear_text_password() for PRIMARY and ADDITIONAL, + * and verify stored values are retrievable via lookup(). + */ +static void test_mysql_clear_text_password() { + mysql_add_frontend(GloMyAuth, "ctpuser", "original"); + + bool set_ok = GloMyAuth->set_clear_text_password( + (char *)"ctpuser", USERNAME_FRONTEND, + "clearpass", PASSWORD_TYPE::PRIMARY); + ok(set_ok == true, + "MySQL: set_clear_text_password(PRIMARY) returns true"); + + set_ok = GloMyAuth->set_clear_text_password( + (char *)"ctpuser", USERNAME_FRONTEND, + "altpass", PASSWORD_TYPE::ADDITIONAL); + ok(set_ok == true, + "MySQL: set_clear_text_password(ADDITIONAL) returns true"); + + // Verify stored clear-text passwords are retrievable + dup_account_details_t dup = {false, false, false}; + account_details_t ad = GloMyAuth->lookup( + (char *)"ctpuser", USERNAME_FRONTEND, dup); + ok(ad.clear_text_password[PASSWORD_TYPE::PRIMARY] != nullptr + && strcmp(ad.clear_text_password[PASSWORD_TYPE::PRIMARY], "clearpass") == 0, + "MySQL: PRIMARY clear_text_password retrievable via lookup()"); + ok(ad.clear_text_password[PASSWORD_TYPE::ADDITIONAL] != nullptr + && strcmp(ad.clear_text_password[PASSWORD_TYPE::ADDITIONAL], "altpass") == 0, + "MySQL: ADDITIONAL clear_text_password retrievable via lookup()"); + free_account_details(ad); + + bool set_fail = GloMyAuth->set_clear_text_password( + (char *)"nobody", USERNAME_FRONTEND, + "pass", PASSWORD_TYPE::PRIMARY); + ok(set_fail == false, + "MySQL: set_clear_text_password() returns false for nonexistent user"); +} + +// ============================================================================ +// 3. MySQL_Authentication: Connection Counting +// ============================================================================ + +/** + * @brief Test increase/decrease frontend user connections. + */ +static void test_mysql_connection_counting() { + mysql_add_frontend(GloMyAuth, "connuser", "pass", 0, 3); + + // Increase connections until limit + int remaining; + remaining = GloMyAuth->increase_frontend_user_connections( + (char *)"connuser", PASSWORD_TYPE::PRIMARY); + ok(remaining > 0, "MySQL: 1st connection: remaining > 0"); + + remaining = GloMyAuth->increase_frontend_user_connections( + (char *)"connuser", PASSWORD_TYPE::PRIMARY); + ok(remaining > 0, "MySQL: 2nd connection: remaining > 0"); + + remaining = GloMyAuth->increase_frontend_user_connections( + (char *)"connuser", PASSWORD_TYPE::PRIMARY); + ok(remaining > 0, "MySQL: 3rd connection: remaining > 0"); + + // At limit now — next should return 0 + remaining = GloMyAuth->increase_frontend_user_connections( + (char *)"connuser", PASSWORD_TYPE::PRIMARY); + ok(remaining == 0, "MySQL: 4th connection rejected (max_connections=3)"); + + // Decrease and verify we can connect again + GloMyAuth->decrease_frontend_user_connections( + (char *)"connuser", PASSWORD_TYPE::PRIMARY); + + remaining = GloMyAuth->increase_frontend_user_connections( + (char *)"connuser", PASSWORD_TYPE::PRIMARY); + ok(remaining > 0, + "MySQL: connection allowed after decrease"); +} + +// ============================================================================ +// 4. MySQL_Authentication: Bulk Operations +// ============================================================================ + +/** + * @brief Test set_all_inactive + remove_inactives pattern. + */ +static void test_mysql_inactive_pattern() { + // Start fresh + GloMyAuth->reset(); + + mysql_add_frontend(GloMyAuth, "keep_me", "pass1"); + mysql_add_frontend(GloMyAuth, "remove_me", "pass2"); + + // Mark all inactive + GloMyAuth->set_all_inactive(USERNAME_FRONTEND); + + // Re-add the one we want to keep (sets __active = true) + mysql_add_frontend(GloMyAuth, "keep_me", "pass1"); + + // Remove inactive users + GloMyAuth->remove_inactives(USERNAME_FRONTEND); + + ok(GloMyAuth->exists((char *)"keep_me") == true, + "MySQL: re-added user survives remove_inactives()"); + ok(GloMyAuth->exists((char *)"remove_me") == false, + "MySQL: inactive user removed by remove_inactives()"); +} + +/** + * @brief Test reset() clears all users. + */ +static void test_mysql_reset() { + mysql_add_frontend(GloMyAuth, "user1", "p1"); + mysql_add_frontend(GloMyAuth, "user2", "p2"); + mysql_add_backend(GloMyAuth, "user3", "p3"); + + GloMyAuth->reset(); + + ok(GloMyAuth->exists((char *)"user1") == false, + "MySQL: frontend user gone after reset()"); + + dup_account_details_t dup = {false, false, false}; + account_details_t ad = GloMyAuth->lookup( + (char *)"user3", USERNAME_BACKEND, dup); + ok(ad.password == nullptr, + "MySQL: backend user gone after reset()"); +} + +// ============================================================================ +// 5. MySQL_Authentication: Checksums +// ============================================================================ + +/** + * @brief Test runtime checksum behavior. + */ +static void test_mysql_checksums() { + GloMyAuth->reset(); + + uint64_t empty_checksum = GloMyAuth->get_runtime_checksum(); + ok(empty_checksum == 0, + "MySQL: checksum is 0 with no users"); + + mysql_add_frontend(GloMyAuth, "checksumA", "passA", 1); + uint64_t checksum1 = GloMyAuth->get_runtime_checksum(); + ok(checksum1 != 0, + "MySQL: checksum is non-zero with users"); + + mysql_add_frontend(GloMyAuth, "checksumB", "passB", 2); + uint64_t checksum2 = GloMyAuth->get_runtime_checksum(); + ok(checksum2 != checksum1, + "MySQL: checksum changes when users are added"); + + // Modify a user and verify checksum changes + mysql_add_frontend(GloMyAuth, "checksumA", "passA_changed", 1); + uint64_t checksum3 = GloMyAuth->get_runtime_checksum(); + ok(checksum3 != checksum2, + "MySQL: checksum changes when password is modified"); +} + +// ============================================================================ +// 6. MySQL_Authentication: Memory Usage +// ============================================================================ + +/** + * @brief Test memory_usage() tracking. + */ +static void test_mysql_memory() { + GloMyAuth->reset(); + + unsigned int mem_empty = GloMyAuth->memory_usage(); + + mysql_add_frontend(GloMyAuth, "memuser1", "password1"); + unsigned int mem_one = GloMyAuth->memory_usage(); + ok(mem_one > mem_empty, + "MySQL: memory_usage() increases after add()"); + + mysql_add_frontend(GloMyAuth, "memuser2", "password2_longer"); + unsigned int mem_two = GloMyAuth->memory_usage(); + ok(mem_two > mem_one, + "MySQL: memory_usage() increases with more users"); + + GloMyAuth->reset(); + unsigned int mem_after_reset = GloMyAuth->memory_usage(); + ok(mem_after_reset <= mem_empty, + "MySQL: memory_usage() returns to baseline after reset()"); +} + +// ============================================================================ +// 7. MySQL_Authentication: Frontend vs Backend Separation +// ============================================================================ + +/** + * @brief Test that frontend and backend are independent. + */ +static void test_mysql_frontend_backend_separation() { + GloMyAuth->reset(); + + // Same username in both frontend and backend with different passwords + mysql_add_frontend(GloMyAuth, "dualuser", "frontend_pass", 1); + mysql_add_backend(GloMyAuth, "dualuser", "backend_pass", 2); + + dup_account_details_t dup = {false, false, false}; + + account_details_t fe = GloMyAuth->lookup( + (char *)"dualuser", USERNAME_FRONTEND, dup); + ok(fe.password != nullptr && strcmp(fe.password, "frontend_pass") == 0, + "MySQL: frontend lookup returns frontend password"); + ok(fe.default_hostgroup == 1, + "MySQL: frontend lookup returns frontend hostgroup"); + + account_details_t be = GloMyAuth->lookup( + (char *)"dualuser", USERNAME_BACKEND, dup); + ok(be.password != nullptr && strcmp(be.password, "backend_pass") == 0, + "MySQL: backend lookup returns backend password"); + ok(be.default_hostgroup == 2, + "MySQL: backend lookup returns backend hostgroup"); + + free_account_details(fe); + free_account_details(be); +} + +// ============================================================================ +// 8. PgSQL_Authentication: Core CRUD +// ============================================================================ + +/** + * @brief Test PgSQL basic add + exists + lookup cycle. + */ +static void test_pgsql_add_exists_lookup() { + pgsql_add_frontend(GloPgAuth, "pg_alice", "pgpass", 1, 50); + + ok(GloPgAuth->exists((char *)"pg_alice") == true, + "PgSQL: exists() returns true for added frontend user"); + ok(GloPgAuth->exists((char *)"pg_unknown") == false, + "PgSQL: exists() returns false for nonexistent user"); + + // PgSQL lookup has different signature — returns password string + bool use_ssl = false; + int default_hg = -1, max_conn = -1; + bool trans_persist = false, fast_fwd = false; + void *sha1 = nullptr; + char *attrs = nullptr; + + char *password = GloPgAuth->lookup( + (char *)"pg_alice", USERNAME_FRONTEND, + &use_ssl, &default_hg, &trans_persist, &fast_fwd, + &max_conn, &sha1, &attrs); + + ok(password != nullptr && strcmp(password, "pgpass") == 0, + "PgSQL: lookup() returns correct password"); + ok(default_hg == 1, + "PgSQL: lookup() returns correct default_hostgroup"); + ok(max_conn == 50, + "PgSQL: lookup() returns correct max_connections"); + + if (password) free(password); + if (attrs) free(attrs); + if (sha1) free(sha1); +} + +/** + * @brief Test PgSQL del() and reset(). + */ +static void test_pgsql_del_and_reset() { + pgsql_add_frontend(GloPgAuth, "pg_del", "pass"); + ok(GloPgAuth->exists((char *)"pg_del") == true, + "PgSQL: user exists before del()"); + + bool del_ok = GloPgAuth->del((char *)"pg_del", USERNAME_FRONTEND); + ok(del_ok == true, "PgSQL: del() returns true"); + ok(GloPgAuth->exists((char *)"pg_del") == false, + "PgSQL: user gone after del()"); + + // Test reset + pgsql_add_frontend(GloPgAuth, "pg_r1", "p1"); + pgsql_add_frontend(GloPgAuth, "pg_r2", "p2"); + GloPgAuth->reset(); + ok(GloPgAuth->exists((char *)"pg_r1") == false, + "PgSQL: user gone after reset()"); +} + +/** + * @brief Test PgSQL connection counting. + */ +static void test_pgsql_connection_counting() { + GloPgAuth->reset(); + pgsql_add_frontend(GloPgAuth, "pg_conn", "pass", 0, 2); + + int r1 = GloPgAuth->increase_frontend_user_connections( + (char *)"pg_conn"); + ok(r1 > 0, "PgSQL: 1st connection allowed"); + + int r2 = GloPgAuth->increase_frontend_user_connections( + (char *)"pg_conn"); + ok(r2 > 0, "PgSQL: 2nd connection allowed"); + + int r3 = GloPgAuth->increase_frontend_user_connections( + (char *)"pg_conn"); + ok(r3 == 0, "PgSQL: 3rd connection rejected (max=2)"); + + GloPgAuth->decrease_frontend_user_connections((char *)"pg_conn"); + int r4 = GloPgAuth->increase_frontend_user_connections( + (char *)"pg_conn"); + ok(r4 > 0, "PgSQL: connection allowed after decrease"); +} + +/** + * @brief Test PgSQL inactive pattern. + */ +static void test_pgsql_inactive_pattern() { + GloPgAuth->reset(); + pgsql_add_frontend(GloPgAuth, "pg_keep", "p1"); + pgsql_add_frontend(GloPgAuth, "pg_drop", "p2"); + + GloPgAuth->set_all_inactive(USERNAME_FRONTEND); + pgsql_add_frontend(GloPgAuth, "pg_keep", "p1"); + GloPgAuth->remove_inactives(USERNAME_FRONTEND); + + ok(GloPgAuth->exists((char *)"pg_keep") == true, + "PgSQL: re-added user survives remove_inactives()"); + ok(GloPgAuth->exists((char *)"pg_drop") == false, + "PgSQL: inactive user removed"); +} + + +// ============================================================================ +// Main +// ============================================================================ + +int main() { + plan(60); + + test_init_minimal(); + test_init_auth(); + + // MySQL tests + test_mysql_add_exists_lookup(); // 5 tests + test_mysql_exists_frontend_only(); // 2 tests + test_mysql_add_overwrites(); // 3 tests + test_mysql_del(); // 4 tests + test_mysql_lookup_not_found(); // 2 tests + test_mysql_sha1(); // 4 tests + test_mysql_clear_text_password(); // 3 tests + test_mysql_connection_counting(); // 5 tests + test_mysql_inactive_pattern(); // 2 tests + test_mysql_reset(); // 2 tests + test_mysql_checksums(); // 4 tests + test_mysql_memory(); // 3 tests + test_mysql_frontend_backend_separation();// 4 tests + + // PgSQL tests + test_pgsql_add_exists_lookup(); // 5 tests + test_pgsql_del_and_reset(); // 4 tests (49-52) + test_pgsql_connection_counting(); // 4 tests (53-56) + test_pgsql_inactive_pattern(); // 2 tests (57-58) + // Note: PgSQL does not have set_clear_text_password() + // Note: PgSQL does not have default_schema or schema_locked + + test_cleanup_auth(); + test_cleanup_minimal(); + + return exit_status(); +} 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 0000000000..8dd26dbe2d --- /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/connection_pool_unit-t.cpp b/test/tap/tests/unit/connection_pool_unit-t.cpp new file mode 100644 index 0000000000..21b7a13644 --- /dev/null +++ b/test/tap/tests/unit/connection_pool_unit-t.cpp @@ -0,0 +1,148 @@ +/** + * @file connection_pool_unit-t.cpp + * @brief Unit tests for connection pool decision functions. + * + * Tests the pure functions extracted from get_random_MyConn(): + * - calculate_eviction_count() + * - should_throttle_connection_creation() + * - evaluate_pool_state() + * + * These functions have no global state dependencies and are linked + * from libproxysql.a via the unit test harness. + * + * @see Phase 3.1 (GitHub issue #5489) + */ + +#include "tap.h" +#include "test_globals.h" +#include "test_init.h" + +#include "proxysql.h" +#include "ConnectionPoolDecision.h" + +// ============================================================================ +// 1. calculate_eviction_count +// ============================================================================ + +static void test_eviction_below_threshold() { + ok(calculate_eviction_count(10, 10, 100) == 0, + "eviction: no eviction when total=20, below 75%% of 100"); +} + +static void test_eviction_at_threshold() { + unsigned int c = calculate_eviction_count(50, 25, 100); + ok(c >= 1, "eviction: at least 1 at 75%% threshold (got %u)", c); +} + +static void test_eviction_above_threshold() { + ok(calculate_eviction_count(60, 30, 100) == 15, + "eviction: evict 15 when total=90, max=100"); +} + +static void test_eviction_no_free() { + ok(calculate_eviction_count(0, 80, 100) == 0, + "eviction: 0 when no free connections"); +} + +static void test_eviction_max_zero() { + ok(calculate_eviction_count(5, 0, 0) >= 1, + "eviction: evicts when max_connections=0"); +} + +static void test_eviction_max_one() { + ok(calculate_eviction_count(1, 0, 1) >= 1, + "eviction: evicts when max_connections=1"); +} + +// ============================================================================ +// 2. should_throttle_connection_creation +// ============================================================================ + +static void test_throttle() { + ok(should_throttle_connection_creation(0, 10) == false, + "throttle: not throttled at 0/10"); + ok(should_throttle_connection_creation(10, 10) == false, + "throttle: not throttled at limit"); + ok(should_throttle_connection_creation(11, 10) == true, + "throttle: throttled above limit"); + ok(should_throttle_connection_creation(100, 0) == true, + "throttle: throttled when limit=0"); +} + +// ============================================================================ +// 3. evaluate_pool_state +// ============================================================================ + +static void test_pool_quality_0() { + auto d = evaluate_pool_state(5, 5, 100, 0, false, 0); + ok(d.create_new_connection == true, + "pool q=0: creates new connection"); +} + +static void test_pool_quality_0_evict() { + auto d = evaluate_pool_state(50, 30, 100, 0, false, 0); + ok(d.create_new_connection == true, "pool q=0 full: creates"); + ok(d.evict_connections == true, "pool q=0 full: evicts"); + ok(d.num_to_evict > 0, "pool q=0 full: num_to_evict > 0"); +} + +static void test_pool_quality_1_create() { + auto d = evaluate_pool_state(2, 10, 100, 1, false, 0); + ok(d.create_new_connection == true, + "pool q=1: creates when used > free"); +} + +static void test_pool_quality_1_reuse() { + auto d = evaluate_pool_state(10, 5, 100, 1, false, 0); + ok(d.create_new_connection == false, + "pool q=1: reuses when free >= used"); +} + +static void test_pool_quality_2_3() { + ok(evaluate_pool_state(10, 10, 100, 2, false, 0).create_new_connection == false, + "pool q=2: reuses"); + ok(evaluate_pool_state(10, 10, 100, 3, false, 0).create_new_connection == false, + "pool q=3: reuses"); +} + +static void test_pool_warming() { + auto d = evaluate_pool_state(5, 5, 100, 3, true, 50); + ok(d.needs_warming == true, "warming: below threshold"); + ok(d.create_new_connection == true, "warming: creates"); + + auto d2 = evaluate_pool_state(30, 30, 100, 3, true, 10); + ok(d2.needs_warming == false, "warming: above threshold"); +} + +static void test_pool_empty() { + auto d = evaluate_pool_state(0, 0, 100, 0, false, 0); + ok(d.create_new_connection == true, "empty pool: creates"); +} + +// ============================================================================ +// Main +// ============================================================================ + +int main() { + plan(23); + int rc = test_init_minimal(); + ok(rc == 0, "test_init_minimal() succeeds"); + + test_eviction_below_threshold(); // 1 + test_eviction_at_threshold(); // 1 + test_eviction_above_threshold(); // 1 + test_eviction_no_free(); // 1 + test_eviction_max_zero(); // 1 + test_eviction_max_one(); // 1 + test_throttle(); // 4 + test_pool_quality_0(); // 1 + test_pool_quality_0_evict(); // 3 + test_pool_quality_1_create(); // 1 + test_pool_quality_1_reuse(); // 1 + test_pool_quality_2_3(); // 2 + test_pool_warming(); // 3 + test_pool_empty(); // 1 + + test_cleanup_minimal(); + return exit_status(); +} diff --git a/test/tap/tests/unit/ffto_protocol_unit-t.cpp b/test/tap/tests/unit/ffto_protocol_unit-t.cpp new file mode 100644 index 0000000000..c7e6a11968 --- /dev/null +++ b/test/tap/tests/unit/ffto_protocol_unit-t.cpp @@ -0,0 +1,291 @@ +/** + * @file ffto_protocol_unit-t.cpp + * @brief Comprehensive unit tests for FFTO protocol parsing utilities. + * + * Tests both MySQL and PgSQL protocol parsing functions used by FFTO: + * - MySQL: read_lenenc_int, packet building, OK packet parsing + * - PgSQL: CommandComplete tag parsing + * - Both: fragmented data reassembly simulation, large payloads + * + * @see FFTO unit testing (GitHub issue #5499) + */ + +#include "tap.h" +#include "test_globals.h" +#include "test_init.h" +#include "proxysql.h" +#include "MySQLProtocolUtils.h" +#include "PgSQLCommandComplete.h" + +#include +#include + +// ============================================================================ +// 1. MySQL: read_lenenc_int +// ============================================================================ + +static void test_mysql_lenenc_1byte() { + unsigned char buf[] = {0}; + const unsigned char *p = buf; size_t len = 1; + ok(mysql_read_lenenc_int(p, len) == 0, "lenenc: 0x00 → 0"); + ok(len == 0 && p == buf + 1, "lenenc: consumed 1 byte"); + + unsigned char buf2[] = {250}; + p = buf2; len = 1; + ok(mysql_read_lenenc_int(p, len) == 250, "lenenc: 0xFA → 250 (max 1-byte)"); +} + +static void test_mysql_lenenc_2byte() { + unsigned char buf[] = {0xFC, 0x01, 0x00}; + const unsigned char *p = buf; size_t len = 3; + ok(mysql_read_lenenc_int(p, len) == 1, "lenenc 2-byte: 0xFC 01 00 → 1"); + ok(len == 0, "lenenc 2-byte: consumed 3 bytes"); + + unsigned char buf2[] = {0xFC, 0xFF, 0xFF}; + p = buf2; len = 3; + ok(mysql_read_lenenc_int(p, len) == 65535, "lenenc 2-byte: max → 65535"); +} + +static void test_mysql_lenenc_3byte() { + unsigned char buf[] = {0xFD, 0x00, 0x00, 0x01, 0x00}; // padded for CPY safety + const unsigned char *p = buf; size_t len = 4; + ok(mysql_read_lenenc_int(p, len) == 65536, "lenenc 3-byte: → 65536"); +} + +static void test_mysql_lenenc_8byte() { + unsigned char buf[9] = {0xFE, 0x01, 0, 0, 0, 0, 0, 0, 0}; + const unsigned char *p = buf; size_t len = 9; + ok(mysql_read_lenenc_int(p, len) == 1, "lenenc 8-byte: → 1"); + + unsigned char buf2[9] = {0xFE, 0xFF, 0xFF, 0xFF, 0xFF, 0, 0, 0, 0}; + p = buf2; len = 9; + ok(mysql_read_lenenc_int(p, len) == 0xFFFFFFFF, "lenenc 8-byte: → 4294967295"); +} + +static void test_mysql_lenenc_truncated() { + // 2-byte prefix but only 1 byte of data + unsigned char buf[] = {0xFC, 0x01}; + const unsigned char *p = buf; size_t len = 2; + ok(mysql_read_lenenc_int(p, len) == 0, "lenenc truncated: 0xFC with 1 byte → 0"); + + // Empty buffer + const unsigned char *p2 = nullptr; size_t len2 = 0; + ok(mysql_read_lenenc_int(p2, len2) == 0, "lenenc empty: → 0"); +} + +// ============================================================================ +// 2. MySQL: packet building +// ============================================================================ + +static void test_mysql_build_packet() { + unsigned char payload[] = {0x03, 'S', 'E', 'L', 'E', 'C', 'T', ' ', '1'}; + unsigned char out[13]; + size_t total = mysql_build_packet(payload, 9, 0, out); + ok(total == 13, "build packet: total size 13"); + ok(out[0] == 9 && out[1] == 0 && out[2] == 0, "build packet: length = 9"); + ok(out[3] == 0, "build packet: seq_id = 0"); + ok(memcmp(out + 4, payload, 9) == 0, "build packet: payload intact"); +} + +static void test_mysql_build_large_packet() { + // Build a packet with 1000-byte payload + std::vector payload(1000, 'X'); + std::vector out(1004); + size_t total = mysql_build_packet(payload.data(), 1000, 5, out.data()); + ok(total == 1004, "large packet: total size 1004"); + ok(out[0] == 0xE8 && out[1] == 0x03 && out[2] == 0x00, + "large packet: length = 1000 (little-endian)"); + ok(out[3] == 5, "large packet: seq_id = 5"); +} + +static void test_mysql_build_empty_packet() { + unsigned char out[4]; + size_t total = mysql_build_packet(nullptr, 0, 1, out); + ok(total == 4, "empty packet: header only"); + ok(out[0] == 0 && out[1] == 0 && out[2] == 0, "empty packet: length = 0"); +} + +// ============================================================================ +// 3. MySQL: OK packet affected_rows extraction +// ============================================================================ + +static void test_mysql_ok_affected_rows() { + // Build an OK packet: 0x00 + affected_rows(lenenc) + last_insert_id(lenenc) + // affected_rows = 42 + unsigned char ok_payload[] = {0x00, 42, 0}; // OK, affected=42, last_insert=0 + unsigned char pkt[7]; + mysql_build_packet(ok_payload, 3, 1, pkt); + + // Parse affected_rows from the OK packet payload + const unsigned char *pos = ok_payload + 1; + size_t rem = 2; + uint64_t affected = mysql_read_lenenc_int(pos, rem); + ok(affected == 42, "OK packet: affected_rows = 42"); +} + +static void test_mysql_ok_large_affected_rows() { + // affected_rows = 300 (needs 0xFC prefix) + unsigned char ok_payload[] = {0x00, 0xFC, 0x2C, 0x01, 0}; + const unsigned char *pos = ok_payload + 1; + size_t rem = 4; + uint64_t affected = mysql_read_lenenc_int(pos, rem); + ok(affected == 300, "OK packet: affected_rows = 300 (2-byte lenenc)"); +} + +// ============================================================================ +// 4. PgSQL: CommandComplete — extended tests +// ============================================================================ + +static void test_pgsql_insert_with_oid() { + // "INSERT oid count" format — the count is always the last token + auto r = parse_pgsql_command_complete( + (const unsigned char *)"INSERT 12345 50", 15); + ok(r.rows == 50 && r.is_select == false, + "PgSQL INSERT with OID: rows=50 (last token)"); +} + +static void test_pgsql_large_row_count() { + auto r = parse_pgsql_command_complete( + (const unsigned char *)"SELECT 1000000", 14); + ok(r.rows == 1000000 && r.is_select == true, + "PgSQL large SELECT: rows=1000000"); +} + +static void test_pgsql_zero_rows() { + auto r = parse_pgsql_command_complete( + (const unsigned char *)"UPDATE 0", 8); + ok(r.rows == 0 && r.is_select == false, "PgSQL UPDATE 0: rows=0"); + + auto r2 = parse_pgsql_command_complete( + (const unsigned char *)"SELECT 0", 8); + ok(r2.rows == 0 && r2.is_select == true, "PgSQL SELECT 0: rows=0"); +} + +static void test_pgsql_all_command_types() { + struct { const char *tag; uint64_t expected; bool is_sel; } cases[] = { + {"INSERT 0 1", 1, false}, + {"UPDATE 5", 5, false}, + {"DELETE 3", 3, false}, + {"SELECT 10", 10, true}, + {"FETCH 7", 7, true}, + {"MOVE 2", 2, true}, + {"COPY 100", 100, false}, + {"MERGE 8", 8, false}, + }; + int pass = 0; + for (auto &c : cases) { + auto r = parse_pgsql_command_complete( + (const unsigned char *)c.tag, strlen(c.tag)); + if (r.rows == c.expected && r.is_select == c.is_sel) pass++; + } + ok(pass == 8, "PgSQL all 8 command types parse correctly"); +} + +static void test_pgsql_ddl_commands() { + const char *ddls[] = { + "CREATE TABLE", "ALTER TABLE", "DROP TABLE", + "CREATE INDEX", "DROP INDEX", "VACUUM", + "TRUNCATE TABLE", "GRANT", "REVOKE", + }; + int pass = 0; + for (auto &ddl : ddls) { + auto r = parse_pgsql_command_complete( + (const unsigned char *)ddl, strlen(ddl)); + if (r.rows == 0) pass++; + } + ok(pass == 9, "PgSQL DDL commands all return rows=0"); +} + +static void test_pgsql_null_terminated_payload() { + // Payload with null terminator (common in real wire format) + unsigned char payload[] = {'S', 'E', 'L', 'E', 'C', 'T', ' ', '5', '\0'}; + auto r = parse_pgsql_command_complete(payload, 9); + ok(r.rows == 5, "PgSQL null-terminated: SELECT 5 → rows=5"); +} + +// ============================================================================ +// 5. Fragmented data simulation +// ============================================================================ + +static void test_mysql_fragmented_lenenc() { + // Simulate reading a lenenc int where data arrives in chunks + // Build a 3-byte lenenc (0xFD prefix + 3 bytes) + unsigned char full[] = {0xFD, 0x40, 0x42, 0x0F}; // = 999,999 + 1 (not quite, but valid) + + // First chunk: just the prefix + const unsigned char *p = full; size_t len = 1; + uint64_t val = mysql_read_lenenc_int(p, len); + // With only 1 byte, the 0xFD prefix is consumed but there aren't 3 bytes after → returns 0 + ok(val == 0, "fragmented: 0xFD with no data bytes → 0 (truncated)"); + + // Full data + p = full; len = 4; + val = mysql_read_lenenc_int(p, len); + ok(val > 0, "fragmented: full 3-byte lenenc decoded successfully"); +} + +static void test_mysql_multi_packet_build() { + // Build 3 packets sequentially (simulating a multi-packet stream) + unsigned char stream[64]; + size_t offset = 0; + + unsigned char p1[] = {0x03, 'S', 'E', 'L'}; + offset += mysql_build_packet(p1, 4, 0, stream + offset); + + unsigned char p2[] = {0x01, 0x02, 0x03}; + offset += mysql_build_packet(p2, 3, 1, stream + offset); + + unsigned char p3[] = {0xFE, 0x00, 0x00, 0x00, 0x00}; + offset += mysql_build_packet(p3, 5, 2, stream + offset); + + ok(offset == 4+4 + 3+4 + 5+4, "multi-packet: total stream size correct"); + + // Verify each packet header + ok(stream[0] == 4 && stream[3] == 0, "multi-packet: pkt 0 header ok"); + ok(stream[8] == 3 && stream[11] == 1, "multi-packet: pkt 1 header ok"); + ok(stream[15] == 5 && stream[18] == 2, "multi-packet: pkt 2 header ok"); +} + +// ============================================================================ +// Main +// ============================================================================ + +int main() { + plan(36); + int rc = test_init_minimal(); + ok(rc == 0, "test_init_minimal() succeeds"); + + // MySQL lenenc + test_mysql_lenenc_1byte(); // 3 + test_mysql_lenenc_2byte(); // 3 + test_mysql_lenenc_3byte(); // 1 + test_mysql_lenenc_8byte(); // 2 + test_mysql_lenenc_truncated(); // 2 + + // MySQL packet building + test_mysql_build_packet(); // 4 + test_mysql_build_large_packet(); // 3 + test_mysql_build_empty_packet(); // 2 + + // MySQL OK packet parsing + test_mysql_ok_affected_rows(); // 1 + test_mysql_ok_large_affected_rows(); // 1 + + // PgSQL extended + test_pgsql_insert_with_oid(); // 1 + test_pgsql_large_row_count(); // 1 + test_pgsql_zero_rows(); // 2 + test_pgsql_all_command_types(); // 1 + test_pgsql_ddl_commands(); // 1 + test_pgsql_null_terminated_payload(); // 1 + + // Fragmentation + test_mysql_fragmented_lenenc(); // 2 + test_mysql_multi_packet_build(); // 4 + // Total: 1+3+3+1+2+2+4+3+2+1+1+1+1+2+1+1+1+2+4 = 36... recount + // 1 (init) + 3+3+1+2+2 (lenenc=11) + 4+3+2 (build=9) + 1+1 (ok=2) + + // 1+1+2+1+1+1 (pgsql=7) + 2+4 (frag=6) = 1+11+9+2+7+6 = 36 + + test_cleanup_minimal(); + return exit_status(); +} diff --git a/test/tap/tests/unit/hostgroup_routing_unit-t.cpp b/test/tap/tests/unit/hostgroup_routing_unit-t.cpp new file mode 100644 index 0000000000..1d07bd1a30 --- /dev/null +++ b/test/tap/tests/unit/hostgroup_routing_unit-t.cpp @@ -0,0 +1,110 @@ +/** + * @file hostgroup_routing_unit-t.cpp + * @brief Unit tests for hostgroup routing decision logic. + * + * @see Phase 3.5 (GitHub issue #5493) + */ + +#include "tap.h" +#include "test_globals.h" +#include "test_init.h" +#include "proxysql.h" +#include "HostgroupRouting.h" + +static void test_basic_routing() { + // No transaction, no lock → uses QP destination + auto d = resolve_hostgroup_routing(0, 5, -1, -1, false, false); + ok(d.target_hostgroup == 5, "basic: QP destination used"); + ok(d.error == false, "basic: no error"); + + // QP destination -1 → uses default + auto d2 = resolve_hostgroup_routing(0, -1, -1, -1, false, false); + ok(d2.target_hostgroup == 0, "basic: default HG when QP=-1"); +} + +static void test_transaction_affinity() { + // Transaction active → overrides QP destination + auto d = resolve_hostgroup_routing(0, 5, 3, -1, false, false); + ok(d.target_hostgroup == 3, + "txn: transaction_persistent_hostgroup overrides QP"); + + // Transaction + QP both set → transaction wins + auto d2 = resolve_hostgroup_routing(0, 10, 7, -1, false, false); + ok(d2.target_hostgroup == 7, "txn: transaction wins over QP"); +} + +static void test_locking_acquire() { + // Lock enabled, lock_hostgroup=true, not yet locked → acquires lock + auto d = resolve_hostgroup_routing(0, 5, -1, -1, true, true); + ok(d.target_hostgroup == 5, "lock acquire: routes to QP dest"); + ok(d.new_locked_on_hostgroup == 5, + "lock acquire: lock set to target HG"); + ok(d.error == false, "lock acquire: no error"); +} + +static void test_locking_enforce() { + // Already locked on HG 5, QP routes to 5 → ok + auto d = resolve_hostgroup_routing(0, 5, -1, 5, false, true); + ok(d.target_hostgroup == 5, "lock enforce: same HG ok"); + ok(d.error == false, "lock enforce: no error on match"); + + // Already locked on HG 5, QP routes to 10 → error + auto d2 = resolve_hostgroup_routing(0, 10, -1, 5, false, true); + ok(d2.error == true, "lock enforce: error on HG mismatch"); + ok(d2.target_hostgroup == 5, + "lock enforce: stays on locked HG despite QP mismatch"); +} + +static void test_locking_disabled() { + // Lock feature disabled → no locking even with flag + auto d = resolve_hostgroup_routing(0, 5, -1, -1, true, false); + ok(d.new_locked_on_hostgroup == -1, + "lock disabled: no lock acquired"); + + // Lock feature disabled → no enforcement even if locked state passed + auto d2 = resolve_hostgroup_routing(0, 10, -1, 5, false, false); + ok(d2.target_hostgroup == 10, + "lock disabled: routes to QP dest ignoring lock"); + ok(d2.error == false, "lock disabled: no error"); +} + +static void test_transaction_plus_lock() { + // Transaction active + locked on same HG → no error + auto d = resolve_hostgroup_routing(0, 10, 3, 3, false, true); + ok(d.target_hostgroup == 3, + "txn+lock: transaction HG used"); + ok(d.error == false, "txn+lock: no error when txn matches lock"); + + // Transaction active on HG 3 but locked on HG 5 → error (mismatch) + auto d2 = resolve_hostgroup_routing(0, 10, 3, 5, false, true); + ok(d2.error == true, + "txn+lock: error when txn HG differs from lock HG"); +} + +static void test_edge_cases() { + // default_hostgroup=-1 (shouldn't happen but handle gracefully) + auto d = resolve_hostgroup_routing(-1, -1, -1, -1, false, false); + ok(d.target_hostgroup == -1, "edge: negative defaults pass through"); + + // All zeros + auto d2 = resolve_hostgroup_routing(0, 0, -1, -1, false, false); + ok(d2.target_hostgroup == 0, "edge: HG 0 is valid"); +} + +int main() { + plan(21); + int rc = test_init_minimal(); + ok(rc == 0, "test_init_minimal() succeeds"); + + test_basic_routing(); // 3 + test_transaction_affinity(); // 2 + test_locking_acquire(); // 3 + test_locking_enforce(); // 4 + test_locking_disabled(); // 3 + test_transaction_plus_lock(); // 3 + test_edge_cases(); // 2 + // Total: 1+3+2+3+4+3+3+2 = 21 + + test_cleanup_minimal(); + return exit_status(); +} diff --git a/test/tap/tests/unit/hostgroups_unit-t.cpp b/test/tap/tests/unit/hostgroups_unit-t.cpp new file mode 100644 index 0000000000..0e39840446 --- /dev/null +++ b/test/tap/tests/unit/hostgroups_unit-t.cpp @@ -0,0 +1,273 @@ +/** + * @file hostgroups_unit-t.cpp + * @brief Unit tests for MySQL_HostGroups_Manager and PgSQL_HostGroups_Manager. + * + * Tests the HostGroups Manager server management in isolation: + * - Server creation and removal via create_new_server_in_hg / remove_server_in_hg + * - Server status transitions (ONLINE, SHUNNED, OFFLINE_SOFT, OFFLINE_HARD) + * - Server property updates (latency, status) + * - Multiple hostgroups independence + * - PgSQL HostGroups Manager parity + * + * These tests use the real MySQL_HostGroups_Manager with its internal + * SQLite3 database but do not create real network connections. + * + * @see Phase 2.6 of the Unit Testing Framework (GitHub issue #5478) + */ + +#include "tap.h" +#include "test_globals.h" +#include "test_init.h" + +#include "proxysql.h" +#include "cpp.h" + +// Extern declarations (defined in test_globals.cpp) +extern MySQL_HostGroups_Manager *MyHGM; +extern PgSQL_HostGroups_Manager *PgHGM; + +// ============================================================================ +// Helpers +// ============================================================================ + +/** + * @brief Add a MySQL server to a hostgroup using the manager API. + * @return 0 on success, -1 on failure. + */ +static int add_mysql_server(int hg, const char *addr, int port, + int weight = 1, int max_conns = 100) +{ + srv_info_t info; + info.addr = addr; + info.port = port; + info.kind = "test"; + + srv_opts_t opts; + opts.weigth = weight; + opts.max_conns = max_conns; + opts.use_ssl = 0; + + MyHGM->wrlock(); + int rc = MyHGM->create_new_server_in_hg(hg, info, opts); + MyHGM->wrunlock(); + return rc; +} + +/** + * @brief Remove a MySQL server from a hostgroup. + * @return 0 on success, -1 on failure. + */ +static int remove_mysql_server(int hg, const char *addr, int port) { + MyHGM->wrlock(); + int rc = MyHGM->remove_server_in_hg(hg, std::string(addr), port); + MyHGM->wrunlock(); + return rc; +} + +// ============================================================================ +// 1. Server creation and removal +// ============================================================================ + +/** + * @brief Test creating a server in a hostgroup. + */ +static void test_mysql_create_server() { + int rc = add_mysql_server(10, "127.0.0.1", 3306, 1, 100); + ok(rc == 0, "MySQL HGM: create_new_server_in_hg() returns 0"); + + // Add a second server to same hostgroup + rc = add_mysql_server(10, "127.0.0.2", 3306, 2, 200); + ok(rc == 0, "MySQL HGM: second server added to same hostgroup"); + + // Add server to different hostgroup + rc = add_mysql_server(20, "127.0.0.3", 3307, 1, 50); + ok(rc == 0, "MySQL HGM: server added to different hostgroup"); +} + +/** + * @brief Test removing a server from a hostgroup. + */ +static void test_mysql_remove_server() { + // First add a server + add_mysql_server(30, "10.0.0.1", 3306); + + // Remove it + int rc = remove_mysql_server(30, "10.0.0.1", 3306); + ok(rc == 0, "MySQL HGM: remove_server_in_hg() returns 0"); + + // Remove non-existent server + rc = remove_mysql_server(30, "10.0.0.99", 3306); + ok(rc == -1, "MySQL HGM: remove non-existent server returns -1"); +} + +// ============================================================================ +// 2. Server status transitions +// ============================================================================ + +/** + * @brief Test shun_and_killall via the manager. + */ +static void test_mysql_shun_and_killall() { + add_mysql_server(40, "192.168.1.1", 3306); + + // shun_and_killall acquires its own write lock internally + bool shunned = MyHGM->shun_and_killall( + (char *)"192.168.1.1", 3306); + ok(shunned == true, + "MySQL HGM: shun_and_killall() returns true for existing server"); + + // shun_and_killall on non-existent server + bool not_found = MyHGM->shun_and_killall( + (char *)"10.10.10.10", 9999); + ok(not_found == false, + "MySQL HGM: shun_and_killall() returns false for non-existent server"); +} + +// ============================================================================ +// 3. Server latency tracking +// ============================================================================ + +/** + * @brief Test setting server latency via the manager. + */ +static void test_mysql_latency() { + add_mysql_server(50, "172.16.0.1", 3306); + + // set_server_current_latency_us acquires its own write lock + MyHGM->set_server_current_latency_us( + (char *)"172.16.0.1", 3306, 5000); // 5ms latency + + // No crash = success; the value is stored on the MySrvC object + ok(1, "MySQL HGM: set_server_current_latency_us() succeeds"); +} + +// ============================================================================ +// 4. Multiple hostgroups independence +// ============================================================================ + +/** + * @brief Test that servers in different hostgroups are independent. + */ +static void test_mysql_hostgroup_independence() { + add_mysql_server(60, "hg60-server", 3306, 1, 100); + add_mysql_server(70, "hg70-server", 3306, 1, 100); + + // Shun server in HG 60 — should not affect HG 70 + bool s1 = MyHGM->shun_and_killall((char *)"hg60-server", 3306); + ok(s1 == true, + "MySQL HGM: shunned server in HG 60"); + + // HG 70 server should still be accessible + bool s2 = MyHGM->shun_and_killall((char *)"hg70-server", 3306); + ok(s2 == true, + "MySQL HGM: HG 70 server independently operable"); +} + +// ============================================================================ +// 5. Duplicate server handling +// ============================================================================ + +/** + * @brief Test adding the same server twice to the same hostgroup. + */ +static void test_mysql_duplicate_server() { + add_mysql_server(80, "dup-server", 3306); + // Adding same server again — should either succeed (re-enable) or fail + int rc = add_mysql_server(80, "dup-server", 3306); + // create_new_server_in_hg re-enables OFFLINE_HARD servers, so this + // depends on current state. Just verify it doesn't crash. + ok(rc == 0 || rc == -1, + "MySQL HGM: duplicate server add doesn't crash (rc=%d)", rc); +} + +// ============================================================================ +// 6. PgSQL HostGroups Manager +// ============================================================================ + +/** + * @brief Test PgSQL HostGroups Manager basic operations. + */ +static void test_pgsql_create_and_remove() { + ok(PgHGM != nullptr, "PgSQL HGM: PgHGM is initialized"); + + // PgSQL uses PgSQL_srv_info_t / PgSQL_srv_opts_t + PgSQL_srv_info_t info; + info.addr = "pg-server-1"; + info.port = 5432; + info.kind = "test"; + + PgSQL_srv_opts_t opts; + opts.weigth = 1; + opts.max_conns = 50; + opts.use_ssl = 0; + + PgHGM->wrlock(); + int rc = PgHGM->create_new_server_in_hg(100, info, opts); + PgHGM->wrunlock(); + ok(rc == 0, "PgSQL HGM: create_new_server_in_hg() returns 0"); + + // Remove + PgHGM->wrlock(); + rc = PgHGM->remove_server_in_hg(100, std::string("pg-server-1"), 5432); + PgHGM->wrunlock(); + ok(rc == 0, "PgSQL HGM: remove_server_in_hg() returns 0"); +} + +/** + * @brief Test PgSQL shun_and_killall. + */ +static void test_pgsql_shun() { + PgSQL_srv_info_t info; + info.addr = "pg-shun-server"; + info.port = 5432; + info.kind = "test"; + + PgSQL_srv_opts_t opts; + opts.weigth = 1; + opts.max_conns = 50; + opts.use_ssl = 0; + + PgHGM->wrlock(); + PgHGM->create_new_server_in_hg(110, info, opts); + PgHGM->wrunlock(); + + bool shunned = PgHGM->shun_and_killall( + (char *)"pg-shun-server", 5432); + ok(shunned == true, + "PgSQL HGM: shun_and_killall() returns true"); +} + +// ============================================================================ +// Main +// ============================================================================ + +int main() { + plan(17); + + int rc = test_init_minimal(); + ok(rc == 0, "test_init_minimal() succeeds"); + + rc = test_init_hostgroups(); + ok(rc == 0, "test_init_hostgroups() succeeds"); + + // MySQL tests + test_mysql_create_server(); // 3 tests + test_mysql_remove_server(); // 2 tests + test_mysql_shun_and_killall(); // 2 tests + test_mysql_latency(); // 1 test + test_mysql_hostgroup_independence(); // 2 tests + test_mysql_duplicate_server(); // 1 test + + // PgSQL tests + test_pgsql_create_and_remove(); // 3 tests + test_pgsql_shun(); // 1 test + // Total: 1+1+3+2+2+1+2+1+3+1 = 17... let me recount + // init: 2, create: 3, remove: 2, status: 2, latency: 1, + // independence: 2, duplicate: 1, pgsql_create: 3, pgsql_shun: 1 + // = 17. Fix plan. + + test_cleanup_hostgroups(); + test_cleanup_minimal(); + + return exit_status(); +} diff --git a/test/tap/tests/unit/monitor_health_unit-t.cpp b/test/tap/tests/unit/monitor_health_unit-t.cpp new file mode 100644 index 0000000000..23392c9ef8 --- /dev/null +++ b/test/tap/tests/unit/monitor_health_unit-t.cpp @@ -0,0 +1,187 @@ +/** + * @file monitor_health_unit-t.cpp + * @brief Unit tests for monitor health state decision functions. + * + * Tests the pure functions extracted from MySQL_Monitor, MySrvC, + * and MyHGC: + * - should_shun_on_connect_errors() + * - can_unshun_server() + * - should_shun_on_replication_lag() + * - can_recover_from_replication_lag() + * + * @see Phase 3.3 (GitHub issue #5491) + */ + +#include "tap.h" +#include "test_globals.h" +#include "test_init.h" + +#include "proxysql.h" +#include "MonitorHealthDecision.h" + +// ============================================================================ +// 1. should_shun_on_connect_errors +// ============================================================================ + +static void test_shun_connect_errors() { + // shun_on_failures=5, connect_retries=3 → threshold = min(5, 3+1) = 4 + ok(should_shun_on_connect_errors(4, 5, 3) == true, + "shun: errors=4 meets threshold min(5,4)=4"); + ok(should_shun_on_connect_errors(3, 5, 3) == false, + "no shun: errors=3 below threshold 4"); + ok(should_shun_on_connect_errors(10, 5, 3) == true, + "shun: errors=10 exceeds threshold"); + + // shun_on_failures=2, connect_retries=10 → threshold = min(2, 11) = 2 + ok(should_shun_on_connect_errors(2, 2, 10) == true, + "shun: errors=2 meets threshold min(2,11)=2"); + ok(should_shun_on_connect_errors(1, 2, 10) == false, + "no shun: errors=1 below threshold 2"); + + // Edge: shun_on_failures=1 → shun on first error + ok(should_shun_on_connect_errors(1, 1, 0) == true, + "shun: threshold=1, first error triggers shun"); + ok(should_shun_on_connect_errors(0, 1, 0) == false, + "no shun: zero errors"); +} + +// ============================================================================ +// 2. can_unshun_server +// ============================================================================ + +static void test_unshun_time_elapsed() { + // Recovery after enough time: last_error=100, now=200, recovery=10s + ok(can_unshun_server(100, 200, 10, 60000, false, 0, 0) == true, + "unshun: 100s elapsed > 10s recovery"); + + // Not enough time: last_error=100, now=105, recovery=10s + ok(can_unshun_server(100, 105, 10, 60000, false, 0, 0) == false, + "no unshun: 5s elapsed < 10s recovery"); + + // Exactly at boundary: elapsed == recovery → should NOT unshun (needs >) + ok(can_unshun_server(100, 110, 10, 60000, false, 0, 0) == false, + "no unshun: elapsed == recovery (needs >)"); +} + +static void test_unshun_timeout_cap() { + // recovery=30s, connect_timeout_max=10000ms → cap = 10000/1000-1 = 9s + ok(can_unshun_server(100, 200, 30, 10000, false, 0, 0) == true, + "unshun: capped to 9s, 100s elapsed is enough"); + + // recovery=30s, connect_timeout_max=10000ms, but only 5s elapsed + ok(can_unshun_server(100, 105, 30, 10000, false, 0, 0) == false, + "no unshun: capped to 9s but only 5s elapsed"); +} + +static void test_unshun_kill_all_conns() { + // kill_all=true, connections still active → cannot unshun + ok(can_unshun_server(100, 200, 10, 60000, true, 5, 0) == false, + "no unshun: kill_all=true, used=5"); + ok(can_unshun_server(100, 200, 10, 60000, true, 0, 3) == false, + "no unshun: kill_all=true, free=3"); + + // kill_all=true, all connections drained → can unshun + ok(can_unshun_server(100, 200, 10, 60000, true, 0, 0) == true, + "unshun: kill_all=true, all connections drained"); + + // kill_all=false, connections exist → can still unshun + ok(can_unshun_server(100, 200, 10, 60000, false, 10, 5) == true, + "unshun: kill_all=false, connections don't matter"); +} + +static void test_unshun_recovery_disabled() { + // recovery_time=0 → recovery disabled + ok(can_unshun_server(100, 200, 0, 60000, false, 0, 0) == false, + "no unshun: recovery disabled (recovery_time=0)"); +} + +static void test_unshun_clock_skew() { + // current_time <= time_last_error → no recovery + ok(can_unshun_server(200, 100, 10, 60000, false, 0, 0) == false, + "no unshun: clock skew (current < last_error)"); + ok(can_unshun_server(100, 100, 10, 60000, false, 0, 0) == false, + "no unshun: current == last_error"); +} + +static void test_unshun_max_wait_minimum() { + // recovery=1s, timeout_max=500ms → cap = 500/1000-1 = -1 → clamped to 1 + ok(can_unshun_server(100, 103, 1, 500, false, 0, 0) == true, + "unshun: max_wait clamped to 1s minimum, 3s elapsed"); +} + +// ============================================================================ +// 3. should_shun_on_replication_lag +// ============================================================================ + +static void test_replication_lag_shun() { + // lag=15, max=10, count=3, threshold=3 → shun + ok(should_shun_on_replication_lag(15, 10, 3, 3) == true, + "lag shun: lag=15 > max=10, count=3 meets threshold=3"); + + // lag=15, max=10, count=2, threshold=3 → not yet + ok(should_shun_on_replication_lag(15, 10, 2, 3) == false, + "no lag shun: count=2 below threshold=3"); + + // lag=5, max=10 → within bounds + ok(should_shun_on_replication_lag(5, 10, 10, 1) == false, + "no lag shun: lag=5 within max=10"); + + // max_replication_lag=0 → check disabled + ok(should_shun_on_replication_lag(100, 0, 10, 1) == false, + "no lag shun: check disabled (max=0)"); + + // lag=-1 (unknown) → don't shun + ok(should_shun_on_replication_lag(-1, 10, 10, 1) == false, + "no lag shun: lag unknown (-1)"); + + // lag exactly at max → not shunned (needs >) + ok(should_shun_on_replication_lag(10, 10, 5, 1) == false, + "no lag shun: lag=10 == max=10 (needs >)"); +} + +// ============================================================================ +// 4. can_recover_from_replication_lag +// ============================================================================ + +static void test_replication_lag_recovery() { + // lag drops below max → recover + ok(can_recover_from_replication_lag(5, 10) == true, + "lag recover: lag=5 <= max=10"); + + // lag exactly at max → recover + ok(can_recover_from_replication_lag(10, 10) == true, + "lag recover: lag=10 == max=10"); + + // lag still above → don't recover + ok(can_recover_from_replication_lag(15, 10) == false, + "no lag recover: lag=15 > max=10"); + + // unknown lag → don't recover + ok(can_recover_from_replication_lag(-1, 10) == false, + "no lag recover: lag unknown (-1)"); +} + +// ============================================================================ +// Main +// ============================================================================ + +int main() { + plan(31); + + int rc = test_init_minimal(); + ok(rc == 0, "test_init_minimal() succeeds"); + + test_shun_connect_errors(); // 7 + test_unshun_time_elapsed(); // 3 + test_unshun_timeout_cap(); // 2 + test_unshun_kill_all_conns(); // 4 + test_unshun_recovery_disabled(); // 1 + test_unshun_clock_skew(); // 2 + test_unshun_max_wait_minimum(); // 1 + test_replication_lag_shun(); // 6 + test_replication_lag_recovery(); // 4 + // Total: 1+7+3+2+4+1+2+1+6+4 = 31 + + 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 0000000000..471779c6b7 --- /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/pgsql_command_complete_unit-t.cpp b/test/tap/tests/unit/pgsql_command_complete_unit-t.cpp new file mode 100644 index 0000000000..81c8df0318 --- /dev/null +++ b/test/tap/tests/unit/pgsql_command_complete_unit-t.cpp @@ -0,0 +1,97 @@ +/** + * @file pgsql_command_complete_unit-t.cpp + * @brief Unit tests for PostgreSQL CommandComplete tag parser. + * + * Tests parse_pgsql_command_complete() which extracts row counts + * from PgSQL CommandComplete message tags. + * + * @see FFTO unit testing (GitHub issue #5499) + */ + +#include "tap.h" +#include "test_globals.h" +#include "test_init.h" +#include "proxysql.h" +#include "PgSQLCommandComplete.h" + +#include + +static PgSQLCommandResult parse(const char *tag) { + return parse_pgsql_command_complete( + (const unsigned char *)tag, strlen(tag)); +} + +static void test_dml_commands() { + auto r = parse("INSERT 0 10"); + ok(r.rows == 10 && r.is_select == false, "INSERT 0 10 → rows=10"); + + r = parse("UPDATE 3"); + ok(r.rows == 3 && r.is_select == false, "UPDATE 3 → rows=3"); + + r = parse("DELETE 0"); + ok(r.rows == 0 && r.is_select == false, "DELETE 0 → rows=0"); + + r = parse("COPY 100"); + ok(r.rows == 100 && r.is_select == false, "COPY 100 → rows=100"); + + r = parse("MERGE 5"); + ok(r.rows == 5 && r.is_select == false, "MERGE 5 → rows=5"); +} + +static void test_select_commands() { + auto r = parse("SELECT 50"); + ok(r.rows == 50 && r.is_select == true, "SELECT 50 → rows=50, is_select"); + + r = parse("FETCH 10"); + ok(r.rows == 10 && r.is_select == true, "FETCH 10 → rows=10, is_select"); + + r = parse("MOVE 7"); + ok(r.rows == 7 && r.is_select == true, "MOVE 7 → rows=7, is_select"); +} + +static void test_no_row_count() { + auto r = parse("CREATE TABLE"); + ok(r.rows == 0, "CREATE TABLE → rows=0 (no row count)"); + + r = parse("DROP INDEX"); + ok(r.rows == 0, "DROP INDEX → rows=0"); + + r = parse("BEGIN"); + ok(r.rows == 0, "BEGIN → rows=0 (single token, no space)"); +} + +static void test_edge_cases() { + // Empty payload + auto r = parse_pgsql_command_complete(nullptr, 0); + ok(r.rows == 0, "null payload → rows=0"); + + r = parse(""); + ok(r.rows == 0, "empty string → rows=0"); + + // Whitespace padding + r = parse(" SELECT 42 "); + ok(r.rows == 42, "whitespace padded SELECT → rows=42"); + + // INSERT with OID (two numbers after command) + r = parse("INSERT 0 1"); + ok(r.rows == 1, "INSERT 0 1 → rows=1 (last token)"); + + // Large row count + r = parse("SELECT 999999999"); + ok(r.rows == 999999999, "large row count parsed correctly"); +} + +int main() { + plan(17); + int rc = test_init_minimal(); + ok(rc == 0, "test_init_minimal() succeeds"); + + test_dml_commands(); // 5 + test_select_commands(); // 3 + test_no_row_count(); // 3 + test_edge_cases(); // 5 + // Total: 1+5+3+3+5 = 17 + + test_cleanup_minimal(); + return exit_status(); +} diff --git a/test/tap/tests/unit/pgsql_error_classifier_unit-t.cpp b/test/tap/tests/unit/pgsql_error_classifier_unit-t.cpp new file mode 100644 index 0000000000..396492415b --- /dev/null +++ b/test/tap/tests/unit/pgsql_error_classifier_unit-t.cpp @@ -0,0 +1,102 @@ +/** + * @file pgsql_error_classifier_unit-t.cpp + * @brief Unit tests for PgSQL error classification. + * + * @see Phase 3.10 (GitHub issue #5498) + */ + +#include "tap.h" +#include "test_globals.h" +#include "test_init.h" +#include "proxysql.h" +#include "PgSQLErrorClassifier.h" + +static void test_connection_errors() { + ok(classify_pgsql_error("08000") == PGSQL_ERROR_RETRY, + "08000 (connection exception): retryable"); + ok(classify_pgsql_error("08003") == PGSQL_ERROR_RETRY, + "08003 (connection does not exist): retryable"); + ok(classify_pgsql_error("08006") == PGSQL_ERROR_RETRY, + "08006 (connection failure): retryable"); +} + +static void test_transaction_errors() { + ok(classify_pgsql_error("40001") == PGSQL_ERROR_RETRY, + "40001 (serialization failure): retryable"); + ok(classify_pgsql_error("40P01") == PGSQL_ERROR_RETRY, + "40P01 (deadlock detected): retryable"); +} + +static void test_resource_errors() { + ok(classify_pgsql_error("53000") == PGSQL_ERROR_RETRY, + "53000 (insufficient resources): retryable"); + ok(classify_pgsql_error("53300") == PGSQL_ERROR_RETRY, + "53300 (too many connections): retryable"); +} + +static void test_fatal_errors() { + ok(classify_pgsql_error("57000") == PGSQL_ERROR_FATAL, + "57000 (operator intervention): fatal"); + ok(classify_pgsql_error("57P01") == PGSQL_ERROR_FATAL, + "57P01 (admin shutdown): fatal"); + ok(classify_pgsql_error("57P02") == PGSQL_ERROR_FATAL, + "57P02 (crash shutdown): fatal"); + ok(classify_pgsql_error("58000") == PGSQL_ERROR_FATAL, + "58000 (system error): fatal"); + // 57014 is an exception — query_canceled is NOT fatal + ok(classify_pgsql_error("57014") == PGSQL_ERROR_REPORT_TO_CLIENT, + "57014 (query canceled): not fatal, report to client"); +} + +static void test_non_retryable_errors() { + ok(classify_pgsql_error("42601") == PGSQL_ERROR_REPORT_TO_CLIENT, + "42601 (syntax error): report"); + ok(classify_pgsql_error("42P01") == PGSQL_ERROR_REPORT_TO_CLIENT, + "42P01 (undefined table): report"); + ok(classify_pgsql_error("23505") == PGSQL_ERROR_REPORT_TO_CLIENT, + "23505 (unique violation): report"); + ok(classify_pgsql_error("23503") == PGSQL_ERROR_REPORT_TO_CLIENT, + "23503 (foreign key violation): report"); + ok(classify_pgsql_error("22001") == PGSQL_ERROR_REPORT_TO_CLIENT, + "22001 (string data right truncation): report"); +} + +static void test_edge_cases() { + ok(classify_pgsql_error(nullptr) == PGSQL_ERROR_REPORT_TO_CLIENT, + "null sqlstate: report"); + ok(classify_pgsql_error("") == PGSQL_ERROR_REPORT_TO_CLIENT, + "empty sqlstate: report"); + ok(classify_pgsql_error("0") == PGSQL_ERROR_REPORT_TO_CLIENT, + "single char sqlstate: report"); +} + +static void test_retry_conditions() { + ok(pgsql_can_retry_error(PGSQL_ERROR_RETRY, 3, false) == true, + "can retry: retryable + retries left + no txn"); + ok(pgsql_can_retry_error(PGSQL_ERROR_RETRY, 0, false) == false, + "no retry: no retries left"); + ok(pgsql_can_retry_error(PGSQL_ERROR_RETRY, 3, true) == false, + "no retry: in transaction"); + ok(pgsql_can_retry_error(PGSQL_ERROR_REPORT_TO_CLIENT, 3, false) == false, + "no retry: non-retryable error"); + ok(pgsql_can_retry_error(PGSQL_ERROR_FATAL, 3, false) == false, + "no retry: fatal error"); +} + +int main() { + plan(26); + int rc = test_init_minimal(); + ok(rc == 0, "test_init_minimal() succeeds"); + + test_connection_errors(); // 3 + test_transaction_errors(); // 2 + test_resource_errors(); // 2 + test_fatal_errors(); // 4 + test_non_retryable_errors(); // 5 + test_edge_cases(); // 3 + test_retry_conditions(); // 5 + // Total: 1+3+2+2+5+5+3+5 = 26 + + test_cleanup_minimal(); + return exit_status(); +} diff --git a/test/tap/tests/unit/pgsql_monitor_unit-t.cpp b/test/tap/tests/unit/pgsql_monitor_unit-t.cpp new file mode 100644 index 0000000000..a4734595b2 --- /dev/null +++ b/test/tap/tests/unit/pgsql_monitor_unit-t.cpp @@ -0,0 +1,51 @@ +/** + * @file pgsql_monitor_unit-t.cpp + * @brief Unit tests for PgSQL monitor health decisions. + * + * @see Phase 3.9 (GitHub issue #5497) + */ + +#include "tap.h" +#include "test_globals.h" +#include "test_init.h" +#include "proxysql.h" +#include "PgSQLMonitorDecision.h" + +static void test_ping_shunning() { + ok(pgsql_should_shun_on_ping_failure(3, 3) == true, + "shun: failures=3 meets threshold=3"); + ok(pgsql_should_shun_on_ping_failure(5, 3) == true, + "shun: failures=5 exceeds threshold=3"); + ok(pgsql_should_shun_on_ping_failure(2, 3) == false, + "no shun: failures=2 below threshold=3"); + ok(pgsql_should_shun_on_ping_failure(0, 3) == false, + "no shun: zero failures"); + ok(pgsql_should_shun_on_ping_failure(1, 1) == true, + "shun: threshold=1, single failure"); + ok(pgsql_should_shun_on_ping_failure(10, 0) == false, + "no shun: threshold=0 (disabled)"); +} + +static void test_readonly_offline() { + ok(pgsql_should_offline_for_readonly(true, true) == true, + "offline: read_only + writer HG"); + ok(pgsql_should_offline_for_readonly(true, false) == false, + "not offline: read_only + reader HG"); + ok(pgsql_should_offline_for_readonly(false, true) == false, + "not offline: not read_only + writer HG"); + ok(pgsql_should_offline_for_readonly(false, false) == false, + "not offline: not read_only + reader HG"); +} + +int main() { + plan(11); + int rc = test_init_minimal(); + ok(rc == 0, "test_init_minimal() succeeds"); + + test_ping_shunning(); // 6 + test_readonly_offline(); // 4 + // Total: 1+6+4 = 11 + + test_cleanup_minimal(); + return exit_status(); +} diff --git a/test/tap/tests/unit/protocol_unit-t.cpp b/test/tap/tests/unit/protocol_unit-t.cpp new file mode 100644 index 0000000000..83db246500 --- /dev/null +++ b/test/tap/tests/unit/protocol_unit-t.cpp @@ -0,0 +1,425 @@ +/** + * @file protocol_unit-t.cpp + * @brief Unit tests for protocol encoding/decoding and query digest functions. + * + * Tests standalone protocol utility functions in isolation: + * - MySQL length-encoded integer encoding and decoding + * - mysql_hdr packet header structure + * - Query digest functions (MySQL and PostgreSQL) + * - String utility functions (escaping, wildcard matching) + * - Byte copy helpers (CPY3, CPY8) + * + * These functions are pure computation with no global state + * dependencies, making them ideal for unit testing. + * + * @see Phase 2.5 of the Unit Testing Framework (GitHub issue #5477) + */ + +#include "tap.h" +#include "test_globals.h" +#include "test_init.h" + +#include "proxysql.h" +#include "MySQL_Protocol.h" +#include "MySQL_encode.h" +#include "c_tokenizer.h" +#include "gen_utils.h" + +#include +#include + +// ============================================================================ +// 1. MySQL length-encoded integer decoding +// ============================================================================ + +/** + * @brief Test mysql_decode_length() for 1-byte values (0-250). + */ +static void test_decode_length_1byte() { + unsigned char buf[1]; + uint32_t len = 0; + + buf[0] = 0; + uint8_t bytes = mysql_decode_length(buf, &len); + ok(len == 0 && bytes == 1, + "decode_length: 0 → 1 byte"); + + buf[0] = 1; + bytes = mysql_decode_length(buf, &len); + ok(len == 1 && bytes == 1, + "decode_length: 1 → 1 byte"); + + buf[0] = 250; + bytes = mysql_decode_length(buf, &len); + ok(len == 250 && bytes == 1, + "decode_length: 250 → 1 byte (max 1-byte value)"); +} + +/** + * @brief Test mysql_decode_length() for 2-byte values (0xFC prefix). + */ +static void test_decode_length_2byte() { + unsigned char buf[3]; + uint32_t len = 0; + + // 0xFC prefix = 2-byte length follows + buf[0] = 0xFC; + buf[1] = 0x01; + buf[2] = 0x00; + uint8_t bytes = mysql_decode_length(buf, &len); + ok(len == 1 && bytes == 3, + "decode_length: 0xFC 0x01 0x00 → 1 (3 bytes total)"); + + buf[0] = 0xFC; + buf[1] = 0xFF; + buf[2] = 0xFF; + bytes = mysql_decode_length(buf, &len); + ok(len == 0xFFFF && bytes == 3, + "decode_length: 0xFC 0xFF 0xFF → 65535 (3 bytes total)"); +} + +/** + * @brief Test mysql_decode_length() for 3-byte values (0xFD prefix). + */ +static void test_decode_length_3byte() { + // CPY3() reads 4 bytes via uint32_t* cast, so pad buffer to avoid OOB + unsigned char buf[5]; + uint32_t len = 0; + + buf[0] = 0xFD; + buf[1] = 0x00; + buf[2] = 0x00; + buf[3] = 0x01; + buf[4] = 0x00; // padding for CPY3's 4-byte read from buf+1 + uint8_t bytes = mysql_decode_length(buf, &len); + ok(len == 0x010000 && bytes == 4, + "decode_length: 0xFD prefix → 65536 (4 bytes total)"); +} + +/** + * @brief Test mysql_decode_length_ll() for 8-byte values (0xFE prefix). + */ +static void test_decode_length_8byte() { + unsigned char buf[9]; + uint64_t len = 0; + + buf[0] = 0xFE; + memset(buf + 1, 0, 8); + buf[1] = 0x01; + uint8_t bytes = mysql_decode_length_ll(buf, &len); + ok(len == 1 && bytes == 9, + "decode_length_ll: 0xFE prefix → 1 (9 bytes total)"); + + // Large value + buf[0] = 0xFE; + buf[1] = 0xFF; + buf[2] = 0xFF; + buf[3] = 0xFF; + buf[4] = 0xFF; + buf[5] = 0x00; + buf[6] = 0x00; + buf[7] = 0x00; + buf[8] = 0x00; + bytes = mysql_decode_length_ll(buf, &len); + ok(len == 0xFFFFFFFF && bytes == 9, + "decode_length_ll: 0xFE prefix → 4294967295 (9 bytes total)"); +} + +// ============================================================================ +// 2. MySQL length-encoded integer encoding +// ============================================================================ + +/** + * @brief Test mysql_encode_length() and roundtrip with decode. + */ +static void test_encode_length() { + char hd[9]; + + // 1-byte range (0-250): encode returns 1, does NOT write hd + // (the value itself is the length byte, no prefix needed) + uint8_t enc_len = mysql_encode_length(0, hd); + ok(enc_len == 1, + "encode_length: 0 → 1 byte"); + + enc_len = mysql_encode_length(250, hd); + ok(enc_len == 1, + "encode_length: 250 → 1 byte"); + + // 2-byte range (251-65535) + enc_len = mysql_encode_length(251, hd); + ok(enc_len == 3 && (unsigned char)hd[0] == 0xFC, + "encode_length: 251 → 3 bytes (0xFC prefix)"); + + enc_len = mysql_encode_length(65535, hd); + ok(enc_len == 3 && (unsigned char)hd[0] == 0xFC, + "encode_length: 65535 → 3 bytes (0xFC prefix)"); + + // 3-byte range (65536 - 16777215) + enc_len = mysql_encode_length(65536, hd); + ok(enc_len == 4 && (unsigned char)hd[0] == 0xFD, + "encode_length: 65536 → 4 bytes (0xFD prefix)"); + + // 8-byte range (>= 16777216) + enc_len = mysql_encode_length(16777216, hd); + ok(enc_len == 9 && (unsigned char)hd[0] == 0xFE, + "encode_length: 16777216 → 9 bytes (0xFE prefix)"); +} + +/** + * @brief Test write_encoded_length + decode roundtrip for various values. + * + * write_encoded_length() writes both prefix and value bytes. + * mysql_decode_length_ll() reads them back. + */ +static void test_encode_decode_roundtrip() { + unsigned char buf[9]; + char prefix[1]; + uint64_t test_values[] = {0, 1, 250, 251, 1000, 65535, 65536, 16777215, 16777216, 100000000ULL}; + int num_values = sizeof(test_values) / sizeof(test_values[0]); + + int pass_count = 0; + for (int i = 0; i < num_values; i++) { + memset(buf, 0, sizeof(buf)); + prefix[0] = 0; // Initialize to avoid UB for 1-byte values + uint8_t enc_len = mysql_encode_length(test_values[i], prefix); + write_encoded_length(buf, test_values[i], enc_len, prefix[0]); + + uint64_t decoded = 0; + mysql_decode_length_ll(buf, &decoded); + if (decoded == test_values[i]) pass_count++; + } + ok(pass_count == num_values, + "encode/decode roundtrip: all %d values survive roundtrip", num_values); +} + +// ============================================================================ +// 3. mysql_hdr packet header structure +// ============================================================================ + +/** + * @brief Test mysql_hdr structure layout (24-bit length + 8-bit id). + */ +static void test_mysql_hdr() { + mysql_hdr hdr; + memset(&hdr, 0, sizeof(hdr)); + + ok(sizeof(mysql_hdr) == 4, + "mysql_hdr: sizeof is 4 bytes"); + + hdr.pkt_length = 100; + hdr.pkt_id = 1; + ok(hdr.pkt_length == 100 && hdr.pkt_id == 1, + "mysql_hdr: pkt_length and pkt_id set correctly"); + + // Max 24-bit value + hdr.pkt_length = 0xFFFFFF; + ok(hdr.pkt_length == 0xFFFFFF, + "mysql_hdr: max pkt_length (16MB-1)"); +} + +// ============================================================================ +// 4. CPY3 and CPY8 byte copy helpers +// ============================================================================ + +/** + * @brief Test CPY3() — copies 3 bytes as little-endian unsigned int. + */ +static void test_cpy3() { + // CPY3() reads 4 bytes via uint32_t* cast, so use 4-byte buffers + unsigned char buf[4] = {0x01, 0x02, 0x03, 0x00}; + unsigned int val = CPY3(buf); + ok(val == 0x030201, + "CPY3: little-endian 3-byte copy correct"); + + unsigned char zero[4] = {0, 0, 0, 0}; + ok(CPY3(zero) == 0, "CPY3: zero bytes → 0"); +} + +/** + * @brief Test CPY8() — copies 8 bytes as little-endian uint64. + */ +static void test_cpy8() { + unsigned char buf[8] = {0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; + uint64_t val = CPY8(buf); + ok(val == 1, "CPY8: little-endian 8-byte copy correct"); + + unsigned char max[8]; + memset(max, 0xFF, 8); + uint64_t maxval = CPY8(max); + ok(maxval == UINT64_MAX, "CPY8: all 0xFF → UINT64_MAX"); +} + +// ============================================================================ +// 5. Query digest functions +// ============================================================================ + +/** + * @brief Test MySQL query digest normalization (two-stage pipeline). + * + * first_stage normalizes whitespace/structure, second_stage replaces + * literals with '?'. The combined _2 function does both. + */ +static void test_mysql_query_digest() { + char buf[QUERY_DIGEST_BUF]; + char *first_comment = nullptr; + + // Digest normalizes the query (whitespace, literals) + char *digest = mysql_query_digest_and_first_comment_2( + "SELECT * FROM users WHERE id = 1", 37, + &first_comment, buf); + ok(digest != nullptr, + "mysql digest: SELECT produces non-null digest"); + if (digest != nullptr) { + // Verify whitespace is normalized (collapsed to single space) + ok(strstr(digest, " ") == nullptr, + "mysql digest: extra whitespace normalized"); + } else { + ok(0, "mysql digest: whitespace normalized (skipped)"); + } + + // Query with comment — first_comment should capture it + if (first_comment) { free(first_comment); first_comment = nullptr; } + digest = mysql_query_digest_and_first_comment_2( + "/* my_comment */ SELECT 1", 25, + &first_comment, buf); + ok(digest != nullptr, + "mysql digest: query with comment produces non-null digest"); + + // Empty query + if (first_comment) { free(first_comment); first_comment = nullptr; } + digest = mysql_query_digest_and_first_comment_2( + "", 0, &first_comment, buf); + ok(digest != nullptr, + "mysql digest: empty query produces non-null digest"); + if (first_comment) { free(first_comment); first_comment = nullptr; } +} + +/** + * @brief Test PgSQL query digest normalization. + */ +static void test_pgsql_query_digest() { + char buf[QUERY_DIGEST_BUF]; + char *first_comment = nullptr; + + const char *pgsql_q = "SELECT * FROM orders WHERE total > 0"; + char *digest = pgsql_query_digest_and_first_comment_2( + pgsql_q, strlen(pgsql_q), + &first_comment, buf); + ok(digest != nullptr, + "pgsql digest: SELECT produces non-null digest"); + if (digest != nullptr) { + // Verify whitespace is normalized + ok(strstr(digest, " ") == nullptr, + "pgsql digest: extra whitespace normalized"); + } else { + ok(0, "pgsql digest: whitespace normalized (skipped)"); + } + if (first_comment) { free(first_comment); first_comment = nullptr; } +} + +// ============================================================================ +// 6. String utility functions +// ============================================================================ + +/** + * @brief Test escape_string_single_quotes(). + */ +static void test_escape_single_quotes() { + // No quotes — returns the original pointer (not a copy) + char *input1 = strdup("hello world"); + char *escaped1 = escape_string_single_quotes(input1, true); + ok(escaped1 != nullptr && strcmp(escaped1, "hello world") == 0, + "escape: string without quotes unchanged"); + free(escaped1); + + // With single quotes — should double them + char *input2 = strdup("it's a test"); + char *escaped2 = escape_string_single_quotes(input2, true); + ok(escaped2 != nullptr && strcmp(escaped2, "it''s a test") == 0, + "escape: single quote doubled"); + free(escaped2); + + // Multiple quotes + char *input3 = strdup("a'b'c"); + char *escaped3 = escape_string_single_quotes(input3, true); + ok(escaped3 != nullptr && strcmp(escaped3, "a''b''c") == 0, + "escape: multiple single quotes doubled"); + free(escaped3); +} + +/** + * @brief Test mywildcmp() — wildcard pattern matching with % and _. + */ +static void test_wildcard_matching() { + // Exact match + ok(mywildcmp("hello", "hello") == true, + "wildcard: exact match"); + + // % matches any sequence + ok(mywildcmp("hel%", "hello") == true, + "wildcard: % suffix matches"); + ok(mywildcmp("%llo", "hello") == true, + "wildcard: % prefix matches"); + ok(mywildcmp("%ll%", "hello") == true, + "wildcard: % on both sides matches"); + ok(mywildcmp("%", "anything") == true, + "wildcard: lone % matches anything"); + + // _ matches single character + ok(mywildcmp("h_llo", "hello") == true, + "wildcard: _ matches single char"); + ok(mywildcmp("h_llo", "hallo") == true, + "wildcard: _ matches any single char"); + + // Non-match + ok(mywildcmp("hello", "world") == false, + "wildcard: no match on different strings"); + ok(mywildcmp("hel%", "world") == false, + "wildcard: % prefix doesn't match unrelated"); + ok(mywildcmp("h_llo", "hllo") == false, + "wildcard: _ requires exactly one char"); + + // Empty cases + ok(mywildcmp("", "") == true, + "wildcard: empty pattern matches empty string"); + ok(mywildcmp("%", "") == true, + "wildcard: % matches empty string"); +} + +// ============================================================================ +// Main +// ============================================================================ + +int main() { + plan(43); + + test_init_minimal(); + + // MySQL length-encoded integers + test_decode_length_1byte(); // 3 tests + test_decode_length_2byte(); // 2 tests + test_decode_length_3byte(); // 1 test + test_decode_length_8byte(); // 2 tests + test_encode_length(); // 6 tests + test_encode_decode_roundtrip(); // 1 test + + // Packet header + test_mysql_hdr(); // 3 tests + + // Byte copy helpers + test_cpy3(); // 2 tests + test_cpy8(); // 2 tests + + // Query digest + test_mysql_query_digest(); // 4 tests + test_pgsql_query_digest(); // 2 tests + + // String utilities + test_escape_single_quotes(); // 3 tests + test_wildcard_matching(); // 12 tests + // Total: 3+2+1+2+6+1+3+2+2+4+2+3+12 = 43 + + test_cleanup_minimal(); + + return exit_status(); +} diff --git a/test/tap/tests/unit/query_cache_unit-t.cpp b/test/tap/tests/unit/query_cache_unit-t.cpp new file mode 100644 index 0000000000..9e515ec05c --- /dev/null +++ b/test/tap/tests/unit/query_cache_unit-t.cpp @@ -0,0 +1,503 @@ +/** + * @file query_cache_unit-t.cpp + * @brief Unit tests for MySQL_Query_Cache and PgSQL_Query_Cache. + * + * Tests the query cache subsystem in isolation without a running + * ProxySQL instance. Covers: + * - Basic set/get cycle (PgSQL — simpler API for core logic testing) + * - Cache miss on nonexistent key + * - Cache replacement (same key, new value) + * - TTL expiration (hard TTL) + * - flush() clears all entries + * - purgeHash() eviction under memory pressure + * - Global stats counter accuracy + * - Memory tracking via get_data_size_total() + * - Multiple entries across hash buckets + * + * PgSQL_Query_Cache is used for most tests because its set()/get() + * API is simpler (no MySQL protocol parsing). The underlying + * Query_Cache template logic is identical for both protocols. + * + * @note MySQL_Query_Cache::set() requires valid MySQL protocol result + * sets (it parses packet boundaries), so MySQL-specific tests + * are limited to construction/flush. + * + * @see Phase 2.3 of the Unit Testing Framework (GitHub issue #5475) + */ + +#include "tap.h" +#include "test_globals.h" +#include "test_init.h" + +#include "proxysql.h" +#include "MySQL_Query_Cache.h" +#include "PgSQL_Query_Cache.h" + +#include + +// Extern declarations for Glo* pointers (defined in test_globals.cpp) +extern MySQL_Query_Cache *GloMyQC; +extern PgSQL_Query_Cache *GloPgQC; + +/** + * @brief Helper to get the current monotonic time in milliseconds. + */ +static uint64_t now_ms() { + return monotonic_time() / 1000; +} + +// ============================================================================ +// 1. PgSQL Query Cache: Basic set/get +// ============================================================================ + +/** + * @brief Test basic set + get cycle with PgSQL cache. + * + * Stores a value with a 10-second TTL and retrieves it immediately. + * Verifies the returned shared_ptr points to the correct data. + */ +static void test_pgsql_set_get() { + uint64_t user_hash = 12345; + const unsigned char *key = (const unsigned char *)"SELECT 1"; + uint32_t kl = 8; + + // Create a value buffer — the cache takes ownership of the pointer + unsigned char *value = (unsigned char *)malloc(16); + memcpy(value, "result_data_001", 16); + uint32_t vl = 16; + + uint64_t t = now_ms(); + uint64_t expire = t + 10000; // 10 seconds from now + + bool set_ok = GloPgQC->set(user_hash, key, kl, value, vl, + t, t, expire); + ok(set_ok == true, "PgSQL QC: set() returns true"); + + // Get with same key and user_hash + auto entry = GloPgQC->get(user_hash, key, kl, now_ms(), 10000); + ok(entry != nullptr, "PgSQL QC: get() returns non-null for cached entry"); + + if (entry != nullptr) { + ok(entry->length == 16, "PgSQL QC: retrieved entry has correct length"); + ok(memcmp(entry->value, "result_data_001", 16) == 0, + "PgSQL QC: retrieved entry has correct value"); + } else { + ok(0, "PgSQL QC: retrieved entry has correct length (skipped)"); + ok(0, "PgSQL QC: retrieved entry has correct value (skipped)"); + } +} + +/** + * @brief Test cache miss — get() on nonexistent key returns nullptr. + */ +static void test_pgsql_cache_miss() { + uint64_t user_hash = 99999; + const unsigned char *key = (const unsigned char *)"SELECT nonexistent"; + uint32_t kl = 18; + + auto entry = GloPgQC->get(user_hash, key, kl, now_ms(), 10000); + ok(entry == nullptr, "PgSQL QC: get() returns null for nonexistent key"); +} + +/** + * @brief Test that different user_hash values produce cache misses + * even for the same key bytes. + */ +static void test_pgsql_user_hash_isolation() { + const unsigned char *key = (const unsigned char *)"SELECT shared"; + uint32_t kl = 13; + uint64_t t = now_ms(); + + unsigned char *val1 = (unsigned char *)malloc(6); + memcpy(val1, "user_A", 6); + GloPgQC->set(100, key, kl, val1, 6, t, t, t + 10000); + + // Same key but different user_hash — should miss + auto entry = GloPgQC->get(200, key, kl, now_ms(), 10000); + ok(entry == nullptr, + "PgSQL QC: different user_hash produces cache miss for same key"); + + // Same user_hash — should hit + auto entry2 = GloPgQC->get(100, key, kl, now_ms(), 10000); + ok(entry2 != nullptr, + "PgSQL QC: same user_hash produces cache hit"); +} + +// ============================================================================ +// 2. Cache replacement +// ============================================================================ + +/** + * @brief Test that set() with the same key replaces the cached value. + */ +static void test_pgsql_replace() { + uint64_t user_hash = 20000; + const unsigned char *key = (const unsigned char *)"SELECT replace_me"; + uint32_t kl = 17; + uint64_t t = now_ms(); + + unsigned char *val1 = (unsigned char *)malloc(5); + memcpy(val1, "old_v", 5); + GloPgQC->set(user_hash, key, kl, val1, 5, t, t, t + 10000); + + unsigned char *val2 = (unsigned char *)malloc(5); + memcpy(val2, "new_v", 5); + GloPgQC->set(user_hash, key, kl, val2, 5, t, t, t + 10000); + + auto entry = GloPgQC->get(user_hash, key, kl, now_ms(), 10000); + ok(entry != nullptr, "PgSQL QC: get() after replace returns non-null"); + if (entry != nullptr) { + ok(memcmp(entry->value, "new_v", 5) == 0, + "PgSQL QC: replaced entry has new value"); + } else { + ok(0, "PgSQL QC: replaced entry has new value (skipped)"); + } +} + +// ============================================================================ +// 3. TTL expiration +// ============================================================================ + +/** + * @brief Test that entries are not returned after their hard TTL expires. + * + * Sets an entry with expire_ms in the past, then verifies get() + * returns nullptr. + */ +static void test_pgsql_ttl_expired() { + uint64_t user_hash = 30000; + const unsigned char *key = (const unsigned char *)"SELECT expired"; + uint32_t kl = 14; + uint64_t t = now_ms(); + + // Create entry that expires 1ms before "now" + unsigned char *val = (unsigned char *)malloc(4); + memcpy(val, "old!", 4); + GloPgQC->set(user_hash, key, kl, val, 4, + t - 5000, // created 5 seconds ago + t - 5000, // curtime when set + t - 1); // expired 1ms ago + + auto entry = GloPgQC->get(user_hash, key, kl, now_ms(), 10000); + ok(entry == nullptr, + "PgSQL QC: get() returns null for expired entry (hard TTL)"); +} + +/** + * @brief Test that entries are not returned after the soft TTL + * (cache_ttl parameter to get()) is exceeded. + */ +static void test_pgsql_soft_ttl() { + uint64_t user_hash = 31000; + const unsigned char *key = (const unsigned char *)"SELECT soft_ttl"; + uint32_t kl = 15; + uint64_t t = now_ms(); + + unsigned char *val = (unsigned char *)malloc(4); + memcpy(val, "soft", 4); + // Hard TTL far in the future, but created 5 seconds ago + GloPgQC->set(user_hash, key, kl, val, 4, + t - 5000, // created 5 seconds ago + t - 5000, + t + 60000); // hard TTL 60s from now + + // Get with cache_ttl=2000ms — entry was created 5s ago, so + // create_ms + cache_ttl < now → soft TTL exceeded + auto entry = GloPgQC->get(user_hash, key, kl, now_ms(), 2000); + ok(entry == nullptr, + "PgSQL QC: get() returns null when soft TTL exceeded"); +} + +// ============================================================================ +// 4. flush() +// ============================================================================ + +/** + * @brief Test that flush() removes all entries and returns correct count. + */ +static void test_pgsql_flush() { + // Ensure clean state before test + GloPgQC->flush(); + + // Add several entries + uint64_t t = now_ms(); + for (int i = 0; i < 10; i++) { + char keybuf[32]; + snprintf(keybuf, sizeof(keybuf), "SELECT flush_%d", i); + unsigned char *val = (unsigned char *)malloc(4); + memcpy(val, "data", 4); + GloPgQC->set(40000 + i, + (const unsigned char *)keybuf, strlen(keybuf), + val, 4, t, t, t + 60000); + } + + uint64_t flushed = GloPgQC->flush(); + ok(flushed == 10, + "PgSQL QC: flush() returns exactly 10"); + + // Verify entries are gone + auto entry = GloPgQC->get(40000, + (const unsigned char *)"SELECT flush_0", 14, now_ms(), 60000); + ok(entry == nullptr, + "PgSQL QC: entries gone after flush()"); +} + +// ============================================================================ +// 5. Memory tracking +// ============================================================================ + +/** + * @brief Helper to extract a named stat value from SQL3_getStats(). + * @return The stat value as uint64_t, or 0 if not found. + */ +static uint64_t get_qc_stat(PgSQL_Query_Cache *qc, const char *name) { + SQLite3_result *result = qc->SQL3_getStats(); + if (result == nullptr) return 0; + uint64_t val = 0; + for (auto it = result->rows.begin(); it != result->rows.end(); it++) { + if (strcmp((*it)->fields[0], name) == 0) { + val = strtoull((*it)->fields[1], nullptr, 10); + break; + } + } + delete result; + return val; +} + +/** + * @brief Test that set() stores data retrievable by get(), and + * flush() makes it unretrievable. + */ +static void test_pgsql_set_flush_cycle() { + GloPgQC->flush(); + + uint64_t t = now_ms(); + unsigned char *val = (unsigned char *)malloc(1024); + memset(val, 'A', 1024); + GloPgQC->set(50000, + (const unsigned char *)"SELECT cycle_test", 17, + val, 1024, t, t, t + 60000); + + auto entry = GloPgQC->get(50000, + (const unsigned char *)"SELECT cycle_test", 17, now_ms(), 60000); + ok(entry != nullptr, + "PgSQL QC: entry retrievable after set()"); + + GloPgQC->flush(); + auto entry2 = GloPgQC->get(50000, + (const unsigned char *)"SELECT cycle_test", 17, now_ms(), 60000); + ok(entry2 == nullptr, + "PgSQL QC: entry unretrievable after flush()"); +} + +// ============================================================================ +// 6. Stats counters +// ============================================================================ + +/** + * @brief Test that stats counters increment correctly. + * + * Uses SQL3_getStats() to read counter values before and after + * set/get operations. + */ +static void test_stats_counters() { + uint64_t set_before = get_qc_stat(GloPgQC, "Query_Cache_count_SET"); + uint64_t get_before = get_qc_stat(GloPgQC, "Query_Cache_count_GET"); + + uint64_t t = now_ms(); + unsigned char *val = (unsigned char *)malloc(8); + memcpy(val, "countval", 8); + GloPgQC->set(60000, + (const unsigned char *)"SELECT counter", 14, + val, 8, t, t, t + 60000); + + uint64_t set_after = get_qc_stat(GloPgQC, "Query_Cache_count_SET"); + ok(set_after > set_before, + "PgSQL QC: SET counter increments after set()"); + + // Trigger a get (hit) + GloPgQC->get(60000, + (const unsigned char *)"SELECT counter", 14, now_ms(), 60000); + + uint64_t get_after = get_qc_stat(GloPgQC, "Query_Cache_count_GET"); + ok(get_after > get_before, + "PgSQL QC: GET counter increments after get()"); +} + +// ============================================================================ +// 7. SQL3_getStats() +// ============================================================================ + +/** + * @brief Test that SQL3_getStats() returns a valid result set. + */ +static void test_sql3_get_stats() { + SQLite3_result *result = GloPgQC->SQL3_getStats(); + ok(result != nullptr, "PgSQL QC: SQL3_getStats() returns non-null"); + + if (result != nullptr) { + ok(result->columns == 2, + "PgSQL QC: SQL3_getStats() has 2 columns"); + ok(result->rows_count > 0, + "PgSQL QC: SQL3_getStats() has rows"); + delete result; + } else { + ok(0, "PgSQL QC: SQL3_getStats() has 2 columns (skipped)"); + ok(0, "PgSQL QC: SQL3_getStats() has rows (skipped)"); + } +} + +// ============================================================================ +// 8. MySQL Query Cache: Construction and flush +// ============================================================================ + +/** + * @brief Test MySQL_Query_Cache construction and basic flush. + * + * MySQL set() requires valid protocol data so we only test + * construction and flush here. + */ +static void test_mysql_construction_and_flush() { + ok(GloMyQC != nullptr, + "MySQL QC: GloMyQC is initialized"); + + // First flush clears any residual entries + GloMyQC->flush(); + // Second flush on empty cache should return 0 + uint64_t flushed = GloMyQC->flush(); + ok(flushed == 0, + "MySQL QC: flush() on empty cache returns 0"); + + SQLite3_result *result = GloMyQC->SQL3_getStats(); + ok(result != nullptr, + "MySQL QC: SQL3_getStats() returns non-null"); + if (result != nullptr) { + delete result; + } +} + +// ============================================================================ +// 9. purgeHash() eviction +// ============================================================================ + +/** + * @brief Test that purgeHash() removes expired entries. + * + * Creates entries with already-expired TTLs, then calls purgeHash() + * and verifies they are evicted. + */ +static void test_pgsql_purge_expired() { + GloPgQC->flush(); + uint64_t t = now_ms(); + + // Add entries that are already expired + for (int i = 0; i < 5; i++) { + char keybuf[32]; + snprintf(keybuf, sizeof(keybuf), "SELECT purge_%d", i); + unsigned char *val = (unsigned char *)malloc(64); + memset(val, 'X', 64); + GloPgQC->set(70000 + i, + (const unsigned char *)keybuf, strlen(keybuf), + val, 64, + t - 10000, // created 10s ago + t - 10000, + t - 1); // expired 1ms ago + } + + // Add one entry that is NOT expired + unsigned char *live_val = (unsigned char *)malloc(64); + memset(live_val, 'L', 64); + GloPgQC->set(70099, + (const unsigned char *)"SELECT live", 11, + live_val, 64, t, t, t + 60000); + + // purgeHash with small max_memory to force eviction logic to run. + // The threshold is 3% minimum, so use a size smaller than total + // cached data to ensure the purge path executes. + GloPgQC->purgeHash(1); + + // Live entry should still be accessible + auto entry = GloPgQC->get(70099, + (const unsigned char *)"SELECT live", 11, now_ms(), 60000); + ok(entry != nullptr, + "PgSQL QC: live entry survives purgeHash()"); + + GloPgQC->flush(); +} + +// ============================================================================ +// 10. Multiple entries across hash buckets +// ============================================================================ + +/** + * @brief Test storing and retrieving many entries. + * + * Verifies that bulk inserts with unique keys are all retrievable + * and that flush correctly reports the total count. + */ +static void test_pgsql_many_entries() { + GloPgQC->flush(); + uint64_t t = now_ms(); + const int N = 100; + + // Insert N entries with unique keys + for (int i = 0; i < N; i++) { + char keybuf[32]; + snprintf(keybuf, sizeof(keybuf), "SELECT many_%04d", i); + unsigned char *val = (unsigned char *)malloc(8); + snprintf((char *)val, 8, "val%04d", i); + GloPgQC->set(80000, + (const unsigned char *)keybuf, strlen(keybuf), + val, 8, t, t, t + 60000); + } + + // Verify a sample of entries are retrievable + int hits = 0; + for (int i = 0; i < N; i += 10) { + char keybuf[32]; + snprintf(keybuf, sizeof(keybuf), "SELECT many_%04d", i); + auto entry = GloPgQC->get(80000, + (const unsigned char *)keybuf, strlen(keybuf), + now_ms(), 60000); + if (entry != nullptr) hits++; + } + ok(hits == 10, + "PgSQL QC: all sampled entries retrievable from 100 inserts"); + + uint64_t flushed = GloPgQC->flush(); + ok(flushed >= (uint64_t)N, + "PgSQL QC: flush() returns count >= N after bulk insert"); +} + +// ============================================================================ +// Main +// ============================================================================ + +int main() { + plan(26); + + test_init_minimal(); + test_init_query_cache(); + + // PgSQL cache tests (exercises Query_Cache template logic) + test_pgsql_set_get(); // 4 tests + test_pgsql_cache_miss(); // 1 test + test_pgsql_user_hash_isolation(); // 2 tests + test_pgsql_replace(); // 2 tests + test_pgsql_ttl_expired(); // 1 test + test_pgsql_soft_ttl(); // 1 test + test_pgsql_flush(); // 2 tests + test_pgsql_set_flush_cycle(); // 2 tests + test_stats_counters(); // 2 tests + test_sql3_get_stats(); // 3 tests + test_mysql_construction_and_flush();// 3 tests + test_pgsql_purge_expired(); // 1 test + test_pgsql_many_entries(); // 2 tests + // Total: 26 ... let me recount + // 4+1+2+2+1+1+2+2+2+3+3+1+2 = 26 + + test_cleanup_query_cache(); + test_cleanup_minimal(); + + return exit_status(); +} diff --git a/test/tap/tests/unit/query_processor_unit-t.cpp b/test/tap/tests/unit/query_processor_unit-t.cpp new file mode 100644 index 0000000000..9b88f03f78 --- /dev/null +++ b/test/tap/tests/unit/query_processor_unit-t.cpp @@ -0,0 +1,528 @@ +/** + * @file query_processor_unit-t.cpp + * @brief Unit tests for MySQL_Query_Processor and PgSQL_Query_Processor. + * + * Tests the query processor rule management in isolation without a + * running ProxySQL instance. Covers: + * - Rule creation via new_query_rule() factory method + * - Rule field storage and retrieval + * - Rule insertion, sorting by rule_id, and commit + * - Rule retrieval via get_current_query_rules() (SQLite3 result) + * - Regex modifier parsing (CASELESS, GLOBAL) + * - Active/inactive rule filtering + * - PgSQL rule parity + * + * @note Full process_query() testing requires a MySQL_Session with + * populated connection data (username, schema, client address), + * which is beyond the scope of isolated unit tests. Those + * scenarios are covered by the existing E2E TAP tests. + * + * @see Phase 2.4 of the Unit Testing Framework (GitHub issue #5476) + */ + +#include "tap.h" +#include "test_globals.h" +#include "test_init.h" + +#include "proxysql.h" +#include "MySQL_Query_Processor.h" +#include "PgSQL_Query_Processor.h" + +#include + +// Extern declarations for Glo* pointers (defined in test_globals.cpp) +extern MySQL_Query_Processor *GloMyQPro; +extern PgSQL_Query_Processor *GloPgQPro; + +// ============================================================================ +// Helpers +// ============================================================================ + +/** + * @brief Create a simple MySQL query rule with common defaults. + * + * Most fields default to -1/NULL/false (no match / no action). + * Only rule_id, active, and explicitly passed fields are set. + */ +static MySQL_Query_Processor_Rule_t *mysql_simple_rule( + int rule_id, bool active, + const char *match_pattern = nullptr, + int destination_hostgroup = -1, + bool apply = false, + const char *username = nullptr, + int flagIN = 0, int flagOUT = -1) +{ + return MySQL_Query_Processor::new_query_rule( + rule_id, active, + username, // username + nullptr, // schemaname + flagIN, // flagIN + nullptr, // client_addr + nullptr, // proxy_addr + -1, // proxy_port + nullptr, // digest + nullptr, // match_digest + match_pattern, // match_pattern + false, // negate_match_pattern + nullptr, // re_modifiers + flagOUT, // flagOUT + nullptr, // replace_pattern + destination_hostgroup, // destination_hostgroup + -1, // cache_ttl + -1, // cache_empty_result + -1, // cache_timeout + -1, // reconnect + -1, // timeout + -1, // retries + -1, // delay + -1, // next_query_flagIN + -1, // mirror_flagOUT + -1, // mirror_hostgroup + nullptr, // error_msg + nullptr, // OK_msg + -1, // sticky_conn + -1, // multiplex + -1, // gtid_from_hostgroup + -1, // log + apply, // apply + nullptr, // attributes + nullptr // comment + ); +} + +/** + * @brief Create a simple PgSQL query rule with common defaults. + */ +static PgSQL_Query_Processor_Rule_t *pgsql_simple_rule( + int rule_id, bool active, + const char *match_pattern = nullptr, + int destination_hostgroup = -1, + bool apply = false) +{ + return PgSQL_Query_Processor::new_query_rule( + rule_id, active, + nullptr, nullptr, // username, schemaname + 0, // flagIN + nullptr, nullptr, -1, // client_addr, proxy_addr, proxy_port + nullptr, // digest + nullptr, // match_digest + match_pattern, // match_pattern + false, // negate_match_pattern + nullptr, // re_modifiers + -1, // flagOUT + nullptr, // replace_pattern + destination_hostgroup, // destination_hostgroup + -1, -1, -1, // cache_ttl, cache_empty_result, cache_timeout + -1, -1, -1, -1, // reconnect, timeout, retries, delay + -1, -1, -1, // next_query_flagIN, mirror_flagOUT, mirror_hostgroup + nullptr, nullptr, // error_msg, OK_msg + -1, -1, // sticky_conn, multiplex + -1, // log + apply, // apply + nullptr, nullptr // attributes, comment + ); +} + +// ============================================================================ +// 1. Rule creation via new_query_rule() +// ============================================================================ + +/** + * @brief Test that new_query_rule() allocates and populates a rule. + */ +static void test_mysql_rule_creation() { + auto *rule = MySQL_Query_Processor::new_query_rule( + 100, // rule_id + true, // active + "testuser", // username + "testdb", // schemaname + 0, // flagIN + "192.168.1.%", // client_addr + nullptr, // proxy_addr + -1, // proxy_port + nullptr, // digest + "^SELECT", // match_digest + "SELECT.*FROM users", // match_pattern + false, // negate_match_pattern + "CASELESS", // re_modifiers + -1, // flagOUT + nullptr, // replace_pattern + 5, // destination_hostgroup + 3000, // cache_ttl + 1, // cache_empty_result + -1, // cache_timeout + -1, // reconnect + 5000, // timeout + 3, // retries + -1, // delay + -1, // next_query_flagIN + -1, // mirror_flagOUT + -1, // mirror_hostgroup + nullptr, // error_msg + nullptr, // OK_msg + 1, // sticky_conn + 0, // multiplex + -1, // gtid_from_hostgroup + 1, // log + true, // apply + nullptr, // attributes + "route reads to HG 5" // comment + ); + + ok(rule != nullptr, "MySQL QP: new_query_rule() returns non-null"); + ok(rule->rule_id == 100, "MySQL QP: rule_id is correct"); + ok(rule->active == true, "MySQL QP: active is correct"); + ok(rule->username != nullptr && strcmp(rule->username, "testuser") == 0, + "MySQL QP: username is stored correctly"); + ok(rule->schemaname != nullptr && strcmp(rule->schemaname, "testdb") == 0, + "MySQL QP: schemaname is stored correctly"); + ok(rule->match_digest != nullptr && strcmp(rule->match_digest, "^SELECT") == 0, + "MySQL QP: match_digest is stored correctly"); + ok(rule->match_pattern != nullptr && strcmp(rule->match_pattern, "SELECT.*FROM users") == 0, + "MySQL QP: match_pattern is stored correctly"); + ok(rule->destination_hostgroup == 5, + "MySQL QP: destination_hostgroup is correct"); + ok(rule->cache_ttl == 3000, + "MySQL QP: cache_ttl is correct"); + ok(rule->timeout == 5000, + "MySQL QP: timeout is correct"); + ok(rule->retries == 3, + "MySQL QP: retries is correct"); + ok(rule->sticky_conn == 1, + "MySQL QP: sticky_conn is correct"); + ok(rule->multiplex == 0, + "MySQL QP: multiplex is correct"); + ok(rule->apply == true, + "MySQL QP: apply flag is correct"); + ok(rule->comment != nullptr && strcmp(rule->comment, "route reads to HG 5") == 0, + "MySQL QP: comment is stored correctly"); + ok(rule->log == 1, + "MySQL QP: log is correct"); + ok(rule->client_addr != nullptr && strcmp(rule->client_addr, "192.168.1.%") == 0, + "MySQL QP: client_addr is stored correctly"); + + // Verify re_modifiers parsed correctly + ok(rule->re_modifiers & QP_RE_MOD_CASELESS, + "MySQL QP: CASELESS re_modifier is set"); + + // This rule is not inserted into the QP, so we free it manually. + free(rule->username); free(rule->schemaname); + free(rule->match_digest); free(rule->match_pattern); + free(rule->client_addr); free(rule->comment); + free(rule); +} + +// ============================================================================ +// 2. Rule insertion and retrieval via get_current_query_rules() +// ============================================================================ + +/** + * @brief Test inserting rules and retrieving them via SQL result set. + */ +static void test_mysql_insert_and_retrieve() { + // Create and insert rules + auto *r1 = mysql_simple_rule(10, true, "^SELECT", 1, true); + auto *r2 = mysql_simple_rule(20, true, "^INSERT", 2, true); + auto *r3 = mysql_simple_rule(30, false, "^DELETE", 3, true); // inactive + + GloMyQPro->insert((QP_rule_t *)r1); + GloMyQPro->insert((QP_rule_t *)r2); + GloMyQPro->insert((QP_rule_t *)r3); + GloMyQPro->sort(); + GloMyQPro->commit(); + + SQLite3_result *result = GloMyQPro->get_current_query_rules(); + ok(result != nullptr, "MySQL QP: get_current_query_rules() returns non-null"); + + if (result != nullptr) { + ok(result->rows_count == 3, + "MySQL QP: get_current_query_rules() returns 3 rules"); + delete result; + } else { + ok(0, "MySQL QP: get_current_query_rules() returns 3 rules (skipped)"); + } +} + +/** + * @brief Test that rules are sorted by rule_id after sort(). + */ +static void test_mysql_rule_sorting() { + // Reset rules + GloMyQPro->reset_all(true); + + // Insert in reverse order + auto *r3 = mysql_simple_rule(300, true, nullptr, 3); + auto *r1 = mysql_simple_rule(100, true, nullptr, 1); + auto *r2 = mysql_simple_rule(200, true, nullptr, 2); + + GloMyQPro->insert((QP_rule_t *)r3); + GloMyQPro->insert((QP_rule_t *)r1); + GloMyQPro->insert((QP_rule_t *)r2); + GloMyQPro->sort(); + GloMyQPro->commit(); + + SQLite3_result *result = GloMyQPro->get_current_query_rules(); + ok(result != nullptr && result->rows_count == 3, + "MySQL QP: 3 rules after insert in reverse order"); + + if (result != nullptr && result->rows_count == 3) { + // First row should be rule_id=100 (sorted ascending) + auto it = result->rows.begin(); + ok(strcmp((*it)->fields[0], "100") == 0, + "MySQL QP: first rule after sort has rule_id=100"); + ++it; + ok(strcmp((*it)->fields[0], "200") == 0, + "MySQL QP: second rule after sort has rule_id=200"); + ++it; + ok(strcmp((*it)->fields[0], "300") == 0, + "MySQL QP: third rule after sort has rule_id=300"); + delete result; + } else { + ok(0, "MySQL QP: first rule sorted (skipped)"); + ok(0, "MySQL QP: second rule sorted (skipped)"); + ok(0, "MySQL QP: third rule sorted (skipped)"); + if (result) delete result; + } +} + +// ============================================================================ +// 3. Regex modifier parsing +// ============================================================================ + +/** + * @brief Test re_modifiers parsing for CASELESS, GLOBAL, and combined. + */ +static void test_regex_modifiers() { + auto *r1 = mysql_simple_rule(1, true); + ok((r1->re_modifiers & QP_RE_MOD_CASELESS) == 0, + "MySQL QP: no modifiers when re_modifiers is null"); + free(r1); + + // Create rule with CASELESS + auto *r2 = MySQL_Query_Processor::new_query_rule( + 2, true, nullptr, nullptr, 0, nullptr, nullptr, -1, + nullptr, nullptr, "test", false, "CASELESS", + -1, nullptr, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + nullptr, nullptr, -1, -1, -1, -1, false, nullptr, nullptr); + ok((r2->re_modifiers & QP_RE_MOD_CASELESS) != 0, + "MySQL QP: CASELESS modifier parsed"); + ok((r2->re_modifiers & QP_RE_MOD_GLOBAL) == 0, + "MySQL QP: GLOBAL not set when only CASELESS specified"); + free(r2->match_pattern); + free(r2); + + // Create rule with CASELESS,GLOBAL + auto *r3 = MySQL_Query_Processor::new_query_rule( + 3, true, nullptr, nullptr, 0, nullptr, nullptr, -1, + nullptr, nullptr, "test", false, "CASELESS,GLOBAL", + -1, nullptr, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + nullptr, nullptr, -1, -1, -1, -1, false, nullptr, nullptr); + ok((r3->re_modifiers & QP_RE_MOD_CASELESS) != 0, + "MySQL QP: CASELESS set in combined modifiers"); + ok((r3->re_modifiers & QP_RE_MOD_GLOBAL) != 0, + "MySQL QP: GLOBAL set in combined modifiers"); + free(r3->match_pattern); + free(r3); +} + +// ============================================================================ +// 4. Rule with all match fields populated +// ============================================================================ + +/** + * @brief Test rule with error_msg, OK_msg, and replace_pattern. + */ +static void test_mysql_rule_special_fields() { + auto *rule = MySQL_Query_Processor::new_query_rule( + 50, true, + nullptr, nullptr, // username, schemaname + 0, // flagIN + nullptr, nullptr, -1, // client_addr, proxy_addr, proxy_port + nullptr, // digest + nullptr, // match_digest + "^BLOCKED", // match_pattern + false, // negate_match_pattern + nullptr, // re_modifiers + -1, // flagOUT + "REWRITTEN", // replace_pattern + -1, // destination_hostgroup + -1, -1, -1, // cache_ttl, cache_empty_result, cache_timeout + -1, -1, -1, -1, // reconnect, timeout, retries, delay + -1, -1, -1, // next_query_flagIN, mirror_flagOUT, mirror_hostgroup + "Access denied", // error_msg + "Query OK", // OK_msg + -1, -1, -1, -1, // sticky_conn, multiplex, gtid_from_hostgroup, log + true, nullptr, nullptr // apply, attributes, comment + ); + + ok(rule->error_msg != nullptr && strcmp(rule->error_msg, "Access denied") == 0, + "MySQL QP: error_msg stored correctly"); + ok(rule->OK_msg != nullptr && strcmp(rule->OK_msg, "Query OK") == 0, + "MySQL QP: OK_msg stored correctly"); + ok(rule->replace_pattern != nullptr && strcmp(rule->replace_pattern, "REWRITTEN") == 0, + "MySQL QP: replace_pattern stored correctly"); + + free(rule->match_pattern); free(rule->replace_pattern); + free(rule->error_msg); free(rule->OK_msg); + free(rule); +} + +// ============================================================================ +// 5. flagIN/flagOUT chaining +// ============================================================================ + +/** + * @brief Test rule creation with flagIN/flagOUT for chain matching. + */ +static void test_mysql_flag_chaining() { + GloMyQPro->reset_all(true); + + // Rule 1: flagIN=0, flagOUT=1 (passes to next stage) + auto *r1 = mysql_simple_rule(10, true, nullptr, -1, false, + nullptr, 0, 1); + // Rule 2: flagIN=1, applies with destination + auto *r2 = mysql_simple_rule(20, true, nullptr, 5, true, + nullptr, 1, -1); + + GloMyQPro->insert((QP_rule_t *)r1); + GloMyQPro->insert((QP_rule_t *)r2); + GloMyQPro->sort(); + GloMyQPro->commit(); + + SQLite3_result *result = GloMyQPro->get_current_query_rules(); + ok(result != nullptr && result->rows_count == 2, + "MySQL QP: 2 chained rules inserted correctly"); + if (result) delete result; +} + +// ============================================================================ +// 6. Rule with username filter +// ============================================================================ + +/** + * @brief Test rule creation with username filter. + */ +static void test_mysql_rule_with_username() { + GloMyQPro->reset_all(true); + + auto *r1 = mysql_simple_rule(10, true, "^SELECT", 1, true, "admin"); + auto *r2 = mysql_simple_rule(20, true, "^SELECT", 2, true, "readonly"); + + GloMyQPro->insert((QP_rule_t *)r1); + GloMyQPro->insert((QP_rule_t *)r2); + GloMyQPro->sort(); + GloMyQPro->commit(); + + SQLite3_result *result = GloMyQPro->get_current_query_rules(); + ok(result != nullptr && result->rows_count == 2, + "MySQL QP: 2 rules with username filters inserted"); + if (result) delete result; +} + +// ============================================================================ +// 7. Reset all rules +// ============================================================================ + +/** + * @brief Test reset_all() clears all rules. + */ +static void test_mysql_reset_all() { + GloMyQPro->reset_all(true); + + GloMyQPro->insert((QP_rule_t *)mysql_simple_rule(10, true)); + GloMyQPro->insert((QP_rule_t *)mysql_simple_rule(20, true)); + GloMyQPro->commit(); + + GloMyQPro->reset_all(true); + + SQLite3_result *result = GloMyQPro->get_current_query_rules(); + ok(result != nullptr && result->rows_count == 0, + "MySQL QP: no rules after reset_all()"); + if (result) delete result; +} + +// ============================================================================ +// 8. Stats commands counters +// ============================================================================ + +/** + * @brief Test get_stats_commands_counters() returns a valid result. + */ +static void test_mysql_stats_counters() { + SQLite3_result *result = GloMyQPro->get_stats_commands_counters(); + ok(result != nullptr, + "MySQL QP: get_stats_commands_counters() returns non-null"); + if (result != nullptr) { + ok(result->columns > 0, + "MySQL QP: stats counters result has columns"); + delete result; + } else { + ok(0, "MySQL QP: stats counters result has columns (skipped)"); + } +} + +// ============================================================================ +// 9. PgSQL Query Processor: Basic operations +// ============================================================================ + +/** + * @brief Test PgSQL rule creation and insertion. + */ +static void test_pgsql_rule_creation_and_insert() { + auto *rule = pgsql_simple_rule(10, true, "^SELECT", 1, true); + ok(rule != nullptr, "PgSQL QP: new_query_rule() returns non-null"); + ok(rule->rule_id == 10, "PgSQL QP: rule_id is correct"); + ok(rule->destination_hostgroup == 1, + "PgSQL QP: destination_hostgroup is correct"); + + GloPgQPro->insert((QP_rule_t *)rule); + GloPgQPro->sort(); + GloPgQPro->commit(); + + SQLite3_result *result = GloPgQPro->get_current_query_rules(); + ok(result != nullptr && result->rows_count >= 1, + "PgSQL QP: rule appears in get_current_query_rules()"); + if (result) delete result; +} + +/** + * @brief Test PgSQL reset and stats. + */ +static void test_pgsql_reset_and_stats() { + GloPgQPro->reset_all(true); + SQLite3_result *result = GloPgQPro->get_current_query_rules(); + ok(result != nullptr && result->rows_count == 0, + "PgSQL QP: no rules after reset_all()"); + if (result) delete result; +} + +// ============================================================================ +// Main +// ============================================================================ + +int main() { + plan(42); + + test_init_minimal(); + test_init_query_processor(); + + // MySQL tests + test_mysql_rule_creation(); // 18 tests + test_mysql_insert_and_retrieve(); // 2 tests + test_mysql_rule_sorting(); // 4 tests + test_regex_modifiers(); // 5 tests + test_mysql_rule_special_fields(); // 3 tests + test_mysql_flag_chaining(); // 1 test + test_mysql_rule_with_username(); // 1 test + test_mysql_reset_all(); // 1 test + test_mysql_stats_counters(); // 2 tests + + // PgSQL tests + test_pgsql_rule_creation_and_insert(); // 4 tests + test_pgsql_reset_and_stats(); // 1 test + + test_cleanup_query_processor(); + test_cleanup_minimal(); + + return exit_status(); +} diff --git a/test/tap/tests/unit/rule_matching_unit-t.cpp b/test/tap/tests/unit/rule_matching_unit-t.cpp new file mode 100644 index 0000000000..cd23d1450c --- /dev/null +++ b/test/tap/tests/unit/rule_matching_unit-t.cpp @@ -0,0 +1,233 @@ +/** + * @file rule_matching_unit-t.cpp + * @brief Unit tests for the extracted rule_matches_query() function. + * + * Tests the query rule matching predicate extracted from process_query() + * in lib/Query_Processor.cpp. The function takes all inputs as parameters + * (no session dependency) and supports both RE2 and PCRE regex engines. + * + * @see Phase 3.2 (GitHub issue #5490) + */ + +#include "tap.h" +#include "test_globals.h" +#include "test_init.h" + +#include "proxysql.h" +#include "cpp.h" +#include "query_processor.h" +#include "QP_rule_text.h" + +#include + +/** + * @brief Create a zeroed QP_rule_t with safe defaults. + */ +static QP_rule_t make_rule() { + QP_rule_t rule {}; + rule.flagIN = 0; + rule.proxy_port = -1; + rule.client_addr_wildcard_position = -1; + return rule; +} + +// ============================================================================ +// 1. Basic matching criteria +// ============================================================================ + +static void test_match_all() { + QP_rule_t r = make_rule(); + ok(rule_matches_query(&r, 0, "anyuser", "anydb", "10.0.0.1", + "127.0.0.1", 6033, 42, "digest", "SELECT 1", nullptr, 2), + "rule with no criteria matches everything"); +} + +static void test_flagIN() { + QP_rule_t r = make_rule(); + r.flagIN = 3; + ok(rule_matches_query(&r, 3, "u", "d", "1.2.3.4", + "127.0.0.1", 6033, 0, nullptr, "SELECT 1", nullptr, 2), + "flagIN=3 matches current_flagIN=3"); + ok(!rule_matches_query(&r, 0, "u", "d", "1.2.3.4", + "127.0.0.1", 6033, 0, nullptr, "SELECT 1", nullptr, 2), + "flagIN=3 does not match current_flagIN=0"); +} + +static void test_username() { + QP_rule_t r = make_rule(); + r.username = const_cast("appuser"); + ok(rule_matches_query(&r, 0, "appuser", "db", "10.0.0.1", + "127.0.0.1", 6033, 0, nullptr, "SELECT 1", nullptr, 2), + "username matches exactly"); + ok(!rule_matches_query(&r, 0, "other", "db", "10.0.0.1", + "127.0.0.1", 6033, 0, nullptr, "SELECT 1", nullptr, 2), + "username mismatch rejects"); + ok(!rule_matches_query(&r, 0, nullptr, "db", "10.0.0.1", + "127.0.0.1", 6033, 0, nullptr, "SELECT 1", nullptr, 2), + "username rule rejects null session username"); +} + +static void test_schemaname() { + QP_rule_t r = make_rule(); + r.schemaname = const_cast("analytics"); + ok(rule_matches_query(&r, 0, "u", "analytics", "10.0.0.1", + "127.0.0.1", 6033, 0, nullptr, "SELECT 1", nullptr, 2), + "schemaname matches"); + ok(!rule_matches_query(&r, 0, "u", "other_db", "10.0.0.1", + "127.0.0.1", 6033, 0, nullptr, "SELECT 1", nullptr, 2), + "schemaname mismatch rejects"); +} + +static void test_client_addr_wildcard() { + QP_rule_t r = make_rule(); + r.client_addr = const_cast("192.168.%"); + r.client_addr_wildcard_position = 8; // position of '%' + ok(rule_matches_query(&r, 0, "u", "d", "192.168.55.19", + "127.0.0.1", 6033, 0, nullptr, "SELECT 1", nullptr, 2), + "client_addr wildcard matches"); + ok(!rule_matches_query(&r, 0, "u", "d", "10.0.0.1", + "127.0.0.1", 6033, 0, nullptr, "SELECT 1", nullptr, 2), + "client_addr wildcard rejects non-match"); +} + +static void test_proxy_addr_port() { + QP_rule_t r = make_rule(); + r.proxy_addr = const_cast("10.0.0.5"); + r.proxy_port = 6033; + ok(rule_matches_query(&r, 0, "u", "d", "1.2.3.4", + "10.0.0.5", 6033, 0, nullptr, "SELECT 1", nullptr, 2), + "proxy_addr + proxy_port match"); + ok(!rule_matches_query(&r, 0, "u", "d", "1.2.3.4", + "10.0.0.5", 6034, 0, nullptr, "SELECT 1", nullptr, 2), + "proxy_port mismatch rejects"); +} + +static void test_digest() { + QP_rule_t r = make_rule(); + r.digest = 123456789ULL; + ok(rule_matches_query(&r, 0, "u", "d", "1.2.3.4", + "127.0.0.1", 6033, 123456789ULL, nullptr, "SELECT 1", nullptr, 2), + "digest matches"); + ok(!rule_matches_query(&r, 0, "u", "d", "1.2.3.4", + "127.0.0.1", 6033, 999ULL, nullptr, "SELECT 1", nullptr, 2), + "digest mismatch rejects"); +} + +// ============================================================================ +// 2. Regex matching +// ============================================================================ + +static void test_match_digest_re2() { + QP_rule_t r = make_rule(); + r.match_digest = const_cast("^SELECT .* FROM users$"); + ok(rule_matches_query(&r, 0, "u", "d", "1.2.3.4", + "127.0.0.1", 6033, 0, "SELECT name FROM users", + "SELECT name FROM users WHERE id=1", nullptr, 2), + "match_digest regex matches with RE2"); +} + +static void test_match_digest_pcre() { + QP_rule_t r = make_rule(); + r.match_digest = const_cast("^SELECT .* FROM users$"); + ok(rule_matches_query(&r, 0, "u", "d", "1.2.3.4", + "127.0.0.1", 6033, 0, "SELECT email FROM users", + "SELECT email FROM users WHERE id=1", nullptr, 1), + "match_digest regex matches with PCRE"); +} + +static void test_match_pattern() { + QP_rule_t r = make_rule(); + r.match_pattern = const_cast("SELECT .* FROM orders"); + ok(rule_matches_query(&r, 0, "u", "d", "1.2.3.4", + "127.0.0.1", 6033, 0, nullptr, + "SELECT id FROM orders WHERE id=10", nullptr, 2), + "match_pattern regex matches query text"); +} + +static void test_negate_match_pattern() { + QP_rule_t r = make_rule(); + r.match_pattern = const_cast("DELETE"); + r.negate_match_pattern = true; + ok(rule_matches_query(&r, 0, "u", "d", "1.2.3.4", + "127.0.0.1", 6033, 0, nullptr, "SELECT 1", nullptr, 2), + "negate_match_pattern inverts result"); +} + +static void test_caseless_modifier() { + QP_rule_t r = make_rule(); + r.match_pattern = const_cast("select .* from inventory"); + r.re_modifiers = QP_RE_MOD_CASELESS; + ok(rule_matches_query(&r, 0, "u", "d", "1.2.3.4", + "127.0.0.1", 6033, 0, nullptr, + "SELECT SKU FROM INVENTORY", nullptr, 2), + "CASELESS modifier makes regex case-insensitive"); +} + +static void test_rewritten_query() { + QP_rule_t r = make_rule(); + r.match_pattern = const_cast("SELECT .* FROM rewritten_table"); + ok(rule_matches_query(&r, 0, "u", "d", "1.2.3.4", + "127.0.0.1", 6033, 0, nullptr, + "SELECT * FROM original_table", + "SELECT * FROM rewritten_table", 2), + "rewritten query used for match_pattern when present"); +} + +// ============================================================================ +// 3. Combined criteria (AND logic) +// ============================================================================ + +static void test_combined_criteria() { + QP_rule_t r = make_rule(); + r.username = const_cast("appuser"); + r.schemaname = const_cast("analytics"); + r.proxy_addr = const_cast("10.0.0.9"); + r.proxy_port = 6033; + r.match_pattern = const_cast("SELECT"); + ok(rule_matches_query(&r, 0, "appuser", "analytics", "1.2.3.4", + "10.0.0.9", 6033, 0, nullptr, "SELECT 1", nullptr, 2), + "multiple criteria use AND logic — all match"); + ok(!rule_matches_query(&r, 0, "other", "analytics", "1.2.3.4", + "10.0.0.9", 6033, 0, nullptr, "SELECT 1", nullptr, 2), + "multiple criteria AND logic — username mismatch rejects"); +} + +// ============================================================================ +// 4. Edge cases +// ============================================================================ + +static void test_null_rule() { + ok(!rule_matches_query(nullptr, 0, "u", "d", "1.2.3.4", + "127.0.0.1", 6033, 0, nullptr, "SELECT 1", nullptr, 2), + "null rule returns false"); +} + +// ============================================================================ +// Main +// ============================================================================ + +int main() { + plan(23); + + test_init_minimal(); + + test_match_all(); // 1 + test_flagIN(); // 2 + test_username(); // 3 + test_schemaname(); // 2 + test_client_addr_wildcard(); // 2 + test_proxy_addr_port(); // 2 + test_digest(); // 2 + test_match_digest_re2(); // 1 + test_match_digest_pcre(); // 1 + test_match_pattern(); // 1 + test_negate_match_pattern(); // 1 + test_caseless_modifier(); // 1 + test_rewritten_query(); // 1 + test_combined_criteria(); // 2 + test_null_rule(); // 1 + // Total: 22 + + 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 0000000000..4b02d027b4 --- /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(); +} diff --git a/test/tap/tests/unit/smoke_test-t.cpp b/test/tap/tests/unit/smoke_test-t.cpp new file mode 100644 index 0000000000..5982b8c9dd --- /dev/null +++ b/test/tap/tests/unit/smoke_test-t.cpp @@ -0,0 +1,111 @@ +/** + * @file smoke_test-t.cpp + * @brief Smoke test for the ProxySQL unit test harness. + * + * Validates that the test infrastructure (test_globals + test_init) + * works correctly by performing minimal operations on each supported + * component. This test must pass before any component-specific unit + * tests can be trusted. + * + * Test coverage: + * 1. test_init_minimal() — GloVars is usable + * 2. test_init_auth() — MySQL_Authentication add/lookup cycle + * 3. test_cleanup_*() — clean shutdown without leaks + * + * @see Phase 2.1 of the Unit Testing Framework (GitHub issue #5473) + */ + +#include "tap.h" +#include "test_globals.h" +#include "test_init.h" + +#include "proxysql.h" +#include "MySQL_Authentication.hpp" +#include "PgSQL_Authentication.h" + +// Extern declarations for Glo* pointers (defined in test_globals.cpp) +extern MySQL_Authentication *GloMyAuth; +extern PgSQL_Authentication *GloPgAuth; + +/** + * @brief Test that minimal initialization sets up GloVars correctly. + */ +static void test_minimal_init() { + int rc = test_init_minimal(); + ok(rc == 0, "test_init_minimal() returns 0"); + ok(GloVars.datadir != nullptr, "GloVars.datadir is set after init"); + ok(GloVars.global.nostart == true, "GloVars.global.nostart is true"); +} + +/** + * @brief Test MySQL_Authentication add/lookup/del cycle. + */ +static void test_mysql_auth_basic() { + int rc = test_init_auth(); + ok(rc == 0, "test_init_auth() returns 0"); + ok(GloMyAuth != nullptr, "GloMyAuth is initialized"); + ok(GloPgAuth != nullptr, "GloPgAuth is initialized"); + + // Add a frontend user + bool added = GloMyAuth->add( + (char *)"testuser", // username + (char *)"testpass", // password + USERNAME_FRONTEND, // user type + false, // use_ssl + 0, // default_hostgroup + (char *)"", // default_schema + false, // schema_locked + false, // transaction_persistent + false, // fast_forward + 100, // max_connections + (char *)"", // attributes + (char *)"" // comment + ); + ok(added == true, "GloMyAuth->add() succeeds for frontend user"); + + // Verify user exists + bool exists = GloMyAuth->exists((char *)"testuser"); + ok(exists == true, "GloMyAuth->exists() returns true for added user"); + + // Verify user does not exist + bool not_exists = GloMyAuth->exists((char *)"nonexistent"); + ok(not_exists == false, "GloMyAuth->exists() returns false for unknown user"); + + // Cleanup + test_cleanup_auth(); + ok(GloMyAuth == nullptr, "GloMyAuth is nullptr after cleanup"); + ok(GloPgAuth == nullptr, "GloPgAuth is nullptr after cleanup"); +} + +/** + * @brief Test idempotency of init/cleanup functions. + */ +static void test_idempotency() { + // Double init should be safe + int rc1 = test_init_minimal(); + int rc2 = test_init_minimal(); + ok(rc1 == 0 && rc2 == 0, "test_init_minimal() is idempotent"); + + int rc3 = test_init_auth(); + int rc4 = test_init_auth(); + ok(rc3 == 0 && rc4 == 0, "test_init_auth() is idempotent"); + + // Double cleanup should be safe + test_cleanup_auth(); + test_cleanup_auth(); // should not crash + ok(1, "test_cleanup_auth() double-call does not crash"); + + test_cleanup_minimal(); + test_cleanup_minimal(); // should not crash + ok(1, "test_cleanup_minimal() double-call does not crash"); +} + +int main() { + plan(15); + + test_minimal_init(); + test_mysql_auth_basic(); + test_idempotency(); + + return exit_status(); +} diff --git a/test/tap/tests/unit/transaction_state_unit-t.cpp b/test/tap/tests/unit/transaction_state_unit-t.cpp new file mode 100644 index 0000000000..457c47da88 --- /dev/null +++ b/test/tap/tests/unit/transaction_state_unit-t.cpp @@ -0,0 +1,117 @@ +/** + * @file transaction_state_unit-t.cpp + * @brief Unit tests for transaction state tracking logic. + * + * @see Phase 3.8 (GitHub issue #5496) + */ + +#include "tap.h" +#include "test_globals.h" +#include "test_init.h" +#include "proxysql.h" +#include "TransactionState.h" + +// ============================================================================ +// 1. update_transaction_persistent_hostgroup +// ============================================================================ + +static void test_persistence_disabled() { + ok(update_transaction_persistent_hostgroup(false, -1, 5, true) == -1, + "disabled: returns -1 even with active txn"); + ok(update_transaction_persistent_hostgroup(false, 3, 5, true) == -1, + "disabled: clears existing lock"); +} + +static void test_txn_start_locks() { + ok(update_transaction_persistent_hostgroup(true, -1, 5, true) == 5, + "txn start: locks to current HG"); + ok(update_transaction_persistent_hostgroup(true, -1, 0, true) == 0, + "txn start: locks to HG 0"); +} + +static void test_txn_end_unlocks() { + ok(update_transaction_persistent_hostgroup(true, 5, 5, false) == -1, + "txn end: unlocks when txn completes"); + ok(update_transaction_persistent_hostgroup(true, 3, 10, false) == -1, + "txn end: unlocks regardless of current HG"); +} + +static void test_no_change() { + // No txn, no existing lock → stays unlocked + ok(update_transaction_persistent_hostgroup(true, -1, 5, false) == -1, + "no change: no txn, stays unlocked"); + // Active txn, already locked → stays locked + ok(update_transaction_persistent_hostgroup(true, 5, 5, true) == 5, + "no change: already locked, stays locked"); + // Already locked on HG 5, txn still active on different current_hostgroup + // → stays locked on original HG (doesn't change to new HG) + ok(update_transaction_persistent_hostgroup(true, 5, 10, true) == 5, + "no change: locked HG stays even if current_hostgroup differs"); +} + +static void test_txn_lifecycle() { + int state = -1; + // BEGIN + state = update_transaction_persistent_hostgroup(true, state, 7, true); + ok(state == 7, "lifecycle: BEGIN locks to HG 7"); + // Mid-transaction query + state = update_transaction_persistent_hostgroup(true, state, 7, true); + ok(state == 7, "lifecycle: mid-txn stays locked"); + // COMMIT + state = update_transaction_persistent_hostgroup(true, state, 7, false); + ok(state == -1, "lifecycle: COMMIT unlocks"); +} + +// ============================================================================ +// 2. is_transaction_timed_out +// ============================================================================ + +static void test_timeout_exceeded() { + // started 10s ago, limit 5s (in ms), times in microseconds + ok(is_transaction_timed_out(1000000, 11000000, 5000) == true, + "timeout: 10s elapsed > 5s limit"); +} + +static void test_timeout_not_exceeded() { + ok(is_transaction_timed_out(1000000, 3000000, 5000) == false, + "no timeout: 2s elapsed < 5s limit"); +} + +static void test_timeout_boundary() { + // Exactly at limit: 5000ms elapsed, limit 5000ms → NOT timed out (needs >) + ok(is_transaction_timed_out(1000000, 6000000, 5000) == false, + "no timeout: elapsed == limit (strict > comparison)"); +} + +static void test_timeout_no_transaction() { + ok(is_transaction_timed_out(0, 99000000, 5000) == false, + "no timeout: no active transaction (started_at=0)"); +} + +static void test_timeout_no_limit() { + ok(is_transaction_timed_out(1000000, 99000000, 0) == false, + "no timeout: limit disabled (max=0)"); + ok(is_transaction_timed_out(1000000, 99000000, -1) == false, + "no timeout: limit disabled (max=-1)"); +} + +int main() { + plan(19); + int rc = test_init_minimal(); + ok(rc == 0, "test_init_minimal() succeeds"); + + test_persistence_disabled(); // 2 + test_txn_start_locks(); // 2 + test_txn_end_unlocks(); // 2 + test_no_change(); // 3 + test_txn_lifecycle(); // 3 + test_timeout_exceeded(); // 1 + test_timeout_not_exceeded(); // 1 + test_timeout_boundary(); // 1 + test_timeout_no_transaction(); // 1 + test_timeout_no_limit(); // 2 + // Total: 1+2+2+2+3+3+1+1+1+1+2 = 19 + + test_cleanup_minimal(); + return exit_status(); +}