diff --git a/examples/jdbc-dispatcher-demo/.gitignore b/examples/jdbc-dispatcher-demo/.gitignore new file mode 100644 index 000000000..b7062213a --- /dev/null +++ b/examples/jdbc-dispatcher-demo/.gitignore @@ -0,0 +1,15 @@ +# Gradle +.gradle/ +build/ + +# IDE +.idea/ +*.iml +.vscode/ + +# Downloaded drivers (can be re-downloaded) +drivers/ + +# OS files +.DS_Store +Thumbs.db diff --git a/examples/jdbc-dispatcher-demo/README.md b/examples/jdbc-dispatcher-demo/README.md new file mode 100644 index 000000000..7de2ce646 --- /dev/null +++ b/examples/jdbc-dispatcher-demo/README.md @@ -0,0 +1,259 @@ +# JDBC Dispatcher Demo + +This example demonstrates how to use the **JDBC Dispatcher** module to load and manage multiple versions of the ClickHouse JDBC driver with automatic failover capabilities. + +## Overview + +The JDBC Dispatcher allows you to: +- Load multiple JDBC driver versions in isolated classloaders +- Automatically failover between driver versions when operations fail +- Use different retry strategies (NewestFirst, RoundRobin, FailoverOnly) +- Integrate with standard JDBC DriverManager + +## Prerequisites + +1. **Java 17+** installed +2. **ClickHouse server** running on `localhost:8123` + + Start a ClickHouse server using Docker: + ```bash + docker run -d -p 8123:8123 --name clickhouse clickhouse/clickhouse-server + ``` + +3. **Build the parent project** (to install jdbc-dispatcher to local Maven repository): + ```bash + cd ../.. + mvn clean install -DskipTests + ``` + +## Project Structure + +``` +jdbc-dispatcher-demo/ +├── build.gradle.kts # Gradle build configuration +├── settings.gradle.kts # Gradle settings +├── gradle.properties # Project properties +├── drivers/ # Downloaded driver JARs (created by downloadDrivers task) +│ ├── clickhouse-jdbc-0.9.6-all.jar +│ └── clickhouse-jdbc-0.7.2-all.jar +└── src/main/java/ + └── com/clickhouse/examples/dispatcher/ + └── DispatcherDemo.java +``` + +## Running the Demo + +### Step 1: Download Driver JARs + +The build will automatically download the required driver versions from GitHub releases: + +```bash +./gradlew downloadDrivers +``` + +This downloads: +- `clickhouse-jdbc-0.9.6-all.jar` (latest version) +- `clickhouse-jdbc-0.7.2-all.jar` (older version) + +### Step 2: Run the Demo + +```bash +./gradlew run +``` + +## Running the HTTP Service + +There's also a simple HTTP backend service that demonstrates jdbc-dispatcher in a web context. +It uses only JDK built-in tools (`com.sun.net.httpserver.HttpServer`) - no external frameworks. + +### Start the Service + +```bash +./gradlew runService +``` + +The service starts on **http://localhost:8080**. + +### Available Endpoints + +| Endpoint | Description | +|----------|-------------| +| `GET /health` | Health check - returns service status | +| `GET /version` | Returns ClickHouse server version | +| `GET /drivers` | Lists all loaded driver versions with health status | +| `GET /query?sql=SELECT...` | Executes a SELECT query and returns JSON results | + +### Example Requests + +```bash +# Health check +curl http://localhost:8080/health + +# Get ClickHouse version +curl http://localhost:8080/version + +# List loaded drivers +curl http://localhost:8080/drivers + +# Execute a query +curl "http://localhost:8080/query?sql=SELECT%20number%20FROM%20system.numbers%20LIMIT%205" + +# Query with more complex SQL +curl "http://localhost:8080/query?sql=SELECT%20name,%20value%20FROM%20system.settings%20LIMIT%2010" +``` + +### Example Responses + +**GET /health** +```json +{"status":"ok","service":"jdbc-dispatcher-demo"} +``` + +**GET /version** +```json +{"clickhouse_version":"24.8.1.1","server_time":"2026-02-02 21:30:00","status":"connected"} +``` + +**GET /drivers** +```json +{ + "drivers": [ + {"version":"0.9.6","healthy":"true","major":"0","minor":"9"}, + {"version":"0.7.2","healthy":"true","major":"0","minor":"7"} + ], + "newest": "0.9.6", + "count": 2 +} +``` + +**GET /query?sql=SELECT...** +```json +{ + "columns": ["number"], + "rows": [[0],[1],[2],[3],[4]], + "row_count": 5 +} +``` + +## What the Demo Shows + +The demo runs four scenarios: + +### Demo 1: Basic Usage +- Loads all driver JARs from the `drivers/` directory +- Connects to ClickHouse and executes simple queries +- Shows transparent failover handling + +### Demo 2: Retry Strategies +- **NewestFirstRetryStrategy**: Tries newest version first, then older versions +- **RoundRobinRetryStrategy**: Rotates starting version for load distribution + +### Demo 3: DriverManager Integration +- Registers the dispatcher with `java.sql.DriverManager` +- Uses standard JDBC URL with `jdbc:dispatcher:` prefix +- Properly deregisters when done + +### Demo 4: Version Inspection +- Lists all loaded driver versions +- Shows health status of each version +- Demonstrates marking versions as unhealthy + +## Configuration + +### Changing ClickHouse Connection + +Edit `DispatcherDemo.java`: + +```java +private static final String CLICKHOUSE_URL = "jdbc:clickhouse://your-host:8123/your-database"; +``` + +### Using Different Driver Versions + +Edit `build.gradle.kts` to modify the `downloadDrivers` task: + +```kotlin +val drivers = mapOf( + "clickhouse-jdbc-0.9.6-all.jar" to "https://github.com/ClickHouse/clickhouse-java/releases/download/v0.9.6/clickhouse-jdbc-0.9.6-all.jar", + "clickhouse-jdbc-0.8.0-all.jar" to "https://github.com/ClickHouse/clickhouse-java/releases/download/v0.8.0/clickhouse-jdbc-0.8.0-all.jar" +) +``` + +### Retry Strategy Configuration + +```java +// Maximum 3 retries, skip unhealthy versions on first pass +new NewestFirstRetryStrategy(3, true) + +// Maximum 2 retries with round-robin +new RoundRobinRetryStrategy(2) + +// Failover-only strategy +new FailoverOnlyRetryStrategy(3) +``` + +## Expected Output + +``` +=== JDBC Dispatcher Demo === + +--- Demo 1: Basic Usage --- +Loaded 2 driver versions from /path/to/drivers +Connected to ClickHouse version: 24.x.x.x +Query result: 2 at 2026-02-02 ... +Demo 1 completed successfully + +--- Demo 2: Retry Strategies --- +Using NewestFirstRetryStrategy... + [NewestFirst] Query executed at: ... +Using RoundRobinRetryStrategy... + [RoundRobin-1] Query executed at: ... + [RoundRobin-2] Query executed at: ... + [RoundRobin-3] Query executed at: ... +Demo 2 completed successfully + +--- Demo 3: DriverManager Integration --- +Dispatcher registered with DriverManager +DriverManager query result: Hello from DriverManager! +Demo 3 completed successfully +Dispatcher deregistered from DriverManager + +--- Demo 4: Version Inspection --- +Loaded driver versions: + - Version 0.9.6: healthy=true, major=0, minor=9 + - Version 0.7.2: healthy=true, major=0, minor=7 +Newest version: 0.9.6 +Marking version 0.7.2 as unhealthy for demonstration... +Updated version status: + - Version 0.9.6: healthy=true + - Version 0.7.2: healthy=false +Query with unhealthy version marked: Still working! +Demo 4 completed successfully + +=== All demos completed successfully! === +``` + +## Troubleshooting + +### "Drivers directory not found" +Run `./gradlew downloadDrivers` to download the driver JARs. + +### Connection refused +Ensure ClickHouse is running on `localhost:8123`: +```bash +docker ps | grep clickhouse +# If not running: +docker start clickhouse +``` + +### "jdbc-dispatcher not found" +Build the parent project first: +```bash +cd ../.. +mvn clean install -DskipTests +``` + +## Learn More + +- [JDBC Dispatcher README](../../jdbc-dispatcher/README.md) - Full documentation +- [ClickHouse JDBC Releases](https://github.com/ClickHouse/clickhouse-java/releases) - Available driver versions diff --git a/examples/jdbc-dispatcher-demo/build.gradle.kts b/examples/jdbc-dispatcher-demo/build.gradle.kts new file mode 100644 index 000000000..ee427f918 --- /dev/null +++ b/examples/jdbc-dispatcher-demo/build.gradle.kts @@ -0,0 +1,81 @@ +import java.net.URI + +plugins { + java + application +} + +group = "com.clickhouse.examples" +version = "1.0.0-SNAPSHOT" + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +repositories { + mavenLocal() + mavenCentral() + maven("https://central.sonatype.com/repository/maven-snapshots/") +} + +val ch_java_client_version: String by extra + +dependencies { + // JDBC Dispatcher from local build or Maven Central + implementation("com.clickhouse:jdbc-dispatcher:${ch_java_client_version}-SNAPSHOT") + + // SLF4J for logging + implementation("org.slf4j:slf4j-api:2.0.16") + implementation("org.slf4j:slf4j-simple:2.0.16") +} + +application { + mainClass.set("com.clickhouse.examples.dispatcher.DispatcherDemo") +} + +// Task to run the HTTP service +tasks.register("runService") { + group = "application" + description = "Runs the HTTP backend service on port 8080" + classpath = sourceSets["main"].runtimeClasspath + mainClass.set("com.clickhouse.examples.dispatcher.DispatcherService") + dependsOn("downloadDrivers") +} + +// Task to download driver JARs from GitHub releases +tasks.register("downloadDrivers") { + group = "setup" + description = "Downloads ClickHouse JDBC driver JARs from GitHub releases" + + val driversDir = layout.projectDirectory.dir("drivers") + + doLast { + val drivers = mapOf( + "clickhouse-jdbc-0.9.6-all.jar" to "https://github.com/ClickHouse/clickhouse-java/releases/download/v0.9.6/clickhouse-jdbc-0.9.6-all.jar", + "clickhouse-jdbc-0.7.2-all.jar" to "https://github.com/ClickHouse/clickhouse-java/releases/download/v0.7.2/clickhouse-jdbc-0.7.2-all.jar" + ) + + driversDir.asFile.mkdirs() + + drivers.forEach { (filename, url) -> + val targetFile = driversDir.file(filename).asFile + if (!targetFile.exists()) { + println("Downloading $filename from $url") + URI.create(url).toURL().openStream().use { input -> + targetFile.outputStream().use { output -> + input.copyTo(output) + } + } + println("Downloaded $filename (${targetFile.length() / 1024} KB)") + } else { + println("$filename already exists, skipping download") + } + } + } +} + +tasks.named("run") { + dependsOn("downloadDrivers") +} diff --git a/examples/jdbc-dispatcher-demo/gradle.properties b/examples/jdbc-dispatcher-demo/gradle.properties new file mode 100644 index 000000000..57f154b4e --- /dev/null +++ b/examples/jdbc-dispatcher-demo/gradle.properties @@ -0,0 +1 @@ +ch_java_client_version=0.9.6 diff --git a/examples/jdbc-dispatcher-demo/gradle/wrapper/gradle-wrapper.properties b/examples/jdbc-dispatcher-demo/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..a4413138c --- /dev/null +++ b/examples/jdbc-dispatcher-demo/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/examples/jdbc-dispatcher-demo/gradlew b/examples/jdbc-dispatcher-demo/gradlew new file mode 100755 index 000000000..b740cf133 --- /dev/null +++ b/examples/jdbc-dispatcher-demo/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/examples/jdbc-dispatcher-demo/gradlew.bat b/examples/jdbc-dispatcher-demo/gradlew.bat new file mode 100644 index 000000000..7101f8e46 --- /dev/null +++ b/examples/jdbc-dispatcher-demo/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/examples/jdbc-dispatcher-demo/settings.gradle.kts b/examples/jdbc-dispatcher-demo/settings.gradle.kts new file mode 100644 index 000000000..1cf3b77df --- /dev/null +++ b/examples/jdbc-dispatcher-demo/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "jdbc-dispatcher-demo" diff --git a/examples/jdbc-dispatcher-demo/src/main/java/com/clickhouse/examples/dispatcher/DispatcherDemo.java b/examples/jdbc-dispatcher-demo/src/main/java/com/clickhouse/examples/dispatcher/DispatcherDemo.java new file mode 100644 index 000000000..0ad53fb9d --- /dev/null +++ b/examples/jdbc-dispatcher-demo/src/main/java/com/clickhouse/examples/dispatcher/DispatcherDemo.java @@ -0,0 +1,287 @@ +package com.clickhouse.examples.dispatcher; + +import com.clickhouse.jdbc.dispatcher.DispatcherDriver; +import com.clickhouse.jdbc.dispatcher.DispatcherException; +import com.clickhouse.jdbc.dispatcher.DriverVersion; +import com.clickhouse.jdbc.dispatcher.strategy.NewestFirstRetryStrategy; +import com.clickhouse.jdbc.dispatcher.strategy.RoundRobinRetryStrategy; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.Properties; + +/** + * Demonstrates the JDBC Dispatcher functionality with multiple ClickHouse JDBC driver versions. + * + *

This example shows how to: + *

+ * + *

Before running, ensure you have a ClickHouse server running on localhost:8123. + * You can start one using Docker: + *

+ * docker run -d -p 8123:8123 --name clickhouse clickhouse/clickhouse-server
+ * 
+ */ +public class DispatcherDemo { + + private static final Logger log = LoggerFactory.getLogger(DispatcherDemo.class); + + // Configuration - adjust these for your environment + private static final String CLICKHOUSE_URL = "jdbc:clickhouse://localhost:8123/default"; + private static final String DRIVERS_DIR = "drivers"; + private static final String DRIVER_CLASS_NAME = "com.clickhouse.jdbc.ClickHouseDriver"; + + public static void main(String[] args) { + log.info("=== JDBC Dispatcher Demo ==="); + log.info(""); + + try { + // Demo 1: Basic usage with directory loading + demoBasicUsage(); + + // Demo 2: Using different retry strategies + demoRetryStrategies(); + + // Demo 3: Using with DriverManager + demoDriverManagerIntegration(); + + // Demo 4: Inspecting loaded versions + demoVersionInspection(); + + log.info(""); + log.info("=== All demos completed successfully! ==="); + + } catch (Exception e) { + log.error("Demo failed", e); + System.exit(1); + } + } + + /** + * Demo 1: Basic usage - load drivers from directory and execute queries. + */ + private static void demoBasicUsage() throws SQLException { + log.info("--- Demo 1: Basic Usage ---"); + + File driversDir = new File(DRIVERS_DIR); + if (!driversDir.exists() || !driversDir.isDirectory()) { + throw new IllegalStateException("Drivers directory not found: " + driversDir.getAbsolutePath() + + ". Run './gradlew downloadDrivers' first."); + } + + // Create dispatcher and load all drivers from directory + DispatcherDriver dispatcher = new DispatcherDriver(DRIVER_CLASS_NAME); + int loadedCount = dispatcher.loadFromDirectory(driversDir); + log.info("Loaded {} driver versions from {}", loadedCount, driversDir.getAbsolutePath()); + + // Connect and execute a query + Properties props = new Properties(); + props.setProperty("user", "default"); + props.setProperty("password", ""); + + try (Connection conn = dispatcher.connect(CLICKHOUSE_URL, props); + Statement stmt = conn.createStatement()) { + + // Query ClickHouse version + try (ResultSet rs = stmt.executeQuery("SELECT version()")) { + if (rs.next()) { + log.info("Connected to ClickHouse version: {}", rs.getString(1)); + } + } + + // Execute a simple calculation + try (ResultSet rs = stmt.executeQuery("SELECT 1 + 1 AS result, now() AS current_time")) { + if (rs.next()) { + log.info("Query result: {} at {}", rs.getInt("result"), rs.getTimestamp("current_time")); + } + } + + log.info("Demo 1 completed successfully"); + } + log.info(""); + } + + /** + * Demo 2: Using different retry strategies. + */ + private static void demoRetryStrategies() throws SQLException { + log.info("--- Demo 2: Retry Strategies ---"); + + File driversDir = new File(DRIVERS_DIR); + + // Strategy 1: NewestFirstRetryStrategy (default) + log.info("Using NewestFirstRetryStrategy..."); + DispatcherDriver newestFirstDispatcher = new DispatcherDriver( + DRIVER_CLASS_NAME, + new NewestFirstRetryStrategy(3, true) + ); + newestFirstDispatcher.loadFromDirectory(driversDir); + executeSimpleQuery(newestFirstDispatcher, "NewestFirst"); + + // Strategy 2: RoundRobinRetryStrategy + log.info("Using RoundRobinRetryStrategy..."); + DispatcherDriver roundRobinDispatcher = new DispatcherDriver( + DRIVER_CLASS_NAME, + new RoundRobinRetryStrategy(3) + ); + roundRobinDispatcher.loadFromDirectory(driversDir); + + // Execute multiple queries to demonstrate round-robin behavior + for (int i = 1; i <= 3; i++) { + executeSimpleQuery(roundRobinDispatcher, "RoundRobin-" + i); + } + + log.info("Demo 2 completed successfully"); + log.info(""); + } + + /** + * Demo 3: Integration with standard JDBC DriverManager. + */ + private static void demoDriverManagerIntegration() throws SQLException { + log.info("--- Demo 3: DriverManager Integration ---"); + + File driversDir = new File(DRIVERS_DIR); + + // Create and configure dispatcher + DispatcherDriver dispatcher = new DispatcherDriver(DRIVER_CLASS_NAME); + dispatcher.loadFromDirectory(driversDir); + + // Register with DriverManager + dispatcher.register(); + log.info("Dispatcher registered with DriverManager"); + + try { + // Use DriverManager with dispatcher URL prefix + String dispatcherUrl = "jdbc:dispatcher:" + CLICKHOUSE_URL; + try (Connection conn = DriverManager.getConnection(dispatcherUrl, "default", "")) { + try (Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery("SELECT 'Hello from DriverManager!' AS message")) { + if (rs.next()) { + log.info("DriverManager query result: {}", rs.getString("message")); + } + } + } + log.info("Demo 3 completed successfully"); + } finally { + // Always deregister when done + dispatcher.deregister(); + log.info("Dispatcher deregistered from DriverManager"); + } + log.info(""); + } + + /** + * Demo 4: Inspecting loaded driver versions. + */ + private static void demoVersionInspection() throws SQLException { + log.info("--- Demo 4: Version Inspection ---"); + + File driversDir = new File(DRIVERS_DIR); + + DispatcherDriver dispatcher = new DispatcherDriver(DRIVER_CLASS_NAME); + dispatcher.loadFromDirectory(driversDir); + + // List all loaded versions + log.info("Loaded driver versions:"); + for (DriverVersion version : dispatcher.getVersionManager().getVersions()) { + log.info(" - Version {}: healthy={}, major={}, minor={}", + version.getVersion(), + version.isHealthy(), + version.getMajorVersion(), + version.getMinorVersion()); + } + + // Get newest version + DriverVersion newest = dispatcher.getVersionManager().getNewestVersion(); + if (newest != null) { + log.info("Newest version: {}", newest.getVersion()); + } + + // Demonstrate marking a version as unhealthy + log.info("Marking version 0.7.2 as unhealthy for demonstration..."); + dispatcher.getVersionManager().markUnhealthy("0.7.2"); + + // Show updated health status + log.info("Updated version status:"); + for (DriverVersion version : dispatcher.getVersionManager().getVersions()) { + log.info(" - Version {}: healthy={}", version.getVersion(), version.isHealthy()); + } + + // Connection should still work, using the healthy version + Properties props = new Properties(); + props.setProperty("user", "default"); + props.setProperty("password", ""); + + try (Connection conn = dispatcher.connect(CLICKHOUSE_URL, props); + Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery("SELECT 'Still working!' AS status")) { + if (rs.next()) { + log.info("Query with unhealthy version marked: {}", rs.getString("status")); + } + } + + log.info("Demo 4 completed successfully"); + log.info(""); + } + + /** + * Helper method to execute a simple query and log the result. + */ + private static void executeSimpleQuery(DispatcherDriver dispatcher, String label) throws SQLException { + Properties props = new Properties(); + props.setProperty("user", "default"); + props.setProperty("password", ""); + + try (Connection conn = dispatcher.connect(CLICKHOUSE_URL, props); + Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery("SELECT now64() AS timestamp")) { + if (rs.next()) { + log.info(" [{}] Query executed at: {}", label, rs.getString("timestamp")); + } + } + } + + /** + * Demonstrates error handling when all driver versions fail. + * This method is not called by default as it requires a non-existent server. + */ + @SuppressWarnings("unused") + private static void demoFailoverHandling() { + log.info("--- Demo: Failover Handling ---"); + + File driversDir = new File(DRIVERS_DIR); + DispatcherDriver dispatcher = new DispatcherDriver(DRIVER_CLASS_NAME); + dispatcher.loadFromDirectory(driversDir); + + Properties props = new Properties(); + props.setProperty("user", "default"); + props.setProperty("password", ""); + + // Try to connect to a non-existent server + try { + Connection conn = dispatcher.connect("jdbc:clickhouse://nonexistent:8123", props); + conn.close(); + } catch (DispatcherException e) { + log.error("All driver versions failed to connect:"); + for (DispatcherException.VersionFailure failure : e.getFailures()) { + log.error(" - Version {} failed: {}", + failure.getVersion(), + failure.getException().getMessage()); + } + } catch (SQLException e) { + log.error("Connection failed", e); + } + } +} diff --git a/examples/jdbc-dispatcher-demo/src/main/java/com/clickhouse/examples/dispatcher/DispatcherService.java b/examples/jdbc-dispatcher-demo/src/main/java/com/clickhouse/examples/dispatcher/DispatcherService.java new file mode 100644 index 000000000..b7cb8c066 --- /dev/null +++ b/examples/jdbc-dispatcher-demo/src/main/java/com/clickhouse/examples/dispatcher/DispatcherService.java @@ -0,0 +1,364 @@ +package com.clickhouse.examples.dispatcher; + +import com.clickhouse.jdbc.dispatcher.DispatcherDriver; +import com.clickhouse.jdbc.dispatcher.DriverVersion; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; +import java.util.concurrent.Executors; + +/** + * A simple HTTP backend service demonstrating jdbc-dispatcher usage. + * Uses only JDK built-in HTTP server (com.sun.net.httpserver). + * + *

Endpoints: + *

+ * + *

Start ClickHouse first: + *

docker run -d -p 8123:8123 --name clickhouse clickhouse/clickhouse-server
+ */ +public class DispatcherService { + + private static final Logger log = LoggerFactory.getLogger(DispatcherService.class); + + // Configuration + private static final int HTTP_PORT = 8080; + private static final String CLICKHOUSE_URL = "jdbc:clickhouse://localhost:8123/default"; + private static final String DRIVERS_DIR = "drivers"; + private static final String DRIVER_CLASS_NAME = "com.clickhouse.jdbc.ClickHouseDriver"; + + private final HttpServer server; + private final DispatcherDriver dispatcher; + private final Properties connectionProps; + + public static void main(String[] args) throws Exception { + DispatcherService service = new DispatcherService(); + service.start(); + + // Add shutdown hook for graceful shutdown + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + log.info("Shutting down..."); + service.stop(); + })); + + log.info("Service started on http://localhost:{}", HTTP_PORT); + log.info("Endpoints:"); + log.info(" GET /health - Health check"); + log.info(" GET /version - ClickHouse version"); + log.info(" GET /drivers - Loaded driver versions"); + log.info(" GET /query?sql=SELECT... - Execute query"); + log.info(""); + log.info("Press Ctrl+C to stop"); + + // Keep the main thread alive + Thread.currentThread().join(); + } + + public DispatcherService() throws IOException { + // Initialize dispatcher with drivers + this.dispatcher = initializeDispatcher(); + + // Connection properties + this.connectionProps = new Properties(); + connectionProps.setProperty("user", "default"); + connectionProps.setProperty("password", ""); + + // Create HTTP server + this.server = HttpServer.create(new InetSocketAddress(HTTP_PORT), 0); + server.setExecutor(Executors.newFixedThreadPool(10)); + + // Register endpoints + server.createContext("/health", new HealthHandler()); + server.createContext("/version", new VersionHandler()); + server.createContext("/drivers", new DriversHandler()); + server.createContext("/query", new QueryHandler()); + } + + private DispatcherDriver initializeDispatcher() { + File driversDir = new File(DRIVERS_DIR); + if (!driversDir.exists() || !driversDir.isDirectory()) { + throw new IllegalStateException("Drivers directory not found: " + driversDir.getAbsolutePath() + + ". Run './gradlew downloadDrivers' first."); + } + + DispatcherDriver driver = new DispatcherDriver(DRIVER_CLASS_NAME); + int loaded = driver.loadFromDirectory(driversDir); + log.info("Loaded {} driver versions from {}", loaded, driversDir.getAbsolutePath()); + + return driver; + } + + public void start() { + server.start(); + } + + public void stop() { + server.stop(1); + } + + // ============ HTTP Handlers ============ + + /** + * GET /health - Simple health check + */ + class HealthHandler implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + if (!"GET".equals(exchange.getRequestMethod())) { + sendError(exchange, 405, "Method not allowed"); + return; + } + + String response = jsonObject( + "status", "ok", + "service", "jdbc-dispatcher-demo" + ); + sendJson(exchange, 200, response); + } + } + + /** + * GET /version - Returns ClickHouse server version + */ + class VersionHandler implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + if (!"GET".equals(exchange.getRequestMethod())) { + sendError(exchange, 405, "Method not allowed"); + return; + } + + try (Connection conn = dispatcher.connect(CLICKHOUSE_URL, connectionProps); + Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery("SELECT version() AS version, now() AS server_time")) { + + if (rs.next()) { + String response = jsonObject( + "clickhouse_version", rs.getString("version"), + "server_time", rs.getString("server_time"), + "status", "connected" + ); + sendJson(exchange, 200, response); + } else { + sendError(exchange, 500, "No result from version query"); + } + } catch (SQLException e) { + log.error("Database error", e); + sendError(exchange, 500, "Database error: " + e.getMessage()); + } + } + } + + /** + * GET /drivers - Lists all loaded driver versions + */ + class DriversHandler implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + if (!"GET".equals(exchange.getRequestMethod())) { + sendError(exchange, 405, "Method not allowed"); + return; + } + + StringBuilder driversJson = new StringBuilder("["); + boolean first = true; + + for (DriverVersion version : dispatcher.getVersionManager().getVersions()) { + if (!first) driversJson.append(","); + first = false; + + driversJson.append(jsonObject( + "version", version.getVersion(), + "healthy", String.valueOf(version.isHealthy()), + "major", String.valueOf(version.getMajorVersion()), + "minor", String.valueOf(version.getMinorVersion()) + )); + } + driversJson.append("]"); + + DriverVersion newest = dispatcher.getVersionManager().getNewestVersion(); + String response = "{" + + "\"drivers\":" + driversJson + "," + + "\"newest\":\"" + (newest != null ? newest.getVersion() : "none") + "\"," + + "\"count\":" + dispatcher.getVersionManager().getVersions().size() + + "}"; + + sendJson(exchange, 200, response); + } + } + + /** + * GET /query?sql=SELECT... - Executes a query and returns JSON results + */ + class QueryHandler implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + if (!"GET".equals(exchange.getRequestMethod())) { + sendError(exchange, 405, "Method not allowed"); + return; + } + + // Parse query parameters + Map params = parseQueryParams(exchange.getRequestURI().getQuery()); + String sql = params.get("sql"); + + if (sql == null || sql.isBlank()) { + sendError(exchange, 400, "Missing 'sql' parameter. Usage: /query?sql=SELECT..."); + return; + } + + // Security: only allow SELECT queries + if (!sql.trim().toUpperCase().startsWith("SELECT")) { + sendError(exchange, 400, "Only SELECT queries are allowed"); + return; + } + + log.info("Executing query: {}", sql); + + try (Connection conn = dispatcher.connect(CLICKHOUSE_URL, connectionProps); + Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery(sql)) { + + String response = resultSetToJson(rs); + sendJson(exchange, 200, response); + + } catch (SQLException e) { + log.error("Query error", e); + sendError(exchange, 500, "Query error: " + e.getMessage()); + } + } + } + + // ============ Helper Methods ============ + + private Map parseQueryParams(String query) { + Map params = new HashMap<>(); + if (query == null || query.isBlank()) { + return params; + } + + for (String param : query.split("&")) { + String[] pair = param.split("=", 2); + if (pair.length == 2) { + String key = URLDecoder.decode(pair[0], StandardCharsets.UTF_8); + String value = URLDecoder.decode(pair[1], StandardCharsets.UTF_8); + params.put(key, value); + } + } + return params; + } + + private String resultSetToJson(ResultSet rs) throws SQLException { + ResultSetMetaData meta = rs.getMetaData(); + int columnCount = meta.getColumnCount(); + + // Build column names array + StringBuilder columns = new StringBuilder("["); + for (int i = 1; i <= columnCount; i++) { + if (i > 1) columns.append(","); + columns.append("\"").append(escapeJson(meta.getColumnName(i))).append("\""); + } + columns.append("]"); + + // Build rows array + StringBuilder rows = new StringBuilder("["); + boolean firstRow = true; + int rowCount = 0; + + while (rs.next() && rowCount < 1000) { // Limit to 1000 rows + if (!firstRow) rows.append(","); + firstRow = false; + rowCount++; + + rows.append("["); + for (int i = 1; i <= columnCount; i++) { + if (i > 1) rows.append(","); + Object value = rs.getObject(i); + if (value == null) { + rows.append("null"); + } else if (value instanceof Number) { + rows.append(value); + } else if (value instanceof Boolean) { + rows.append(value); + } else { + rows.append("\"").append(escapeJson(value.toString())).append("\""); + } + } + rows.append("]"); + } + rows.append("]"); + + return "{" + + "\"columns\":" + columns + "," + + "\"rows\":" + rows + "," + + "\"row_count\":" + rowCount + + "}"; + } + + private String jsonObject(String... keyValues) { + StringBuilder sb = new StringBuilder("{"); + for (int i = 0; i < keyValues.length; i += 2) { + if (i > 0) sb.append(","); + String key = keyValues[i]; + String value = keyValues[i + 1]; + + sb.append("\"").append(key).append("\":"); + + // Check if value looks like a boolean or number + if ("true".equals(value) || "false".equals(value)) { + sb.append(value); + } else if (value.matches("-?\\d+(\\.\\d+)?")) { + sb.append(value); + } else { + sb.append("\"").append(escapeJson(value)).append("\""); + } + } + sb.append("}"); + return sb.toString(); + } + + private String escapeJson(String s) { + if (s == null) return ""; + return s.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); + } + + private void sendJson(HttpExchange exchange, int statusCode, String json) throws IOException { + byte[] response = json.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8"); + exchange.sendResponseHeaders(statusCode, response.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(response); + } + } + + private void sendError(HttpExchange exchange, int statusCode, String message) throws IOException { + String json = jsonObject("error", message, "status", String.valueOf(statusCode)); + sendJson(exchange, statusCode, json); + } +} diff --git a/examples/jdbc-dispatcher-demo/src/main/resources/simplelogger.properties b/examples/jdbc-dispatcher-demo/src/main/resources/simplelogger.properties new file mode 100644 index 000000000..1f90e6a60 --- /dev/null +++ b/examples/jdbc-dispatcher-demo/src/main/resources/simplelogger.properties @@ -0,0 +1,18 @@ +# SLF4J SimpleLogger configuration + +# Default log level for all loggers +org.slf4j.simpleLogger.defaultLogLevel=info + +# Log level for specific loggers +org.slf4j.simpleLogger.log.com.clickhouse.examples.dispatcher=info +org.slf4j.simpleLogger.log.com.clickhouse.jdbc.dispatcher=debug + +# Show date/time in log output +org.slf4j.simpleLogger.showDateTime=true +org.slf4j.simpleLogger.dateTimeFormat=HH:mm:ss.SSS + +# Show logger name (short form) +org.slf4j.simpleLogger.showShortLogName=true + +# Show thread name +org.slf4j.simpleLogger.showThreadName=false diff --git a/jdbc-dispatcher/README.md b/jdbc-dispatcher/README.md new file mode 100644 index 000000000..5331ed491 --- /dev/null +++ b/jdbc-dispatcher/README.md @@ -0,0 +1,323 @@ +# JDBC Dispatcher + +A JDBC driver proxy library that loads multiple versions of the same JDBC driver and provides automatic failover with configurable retry strategies. + +## Why Is This Needed? + +When working with databases in production environments, you may encounter situations where: + +1. **Driver Version Incompatibilities**: A new driver version introduces a bug or regression that affects your specific use case, but you've already deployed it. You need a quick fallback mechanism. + +2. **Gradual Migration**: You want to test a new driver version in production while having the ability to automatically fall back to the proven stable version if issues occur. + +3. **High Availability Requirements**: Critical applications need resilience against driver-level failures. If one driver version has connectivity issues (e.g., due to a specific server version incompatibility), another version might work. + +4. **A/B Testing Drivers**: You want to compare behavior or performance between driver versions in a controlled manner. + +5. **Zero-Downtime Upgrades**: During driver upgrades, you want to seamlessly switch between versions without application restarts. + +The JDBC Dispatcher solves these problems by: +- Loading multiple driver versions in isolated classloaders (no class conflicts) +- Wrapping JDBC connections, statements, and result sets in proxies +- Automatically retrying failed operations with different driver versions +- Supporting pluggable retry strategies + +## How It Works + +### Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Application │ +│ │ │ +│ DispatcherDriver │ +│ │ │ +│ ┌──────────────┼──────────────┐ │ +│ ▼ ▼ ▼ │ +│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ +│ │ Driver v3 │ │ Driver v2 │ │ Driver v1 │ │ +│ │ (newest) │ │ │ │ (oldest) │ │ +│ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │ +│ │ │ │ │ +│ IsolatedCL #1 IsolatedCL #2 IsolatedCL #3 │ +└───────────┼───────────────┼───────────────┼─────────────────────┘ + │ │ │ + ▼ ▼ ▼ + ┌───────────────────────────────────────┐ + │ Database │ + └───────────────────────────────────────┘ +``` + +### Component Overview + +| Component | Description | +|-----------|-------------| +| `DispatcherDriver` | Main entry point implementing `java.sql.Driver`. Manages driver versions and creates proxy connections. | +| `DriverVersionManager` | Loads and manages multiple driver versions. Extracts version info from JAR filenames. | +| `IsolatedClassLoader` | Child-first classloader that loads each driver version in isolation, preventing class conflicts. | +| `ConnectionProxy` | Wraps `java.sql.Connection`. Maintains connections to multiple driver versions for failover. | +| `StatementProxy` | Wraps `java.sql.Statement`. Implements retry logic for `executeQuery`, `executeUpdate`, and `execute`. | +| `PreparedStatementProxy` | Wraps `java.sql.PreparedStatement` with the same retry capabilities. | +| `ResultSetProxy` | Wraps `java.sql.ResultSet`. Tied to the specific execution that created it. | +| `RetryStrategy` | Interface for implementing different failover strategies. | + +### Proxy Chain + +When you call `dispatcher.connect()`, you receive a `ConnectionProxy`: + +``` +DispatcherDriver.connect() + │ + ▼ + ConnectionProxy ─────────────────┐ + │ │ + │ createStatement() │ Maintains connections to + ▼ │ multiple driver versions + StatementProxy │ + │ │ + │ executeQuery() │ + │ ┌──────────────────────┐│ + │ │ Try version 3.0.0 ││ + │ │ If fails, try 2.0.0 ││ + │ │ If fails, try 1.0.0 ││ + │ └──────────────────────┘│ + ▼ │ + ResultSetProxy ◄─────────────────┘ +``` + +### Retry Strategies + +Three built-in strategies are provided: + +#### NewestFirstRetryStrategy (Default) +Tries the newest driver version first, then falls back to older versions in descending order. + +```java +// Version order: 3.0.0 → 2.0.0 → 1.0.0 +new NewestFirstRetryStrategy(maxRetries, skipUnhealthy) +``` + +#### RoundRobinRetryStrategy +Rotates the starting version for each new operation, distributing load across all versions. + +```java +// First call: 3.0.0 → 2.0.0 → 1.0.0 +// Second call: 2.0.0 → 1.0.0 → 3.0.0 +// Third call: 1.0.0 → 3.0.0 → 2.0.0 +new RoundRobinRetryStrategy(maxRetries) +``` + +#### FailoverOnlyRetryStrategy +Sticks to one preferred version until it fails, then switches to the next available version. + +```java +// Uses preferred version until failure, then picks a new preferred +new FailoverOnlyRetryStrategy(maxRetries) +``` + +### Health Tracking + +Each driver version tracks its health status: +- When an operation fails, the version is marked **unhealthy** +- Unhealthy versions are deprioritized in retry ordering +- After a configurable cooldown period (default: 60 seconds), versions are automatically reconsidered + +## Usage Examples + +### Basic Setup + +```java +import com.clickhouse.jdbc.dispatcher.DispatcherDriver; +import com.clickhouse.jdbc.dispatcher.DriverVersion; + +// Create dispatcher for a specific driver class +DispatcherDriver dispatcher = new DispatcherDriver("com.clickhouse.jdbc.ClickHouseDriver"); + +// Load driver versions from JAR files +dispatcher.loadDriver(new File("libs/clickhouse-jdbc-0.4.6.jar"), "0.4.6"); +dispatcher.loadDriver(new File("libs/clickhouse-jdbc-0.5.0.jar"), "0.5.0"); +dispatcher.loadDriver(new File("libs/clickhouse-jdbc-0.6.0.jar"), "0.6.0"); + +// Connect - uses newest version first (0.6.0), fails over if needed +Properties props = new Properties(); +props.setProperty("user", "default"); +props.setProperty("password", ""); + +Connection conn = dispatcher.connect("jdbc:clickhouse://localhost:8123/default", props); + +// Use connection normally - retry logic is transparent +try (Statement stmt = conn.createStatement()) { + ResultSet rs = stmt.executeQuery("SELECT version()"); + while (rs.next()) { + System.out.println("ClickHouse version: " + rs.getString(1)); + } +} +``` + +### Load All Versions from Directory + +```java +DispatcherDriver dispatcher = new DispatcherDriver("com.clickhouse.jdbc.ClickHouseDriver"); + +// Automatically loads all JARs and extracts versions from filenames +// e.g., "clickhouse-jdbc-0.5.0.jar" → version "0.5.0" +int loaded = dispatcher.loadFromDirectory(new File("/opt/drivers/clickhouse")); +System.out.println("Loaded " + loaded + " driver versions"); + +Connection conn = dispatcher.connect("jdbc:clickhouse://localhost:8123", new Properties()); +``` + +### Custom Retry Strategy + +```java +import com.clickhouse.jdbc.dispatcher.strategy.RoundRobinRetryStrategy; + +// Use round-robin with max 2 retries +DispatcherDriver dispatcher = new DispatcherDriver( + "com.clickhouse.jdbc.ClickHouseDriver", + new RoundRobinRetryStrategy(2) +); + +dispatcher.loadFromDirectory(new File("/opt/drivers")); +``` + +### Using with DriverManager + +```java +DispatcherDriver dispatcher = new DispatcherDriver("com.clickhouse.jdbc.ClickHouseDriver"); +dispatcher.loadFromDirectory(new File("/opt/drivers")); + +// Register with DriverManager +dispatcher.register(); + +// Now you can use DriverManager with the dispatcher URL prefix +Connection conn = DriverManager.getConnection( + "jdbc:dispatcher:jdbc:clickhouse://localhost:8123", + "default", + "" +); + +// Don't forget to deregister when done +dispatcher.deregister(); +``` + +### Accessing Version Information + +```java +DispatcherDriver dispatcher = new DispatcherDriver("com.clickhouse.jdbc.ClickHouseDriver"); +dispatcher.loadFromDirectory(new File("/opt/drivers")); + +// Get all loaded versions +for (DriverVersion version : dispatcher.getVersionManager().getVersions()) { + System.out.printf("Version %s: healthy=%s, major=%d, minor=%d%n", + version.getVersion(), + version.isHealthy(), + version.getMajorVersion(), + version.getMinorVersion()); +} + +// Get newest version +DriverVersion newest = dispatcher.getVersionManager().getNewestVersion(); +System.out.println("Newest version: " + newest.getVersion()); + +// Manually mark a version as unhealthy +dispatcher.getVersionManager().markUnhealthy("0.5.0"); +``` + +### Handling Dispatcher Exceptions + +```java +try { + Connection conn = dispatcher.connect("jdbc:clickhouse://localhost:8123", props); +} catch (DispatcherException e) { + System.err.println("All versions failed: " + e.getMessage()); + + // Inspect individual failures + for (DispatcherException.VersionFailure failure : e.getFailures()) { + System.err.printf(" Version %s failed: %s%n", + failure.getVersion(), + failure.getException().getMessage()); + } +} +``` + +## Limitations + +### 1. Transaction Isolation +Transactions are tied to a specific connection. If a failover occurs mid-transaction, the new connection will not have the same transaction state. **Do not rely on failover within an active transaction.** + +```java +conn.setAutoCommit(false); +stmt.executeUpdate("INSERT INTO t VALUES (1)"); +// If failover happens here, the INSERT is lost! +stmt.executeUpdate("INSERT INTO t VALUES (2)"); +conn.commit(); +``` + +**Recommendation**: Keep transactions short and handle `DispatcherException` by retrying the entire transaction. + +### 2. PreparedStatement Parameter Binding +When using `PreparedStatement`, if a failover occurs, parameter bindings are **not** automatically replayed on the new statement. The current implementation requires you to re-bind parameters. + +### 3. ResultSet Failover +ResultSets are tied to the specific Statement execution that created them. If you iterate through a ResultSet and the connection fails, you cannot failover mid-iteration—the entire query must be re-executed. + +### 4. Connection State Synchronization +Connection properties (catalog, schema, transaction isolation, etc.) are synchronized across all version connections when set. However, if a new version connection is created during failover, it starts with default properties and may not match the original connection's state. + +### 5. Stored Procedures and Callable Statements +`CallableStatement` is not wrapped with retry logic. It delegates directly to the underlying connection. + +### 6. Version Extraction from Filenames +The `loadFromDirectory` method extracts versions from JAR filenames using a regex pattern. It expects formats like: +- `driver-1.2.3.jar` +- `driver-1.2.3-SNAPSHOT.jar` +- `driver_1.2.jar` + +Non-standard naming may require manual version specification using `loadDriver(file, version)`. + +### 7. Memory Usage +Each loaded driver version maintains its own classloader and potentially its own connection. Loading many versions or maintaining many failover connections increases memory usage. + +### 8. Driver Compatibility +All loaded driver versions must be compatible with the target database server. The dispatcher cannot help if all versions are incompatible with the server. + +### 9. Thread Safety +The proxy classes are designed for concurrent use, but individual JDBC objects (Connection, Statement, ResultSet) should follow standard JDBC thread-safety guidelines—typically, don't share them across threads. + +### 10. No Automatic Retry for Reads After Writes +The dispatcher does not track read-after-write consistency. If you write with version A and it fails over to version B for reads, there's no guarantee of consistency (this depends on your database, not the dispatcher). + +## Configuration + +### DriverVersionManager Options + +| Option | Default | Description | +|--------|---------|-------------| +| `driverClassName` | (required) | Fully qualified class name of the JDBC driver | +| `healthCheckCooldownMs` | 60000 | Time in ms before unhealthy versions are reconsidered | + +### Retry Strategy Options + +| Strategy | Option | Default | Description | +|----------|--------|---------|-------------| +| NewestFirst | `maxRetries` | 3 | Maximum number of versions to try | +| NewestFirst | `skipUnhealthy` | true | Whether to skip unhealthy versions on first pass | +| RoundRobin | `maxRetries` | 3 | Maximum number of versions to try | +| FailoverOnly | `maxRetries` | 3 | Maximum number of versions to try | + +## Building + +```bash +cd jdbc-dispatcher +mvn clean install +``` + +## Dependencies + +- Java 17+ +- SLF4J API (for logging) + +## License + +Apache License 2.0 diff --git a/jdbc-dispatcher/pom.xml b/jdbc-dispatcher/pom.xml new file mode 100644 index 000000000..587fd6e78 --- /dev/null +++ b/jdbc-dispatcher/pom.xml @@ -0,0 +1,46 @@ + + + 4.0.0 + + + com.clickhouse + clickhouse-java + ${revision} + + + jdbc-dispatcher + jar + JDBC Dispatcher + A JDBC driver dispatcher that supports loading multiple driver versions with failover retry strategies + + + + + org.slf4j + slf4j-api + + + + + org.testng + testng + test + + + org.slf4j + slf4j-simple + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + + diff --git a/jdbc-dispatcher/src/main/java/com/clickhouse/jdbc/dispatcher/DispatcherDriver.java b/jdbc-dispatcher/src/main/java/com/clickhouse/jdbc/dispatcher/DispatcherDriver.java new file mode 100644 index 000000000..63525269c --- /dev/null +++ b/jdbc-dispatcher/src/main/java/com/clickhouse/jdbc/dispatcher/DispatcherDriver.java @@ -0,0 +1,326 @@ +package com.clickhouse.jdbc.dispatcher; + +import com.clickhouse.jdbc.dispatcher.loader.DriverVersionManager; +import com.clickhouse.jdbc.dispatcher.proxy.ConnectionProxy; +import com.clickhouse.jdbc.dispatcher.strategy.NewestFirstRetryStrategy; +import com.clickhouse.jdbc.dispatcher.strategy.RetryContext; +import com.clickhouse.jdbc.dispatcher.strategy.RetryStrategy; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.net.URL; +import java.sql.*; +import java.util.ArrayList; +import java.util.List; +import java.util.Properties; + +/** + * JDBC Driver implementation that dispatches connections across multiple driver versions. + *

+ * This driver loads multiple versions of another JDBC driver and provides failover + * capabilities when connecting or executing queries. When one driver version fails, + * it automatically tries the next version according to the configured retry strategy. + *

+ * Usage: + *

+ * // Create the dispatcher driver
+ * DispatcherDriver dispatcher = new DispatcherDriver("com.clickhouse.jdbc.ClickHouseDriver");
+ *
+ * // Load driver versions from JARs
+ * dispatcher.loadDriver(new File("clickhouse-jdbc-0.4.0.jar"), "0.4.0");
+ * dispatcher.loadDriver(new File("clickhouse-jdbc-0.5.0.jar"), "0.5.0");
+ *
+ * // Connect using the dispatcher
+ * Connection conn = dispatcher.connect("jdbc:clickhouse://localhost:8123", new Properties());
+ * 
+ * + * The dispatcher URL format is: + *
+ * jdbc:dispatcher:<underlying-url>
+ * 
+ * + * For example: jdbc:dispatcher:jdbc:clickhouse://localhost:8123 + */ +public class DispatcherDriver implements Driver { + + private static final Logger log = LoggerFactory.getLogger(DispatcherDriver.class); + + public static final String URL_PREFIX = "jdbc:dispatcher:"; + public static final int MAJOR_VERSION = 1; + public static final int MINOR_VERSION = 0; + + private final DriverVersionManager versionManager; + private RetryStrategy retryStrategy; + private boolean registered = false; + + /** + * Creates a new DispatcherDriver for the specified driver class. + * + * @param driverClassName the fully qualified class name of the JDBC driver to load + */ + public DispatcherDriver(String driverClassName) { + this(driverClassName, new NewestFirstRetryStrategy()); + } + + /** + * Creates a new DispatcherDriver with a custom retry strategy. + * + * @param driverClassName the fully qualified class name of the JDBC driver to load + * @param retryStrategy the retry strategy to use for failover + */ + public DispatcherDriver(String driverClassName, RetryStrategy retryStrategy) { + this.versionManager = new DriverVersionManager(driverClassName); + this.retryStrategy = retryStrategy; + } + + /** + * Creates a new DispatcherDriver with a custom version manager. + * + * @param versionManager the pre-configured version manager + * @param retryStrategy the retry strategy to use for failover + */ + public DispatcherDriver(DriverVersionManager versionManager, RetryStrategy retryStrategy) { + this.versionManager = versionManager; + this.retryStrategy = retryStrategy; + } + + /** + * Loads a driver version from a JAR file. + * + * @param jarFile the JAR file containing the driver + * @param version the version string + * @return the loaded DriverVersion + * @throws Exception if loading fails + */ + public DriverVersion loadDriver(File jarFile, String version) throws Exception { + return versionManager.loadDriver(jarFile, version); + } + + /** + * Loads a driver version from a URL. + * + * @param jarUrl the URL of the JAR file + * @param version the version string + * @return the loaded DriverVersion + * @throws Exception if loading fails + */ + public DriverVersion loadDriver(URL jarUrl, String version) throws Exception { + return versionManager.loadDriver(jarUrl, version); + } + + /** + * Loads all driver versions from a directory. + * + * @param directory the directory containing driver JARs + * @return the number of drivers loaded + */ + public int loadFromDirectory(File directory) { + return versionManager.loadFromDirectory(directory); + } + + /** + * Sets the retry strategy for this driver. + * + * @param retryStrategy the new retry strategy + */ + public void setRetryStrategy(RetryStrategy retryStrategy) { + this.retryStrategy = retryStrategy; + } + + /** + * Gets the current retry strategy. + * + * @return the retry strategy + */ + public RetryStrategy getRetryStrategy() { + return retryStrategy; + } + + /** + * Gets the version manager. + * + * @return the DriverVersionManager + */ + public DriverVersionManager getVersionManager() { + return versionManager; + } + + /** + * Registers this driver with the DriverManager. + * + * @throws SQLException if registration fails + */ + public synchronized void register() throws SQLException { + if (!registered) { + DriverManager.registerDriver(this); + registered = true; + log.info("DispatcherDriver registered with DriverManager"); + } + } + + /** + * Deregisters this driver from the DriverManager. + * + * @throws SQLException if deregistration fails + */ + public synchronized void deregister() throws SQLException { + if (registered) { + DriverManager.deregisterDriver(this); + registered = false; + log.info("DispatcherDriver deregistered from DriverManager"); + } + } + + // ==================== Driver Interface Implementation ==================== + + @Override + public Connection connect(String url, Properties info) throws SQLException { + if (!acceptsURL(url)) { + return null; + } + + // Strip the dispatcher prefix if present + String targetUrl = url; + if (url.startsWith(URL_PREFIX)) { + targetUrl = url.substring(URL_PREFIX.length()); + } + + if (versionManager.isEmpty()) { + throw new SQLException("No driver versions loaded. Load at least one driver version before connecting."); + } + + List failures = new ArrayList<>(); + List versionsToTry = retryStrategy.getVersionsToTry( + versionManager.getVersions(), + RetryContext.forConnect(1) + ); + + log.debug("Attempting connection with {} driver versions", versionsToTry.size()); + + for (int attempt = 0; attempt < versionsToTry.size(); attempt++) { + DriverVersion version = versionsToTry.get(attempt); + RetryContext context = RetryContext.forConnect(attempt + 1); + + try { + log.debug("Trying driver version {} (attempt {})", version.getVersion(), attempt + 1); + + Driver driver = version.getDriver(); + Connection conn = driver.connect(targetUrl, info); + + if (conn == null) { + throw new SQLException("Driver returned null connection for URL: " + targetUrl); + } + + retryStrategy.onSuccess(version, context); + log.info("Connected successfully using driver version {}", version.getVersion()); + + // Wrap in ConnectionProxy for failover support + return new ConnectionProxy( + versionManager, + targetUrl, + info, + retryStrategy, + version, + conn + ); + + } catch (SQLException e) { + log.warn("Connection failed with driver version {}: {}", + version.getVersion(), e.getMessage()); + failures.add(new DispatcherException.VersionFailure(version.getVersion(), e)); + retryStrategy.onFailure(version, context, e); + + // If this was the last version, throw the aggregated exception + if (attempt == versionsToTry.size() - 1) { + throw new DispatcherException( + "All driver versions failed to connect to: " + targetUrl, + failures + ); + } + } + } + + throw new DispatcherException("No driver versions available for connection", failures); + } + + @Override + public boolean acceptsURL(String url) throws SQLException { + if (url == null) { + return false; + } + + // Accept dispatcher URLs + if (url.startsWith(URL_PREFIX)) { + return true; + } + + // Also accept URLs that any of our loaded drivers would accept + for (DriverVersion version : versionManager.getVersions()) { + try { + if (version.getDriver().acceptsURL(url)) { + return true; + } + } catch (SQLException e) { + log.debug("Error checking URL acceptance for version {}: {}", + version.getVersion(), e.getMessage()); + } + } + + return false; + } + + @Override + public DriverPropertyInfo[] getPropertyInfo(String url, Properties info) throws SQLException { + // Delegate to the newest driver version + DriverVersion newest = versionManager.getNewestVersion(); + if (newest != null) { + String targetUrl = url; + if (url != null && url.startsWith(URL_PREFIX)) { + targetUrl = url.substring(URL_PREFIX.length()); + } + return newest.getDriver().getPropertyInfo(targetUrl, info); + } + return new DriverPropertyInfo[0]; + } + + @Override + public int getMajorVersion() { + return MAJOR_VERSION; + } + + @Override + public int getMinorVersion() { + return MINOR_VERSION; + } + + @Override + public boolean jdbcCompliant() { + // Return true if all loaded drivers are JDBC compliant + for (DriverVersion version : versionManager.getVersions()) { + if (!version.getDriver().jdbcCompliant()) { + return false; + } + } + return !versionManager.isEmpty(); + } + + @Override + public java.util.logging.Logger getParentLogger() throws SQLFeatureNotSupportedException { + // Try to get parent logger from the newest driver version + DriverVersion newest = versionManager.getNewestVersion(); + if (newest != null) { + return newest.getDriver().getParentLogger(); + } + throw new SQLFeatureNotSupportedException("No driver versions loaded"); + } + + @Override + public String toString() { + return "DispatcherDriver{" + + "driverClassName='" + versionManager.getDriverClassName() + '\'' + + ", loadedVersions=" + versionManager.size() + + ", retryStrategy=" + retryStrategy.getName() + + '}'; + } +} diff --git a/jdbc-dispatcher/src/main/java/com/clickhouse/jdbc/dispatcher/DispatcherException.java b/jdbc-dispatcher/src/main/java/com/clickhouse/jdbc/dispatcher/DispatcherException.java new file mode 100644 index 000000000..bb771a737 --- /dev/null +++ b/jdbc-dispatcher/src/main/java/com/clickhouse/jdbc/dispatcher/DispatcherException.java @@ -0,0 +1,100 @@ +package com.clickhouse.jdbc.dispatcher; + +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Exception thrown when all driver versions have been exhausted during retry attempts. + *

+ * This exception aggregates all the failures that occurred during the retry process, + * allowing callers to understand what went wrong with each driver version. + */ +public class DispatcherException extends SQLException { + + private final List failures; + + /** + * Creates a new DispatcherException with the given message and list of failures. + * + * @param message the exception message + * @param failures the list of version-specific failures + */ + public DispatcherException(String message, List failures) { + super(message); + this.failures = new ArrayList<>(failures); + + // Set the cause to the first failure's exception if available + if (!failures.isEmpty()) { + initCause(failures.get(0).getException()); + } + } + + /** + * Creates a new DispatcherException with a single cause. + * + * @param message the exception message + * @param cause the underlying cause + */ + public DispatcherException(String message, Throwable cause) { + super(message, cause); + this.failures = Collections.emptyList(); + } + + /** + * Returns the list of failures that occurred during retry attempts. + * + * @return an unmodifiable list of version failures + */ + public List getFailures() { + return Collections.unmodifiableList(failures); + } + + /** + * Represents a failure that occurred with a specific driver version. + */ + public static class VersionFailure { + private final String version; + private final Throwable exception; + private final long timestampMs; + + public VersionFailure(String version, Throwable exception) { + this.version = version; + this.exception = exception; + this.timestampMs = System.currentTimeMillis(); + } + + public String getVersion() { + return version; + } + + public Throwable getException() { + return exception; + } + + public long getTimestampMs() { + return timestampMs; + } + + @Override + public String toString() { + return "VersionFailure{version='" + version + "', error=" + exception.getMessage() + '}'; + } + } + + @Override + public String getMessage() { + StringBuilder sb = new StringBuilder(super.getMessage()); + if (!failures.isEmpty()) { + sb.append(" [Failures: "); + for (int i = 0; i < failures.size(); i++) { + if (i > 0) sb.append(", "); + VersionFailure f = failures.get(i); + sb.append("v").append(f.getVersion()).append(": ").append(f.getException().getMessage()); + } + sb.append("]"); + } + return sb.toString(); + } +} diff --git a/jdbc-dispatcher/src/main/java/com/clickhouse/jdbc/dispatcher/DriverVersion.java b/jdbc-dispatcher/src/main/java/com/clickhouse/jdbc/dispatcher/DriverVersion.java new file mode 100644 index 000000000..1cbc5c5b7 --- /dev/null +++ b/jdbc-dispatcher/src/main/java/com/clickhouse/jdbc/dispatcher/DriverVersion.java @@ -0,0 +1,145 @@ +package com.clickhouse.jdbc.dispatcher; + +import java.sql.Driver; +import java.util.Objects; + +/** + * Represents a loaded JDBC driver version with its associated metadata. + * This class encapsulates a driver instance along with version information + * that can be used for ordering and selection during failover. + */ +public class DriverVersion implements Comparable { + + private final String version; + private final Driver driver; + private final ClassLoader classLoader; + private final int majorVersion; + private final int minorVersion; + private final int patchVersion; + private volatile boolean healthy = true; + private volatile long lastFailureTime = 0; + + /** + * Creates a new DriverVersion with the specified version string and driver instance. + * + * @param version the version string (e.g., "1.2.3") + * @param driver the loaded JDBC driver instance + * @param classLoader the classloader used to load this driver + */ + public DriverVersion(String version, Driver driver, ClassLoader classLoader) { + this.version = Objects.requireNonNull(version, "version cannot be null"); + this.driver = Objects.requireNonNull(driver, "driver cannot be null"); + this.classLoader = Objects.requireNonNull(classLoader, "classLoader cannot be null"); + + int[] parsed = parseVersion(version); + this.majorVersion = parsed[0]; + this.minorVersion = parsed[1]; + this.patchVersion = parsed[2]; + } + + /** + * Parses a version string into major, minor, and patch components. + * + * @param version the version string to parse + * @return an array of [major, minor, patch] + */ + private static int[] parseVersion(String version) { + int[] result = new int[3]; + String[] parts = version.split("[.\\-]"); + for (int i = 0; i < Math.min(parts.length, 3); i++) { + try { + result[i] = Integer.parseInt(parts[i].replaceAll("[^0-9]", "")); + } catch (NumberFormatException e) { + result[i] = 0; + } + } + return result; + } + + public String getVersion() { + return version; + } + + public Driver getDriver() { + return driver; + } + + public ClassLoader getClassLoader() { + return classLoader; + } + + public int getMajorVersion() { + return majorVersion; + } + + public int getMinorVersion() { + return minorVersion; + } + + public int getPatchVersion() { + return patchVersion; + } + + public boolean isHealthy() { + return healthy; + } + + public void setHealthy(boolean healthy) { + this.healthy = healthy; + if (!healthy) { + this.lastFailureTime = System.currentTimeMillis(); + } + } + + public long getLastFailureTime() { + return lastFailureTime; + } + + /** + * Resets the health status after a cooldown period. + * + * @param cooldownMs the cooldown period in milliseconds + * @return true if the health was reset, false otherwise + */ + public boolean resetHealthIfCooledDown(long cooldownMs) { + if (!healthy && System.currentTimeMillis() - lastFailureTime > cooldownMs) { + healthy = true; + return true; + } + return false; + } + + @Override + public int compareTo(DriverVersion other) { + // Compare versions: higher version comes first (descending order) + int cmp = Integer.compare(other.majorVersion, this.majorVersion); + if (cmp != 0) return cmp; + cmp = Integer.compare(other.minorVersion, this.minorVersion); + if (cmp != 0) return cmp; + return Integer.compare(other.patchVersion, this.patchVersion); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + DriverVersion that = (DriverVersion) o; + return version.equals(that.version); + } + + @Override + public int hashCode() { + return version.hashCode(); + } + + @Override + public String toString() { + return "DriverVersion{" + + "version='" + version + '\'' + + ", healthy=" + healthy + + ", majorVersion=" + majorVersion + + ", minorVersion=" + minorVersion + + ", patchVersion=" + patchVersion + + '}'; + } +} diff --git a/jdbc-dispatcher/src/main/java/com/clickhouse/jdbc/dispatcher/loader/DriverVersionManager.java b/jdbc-dispatcher/src/main/java/com/clickhouse/jdbc/dispatcher/loader/DriverVersionManager.java new file mode 100644 index 000000000..c242df866 --- /dev/null +++ b/jdbc-dispatcher/src/main/java/com/clickhouse/jdbc/dispatcher/loader/DriverVersionManager.java @@ -0,0 +1,283 @@ +package com.clickhouse.jdbc.dispatcher.loader; + +import com.clickhouse.jdbc.dispatcher.DriverVersion; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.net.MalformedURLException; +import java.net.URL; +import java.sql.Driver; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Manages loading and organizing multiple JDBC driver versions. + *

+ * This class is responsible for: + * - Loading driver JARs from specified locations + * - Extracting version information from JAR filenames + * - Managing the lifecycle of loaded drivers + * - Providing ordered access to driver versions + */ +public class DriverVersionManager { + + private static final Logger log = LoggerFactory.getLogger(DriverVersionManager.class); + + // Pattern to extract version from JAR filename (e.g., "driver-1.2.3.jar", "driver-1.2.3-SNAPSHOT.jar") + private static final Pattern VERSION_PATTERN = Pattern.compile( + ".*?[-_]?(\\d+\\.\\d+(?:\\.\\d+)?(?:[-._]\\w+)?)\\.jar$", + Pattern.CASE_INSENSITIVE + ); + + private final String driverClassName; + private final List versions = new CopyOnWriteArrayList<>(); + private final Map versionMap = new ConcurrentHashMap<>(); + private final long healthCheckCooldownMs; + + /** + * Creates a new DriverVersionManager. + * + * @param driverClassName the fully qualified class name of the driver + * @param healthCheckCooldownMs cooldown period before retrying an unhealthy driver + */ + public DriverVersionManager(String driverClassName, long healthCheckCooldownMs) { + this.driverClassName = Objects.requireNonNull(driverClassName, "driverClassName cannot be null"); + this.healthCheckCooldownMs = healthCheckCooldownMs; + } + + /** + * Creates a new DriverVersionManager with default cooldown. + * + * @param driverClassName the fully qualified class name of the driver + */ + public DriverVersionManager(String driverClassName) { + this(driverClassName, 60000L); // 1 minute default cooldown + } + + /** + * Loads all driver JARs from the specified directory. + * + * @param directory the directory containing driver JARs + * @return the number of drivers successfully loaded + */ + public int loadFromDirectory(File directory) { + if (!directory.isDirectory()) { + throw new IllegalArgumentException("Path is not a directory: " + directory); + } + + File[] jarFiles = directory.listFiles((dir, name) -> name.toLowerCase().endsWith(".jar")); + if (jarFiles == null || jarFiles.length == 0) { + log.warn("No JAR files found in directory: {}", directory); + return 0; + } + + int loaded = 0; + for (File jarFile : jarFiles) { + try { + String version = extractVersionFromFilename(jarFile.getName()); + if (version != null) { + loadDriver(jarFile.toURI().toURL(), version); + loaded++; + } else { + log.warn("Could not extract version from JAR filename: {}", jarFile.getName()); + } + } catch (Exception e) { + log.error("Failed to load driver from JAR: {}", jarFile, e); + } + } + + sortVersions(); + return loaded; + } + + /** + * Loads a driver from a specific JAR URL with the given version. + * + * @param jarUrl the URL of the driver JAR + * @param version the version string for this driver + * @return the loaded DriverVersion + * @throws Exception if loading fails + */ + public DriverVersion loadDriver(URL jarUrl, String version) throws Exception { + if (versionMap.containsKey(version)) { + log.warn("Driver version {} already loaded, skipping", version); + return versionMap.get(version); + } + + log.info("Loading driver version {} from {}", version, jarUrl); + + IsolatedClassLoader classLoader = new IsolatedClassLoader( + jarUrl, + getClass().getClassLoader(), + driverClassName, + version + ); + + Driver driver = classLoader.loadDriver(); + DriverVersion driverVersion = new DriverVersion(version, driver, classLoader); + + versions.add(driverVersion); + versionMap.put(version, driverVersion); + sortVersions(); + + log.info("Successfully loaded driver version {}: major={}, minor={}", + version, driverVersion.getMajorVersion(), driverVersion.getMinorVersion()); + + return driverVersion; + } + + /** + * Loads a driver from a JAR file. + * + * @param jarFile the driver JAR file + * @param version the version string + * @return the loaded DriverVersion + * @throws Exception if loading fails + */ + public DriverVersion loadDriver(File jarFile, String version) throws Exception { + return loadDriver(jarFile.toURI().toURL(), version); + } + + /** + * Extracts version information from a JAR filename. + * + * @param filename the JAR filename + * @return the extracted version string, or null if not found + */ + public static String extractVersionFromFilename(String filename) { + Matcher matcher = VERSION_PATTERN.matcher(filename); + if (matcher.matches()) { + return matcher.group(1); + } + return null; + } + + /** + * Gets all loaded driver versions, sorted by version (newest first). + * + * @return an unmodifiable list of driver versions + */ + public List getVersions() { + return Collections.unmodifiableList(versions); + } + + /** + * Gets all healthy driver versions, sorted by version (newest first). + * + * @return a list of healthy driver versions + */ + public List getHealthyVersions() { + // First, try to reset health for cooled-down drivers + for (DriverVersion version : versions) { + version.resetHealthIfCooledDown(healthCheckCooldownMs); + } + + List healthy = new ArrayList<>(); + for (DriverVersion version : versions) { + if (version.isHealthy()) { + healthy.add(version); + } + } + return healthy; + } + + /** + * Gets a specific driver version by version string. + * + * @param version the version string + * @return the DriverVersion, or null if not found + */ + public DriverVersion getVersion(String version) { + return versionMap.get(version); + } + + /** + * Gets the newest (highest version) driver. + * + * @return the newest DriverVersion, or null if no versions are loaded + */ + public DriverVersion getNewestVersion() { + return versions.isEmpty() ? null : versions.get(0); + } + + /** + * Gets the newest healthy driver. + * + * @return the newest healthy DriverVersion, or null if none available + */ + public DriverVersion getNewestHealthyVersion() { + List healthy = getHealthyVersions(); + return healthy.isEmpty() ? null : healthy.get(0); + } + + /** + * Marks a driver version as unhealthy. + * + * @param version the version string + */ + public void markUnhealthy(String version) { + DriverVersion dv = versionMap.get(version); + if (dv != null) { + dv.setHealthy(false); + log.warn("Marked driver version {} as unhealthy", version); + } + } + + /** + * Marks a driver version as healthy. + * + * @param version the version string + */ + public void markHealthy(String version) { + DriverVersion dv = versionMap.get(version); + if (dv != null) { + dv.setHealthy(true); + log.info("Marked driver version {} as healthy", version); + } + } + + /** + * Returns the number of loaded driver versions. + * + * @return the count of loaded drivers + */ + public int size() { + return versions.size(); + } + + /** + * Checks if any drivers are loaded. + * + * @return true if at least one driver is loaded + */ + public boolean isEmpty() { + return versions.isEmpty(); + } + + /** + * Clears all loaded drivers. + */ + public void clear() { + versions.clear(); + versionMap.clear(); + } + + /** + * Sorts versions so that the newest version comes first. + */ + private void sortVersions() { + Collections.sort(versions); + } + + public String getDriverClassName() { + return driverClassName; + } + + public long getHealthCheckCooldownMs() { + return healthCheckCooldownMs; + } +} diff --git a/jdbc-dispatcher/src/main/java/com/clickhouse/jdbc/dispatcher/loader/IsolatedClassLoader.java b/jdbc-dispatcher/src/main/java/com/clickhouse/jdbc/dispatcher/loader/IsolatedClassLoader.java new file mode 100644 index 000000000..36b661f5f --- /dev/null +++ b/jdbc-dispatcher/src/main/java/com/clickhouse/jdbc/dispatcher/loader/IsolatedClassLoader.java @@ -0,0 +1,164 @@ +package com.clickhouse.jdbc.dispatcher.loader; + +import java.io.IOException; +import java.net.URL; +import java.net.URLClassLoader; +import java.sql.Driver; +import java.util.Enumeration; + +/** + * A classloader that loads JDBC driver classes in isolation from other driver versions. + * This enables loading multiple versions of the same driver without class conflicts. + *

+ * The classloader uses a child-first delegation model for driver-related classes, + * while delegating to the parent for JDK and common library classes. + */ +public class IsolatedClassLoader extends URLClassLoader { + + private final String driverClassName; + private final String version; + + /** + * Creates a new IsolatedClassLoader for loading a driver JAR. + * + * @param jarUrl the URL of the driver JAR file + * @param parent the parent classloader + * @param driverClassName the fully qualified class name of the driver + * @param version the version identifier for this driver + */ + public IsolatedClassLoader(URL jarUrl, ClassLoader parent, String driverClassName, String version) { + super(new URL[]{jarUrl}, parent); + this.driverClassName = driverClassName; + this.version = version; + } + + /** + * Creates a new IsolatedClassLoader for loading multiple driver JARs. + * + * @param jarUrls the URLs of the driver JAR files + * @param parent the parent classloader + * @param driverClassName the fully qualified class name of the driver + * @param version the version identifier for this driver + */ + public IsolatedClassLoader(URL[] jarUrls, ClassLoader parent, String driverClassName, String version) { + super(jarUrls, parent); + this.driverClassName = driverClassName; + this.version = version; + } + + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + synchronized (getClassLoadingLock(name)) { + // First, check if the class has already been loaded + Class loadedClass = findLoadedClass(name); + if (loadedClass != null) { + return loadedClass; + } + + // For JDK classes and java.sql interfaces, delegate to parent + if (shouldDelegateToParent(name)) { + return super.loadClass(name, resolve); + } + + // Try to find the class locally first (child-first for driver classes) + try { + loadedClass = findClass(name); + if (resolve) { + resolveClass(loadedClass); + } + return loadedClass; + } catch (ClassNotFoundException e) { + // Fall back to parent classloader + return super.loadClass(name, resolve); + } + } + } + + /** + * Determines whether class loading should be delegated to the parent classloader. + * + * @param className the fully qualified class name + * @return true if the class should be loaded by the parent + */ + private boolean shouldDelegateToParent(String className) { + // Always delegate JDK classes + if (className.startsWith("java.") || + className.startsWith("javax.") || + className.startsWith("jdk.") || + className.startsWith("sun.")) { + return true; + } + + // Delegate logging frameworks + if (className.startsWith("org.slf4j.") || + className.startsWith("org.apache.log4j.") || + className.startsWith("ch.qos.logback.")) { + return true; + } + + return false; + } + + @Override + public URL getResource(String name) { + // Child-first resource loading + URL url = findResource(name); + if (url != null) { + return url; + } + return super.getResource(name); + } + + @Override + public Enumeration getResources(String name) throws IOException { + // Return resources from this classloader first, then parent + Enumeration localResources = findResources(name); + Enumeration parentResources = getParent().getResources(name); + + return new Enumeration() { + @Override + public boolean hasMoreElements() { + return localResources.hasMoreElements() || parentResources.hasMoreElements(); + } + + @Override + public URL nextElement() { + if (localResources.hasMoreElements()) { + return localResources.nextElement(); + } + return parentResources.nextElement(); + } + }; + } + + /** + * Loads and instantiates the JDBC driver from this classloader. + * + * @return the instantiated Driver + * @throws ClassNotFoundException if the driver class cannot be found + * @throws ReflectiveOperationException if the driver cannot be instantiated + */ + public Driver loadDriver() throws ClassNotFoundException, ReflectiveOperationException { + Class driverClass = loadClass(driverClassName); + if (!Driver.class.isAssignableFrom(driverClass)) { + throw new ClassNotFoundException("Class " + driverClassName + " does not implement java.sql.Driver"); + } + return (Driver) driverClass.getDeclaredConstructor().newInstance(); + } + + public String getDriverClassName() { + return driverClassName; + } + + public String getVersion() { + return version; + } + + @Override + public String toString() { + return "IsolatedClassLoader{" + + "driverClassName='" + driverClassName + '\'' + + ", version='" + version + '\'' + + '}'; + } +} diff --git a/jdbc-dispatcher/src/main/java/com/clickhouse/jdbc/dispatcher/proxy/ConnectionProxy.java b/jdbc-dispatcher/src/main/java/com/clickhouse/jdbc/dispatcher/proxy/ConnectionProxy.java new file mode 100644 index 000000000..87dc745d9 --- /dev/null +++ b/jdbc-dispatcher/src/main/java/com/clickhouse/jdbc/dispatcher/proxy/ConnectionProxy.java @@ -0,0 +1,589 @@ +package com.clickhouse.jdbc.dispatcher.proxy; + +import com.clickhouse.jdbc.dispatcher.DispatcherException; +import com.clickhouse.jdbc.dispatcher.DriverVersion; +import com.clickhouse.jdbc.dispatcher.loader.DriverVersionManager; +import com.clickhouse.jdbc.dispatcher.strategy.RetryContext; +import com.clickhouse.jdbc.dispatcher.strategy.RetryStrategy; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.sql.*; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executor; + +/** + * Proxy wrapper for java.sql.Connection that supports failover between driver versions. + *

+ * This proxy maintains connections to multiple driver versions and can switch between + * them when failures occur. It tracks which connection is currently active and manages + * the lifecycle of all connections. + */ +public class ConnectionProxy implements Connection { + + private static final Logger log = LoggerFactory.getLogger(ConnectionProxy.class); + + private final DriverVersionManager versionManager; + private final String url; + private final Properties properties; + private final RetryStrategy retryStrategy; + + // Map of version -> connection for that version + private final Map versionConnections = new ConcurrentHashMap<>(); + + // Current active version and connection + private volatile DriverVersion currentVersion; + private volatile Connection currentConnection; + private volatile boolean closed = false; + + /** + * Creates a new ConnectionProxy. + * + * @param versionManager the manager containing all driver versions + * @param url the JDBC URL used for connections + * @param properties the connection properties + * @param retryStrategy the retry strategy for failover + * @param initialVersion the initial driver version to use + * @param initialConn the initial connection + */ + public ConnectionProxy(DriverVersionManager versionManager, String url, Properties properties, + RetryStrategy retryStrategy, DriverVersion initialVersion, Connection initialConn) { + this.versionManager = versionManager; + this.url = url; + this.properties = properties; + this.retryStrategy = retryStrategy; + this.currentVersion = initialVersion; + this.currentConnection = initialConn; + this.versionConnections.put(initialVersion.getVersion(), initialConn); + } + + /** + * Returns the list of available driver versions. + */ + public List getAvailableVersions() { + return versionManager.getVersions(); + } + + /** + * Gets or creates a connection for the specified driver version. + * + * @param version the driver version + * @return a connection for that version + * @throws SQLException if connection cannot be established + */ + public Connection getConnectionForVersion(DriverVersion version) throws SQLException { + String versionStr = version.getVersion(); + + // Check if we already have a connection for this version + Connection conn = versionConnections.get(versionStr); + if (conn != null && !conn.isClosed()) { + return conn; + } + + // Create a new connection using the target driver version + log.info("Creating new connection for driver version {}", versionStr); + try { + conn = version.getDriver().connect(url, properties); + if (conn == null) { + throw new SQLException("Driver returned null connection for URL: " + url); + } + versionConnections.put(versionStr, conn); + return conn; + } catch (SQLException e) { + log.error("Failed to create connection with driver version {}: {}", + versionStr, e.getMessage()); + throw e; + } + } + + /** + * Switches to a different driver version, creating a connection if needed. + * + * @param version the version to switch to + * @throws SQLException if switching fails + */ + public void switchToVersion(DriverVersion version) throws SQLException { + if (version.equals(currentVersion)) { + return; + } + + Connection newConn = getConnectionForVersion(version); + this.currentVersion = version; + this.currentConnection = newConn; + log.info("Switched to driver version {}", version.getVersion()); + } + + /** + * Returns the current driver version. + */ + public DriverVersion getCurrentVersion() { + return currentVersion; + } + + /** + * Returns the current underlying connection. + */ + public Connection getCurrentConnection() { + return currentConnection; + } + + /** + * Returns the retry strategy. + */ + public RetryStrategy getRetryStrategy() { + return retryStrategy; + } + + // ==================== Statement Creation with Retry Support ==================== + + @Override + public Statement createStatement() throws SQLException { + checkClosed(); + Statement stmt = currentConnection.createStatement(); + return new StatementProxy(this, stmt, currentVersion, retryStrategy); + } + + @Override + public PreparedStatement prepareStatement(String sql) throws SQLException { + checkClosed(); + List failures = new ArrayList<>(); + List versionsToTry = retryStrategy.getVersionsToTry( + versionManager.getVersions(), + new RetryContext(RetryContext.OperationType.PREPARE_STATEMENT, "prepareStatement", 1) + ); + + for (int attempt = 0; attempt < versionsToTry.size(); attempt++) { + DriverVersion version = versionsToTry.get(attempt); + RetryContext context = new RetryContext( + RetryContext.OperationType.PREPARE_STATEMENT, "prepareStatement", attempt + 1 + ); + + try { + Connection conn = getConnectionForVersion(version); + PreparedStatement pstmt = conn.prepareStatement(sql); + retryStrategy.onSuccess(version, context); + return new PreparedStatementProxy(this, pstmt, version, retryStrategy, sql); + } catch (SQLException e) { + log.warn("prepareStatement failed with version {}: {}", version.getVersion(), e.getMessage()); + failures.add(new DispatcherException.VersionFailure(version.getVersion(), e)); + retryStrategy.onFailure(version, context, e); + + if (attempt == versionsToTry.size() - 1) { + throw new DispatcherException("All driver versions failed for prepareStatement", failures); + } + } + } + + throw new DispatcherException("No driver versions available", failures); + } + + @Override + public CallableStatement prepareCall(String sql) throws SQLException { + checkClosed(); + return currentConnection.prepareCall(sql); + } + + @Override + public String nativeSQL(String sql) throws SQLException { + checkClosed(); + return currentConnection.nativeSQL(sql); + } + + // ==================== Transaction Management ==================== + + @Override + public void setAutoCommit(boolean autoCommit) throws SQLException { + checkClosed(); + // Apply to all open connections + for (Connection conn : versionConnections.values()) { + if (!conn.isClosed()) { + conn.setAutoCommit(autoCommit); + } + } + } + + @Override + public boolean getAutoCommit() throws SQLException { + checkClosed(); + return currentConnection.getAutoCommit(); + } + + @Override + public void commit() throws SQLException { + checkClosed(); + // Commit current connection + currentConnection.commit(); + } + + @Override + public void rollback() throws SQLException { + checkClosed(); + // Rollback current connection + currentConnection.rollback(); + } + + @Override + public void close() throws SQLException { + if (closed) { + return; + } + closed = true; + + // Close all connections + List exceptions = new ArrayList<>(); + for (Connection conn : versionConnections.values()) { + try { + if (!conn.isClosed()) { + conn.close(); + } + } catch (SQLException e) { + exceptions.add(e); + } + } + versionConnections.clear(); + + if (!exceptions.isEmpty()) { + SQLException first = exceptions.get(0); + for (int i = 1; i < exceptions.size(); i++) { + first.addSuppressed(exceptions.get(i)); + } + throw first; + } + } + + @Override + public boolean isClosed() throws SQLException { + return closed; + } + + private void checkClosed() throws SQLException { + if (closed) { + throw new SQLException("Connection is closed"); + } + } + + // ==================== Metadata ==================== + + @Override + public DatabaseMetaData getMetaData() throws SQLException { + checkClosed(); + return currentConnection.getMetaData(); + } + + @Override + public void setReadOnly(boolean readOnly) throws SQLException { + checkClosed(); + for (Connection conn : versionConnections.values()) { + if (!conn.isClosed()) { + conn.setReadOnly(readOnly); + } + } + } + + @Override + public boolean isReadOnly() throws SQLException { + checkClosed(); + return currentConnection.isReadOnly(); + } + + @Override + public void setCatalog(String catalog) throws SQLException { + checkClosed(); + for (Connection conn : versionConnections.values()) { + if (!conn.isClosed()) { + conn.setCatalog(catalog); + } + } + } + + @Override + public String getCatalog() throws SQLException { + checkClosed(); + return currentConnection.getCatalog(); + } + + @Override + public void setTransactionIsolation(int level) throws SQLException { + checkClosed(); + for (Connection conn : versionConnections.values()) { + if (!conn.isClosed()) { + conn.setTransactionIsolation(level); + } + } + } + + @Override + public int getTransactionIsolation() throws SQLException { + checkClosed(); + return currentConnection.getTransactionIsolation(); + } + + @Override + public SQLWarning getWarnings() throws SQLException { + checkClosed(); + return currentConnection.getWarnings(); + } + + @Override + public void clearWarnings() throws SQLException { + checkClosed(); + currentConnection.clearWarnings(); + } + + // ==================== Statement Creation Variants ==================== + + @Override + public Statement createStatement(int resultSetType, int resultSetConcurrency) throws SQLException { + checkClosed(); + Statement stmt = currentConnection.createStatement(resultSetType, resultSetConcurrency); + return new StatementProxy(this, stmt, currentVersion, retryStrategy); + } + + @Override + public PreparedStatement prepareStatement(String sql, int resultSetType, int resultSetConcurrency) + throws SQLException { + checkClosed(); + PreparedStatement pstmt = currentConnection.prepareStatement(sql, resultSetType, resultSetConcurrency); + return new PreparedStatementProxy(this, pstmt, currentVersion, retryStrategy, sql); + } + + @Override + public CallableStatement prepareCall(String sql, int resultSetType, int resultSetConcurrency) + throws SQLException { + checkClosed(); + return currentConnection.prepareCall(sql, resultSetType, resultSetConcurrency); + } + + @Override + public Map> getTypeMap() throws SQLException { + checkClosed(); + return currentConnection.getTypeMap(); + } + + @Override + public void setTypeMap(Map> map) throws SQLException { + checkClosed(); + currentConnection.setTypeMap(map); + } + + @Override + public void setHoldability(int holdability) throws SQLException { + checkClosed(); + for (Connection conn : versionConnections.values()) { + if (!conn.isClosed()) { + conn.setHoldability(holdability); + } + } + } + + @Override + public int getHoldability() throws SQLException { + checkClosed(); + return currentConnection.getHoldability(); + } + + @Override + public Savepoint setSavepoint() throws SQLException { + checkClosed(); + return currentConnection.setSavepoint(); + } + + @Override + public Savepoint setSavepoint(String name) throws SQLException { + checkClosed(); + return currentConnection.setSavepoint(name); + } + + @Override + public void rollback(Savepoint savepoint) throws SQLException { + checkClosed(); + currentConnection.rollback(savepoint); + } + + @Override + public void releaseSavepoint(Savepoint savepoint) throws SQLException { + checkClosed(); + currentConnection.releaseSavepoint(savepoint); + } + + @Override + public Statement createStatement(int resultSetType, int resultSetConcurrency, int resultSetHoldability) + throws SQLException { + checkClosed(); + Statement stmt = currentConnection.createStatement(resultSetType, resultSetConcurrency, resultSetHoldability); + return new StatementProxy(this, stmt, currentVersion, retryStrategy); + } + + @Override + public PreparedStatement prepareStatement(String sql, int resultSetType, int resultSetConcurrency, + int resultSetHoldability) throws SQLException { + checkClosed(); + PreparedStatement pstmt = currentConnection.prepareStatement(sql, resultSetType, resultSetConcurrency, + resultSetHoldability); + return new PreparedStatementProxy(this, pstmt, currentVersion, retryStrategy, sql); + } + + @Override + public CallableStatement prepareCall(String sql, int resultSetType, int resultSetConcurrency, + int resultSetHoldability) throws SQLException { + checkClosed(); + return currentConnection.prepareCall(sql, resultSetType, resultSetConcurrency, resultSetHoldability); + } + + @Override + public PreparedStatement prepareStatement(String sql, int autoGeneratedKeys) throws SQLException { + checkClosed(); + PreparedStatement pstmt = currentConnection.prepareStatement(sql, autoGeneratedKeys); + return new PreparedStatementProxy(this, pstmt, currentVersion, retryStrategy, sql); + } + + @Override + public PreparedStatement prepareStatement(String sql, int[] columnIndexes) throws SQLException { + checkClosed(); + PreparedStatement pstmt = currentConnection.prepareStatement(sql, columnIndexes); + return new PreparedStatementProxy(this, pstmt, currentVersion, retryStrategy, sql); + } + + @Override + public PreparedStatement prepareStatement(String sql, String[] columnNames) throws SQLException { + checkClosed(); + PreparedStatement pstmt = currentConnection.prepareStatement(sql, columnNames); + return new PreparedStatementProxy(this, pstmt, currentVersion, retryStrategy, sql); + } + + @Override + public Clob createClob() throws SQLException { + checkClosed(); + return currentConnection.createClob(); + } + + @Override + public Blob createBlob() throws SQLException { + checkClosed(); + return currentConnection.createBlob(); + } + + @Override + public NClob createNClob() throws SQLException { + checkClosed(); + return currentConnection.createNClob(); + } + + @Override + public SQLXML createSQLXML() throws SQLException { + checkClosed(); + return currentConnection.createSQLXML(); + } + + @Override + public boolean isValid(int timeout) throws SQLException { + if (closed) { + return false; + } + return currentConnection.isValid(timeout); + } + + @Override + public void setClientInfo(String name, String value) throws SQLClientInfoException { + for (Connection conn : versionConnections.values()) { + try { + if (!conn.isClosed()) { + conn.setClientInfo(name, value); + } + } catch (SQLException e) { + // Ignore closed connections + } + } + } + + @Override + public void setClientInfo(Properties properties) throws SQLClientInfoException { + for (Connection conn : versionConnections.values()) { + try { + if (!conn.isClosed()) { + conn.setClientInfo(properties); + } + } catch (SQLException e) { + // Ignore closed connections + } + } + } + + @Override + public String getClientInfo(String name) throws SQLException { + checkClosed(); + return currentConnection.getClientInfo(name); + } + + @Override + public Properties getClientInfo() throws SQLException { + checkClosed(); + return currentConnection.getClientInfo(); + } + + @Override + public Array createArrayOf(String typeName, Object[] elements) throws SQLException { + checkClosed(); + return currentConnection.createArrayOf(typeName, elements); + } + + @Override + public Struct createStruct(String typeName, Object[] attributes) throws SQLException { + checkClosed(); + return currentConnection.createStruct(typeName, attributes); + } + + @Override + public void setSchema(String schema) throws SQLException { + checkClosed(); + for (Connection conn : versionConnections.values()) { + if (!conn.isClosed()) { + conn.setSchema(schema); + } + } + } + + @Override + public String getSchema() throws SQLException { + checkClosed(); + return currentConnection.getSchema(); + } + + @Override + public void abort(Executor executor) throws SQLException { + closed = true; + for (Connection conn : versionConnections.values()) { + conn.abort(executor); + } + } + + @Override + public void setNetworkTimeout(Executor executor, int milliseconds) throws SQLException { + checkClosed(); + for (Connection conn : versionConnections.values()) { + if (!conn.isClosed()) { + conn.setNetworkTimeout(executor, milliseconds); + } + } + } + + @Override + public int getNetworkTimeout() throws SQLException { + checkClosed(); + return currentConnection.getNetworkTimeout(); + } + + @Override + public T unwrap(Class iface) throws SQLException { + if (iface.isInstance(this)) { + return iface.cast(this); + } + return currentConnection.unwrap(iface); + } + + @Override + public boolean isWrapperFor(Class iface) throws SQLException { + return iface.isInstance(this) || currentConnection.isWrapperFor(iface); + } +} diff --git a/jdbc-dispatcher/src/main/java/com/clickhouse/jdbc/dispatcher/proxy/PreparedStatementProxy.java b/jdbc-dispatcher/src/main/java/com/clickhouse/jdbc/dispatcher/proxy/PreparedStatementProxy.java new file mode 100644 index 000000000..fac5c79cf --- /dev/null +++ b/jdbc-dispatcher/src/main/java/com/clickhouse/jdbc/dispatcher/proxy/PreparedStatementProxy.java @@ -0,0 +1,325 @@ +package com.clickhouse.jdbc.dispatcher.proxy; + +import com.clickhouse.jdbc.dispatcher.DriverVersion; +import com.clickhouse.jdbc.dispatcher.strategy.RetryStrategy; + +import java.io.InputStream; +import java.io.Reader; +import java.math.BigDecimal; +import java.net.URL; +import java.sql.*; +import java.util.Calendar; + +/** + * Proxy wrapper for java.sql.PreparedStatement that supports failover between driver versions. + *

+ * This proxy extends StatementProxy with PreparedStatement-specific functionality. + * Parameter bindings are tracked so they can be reapplied when switching to a different + * driver version. + */ +public class PreparedStatementProxy extends StatementProxy implements PreparedStatement { + + private final String sql; + + /** + * Creates a new PreparedStatementProxy. + * + * @param connectionProxy the parent connection proxy + * @param delegate the underlying prepared statement + * @param currentVersion the driver version that created this statement + * @param retryStrategy the retry strategy to use for failover + * @param sql the SQL statement this was prepared with + */ + public PreparedStatementProxy(ConnectionProxy connectionProxy, PreparedStatement delegate, + DriverVersion currentVersion, RetryStrategy retryStrategy, String sql) { + super(connectionProxy, delegate, currentVersion, retryStrategy); + this.sql = sql; + } + + private PreparedStatement getPreparedDelegate() { + return (PreparedStatement) delegate; + } + + public String getSql() { + return sql; + } + + // ==================== PreparedStatement Interface Implementation ==================== + + @Override + public ResultSet executeQuery() throws SQLException { + ResultSet rs = getPreparedDelegate().executeQuery(); + return new ResultSetProxy(rs, currentVersion, this); + } + + @Override + public int executeUpdate() throws SQLException { + return getPreparedDelegate().executeUpdate(); + } + + @Override + public void setNull(int parameterIndex, int sqlType) throws SQLException { + getPreparedDelegate().setNull(parameterIndex, sqlType); + } + + @Override + public void setBoolean(int parameterIndex, boolean x) throws SQLException { + getPreparedDelegate().setBoolean(parameterIndex, x); + } + + @Override + public void setByte(int parameterIndex, byte x) throws SQLException { + getPreparedDelegate().setByte(parameterIndex, x); + } + + @Override + public void setShort(int parameterIndex, short x) throws SQLException { + getPreparedDelegate().setShort(parameterIndex, x); + } + + @Override + public void setInt(int parameterIndex, int x) throws SQLException { + getPreparedDelegate().setInt(parameterIndex, x); + } + + @Override + public void setLong(int parameterIndex, long x) throws SQLException { + getPreparedDelegate().setLong(parameterIndex, x); + } + + @Override + public void setFloat(int parameterIndex, float x) throws SQLException { + getPreparedDelegate().setFloat(parameterIndex, x); + } + + @Override + public void setDouble(int parameterIndex, double x) throws SQLException { + getPreparedDelegate().setDouble(parameterIndex, x); + } + + @Override + public void setBigDecimal(int parameterIndex, BigDecimal x) throws SQLException { + getPreparedDelegate().setBigDecimal(parameterIndex, x); + } + + @Override + public void setString(int parameterIndex, String x) throws SQLException { + getPreparedDelegate().setString(parameterIndex, x); + } + + @Override + public void setBytes(int parameterIndex, byte[] x) throws SQLException { + getPreparedDelegate().setBytes(parameterIndex, x); + } + + @Override + public void setDate(int parameterIndex, Date x) throws SQLException { + getPreparedDelegate().setDate(parameterIndex, x); + } + + @Override + public void setTime(int parameterIndex, Time x) throws SQLException { + getPreparedDelegate().setTime(parameterIndex, x); + } + + @Override + public void setTimestamp(int parameterIndex, Timestamp x) throws SQLException { + getPreparedDelegate().setTimestamp(parameterIndex, x); + } + + @Override + public void setAsciiStream(int parameterIndex, InputStream x, int length) throws SQLException { + getPreparedDelegate().setAsciiStream(parameterIndex, x, length); + } + + @Override + @Deprecated + public void setUnicodeStream(int parameterIndex, InputStream x, int length) throws SQLException { + getPreparedDelegate().setUnicodeStream(parameterIndex, x, length); + } + + @Override + public void setBinaryStream(int parameterIndex, InputStream x, int length) throws SQLException { + getPreparedDelegate().setBinaryStream(parameterIndex, x, length); + } + + @Override + public void clearParameters() throws SQLException { + getPreparedDelegate().clearParameters(); + } + + @Override + public void setObject(int parameterIndex, Object x, int targetSqlType) throws SQLException { + getPreparedDelegate().setObject(parameterIndex, x, targetSqlType); + } + + @Override + public void setObject(int parameterIndex, Object x) throws SQLException { + getPreparedDelegate().setObject(parameterIndex, x); + } + + @Override + public boolean execute() throws SQLException { + return getPreparedDelegate().execute(); + } + + @Override + public void addBatch() throws SQLException { + getPreparedDelegate().addBatch(); + } + + @Override + public void setCharacterStream(int parameterIndex, Reader reader, int length) throws SQLException { + getPreparedDelegate().setCharacterStream(parameterIndex, reader, length); + } + + @Override + public void setRef(int parameterIndex, Ref x) throws SQLException { + getPreparedDelegate().setRef(parameterIndex, x); + } + + @Override + public void setBlob(int parameterIndex, Blob x) throws SQLException { + getPreparedDelegate().setBlob(parameterIndex, x); + } + + @Override + public void setClob(int parameterIndex, Clob x) throws SQLException { + getPreparedDelegate().setClob(parameterIndex, x); + } + + @Override + public void setArray(int parameterIndex, Array x) throws SQLException { + getPreparedDelegate().setArray(parameterIndex, x); + } + + @Override + public ResultSetMetaData getMetaData() throws SQLException { + return getPreparedDelegate().getMetaData(); + } + + @Override + public void setDate(int parameterIndex, Date x, Calendar cal) throws SQLException { + getPreparedDelegate().setDate(parameterIndex, x, cal); + } + + @Override + public void setTime(int parameterIndex, Time x, Calendar cal) throws SQLException { + getPreparedDelegate().setTime(parameterIndex, x, cal); + } + + @Override + public void setTimestamp(int parameterIndex, Timestamp x, Calendar cal) throws SQLException { + getPreparedDelegate().setTimestamp(parameterIndex, x, cal); + } + + @Override + public void setNull(int parameterIndex, int sqlType, String typeName) throws SQLException { + getPreparedDelegate().setNull(parameterIndex, sqlType, typeName); + } + + @Override + public void setURL(int parameterIndex, URL x) throws SQLException { + getPreparedDelegate().setURL(parameterIndex, x); + } + + @Override + public ParameterMetaData getParameterMetaData() throws SQLException { + return getPreparedDelegate().getParameterMetaData(); + } + + @Override + public void setRowId(int parameterIndex, RowId x) throws SQLException { + getPreparedDelegate().setRowId(parameterIndex, x); + } + + @Override + public void setNString(int parameterIndex, String value) throws SQLException { + getPreparedDelegate().setNString(parameterIndex, value); + } + + @Override + public void setNCharacterStream(int parameterIndex, Reader value, long length) throws SQLException { + getPreparedDelegate().setNCharacterStream(parameterIndex, value, length); + } + + @Override + public void setNClob(int parameterIndex, NClob value) throws SQLException { + getPreparedDelegate().setNClob(parameterIndex, value); + } + + @Override + public void setClob(int parameterIndex, Reader reader, long length) throws SQLException { + getPreparedDelegate().setClob(parameterIndex, reader, length); + } + + @Override + public void setBlob(int parameterIndex, InputStream inputStream, long length) throws SQLException { + getPreparedDelegate().setBlob(parameterIndex, inputStream, length); + } + + @Override + public void setNClob(int parameterIndex, Reader reader, long length) throws SQLException { + getPreparedDelegate().setNClob(parameterIndex, reader, length); + } + + @Override + public void setSQLXML(int parameterIndex, SQLXML xmlObject) throws SQLException { + getPreparedDelegate().setSQLXML(parameterIndex, xmlObject); + } + + @Override + public void setObject(int parameterIndex, Object x, int targetSqlType, int scaleOrLength) throws SQLException { + getPreparedDelegate().setObject(parameterIndex, x, targetSqlType, scaleOrLength); + } + + @Override + public void setAsciiStream(int parameterIndex, InputStream x, long length) throws SQLException { + getPreparedDelegate().setAsciiStream(parameterIndex, x, length); + } + + @Override + public void setBinaryStream(int parameterIndex, InputStream x, long length) throws SQLException { + getPreparedDelegate().setBinaryStream(parameterIndex, x, length); + } + + @Override + public void setCharacterStream(int parameterIndex, Reader reader, long length) throws SQLException { + getPreparedDelegate().setCharacterStream(parameterIndex, reader, length); + } + + @Override + public void setAsciiStream(int parameterIndex, InputStream x) throws SQLException { + getPreparedDelegate().setAsciiStream(parameterIndex, x); + } + + @Override + public void setBinaryStream(int parameterIndex, InputStream x) throws SQLException { + getPreparedDelegate().setBinaryStream(parameterIndex, x); + } + + @Override + public void setCharacterStream(int parameterIndex, Reader reader) throws SQLException { + getPreparedDelegate().setCharacterStream(parameterIndex, reader); + } + + @Override + public void setNCharacterStream(int parameterIndex, Reader value) throws SQLException { + getPreparedDelegate().setNCharacterStream(parameterIndex, value); + } + + @Override + public void setClob(int parameterIndex, Reader reader) throws SQLException { + getPreparedDelegate().setClob(parameterIndex, reader); + } + + @Override + public void setBlob(int parameterIndex, InputStream inputStream) throws SQLException { + getPreparedDelegate().setBlob(parameterIndex, inputStream); + } + + @Override + public void setNClob(int parameterIndex, Reader reader) throws SQLException { + getPreparedDelegate().setNClob(parameterIndex, reader); + } +} diff --git a/jdbc-dispatcher/src/main/java/com/clickhouse/jdbc/dispatcher/proxy/ResultSetProxy.java b/jdbc-dispatcher/src/main/java/com/clickhouse/jdbc/dispatcher/proxy/ResultSetProxy.java new file mode 100644 index 000000000..cd5f3a9ad --- /dev/null +++ b/jdbc-dispatcher/src/main/java/com/clickhouse/jdbc/dispatcher/proxy/ResultSetProxy.java @@ -0,0 +1,1021 @@ +package com.clickhouse.jdbc.dispatcher.proxy; + +import com.clickhouse.jdbc.dispatcher.DriverVersion; + +import java.io.InputStream; +import java.io.Reader; +import java.math.BigDecimal; +import java.net.URL; +import java.sql.*; +import java.util.Calendar; +import java.util.Map; + +/** + * Proxy wrapper for java.sql.ResultSet. + *

+ * This proxy delegates all calls to the underlying ResultSet from a specific + * driver version. Unlike Connection and Statement proxies, ResultSet proxy + * does not support failover since results are inherently tied to a specific + * execution context. + */ +public class ResultSetProxy implements ResultSet { + + private final ResultSet delegate; + private final DriverVersion driverVersion; + private final StatementProxy statementProxy; + + /** + * Creates a new ResultSetProxy. + * + * @param delegate the underlying ResultSet to delegate to + * @param driverVersion the driver version that created this ResultSet + * @param statementProxy the parent statement proxy (may be null) + */ + public ResultSetProxy(ResultSet delegate, DriverVersion driverVersion, StatementProxy statementProxy) { + this.delegate = delegate; + this.driverVersion = driverVersion; + this.statementProxy = statementProxy; + } + + /** + * Returns the underlying ResultSet. + * + * @return the delegate ResultSet + */ + public ResultSet getDelegate() { + return delegate; + } + + /** + * Returns the driver version that created this ResultSet. + * + * @return the DriverVersion + */ + public DriverVersion getDriverVersion() { + return driverVersion; + } + + // ==================== ResultSet Interface Implementation ==================== + + @Override + public boolean next() throws SQLException { + return delegate.next(); + } + + @Override + public void close() throws SQLException { + delegate.close(); + } + + @Override + public boolean wasNull() throws SQLException { + return delegate.wasNull(); + } + + @Override + public String getString(int columnIndex) throws SQLException { + return delegate.getString(columnIndex); + } + + @Override + public boolean getBoolean(int columnIndex) throws SQLException { + return delegate.getBoolean(columnIndex); + } + + @Override + public byte getByte(int columnIndex) throws SQLException { + return delegate.getByte(columnIndex); + } + + @Override + public short getShort(int columnIndex) throws SQLException { + return delegate.getShort(columnIndex); + } + + @Override + public int getInt(int columnIndex) throws SQLException { + return delegate.getInt(columnIndex); + } + + @Override + public long getLong(int columnIndex) throws SQLException { + return delegate.getLong(columnIndex); + } + + @Override + public float getFloat(int columnIndex) throws SQLException { + return delegate.getFloat(columnIndex); + } + + @Override + public double getDouble(int columnIndex) throws SQLException { + return delegate.getDouble(columnIndex); + } + + @Override + @Deprecated + public BigDecimal getBigDecimal(int columnIndex, int scale) throws SQLException { + return delegate.getBigDecimal(columnIndex, scale); + } + + @Override + public byte[] getBytes(int columnIndex) throws SQLException { + return delegate.getBytes(columnIndex); + } + + @Override + public Date getDate(int columnIndex) throws SQLException { + return delegate.getDate(columnIndex); + } + + @Override + public Time getTime(int columnIndex) throws SQLException { + return delegate.getTime(columnIndex); + } + + @Override + public Timestamp getTimestamp(int columnIndex) throws SQLException { + return delegate.getTimestamp(columnIndex); + } + + @Override + public InputStream getAsciiStream(int columnIndex) throws SQLException { + return delegate.getAsciiStream(columnIndex); + } + + @Override + @Deprecated + public InputStream getUnicodeStream(int columnIndex) throws SQLException { + return delegate.getUnicodeStream(columnIndex); + } + + @Override + public InputStream getBinaryStream(int columnIndex) throws SQLException { + return delegate.getBinaryStream(columnIndex); + } + + @Override + public String getString(String columnLabel) throws SQLException { + return delegate.getString(columnLabel); + } + + @Override + public boolean getBoolean(String columnLabel) throws SQLException { + return delegate.getBoolean(columnLabel); + } + + @Override + public byte getByte(String columnLabel) throws SQLException { + return delegate.getByte(columnLabel); + } + + @Override + public short getShort(String columnLabel) throws SQLException { + return delegate.getShort(columnLabel); + } + + @Override + public int getInt(String columnLabel) throws SQLException { + return delegate.getInt(columnLabel); + } + + @Override + public long getLong(String columnLabel) throws SQLException { + return delegate.getLong(columnLabel); + } + + @Override + public float getFloat(String columnLabel) throws SQLException { + return delegate.getFloat(columnLabel); + } + + @Override + public double getDouble(String columnLabel) throws SQLException { + return delegate.getDouble(columnLabel); + } + + @Override + @Deprecated + public BigDecimal getBigDecimal(String columnLabel, int scale) throws SQLException { + return delegate.getBigDecimal(columnLabel, scale); + } + + @Override + public byte[] getBytes(String columnLabel) throws SQLException { + return delegate.getBytes(columnLabel); + } + + @Override + public Date getDate(String columnLabel) throws SQLException { + return delegate.getDate(columnLabel); + } + + @Override + public Time getTime(String columnLabel) throws SQLException { + return delegate.getTime(columnLabel); + } + + @Override + public Timestamp getTimestamp(String columnLabel) throws SQLException { + return delegate.getTimestamp(columnLabel); + } + + @Override + public InputStream getAsciiStream(String columnLabel) throws SQLException { + return delegate.getAsciiStream(columnLabel); + } + + @Override + @Deprecated + public InputStream getUnicodeStream(String columnLabel) throws SQLException { + return delegate.getUnicodeStream(columnLabel); + } + + @Override + public InputStream getBinaryStream(String columnLabel) throws SQLException { + return delegate.getBinaryStream(columnLabel); + } + + @Override + public SQLWarning getWarnings() throws SQLException { + return delegate.getWarnings(); + } + + @Override + public void clearWarnings() throws SQLException { + delegate.clearWarnings(); + } + + @Override + public String getCursorName() throws SQLException { + return delegate.getCursorName(); + } + + @Override + public ResultSetMetaData getMetaData() throws SQLException { + return delegate.getMetaData(); + } + + @Override + public Object getObject(int columnIndex) throws SQLException { + return delegate.getObject(columnIndex); + } + + @Override + public Object getObject(String columnLabel) throws SQLException { + return delegate.getObject(columnLabel); + } + + @Override + public int findColumn(String columnLabel) throws SQLException { + return delegate.findColumn(columnLabel); + } + + @Override + public Reader getCharacterStream(int columnIndex) throws SQLException { + return delegate.getCharacterStream(columnIndex); + } + + @Override + public Reader getCharacterStream(String columnLabel) throws SQLException { + return delegate.getCharacterStream(columnLabel); + } + + @Override + public BigDecimal getBigDecimal(int columnIndex) throws SQLException { + return delegate.getBigDecimal(columnIndex); + } + + @Override + public BigDecimal getBigDecimal(String columnLabel) throws SQLException { + return delegate.getBigDecimal(columnLabel); + } + + @Override + public boolean isBeforeFirst() throws SQLException { + return delegate.isBeforeFirst(); + } + + @Override + public boolean isAfterLast() throws SQLException { + return delegate.isAfterLast(); + } + + @Override + public boolean isFirst() throws SQLException { + return delegate.isFirst(); + } + + @Override + public boolean isLast() throws SQLException { + return delegate.isLast(); + } + + @Override + public void beforeFirst() throws SQLException { + delegate.beforeFirst(); + } + + @Override + public void afterLast() throws SQLException { + delegate.afterLast(); + } + + @Override + public boolean first() throws SQLException { + return delegate.first(); + } + + @Override + public boolean last() throws SQLException { + return delegate.last(); + } + + @Override + public int getRow() throws SQLException { + return delegate.getRow(); + } + + @Override + public boolean absolute(int row) throws SQLException { + return delegate.absolute(row); + } + + @Override + public boolean relative(int rows) throws SQLException { + return delegate.relative(rows); + } + + @Override + public boolean previous() throws SQLException { + return delegate.previous(); + } + + @Override + public void setFetchDirection(int direction) throws SQLException { + delegate.setFetchDirection(direction); + } + + @Override + public int getFetchDirection() throws SQLException { + return delegate.getFetchDirection(); + } + + @Override + public void setFetchSize(int rows) throws SQLException { + delegate.setFetchSize(rows); + } + + @Override + public int getFetchSize() throws SQLException { + return delegate.getFetchSize(); + } + + @Override + public int getType() throws SQLException { + return delegate.getType(); + } + + @Override + public int getConcurrency() throws SQLException { + return delegate.getConcurrency(); + } + + @Override + public boolean rowUpdated() throws SQLException { + return delegate.rowUpdated(); + } + + @Override + public boolean rowInserted() throws SQLException { + return delegate.rowInserted(); + } + + @Override + public boolean rowDeleted() throws SQLException { + return delegate.rowDeleted(); + } + + @Override + public void updateNull(int columnIndex) throws SQLException { + delegate.updateNull(columnIndex); + } + + @Override + public void updateBoolean(int columnIndex, boolean x) throws SQLException { + delegate.updateBoolean(columnIndex, x); + } + + @Override + public void updateByte(int columnIndex, byte x) throws SQLException { + delegate.updateByte(columnIndex, x); + } + + @Override + public void updateShort(int columnIndex, short x) throws SQLException { + delegate.updateShort(columnIndex, x); + } + + @Override + public void updateInt(int columnIndex, int x) throws SQLException { + delegate.updateInt(columnIndex, x); + } + + @Override + public void updateLong(int columnIndex, long x) throws SQLException { + delegate.updateLong(columnIndex, x); + } + + @Override + public void updateFloat(int columnIndex, float x) throws SQLException { + delegate.updateFloat(columnIndex, x); + } + + @Override + public void updateDouble(int columnIndex, double x) throws SQLException { + delegate.updateDouble(columnIndex, x); + } + + @Override + public void updateBigDecimal(int columnIndex, BigDecimal x) throws SQLException { + delegate.updateBigDecimal(columnIndex, x); + } + + @Override + public void updateString(int columnIndex, String x) throws SQLException { + delegate.updateString(columnIndex, x); + } + + @Override + public void updateBytes(int columnIndex, byte[] x) throws SQLException { + delegate.updateBytes(columnIndex, x); + } + + @Override + public void updateDate(int columnIndex, Date x) throws SQLException { + delegate.updateDate(columnIndex, x); + } + + @Override + public void updateTime(int columnIndex, Time x) throws SQLException { + delegate.updateTime(columnIndex, x); + } + + @Override + public void updateTimestamp(int columnIndex, Timestamp x) throws SQLException { + delegate.updateTimestamp(columnIndex, x); + } + + @Override + public void updateAsciiStream(int columnIndex, InputStream x, int length) throws SQLException { + delegate.updateAsciiStream(columnIndex, x, length); + } + + @Override + public void updateBinaryStream(int columnIndex, InputStream x, int length) throws SQLException { + delegate.updateBinaryStream(columnIndex, x, length); + } + + @Override + public void updateCharacterStream(int columnIndex, Reader x, int length) throws SQLException { + delegate.updateCharacterStream(columnIndex, x, length); + } + + @Override + public void updateObject(int columnIndex, Object x, int scaleOrLength) throws SQLException { + delegate.updateObject(columnIndex, x, scaleOrLength); + } + + @Override + public void updateObject(int columnIndex, Object x) throws SQLException { + delegate.updateObject(columnIndex, x); + } + + @Override + public void updateNull(String columnLabel) throws SQLException { + delegate.updateNull(columnLabel); + } + + @Override + public void updateBoolean(String columnLabel, boolean x) throws SQLException { + delegate.updateBoolean(columnLabel, x); + } + + @Override + public void updateByte(String columnLabel, byte x) throws SQLException { + delegate.updateByte(columnLabel, x); + } + + @Override + public void updateShort(String columnLabel, short x) throws SQLException { + delegate.updateShort(columnLabel, x); + } + + @Override + public void updateInt(String columnLabel, int x) throws SQLException { + delegate.updateInt(columnLabel, x); + } + + @Override + public void updateLong(String columnLabel, long x) throws SQLException { + delegate.updateLong(columnLabel, x); + } + + @Override + public void updateFloat(String columnLabel, float x) throws SQLException { + delegate.updateFloat(columnLabel, x); + } + + @Override + public void updateDouble(String columnLabel, double x) throws SQLException { + delegate.updateDouble(columnLabel, x); + } + + @Override + public void updateBigDecimal(String columnLabel, BigDecimal x) throws SQLException { + delegate.updateBigDecimal(columnLabel, x); + } + + @Override + public void updateString(String columnLabel, String x) throws SQLException { + delegate.updateString(columnLabel, x); + } + + @Override + public void updateBytes(String columnLabel, byte[] x) throws SQLException { + delegate.updateBytes(columnLabel, x); + } + + @Override + public void updateDate(String columnLabel, Date x) throws SQLException { + delegate.updateDate(columnLabel, x); + } + + @Override + public void updateTime(String columnLabel, Time x) throws SQLException { + delegate.updateTime(columnLabel, x); + } + + @Override + public void updateTimestamp(String columnLabel, Timestamp x) throws SQLException { + delegate.updateTimestamp(columnLabel, x); + } + + @Override + public void updateAsciiStream(String columnLabel, InputStream x, int length) throws SQLException { + delegate.updateAsciiStream(columnLabel, x, length); + } + + @Override + public void updateBinaryStream(String columnLabel, InputStream x, int length) throws SQLException { + delegate.updateBinaryStream(columnLabel, x, length); + } + + @Override + public void updateCharacterStream(String columnLabel, Reader reader, int length) throws SQLException { + delegate.updateCharacterStream(columnLabel, reader, length); + } + + @Override + public void updateObject(String columnLabel, Object x, int scaleOrLength) throws SQLException { + delegate.updateObject(columnLabel, x, scaleOrLength); + } + + @Override + public void updateObject(String columnLabel, Object x) throws SQLException { + delegate.updateObject(columnLabel, x); + } + + @Override + public void insertRow() throws SQLException { + delegate.insertRow(); + } + + @Override + public void updateRow() throws SQLException { + delegate.updateRow(); + } + + @Override + public void deleteRow() throws SQLException { + delegate.deleteRow(); + } + + @Override + public void refreshRow() throws SQLException { + delegate.refreshRow(); + } + + @Override + public void cancelRowUpdates() throws SQLException { + delegate.cancelRowUpdates(); + } + + @Override + public void moveToInsertRow() throws SQLException { + delegate.moveToInsertRow(); + } + + @Override + public void moveToCurrentRow() throws SQLException { + delegate.moveToCurrentRow(); + } + + @Override + public Statement getStatement() throws SQLException { + return statementProxy != null ? statementProxy : delegate.getStatement(); + } + + @Override + public Object getObject(int columnIndex, Map> map) throws SQLException { + return delegate.getObject(columnIndex, map); + } + + @Override + public Ref getRef(int columnIndex) throws SQLException { + return delegate.getRef(columnIndex); + } + + @Override + public Blob getBlob(int columnIndex) throws SQLException { + return delegate.getBlob(columnIndex); + } + + @Override + public Clob getClob(int columnIndex) throws SQLException { + return delegate.getClob(columnIndex); + } + + @Override + public Array getArray(int columnIndex) throws SQLException { + return delegate.getArray(columnIndex); + } + + @Override + public Object getObject(String columnLabel, Map> map) throws SQLException { + return delegate.getObject(columnLabel, map); + } + + @Override + public Ref getRef(String columnLabel) throws SQLException { + return delegate.getRef(columnLabel); + } + + @Override + public Blob getBlob(String columnLabel) throws SQLException { + return delegate.getBlob(columnLabel); + } + + @Override + public Clob getClob(String columnLabel) throws SQLException { + return delegate.getClob(columnLabel); + } + + @Override + public Array getArray(String columnLabel) throws SQLException { + return delegate.getArray(columnLabel); + } + + @Override + public Date getDate(int columnIndex, Calendar cal) throws SQLException { + return delegate.getDate(columnIndex, cal); + } + + @Override + public Date getDate(String columnLabel, Calendar cal) throws SQLException { + return delegate.getDate(columnLabel, cal); + } + + @Override + public Time getTime(int columnIndex, Calendar cal) throws SQLException { + return delegate.getTime(columnIndex, cal); + } + + @Override + public Time getTime(String columnLabel, Calendar cal) throws SQLException { + return delegate.getTime(columnLabel, cal); + } + + @Override + public Timestamp getTimestamp(int columnIndex, Calendar cal) throws SQLException { + return delegate.getTimestamp(columnIndex, cal); + } + + @Override + public Timestamp getTimestamp(String columnLabel, Calendar cal) throws SQLException { + return delegate.getTimestamp(columnLabel, cal); + } + + @Override + public URL getURL(int columnIndex) throws SQLException { + return delegate.getURL(columnIndex); + } + + @Override + public URL getURL(String columnLabel) throws SQLException { + return delegate.getURL(columnLabel); + } + + @Override + public void updateRef(int columnIndex, Ref x) throws SQLException { + delegate.updateRef(columnIndex, x); + } + + @Override + public void updateRef(String columnLabel, Ref x) throws SQLException { + delegate.updateRef(columnLabel, x); + } + + @Override + public void updateBlob(int columnIndex, Blob x) throws SQLException { + delegate.updateBlob(columnIndex, x); + } + + @Override + public void updateBlob(String columnLabel, Blob x) throws SQLException { + delegate.updateBlob(columnLabel, x); + } + + @Override + public void updateClob(int columnIndex, Clob x) throws SQLException { + delegate.updateClob(columnIndex, x); + } + + @Override + public void updateClob(String columnLabel, Clob x) throws SQLException { + delegate.updateClob(columnLabel, x); + } + + @Override + public void updateArray(int columnIndex, Array x) throws SQLException { + delegate.updateArray(columnIndex, x); + } + + @Override + public void updateArray(String columnLabel, Array x) throws SQLException { + delegate.updateArray(columnLabel, x); + } + + @Override + public RowId getRowId(int columnIndex) throws SQLException { + return delegate.getRowId(columnIndex); + } + + @Override + public RowId getRowId(String columnLabel) throws SQLException { + return delegate.getRowId(columnLabel); + } + + @Override + public void updateRowId(int columnIndex, RowId x) throws SQLException { + delegate.updateRowId(columnIndex, x); + } + + @Override + public void updateRowId(String columnLabel, RowId x) throws SQLException { + delegate.updateRowId(columnLabel, x); + } + + @Override + public int getHoldability() throws SQLException { + return delegate.getHoldability(); + } + + @Override + public boolean isClosed() throws SQLException { + return delegate.isClosed(); + } + + @Override + public void updateNString(int columnIndex, String nString) throws SQLException { + delegate.updateNString(columnIndex, nString); + } + + @Override + public void updateNString(String columnLabel, String nString) throws SQLException { + delegate.updateNString(columnLabel, nString); + } + + @Override + public void updateNClob(int columnIndex, NClob nClob) throws SQLException { + delegate.updateNClob(columnIndex, nClob); + } + + @Override + public void updateNClob(String columnLabel, NClob nClob) throws SQLException { + delegate.updateNClob(columnLabel, nClob); + } + + @Override + public NClob getNClob(int columnIndex) throws SQLException { + return delegate.getNClob(columnIndex); + } + + @Override + public NClob getNClob(String columnLabel) throws SQLException { + return delegate.getNClob(columnLabel); + } + + @Override + public SQLXML getSQLXML(int columnIndex) throws SQLException { + return delegate.getSQLXML(columnIndex); + } + + @Override + public SQLXML getSQLXML(String columnLabel) throws SQLException { + return delegate.getSQLXML(columnLabel); + } + + @Override + public void updateSQLXML(int columnIndex, SQLXML xmlObject) throws SQLException { + delegate.updateSQLXML(columnIndex, xmlObject); + } + + @Override + public void updateSQLXML(String columnLabel, SQLXML xmlObject) throws SQLException { + delegate.updateSQLXML(columnLabel, xmlObject); + } + + @Override + public String getNString(int columnIndex) throws SQLException { + return delegate.getNString(columnIndex); + } + + @Override + public String getNString(String columnLabel) throws SQLException { + return delegate.getNString(columnLabel); + } + + @Override + public Reader getNCharacterStream(int columnIndex) throws SQLException { + return delegate.getNCharacterStream(columnIndex); + } + + @Override + public Reader getNCharacterStream(String columnLabel) throws SQLException { + return delegate.getNCharacterStream(columnLabel); + } + + @Override + public void updateNCharacterStream(int columnIndex, Reader x, long length) throws SQLException { + delegate.updateNCharacterStream(columnIndex, x, length); + } + + @Override + public void updateNCharacterStream(String columnLabel, Reader reader, long length) throws SQLException { + delegate.updateNCharacterStream(columnLabel, reader, length); + } + + @Override + public void updateAsciiStream(int columnIndex, InputStream x, long length) throws SQLException { + delegate.updateAsciiStream(columnIndex, x, length); + } + + @Override + public void updateBinaryStream(int columnIndex, InputStream x, long length) throws SQLException { + delegate.updateBinaryStream(columnIndex, x, length); + } + + @Override + public void updateCharacterStream(int columnIndex, Reader x, long length) throws SQLException { + delegate.updateCharacterStream(columnIndex, x, length); + } + + @Override + public void updateAsciiStream(String columnLabel, InputStream x, long length) throws SQLException { + delegate.updateAsciiStream(columnLabel, x, length); + } + + @Override + public void updateBinaryStream(String columnLabel, InputStream x, long length) throws SQLException { + delegate.updateBinaryStream(columnLabel, x, length); + } + + @Override + public void updateCharacterStream(String columnLabel, Reader reader, long length) throws SQLException { + delegate.updateCharacterStream(columnLabel, reader, length); + } + + @Override + public void updateBlob(int columnIndex, InputStream inputStream, long length) throws SQLException { + delegate.updateBlob(columnIndex, inputStream, length); + } + + @Override + public void updateBlob(String columnLabel, InputStream inputStream, long length) throws SQLException { + delegate.updateBlob(columnLabel, inputStream, length); + } + + @Override + public void updateClob(int columnIndex, Reader reader, long length) throws SQLException { + delegate.updateClob(columnIndex, reader, length); + } + + @Override + public void updateClob(String columnLabel, Reader reader, long length) throws SQLException { + delegate.updateClob(columnLabel, reader, length); + } + + @Override + public void updateNClob(int columnIndex, Reader reader, long length) throws SQLException { + delegate.updateNClob(columnIndex, reader, length); + } + + @Override + public void updateNClob(String columnLabel, Reader reader, long length) throws SQLException { + delegate.updateNClob(columnLabel, reader, length); + } + + @Override + public void updateNCharacterStream(int columnIndex, Reader x) throws SQLException { + delegate.updateNCharacterStream(columnIndex, x); + } + + @Override + public void updateNCharacterStream(String columnLabel, Reader reader) throws SQLException { + delegate.updateNCharacterStream(columnLabel, reader); + } + + @Override + public void updateAsciiStream(int columnIndex, InputStream x) throws SQLException { + delegate.updateAsciiStream(columnIndex, x); + } + + @Override + public void updateBinaryStream(int columnIndex, InputStream x) throws SQLException { + delegate.updateBinaryStream(columnIndex, x); + } + + @Override + public void updateCharacterStream(int columnIndex, Reader x) throws SQLException { + delegate.updateCharacterStream(columnIndex, x); + } + + @Override + public void updateAsciiStream(String columnLabel, InputStream x) throws SQLException { + delegate.updateAsciiStream(columnLabel, x); + } + + @Override + public void updateBinaryStream(String columnLabel, InputStream x) throws SQLException { + delegate.updateBinaryStream(columnLabel, x); + } + + @Override + public void updateCharacterStream(String columnLabel, Reader reader) throws SQLException { + delegate.updateCharacterStream(columnLabel, reader); + } + + @Override + public void updateBlob(int columnIndex, InputStream inputStream) throws SQLException { + delegate.updateBlob(columnIndex, inputStream); + } + + @Override + public void updateBlob(String columnLabel, InputStream inputStream) throws SQLException { + delegate.updateBlob(columnLabel, inputStream); + } + + @Override + public void updateClob(int columnIndex, Reader reader) throws SQLException { + delegate.updateClob(columnIndex, reader); + } + + @Override + public void updateClob(String columnLabel, Reader reader) throws SQLException { + delegate.updateClob(columnLabel, reader); + } + + @Override + public void updateNClob(int columnIndex, Reader reader) throws SQLException { + delegate.updateNClob(columnIndex, reader); + } + + @Override + public void updateNClob(String columnLabel, Reader reader) throws SQLException { + delegate.updateNClob(columnLabel, reader); + } + + @Override + public T getObject(int columnIndex, Class type) throws SQLException { + return delegate.getObject(columnIndex, type); + } + + @Override + public T getObject(String columnLabel, Class type) throws SQLException { + return delegate.getObject(columnLabel, type); + } + + @Override + public T unwrap(Class iface) throws SQLException { + if (iface.isInstance(this)) { + return iface.cast(this); + } + return delegate.unwrap(iface); + } + + @Override + public boolean isWrapperFor(Class iface) throws SQLException { + return iface.isInstance(this) || delegate.isWrapperFor(iface); + } +} diff --git a/jdbc-dispatcher/src/main/java/com/clickhouse/jdbc/dispatcher/proxy/StatementProxy.java b/jdbc-dispatcher/src/main/java/com/clickhouse/jdbc/dispatcher/proxy/StatementProxy.java new file mode 100644 index 000000000..37cb255a7 --- /dev/null +++ b/jdbc-dispatcher/src/main/java/com/clickhouse/jdbc/dispatcher/proxy/StatementProxy.java @@ -0,0 +1,401 @@ +package com.clickhouse.jdbc.dispatcher.proxy; + +import com.clickhouse.jdbc.dispatcher.DispatcherException; +import com.clickhouse.jdbc.dispatcher.DriverVersion; +import com.clickhouse.jdbc.dispatcher.strategy.RetryContext; +import com.clickhouse.jdbc.dispatcher.strategy.RetryStrategy; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.sql.*; +import java.util.ArrayList; +import java.util.List; + +/** + * Proxy wrapper for java.sql.Statement that supports failover between driver versions. + *

+ * This proxy implements retry logic for statement operations. When an operation fails, + * it can attempt the same operation using a different driver version according to the + * configured retry strategy. + */ +public class StatementProxy implements Statement { + + private static final Logger log = LoggerFactory.getLogger(StatementProxy.class); + + protected final ConnectionProxy connectionProxy; + protected volatile Statement delegate; + protected volatile DriverVersion currentVersion; + protected final RetryStrategy retryStrategy; + + /** + * Creates a new StatementProxy. + * + * @param connectionProxy the parent connection proxy + * @param delegate the underlying statement + * @param currentVersion the driver version that created this statement + * @param retryStrategy the retry strategy to use for failover + */ + public StatementProxy(ConnectionProxy connectionProxy, Statement delegate, + DriverVersion currentVersion, RetryStrategy retryStrategy) { + this.connectionProxy = connectionProxy; + this.delegate = delegate; + this.currentVersion = currentVersion; + this.retryStrategy = retryStrategy; + } + + /** + * Executes a query with retry support across driver versions. + */ + @Override + public ResultSet executeQuery(String sql) throws SQLException { + List failures = new ArrayList<>(); + List versionsToTry = retryStrategy.getVersionsToTry( + connectionProxy.getAvailableVersions(), + RetryContext.forQuery(sql, 1) + ); + + for (int attempt = 0; attempt < versionsToTry.size(); attempt++) { + DriverVersion version = versionsToTry.get(attempt); + RetryContext context = RetryContext.forQuery(sql, attempt + 1); + + try { + // Switch to the target version if needed + Statement stmt = getStatementForVersion(version); + ResultSet rs = stmt.executeQuery(sql); + retryStrategy.onSuccess(version, context); + return new ResultSetProxy(rs, version, this); + } catch (SQLException e) { + log.warn("executeQuery failed with version {}: {}", version.getVersion(), e.getMessage()); + failures.add(new DispatcherException.VersionFailure(version.getVersion(), e)); + retryStrategy.onFailure(version, context, e); + + // If this was the last version, throw the aggregated exception + if (attempt == versionsToTry.size() - 1) { + throw new DispatcherException("All driver versions failed for executeQuery", failures); + } + } + } + + throw new DispatcherException("No driver versions available", failures); + } + + /** + * Executes an update with retry support across driver versions. + */ + @Override + public int executeUpdate(String sql) throws SQLException { + List failures = new ArrayList<>(); + List versionsToTry = retryStrategy.getVersionsToTry( + connectionProxy.getAvailableVersions(), + RetryContext.forUpdate(sql, 1) + ); + + for (int attempt = 0; attempt < versionsToTry.size(); attempt++) { + DriverVersion version = versionsToTry.get(attempt); + RetryContext context = RetryContext.forUpdate(sql, attempt + 1); + + try { + Statement stmt = getStatementForVersion(version); + int result = stmt.executeUpdate(sql); + retryStrategy.onSuccess(version, context); + return result; + } catch (SQLException e) { + log.warn("executeUpdate failed with version {}: {}", version.getVersion(), e.getMessage()); + failures.add(new DispatcherException.VersionFailure(version.getVersion(), e)); + retryStrategy.onFailure(version, context, e); + + if (attempt == versionsToTry.size() - 1) { + throw new DispatcherException("All driver versions failed for executeUpdate", failures); + } + } + } + + throw new DispatcherException("No driver versions available", failures); + } + + @Override + public boolean execute(String sql) throws SQLException { + List failures = new ArrayList<>(); + List versionsToTry = retryStrategy.getVersionsToTry( + connectionProxy.getAvailableVersions(), + new RetryContext(RetryContext.OperationType.OTHER, "execute", 1) + ); + + for (int attempt = 0; attempt < versionsToTry.size(); attempt++) { + DriverVersion version = versionsToTry.get(attempt); + RetryContext context = new RetryContext(RetryContext.OperationType.OTHER, "execute", attempt + 1); + + try { + Statement stmt = getStatementForVersion(version); + boolean result = stmt.execute(sql); + retryStrategy.onSuccess(version, context); + return result; + } catch (SQLException e) { + log.warn("execute failed with version {}: {}", version.getVersion(), e.getMessage()); + failures.add(new DispatcherException.VersionFailure(version.getVersion(), e)); + retryStrategy.onFailure(version, context, e); + + if (attempt == versionsToTry.size() - 1) { + throw new DispatcherException("All driver versions failed for execute", failures); + } + } + } + + throw new DispatcherException("No driver versions available", failures); + } + + /** + * Gets or creates a statement for the specified driver version. + */ + protected Statement getStatementForVersion(DriverVersion version) throws SQLException { + if (version.equals(currentVersion)) { + return delegate; + } + + // Get connection for the target version and create a new statement + Connection conn = connectionProxy.getConnectionForVersion(version); + Statement newStmt = conn.createStatement(); + + // Copy statement properties + try { + newStmt.setQueryTimeout(delegate.getQueryTimeout()); + newStmt.setMaxRows(delegate.getMaxRows()); + newStmt.setFetchSize(delegate.getFetchSize()); + } catch (SQLException e) { + log.debug("Could not copy all statement properties: {}", e.getMessage()); + } + + // Update current delegate + this.delegate = newStmt; + this.currentVersion = version; + + return newStmt; + } + + /** + * Returns the underlying delegate statement. + */ + public Statement getDelegate() { + return delegate; + } + + /** + * Returns the current driver version. + */ + public DriverVersion getCurrentVersion() { + return currentVersion; + } + + // ==================== Statement Interface Implementation ==================== + // Non-retry operations delegate directly + + @Override + public void close() throws SQLException { + delegate.close(); + } + + @Override + public int getMaxFieldSize() throws SQLException { + return delegate.getMaxFieldSize(); + } + + @Override + public void setMaxFieldSize(int max) throws SQLException { + delegate.setMaxFieldSize(max); + } + + @Override + public int getMaxRows() throws SQLException { + return delegate.getMaxRows(); + } + + @Override + public void setMaxRows(int max) throws SQLException { + delegate.setMaxRows(max); + } + + @Override + public void setEscapeProcessing(boolean enable) throws SQLException { + delegate.setEscapeProcessing(enable); + } + + @Override + public int getQueryTimeout() throws SQLException { + return delegate.getQueryTimeout(); + } + + @Override + public void setQueryTimeout(int seconds) throws SQLException { + delegate.setQueryTimeout(seconds); + } + + @Override + public void cancel() throws SQLException { + delegate.cancel(); + } + + @Override + public SQLWarning getWarnings() throws SQLException { + return delegate.getWarnings(); + } + + @Override + public void clearWarnings() throws SQLException { + delegate.clearWarnings(); + } + + @Override + public void setCursorName(String name) throws SQLException { + delegate.setCursorName(name); + } + + @Override + public ResultSet getResultSet() throws SQLException { + ResultSet rs = delegate.getResultSet(); + return rs != null ? new ResultSetProxy(rs, currentVersion, this) : null; + } + + @Override + public int getUpdateCount() throws SQLException { + return delegate.getUpdateCount(); + } + + @Override + public boolean getMoreResults() throws SQLException { + return delegate.getMoreResults(); + } + + @Override + public void setFetchDirection(int direction) throws SQLException { + delegate.setFetchDirection(direction); + } + + @Override + public int getFetchDirection() throws SQLException { + return delegate.getFetchDirection(); + } + + @Override + public void setFetchSize(int rows) throws SQLException { + delegate.setFetchSize(rows); + } + + @Override + public int getFetchSize() throws SQLException { + return delegate.getFetchSize(); + } + + @Override + public int getResultSetConcurrency() throws SQLException { + return delegate.getResultSetConcurrency(); + } + + @Override + public int getResultSetType() throws SQLException { + return delegate.getResultSetType(); + } + + @Override + public void addBatch(String sql) throws SQLException { + delegate.addBatch(sql); + } + + @Override + public void clearBatch() throws SQLException { + delegate.clearBatch(); + } + + @Override + public int[] executeBatch() throws SQLException { + return delegate.executeBatch(); + } + + @Override + public Connection getConnection() throws SQLException { + return connectionProxy; + } + + @Override + public boolean getMoreResults(int current) throws SQLException { + return delegate.getMoreResults(current); + } + + @Override + public ResultSet getGeneratedKeys() throws SQLException { + ResultSet rs = delegate.getGeneratedKeys(); + return rs != null ? new ResultSetProxy(rs, currentVersion, this) : null; + } + + @Override + public int executeUpdate(String sql, int autoGeneratedKeys) throws SQLException { + return delegate.executeUpdate(sql, autoGeneratedKeys); + } + + @Override + public int executeUpdate(String sql, int[] columnIndexes) throws SQLException { + return delegate.executeUpdate(sql, columnIndexes); + } + + @Override + public int executeUpdate(String sql, String[] columnNames) throws SQLException { + return delegate.executeUpdate(sql, columnNames); + } + + @Override + public boolean execute(String sql, int autoGeneratedKeys) throws SQLException { + return delegate.execute(sql, autoGeneratedKeys); + } + + @Override + public boolean execute(String sql, int[] columnIndexes) throws SQLException { + return delegate.execute(sql, columnIndexes); + } + + @Override + public boolean execute(String sql, String[] columnNames) throws SQLException { + return delegate.execute(sql, columnNames); + } + + @Override + public int getResultSetHoldability() throws SQLException { + return delegate.getResultSetHoldability(); + } + + @Override + public boolean isClosed() throws SQLException { + return delegate.isClosed(); + } + + @Override + public void setPoolable(boolean poolable) throws SQLException { + delegate.setPoolable(poolable); + } + + @Override + public boolean isPoolable() throws SQLException { + return delegate.isPoolable(); + } + + @Override + public void closeOnCompletion() throws SQLException { + delegate.closeOnCompletion(); + } + + @Override + public boolean isCloseOnCompletion() throws SQLException { + return delegate.isCloseOnCompletion(); + } + + @Override + public T unwrap(Class iface) throws SQLException { + if (iface.isInstance(this)) { + return iface.cast(this); + } + return delegate.unwrap(iface); + } + + @Override + public boolean isWrapperFor(Class iface) throws SQLException { + return iface.isInstance(this) || delegate.isWrapperFor(iface); + } +} diff --git a/jdbc-dispatcher/src/main/java/com/clickhouse/jdbc/dispatcher/strategy/FailoverOnlyRetryStrategy.java b/jdbc-dispatcher/src/main/java/com/clickhouse/jdbc/dispatcher/strategy/FailoverOnlyRetryStrategy.java new file mode 100644 index 000000000..3efef701f --- /dev/null +++ b/jdbc-dispatcher/src/main/java/com/clickhouse/jdbc/dispatcher/strategy/FailoverOnlyRetryStrategy.java @@ -0,0 +1,136 @@ +package com.clickhouse.jdbc.dispatcher.strategy; + +import com.clickhouse.jdbc.dispatcher.DriverVersion; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Retry strategy that uses a single preferred version until it fails, then fails over. + *

+ * This strategy is useful when you want to stick to one version (typically the newest) + * and only switch to an alternative when the primary fails. Once a failover occurs, + * the strategy continues using the new version until it also fails. + */ +public class FailoverOnlyRetryStrategy implements RetryStrategy { + + private static final Logger log = LoggerFactory.getLogger(FailoverOnlyRetryStrategy.class); + + private final int maxRetries; + private volatile DriverVersion preferredVersion; + + /** + * Creates a FailoverOnlyRetryStrategy with default settings. + */ + public FailoverOnlyRetryStrategy() { + this(3); + } + + /** + * Creates a FailoverOnlyRetryStrategy with custom max retries. + * + * @param maxRetries maximum number of retries + */ + public FailoverOnlyRetryStrategy(int maxRetries) { + this.maxRetries = maxRetries; + } + + /** + * Sets the preferred driver version to use. + * + * @param version the preferred version + */ + public void setPreferredVersion(DriverVersion version) { + this.preferredVersion = version; + } + + /** + * Gets the current preferred driver version. + * + * @return the preferred version, or null if not set + */ + public DriverVersion getPreferredVersion() { + return preferredVersion; + } + + @Override + public List getVersionsToTry(List availableVersions, RetryContext context) { + if (availableVersions == null || availableVersions.isEmpty()) { + return Collections.emptyList(); + } + + List result = new ArrayList<>(); + + // If we have a healthy preferred version, try it first + if (preferredVersion != null && preferredVersion.isHealthy()) { + result.add(preferredVersion); + } + + // Add other healthy versions as fallbacks (sorted by version, newest first) + List sorted = new ArrayList<>(availableVersions); + Collections.sort(sorted); + + for (DriverVersion v : sorted) { + if (v.isHealthy() && !result.contains(v)) { + result.add(v); + } + } + + // Add unhealthy versions as last resort + for (DriverVersion v : sorted) { + if (!v.isHealthy() && !result.contains(v)) { + result.add(v); + } + } + + // Limit to max retries + if (result.size() > maxRetries) { + return new ArrayList<>(result.subList(0, maxRetries)); + } + return result; + } + + @Override + public void onSuccess(DriverVersion version, RetryContext context) { + // Update preferred version on success + if (preferredVersion == null || !preferredVersion.equals(version)) { + log.info("Setting preferred driver version to {} after successful operation", + version.getVersion()); + preferredVersion = version; + } + } + + @Override + public void onFailure(DriverVersion version, RetryContext context, Throwable exception) { + log.warn("Operation {} failed with driver version {}: {}", + context.getOperationName(), version.getVersion(), exception.getMessage()); + version.setHealthy(false); + + // If this was our preferred version, we'll pick a new one on the next success + if (preferredVersion != null && preferredVersion.equals(version)) { + log.info("Preferred version {} marked unhealthy, will select new preferred on next success", + version.getVersion()); + } + } + + @Override + public int getMaxRetries() { + return maxRetries; + } + + @Override + public String getName() { + return "FailoverOnly"; + } + + @Override + public String toString() { + return "FailoverOnlyRetryStrategy{" + + "maxRetries=" + maxRetries + + ", preferredVersion=" + (preferredVersion != null ? preferredVersion.getVersion() : "none") + + '}'; + } +} diff --git a/jdbc-dispatcher/src/main/java/com/clickhouse/jdbc/dispatcher/strategy/NewestFirstRetryStrategy.java b/jdbc-dispatcher/src/main/java/com/clickhouse/jdbc/dispatcher/strategy/NewestFirstRetryStrategy.java new file mode 100644 index 000000000..ae531a0e9 --- /dev/null +++ b/jdbc-dispatcher/src/main/java/com/clickhouse/jdbc/dispatcher/strategy/NewestFirstRetryStrategy.java @@ -0,0 +1,121 @@ +package com.clickhouse.jdbc.dispatcher.strategy; + +import com.clickhouse.jdbc.dispatcher.DriverVersion; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Retry strategy that tries the newest driver version first. + *

+ * This strategy orders driver versions from newest to oldest, preferring + * healthy drivers over unhealthy ones. If a driver fails, it moves to + * the next newest version. + */ +public class NewestFirstRetryStrategy implements RetryStrategy { + + private static final Logger log = LoggerFactory.getLogger(NewestFirstRetryStrategy.class); + + private final int maxRetries; + private final boolean skipUnhealthy; + + /** + * Creates a NewestFirstRetryStrategy with default settings. + */ + public NewestFirstRetryStrategy() { + this(3, true); + } + + /** + * Creates a NewestFirstRetryStrategy with custom settings. + * + * @param maxRetries maximum number of retries + * @param skipUnhealthy whether to skip unhealthy drivers on first pass + */ + public NewestFirstRetryStrategy(int maxRetries, boolean skipUnhealthy) { + this.maxRetries = maxRetries; + this.skipUnhealthy = skipUnhealthy; + } + + @Override + public List getVersionsToTry(List availableVersions, RetryContext context) { + if (availableVersions == null || availableVersions.isEmpty()) { + return Collections.emptyList(); + } + + // Make a copy and sort by version (newest first) + List sorted = new ArrayList<>(availableVersions); + Collections.sort(sorted); + + if (!skipUnhealthy) { + // Return all versions in order + return limitToMaxRetries(sorted); + } + + // Separate healthy and unhealthy versions + List healthy = new ArrayList<>(); + List unhealthy = new ArrayList<>(); + + for (DriverVersion version : sorted) { + if (version.isHealthy()) { + healthy.add(version); + } else { + unhealthy.add(version); + } + } + + // Try healthy versions first, then unhealthy as fallback + List result = new ArrayList<>(); + result.addAll(healthy); + result.addAll(unhealthy); + + return limitToMaxRetries(result); + } + + /** + * Limits the list to the maximum number of retries. + */ + private List limitToMaxRetries(List versions) { + if (versions.size() <= maxRetries) { + return versions; + } + return new ArrayList<>(versions.subList(0, maxRetries)); + } + + @Override + public void onSuccess(DriverVersion version, RetryContext context) { + log.debug("Operation {} succeeded with driver version {} on attempt {}", + context.getOperationName(), version.getVersion(), context.getAttemptNumber()); + } + + @Override + public void onFailure(DriverVersion version, RetryContext context, Throwable exception) { + log.warn("Operation {} failed with driver version {} on attempt {}: {}", + context.getOperationName(), version.getVersion(), context.getAttemptNumber(), + exception.getMessage()); + + // Mark the version as unhealthy after failure + version.setHealthy(false); + } + + @Override + public int getMaxRetries() { + return maxRetries; + } + + @Override + public String getName() { + return "NewestFirst"; + } + + @Override + public String toString() { + return "NewestFirstRetryStrategy{" + + "maxRetries=" + maxRetries + + ", skipUnhealthy=" + skipUnhealthy + + '}'; + } +} diff --git a/jdbc-dispatcher/src/main/java/com/clickhouse/jdbc/dispatcher/strategy/RetryContext.java b/jdbc-dispatcher/src/main/java/com/clickhouse/jdbc/dispatcher/strategy/RetryContext.java new file mode 100644 index 000000000..018d8a923 --- /dev/null +++ b/jdbc-dispatcher/src/main/java/com/clickhouse/jdbc/dispatcher/strategy/RetryContext.java @@ -0,0 +1,154 @@ +package com.clickhouse.jdbc.dispatcher.strategy; + +import java.util.HashMap; +import java.util.Map; + +/** + * Context information for retry operations. + *

+ * This class carries metadata about the current operation being performed, + * which can be used by retry strategies to make informed decisions. + */ +public class RetryContext { + + /** + * Types of operations that can trigger retries. + */ + public enum OperationType { + CONNECT, + EXECUTE_QUERY, + EXECUTE_UPDATE, + PREPARE_STATEMENT, + CALL_PROCEDURE, + METADATA, + TRANSACTION, + OTHER + } + + private final OperationType operationType; + private final String operationName; + private final int attemptNumber; + private final long startTimeMs; + private final Map attributes = new HashMap<>(); + + /** + * Creates a new RetryContext. + * + * @param operationType the type of operation + * @param operationName a descriptive name for the operation + * @param attemptNumber the current attempt number (1-based) + */ + public RetryContext(OperationType operationType, String operationName, int attemptNumber) { + this.operationType = operationType; + this.operationName = operationName; + this.attemptNumber = attemptNumber; + this.startTimeMs = System.currentTimeMillis(); + } + + /** + * Creates a context for a connection operation. + * + * @param attemptNumber the attempt number + * @return a new RetryContext + */ + public static RetryContext forConnect(int attemptNumber) { + return new RetryContext(OperationType.CONNECT, "connect", attemptNumber); + } + + /** + * Creates a context for a query execution. + * + * @param sql the SQL being executed + * @param attemptNumber the attempt number + * @return a new RetryContext + */ + public static RetryContext forQuery(String sql, int attemptNumber) { + RetryContext ctx = new RetryContext(OperationType.EXECUTE_QUERY, "executeQuery", attemptNumber); + ctx.setAttribute("sql", sql); + return ctx; + } + + /** + * Creates a context for an update execution. + * + * @param sql the SQL being executed + * @param attemptNumber the attempt number + * @return a new RetryContext + */ + public static RetryContext forUpdate(String sql, int attemptNumber) { + RetryContext ctx = new RetryContext(OperationType.EXECUTE_UPDATE, "executeUpdate", attemptNumber); + ctx.setAttribute("sql", sql); + return ctx; + } + + /** + * Creates a new context for the next retry attempt. + * + * @return a new RetryContext with incremented attempt number + */ + public RetryContext nextAttempt() { + RetryContext next = new RetryContext(operationType, operationName, attemptNumber + 1); + next.attributes.putAll(this.attributes); + return next; + } + + public OperationType getOperationType() { + return operationType; + } + + public String getOperationName() { + return operationName; + } + + public int getAttemptNumber() { + return attemptNumber; + } + + public long getStartTimeMs() { + return startTimeMs; + } + + public long getElapsedTimeMs() { + return System.currentTimeMillis() - startTimeMs; + } + + /** + * Sets an attribute on this context. + * + * @param key the attribute key + * @param value the attribute value + */ + public void setAttribute(String key, Object value) { + attributes.put(key, value); + } + + /** + * Gets an attribute from this context. + * + * @param key the attribute key + * @return the attribute value, or null if not set + */ + @SuppressWarnings("unchecked") + public T getAttribute(String key) { + return (T) attributes.get(key); + } + + /** + * Checks if this is the first attempt. + * + * @return true if this is attempt number 1 + */ + public boolean isFirstAttempt() { + return attemptNumber == 1; + } + + @Override + public String toString() { + return "RetryContext{" + + "operationType=" + operationType + + ", operationName='" + operationName + '\'' + + ", attemptNumber=" + attemptNumber + + ", elapsedMs=" + getElapsedTimeMs() + + '}'; + } +} diff --git a/jdbc-dispatcher/src/main/java/com/clickhouse/jdbc/dispatcher/strategy/RetryStrategy.java b/jdbc-dispatcher/src/main/java/com/clickhouse/jdbc/dispatcher/strategy/RetryStrategy.java new file mode 100644 index 000000000..9c4dd3eac --- /dev/null +++ b/jdbc-dispatcher/src/main/java/com/clickhouse/jdbc/dispatcher/strategy/RetryStrategy.java @@ -0,0 +1,61 @@ +package com.clickhouse.jdbc.dispatcher.strategy; + +import com.clickhouse.jdbc.dispatcher.DriverVersion; + +import java.util.List; + +/** + * Strategy interface for selecting which driver version to try next during failover. + *

+ * Implementations of this interface define the order in which driver versions + * are attempted when establishing connections or executing operations. + */ +public interface RetryStrategy { + + /** + * Returns an ordered list of driver versions to try for the given operation. + * The first element should be tried first, then the second on failure, etc. + * + * @param availableVersions all available driver versions (may include unhealthy ones) + * @param context optional context about the current operation + * @return an ordered list of versions to attempt + */ + List getVersionsToTry(List availableVersions, RetryContext context); + + /** + * Called when an operation succeeds with a specific version. + * Allows the strategy to track success patterns. + * + * @param version the version that succeeded + * @param context the context of the successful operation + */ + default void onSuccess(DriverVersion version, RetryContext context) { + // Default implementation does nothing + } + + /** + * Called when an operation fails with a specific version. + * Allows the strategy to track failure patterns and adjust behavior. + * + * @param version the version that failed + * @param context the context of the failed operation + * @param exception the exception that caused the failure + */ + default void onFailure(DriverVersion version, RetryContext context, Throwable exception) { + // Default implementation does nothing + } + + /** + * Returns the maximum number of retry attempts allowed. + * + * @return the maximum retry count + */ + int getMaxRetries(); + + /** + * Returns the name of this retry strategy for logging and monitoring. + * + * @return the strategy name + */ + String getName(); +} diff --git a/jdbc-dispatcher/src/main/java/com/clickhouse/jdbc/dispatcher/strategy/RoundRobinRetryStrategy.java b/jdbc-dispatcher/src/main/java/com/clickhouse/jdbc/dispatcher/strategy/RoundRobinRetryStrategy.java new file mode 100644 index 000000000..f175d1b59 --- /dev/null +++ b/jdbc-dispatcher/src/main/java/com/clickhouse/jdbc/dispatcher/strategy/RoundRobinRetryStrategy.java @@ -0,0 +1,104 @@ +package com.clickhouse.jdbc.dispatcher.strategy; + +import com.clickhouse.jdbc.dispatcher.DriverVersion; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Retry strategy that rotates through driver versions in round-robin fashion. + *

+ * This strategy distributes load across all driver versions by starting with + * a different version for each new operation. Healthy versions are preferred + * over unhealthy ones. + */ +public class RoundRobinRetryStrategy implements RetryStrategy { + + private static final Logger log = LoggerFactory.getLogger(RoundRobinRetryStrategy.class); + + private final int maxRetries; + private final AtomicInteger nextIndex = new AtomicInteger(0); + + /** + * Creates a RoundRobinRetryStrategy with default settings. + */ + public RoundRobinRetryStrategy() { + this(3); + } + + /** + * Creates a RoundRobinRetryStrategy with custom max retries. + * + * @param maxRetries maximum number of retries + */ + public RoundRobinRetryStrategy(int maxRetries) { + this.maxRetries = maxRetries; + } + + @Override + public List getVersionsToTry(List availableVersions, RetryContext context) { + if (availableVersions == null || availableVersions.isEmpty()) { + return Collections.emptyList(); + } + + int size = availableVersions.size(); + int startIdx = nextIndex.getAndUpdate(i -> (i + 1) % size); + + // Build ordered list starting from startIdx + List result = new ArrayList<>(size); + List healthy = new ArrayList<>(); + List unhealthy = new ArrayList<>(); + + for (int i = 0; i < size; i++) { + DriverVersion v = availableVersions.get((startIdx + i) % size); + if (v.isHealthy()) { + healthy.add(v); + } else { + unhealthy.add(v); + } + } + + // Healthy first, then unhealthy + result.addAll(healthy); + result.addAll(unhealthy); + + // Limit to max retries + if (result.size() > maxRetries) { + return new ArrayList<>(result.subList(0, maxRetries)); + } + return result; + } + + @Override + public void onSuccess(DriverVersion version, RetryContext context) { + log.debug("Operation {} succeeded with driver version {} on attempt {} (round-robin)", + context.getOperationName(), version.getVersion(), context.getAttemptNumber()); + } + + @Override + public void onFailure(DriverVersion version, RetryContext context, Throwable exception) { + log.warn("Operation {} failed with driver version {} on attempt {} (round-robin): {}", + context.getOperationName(), version.getVersion(), context.getAttemptNumber(), + exception.getMessage()); + version.setHealthy(false); + } + + @Override + public int getMaxRetries() { + return maxRetries; + } + + @Override + public String getName() { + return "RoundRobin"; + } + + @Override + public String toString() { + return "RoundRobinRetryStrategy{maxRetries=" + maxRetries + '}'; + } +} diff --git a/jdbc-dispatcher/src/test/java/com/clickhouse/jdbc/dispatcher/DriverVersionTest.java b/jdbc-dispatcher/src/test/java/com/clickhouse/jdbc/dispatcher/DriverVersionTest.java new file mode 100644 index 000000000..9827b11c8 --- /dev/null +++ b/jdbc-dispatcher/src/test/java/com/clickhouse/jdbc/dispatcher/DriverVersionTest.java @@ -0,0 +1,151 @@ +package com.clickhouse.jdbc.dispatcher; + +import org.testng.annotations.Test; + +import java.sql.Driver; +import java.sql.DriverPropertyInfo; +import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Properties; +import java.util.logging.Logger; + +import static org.testng.Assert.*; + +/** + * Unit tests for DriverVersion class. + */ +public class DriverVersionTest { + + /** + * Mock driver implementation for testing. + */ + private static class MockDriver implements Driver { + @Override + public java.sql.Connection connect(String url, Properties info) throws SQLException { + return null; + } + + @Override + public boolean acceptsURL(String url) throws SQLException { + return true; + } + + @Override + public DriverPropertyInfo[] getPropertyInfo(String url, Properties info) throws SQLException { + return new DriverPropertyInfo[0]; + } + + @Override + public int getMajorVersion() { + return 1; + } + + @Override + public int getMinorVersion() { + return 0; + } + + @Override + public boolean jdbcCompliant() { + return true; + } + + @Override + public Logger getParentLogger() throws SQLFeatureNotSupportedException { + return Logger.getLogger(MockDriver.class.getName()); + } + } + + @Test + public void testVersionParsing() { + Driver mockDriver = new MockDriver(); + ClassLoader cl = getClass().getClassLoader(); + + DriverVersion v1 = new DriverVersion("1.2.3", mockDriver, cl); + assertEquals(v1.getMajorVersion(), 1); + assertEquals(v1.getMinorVersion(), 2); + assertEquals(v1.getPatchVersion(), 3); + + DriverVersion v2 = new DriverVersion("0.5.0-SNAPSHOT", mockDriver, cl); + assertEquals(v2.getMajorVersion(), 0); + assertEquals(v2.getMinorVersion(), 5); + assertEquals(v2.getPatchVersion(), 0); + + DriverVersion v3 = new DriverVersion("2.0", mockDriver, cl); + assertEquals(v3.getMajorVersion(), 2); + assertEquals(v3.getMinorVersion(), 0); + assertEquals(v3.getPatchVersion(), 0); + } + + @Test + public void testVersionComparison() { + Driver mockDriver = new MockDriver(); + ClassLoader cl = getClass().getClassLoader(); + + List versions = new ArrayList<>(); + versions.add(new DriverVersion("1.0.0", mockDriver, cl)); + versions.add(new DriverVersion("2.0.0", mockDriver, cl)); + versions.add(new DriverVersion("1.5.0", mockDriver, cl)); + versions.add(new DriverVersion("0.9.0", mockDriver, cl)); + + Collections.sort(versions); + + // Should be sorted in descending order (newest first) + assertEquals(versions.get(0).getVersion(), "2.0.0"); + assertEquals(versions.get(1).getVersion(), "1.5.0"); + assertEquals(versions.get(2).getVersion(), "1.0.0"); + assertEquals(versions.get(3).getVersion(), "0.9.0"); + } + + @Test + public void testHealthStatus() { + Driver mockDriver = new MockDriver(); + ClassLoader cl = getClass().getClassLoader(); + + DriverVersion v = new DriverVersion("1.0.0", mockDriver, cl); + assertTrue(v.isHealthy()); + + v.setHealthy(false); + assertFalse(v.isHealthy()); + assertTrue(v.getLastFailureTime() > 0); + + v.setHealthy(true); + assertTrue(v.isHealthy()); + } + + @Test + public void testHealthCooldown() throws InterruptedException { + Driver mockDriver = new MockDriver(); + ClassLoader cl = getClass().getClassLoader(); + + DriverVersion v = new DriverVersion("1.0.0", mockDriver, cl); + v.setHealthy(false); + assertFalse(v.isHealthy()); + + // Should not reset with 10 second cooldown + assertFalse(v.resetHealthIfCooledDown(10000)); + assertFalse(v.isHealthy()); + + // Should reset with 0 second cooldown (or wait) + Thread.sleep(10); + assertTrue(v.resetHealthIfCooledDown(1)); // 1ms cooldown + assertTrue(v.isHealthy()); + } + + @Test + public void testEqualsAndHashCode() { + Driver mockDriver = new MockDriver(); + ClassLoader cl = getClass().getClassLoader(); + + DriverVersion v1 = new DriverVersion("1.0.0", mockDriver, cl); + DriverVersion v2 = new DriverVersion("1.0.0", mockDriver, cl); + DriverVersion v3 = new DriverVersion("2.0.0", mockDriver, cl); + + assertEquals(v1, v2); + assertEquals(v1.hashCode(), v2.hashCode()); + assertNotEquals(v1, v3); + } +} diff --git a/jdbc-dispatcher/src/test/java/com/clickhouse/jdbc/dispatcher/loader/DriverVersionManagerTest.java b/jdbc-dispatcher/src/test/java/com/clickhouse/jdbc/dispatcher/loader/DriverVersionManagerTest.java new file mode 100644 index 000000000..7713b0a47 --- /dev/null +++ b/jdbc-dispatcher/src/test/java/com/clickhouse/jdbc/dispatcher/loader/DriverVersionManagerTest.java @@ -0,0 +1,71 @@ +package com.clickhouse.jdbc.dispatcher.loader; + +import org.testng.annotations.Test; + +import static org.testng.Assert.*; + +/** + * Unit tests for DriverVersionManager. + */ +public class DriverVersionManagerTest { + + @Test + public void testExtractVersionFromFilename() { + // Standard versions + assertEquals(DriverVersionManager.extractVersionFromFilename("driver-1.2.3.jar"), "1.2.3"); + assertEquals(DriverVersionManager.extractVersionFromFilename("clickhouse-jdbc-0.4.6.jar"), "0.4.6"); + + // With SNAPSHOT suffix + assertEquals(DriverVersionManager.extractVersionFromFilename("driver-1.0.0-SNAPSHOT.jar"), "1.0.0-SNAPSHOT"); + + // With underscore separator + assertEquals(DriverVersionManager.extractVersionFromFilename("driver_2.0.0.jar"), "2.0.0"); + + // Two-part version + assertEquals(DriverVersionManager.extractVersionFromFilename("driver-1.0.jar"), "1.0"); + + // Complex version with RC/beta + assertEquals(DriverVersionManager.extractVersionFromFilename("driver-1.0.0-RC1.jar"), "1.0.0-RC1"); + } + + @Test + public void testExtractVersionFromFilenameInvalid() { + // No version in filename + assertNull(DriverVersionManager.extractVersionFromFilename("driver.jar")); + + // Just text + assertNull(DriverVersionManager.extractVersionFromFilename("noversion.jar")); + } + + @Test + public void testManagerConstruction() { + DriverVersionManager manager = new DriverVersionManager("com.example.Driver"); + + assertEquals(manager.getDriverClassName(), "com.example.Driver"); + assertTrue(manager.isEmpty()); + assertEquals(manager.size(), 0); + assertNull(manager.getNewestVersion()); + assertTrue(manager.getVersions().isEmpty()); + } + + @Test + public void testHealthCheckCooldown() { + DriverVersionManager manager = new DriverVersionManager("com.example.Driver", 5000L); + assertEquals(manager.getHealthCheckCooldownMs(), 5000L); + + // Default cooldown + DriverVersionManager manager2 = new DriverVersionManager("com.example.Driver"); + assertEquals(manager2.getHealthCheckCooldownMs(), 60000L); + } + + @Test(expectedExceptions = NullPointerException.class) + public void testNullDriverClassName() { + new DriverVersionManager(null); + } + + @Test(expectedExceptions = IllegalArgumentException.class) + public void testLoadFromNonDirectory() { + DriverVersionManager manager = new DriverVersionManager("com.example.Driver"); + manager.loadFromDirectory(new java.io.File("/nonexistent/path")); + } +} diff --git a/jdbc-dispatcher/src/test/java/com/clickhouse/jdbc/dispatcher/strategy/NewestFirstRetryStrategyTest.java b/jdbc-dispatcher/src/test/java/com/clickhouse/jdbc/dispatcher/strategy/NewestFirstRetryStrategyTest.java new file mode 100644 index 000000000..29104a8f7 --- /dev/null +++ b/jdbc-dispatcher/src/test/java/com/clickhouse/jdbc/dispatcher/strategy/NewestFirstRetryStrategyTest.java @@ -0,0 +1,131 @@ +package com.clickhouse.jdbc.dispatcher.strategy; + +import com.clickhouse.jdbc.dispatcher.DriverVersion; +import org.testng.annotations.Test; + +import java.sql.*; +import java.util.*; +import java.util.logging.Logger; + +import static org.testng.Assert.*; + +/** + * Unit tests for NewestFirstRetryStrategy. + */ +public class NewestFirstRetryStrategyTest { + + /** + * Mock driver implementation for testing. + */ + private static class MockDriver implements Driver { + @Override + public Connection connect(String url, Properties info) { return null; } + @Override + public boolean acceptsURL(String url) { return true; } + @Override + public DriverPropertyInfo[] getPropertyInfo(String url, Properties info) { return new DriverPropertyInfo[0]; } + @Override + public int getMajorVersion() { return 1; } + @Override + public int getMinorVersion() { return 0; } + @Override + public boolean jdbcCompliant() { return true; } + @Override + public Logger getParentLogger() throws SQLFeatureNotSupportedException { + return Logger.getLogger(MockDriver.class.getName()); + } + } + + private DriverVersion createVersion(String version) { + return new DriverVersion(version, new MockDriver(), getClass().getClassLoader()); + } + + @Test + public void testNewestFirstOrdering() { + NewestFirstRetryStrategy strategy = new NewestFirstRetryStrategy(10, false); + + List versions = Arrays.asList( + createVersion("1.0.0"), + createVersion("3.0.0"), + createVersion("2.0.0") + ); + + List result = strategy.getVersionsToTry(versions, RetryContext.forConnect(1)); + + assertEquals(result.size(), 3); + assertEquals(result.get(0).getVersion(), "3.0.0"); + assertEquals(result.get(1).getVersion(), "2.0.0"); + assertEquals(result.get(2).getVersion(), "1.0.0"); + } + + @Test + public void testHealthyVersionsFirst() { + NewestFirstRetryStrategy strategy = new NewestFirstRetryStrategy(10, true); + + DriverVersion v1 = createVersion("1.0.0"); + DriverVersion v2 = createVersion("2.0.0"); + DriverVersion v3 = createVersion("3.0.0"); + + // Mark newest as unhealthy + v3.setHealthy(false); + + List versions = Arrays.asList(v1, v2, v3); + List result = strategy.getVersionsToTry(versions, RetryContext.forConnect(1)); + + // Healthy versions first, then unhealthy + assertEquals(result.get(0).getVersion(), "2.0.0"); + assertEquals(result.get(1).getVersion(), "1.0.0"); + assertEquals(result.get(2).getVersion(), "3.0.0"); + } + + @Test + public void testMaxRetriesLimit() { + NewestFirstRetryStrategy strategy = new NewestFirstRetryStrategy(2, false); + + List versions = Arrays.asList( + createVersion("1.0.0"), + createVersion("2.0.0"), + createVersion("3.0.0"), + createVersion("4.0.0") + ); + + List result = strategy.getVersionsToTry(versions, RetryContext.forConnect(1)); + + assertEquals(result.size(), 2); + assertEquals(result.get(0).getVersion(), "4.0.0"); + assertEquals(result.get(1).getVersion(), "3.0.0"); + } + + @Test + public void testEmptyVersionsList() { + NewestFirstRetryStrategy strategy = new NewestFirstRetryStrategy(); + + List result = strategy.getVersionsToTry( + Collections.emptyList(), + RetryContext.forConnect(1) + ); + + assertTrue(result.isEmpty()); + } + + @Test + public void testNullVersionsList() { + NewestFirstRetryStrategy strategy = new NewestFirstRetryStrategy(); + + List result = strategy.getVersionsToTry(null, RetryContext.forConnect(1)); + + assertTrue(result.isEmpty()); + } + + @Test + public void testOnFailureMarksUnhealthy() { + NewestFirstRetryStrategy strategy = new NewestFirstRetryStrategy(); + DriverVersion version = createVersion("1.0.0"); + + assertTrue(version.isHealthy()); + + strategy.onFailure(version, RetryContext.forConnect(1), new SQLException("Test error")); + + assertFalse(version.isHealthy()); + } +} diff --git a/jdbc-dispatcher/src/test/resources/simplelogger.properties b/jdbc-dispatcher/src/test/resources/simplelogger.properties new file mode 100644 index 000000000..f3be0ca3b --- /dev/null +++ b/jdbc-dispatcher/src/test/resources/simplelogger.properties @@ -0,0 +1,8 @@ +# SLF4J Simple Logger configuration for tests +org.slf4j.simpleLogger.defaultLogLevel=debug +org.slf4j.simpleLogger.showDateTime=true +org.slf4j.simpleLogger.dateTimeFormat=yyyy-MM-dd HH:mm:ss:SSS +org.slf4j.simpleLogger.showThreadName=true +org.slf4j.simpleLogger.showLogName=true +org.slf4j.simpleLogger.showShortLogName=false +org.slf4j.simpleLogger.levelInBrackets=true diff --git a/pom.xml b/pom.xml index 4fc4873a4..92cd09d18 100644 --- a/pom.xml +++ b/pom.xml @@ -48,6 +48,7 @@ clickhouse-jdbc jdbc-v2 clickhouse-r2dbc + jdbc-dispatcher packages/clickhouse-jdbc-all